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)