From 5b6ee553dba3c58eee2bb123104d0a399f8fe5e4 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sat, 16 May 2026 23:54:30 +0000 Subject: [PATCH] repl: :config show meta + HELP (Phase 9 commit #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..auth_token, models..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) --- repl.lua | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/repl.lua b/repl.lua index a865dcf..12212a8 100644 --- a/repl.lua +++ b/repl.lua @@ -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 "" 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.