mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 07:47:27 +01:00
Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design, ios-qa daemon, lib and scripts resolve the state root through bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the twin and stop with a reinstall message when it is missing; hooks source it and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts delegate to resolveStateRoot. Analytics writers and readers move together so the usage log stays one file. gstack-uninstall deletes state only at ~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor, the checkout or the git root, and leaves any other resolved root in place with the removal command. Fixtures that copy single bins now copy the twin.
413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
/**
|
|
* egress-receipt — hash-chained, content-free receipts for every
|
|
* gstack-initiated off-machine send (`~/.gstack/security/egress.jsonl`, 0600).
|
|
*
|
|
* THREAT MODEL: the egress ledger is forensic observability — it records
|
|
* ATTEMPTED egress so accidents are auditable; it is not an exfiltration
|
|
* control. Receipts are written before send, outcomes are best-effort, and
|
|
* fail-open sinks can send unrecorded with a warning.
|
|
*
|
|
* Semantics:
|
|
* - Receipt-before-send: the receipt line is appended BEFORE the network
|
|
* call. Fail-closed sinks MUST refuse the send with the typed code
|
|
* EGRESS_RECEIPT_FAILED when it cannot be written; fail-open sinks warn
|
|
* on stderr and proceed.
|
|
* - Content-free: never payload text, never credentials — a sha256 of the
|
|
* exact bytes sent plus a byte count only (semantic-reviews.jsonl
|
|
* precedent). Sinks where a subprocess/SDK owns the bytes record
|
|
* sha256: null.
|
|
* - Tamper-evident: each line carries `prev` = sha256 of the previous raw
|
|
* line ("" for line 1). `verifyLedger` recomputes the chain.
|
|
*
|
|
* Node builtins only, so bun TS binaries and the compiled browse binary can
|
|
* both import it (same constraint as browse/src/security.ts: no native
|
|
* modules).
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import { resolveStateRoot } from './state-root';
|
|
import path from 'node:path';
|
|
|
|
export const EGRESS_RECEIPT_FAILED = 'EGRESS_RECEIPT_FAILED';
|
|
|
|
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
|
|
/**
|
|
* WARN-at-size threshold. Above this the ledger still appends (never blocks
|
|
* on size), but writeReceipt emits one stderr warning per process so the
|
|
* user learns the file exists and how to inspect it before it gets silly.
|
|
*/
|
|
export const LEDGER_WARN_BYTES = 25 * 1024 * 1024;
|
|
|
|
/**
|
|
* Tail window for reading the last raw line. Receipt lines are ~300 bytes;
|
|
* 4KB covers any legal line with an order of magnitude to spare.
|
|
*/
|
|
const TAIL_READ_BYTES = 4096;
|
|
|
|
// TODO(rotation): ledger rotation. Chain-genesis sketch: when the ledger
|
|
// exceeds the size threshold, rename it to egress.jsonl.1 and start a new
|
|
// generation whose FIRST record embeds `genesis: sha256(<tail raw line of
|
|
// the prior file>)`, so verifyLedger can walk generations end-to-end
|
|
// (verify each file's internal chain, then check each genesis hash against
|
|
// the previous generation's last line). Until then we warn at 25MB.
|
|
|
|
type Env = Record<string, string | undefined>;
|
|
|
|
export interface WriteReceiptOptions {
|
|
/** gstack home; resolved from env when omitted */
|
|
home?: string;
|
|
/** env for home resolution (tests) */
|
|
env?: Env;
|
|
/** which gstack component is sending */
|
|
sink: string;
|
|
/** destination host[:port] */
|
|
host: string;
|
|
/** content-free payload description */
|
|
payloadClass: string;
|
|
/** exact byte count sent (0 for bodyless requests) */
|
|
bytes?: number;
|
|
/** sha256 hex of the exact bytes sent; null when a subprocess/SDK owns the bytes */
|
|
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 {
|
|
home?: string;
|
|
env?: Env;
|
|
/** 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;
|
|
record: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface VerifyResult {
|
|
ok: boolean;
|
|
count: number;
|
|
brokenLine: number | null;
|
|
reason: string | null;
|
|
/** present when the ledger file exceeds LEDGER_WARN_BYTES */
|
|
sizeWarning: string | null;
|
|
}
|
|
|
|
/** The shared state root (lib/state-root.ts), made absolute. */
|
|
export function resolveEgressHome(env: Env = process.env): string {
|
|
return path.resolve(resolveStateRoot(env));
|
|
}
|
|
|
|
export function egressLedgerPath(home: string): string {
|
|
return path.join(home, 'security', 'egress.jsonl');
|
|
}
|
|
|
|
export function sha256Hex(data: string | Uint8Array): string {
|
|
return createHash('sha256').update(data).digest('hex');
|
|
}
|
|
|
|
function receiptError(message: string, cause?: unknown): Error & { code: string } {
|
|
const error = (cause === undefined ? new Error(message) : new Error(message, { cause })) as Error & { code: string };
|
|
error.code = EGRESS_RECEIPT_FAILED;
|
|
return error;
|
|
}
|
|
|
|
// A serialized receipt line must stay under TAIL_READ_BYTES so the O(1)
|
|
// tail-read always captures the FULL previous line before hashing it into the
|
|
// chain. A caller-controlled field (sink/host/payloadClass/consent — e.g.
|
|
// context-bill builds payloadClass dynamically) long enough to push the line
|
|
// past the tail window would make the next append hash a truncated prior line,
|
|
// and verifyLedger would then report a permanent false TAMPER. Cap each field
|
|
// well under the window so the invariant holds by construction.
|
|
const MAX_FIELD_BYTES = 512;
|
|
|
|
function requireString(value: unknown, name: string): string {
|
|
if (typeof value !== 'string' || !value) throw receiptError(`Egress receipt requires a non-empty ${name}`);
|
|
if (Buffer.byteLength(value) > MAX_FIELD_BYTES) {
|
|
throw receiptError(`Egress receipt ${name} exceeds ${MAX_FIELD_BYTES} bytes (${Buffer.byteLength(value)})`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* mkdir spin lock; the budget defaults to LEDGER_LOCK_BUDGET_MS (2.5 s) and
|
|
* callers on their own deadline pass less. Egress events are usually rare
|
|
* (minutes apart; the memorable hook is the per-prompt exception); the lock
|
|
* only protects the read-last-line → append window.
|
|
*
|
|
* Stale-lock reclaim: a crashed writer strands the lock dir. Once the spin
|
|
* budget is exhausted, a lock dir whose mtime is >10s old is stale by
|
|
* definition (appends take milliseconds), so the waiter removes it and
|
|
* 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, budgetMs: number = LEDGER_LOCK_BUDGET_MS): T {
|
|
const lock = `${ledger}.lock`;
|
|
const deadline = Date.now() + Math.max(0, budgetMs);
|
|
for (;;) {
|
|
try {
|
|
fs.mkdirSync(lock);
|
|
break;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException)?.code !== 'EEXIST') throw error;
|
|
if (Date.now() > deadline) {
|
|
try {
|
|
const age = Date.now() - fs.statSync(lock).mtimeMs;
|
|
if (age > 10_000) { fs.rmdirSync(lock); continue; }
|
|
} catch { /* raced with the owner's cleanup — retry */ }
|
|
throw receiptError(`Egress ledger is locked: ${lock}`);
|
|
}
|
|
// Sync sleep (node + bun): the API is sync on purpose so shell, bun,
|
|
// and node callers all share one implementation.
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
}
|
|
}
|
|
try {
|
|
return callback();
|
|
} finally {
|
|
try { fs.rmdirSync(lock); } catch { /* best-effort unlock */ }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Last raw line via a tail read: open the file, read the final
|
|
* TAIL_READ_BYTES, take the last newline-terminated chunk. Never loads the
|
|
* whole ledger, so appends stay O(1) as the file grows.
|
|
*/
|
|
function lastRawLine(ledger: string): string | null {
|
|
let fd: number;
|
|
try {
|
|
fd = fs.openSync(ledger, 'r');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return null;
|
|
throw error;
|
|
}
|
|
try {
|
|
const size = fs.fstatSync(fd).size;
|
|
if (size === 0) return null;
|
|
const length = Math.min(size, TAIL_READ_BYTES);
|
|
const buffer = Buffer.alloc(length);
|
|
fs.readSync(fd, buffer, 0, length, size - length);
|
|
const tail = buffer.toString('utf8');
|
|
const lines = tail.split('\n').filter((line) => line.length > 0);
|
|
return lines.length ? lines[lines.length - 1] : null;
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
}
|
|
|
|
let warnedLedgerSize = false;
|
|
|
|
/** Test-only: re-arm the once-per-process size warning. */
|
|
export function resetLedgerSizeWarningForTests(): void {
|
|
warnedLedgerSize = false;
|
|
}
|
|
|
|
function warnLedgerSizeOnce(ledger: string): void {
|
|
// Short-circuit BEFORE the stat: the warning fires at most once per process,
|
|
// so after it has fired there is no reason to stat the ledger on every
|
|
// subsequent writeReceipt (this runs on the append hot path).
|
|
if (warnedLedgerSize) return;
|
|
let size: number;
|
|
try {
|
|
size = fs.statSync(ledger).size;
|
|
} catch {
|
|
return; // no file yet — nothing to warn about
|
|
}
|
|
if (size <= LEDGER_WARN_BYTES) return;
|
|
warnedLedgerSize = true;
|
|
process.stderr.write(ledgerSizeWarning(ledger, size) + '\n');
|
|
}
|
|
|
|
/**
|
|
* Self-explanatory size warning: says what the ledger is (records what
|
|
* gstack ATTEMPTS to send off-machine), how to inspect it, and that
|
|
* trimming arrives with rotation.
|
|
*/
|
|
export function ledgerSizeWarning(ledger: string, size: number): string {
|
|
const mb = (size / (1024 * 1024)).toFixed(1);
|
|
return (
|
|
`gstack: egress ledger is large (${mb}MB): ${ledger}. ` +
|
|
`This file records what gstack ATTEMPTS to send off-machine (content-free receipts, for auditing). ` +
|
|
`Inspect it with 'gstack-egress list'. Trimming arrives with ledger rotation (TODO); until then it only grows.`
|
|
);
|
|
}
|
|
|
|
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);
|
|
try {
|
|
fs.mkdirSync(path.dirname(ledger), { recursive: true, mode: 0o700 });
|
|
return withLedgerLock(ledger, () => {
|
|
const previous = lastRawLine(ledger);
|
|
const line = JSON.stringify({ ...record, prev: previous == null ? '' : sha256Hex(previous) });
|
|
const existed = fs.existsSync(ledger);
|
|
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(
|
|
`Egress receipt could not be written to ${ledger}: ${(error as Error)?.message ?? error}`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Append one content-free receipt BEFORE a network send.
|
|
*
|
|
* @returns id = sha256 of the written line (for writeOutcome)
|
|
* @throws Error with code EGRESS_RECEIPT_FAILED — fail-closed callers must
|
|
* refuse the send; fail-open callers warn and proceed
|
|
*/
|
|
export function writeReceipt(opts: WriteReceiptOptions): { id: string; path: string } {
|
|
const sink = requireString(opts.sink, 'sink');
|
|
const host = requireString(opts.host, 'host');
|
|
const payloadClass = requireString(opts.payloadClass, 'payloadClass');
|
|
const consent = requireString(opts.consent, 'consent');
|
|
const bytes = opts.bytes ?? 0;
|
|
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, {
|
|
ts: new Date().toISOString(),
|
|
type: 'egress',
|
|
sink,
|
|
host,
|
|
payload_class: payloadClass,
|
|
bytes,
|
|
sha256,
|
|
consent,
|
|
}, opts.env, budgetMs);
|
|
}
|
|
|
|
/**
|
|
* Append the response status for an earlier receipt (best-effort companion
|
|
* record — the pre-send receipt is the invariant, the outcome is
|
|
* bookkeeping). Chained like every other line.
|
|
*/
|
|
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, budgetMs);
|
|
}
|
|
|
|
/** Raw parsed lines: [{lineNo, raw, record|null}]. Missing ledger → []. */
|
|
export function readLedger(home: string): LedgerLine[] {
|
|
const ledger = egressLedgerPath(home);
|
|
let content: string;
|
|
try {
|
|
content = fs.readFileSync(ledger, 'utf8');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return [];
|
|
throw error;
|
|
}
|
|
return content.split('\n').filter((line) => line.length > 0).map((raw, index) => {
|
|
let record: Record<string, unknown> | null = null;
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === 'object') record = parsed;
|
|
} catch { /* malformed line — verifyLedger reports it */ }
|
|
return { lineNo: index + 1, raw, record };
|
|
});
|
|
}
|
|
|
|
export interface Receipt {
|
|
ts: string;
|
|
type: 'egress';
|
|
sink: string;
|
|
host: string;
|
|
payload_class: string;
|
|
bytes: number;
|
|
sha256: string | null;
|
|
consent: string;
|
|
prev: string;
|
|
id: string;
|
|
status: string | null;
|
|
}
|
|
|
|
/** Receipts with their joined outcome status (`status: null` = none recorded). */
|
|
export function listReceipts(home: string): Receipt[] {
|
|
const lines = readLedger(home);
|
|
const receipts: Receipt[] = [];
|
|
const byId = new Map<string, Receipt>();
|
|
for (const { raw, record } of lines) {
|
|
if (!record) continue;
|
|
if (record.type === 'egress') {
|
|
const entry = { ...(record as unknown as Omit<Receipt, 'id' | 'status'>), id: sha256Hex(raw), status: null };
|
|
receipts.push(entry);
|
|
byId.set(entry.id, entry);
|
|
} else if (record.type === 'outcome' && byId.has(record.receipt as string)) {
|
|
byId.get(record.receipt as string)!.status = String(record.status);
|
|
}
|
|
}
|
|
return receipts;
|
|
}
|
|
|
|
/**
|
|
* Recompute the hash chain. `brokenLine` is the 1-indexed first line whose
|
|
* `prev` no longer matches the sha256 of the previous raw line (or that
|
|
* fails to parse). `sizeWarning` is set when the ledger exceeds
|
|
* LEDGER_WARN_BYTES.
|
|
*/
|
|
export function verifyLedger(home: string): VerifyResult {
|
|
const ledger = egressLedgerPath(home);
|
|
let sizeWarning: string | null = null;
|
|
try {
|
|
const size = fs.statSync(ledger).size;
|
|
if (size > LEDGER_WARN_BYTES) sizeWarning = ledgerSizeWarning(ledger, size);
|
|
} catch { /* missing ledger — verify of an empty chain below */ }
|
|
const lines = readLedger(home);
|
|
let previousRaw: string | null = null;
|
|
for (const { lineNo, raw, record } of lines) {
|
|
if (!record || typeof record.prev !== 'string') {
|
|
return { ok: false, count: lines.length, brokenLine: lineNo, reason: 'unparseable or missing prev', sizeWarning };
|
|
}
|
|
const expected = previousRaw == null ? '' : sha256Hex(previousRaw);
|
|
if (record.prev !== expected) {
|
|
return { ok: false, count: lines.length, brokenLine: lineNo, reason: 'prev hash does not match previous line', sizeWarning };
|
|
}
|
|
previousRaw = raw;
|
|
}
|
|
return { ok: true, count: lines.length, brokenLine: null, reason: null, sizeWarning };
|
|
}
|