From 34b465d6dcc1c47c3bc74f023d7b9e503488ac61 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sat, 16 May 2026 23:48:22 +0000 Subject: [PATCH] main: project-overlay loader (Phase 9 commit #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the project-overlay step around the existing load_config. Activates only when a trusted .aish.lua is found in/above cwd. Changes: - _find_project_config() walks libc.getcwd() up to $HOME, returning first .aish.lua found. R1 fix folded: proper-prefix check (`dir == home OR dir starts with home .. "/"`) avoids the false positive where /home/user2 matches HOME=/home/user via byte prefix. - _trust_file_path() resolves via $AISH_TRUST_FILE env override, else ~/.aish/trusted-projects. Plan-time decision per N3. - _check_and_maybe_prompt(project_path, history) — calls history._sha256_file ONCE; routes through history.is_trusted; on miss prompts via rl.readline; on accept persists via history.add_trusted. A8 mitigation: if rl.readline fails to load, decline silently (no io.read fallback that would consume stdin). - load_config_with_overlay(opts): * Calls existing load_config; seeds sources={k="user", ...} * Walks for .aish.lua; if found: - In opts.prompt mode (-p, R2): skip the prompt entirely; only PRE-TRUSTED overlays load. Avoids io consuming the piped stdin that -p will read for context. - Else: interactive trust check + prompt. * On accept + successful dofile: shallow-merge top-level keys ONTO user config; update sources[k]="project" for overlapping. * R3: embeds sources on cfg._sources for repl.lua's :config show meta to read. No global. * Returns (cfg, user_path, project_path | nil). - main() now calls load_config_with_overlay; on project layer active, emits the "[aish] project config: (overlaid on )" status line per A4 (AFTER the user-config status). E2E verified across 4 scenarios with AISH_TRUST_FILE + isolated HOME: 1. Decline -> overlay skipped; user config active. 2. Accept -> overlay loaded; project_model active; status line "[aish] project config: ... (overlaid on ...)" visible. 3. Re-startup -> NO prompt (cached via sha); overlay loaded transparently. R4 single-sha-call confirmed. 4. -p mode with untrusted overlay -> skipped silently; piped stdin preserved for run_one_shot. Regression: test_safety 87/87, test_router_model 31/31, repl loads. Commit #3 lands :config show + HELP next; commit #4 the config template comment + status -> Implement. Co-Authored-By: Claude Opus 4.7 (1M context) --- main.lua | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/main.lua b/main.lua index 8ed6766..403f934 100644 --- a/main.lua +++ b/main.lua @@ -76,6 +76,132 @@ local function load_config(opts) .. table.concat(candidates, ", ") .. ")") end +-- ---------------------------------------------------------------- Phase 9 project overlay + +-- Walk-up from libc.getcwd() looking for .aish.lua. Stops at $HOME +-- OR filesystem root (whichever comes first). Returns the first +-- found path or nil. Per R1 (review fold-in), uses a proper-prefix +-- check (NOT bare bytes-prefix) to avoid false positive when HOME +-- is "/home/user" and cwd is "/home/user2/...". +local function _find_project_config() + local home = os.getenv("HOME") + if not home or home == "" then return nil end + -- Lazy-require so the existing load_config path stays untouched + -- when no project overlay considered. + local libc_ok, libc = pcall(require, "ffi.libc") + if not libc_ok then return nil end + local dir = libc.getcwd() + if not dir then return nil end + -- R1: proper prefix (dir == home OR dir starts with home .. "/") + 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, "rb") + 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 + +local function _trust_file_path() + return os.getenv("AISH_TRUST_FILE") + or ((os.getenv("HOME") or "") .. "/.aish/trusted-projects") +end + +-- Interactive trust prompt. R2: caller must NOT invoke this in +-- one-shot (-p) mode — io.read or rl.readline would consume piped +-- stdin. Returns true on user accept (and persists the trust). +-- Per A8, uses rl.readline; if it misbehaves at this early call +-- site, the function returns false (skip overlay) rather than +-- falling back to io.read. +local function _check_and_maybe_prompt(project_path, history) + local sha = history._sha256_file(project_path) + if not sha then + io.stderr:write("aish: project config " .. project_path + .. ": sha256 failed; skipping\n") + return false + end + local tpath = _trust_file_path() + if history.is_trusted(tpath, project_path, sha) then + return true + end + -- Trust prompt. + io.stderr:write("aish: project config found: " .. project_path .. "\n") + io.stderr:write("aish: UNTRUSTED. Loading it runs arbitrary Lua code.\n") + local rl_ok, rl = pcall(require, "ffi.readline") + if not rl_ok then + io.stderr:write("aish: readline unavailable; declining trust prompt\n") + return false + end + 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 + +-- Wrap load_config with a project-overlay step. Always-on (no +-- config flag); overlay activates only when a trusted .aish.lua +-- is found in/above cwd. In one-shot (-p) mode the trust prompt +-- is SKIPPED to avoid io consuming piped stdin (R2) — only pre- +-- trusted overlays load in -p. +local function load_config_with_overlay(opts) + 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 + user_cfg._sources = sources + return user_cfg, user_path, nil + end + + local history_ok, history = pcall(require, "history") + if not history_ok then + user_cfg._sources = sources + return user_cfg, user_path, nil + end + + -- R2: skip trust prompt in -p mode. + local trusted + if opts.prompt then + local sha = history._sha256_file(proj_path) + local tpath = _trust_file_path() + trusted = sha and history.is_trusted(tpath, proj_path, sha) + if not trusted then + io.stderr:write("aish: project config " .. proj_path + .. " skipped in -p mode (untrusted; run aish interactively to trust)\n") + end + else + trusted = _check_and_maybe_prompt(proj_path, history) + end + if not trusted then + user_cfg._sources = sources + return user_cfg, user_path, nil + end + + local ok, proj_cfg = pcall(dofile, proj_path) + if not ok or type(proj_cfg) ~= "table" then + io.stderr:write("aish: project config " .. proj_path + .. " load failed: " .. tostring(proj_cfg) .. "\n") + user_cfg._sources = sources + return user_cfg, user_path, nil + end + -- Shallow merge: project replaces user at top level. Update sources map. + for k, v in pairs(proj_cfg) do + user_cfg[k] = v + sources[k] = "project" + end + user_cfg._sources = sources + return user_cfg, user_path, proj_path +end + -- One-shot mode: read non-TTY stdin (if any), compose prompt, stream -- broker reply to stdout, exit. Bypasses repl.lua entirely — no REPL, -- no MCP, no tool loop, no Norris. The model's reply is printed @@ -122,8 +248,12 @@ local function main(argv) local opts = parse_args(argv or {}) if opts.help then io.write(USAGE); return end - local config, config_path = load_config(opts) + local config, config_path, project_path = load_config_with_overlay(opts) io.stderr:write(("aish: loaded config from %s\n"):format(config_path)) + if project_path then + io.stderr:write(("aish: project config: %s (overlaid on %s)\n") + :format(project_path, config_path)) + end if opts.prompt then run_one_shot(config, opts.prompt)