Phase 9 formulate manifest + PHASE0 §11 amendment (adds Phase 9 row)
+ PHASE0 §10 amendment (config resolution order now references Phase
9's overlay step). Substrate-touch lands same commit per CLAUDE.md §3.
Four pillars:
1. .aish.lua walk-up from cwd; stops at $HOME or filesystem root.
First found file becomes the project layer. Absence = no-op.
2. Shallow merge over user config: project top-level keys REPLACE
user keys. Predictable; deep merge surprises with array/table
semantics. Users compose full blocks explicitly.
3. Trust prompt + sha256-pinned persistence in ~/.aish/trusted-
projects (JSONL, mode 0600). First encounter prompts; subsequent
startups load only if recorded sha matches. Content change ->
re-prompt. Matches direnv-allow security posture.
4. :config show meta — lists each source path with the top-level
keys it contributed + sanitized effective config dump
(token-bearing fields masked).
Key design decisions documented:
- Trust mechanism is explicit (not default-trust-all-cwds) —
.aish.lua runs arbitrary Lua via dofile; hostile cloned-repo
case is a real concern.
- $HOME boundary on walk-up — don't search /tmp or /. Repos
outside $HOME get no project layer.
- Reload on cd: NO. Config resolved at startup only.
- sha256 via shelled `sha256sum` (POSIX-portable; avoid
vendoring a Lua impl).
§9 risk table covers: hostile repo (trust prompt), corrupted trust
file (best-effort skip), updated repo (sha mismatch re-prompts),
dofile errors (pcall-protected), walk-up safety ($HOME boundary).
6 open questions for analyze:
Q-P1 — trust prompt before/after startup status
Q-P2 — sha256sum vs openssl dgst (baseline)
Q-P3 — log walk-up path?
Q-P4 — rl.readline safe at startup?
Q-P5 — :config show full vs top-level
Q-P6 — project-set secrets.vault security
Scope confirmed via AskUserQuestion: project-local overlay (chosen
over cost preflight enforcement and cross-session cost persistence,
both deferred as Phase 10 candidates per §11).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
16 KiB
aish — Phase 9 Manifest
Project: aish — AI-augmented conversational shell Document: Phase 9 Requirements, Architecture & Design Decisions Status: Formulate (pre-analyze) Date: 2026-05-16
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 | Add stat for filesystem checks during walk-up (or use io.open(path,"r") for existence — simpler, no new FFI). |
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
-- Don't walk OUTSIDE $HOME. If cwd isn't inside $HOME, no
-- project search.
if dir:sub(1, #home) ~= 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
local function _check_trusted(project_path)
local path = (os.getenv("HOME") or "") .. "/.aish/trusted-projects"
local f = io.open(path, "r")
if not f then return false end
local current_sha = _sha256_file(project_path)
for line in f:lines() do
local entry = json.decode(line)
if entry and entry.path == project_path
and entry.sha256 == current_sha then
f:close()
return true
end
end
f:close()
return false
end
Trust prompt
local function _prompt_trust(project_path)
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
_record_trust(project_path)
return true
end
return false
end
sha256
Shell out: sha256sum <path> | cut -d' ' -f1. POSIX-portable; faster than vendoring. Cached result during the trust check (single call per startup).
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 (anything matching auth_token, *_TOKEN, etc.)
displayed as (set) rather than the value.
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; missing file means all projects untrusted (re-prompt on next encounter). |
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). |
| Shallow merge surprises a user who wanted to add ONE model preset | Documented as predictable / explicit; users compose the full models block deliberately. |
| 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 | Should the trust prompt happen BEFORE or AFTER aish: loaded config from <path> startup status? |
Startup readability | Analyze (probably AFTER — user sees what config is in play, then makes trust decision about overlay) |
| Q-P2 | sha256_file via sha256sum vs openssl dgst -sha256. Both are POSIX-common. Which is more universally present on the fleet? |
Hash backend choice | Baseline (probe both on fleet hosts) |
| Q-P3 | Should _find_project_config log the walk-up path it searched at startup (for debugging)? |
Debug visibility | Analyze (probably only when no file found AND verbose mode enabled — too noisy by default) |
| Q-P4 | Trust prompt is at startup BEFORE the readline prompt is fully set up — is rl.readline safe to call this early? |
Interactive prompt sequencing | Analyze (probably yes — Phase 4 :memory candidate-prompt also calls rl.readline at startup; same pattern) |
| Q-P5 | Should :config show display the FULL effective config (potentially 100s of lines if mcp servers etc. are deeply nested) or just top-level keys? |
UX | Analyze (just top-level with "..." for nested; full dump via :config show full if needed) |
| Q-P6 | Should the project file be allowed to set secrets.vault (Phase 5/13)? It's marked 0600-sensitive — letting an untrusted project file point at a different vault is a leak vector. |
Security | Analyze (resolution: project layer CAN set secrets.vault but it's part of the trust prompt; the user accepts everything when they trust) |
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.