mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 06:58:59 +02:00
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:
co-authored by
Claude Fable 5.1
parent
3034769813
commit
d4dbeb6d42
+28
-5
@@ -72,6 +72,13 @@ export interface WriteReceiptOptions {
|
||||
sha256?: string | null;
|
||||
/** the consent key+value that authorizes this send */
|
||||
consent: string;
|
||||
/**
|
||||
* Max milliseconds to wait for the ledger lock (default LEDGER_LOCK_BUDGET_MS).
|
||||
* Callers on an interactive hot path with their own deadline (a Claude Code
|
||||
* hook under a 5 s kill) pass what they can afford; the lock is always
|
||||
* tried at least once.
|
||||
*/
|
||||
lockBudgetMs?: number;
|
||||
}
|
||||
|
||||
export interface WriteOutcomeOptions {
|
||||
@@ -80,8 +87,13 @@ export interface WriteOutcomeOptions {
|
||||
/** receipt id returned by writeReceipt */
|
||||
receipt: string;
|
||||
status?: string | number;
|
||||
/** see WriteReceiptOptions.lockBudgetMs */
|
||||
lockBudgetMs?: number;
|
||||
}
|
||||
|
||||
/** Default spin budget for the ledger lock. Egress events are rare (minutes apart). */
|
||||
export const LEDGER_LOCK_BUDGET_MS = 2500;
|
||||
|
||||
export interface LedgerLine {
|
||||
lineNo: number;
|
||||
raw: string;
|
||||
@@ -148,9 +160,9 @@ function requireString(value: unknown, name: string): string {
|
||||
* retries instead of failing. The rmdir/stat races with a concurrent
|
||||
* reclaimer or the owner's own cleanup are harmless — losers just loop.
|
||||
*/
|
||||
function withLedgerLock<T>(ledger: string, callback: () => T): T {
|
||||
function withLedgerLock<T>(ledger: string, callback: () => T, budgetMs: number = LEDGER_LOCK_BUDGET_MS): T {
|
||||
const lock = `${ledger}.lock`;
|
||||
const deadline = Date.now() + 2500;
|
||||
const deadline = Date.now() + Math.max(0, budgetMs);
|
||||
for (;;) {
|
||||
try {
|
||||
fs.mkdirSync(lock);
|
||||
@@ -240,10 +252,19 @@ export function ledgerSizeWarning(ledger: string, size: number): string {
|
||||
);
|
||||
}
|
||||
|
||||
function lockBudget(value: number | undefined): number {
|
||||
if (value === undefined) return LEDGER_LOCK_BUDGET_MS;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw receiptError('Egress receipt lockBudgetMs must be a non-negative finite number');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function appendChained(
|
||||
homeOrNull: string | null,
|
||||
record: Record<string, unknown>,
|
||||
env?: Env,
|
||||
budgetMs: number = LEDGER_LOCK_BUDGET_MS,
|
||||
): { id: string; path: string } {
|
||||
const home = homeOrNull ?? resolveEgressHome(env);
|
||||
const ledger = egressLedgerPath(home);
|
||||
@@ -256,7 +277,7 @@ function appendChained(
|
||||
fs.appendFileSync(ledger, `${line}\n`, { mode: 0o600 });
|
||||
if (!existed) fs.chmodSync(ledger, 0o600); // umask must not weaken the ledger
|
||||
return { id: sha256Hex(line), path: ledger };
|
||||
});
|
||||
}, budgetMs);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === EGRESS_RECEIPT_FAILED) throw error;
|
||||
throw receiptError(
|
||||
@@ -282,6 +303,7 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
|
||||
if (!Number.isSafeInteger(bytes) || bytes < 0) throw receiptError('Egress receipt bytes must be a non-negative integer');
|
||||
const sha256 = opts.sha256 ?? null;
|
||||
if (sha256 !== null && !SHA256_HEX.test(String(sha256))) throw receiptError('Egress receipt sha256 must be 64 lowercase hex chars or null');
|
||||
const budgetMs = lockBudget(opts.lockBudgetMs);
|
||||
const home = opts.home ?? resolveEgressHome(opts.env);
|
||||
warnLedgerSizeOnce(egressLedgerPath(home));
|
||||
return appendChained(home, {
|
||||
@@ -293,7 +315,7 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
|
||||
bytes,
|
||||
sha256,
|
||||
consent,
|
||||
}, opts.env);
|
||||
}, opts.env, budgetMs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,12 +325,13 @@ export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: str
|
||||
*/
|
||||
export function writeOutcome(opts: WriteOutcomeOptions): { id: string; path: string } {
|
||||
const receipt = requireString(opts.receipt, 'receipt id');
|
||||
const budgetMs = lockBudget(opts.lockBudgetMs);
|
||||
return appendChained(opts.home ?? null, {
|
||||
ts: new Date().toISOString(),
|
||||
type: 'outcome',
|
||||
receipt,
|
||||
status: String(opts.status ?? 'unknown'),
|
||||
}, opts.env);
|
||||
}, opts.env, budgetMs);
|
||||
}
|
||||
|
||||
/** Raw parsed lines: [{lineNo, raw, record|null}]. Missing ledger → []. */
|
||||
|
||||
Reference in New Issue
Block a user