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
+20 -1
View File
@@ -52,6 +52,11 @@ const POLARITY: Record<string, 'fail-closed' | 'fail-open'> = {
'browse-tunnel (ngrok)': 'fail-closed',
'gbrain-mcp-verify': 'fail-closed',
'supabase-provision': 'fail-closed',
// memorable-recall: a Claude Code hook hands the user's prompt JSON to a
// third-party binary on every prompt. Skipping one recall costs nothing;
// an unrecorded hand-off of user content is the thing the ledger exists to
// prevent, so it fails closed ("no receipt, no send").
'memorable-recall': 'fail-closed',
// fail-open: user-facing operations that must not die over an audit-log
// hiccup; they warn on stderr and proceed.
'design-openai': 'fail-open',
@@ -81,6 +86,10 @@ const MODULE_SINKS = [
// supabase-provision engine (bin/gstack-gbrain-supabase-provision is a thin
// bun-shebang entry over this module; the receipt lives at the api-call layer).
'lib/gbrain-supabase-provision.ts',
// The Memorable bridge hook: gstack-owned code that hands each prompt to a
// vendor CLI. hosts/ has no curl/fetch for the scanner to see, so the
// receipt wiring is pinned here explicitly.
'hosts/claude/hooks/memorable-user-prompt-hook.ts',
];
/** Shell sinks: must source the shared lib; every network op receipted. */
@@ -317,6 +326,7 @@ describe('egress receipt wiring tripwire', () => {
'browse-tunnel (ngrok)',
'gbrain-mcp-verify',
'gbrain-sync',
'memorable-recall',
'memory-ingest',
'supabase-provision',
'telemetry-sync',
@@ -350,6 +360,15 @@ describe('egress receipt wiring tripwire', () => {
expect(provision).toContain('fail-closed');
expect(provision.indexOf('writeReceipt(')).toBeGreaterThan(0);
expect(provision.indexOf('writeReceipt(')).toBeLessThan(provision.indexOf('ctx.fetchImpl('));
// memorable-recall (closed): the hook's receipt precedes the vendor spawn
// (marker-based: the policy lookup spawns git earlier, so plain
// `runExternal(` order would be the wrong thing to pin) and a receipt
// failure skips the vendor. The behavioural proof lives in
// test/memorable-user-prompt-hook.test.ts.
const memo = read('hosts/claude/hooks/memorable-user-prompt-hook.ts');
expect(memo).toContain('fail-closed');
expect(memo.indexOf('writeReceipt(')).toBeGreaterThan(0);
expect(memo.indexOf('writeReceipt(')).toBeLessThan(memo.indexOf('// VENDOR SPAWN'));
// design (open): the wrapper catches receipt errors and proceeds.
const rf = read('design/src/receipted-fetch.ts');
expect(rf).toContain('fail-open');
@@ -357,7 +376,7 @@ describe('egress receipt wiring tripwire', () => {
});
test('NEW-SINK SCANNER: every outbound network op in the tree is wired or reasoned-exempt', () => {
const SWEEP = ['bin', 'lib', 'scripts', 'design/src', 'browse/src'];
const SWEEP = ['bin', 'lib', 'scripts', 'design/src', 'browse/src', 'hosts'];
const offenders: string[] = [];
for (const dirRel of SWEEP) {
const dir = path.join(ROOT, dirRel);