From 4f5c3aeba9dd0857c356b414c6279beaa6360bdc Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sat, 16 May 2026 23:36:35 +0000 Subject: [PATCH] =?UTF-8?q?docs/PHASE9:=20formulate=20=E2=80=94=20project-?= =?UTF-8?q?local=20config=20overlay=20(.aish.lua)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/PHASE0.md | 7 + docs/PHASE9.md | 341 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 docs/PHASE9.md diff --git a/docs/PHASE0.md b/docs/PHASE0.md index a051fdd..810b52b 100644 --- a/docs/PHASE0.md +++ b/docs/PHASE0.md @@ -296,6 +296,12 @@ Config path resolution order: 3. `~/.config/aish/config.lua` 4. `./config.lua` (development fallback) +Phase 9 adds a project-local overlay step AFTER the user config resolves: +walks up from cwd looking for `.aish.lua` (stops at `$HOME` or `/`), +prompts to trust on first encounter, sha256-pins the trust record, and +shallow-merges the project's top-level keys onto the user config. See +`docs/PHASE9.md`. + **Cwd-relative module resolution.** Phase 0 prepends `./?.lua;./vendor/?.lua` to `package.path`, so `luajit main.lua` must be invoked with the repo root as cwd. Cwd-independent resolution (relative to the script's own @@ -318,6 +324,7 @@ from somewhere else. | **6** | Tree-sitter syntax highlighting hooks, diff-aware code injection, project-level context (file tree summary) | | **7** | Cost / usage observability: broker captures `usage` + `cost`; per-session accumulator on ctx; `:cost` reporter; optional warn thresholds | | **8** | Accurate tokenization: per-endpoint `/tokenize` probe (cached); `broker.token_count`; `Context:estimate_tokens` widened; `:cost detail` est-vs-actual annotation | +| **9** | Project-local config overlay (`.aish.lua` walk-up from cwd to $HOME, sha256-pinned trust prompt, shallow merge over user config); `:config show` meta | --- diff --git a/docs/PHASE9.md b/docs/PHASE9.md new file mode 100644 index 0000000..1ab810e --- /dev/null +++ b/docs/PHASE9.md @@ -0,0 +1,341 @@ +# 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 ? [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: (overlaid on )` 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: ` 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 + +```lua +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 + +```lua +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 + +```lua +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: + +```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 + +```lua +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 + +```lua +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 | 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: ` + `[aish] UNTRUSTED. Loading it runs arbitrary Lua.` + `[y/N]` prompt | +| (project file found, trusted, sha matches) | `[aish] project config: (overlaid on )` | +| (project file found, trusted, sha CHANGED) | re-prompt — bytes are different now | +| (declined this session) | `[aish] project config: (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` execution** — `dofile` 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 ` 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.