history: trust file helpers for Phase 9 (commit #1)

Foundation for the project-overlay trust mechanism. No callers yet —
commit #2 wires main.lua to use these.

Three new functions:

  history._sha256_file(path) -> hex digest or nil
    Shells `sha256sum`; parses first whitespace-separated field;
    validates 64-hex-char length. nil on any failure (path missing,
    binary missing, file unreadable). Caller treats nil as "skip
    the trust path" — never crashes.

  history.is_trusted(trust_path, project_path, sha256) -> bool
    Reads trust_path as JSONL; returns true iff an entry exists
    matching BOTH project_path AND sha256. Missing / corrupt /
    unreadable trust file -> false (re-prompt). Per-line JSON
    decode means partial-write corruption affects at most one line.

  history.add_trusted(trust_path, project_path, sha256) -> bool
    mkdir -p parent; append JSONL line {path, sha256, ts (ISO)};
    chmod 600 the trust file (best-effort; ignore failure). Single
    writer per call; append-only.

11 unit cases verified:
  - sha256 known value matches manual `sha256sum`
  - nil / missing-file -> nil (no crash)
  - is_trusted on missing trust file -> false
  - add_trusted + is_trusted roundtrip works
  - Different sha -> not trusted (content-binding)
  - Different path -> not trusted
  - Multi-entry trust file: each entry independently checked
  - chmod 600 verified via stat

Regression: test_safety 87/87, test_router_model 31/31.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 23:45:07 +00:00
parent e796142a23
commit e525063df3
+58
View File
@@ -309,4 +309,62 @@ function M.load_memory(path)
return active
end
-- ---------------------------------------------------------------- Phase 9 trust file
-- ~/.aish/trusted-projects (JSONL, mode 0600). One entry per accepted
-- project .aish.lua. Schema: {path = "<abs>", sha256 = "<hex>",
-- ts = "<iso>"}. sha256 binds bytes; content change re-prompts.
-- Internal helper: shell out to `sha256sum`. Returns hex digest or nil
-- on any failure (binary missing, file unreadable, etc.). Caller
-- treats nil as "skip the trust path" rather than crashing.
function M._sha256_file(path)
if not path or path == "" then return nil end
local q = "'" .. path:gsub("'", [['\'']]) .. "'"
local pipe = io.popen("sha256sum " .. q .. " 2>/dev/null")
if not pipe then return nil end
local line = pipe:read("*l")
pipe:close()
if not line then return nil end
local digest = line:match("^(%x+)") -- first whitespace-separated field
if digest and #digest == 64 then return digest end
return nil
end
-- Returns true iff a JSONL entry exists at trust_path matching BOTH
-- project_path AND sha256. Missing / unreadable / corrupt-line file
-- treated as "not trusted".
function M.is_trusted(trust_path, project_path, sha256)
if not (trust_path and project_path and sha256) then return false end
local fh = io.open(trust_path, "r")
if not fh then return false end
for line in fh:lines() do
if #line > 0 then
local entry = json.decode(line)
if entry and entry.path == project_path
and entry.sha256 == sha256 then
fh:close()
return true
end
end
end
fh:close()
return false
end
-- Appends a trust record. mkdir -p parent; chmod 0600 on first creation.
-- Append-only JSONL; partial writes corrupt at most one line (caller's
-- subsequent reads skip them).
function M.add_trusted(trust_path, project_path, sha256)
if not (trust_path and project_path and sha256) then return false end
ensure_dir(parent_dir(trust_path))
local fh = io.open(trust_path, "a")
if not fh then return false end
local ts = os.date("!%Y-%m-%dT%H:%M:%SZ")
fh:write(json.encode({ path = project_path, sha256 = sha256, ts = ts }) .. "\n")
fh:close()
-- Best-effort chmod 0600; ignore failure (next read will succeed).
os.execute("chmod 600 '" .. trust_path:gsub("'", [['\'']]) .. "' 2>/dev/null")
return true
end
return M