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
+24
View File
@@ -149,6 +149,30 @@ describe('egress receipt library', () => {
}
}, 15_000);
test('lockBudgetMs bounds the lock wait: a held lock fails closed within the budget instead of the 2.5 s default', () => {
const ledger = egressLedgerPath(home);
fs.mkdirSync(path.dirname(ledger), { recursive: true });
fs.mkdirSync(`${ledger}.lock`); // fresh mtime: not reclaimable as stale
const t0 = Date.now();
expect(() => writeReceipt({
home, sink: 's', host: 'h', payloadClass: 'p', consent: 'c', lockBudgetMs: 150,
})).toThrow(/locked/);
const elapsed = Date.now() - t0;
expect(elapsed).toBeGreaterThanOrEqual(100);
expect(elapsed).toBeLessThan(1500);
fs.rmdirSync(`${ledger}.lock`);
// the default still applies when the option is omitted (the lock is free now, so this succeeds)
const { id } = writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'p', consent: 'c' });
expect(() => writeOutcome({ home, receipt: id, status: 'exit:0', lockBudgetMs: 0 })).not.toThrow();
expect(verifyLedger(home).ok).toBe(true);
});
test('lockBudgetMs rejects garbage before touching the ledger', () => {
expect(() => writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'p', consent: 'c', lockBudgetMs: -1 })).toThrow(/lockBudgetMs/);
expect(() => writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'p', consent: 'c', lockBudgetMs: Number.NaN })).toThrow(/lockBudgetMs/);
expect(fs.existsSync(egressLedgerPath(home))).toBe(false);
});
test('tail-read: last line is found correctly on a multi-record ledger larger than the tail window', () => {
// 30 records ≈ 9KB > the 4KB tail window, so the append path must find
// the true last line from a partial read.