mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-17 18:32:19 +02:00
test: group kill on clean close, walk exhaustion refused, GIT_DIR cannot bypass the deny, unmatched-brace banner, whole-tail scan, empty-override parity, uninstall revocation without the hook manager
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
51713f6c70
commit
b936b6b167
@@ -19,6 +19,9 @@
|
|||||||
* echo-stderr copy the prompt to stderr, exit 1 (the log must withhold it)
|
* echo-stderr copy the prompt to stderr, exit 1 (the log must withhold it)
|
||||||
* bg-then-exit start a background sleeper holding the pipes, print
|
* bg-then-exit start a background sleeper holding the pipes, print
|
||||||
* out.json, exit 0 (must resolve on exit, not on close)
|
* out.json, exit 0 (must resolve on exit, not on close)
|
||||||
|
* bg-detached-exit start a background sleeper with its stdio redirected,
|
||||||
|
* print out.json, exit 0 (close fires; the sleeper must
|
||||||
|
* still die with the group)
|
||||||
*
|
*
|
||||||
* Every spawn pins HOME, GSTACK_HOME, GSTACK_STATE_ROOT, GSTACK_STATE_DIR and
|
* Every spawn pins HOME, GSTACK_HOME, GSTACK_STATE_ROOT, GSTACK_STATE_DIR and
|
||||||
* GSTACK_MEMORABLE_BIN into a fresh temp dir, so nothing reaches the real
|
* GSTACK_MEMORABLE_BIN into a fresh temp dir, so nothing reaches the real
|
||||||
@@ -31,8 +34,8 @@ import * as os from 'os';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { listReceipts, sha256Hex, verifyLedger } from '../lib/egress-receipt';
|
import { listReceipts, sha256Hex, verifyLedger } from '../lib/egress-receipt';
|
||||||
import {
|
import {
|
||||||
budgetFor, budgetMs, capUtf8, firstJsonObject, logHookError, pickAdditionalContext, renderContext, resolveVendor, safeStderrTail,
|
budgetFor, budgetMs, capUtf8, firstJsonObject, gitEnv, logHookError, pickAdditionalContext, renderContext, resolveVendor, safeStderrTail,
|
||||||
stringLeaves, stripControl, vendorEnv,
|
stringLeaves, stringLeavesBounded, stripControl, vendorEnv,
|
||||||
BUDGET_MS, LOG_RATE_LIMIT_MS, OUTPUT_CAP_BYTES, ENVELOPE_SOURCE,
|
BUDGET_MS, LOG_RATE_LIMIT_MS, OUTPUT_CAP_BYTES, ENVELOPE_SOURCE,
|
||||||
} from '../hosts/claude/hooks/memorable-user-prompt-hook.ts';
|
} from '../hosts/claude/hooks/memorable-user-prompt-hook.ts';
|
||||||
import { runExternal } from '../hosts/claude/hooks/spawn-bin';
|
import { runExternal } from '../hosts/claude/hooks/spawn-bin';
|
||||||
@@ -54,6 +57,7 @@ case "$MODE" in
|
|||||||
sleep) sleep "10.\${MEMORABLE_TEST_NONCE:-0}" ;;
|
sleep) sleep "10.\${MEMORABLE_TEST_NONCE:-0}" ;;
|
||||||
fork-sleep) sh -c "sleep 30.\${MEMORABLE_TEST_NONCE:-0}" ;;
|
fork-sleep) sh -c "sleep 30.\${MEMORABLE_TEST_NONCE:-0}" ;;
|
||||||
bg-then-exit) sh -c "sleep 20.\${MEMORABLE_TEST_NONCE:-0}" & cat "$HOME/out.json"; exit 0 ;;
|
bg-then-exit) sh -c "sleep 20.\${MEMORABLE_TEST_NONCE:-0}" & cat "$HOME/out.json"; exit 0 ;;
|
||||||
|
bg-detached-exit) sh -c "sleep 22.\${MEMORABLE_TEST_NONCE:-0}" </dev/null >/dev/null 2>&1 & cat "$HOME/out.json"; exit 0 ;;
|
||||||
echo-stderr) cat "$HOME/stdin.bin" >&2; exit 1 ;;
|
echo-stderr) cat "$HOME/stdin.bin" >&2; exit 1 ;;
|
||||||
exit1) echo "vendor said no" >&2; exit 1 ;;
|
exit1) echo "vendor said no" >&2; exit 1 ;;
|
||||||
flood) head -c 2097152 /dev/zero | tr '\\0' a ;;
|
flood) head -c 2097152 /dev/zero | tr '\\0' a ;;
|
||||||
@@ -298,6 +302,26 @@ describe('what comes back from the vendor', () => {
|
|||||||
expect(survivors).toBe('');
|
expect(survivors).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a vendor that forks a helper with redirected stdio and exits cleanly: answer delivered, helper killed with the group', () => {
|
||||||
|
gateOn();
|
||||||
|
fs.writeFileSync(path.join(home, 'mode'), 'bg-detached-exit');
|
||||||
|
const nonce = `${process.pid}${Date.now()}`;
|
||||||
|
const r = runHook(PROMPT, { MEMORABLE_TEST_NONCE: nonce });
|
||||||
|
expect(r.stdout).toContain('remembered');
|
||||||
|
const survivors = spawnSync('sh', ['-c', `ps -eo args | grep '^sleep 22.${nonce}$' || true`], { encoding: 'utf8', timeout: 10_000 }).stdout.trim();
|
||||||
|
expect(survivors).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a prompt JSON too wide to walk is refused as unscanned, never handed over as clean', () => {
|
||||||
|
gateOn();
|
||||||
|
const wide = JSON.stringify({ prompt: 'hello', pad: Array.from({ length: 12_000 }, (_, i) => i) });
|
||||||
|
const r = runHook(wide);
|
||||||
|
expect(r).toEqual({ status: 0, stdout: '', stderr: '' });
|
||||||
|
expect(calls()).toBe('');
|
||||||
|
expect(fs.existsSync(ledger())).toBe(false);
|
||||||
|
expect(errLog()).toContain('refused:payload-too-complex');
|
||||||
|
});
|
||||||
|
|
||||||
test('a vendor that answers without reading a 300 KB prompt: a stdin EPIPE is advisory, the answer is delivered', () => {
|
test('a vendor that answers without reading a 300 KB prompt: a stdin EPIPE is advisory, the answer is delivered', () => {
|
||||||
gateOn();
|
gateOn();
|
||||||
fs.writeFileSync(path.join(home, 'mode'), 'print-before-read');
|
fs.writeFileSync(path.join(home, 'mode'), 'print-before-read');
|
||||||
@@ -478,6 +502,7 @@ describe('pure helpers', () => {
|
|||||||
expect(pickAdditionalContext(`loaded {3} memories\n${answer}`)).toBe('kept {"}"} braces in strings');
|
expect(pickAdditionalContext(`loaded {3} memories\n${answer}`)).toBe('kept {"}"} braces in strings');
|
||||||
expect(pickAdditionalContext(`warn: "{" unexpected\n${answer}`)).toBe('kept {"}"} braces in strings');
|
expect(pickAdditionalContext(`warn: "{" unexpected\n${answer}`)).toBe('kept {"}"} braces in strings');
|
||||||
expect(pickAdditionalContext(`{"progress": 1}\n${answer}`)).toBe('kept {"}"} braces in strings');
|
expect(pickAdditionalContext(`{"progress": 1}\n${answer}`)).toBe('kept {"}"} braces in strings');
|
||||||
|
expect(pickAdditionalContext(`Loading cache {pending\n${answer}`)).toBe('kept {"}"} braces in strings'); // an unmatched brace before the answer
|
||||||
expect(pickAdditionalContext('{a {a {a {a')).toBeNull();
|
expect(pickAdditionalContext('{a {a {a {a')).toBeNull();
|
||||||
});
|
});
|
||||||
test('stripControl drops C0 controls, CR, DEL and Unicode format characters but keeps tab, newline and ZWJ', () => {
|
test('stripControl drops C0 controls, CR, DEL and Unicode format characters but keeps tab, newline and ZWJ', () => {
|
||||||
@@ -491,6 +516,9 @@ describe('pure helpers', () => {
|
|||||||
expect(safeStderrTail('')).toBe('');
|
expect(safeStderrTail('')).toBe('');
|
||||||
expect(safeStderrTail('could not parse: mail jane.doe@northwind-traders.com')).toMatch(/^\[stderr withheld: \d+ redaction finding/);
|
expect(safeStderrTail('could not parse: mail jane.doe@northwind-traders.com')).toMatch(/^\[stderr withheld: \d+ redaction finding/);
|
||||||
expect(safeStderrTail('key AKIA1234567890ABCDEF rejected')).toMatch(/withheld/);
|
expect(safeStderrTail('key AKIA1234567890ABCDEF rejected')).toMatch(/withheld/);
|
||||||
|
// the scan sees the whole kept tail, so a credential whose prefix would fall outside the 300-char crop is still caught
|
||||||
|
expect(safeStderrTail(`key AKIA1234567890ABCDEF ${'x'.repeat(320)}`)).toMatch(/withheld/);
|
||||||
|
expect(safeStderrTail('y'.repeat(400))).toHaveLength(300);
|
||||||
});
|
});
|
||||||
test('budgetMs honours the test-only override but never widens the budget', () => {
|
test('budgetMs honours the test-only override but never widens the budget', () => {
|
||||||
expect(budgetMs({})).toBe(BUDGET_MS);
|
expect(budgetMs({})).toBe(BUDGET_MS);
|
||||||
@@ -526,6 +554,12 @@ describe('pure helpers', () => {
|
|||||||
});
|
});
|
||||||
test('resolveVendor: explicit override wins, may be quoted, and an unresolvable or non-executable override is null (no fall-through)', () => {
|
test('resolveVendor: explicit override wins, may be quoted, and an unresolvable or non-executable override is null (no fall-through)', () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-resolve-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-resolve-'));
|
||||||
|
// parity with bash's ${GSTACK_MEMORABLE_BIN:-${MEMORABLE_BIN:-}}: an EMPTY first override defers to the second
|
||||||
|
{
|
||||||
|
const exe = path.join(dir, 'via-second'); fs.writeFileSync(exe, '#!/bin/sh\n', { mode: 0o755 });
|
||||||
|
expect(resolveVendor({ GSTACK_MEMORABLE_BIN: '', MEMORABLE_BIN: exe }, dir)).toBe(exe);
|
||||||
|
expect(resolveVendor({ GSTACK_MEMORABLE_BIN: ' ', MEMORABLE_BIN: exe }, dir)).toBe(exe);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const exe = path.join(dir, 'vendor'); fs.writeFileSync(exe, '#!/bin/sh\n', { mode: 0o755 });
|
const exe = path.join(dir, 'vendor'); fs.writeFileSync(exe, '#!/bin/sh\n', { mode: 0o755 });
|
||||||
const plain = path.join(dir, 'plain'); fs.writeFileSync(plain, '#!/bin/sh\n', { mode: 0o644 });
|
const plain = path.join(dir, 'plain'); fs.writeFileSync(plain, '#!/bin/sh\n', { mode: 0o644 });
|
||||||
@@ -541,11 +575,20 @@ describe('pure helpers', () => {
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
test('stringLeaves is bounded', () => {
|
test('stringLeaves is bounded and reports exhaustion', () => {
|
||||||
let deep: unknown = 'leaf';
|
let deep: unknown = 'leaf';
|
||||||
for (let i = 0; i < 100; i++) deep = { d: deep };
|
for (let i = 0; i < 100; i++) deep = { d: deep };
|
||||||
expect(stringLeaves(deep)).toEqual([]); // beyond maxDepth
|
const deepWalk = stringLeavesBounded(deep);
|
||||||
expect(stringLeaves({ a: 'x', b: ['y', { c: 'z' }], n: 1 })).toEqual(['x', 'y', 'z']);
|
expect(deepWalk.exhausted).toBe(true); // beyond maxDepth: the leaf is never reached
|
||||||
|
expect(deepWalk.leaves.every((k) => k === 'd')).toBe(true); // only the keys above the cut
|
||||||
|
expect(stringLeaves(deep)).not.toContain('leaf');
|
||||||
|
expect(stringLeavesBounded({ a: 'x', b: ['y', { c: 'z' }], n: 1 })).toEqual({ leaves: ['a', 'x', 'b', 'y', 'c', 'z', 'n'], exhausted: false });
|
||||||
|
expect(stringLeavesBounded(Array.from({ length: 20_000 }, () => 1)).exhausted).toBe(true);
|
||||||
|
expect(stringLeaves({ 'AKIA-in-a-key': 1 })).toEqual(['AKIA-in-a-key']); // keys are forwarded bytes too
|
||||||
|
});
|
||||||
|
test('gitEnv drops every inherited GIT_* selector and forces English messages', () => {
|
||||||
|
const e = gitEnv({ PATH: '/bin', GIT_DIR: '/elsewhere/.git', GIT_WORK_TREE: '/elsewhere', GIT_CONFIG_COUNT: '1', HOME: '/h' });
|
||||||
|
expect(e).toEqual({ PATH: '/bin', HOME: '/h', LC_ALL: 'C', LANGUAGE: '', LC_MESSAGES: 'C' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -681,6 +724,26 @@ describe('trust-policy lookup outcomes (review coverage, second pass)', () => {
|
|||||||
fs.rmSync(repo, { recursive: true, force: true });
|
fs.rmSync(repo, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
test('an inherited GIT_DIR pointing at an allowed repo does not bypass the deny on the session repo', () => {
|
||||||
|
gateOn();
|
||||||
|
const denied = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-denied-'));
|
||||||
|
const allowed = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-allowed-'));
|
||||||
|
try {
|
||||||
|
for (const [dir, url] of [[denied, 'https://github.com/example/denied.git'], [allowed, 'https://github.com/example/allowed.git']] as const) {
|
||||||
|
spawnSync('git', ['init', '-q'], { cwd: dir, timeout: 10_000 });
|
||||||
|
spawnSync('git', ['remote', 'add', 'origin', url], { cwd: dir, timeout: 10_000 });
|
||||||
|
}
|
||||||
|
expect(spawnSync('bash', [POLICY, 'set', 'https://github.com/example/denied.git', 'deny'], { env, encoding: 'utf8', timeout: 20_000 }).status).toBe(0);
|
||||||
|
const r = runHook(JSON.stringify({ prompt: 'hello', cwd: denied }), { GIT_DIR: path.join(allowed, '.git'), GIT_WORK_TREE: allowed }, denied);
|
||||||
|
expect(r.stdout).toBe('');
|
||||||
|
expect(calls()).toBe('');
|
||||||
|
expect(errLog()).toContain('deny or read-only');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(denied, { recursive: true, force: true });
|
||||||
|
fs.rmSync(allowed, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('store present, repository git cannot read (corrupt .git/config): fails closed, nothing spawned', () => {
|
test('store present, repository git cannot read (corrupt .git/config): fails closed, nothing spawned', () => {
|
||||||
gateOn();
|
gateOn();
|
||||||
withStore();
|
withStore();
|
||||||
|
|||||||
@@ -465,6 +465,35 @@ describe('the consent key never outlives the hook, even when the config lives ou
|
|||||||
expect(result.status).toBe(0);
|
expect(result.status).toBe(0);
|
||||||
expect(fs.existsSync(path.join(mockHome, '.gstack'))).toBe(false); // the default state dir went
|
expect(fs.existsSync(path.join(mockHome, '.gstack'))).toBe(false); // the default state dir went
|
||||||
expect(fs.readFileSync(path.join(otherRoot, 'config.yaml'), 'utf-8')).toMatch(/memorable_recall: off/); // the real config did not keep consent
|
expect(fs.readFileSync(path.join(otherRoot, 'config.yaml'), 'utf-8')).toMatch(/memorable_recall: off/); // the real config did not keep consent
|
||||||
|
expect(result.stdout).toContain('memorable_recall consent (set off)');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the consent flip does not depend on the hook manager being present', () => {
|
||||||
|
test('gstack-settings-hook missing from the install: memorable_recall still goes off', () => {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-memo-nohook-'));
|
||||||
|
try {
|
||||||
|
const mockHome = path.join(tmp, 'home');
|
||||||
|
const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack');
|
||||||
|
const installBin = path.join(installRoot, 'bin');
|
||||||
|
fs.mkdirSync(installBin, { recursive: true });
|
||||||
|
for (const b of ['gstack-uninstall', 'gstack-config']) { // no settings hook, no session-update
|
||||||
|
const dst = path.join(installBin, b);
|
||||||
|
fs.copyFileSync(path.join(ROOT, 'bin', b), dst);
|
||||||
|
fs.chmodSync(dst, 0o755);
|
||||||
|
}
|
||||||
|
const stateRoot = path.join(mockHome, '.gstack');
|
||||||
|
fs.mkdirSync(stateRoot, { recursive: true });
|
||||||
|
const env = { ...process.env, HOME: mockHome, GSTACK_STATE_ROOT: stateRoot };
|
||||||
|
expect(spawnSync('bash', [path.join(installBin, 'gstack-config'), 'set', 'memorable_recall', 'on'], { env, timeout: 20_000 }).status).toBe(0);
|
||||||
|
const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force', '--keep-state'], {
|
||||||
|
stdio: 'pipe', timeout: 30_000, encoding: 'utf-8', cwd: tmp, env,
|
||||||
|
});
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(fs.readFileSync(path.join(stateRoot, 'config.yaml'), 'utf-8')).toMatch(/memorable_recall: off/);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tmp, { recursive: true, force: true });
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user