mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
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>
114 lines
5.1 KiB
TypeScript
114 lines
5.1 KiB
TypeScript
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join, resolve } from 'path';
|
|
import { spawnSync } from 'child_process';
|
|
|
|
const ROOT = resolve(import.meta.dir, '..');
|
|
const COMMAND = join(ROOT, 'bin', 'gstack-memorable');
|
|
const HOOK = join(ROOT, 'hosts', 'claude', 'hooks', 'memorable-user-prompt-hook');
|
|
const homes: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true });
|
|
});
|
|
|
|
function fixture() {
|
|
const home = mkdtempSync(join(tmpdir(), 'gstack-memorable-'));
|
|
homes.push(home);
|
|
const claude = join(home, '.claude');
|
|
mkdirSync(claude, { recursive: true });
|
|
const settings = join(claude, 'settings.json');
|
|
const log = join(home, 'calls.log');
|
|
const fake = join(home, 'memorable');
|
|
writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${log}"\nif [ "$1" = hook ]; then printf '%s' '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"remembered"}}'; fi\n`);
|
|
chmodSync(fake, 0o700);
|
|
return { home, settings, log, fake };
|
|
}
|
|
|
|
function envFor(f: ReturnType<typeof fixture>) {
|
|
return {
|
|
...process.env,
|
|
HOME: f.home,
|
|
GSTACK_SETTINGS_FILE: f.settings,
|
|
MEMORABLE_BIN: f.fake,
|
|
};
|
|
}
|
|
|
|
describe('gstack-memorable', () => {
|
|
test('enable registers the hook; disable removes it without deleting foreign hooks', () => {
|
|
const f = fixture();
|
|
writeFileSync(f.settings, JSON.stringify({
|
|
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/foreign/hook' }] }] },
|
|
}));
|
|
|
|
const enabled = spawnSync(COMMAND, ['enable'], { env: envFor(f), encoding: 'utf8' });
|
|
expect(enabled.status).toBe(0);
|
|
expect(readFileSync(f.log, 'utf8')).toContain('enable');
|
|
let settings = JSON.parse(readFileSync(f.settings, 'utf8'));
|
|
const commands = settings.hooks.UserPromptSubmit.flatMap((e: any) => e.hooks.map((h: any) => h.command));
|
|
expect(commands).toContain('/foreign/hook');
|
|
expect(commands).toContain(HOOK);
|
|
|
|
const disabled = spawnSync(COMMAND, ['disable'], { env: envFor(f), encoding: 'utf8' });
|
|
expect(disabled.status).toBe(0);
|
|
expect(readFileSync(f.log, 'utf8')).toContain('disable');
|
|
settings = JSON.parse(readFileSync(f.settings, 'utf8'));
|
|
const remaining = settings.hooks.UserPromptSubmit.flatMap((e: any) => e.hooks.map((h: any) => h.command));
|
|
expect(remaining).toEqual(['/foreign/hook']);
|
|
});
|
|
|
|
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
|
|
// the documented way to install the CLI. Registering ours beside it runs
|
|
// the command twice per prompt: injected twice, captured twice against the
|
|
// user's own allowance.
|
|
const f = fixture();
|
|
const theirs = `"${join(f.home, '.memorable', 'bin', 'memorable')}" hook user-prompt`;
|
|
writeFileSync(f.settings, JSON.stringify({
|
|
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: theirs }] }] },
|
|
}));
|
|
|
|
const enabled = spawnSync(COMMAND, ['enable'], { env: envFor(f), encoding: 'utf8' });
|
|
expect(enabled.status).not.toBe(0);
|
|
expect(enabled.stderr).toContain('already registers this hook itself');
|
|
// It refused before doing anything: no consent recorded, settings untouched.
|
|
expect(existsSync(f.log)).toBe(false);
|
|
const after = JSON.parse(readFileSync(f.settings, 'utf8'));
|
|
const commands = after.hooks.UserPromptSubmit.flatMap((e: any) => e.hooks.map((h: any) => h.command));
|
|
expect(commands).toEqual([theirs]);
|
|
});
|
|
|
|
test('status names Memorable\'s own registration rather than reporting none', () => {
|
|
const f = fixture();
|
|
const theirs = `"${join(f.home, '.memorable', 'bin', 'memorable')}" hook user-prompt`;
|
|
writeFileSync(f.settings, JSON.stringify({
|
|
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: theirs }] }] },
|
|
}));
|
|
|
|
const status = spawnSync(COMMAND, ['status'], { env: envFor(f), encoding: 'utf8' });
|
|
expect(status.status).toBe(0);
|
|
expect(status.stdout).toContain('registered by Memorable itself');
|
|
expect(status.stdout).not.toContain('not registered');
|
|
});
|
|
|
|
test('a foreign UserPromptSubmit hook is not mistaken for Memorable\'s', () => {
|
|
const f = fixture();
|
|
writeFileSync(f.settings, JSON.stringify({
|
|
hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/foreign/hook' }] }] },
|
|
}));
|
|
const enabled = spawnSync(COMMAND, ['enable'], { env: envFor(f), encoding: 'utf8' });
|
|
expect(enabled.status).toBe(0);
|
|
});
|
|
|
|
test('status is read-only and reports both dependencies', () => {
|
|
const f = fixture();
|
|
const status = spawnSync(COMMAND, ['status'], { env: envFor(f), encoding: 'utf8' });
|
|
expect(status.status).toBe(0);
|
|
expect(status.stdout).toContain('Memorable CLI: available');
|
|
expect(status.stdout).toContain('Claude UserPromptSubmit hook: not registered');
|
|
expect(existsSync(f.log)).toBe(false);
|
|
});
|
|
});
|