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
-15
View File
@@ -58,21 +58,6 @@ describe('gstack-memorable', () => {
expect(remaining).toEqual(['/foreign/hook']);
});
test('hook delegates stdin/stdout and fails open when Memorable is unavailable', () => {
const f = fixture();
const payload = '{"session_id":"s1","prompt":"repeat the task"}';
const delegated = spawnSync(HOOK, [], { env: envFor(f), input: payload, encoding: 'utf8' });
expect(delegated.status).toBe(0);
expect(delegated.stdout).toContain('"additionalContext":"remembered"');
expect(readFileSync(f.log, 'utf8')).toContain('hook user-prompt');
const missingEnv = { ...process.env, HOME: f.home, MEMORABLE_BIN: join(f.home, 'missing') };
const missing = spawnSync(HOOK, [], { env: missingEnv, input: payload, encoding: 'utf8' });
expect(missing.status).toBe(0);
expect(missing.stdout).toBe('');
expect(missing.stderr).toBe('');
});
test('enable refuses when Memorable already registered the hook itself', () => {
// Memorable's own installer (`memorable start`, `setup`, `install-hooks`)
// writes this same UserPromptSubmit hook under its own name, and that is