repl: wire #13 secrets — scrub outbound, rehydrate stream + tool args

Plumbs the secrets.lua module (commit e4b818b) into the conversation
pipeline. Hook points:

  ask_ai          — scrub_messages(ctx:to_messages(), mode) before
                    call_broker; rehydrate streamed deltas via
                    streaming_rehydrator so the user sees real values
                    while text_parts accumulates rehydrated chunks
                    (final_resp is plain — CMD: / DELEGATE: extractors
                    see plain values)

  MCP dispatch    — dispatch_tool_call rehydrates the args table before
                    sess:call_tool so the trusted MCP server receives
                    real values (the model emitted placeholders because
                    it saw a scrubbed context)

  DELEGATE: & :delegate
                  — scrub sub_msgs before broker.chat; rehydrate sub_text
                    before appending to context, so future turns see
                    real values restored

  Phase 5 summarize-on-evict
                  — scrub sum_msgs before broker.chat; rehydrate the
                    reply that becomes ctx.summary

  :memory summarize
                  — same scrub + rehydrate pair

Mode resolution per call: model_cfg.redact → config.secrets.default →
"vault+autodetect" if vault loaded, else "off".

ctx storage convention: PLAIN values throughout. The scrub happens at
the egress (broker call) per the active redact mode; ctx.turns never
holds placeholders for content the user typed or executor produced.
The model's own emissions (assistant tool_call arguments) may carry
placeholders because the model saw the scrubbed context — rehydrated
at MCP dispatch and otherwise harmless on re-serialization (idempotent
re-scrubbing).

New meta:
  :secrets [status]         vault entries, placeholders allocated this
                            session, active broker mode. Never prints
                            actual values (vault file is itself a
                            secret per gotcha 7).
  :secrets check <text>     dry-run scrub against the active broker's
                            mode — shows the output transformation.

Documented in config.lua with a commented-out block + per-broker
redact field example.

Deferred to a follow-up issue (clearly scoped):
  - safety.lua broker call sites (Norris main loop, is_destructive
    LLM second-opinion probe) — same wiring pattern, but they don't
    currently see secrets_session; needs threading through helpers.
  - @-mention file content is appended PLAIN to ctx and scrubbed at
    egress alongside the rest of the user turn (covered by the
    ask_ai scrub).
  - exec output streamed live to terminal is pre-scrub (user sees
    real values in their own shell — by design); the captured-for-
    context copy is scrubbed at egress alongside the rest.

This is the "full scope" implementation chosen via AskUserQuestion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:38:23 +00:00
parent e4b818b0e9
commit d852acadc2
2 changed files with 178 additions and 9 deletions
+18
View File
@@ -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 <history.dir>/bg/<id>.log + .status sidecar). The
-- feature is always-on once history.dir exists — no config flag — but
+160 -9
View File
@@ -139,6 +139,8 @@ Meta commands:
:route check <text> report which class <text> 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 <text> show what the active broker's scrub would do to <text>
:every <i> <prompt> schedule a recurring prompt (i: 30s | 5m | 2h)
:every list show scheduled recurring prompts
:every cancel <id> 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 <text>"); 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 <text>]")
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))