main: project-overlay loader (Phase 9 commit #2)
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: <path> (overlaid on
<user>)" 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user