feat(hooks): memorable-user-prompt-hook.ts — consent gate, deny veto, HIGH-tier pre-scan, fail-closed receipt, trust envelope; runExternal in spawn-bin

The PR's hook exec'd the vendor binary with the full environment and
passed its stdout to Claude verbatim. It is now the house pattern: a
fail-open bash shim over a .ts twin that (1) gates on the memorable_recall
consent key, (2) skips repos whose trust policy is deny or read-only,
(3) scans the prompt (raw bytes and decoded string leaves) and refuses to
hand over a HIGH-tier credential shape, (4) writes a fail-closed egress
receipt naming the local executable it ran, (5) spawns the vendor in its
own process group with an allowlisted environment and group-kills it on
timeout, (6) accepts only a string additionalContext back, caps it at
8 KiB on a UTF-8 boundary and wraps it in the trust envelope, and (7)
records an `output-written` outcome after the stdout write completes.
One deadline clock (4.5 s) undercuts Claude Code's 5 s kill and bounds
both ledger writes through the new lockBudgetMs option on
writeReceipt/writeOutcome (default unchanged).

spawn-bin gains runExternal for external executables (detached group,
stderr drained, stdin EPIPE handled, stdout capped, win32 refused).
The wiring test pins the sink fail-closed and sweeps hosts/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-08 17:46:52 +00:00
co-authored by Claude Fable 5.1
parent 3034769813
commit d4dbeb6d42
9 changed files with 933 additions and 45 deletions
+28 -5
View File
@@ -72,6 +72,13 @@ export interface WriteReceiptOptions {
sha256?: string | null;
/** the consent key+value that authorizes this send */
consent: string;
/**
* Max milliseconds to wait for the ledger lock (default LEDGER_LOCK_BUDGET_MS).
* Callers on an interactive hot path with their own deadline (a Claude Code
* hook under a 5 s kill) pass what they can afford; the lock is always
* tried at least once.
*/
lockBudgetMs?: number;
}
export interface WriteOutcomeOptions {
@@ -80,8 +87,13 @@ export interface WriteOutcomeOptions {
/** receipt id returned by writeReceipt */
receipt: string;
status?: string | number;
/** see WriteReceiptOptions.lockBudgetMs */
lockBudgetMs?: number;
}
/** Default spin budget for the ledger lock. Egress events are rare (minutes apart). */
export const LEDGER_LOCK_BUDGET_MS = 2500;
export interface LedgerLine {
lineNo: number;
raw: string;
@@ -148,9 +160,9 @@ function requireString(value: unknown, name: string): string {
* retries instead of failing. The rmdir/stat races with a concurrent
* reclaimer or the owner's own cleanup are harmless — losers just loop.
*/
function withLedgerLock<T>(ledger: string, callback: () => T): T {
function withLedgerLock<T>(ledger: string, callback: () => T, budgetMs: number = LEDGER_LOCK_BUDGET_MS): T {
const lock = `${ledger}.lock`;
const deadline = Date.now() + 2500;
const deadline = Date.now() + Math.max(0, budgetMs);
for (;;) {
try {
fs.mkdirSync(lock);
@@ -240,10 +252,19 @@ export function ledgerSizeWarning(ledger: string, size: number): string {
);
}
function lockBudget(value: number | undefined): number {
if (value === undefined) return LEDGER_LOCK_BUDGET_MS;
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
throw receiptError('Egress receipt lockBudgetMs must be a non-negative finite number');
}
return value;
}
function appendChained(
homeOrNull: string | null,
record: Record<string, unknown>,
env?: Env,
budgetMs: number = LEDGER_LOCK_BUDGET_MS,
): { id: string; path: string } {
const home = homeOrNull ?? resolveEgressHome(env);
const ledger = egressLedgerPath(home);
@@ -256,7 +277,7 @@ function appendChained(
fs.appendFileSync(ledger, `${line}\n`, { mode: 0o600 });
if (!existed) fs.chmodSync(ledger, 0o600); // umask must not weaken the ledger
return { id: sha256Hex(line), path: ledger };
});
}, budgetMs);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === EGRESS_RECEIPT_FAILED) throw error;
throw receiptError(
@@ -282,6 +303,7 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
if (!Number.isSafeInteger(bytes) || bytes < 0) throw receiptError('Egress receipt bytes must be a non-negative integer');
const sha256 = opts.sha256 ?? null;
if (sha256 !== null && !SHA256_HEX.test(String(sha256))) throw receiptError('Egress receipt sha256 must be 64 lowercase hex chars or null');
const budgetMs = lockBudget(opts.lockBudgetMs);
const home = opts.home ?? resolveEgressHome(opts.env);
warnLedgerSizeOnce(egressLedgerPath(home));
return appendChained(home, {
@@ -293,7 +315,7 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
bytes,
sha256,
consent,
}, opts.env);
}, opts.env, budgetMs);
}
/**
@@ -303,12 +325,13 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
*/
export function writeOutcome(opts: WriteOutcomeOptions): { id: string; path: string } {
const receipt = requireString(opts.receipt, 'receipt id');
const budgetMs = lockBudget(opts.lockBudgetMs);
return appendChained(opts.home ?? null, {
ts: new Date().toISOString(),
type: 'outcome',
receipt,
status: String(opts.status ?? 'unknown'),
}, opts.env);
}, opts.env, budgetMs);
}
/** Raw parsed lines: [{lineNo, raw, record|null}]. Missing ledger → []. */