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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user