diff --git a/config.lua b/config.lua index b766d2b..0157cf3 100644 --- a/config.lua +++ b/config.lua @@ -71,6 +71,24 @@ return { -- post_cmd = (os.getenv("HOME") or ".") .. "/.aish/hooks/post-cmd", -- }, + -- Issue #13: secret redaction. Vault is a separate file at ~/.aish/ + -- secrets.lua (mode 0600 enforced). When set, outbound broker messages + -- are scrubbed: vault literals + autodetect heuristics (OpenAI sk-, + -- OpenRouter sk-or-v1-, GitHub ghp_/gho_/ghs_, AWS AKIA, JWT eyJ..., + -- SSH/GPG PRIVATE KEY headers) become $AISH_SECRET_NNN placeholders. + -- The streamed reply is rehydrated before display so the user sees + -- real values. Per-broker override via models[*].redact: + -- "off" -- no scrubbing (trusted local) + -- "vault" -- vault literals only + -- "vault+autodetect" -- + heuristics (default when vault loaded) + -- "stealth" -- + heuristics, opaque decoys, no rehydrate + -- Default per-broker is the global config.secrets.default, falling + -- back to "vault+autodetect" when vault loaded, else "off". + -- secrets = { + -- vault = "~/.aish/secrets.lua", + -- default = "vault+autodetect", -- applies when models[*].redact is nil + -- }, + -- Issue #8: background CMD (CMD&: marker). Requires history.dir set -- (logs land at /bg/.log + .status sidecar). The -- feature is always-on once history.dir exists — no config flag — but diff --git a/repl.lua b/repl.lua index 45e4ae4..3160ca2 100644 --- a/repl.lua +++ b/repl.lua @@ -139,6 +139,8 @@ Meta commands: :route check report which class would route to (debug) :fallback on/off toggle cloud retry when local transport fails :skills list user-defined skills loaded from ~/.config/aish/skills/ + :secrets [status] show vault state, active broker redact mode (never prints values) + :secrets check show what the active broker's scrub would do to :every schedule a recurring prompt (i: 30s | 5m | 2h) :every list show scheduled recurring prompts :every cancel remove a scheduled prompt @@ -172,6 +174,72 @@ function M.run(config) -- has to exist as a local in scope BEFORE ask_ai is declared. local _bg_spawn + -- Issue #13: secret redaction. Load vault if configured, create a + -- session for this conversation. ctx stores PLAIN; we scrub just + -- before broker.chat_stream and rehydrate the streamed reply for + -- display. Tool args dispatched to MCP get rehydrated so the server + -- sees the real values. Default mode resolution: per-broker + -- `redact` field on the model preset → `config.secrets.default` + -- → "vault+autodetect" if vault loaded → "off". + local secrets = require("secrets") + local secrets_session + do + local vpath = config.secrets and config.secrets.vault + if vpath then + -- Tilde expansion: ~ → $HOME for a common form. + if vpath:sub(1, 2) == "~/" then + vpath = (os.getenv("HOME") or "") .. vpath:sub(2) + end + local v, err = secrets.load(vpath) + if v then + secrets_session = secrets.make_session(v) + renderer.status(("secrets vault loaded (%d entries)") + :format(#v.entries)) + else + renderer.status(err or "secrets: load failed") + end + end + end + local function secrets_mode_for(model_cfg) + if not secrets_session then return "off" end + local m = (model_cfg and model_cfg.redact) + or (config.secrets and config.secrets.default) + if m then return m end + return secrets_session:has_vault() and "vault+autodetect" or "off" + end + -- Walk an OpenAI-shape messages array, scrub all string content per + -- the model's redact policy. Tool-call arguments are JSON strings — + -- scrub them too (they may carry secrets if the model put a placeholder + -- in a tool arg and was rendered through here on a re-iteration). + local function scrub_messages(messages, mode) + if mode == "off" or not secrets_session then return messages end + for _, m in ipairs(messages) do + if type(m.content) == "string" then + m.content = secrets_session:scrub(m.content, mode) + end + if m.tool_calls then + for _, tc in ipairs(m.tool_calls) do + if tc["function"] and tc["function"].arguments then + tc["function"].arguments = secrets_session:scrub( + tc["function"].arguments, mode) + end + end + end + end + return messages + end + -- Rehydrate a tool-call args table (recursive). Used at MCP dispatch + -- so the server sees the real values when the model emitted placeholders. + local function rehydrate_args(t) + if not secrets_session then return t end + if type(t) == "string" then + return secrets_session:rehydrate(t) + elseif type(t) == "table" then + for k, v in pairs(t) do t[k] = rehydrate_args(v) end + end + return t + end + -- Phase 5: render the evicted turns into a compact transcript for -- the summarizer prompt. Same shape as :memory summarize uses. local function render_evicted(turns) @@ -208,16 +276,21 @@ function M.run(config) .. "2-3 sentences. Preserve names, facts, decisions.\n\n" .. render_evicted(evicted) end - local reply, err = broker.chat(sum_cfg, { + local sum_msgs = scrub_messages({ { role = "system", content = "Output exactly one short summary paragraph. " .. "No commentary, no markdown, no bullet lists." }, { role = "user", content = body }, - }, { max_tokens = 300, timeout_ms = 30000 }) + }, secrets_mode_for(sum_cfg)) + local reply, err = broker.chat(sum_cfg, sum_msgs, + { max_tokens = 300, timeout_ms = 30000 }) if not reply then renderer.status("context summarize failed: " .. tostring(err)) return nil end + if secrets_session then + reply = secrets_session:rehydrate(reply) + end return reply:gsub("^%s+", ""):gsub("%s+$", "") end end @@ -386,6 +459,11 @@ function M.run(config) return ("[aish] no MCP server connected for alias '%s'") :format(alias), true end + -- Issue #13: when secrets are configured, the model sees placeholders + -- in its context and consequently emits placeholder-bearing tool args. + -- The MCP server is treated as trusted local — rehydrate args before + -- dispatch so the tool gets the real values. + args = rehydrate_args(args) local result, kind, err = sess:call_tool(tool_name, args) if not result then if kind == "rpc_error" then @@ -668,16 +746,35 @@ function M.run(config) while true do local text_parts = {} local tool_calls_seen = {} - local ok, err = call_broker(req_cfg, req_name, ctx:to_messages(), + local redact_mode = secrets_mode_for(req_cfg) + local scrubbed_msgs = scrub_messages(ctx:to_messages(), redact_mode) + -- Streaming rehydrator wraps the on_delta so the user sees real + -- values; text_parts accumulates the REHYDRATED chunks so + -- final_resp (used for CMD: / DELEGATE: extraction) is plain. + local rehydrator = secrets_session + and secrets.streaming_rehydrator(secrets_session) + or nil + local ok, err = call_broker(req_cfg, req_name, scrubbed_msgs, function(kind, payload) if kind == "text" then - text_parts[#text_parts + 1] = payload - renderer.assistant_delta(payload) + local emit = rehydrator and rehydrator:push(payload) + or payload + if emit ~= "" then + text_parts[#text_parts + 1] = emit + renderer.assistant_delta(emit) + end elseif kind == "tool_call" then tool_calls_seen[#tool_calls_seen + 1] = payload end end, { tools = tools_schema() }) + if rehydrator then + local tail = rehydrator:flush() + if tail ~= "" then + text_parts[#text_parts + 1] = tail + renderer.assistant_delta(tail) + end + end renderer.assistant_flush() if not ok then @@ -829,13 +926,21 @@ function M.run(config) ("[delegate %s failed: unknown preset]"):format(d.preset)) else renderer.status(("DELEGATE -> %s: %s"):format(d.preset, d.prompt)) - local sub_msgs = { { role = "user", content = d.prompt } } + local sub_msgs = scrub_messages( + { { role = "user", content = d.prompt } }, + secrets_mode_for(sub_cfg)) local sub_text, sub_err = broker.chat(sub_cfg, sub_msgs) if not sub_text then renderer.status(("delegate %s failed: %s"):format(d.preset, tostring(sub_err))) ctx:append_exec_output( ("[delegate %s failed: %s]"):format(d.preset, tostring(sub_err))) else + -- Rehydrate the reply so the model sees its own + -- secrets restored when this gets re-serialized + -- on the next ask_ai turn. + if secrets_session then + sub_text = secrets_session:rehydrate(sub_text) + end ctx:append_exec_output( ("[delegate %s]: %s"):format(d.preset, sub_text)) end @@ -1281,7 +1386,7 @@ function M.run(config) end renderer.status(("summarizing via %s ..."):format(sum_name)) - local reply, err = broker.chat(sum_cfg, { + local sum_msgs = scrub_messages({ { role = "system", content = "Read the following conversation transcript. Extract " .. "facts, preferences, or context worth remembering " @@ -1291,12 +1396,17 @@ function M.run(config) .. "candidates. No commentary outside candidate lines." }, { role = "user", content = transcript }, - }, { max_tokens = 1024, timeout_ms = 90000 }) + }, secrets_mode_for(sum_cfg)) + local reply, err = broker.chat(sum_cfg, sum_msgs, + { max_tokens = 1024, timeout_ms = 90000 }) if not reply then renderer.status("summarize failed: " .. tostring(err)) return end + if secrets_session then + reply = secrets_session:rehydrate(reply) + end -- Persist the summarize-tagged assistant turn so future -- :memory summarize filters it out (R-C2). @@ -1513,6 +1623,42 @@ function M.run(config) io.write((" :%-16s %s\n"):format(n, skills[n].description)) end end + + -- Issue #13: :secrets meta — vault status, current mode per active + -- broker, mapping size. Never prints actual values (the vault file + -- is itself a secret, gotcha 7). + meta.secrets = function(args) + local sub = args:match("^%s*(%S*)") or "" + if sub == "" or sub == "status" then + if not secrets_session then + renderer.status("(no vault loaded; configure config.secrets.vault)") + return + end + renderer.status(("vault: %d entries; %d placeholders allocated this session") + :format(#secrets_session.entries, secrets_session:mapping_size())) + renderer.status(("active broker mode: %s"):format(secrets_mode_for(active_cfg))) + local names = secrets_session:vault_names() + if #names > 0 then + io.write(" entry names: " .. table.concat(names, ", ") .. "\n") + end + elseif sub == "check" then + -- Run a scrub against the given text and report what would change. + local text = args:match("^%s*check%s+(.+)$") or "" + if text == "" then renderer.status("usage: :secrets check "); return end + if not secrets_session then renderer.status("(no vault loaded)"); return end + local mode = secrets_mode_for(active_cfg) + local scrubbed = secrets_session:scrub(text, mode) + if scrubbed == text then + renderer.status(("no matches (mode=%s)"):format(mode)) + else + renderer.status(("scrubbed (mode=%s):"):format(mode)) + io.write(" " .. scrubbed .. "\n") + end + else + renderer.status("usage: :secrets [status|check ]") + end + end + load_skills() -- Issue #11: in-session recurring prompts (:every). Pre-prompt due-check @@ -1703,11 +1849,16 @@ function M.run(config) renderer.status(("unknown preset: %s"):format(preset)); return end renderer.status(("DELEGATE -> %s: %s"):format(preset, prompt)) - local sub_msgs = { { role = "user", content = prompt } } + local sub_msgs = scrub_messages( + { { role = "user", content = prompt } }, + secrets_mode_for(sub_cfg)) local sub_text, sub_err = broker.chat(sub_cfg, sub_msgs) if not sub_text then renderer.status(("delegate %s failed: %s"):format(preset, tostring(sub_err))) else + if secrets_session then + sub_text = secrets_session:rehydrate(sub_text) + end io.write(sub_text) if not sub_text:match("\n$") then io.write("\n") end ctx:append_exec_output(("[delegate %s]: %s"):format(preset, sub_text))