/** * The init choice for a directory, stored **outside** it: its grant and, in v2, explicit project-ledger * consent. * * `PI_DADDY_GRANT` and `PI_DADDY_LEDGER` remain the propagation channel to children — a parent writes them * and every child inherits them. The store is a root-session source only, so an operator does not have to * `source` a file and restart pi after making the project choice. * * **Outside the workspace, and that is the whole design.** A grant is a ceiling; a ceiling a governed child * can rewrite is not a ceiling. `/.pi/pi-daddy/settings.json` is writable by any child holding `tool:write`, so * storing the live grant there would let a child widen the *next* session's ceiling — ADR-0014's * self-defeating case verbatim, which is why persisted approvals were moved out of the workspace in the * first place. This reuses that pattern exactly: `$PI_CODING_AGENT_DIR/pi-daddy/grants/-.json`, keyed by * the directory, unwritable by a narrowed child because a narrowed child holds no write access to `$HOME`. * * **It does not defend against a child holding `bash`** (ADR-0012). Nothing here does. * * `.pi/pi-daddy/settings.json` is written by `init` and is worth committing — it is the *reviewable record* * of the decision, diffable in a PR. It is not the thing the enforcer reads. */ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { basename, dirname, join, resolve } from "node:path"; import { withFileLock, LockTimeoutError } from "./file-lock.ts"; import type { Capability } from "../kernel/resolve.ts"; import { grantStorePath as userGrantStorePath, projectLedgerPath as projectLedgerFile, agentDir as resolveAgentDir, } from "../kernel/project-paths.ts"; interface GrantFileV1 { version: 1; /** The directory this grant is for. Checked on read — an entry copied elsewhere is refused (R-27). */ cwd: string; grant: Capability[]; /** When `/grants init` wrote it. Informational; nothing expires. */ writtenAt: string; } interface GrantFileV2 { version: 2; cwd: string; grant: Capability[]; /** Explicit consent from ADR-0037's `/grants init`; never inferred from a legacy file merely existing. */ projectLedger: true; writtenAt: string; } export interface StoredGrant { grant: Capability[]; /** Whether a root session defaults to `/.pi/pi-daddy/grants.jsonl`. False for every legacy v1 store. */ projectLedger: boolean; } export type GrantStoreRefusalReason = "malformed" | "unsupported-version" | "unreadable" | "wrong-cwd"; export type StoredGrantState = { state: "absent" } | { state: "valid"; stored: StoredGrant } | { state: "refuse"; reason: GrantStoreRefusalReason }; export interface SaveGrantOptions { projectLedger?: boolean; } /** `$PI_CODING_AGENT_DIR`, or pi's default. Same resolution as the approval store. */ function agentDir(): string { return resolveAgentDir(); } /** * Where this directory's grant lives. * * Slug plus a 64-bit hash, exactly as `approvalsPath` does it and for the same two reasons: the basename * keeps the directory legible to a human reading it, and the hash is what makes it unambiguous, since two * checkouts can share a basename. */ export function grantStorePath(cwd: string): string { return userGrantStorePath(cwd); // `/pi-daddy/grants/-<16 hex>.json` since ADR-0076 PR 3c } /** * Parse a store file's text into its versioned project choice, or null. * * Split out from the readers so the **one** validation lives in one place: both the sync and async paths * must agree, and two parsers is how they come to disagree. * * Rejects every doubt at this parser boundary — it never constructs a partial ceiling. The session currently * treats absent and invalid stores alike (R-175), so do not call that wider behavior fail-closed. The `cwd` * check is R-27's: a copied file describes a directory nobody authorised it for. */ export function parseStoredGrantState(text: string, cwd: string): StoredGrantState { let parsed: { version?: unknown; cwd?: unknown; grant?: unknown; projectLedger?: unknown }; try { parsed = JSON.parse(text) as typeof parsed; } catch { return { state: "refuse", reason: "malformed" }; } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return { state: "refuse", reason: "malformed" }; } if (parsed.version !== 1 && parsed.version !== 2) { return { state: "refuse", reason: "unsupported-version" }; } if (parsed.cwd !== cwd) return { state: "refuse", reason: "wrong-cwd" }; if (!Array.isArray(parsed.grant)) return { state: "refuse", reason: "malformed" }; if (!parsed.grant.every((c) => typeof c === "string" && c.length > 0)) { return { state: "refuse", reason: "malformed" }; } if (parsed.version === 2 && parsed.projectLedger !== true) { return { state: "refuse", reason: "malformed" }; } return { state: "valid", stored: { grant: parsed.grant as Capability[], projectLedger: parsed.version === 2 }, }; } export function parseStoredGrant(text: string, cwd: string): StoredGrant | null { const result = parseStoredGrantState(text, cwd); return result.state === "valid" ? result.stored : null; } /** Compatibility view for callers that need only the capability ceiling. */ export function parseGrantFile(text: string, cwd: string): Capability[] | null { return parseStoredGrant(text, cwd)?.grant ?? null; } /** * Read this directory's stored grant, synchronously. * * **Sync on purpose, and this is the constraint that shapes the feature.** Whether `delegate` is registered * at all is decided when the extension factory runs (S-5: a session without `tool:delegate` must not be * offered the tool), and that is before any `await` is possible. An async read would resolve after the * decision it exists to inform, so the store would silently fail to grant delegation — the exact class of * defect R-38 and R-39 were. */ export function loadStoredGrantStateSync(cwd: string): StoredGrantState { try { return parseStoredGrantState(readFileSync(grantStorePath(cwd), "utf8"), cwd); } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? { state: "absent" } : { state: "refuse", reason: "unreadable" }; } } export function loadStoredGrantSync(cwd: string): StoredGrant | null { const result = loadStoredGrantStateSync(cwd); return result.state === "valid" ? result.stored : null; } export function loadGrantSync(cwd: string): Capability[] | null { return loadStoredGrantSync(cwd)?.grant ?? null; } /** Async twin, for callers that already have one. Same parser, so they cannot disagree. */ export async function loadStoredGrantState(cwd: string): Promise { try { return parseStoredGrantState(await readFile(grantStorePath(cwd), "utf8"), cwd); } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? { state: "absent" } : { state: "refuse", reason: "unreadable" }; } } export async function loadStoredGrant(cwd: string): Promise { const result = await loadStoredGrantState(cwd); return result.state === "valid" ? result.stored : null; } export async function loadGrant(cwd: string): Promise { return (await loadStoredGrant(cwd))?.grant ?? null; } /** The one project-local default ADR-0037 binds to an explicit v2 init choice. */ export function projectLedgerPath(cwd: string): string { return projectLedgerFile(cwd); } export type SaveOutcome = "saved" | "failed" | "busy"; /** * Write this directory's grant and optional project-ledger consent as one atomic choice. * * Locked with the same lock the ledger and the approval store use, for the same reason: two sessions in one * directory running `/grants init` concurrently must not interleave. A lock this cannot take yields `busy` * and changes nothing — the caller reports it and the operator retries, which is the honest outcome for a * write that never looked at the file (R-68). * * `wx` on the temp file refuses to follow a pre-existing symlink, and `rename` is atomic within a * filesystem, so a reader never sees a half-written grant. Both copied from `writeFileSafely`, deliberately * — a second, subtly different atomic-write is how the two come to disagree about what "safe" meant. */ export async function saveGrant( cwd: string, grant: Capability[], options: SaveGrantOptions = {}, ): Promise { const path = grantStorePath(cwd); const common = { cwd, grant: [...grant].sort(), writtenAt: new Date().toISOString() }; const file: GrantFileV1 | GrantFileV2 = options.projectLedger ? { version: 2, ...common, projectLedger: true } : { version: 1, ...common }; try { await mkdir(dirname(path), { recursive: true }); return await withFileLock(path, "grant store", async () => { const temp = `${path}.${process.pid}.${randomUUID()}.tmp`; try { await writeFile(temp, `${JSON.stringify(file, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); await rename(temp, path); return "saved" as const; } catch { await unlink(temp).catch(() => undefined); return "failed" as const; } }); } catch (error) { return error instanceof LockTimeoutError ? "busy" : "failed"; } } /** Remove this directory's stored grant. True when a file was there to remove. */ export async function clearGrant(cwd: string): Promise { const path = grantStorePath(cwd); try { await readFile(path, "utf8"); } catch { return false; } await rm(path, { force: true }).catch(() => undefined); return true; }