repl: :config show meta + HELP (Phase 9 commit #3)

User-facing diagnostic for the project-overlay layer. Reads
config._sources (R3 cfg-embedded by main.lua's load_config_with_
overlay in commit #2) + the effective config; surfaces which file
contributed each top-level key.

:config show           top-level keys + which source set each
                        (nested tables collapsed to inner-key list)
:config show full      recursive dump with sensitive-key masking

Masking heuristic (any key containing token/secret/auth/key,
case-insensitive) -> "(set)" instead of the value. R6: applied
RECURSIVELY in full mode so the actual leak vector
(mcp.servers.<alias>.auth_token, models.<x>.auth_token) is caught.

Defensive depth cap (5) prevents pathological recursion.

When config._sources is absent (caller didn't go through
load_config_with_overlay), status: "(unknown — main didn't pass
_sources)" — meta still runs, just labels source as "?".

N2 known cosmetic false-positive: `key_env` / `auth_env` config
fields hold env-var NAMES (not secrets) but match the heuristic.
Future polish exempts `*_env` patterns. Same for `token_budget`
(contains "token") — also masked despite being a plain number.
Acceptable; errs toward over-masking.

HELP gains 1 :config line.

E2E verified across 4 scenarios with AISH_TRUST_FILE + isolated HOME:
  A. No project overlay: 6 user keys; nested tables collapsed.
     `secrets` masked as (set) at top level.
  B. Project overlay accepted: source map cleanly partitioned
     (user has 4 keys; project has 2 — default_model + models);
     each top-level row tagged [user] or [project].
  C. :config show full: nested dump; auth_token in models.cloud
     correctly masked as (set); SECRET_VAL never appears in
     output (grep count = 0).

Regression: test_safety 87/87, test_router_model 31/31, repl loads.

Commit #4 next: config.lua template comment + PHASE9.md status
header -> Implement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 23:54:30 +00:00
parent 34b465d6dc
commit 5b6ee553db
+95
View File
@@ -188,6 +188,8 @@ Meta commands:
:cost summary of session token/cost usage
:cost detail per-model + per-category breakdown
:cost reset zero the cost meter (also clears warn flags)
:config show [full] show config sources (user / project overlay) +
effective config; sensitive keys masked as (set)
:highlight [on|off|status]
toggle tree-sitter syntax highlighting on assistant
code fences (requires external tree-sitter CLI +
@@ -2045,6 +2047,99 @@ function M.run(config)
-- :cost reset zero the meter (also clears warn flags)
-- R7 sort: (cost desc, model asc, category asc) — table.sort is
-- unstable, so the 3-level key ensures deterministic output.
-- Phase 9: :config show meta. Reads config._sources (R3 cfg-
-- embedded source map from main.lua's load_config_with_overlay)
-- + the effective config. Sanitizes token-bearing fields per
-- the masking heuristic (any key containing token/secret/auth/
-- key, case-insensitive -> "(set)" instead of the value).
--
-- R6: `:config show` (default) shows top-level keys with nested
-- tables collapsed to their inner-key list; `:config show full`
-- recurses and applies the same masking heuristic at every level.
-- N2 known false positive: key_env / auth_env hold env-var
-- names (not secrets) but match the heuristic; future polish
-- exempts `*_env` patterns.
local _CFG_MASK_RE = "token|secret|auth|key" -- pipe-OR via gsub
local function _is_sensitive_key(k)
local lk = tostring(k):lower()
return lk:find("token", 1, true)
or lk:find("secret", 1, true)
or lk:find("auth", 1, true)
or lk:find("key", 1, true)
end
local function _fmt_value(v, full, depth)
depth = depth or 0
if type(v) == "string" then return string.format("%q", v) end
if type(v) == "number" or type(v) == "boolean" then return tostring(v) end
if type(v) == "function" then return "<function>" end
if type(v) == "table" then
local keys = {}
for k, _ in pairs(v) do keys[#keys + 1] = tostring(k) end
table.sort(keys)
if not full then
return "{" .. table.concat(keys, ", ") .. "}"
end
-- Full mode: recurse, masking sensitive keys.
if depth >= 5 then return "{...}" end -- defensive depth cap
local parts = {}
for _, k in ipairs(keys) do
local val
if _is_sensitive_key(k) then
val = "(set)"
else
val = _fmt_value(v[k], true, depth + 1)
end
parts[#parts + 1] = ("%s = %s"):format(k, val)
end
return "{" .. table.concat(parts, ", ") .. "}"
end
return "<" .. type(v) .. ">"
end
meta.config = function(args)
local sub = ((args or ""):match("^%s*(%S*)") or ""):lower()
if sub ~= "" and sub ~= "show" then
renderer.status("usage: :config show [full]")
return
end
local full = (args or ""):lower():match("show%s+full") ~= nil
local sources = config._sources
if sources then
-- Group by source for the "config sources" listing.
local user_keys, project_keys = {}, {}
for k, src in pairs(sources) do
if src == "project" then project_keys[#project_keys + 1] = k
else user_keys[#user_keys + 1] = k end
end
table.sort(user_keys); table.sort(project_keys)
renderer.status("config sources:")
io.write((" user: %s keys: %s\n"):format(
#user_keys, table.concat(user_keys, ", ")))
if #project_keys > 0 then
io.write((" project: %s keys: %s\n"):format(
#project_keys, table.concat(project_keys, ", ")))
end
else
renderer.status("config sources: (unknown — main didn't pass _sources)")
end
renderer.status(full and "effective config (full, sensitive masked):"
or "effective config (top-level; ':config show full' for deep):")
local top_keys = {}
for k, _ in pairs(config) do
if k ~= "_sources" then top_keys[#top_keys + 1] = k end
end
table.sort(top_keys)
for _, k in ipairs(top_keys) do
local source_tag = sources and sources[k] or "?"
local val
if _is_sensitive_key(k) then
val = "(set)"
else
val = _fmt_value(config[k], full)
end
io.write((" %-20s = %s [%s]\n"):format(k, val, source_tag))
end
end
-- R10: $%.6f for sub-cent precision (cloud costs can be ~3e-05).
-- R6: annotation uses the per-slot is_local sticky flag rather
-- than a fragile cost==0 heuristic.