Sonnet review of PHASE9 (formulate + analyze + baseline + plan at
31e5de5). No BLOCKERs (manifest design sound); seven real CONCERNs
including a path-prefix bug + a piped-stdin interaction that would
have surfaced at implement time.
CONCERNs (FOLDED):
R1. HOME-prefix walk-up false positive — dir:sub(1, #home) ~= home
matches /home/user2 when HOME=/home/user. Real bug. Fix:
`dir ~= home and dir:sub(1, #home + 1) ~= home .. "/"`.
R2. A8's io.read("*l") fallback for trust prompt would consume the
first line of piped stdin in aish -p mode. Fix: SKIP trust
prompt in one-shot mode (load only pre-trusted overlays).
If rl.readline misbehaves interactively, emit status + skip
overlay (no fallback to stdin in either mode).
R3. Sources-map delivery decided: cfg-embedded as config._sources.
Globals across module boundaries explicitly avoided. Backward-
compat: if absent, :config show reports "(sources unknown)".
R4. _prompt_trust signature fixed — takes pre-computed sha; single
sha256 call per startup per project file.
R5. _check_trusted no longer reimplements trust-file read logic;
routes through history.is_trusted / history.add_trusted with
AISH_TRUST_FILE env override (single resolution site).
R6. :config show `full` mode masking now spec'd: same heuristic
applied RECURSIVELY to nested values (mcp.servers.X.auth_token
is the actual leak vector).
R7. Shallow-merge UX trap reframed — was "documented as predictable";
now an explicit conspicuous warning in done-when + UX surface +
config.lua template that "if your .aish.lua sets a top-level
block, it REPLACES the user's entire block". Deep-merge with
explicit-replace-syntax v2 polish.
NITs (APPLIED):
N1. (no doc change — review-prompt clarification only)
N2. key_env / auth_env over-masking documented as known cosmetic
false-positive (env-var names, not secrets).
N3. Sources-map decision added to open-at-plan-time before
falling-into-commit-2 surprise.
N4. Trust-file first-write atomicity edge case documented (manual
delete to recover); temp-file+rename = v2.
N5. Stale "stat" mention in §3 module table removed (A2: io.open
is sufficient; no new FFI).
Code sketches in §4 + §5 + §6 + §13 commits 2+3 all updated to
reflect the fixes. Manifest is internally consistent + matches the
history.lua API to be added in commit 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
31 KiB
aish — Phase 9 Manifest
Project: aish — AI-augmented conversational shell
Document: Phase 9 Requirements, Architecture & Design Decisions
Status: Plan + review fold-in (tree at 31e5de5)
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:
-
Project-config resolution + walk-up — at startup, walk up from cwd looking for
.aish.lua. Walk stops at the first found file OR at$HOMEOR 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). -
Merge semantics (shallow over user-config) — load the global config first, then
dofilethe project.aish.luaand merge its top-level keys ONTO the user config. Shallow merge: project'smodels = {...}REPLACES the user's entiremodelsblock (not per-model). Predictable; users who want to add ONE model layer it deliberately or write a completemodelsblock in their project file. -
Trust prompt + persistent record — first time aish encounters a
.aish.luaat a given path, prompt the user to trust it ([aish] trust <path>? [y/N]). Ony, 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. Onnor empty: skip the project layer for this session. -
:config showmeta — print the resolved config sources (which file contributed which top-level key), plus a sanitized dump of the effective config (token-bearing fields likeauth_tokenmasked). Useful for debugging when "why doesn't my project policy apply?" comes up.
Phase 9 is done when:
- A repo with
.aish.luain 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-setpermissions = { allow = ... }allow-rules fire). .aish.luawalk-up finds the file from a nested cwd (e.g.,~/src/aish/docs/finds~/src/aish/.aish.lua).- Walking past
$HOMEstops (doesn't search/home/or/). - Mutating a trusted
.aish.luare-prompts (hash mismatch). :config showlists each source path with the keys it provided.- Existing configs without any
.aish.luabehave 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.luaexecution —dofileruns full Lua; the trust prompt IS the gate. A sandbox (allowlisted globals, noio.popen, etc.) is bigger work and out of scope. - Reload on
cd— config is resolved at startup only.cdinto a sibling project means restarting aish. Documented. - Recursive merge — top-level shallow only.
- Multiple project overlays — walk-up stops at FIRST
.aish.luafound. Nested projects (e.g., monorepo with per-package configs) would need deeper design; defer. :trust/:untrustmetas for runtime management — trust records edited manually in~/.aish/trusted-projectsfor v1. A meta surface is a v2 polish.- Environment variable expansion in project file — project file
is plain Lua; users have
os.getenvalready. - Project-wide aish profile selection —
.aish.luareturns 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/:untrustmetas for runtime trust management.- Sandboxed
.aish.luaexecution (allowlisted Lua globals).
- Phase X+: nested project overlays for monorepos;
:profileswitching; 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:
-
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 tosha256sumand parses the first whitespace-separated field. - Smoke: 5 inline unit cases (read empty, add+read-back, mode check, sha mismatch returns false, missing file).
-
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.luaor nil._check_and_maybe_prompt(project_path)(R4 + R5) callshistory._sha256_fileONCE; routes throughhistory.is_trusted/history.add_trustedwith the env-overridable trust file path. Returns true if the project file should be loaded.load_config_with_overlay(opts)wraps existingload_config; finds project, checks trust, prompts if needed, dofiles + merges shallow over user config. R2: in one-shot mode (opts.promptis 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.runreadsconfig._sourcesfor: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).
-
repl.lua—:config showmeta + startup status line.:config show/:config show fullmeta readsconfig._sources(R3 cfg-embedded) + the effective config; sanitizes token-bearing values (any key containing "token"/"secret"/"auth"/"key", case-insensitive) → display as(set). R6: infullmode, applies the heuristic RECURSIVELY to nested values (the real leak vector ismcp.servers.<alias>.auth_token). Ifconfig._sourcesis 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
:configlines. - N2 known false-positive:
key_env/auth_envconfig 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 showand verify keys + sources line up;:config show fullmasks nested auth tokens but exposes other nested fields.
-
config.luatemplate 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.
- Add a header comment to
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) andluajit 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.luamodule — 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_FILEenv 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.