Files
aish/docs/PHASE9.md
T
marfrit df59ee2f2c config + docs/PHASE9: template comment + status -> Implement (Phase 9 commit #4)
config.lua header gains a Phase 9 paragraph documenting the
project-overlay feature + the R7 shallow-merge warning ("if your
.aish.lua sets a top-level block, it REPLACES the user's entire
block — list every entry OR omit the block"). Inspect at runtime
via `:config show`.

docs/PHASE9.md status header bumped: "Plan + review fold-in" ->
"Implement". Lists the 4 implement commits inline:
  e525063  history: trust file helpers
  34b465d  main: project-overlay loader
  5b6ee55  repl: :config show meta + HELP
  this     config template comment + status bump

Phase 9 implementation complete. Next inner-loop step: verify
(file TCs, run autonomous, close) + memory-update.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:54:53 +00:00

31 KiB

aish — Phase 9 Manifest

Project: aish — AI-augmented conversational shell Document: Phase 9 Requirements, Architecture & Design Decisions Status: Implement (4 commits landed: e525063, 34b465d, 5b6ee55, this) Date: 2026-05-16

Review findings (Sonnet, 2026-05-16) — 0 BLOCKERs, 7 CONCERNs folded, 5 NITs applied:

R1 (CONCERN, FOLDED). HOME prefix false-positive in walk-up. dir:sub(1, #home) ~= home lets /home/user2/... pass when HOME is /home/user (matches first 10 bytes). Real bug. Fix: if dir ~= home and dir:sub(1, #home + 1) ~= home .. "/" then return nil end. §4 code updated.

R2 (CONCERN, FOLDED). io.read trust-prompt fallback breaks aish -p piped stdin. A8's fallback (io.read("*l") if rl.readline misbehaves at startup) would consume the first line of piped stdin in non-interactive mode. Fix: in one-shot mode (opts.prompt set), SKIP the trust prompt entirely and decline silently with a status line. Project overlays in -p mode require pre-existing trust. Documented in §13 commit 2.

R3 (CONCERN, FOLDED). Sources-map delivery decided: cfg._sources embedded on the config table (NOT a global). repl.run reads config._sources for :config show. Backward-compatible — old callers of repl.run that don't pass _sources still work (:config show says (sources unknown)). §4 + §13 commits 2+3 updated to reflect.

R4 (CONCERN, FOLDED). _prompt_trust signature contradicted _check_trusted's "compute sha once" claim. §5 sketch called _record_trust(project_path) which would re-sha256. Fix: _prompt_trust(project_path, sha) takes the pre-computed sha; history.add_trusted(trust_path, project_path, sha) is the one writer. §5 sketches updated to match §13 + the real history.lua API.

R5 (CONCERN, FOLDED). _check_trusted duplicated trust-file read logic vs history.lua API. §5 sketch had inline JSONL read; §13 defines M.is_trusted(trust_path, project_path, sha256) in history.lua to own that. Fix: §5 sketches now call history.is_trusted(...) and history.add_trusted(...) — main.lua holds no trust-file logic itself. This also makes the $AISH_TRUST_FILE env override work cleanly (one resolution site).

R6 (CONCERN, FOLDED). :config show full mode masking unspecified for nested values — the actual leak vector is mcp.servers.<alias>.auth_token. Fix: §6 + §13 commit 3 spell out: same heuristic, applied RECURSIVELY in full mode. Top-level mode (default) already collapses nested tables, so no leak there.

R7 (CONCERN, FOLDED). Shallow merge silently drops user's entire models block (or permissions, cost, etc.). Documented as "predictable" but is a real UX trap. Fix: §1 done-when + §7 UX surface + §13 commit 4 template-comment all gain a conspicuous warning: "If your .aish.lua sets a top-level block (models, permissions, cost, ...) it REPLACES your user config's entire block — list every entry you want available OR omit the block to keep the user's." Stronger framing than "predictable".

R-N1..N5 (NITs, APPLIED): N1. (cosmetic — review-prompt clarification only; no doc change) N2. key_env / auth_env over-masking is a known false-positive of the heuristic (env-var NAME, not a secret). §13 commit 3 risk row gains an explicit note: "values of *_env fields will be masked too; cosmetic only — they hold env-var names, not secrets. Future: refine heuristic to exempt *_env pattern." N3. §13 open-at-plan-time list now includes the sources-map-delivery decision (resolved by R3 — embed on cfg). N4. §9 risk row about trust file partial write gains explicit first-ever-write edge case + workaround (manually delete the corrupt file). Temp-file+rename is v2 polish. N5. §3 module table ffi/libc.lua row had stale "stat" mention; removed per A2 (io.open is sufficient).

Analyze + baseline findings (2026-05-16) — 5/6 open Qs resolved in-place; Q-P4 deferred to implement-time verify:

A1. main.lua load_config surface clean. load_config(opts) at main.lua:53 returns (cfg, path) for the user config. Adding a project-overlay wrapper that calls it then walks for .aish.lua is additive — no refactor of the existing 4-tier resolution.

A2. No new FFI needed for walk-up. io.open(candidate, "rb") is sufficient for existence check; libc.getcwd() from Phase 6 provides the starting point. No new C bindings.

A3. Q-P2 RESOLVED via probe (B1 below): use sha256sum — GNU coreutils ships it everywhere aish targets. Single-shell-out pattern; output: <digest> <path>cut -d' ' -f1 for the hex digest. No new module dependency.

A4. Q-P1 RESOLVED: trust prompt AFTER aish: loaded config status. The user sees what user-config is in play first, then decides about the overlay. Natural ordering.

A5. Q-P3 RESOLVED: don't log walk-up path by default. Too noisy on every startup. If debugging "why isn't my project file found?", :config show after startup will reveal the walk result (declined-or-not-found is visible). Verbose-mode walk log is v2 polish.

A6. Q-P5 RESOLVED: :config show shows top-level only by default. Nested tables collapsed to {key1, key2, ...} (just the inner table's keys for orientation). :config show full for the deep dump. Keeps the diagnostic surface tractable.

A7. Q-P6 RESOLVED: project layer CAN set secrets.vault — it's part of the trust prompt's scope. User accepting the prompt accepts that the project file may redirect secrets. The in-memory secrets session is built AFTER config resolution, so a project-set secrets.vault IS honored.

A8. rl.readline at startup (Q-P4 — deferred). Phase 4's :memory summarize candidate-prompt path also calls rl.readline early (in metas; not pre-loop). The trust prompt fires BEFORE the main loop opens — earlier than any existing rl.readline call site. Implement-time check: smoke-test that rl.readline behaves correctly when called from load_config_with_overlay before M.run ever fires. If it misbehaves, fall back to a printf "..." + read shell-out for the trust prompt.

A9. Walk-up performance is fine — at most ~10 levels from a typical cwd to $HOME, each io.open is ~10us. Total walk cost < 1ms even on slow filesystems.

A10. Trust file race: two aish instances starting concurrently could double-write to ~/.aish/trusted-projects. JSONL append semantics handle this OK (each writes one complete line); a duplicate trust entry is harmless. No flock needed (unlike memory.jsonl per Phase 4 where the writer SOR was important).

A11. Sandboxed env for dofile? Out of scope per §8. The trust prompt IS the gate; we accept full Lua execution post-trust.

A12. Bootstrap chicken-egg: project's .aish.lua could set secrets.vault which would change WHICH secrets are loaded. A12 paths through cleanly: user config loaded → project overlay merged → effective config passed to M.run → M.run reads config.secrets.vault (now possibly the project's) → secrets_session built. Order is correct; no chicken-egg.

Baseline finding:

B1. sha256sum (GNU coreutils 9.7) and openssl dgst -sha256 agree bit-for-bit on the same input file. Both present on noether. sha256sum chosen for simpler output parsing (digest in first whitespace-separated field; openssl needs awk '{print $NF}'). Per A3 resolution; documented in Q-P2.

PHASE0 is the locked substrate; PHASE1-8 are layered on top. This manifest specifies what Phase 9 adds — project-local config overlay (.aish.lua): a per-project config file in or above cwd that merges onto the user's global config, letting a repo ship its own permission rules, model presets, skills, hooks, etc. without modifying anyone's ~/.config.

PHASE0 §11 amendment to add the Phase 9 row lands in the same commit as this formulate doc.


1. Scope of Phase 9

Four pillars:

  1. Project-config resolution + walk-up — at startup, walk up from cwd looking for .aish.lua. Walk stops at the first found file OR at $HOME OR at filesystem root (whichever comes first — filesystem-root reached without a hit means "no project config"). The found path is the project layer; absence is a no-op (existing resolution path unchanged for users who don't ship project config).

  2. Merge semantics (shallow over user-config) — load the global config first, then dofile the project .aish.lua and merge its top-level keys ONTO the user config. Shallow merge: project's models = {...} REPLACES the user's entire models block (not per-model). Predictable; users who want to add ONE model layer it deliberately or write a complete models block in their project file.

  3. Trust prompt + persistent record — first time aish encounters a .aish.lua at a given path, prompt the user to trust it ([aish] trust <path>? [y/N]). On y, record the path's absolute path AND content hash in ~/.aish/trusted-projects (one JSON line per entry: {path, sha256, ts}). On subsequent startups: load only if the recorded hash still matches; if the file changed since trust, re-prompt. On n or empty: skip the project layer for this session.

  4. :config show meta — print the resolved config sources (which file contributed which top-level key), plus a sanitized dump of the effective config (token-bearing fields like auth_token masked). Useful for debugging when "why doesn't my project policy apply?" comes up.

Phase 9 is done when:

  • A repo with .aish.lua in its root opens correctly: aish prompts to trust on first encounter, loads + merges on subsequent startups (when the hash still matches), and the resulting config behavior visibly reflects the project layer (e.g., project-set permissions = { allow = ... } allow-rules fire).
  • .aish.lua walk-up finds the file from a nested cwd (e.g., ~/src/aish/docs/ finds ~/src/aish/.aish.lua).
  • Walking past $HOME stops (doesn't search /home/ or /).
  • Mutating a trusted .aish.lua re-prompts (hash mismatch).
  • :config show lists each source path with the keys it provided.
  • Existing configs without any .aish.lua behave like Phase 8 (Phase 8 regression coverage).

2. Technology Decisions (delta from Phase 8)

Decision Choice Rationale
Walk-up start libc.getcwd() at startup Matches existing convention (Phase 6 :tree cwd capture).
Walk-up stop $HOME OR filesystem root Don't search outside the user's home — limits attack surface. If no .aish.lua between cwd and $HOME, no project layer.
Project file name .aish.lua (dotfile) Matches .envrc / .tool-versions convention; gitignore-friendly.
Merge semantics Shallow top-level Predictable; deep merge surprises users when they redefine an array (Lua tables-as-arrays don't merge cleanly). Project users who want to add a single MCP server can copy the user's full mcp = {...} block and append.
Trust mechanism Explicit prompt; persist absolute-path + sha256 to ~/.aish/trusted-projects Matches direnv allow posture. Defense against hostile cloned repos that ship malicious .aish.lua (would-be RCE on cd + aish start).
Re-prompt trigger sha256 mismatch on the recorded path Trust the BYTES, not just the path — content change = re-prompt.
Trust file format JSONL: {path, sha256, ts} per line Append-only; readable; trivially manageable by hand.
Trust file mode 0600 (matches secrets vault in Phase 5/13) Local-user trust scope; not a secret per se but defensive.
dofile execution context Whatever dofile provides (full Lua env) Project file is arbitrary Lua because that's what the user accepted at trust-prompt. No sandbox; the prompt is the gate.
Reload on cd NO — config resolved at startup only Mid-session config mutation is a complexity tax. cd into a different project means restarting aish. Document.
Status line on load [aish] project config: <path> (overlaid on <user-config>) at startup Visibility — user always knows when project layer is active.
:config show shape Lists each source path with the top-level keys it contributed Diagnoses "why isn't my project rule applying?" cases. Token-bearing fields masked (auth_token: <set> rather than the value).

3. Module Changes

File State after Phase 8 Phase 9 changes
main.lua load_config(opts) walks $AISH_CONFIG → ~/.config/aish → ./config.lua Wrap with load_with_project_overlay(opts) that finds the user config (existing logic) AND walks up from cwd for .aish.lua; if both found, merge project ONTO user and return merged. Records source-per-key for :config show.
ffi/libc.lua getcwd, chdir, isatty, flock No change (per A2): io.open(candidate, "rb") is sufficient for existence-check during walk-up. No new FFI bindings needed.
repl.lua All the metas including :config (nope — no :config yet) New :config show meta. Source-map carried on a module-local set at startup; meta reads it.
history.lua session log, memory.jsonl New helpers: M.read_trusted(path) returns set of trusted entries; M.add_trusted(path, target_path, sha256) appends. Mode 0600 enforced.
config.lua (the user's global; not the in-tree example) n/a No change. The in-tree config.lua becomes a template that project overlays can replace top-level keys of.
docs/PHASE0.md §11 lists phases 0-8; §10 resolution order Amendment: add Phase 9 row to §11; update §10 to mention project overlay.

No new module files in v1. The hashing logic (sha256) — openssl dgst -sha256 shelled out (or use sha256sum). Both POSIX-portable. Avoid vendoring a Lua sha256 since we already have openssl / sha256sum available everywhere aish runs.


4. Pillar 1+2 — Resolution + Merge

Walk-up

local function _find_project_config()
    local libc = require("ffi.libc")
    local home = os.getenv("HOME")
    if not home then return nil end
    local dir = libc.getcwd()
    if not dir then return nil end

    -- R1: don't walk OUTSIDE $HOME. The proper-prefix check requires
    -- `dir == home` OR `dir starts with home .. "/"` — bare
    -- `sub(1, #home) == home` matches "/home/user2" when HOME is
    -- "/home/user" (10-byte prefix). Real bug caught by review.
    if dir ~= home and dir:sub(1, #home + 1) ~= home .. "/" then
        return nil
    end

    while dir and #dir > 0 do
        local candidate = dir .. "/.aish.lua"
        local f = io.open(candidate, "r")
        if f then f:close(); return candidate end
        if dir == home or dir == "/" then return nil end
        -- Walk up one level
        dir = dir:gsub("/[^/]*$", "")
        if dir == "" then dir = "/" end
    end
    return nil
end

Merge

local function _merge_project_over_user(user_cfg, project_cfg, sources)
    -- Shallow merge: project top-level keys REPLACE user keys.
    -- Source-map tracks who set each key for :config show.
    for k, v in pairs(project_cfg) do
        user_cfg[k] = v
        sources[k] = "project"
    end
    -- (sources for unmodified user keys stay "user")
    return user_cfg
end

Loader wrapper

local function load_config_with_overlay(opts)
    -- Existing load_config returns (user_cfg, user_path)
    local user_cfg, user_path = load_config(opts)

    local sources = {}
    for k, _ in pairs(user_cfg) do sources[k] = "user" end

    local proj_path = _find_project_config()
    if not proj_path then
        return user_cfg, sources, { user = user_path }
    end

    -- Trust check
    local trusted = _check_trusted(proj_path)
    if not trusted then
        if not _prompt_trust(proj_path) then
            -- declined; skip project layer
            return user_cfg, sources, { user = user_path, project = "(declined)" }
        end
    end

    local ok, proj_cfg = pcall(dofile, proj_path)
    if not ok or type(proj_cfg) ~= "table" then
        renderer.status("project config " .. proj_path .. " failed to load; ignoring")
        return user_cfg, sources, { user = user_path, project = "(load failed)" }
    end

    _merge_project_over_user(user_cfg, proj_cfg, sources)
    return user_cfg, sources, { user = user_path, project = proj_path }
end

Source map is then carried as a closure local in repl.run for :config show.


5. Pillar 3 — Trust prompt + persistent record

Trust file shape

~/.aish/trusted-projects (mode 0600), JSONL:

{"path":"/home/user/src/aish/.aish.lua","sha256":"abc123...","ts":"2026-05-16T12:34:56Z"}
{"path":"/home/user/src/other/.aish.lua","sha256":"def456...","ts":"2026-05-16T12:40:00Z"}

Trust check + prompt (R4 + R5 — calls history.lua API; sha computed once)

-- R5: trust-file path resolves through history.lua + optional env override.
-- main.lua never reads/writes the trust file directly.
local function _trust_file_path()
    return os.getenv("AISH_TRUST_FILE")
        or ((os.getenv("HOME") or "") .. "/.aish/trusted-projects")
end

-- R4 + R5: compute sha ONCE; pass to history.is_trusted / add_trusted.
local function _check_and_maybe_prompt(project_path)
    local sha = history._sha256_file(project_path)
    if not sha then
        renderer.status("project config "..project_path..": sha256 failed; skipping")
        return false
    end
    local tpath = _trust_file_path()
    if history.is_trusted(tpath, project_path, sha) then
        return true
    end
    renderer.status("project config found: " .. project_path)
    renderer.status("UNTRUSTED. Loading it runs arbitrary Lua code.")
    local ans = rl.readline("[aish] trust this project config? [y/N] ")
    if ans and ans:lower():sub(1, 1) == "y" then
        history.add_trusted(tpath, project_path, sha)
        return true
    end
    return false
end

sha256

history._sha256_file(path) shells out to sha256sum <path> and parses the first whitespace-separated field. Single call per startup per project file (R4 — _check_and_maybe_prompt computes once and passes to both history.is_trusted and history.add_trusted).


6. Pillar 4 — :config show

[aish] config sources:
  user:    ~/.config/aish/config.lua
  project: ~/src/aish/.aish.lua
[aish] effective config (top-level keys):
  default_model   : "fast"          (user)
  models          : {fast, cloud}   (project)
  shell           : {confirm_cmd=true, ...} (user)
  permissions     : {allow={...}, ...}  (project)
  hooks           : (unset)
  ...

Token-bearing fields (any key matching token, secret, auth, key, case-insensitive) displayed as (set) rather than the value.

R6 — :config show full applies the SAME heuristic RECURSIVELY to nested values (the actual leak vector is mcp.servers.<alias>.auth_token which top-level mode collapses but full mode would dump).

Known cosmetic false-positive (N2): key_env / auth_env config fields are over-masked. These hold env-var NAMES (e.g. OPENAI_API_KEY) not the secret values themselves — but the heuristic catches them. Future polish: exempt *_env from the heuristic.


7. UX Surface Summary

Meta Behavior
:config show Print resolved sources + sanitized effective config (read-only)
Startup status Behavior
(no project file) nothing — existing UX preserved
(project file found, untrusted) [aish] project config found: <path> + [aish] UNTRUSTED. Loading it runs arbitrary Lua. + [y/N] prompt
(project file found, trusted, sha matches) [aish] project config: <path> (overlaid on <user>)
(project file found, trusted, sha CHANGED) re-prompt — bytes are different now
(declined this session) [aish] project config: <path> (declined this session)

No new config keys in v1 (the project overlay IS the new mechanism; it doesn't need a config flag to be enabled).


8. Out of Scope (Phase 9)

  • Sandboxed .aish.lua executiondofile runs full Lua; the trust prompt IS the gate. A sandbox (allowlisted globals, no io.popen, etc.) is bigger work and out of scope.
  • Reload on cd — config is resolved at startup only. cd into a sibling project means restarting aish. Documented.
  • Recursive merge — top-level shallow only.
  • Multiple project overlays — walk-up stops at FIRST .aish.lua found. Nested projects (e.g., monorepo with per-package configs) would need deeper design; defer.
  • :trust / :untrust metas for runtime management — trust records edited manually in ~/.aish/trusted-projects for v1. A meta surface is a v2 polish.
  • Environment variable expansion in project file — project file is plain Lua; users have os.getenv already.
  • Project-wide aish profile selection.aish.lua returns a config table, not a profile name. If multi-profile support is desired, the project file can compute a different config based on its OWN env vars / heuristics.

9. Risks

Risk Mitigation
Hostile .aish.lua in cloned repo runs arbitrary Lua on first aish run in that cwd Trust prompt + sha256 persistence; default = decline if user just hits Enter at the [y/N].
Trust file becomes corrupted / unreadable Best-effort: corrupted lines skipped (each line is independent JSON); missing file means all projects untrusted (re-prompt on next encounter). N4 edge case: if the FIRST-EVER write is interrupted partway, the file's sole line may be corrupt JSON and the project never stays trusted — user manually deletes ~/.aish/trusted-projects to recover. Temp-file+rename atomicity is v2 polish.
User trusts .aish.lua, repo is updated, malicious code is injected sha256 mismatch on next startup triggers re-prompt. User sees the prompt and can investigate before granting trust again.
dofile errors at load time (syntax error in project config) pcall-protected; status line "project config X failed to load; ignoring" — aish continues with just the user config.
Walk-up walks above $HOME (e.g., a repo cloned to /tmp) $HOME boundary check stops the walk. /tmp repos get no project layer (user can move them under $HOME or use --config).
R7 — shallow merge silently DROPS the user's entire block on overlap. A .aish.lua that sets models = {...} REPLACES the user's full models block; same for permissions, cost, shell, etc. This is a genuine UX trap, not just "predictable" — accept-and-warn-clearly is the resolution rather than hiding behind framing. Conspicuous warning in §1 done-when + §7 UX table + config.lua template header: "If your .aish.lua sets a top-level block (models, permissions, cost, ...) it REPLACES your user config's entire block — list every entry you want available OR omit the block to keep the user's." Deep-merge-with-explicit-replace-syntax (systemd drop-in style) is v2 polish.
Source map dict grows unboundedly with new keys mid-session Bounded by #config top-level keys (small constant; <20). No GC needed.

10. Open Questions (Phase 9)

# Question Impact Resolution target
Q-P1 Trust prompt before/after aish: loaded config status A4 — AFTER; user sees user-config first, then decides about overlay.
Q-P2 sha256 backend choice B1 RESOLVED — sha256sum (GNU coreutils; universal on Linux); simpler output parsing than openssl.
Q-P3 Log walk-up path A5 — no by default; :config show reveals walk result on demand. Verbose-mode walk log is v2 polish.
Q-P4 rl.readline safe at startup A8 — DEFERRED to implement-time smoke (Phase 4 metas call rl.readline early too; new wrinkle is firing BEFORE main loop opens). If issue, fall back to printf+read shell-out.
Q-P5 :config show full vs top-level A6 — top-level by default (nested collapsed to inner keys); :config show full for deep dump.
Q-P6 Project layer setting secrets.vault security A7 — allowed; part of the trust prompt's scope. Bootstrap order (A12) ensures project's vault is honored if set.

11. Phase 9 → Phase 10+ Out-of-band

Candidate follow-ups (non-binding):

  • Phase 10 candidates:
    • Cost preflight enforcement (Phase 7 §12 option 2; Phase 8 §11 candidate).
    • Cross-session cost rollup (Phase 7 §12 option 1; Phase 8 §11 candidate).
    • :trust / :untrust metas for runtime trust management.
    • Sandboxed .aish.lua execution (allowlisted Lua globals).
  • Phase X+: nested project overlays for monorepos; :profile switching; reload-on-cd.

Phase 9 itself is self-contained — depends on no specific prior phase beyond the existing config loader.


13. Implementation Plan (commit-by-commit)

4 commits, bottom-up:

  1. history.lua — trust file helpers.

    • M.read_trusted(path) -> list of {path, sha256, ts} entries; mode-check the file at 0600, refuse to load (warn) if wider. Missing file → empty list.
    • M.add_trusted(trust_path, project_path, sha256) appends a JSONL line; mkdir -p the parent if needed; chmod 0600.
    • M.is_trusted(trust_path, project_path, sha256) reads + checks for matching entry.
    • Internal _sha256_file(path) shells out to sha256sum and parses the first whitespace-separated field.
    • Smoke: 5 inline unit cases (read empty, add+read-back, mode check, sha mismatch returns false, missing file).
  2. main.lua — walk-up + load_with_project_overlay.

    • _find_project_config() walks from libc.getcwd() up to $HOME (R1 corrected proper-prefix check), returning first .aish.lua or nil.
    • _check_and_maybe_prompt(project_path) (R4 + R5) calls history._sha256_file ONCE; routes through history.is_trusted / history.add_trusted with the env-overridable trust file path. Returns true if the project file should be loaded.
    • load_config_with_overlay(opts) wraps existing load_config; finds project, checks trust, prompts if needed, dofiles + merges shallow over user config. R2: in one-shot mode (opts.prompt is set), the trust prompt is SKIPPED entirely — the project layer is only loaded if it's already pre-trusted. Avoids io.read consuming the first line of piped stdin.
    • R3 sources delivery: embed on config._sources (a sentinel field on the config table itself). NOT a global. repl.run reads config._sources for :config show; backward-compatible (old callers without _sources are reported as "(sources unknown)" by the meta).
    • Smoke: (a) tree-resolution from a nested cwd; (b) trust prompt accept-then-load + decline-then-skip paths; (c) -p mode with untrusted .aish.lua + piped stdin -> trust prompt SKIPPED, no stdin consumption; (d) A8: rl.readline early-startup smoke; if rl.readline misbehaves, NO fallback to io.read in interactive mode either — emit status + skip overlay (avoids the silent-data-loss risk R2 covers).
  3. repl.lua:config show meta + startup status line.

    • :config show / :config show full meta reads config._sources (R3 cfg-embedded) + the effective config; sanitizes token-bearing values (any key containing "token"/"secret"/"auth"/"key", case-insensitive) → display as (set). R6: in full mode, applies the heuristic RECURSIVELY to nested values (the real leak vector is mcp.servers.<alias>.auth_token). If config._sources is absent, status: "(sources unknown — main didn't pass _sources)" so the meta still runs but doesn't lie.
    • Startup status line per A4: AFTER the existing aish: loaded config from <path>, if project layer fired, emit [aish] project config: <path> (overlaid on <user>).
    • HELP gains 2 :config lines.
    • N2 known false-positive: key_env / auth_env config field VALUES are masked too (they hold env-var names, not secrets). Cosmetic; future polish exempts *_env.
    • Smoke: with a test project file, run :config show and verify keys + sources line up; :config show full masks nested auth tokens but exposes other nested fields.
  4. config.lua template note + status bump.

    • Add a header comment to config.lua (the in-tree example) noting Phase 9 project-overlay availability (no other config change — overlay is a separate file).
    • PHASE9.md status header -> Implement.

Risk index per commit

Commit Risk Mitigation
1 (history) sha256sum not installed (some minimal images) Detect at startup; if missing, warn + decline all trust prompts (project layer disabled). Documented.
1 (history) Trust file partial write (interrupted append) corrupts later parse JSONL one-line-per-entry; partial line at EOF is skipped on read (each line is a single json.decode).
2 (main) A8 — rl.readline at startup (before main loop) untested in earlier phases Smoke-test at commit-time; if broken, fall back to io.read("*l") from stdin (no readline frills like ^C-handling but functional).
2 (main) Walk-up symlink loops realpath/stat defenses out of scope for v1; walk is bounded by $HOME stop. Pathological symlinks could waste cycles but not infinite-loop (every iteration strips a path component).
3 (repl) :config show might leak token values if a config key isn't matched by the masking heuristic Conservative mask: any key containing "token", "secret", "auth", "key" (case-insensitive) → display (set). Errs toward over-masking.
4 (config + status) None

Tests + smoke per commit

Each commit:

  • Pass luajit test_safety.lua (87/87) and luajit test_router_model.lua (31/31)
  • Load cleanly via luajit -e 'package.path=...; require("repl"); print("ok")'
  • Pass a per-feature smoke (described per row above)

Things deliberately NOT split

  • Separate project.lua module — small enough; history.lua already handles file-with-mode-check (memory.jsonl); same shape.
  • :trust / :untrust runtime metas — manual ~/.aish/trusted-projects editing is fine for v1.
  • Walk-up logging on first startup — easy to add later if needed.

Open at plan-time (resolve at implement)

  • A8: rl.readline early-startup behavior. R2 supersedes the formulate-time io.read fallback — if rl.readline misbehaves, emit status + skip the overlay entirely (NOT a fallback to stdin which would consume piped data in -p mode).
  • $AISH_TRUST_FILE env override — RESOLVED: implement it (one line; useful for CI / test isolation). Used by the verify TCs.
  • N3 — sources-map delivery RESOLVED: embed on config._sources (cfg-field; not a global). Per R3.