mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 18:05:31 +02:00
fix(hooks): outside-model review fixes for the memorable hook and runExternal
- Nothing in the vendor's process group outlives the call: the group is killed on every resolve, the clean 'close' path included (a helper the vendor forked with redirected stdio ran on unsupervised before). - A child that already exited when the deadline fires keeps its result; the deadline only ends the drain instead of rewriting a completed exit as a timeout. - The decoded-leaf scan reports when its node/depth bound cut the walk short and the hook refuses the hand-off as unscanned; object keys are scanned too. - git for the trust-policy lookup runs without inherited GIT_* selectors (GIT_DIR, GIT_WORK_TREE, GIT_CONFIG_*), so it inspects the session's repository and no other; a host cancellation kills that git as well. - An unmatched brace in a vendor banner no longer hides the answer after it. - The stderr tail is scanned whole before it is cropped for the log, so a credential's prefix cannot be cropped away from its secret half. - The vendor override reads an empty GSTACK_MEMORABLE_BIN as unset, exactly as bin/gstack-memorable does, so enable checks the binary the hook runs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
1b9d0d11f0
commit
73245d8e1d
@@ -129,18 +129,32 @@ export function stripControl(text: string): string {
|
|||||||
return text.replace(CONTROL_RE, '').replace(FORMAT_RE, '');
|
return text.replace(CONTROL_RE, '').replace(FORMAT_RE, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every string leaf of a parsed JSON value, bounded so a hostile payload cannot monopolize the clock. */
|
/**
|
||||||
export function stringLeaves(value: unknown, maxNodes = 10_000, maxDepth = 32): string[] {
|
* Every string leaf of a parsed JSON value, bounded so a hostile payload
|
||||||
const out: string[] = [];
|
* cannot monopolize the clock. `exhausted` is true when the bound cut the
|
||||||
|
* walk short: the caller must then treat the payload as unscanned (and refuse
|
||||||
|
* the hand-off), never as clean.
|
||||||
|
*/
|
||||||
|
export function stringLeavesBounded(value: unknown, maxNodes = 10_000, maxDepth = 32): { leaves: string[]; exhausted: boolean } {
|
||||||
|
const leaves: string[] = [];
|
||||||
let nodes = 0;
|
let nodes = 0;
|
||||||
|
let exhausted = false;
|
||||||
const walk = (v: unknown, depth: number): void => {
|
const walk = (v: unknown, depth: number): void => {
|
||||||
if (nodes++ > maxNodes || depth > maxDepth) return;
|
if (nodes++ > maxNodes || depth > maxDepth) { exhausted = true; return; }
|
||||||
if (typeof v === 'string') { out.push(v); return; }
|
if (typeof v === 'string') { leaves.push(v); return; }
|
||||||
if (Array.isArray(v)) { for (const item of v) walk(item, depth + 1); return; }
|
if (Array.isArray(v)) { for (const item of v) walk(item, depth + 1); return; }
|
||||||
if (v && typeof v === 'object') { for (const item of Object.values(v as Record<string, unknown>)) walk(item, depth + 1); }
|
if (v && typeof v === 'object') {
|
||||||
|
// keys are forwarded bytes too; a credential can sit in one
|
||||||
|
for (const [k, item] of Object.entries(v as Record<string, unknown>)) { leaves.push(k); walk(item, depth + 1); }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
walk(value, 0);
|
walk(value, 0);
|
||||||
return out;
|
return { leaves, exhausted };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The leaves alone (see stringLeavesBounded). */
|
||||||
|
export function stringLeaves(value: unknown, maxNodes = 10_000, maxDepth = 32): string[] {
|
||||||
|
return stringLeavesBounded(value, maxNodes, maxDepth).leaves;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identity, locale and temp; the standard proxy, TLS and XDG knobs the vendor
|
// Identity, locale and temp; the standard proxy, TLS and XDG knobs the vendor
|
||||||
@@ -196,10 +210,11 @@ export function* jsonObjects(raw: string): Generator<unknown> {
|
|||||||
let start = raw.indexOf('{');
|
let start = raw.indexOf('{');
|
||||||
for (let tries = 0; start >= 0 && tries < JSON_CANDIDATES; tries++) {
|
for (let tries = 0; start >= 0 && tries < JSON_CANDIDATES; tries++) {
|
||||||
const end = balancedObjectEnd(raw, start);
|
const end = balancedObjectEnd(raw, start);
|
||||||
if (end < 0) return; // nothing complete remains
|
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
let ok = false;
|
let ok = false;
|
||||||
try { parsed = JSON.parse(raw.slice(start, end + 1)); ok = true; } catch { /* a brace in prose */ }
|
// An unbalanced candidate (a lone brace in a banner) is skipped like an
|
||||||
|
// unparsable one: the complete object after it must still be found.
|
||||||
|
if (end >= 0) { try { parsed = JSON.parse(raw.slice(start, end + 1)); ok = true; } catch { /* a brace in prose */ } }
|
||||||
if (ok) { yield parsed; start = raw.indexOf('{', end + 1); }
|
if (ok) { yield parsed; start = raw.indexOf('{', end + 1); }
|
||||||
else start = raw.indexOf('{', start + 1);
|
else start = raw.indexOf('{', start + 1);
|
||||||
}
|
}
|
||||||
@@ -236,11 +251,13 @@ export function renderContext(vendorText: string): string {
|
|||||||
* of HIGH-tier shapes).
|
* of HIGH-tier shapes).
|
||||||
*/
|
*/
|
||||||
export function safeStderrTail(tail: string): string {
|
export function safeStderrTail(tail: string): string {
|
||||||
const t = stripControl(tail).replace(/\s+/g, ' ').trim().slice(-300);
|
// Scan everything runExternal kept, THEN crop for the log: cropping first
|
||||||
if (!t) return '';
|
// could cut a credential's identifying prefix off and log its secret half.
|
||||||
const r = scan(t, { repoVisibility: 'unknown' });
|
const whole = stripControl(tail).replace(/\s+/g, ' ').trim();
|
||||||
|
if (!whole) return '';
|
||||||
|
const r = scan(whole, { repoVisibility: 'unknown' });
|
||||||
const n = r.counts.HIGH + r.counts.MEDIUM;
|
const n = r.counts.HIGH + r.counts.MEDIUM;
|
||||||
return r.oversize || n > 0 ? `[stderr withheld: ${n} redaction finding(s)]` : t;
|
return r.oversize || n > 0 ? `[stderr withheld: ${n} redaction finding(s)]` : whole.slice(-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripQuotes(v: string): string {
|
function stripQuotes(v: string): string {
|
||||||
@@ -268,8 +285,10 @@ function isDirectory(p: string): boolean {
|
|||||||
* fall-through to something else (lib/claude-bin.ts contract).
|
* fall-through to something else (lib/claude-bin.ts contract).
|
||||||
*/
|
*/
|
||||||
export function resolveVendor(env: Record<string, string | undefined>, homeDir: string): string | null {
|
export function resolveVendor(env: Record<string, string | undefined>, homeDir: string): string | null {
|
||||||
const override = env.GSTACK_MEMORABLE_BIN ?? env.MEMORABLE_BIN;
|
// Empty means unset, exactly as bash's ${GSTACK_MEMORABLE_BIN:-${MEMORABLE_BIN:-}} reads it
|
||||||
if (override && override.trim()) {
|
// in bin/gstack-memorable: the binary enable checked is the binary the hook runs.
|
||||||
|
const override = (env.GSTACK_MEMORABLE_BIN ?? '').trim() || (env.MEMORABLE_BIN ?? '').trim();
|
||||||
|
if (override) {
|
||||||
const o = stripQuotes(override);
|
const o = stripQuotes(override);
|
||||||
const resolved = path.isAbsolute(o) ? o : (Bun.which(o) ?? null);
|
const resolved = path.isAbsolute(o) ? o : (Bun.which(o) ?? null);
|
||||||
return resolved && executable(resolved) ? resolved : null;
|
return resolved && executable(resolved) ? resolved : null;
|
||||||
@@ -361,7 +380,26 @@ function gateIsOn(timeoutMs: number): 'on' | 'off' | 'error' {
|
|||||||
const GIT_NO_REMOTE = 2;
|
const GIT_NO_REMOTE = 2;
|
||||||
const GIT_FATAL = 128;
|
const GIT_FATAL = 128;
|
||||||
const NOT_A_REPO_RE = /^fatal: not a git repository\b/;
|
const NOT_A_REPO_RE = /^fatal: not a git repository\b/;
|
||||||
const GIT_ENV = { ...process.env, LC_ALL: 'C', LANGUAGE: '', LC_MESSAGES: 'C' };
|
|
||||||
|
/** Kills the in-flight detached child (git for the policy lookup, then the vendor); read by the signal handlers. */
|
||||||
|
let killInflight: (() => void) | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* git's environment for the policy lookup: English messages, and NO inherited
|
||||||
|
* GIT_* repository selectors (GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR,
|
||||||
|
* GIT_CONFIG_*, ...): cwd does not override them, so an inherited GIT_DIR
|
||||||
|
* would make the veto inspect a different repository than the one the
|
||||||
|
* session works in. Exported for tests.
|
||||||
|
*/
|
||||||
|
export function gitEnv(env: Record<string, string | undefined>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(env)) {
|
||||||
|
if (v == null || k.startsWith('GIT_')) continue;
|
||||||
|
out[k] = v;
|
||||||
|
}
|
||||||
|
out.LC_ALL = 'C'; out.LANGUAGE = ''; out.LC_MESSAGES = 'C';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trust-policy veto by the session's repo (keyed by its origin remote).
|
* Trust-policy veto by the session's repo (keyed by its origin remote).
|
||||||
@@ -375,8 +413,10 @@ const GIT_ENV = { ...process.env, LC_ALL: 'C', LANGUAGE: '', LC_MESSAGES: 'C' };
|
|||||||
async function policyVeto(cwd: string, timeoutMs: number, remaining: () => number): Promise<'ok' | 'skip' | 'error'> {
|
async function policyVeto(cwd: string, timeoutMs: number, remaining: () => number): Promise<'ok' | 'skip' | 'error'> {
|
||||||
if (!hasRepoPolicyStore()) return 'ok';
|
if (!hasRepoPolicyStore()) return 'ok';
|
||||||
const git = await runExternal('git', ['remote', 'get-url', 'origin'], {
|
const git = await runExternal('git', ['remote', 'get-url', 'origin'], {
|
||||||
cwd, timeoutMs: Math.max(1, timeoutMs), maxBuffer: GIT_MAX_BUFFER, env: GIT_ENV,
|
cwd, timeoutMs: Math.max(1, timeoutMs), maxBuffer: GIT_MAX_BUFFER, env: gitEnv(process.env),
|
||||||
|
onSpawn: (kill) => { killInflight = kill; }, // a host cancellation must not leak this git either
|
||||||
});
|
});
|
||||||
|
killInflight = null;
|
||||||
if (git.timedOut || git.error) return 'error';
|
if (git.timedOut || git.error) return 'error';
|
||||||
if (git.status === GIT_NO_REMOTE) return 'ok';
|
if (git.status === GIT_NO_REMOTE) return 'ok';
|
||||||
if (git.status === GIT_FATAL && NOT_A_REPO_RE.test(git.stderrTail.trim())) return 'ok';
|
if (git.status === GIT_FATAL && NOT_A_REPO_RE.test(git.stderrTail.trim())) return 'ok';
|
||||||
@@ -394,8 +434,6 @@ function writeStdout(text: string): Promise<void> {
|
|||||||
return new Promise((resolve) => { process.stdout.write(text, () => resolve()); });
|
return new Promise((resolve) => { process.stdout.write(text, () => resolve()); });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kills the in-flight vendor group; set for the duration of the vendor spawn, read by the signal handlers. */
|
|
||||||
let killInflight: (() => void) | null = null;
|
|
||||||
|
|
||||||
/** Best-effort: the ledger's size warning goes to stderr, which the host discards for an exit-0 hook; log it where `status` looks. */
|
/** Best-effort: the ledger's size warning goes to stderr, which the host discards for an exit-0 hook; log it where `status` looks. */
|
||||||
function noteLedgerSize(): void {
|
function noteLedgerSize(): void {
|
||||||
@@ -444,7 +482,13 @@ export async function main(): Promise<void> {
|
|||||||
// its cost is roughly linear in bytes, so each scan is admitted by the clock
|
// its cost is roughly linear in bytes, so each scan is admitted by the clock
|
||||||
// with a size-derived allowance: a scan we cannot afford skips recall
|
// with a size-derived allowance: a scan we cannot afford skips recall
|
||||||
// instead of letting the host kill us mid-scan.
|
// instead of letting the host kill us mid-scan.
|
||||||
const leaves = stringLeaves(payload).join('\n');
|
const walked = stringLeavesBounded(payload);
|
||||||
|
if (walked.exhausted) {
|
||||||
|
// A payload too deep or too wide to walk is unscanned, not clean.
|
||||||
|
logHookError('refused:payload-too-complex: the prompt JSON exceeded the scan walk bounds, nothing handed to the vendor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const leaves = walked.leaves.join('\n');
|
||||||
for (const text of [rawText, leaves]) {
|
for (const text of [rawText, leaves]) {
|
||||||
const allowance = MIN_SPAWN_MS + Math.ceil(Buffer.byteLength(text, 'utf8') / 1024) * SCAN_MS_PER_KIB;
|
const allowance = MIN_SPAWN_MS + Math.ceil(Buffer.byteLength(text, 'utf8') / 1024) * SCAN_MS_PER_KIB;
|
||||||
if (remaining() < allowance) { logHookError('budget-exhausted before the secret scan, recall skipped'); return; }
|
if (remaining() < allowance) { logHookError('budget-exhausted before the secret scan, recall skipped'); return; }
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ export interface RunExternalResult {
|
|||||||
* only on 'close': a child that exits 0 but leaves a background process
|
* only on 'close': a child that exits 0 but leaves a background process
|
||||||
* holding its pipes gets its output delivered and the straggler group-
|
* holding its pipes gets its output delivered and the straggler group-
|
||||||
* killed, instead of being reported as a timeout with its answer dropped.
|
* killed, instead of being reported as a timeout with its answer dropped.
|
||||||
|
* - NOTHING in the group outlives the call: the group is killed on every
|
||||||
|
* resolve, including a clean 'close' (a helper the child forked with its
|
||||||
|
* stdio redirected would otherwise run on unsupervised). A child that
|
||||||
|
* must leave a daemon behind has to setsid it; that is the child's
|
||||||
|
* explicit choice, visible in its own code, not an accident of ours.
|
||||||
|
* - a child that has already exited when the deadline fires keeps its
|
||||||
|
* result: the deadline then ends the drain, it does not rewrite a
|
||||||
|
* completed exit as a timeout.
|
||||||
* - stderr is drained continuously (an undrained pipe blocks a noisy child
|
* - stderr is drained continuously (an undrained pipe blocks a noisy child
|
||||||
* before it writes stdout) and only its tail is kept, never forwarded.
|
* before it writes stdout) and only its tail is kept, never forwarded.
|
||||||
* - stdin gets an error listener, so a child that exits before reading a
|
* - stdin gets an error listener, so a child that exits before reading a
|
||||||
@@ -132,6 +140,7 @@ export function runExternal(exe: string, args: string[], opts: RunExternalOption
|
|||||||
let stdinError: string | undefined;
|
let stdinError: string | undefined;
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let done = false;
|
let done = false;
|
||||||
|
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||||
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let drainTimer: ReturnType<typeof setTimeout> | undefined;
|
let drainTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const killGroup = (): void => {
|
const killGroup = (): void => {
|
||||||
@@ -144,12 +153,16 @@ export function runExternal(exe: string, args: string[], opts: RunExternalOption
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (graceTimer) clearTimeout(graceTimer);
|
if (graceTimer) clearTimeout(graceTimer);
|
||||||
if (drainTimer) clearTimeout(drainTimer);
|
if (drainTimer) clearTimeout(drainTimer);
|
||||||
// A straggler holding our pipes must not pin this process either.
|
// Nothing in the group outlives the call; a straggler holding our pipes
|
||||||
|
// must not pin this process either.
|
||||||
|
killGroup();
|
||||||
for (const s of [child.stdout, child.stderr, child.stdin]) { try { s?.destroy(); } catch { /* closed */ } }
|
for (const s of [child.stdout, child.stderr, child.stdin]) { try { s?.destroy(); } catch { /* closed */ } }
|
||||||
try { child.unref(); } catch { /* fine */ }
|
try { child.unref(); } catch { /* fine */ }
|
||||||
resolve({ status, signal, stdout: Buffer.concat(chunks), stderrTail, ...(stdinError ? { stdinError } : {}), error, timedOut });
|
resolve({ status, signal, stdout: Buffer.concat(chunks), stderrTail, ...(stdinError ? { stdinError } : {}), error, timedOut });
|
||||||
};
|
};
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
|
// The child already answered and exited; the deadline only ends the drain.
|
||||||
|
if (exited) { finish(exited.code, exited.signal); return; }
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
error = error ?? 'ETIMEDOUT';
|
error = error ?? 'ETIMEDOUT';
|
||||||
killGroup();
|
killGroup();
|
||||||
@@ -167,6 +180,7 @@ export function runExternal(exe: string, args: string[], opts: RunExternalOption
|
|||||||
});
|
});
|
||||||
child.stderr?.on('data', (d: Buffer) => { stderrTail = (stderrTail + d.toString('utf8')).slice(-STDERR_TAIL_BYTES); });
|
child.stderr?.on('data', (d: Buffer) => { stderrTail = (stderrTail + d.toString('utf8')).slice(-STDERR_TAIL_BYTES); });
|
||||||
child.on('exit', (code, signal) => {
|
child.on('exit', (code, signal) => {
|
||||||
|
exited = { code, signal };
|
||||||
if (done) return;
|
if (done) return;
|
||||||
// A killed child (timeout, ENOBUFS) has nothing worth draining: resolve
|
// A killed child (timeout, ENOBUFS) has nothing worth draining: resolve
|
||||||
// now so the caller keeps its post-spawn reserve.
|
// now so the caller keeps its post-spawn reserve.
|
||||||
|
|||||||
Reference in New Issue
Block a user