Files
aish/docs/PHASE9.md
T
marfrit 4f5c3aeba9 docs/PHASE9: formulate — project-local config overlay (.aish.lua)
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>
2026-05-16 23:36:35 +00:00

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:

  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 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.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; 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 / :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.