Files
gstack/test/egress-receipt.test.ts
T
Garry TanandClaude Fable 5 b5a951e623 v1.72.0.0 feat: Aside recommended driver for third-party web actions (#2710)
* fix(browse): never chmod shared, symlinked, or foreign-owned dirs to 0700

restrictDirectoryPermissions unconditionally chmodded its target. On hosts
where the process holds CAP_FOWNER (Docker as root, CI sandboxes) that
chmod SUCCEEDS on root-owned /tmp whenever a state file is configured
there (BROWSE_STATE_FILE=/tmp/x.json derives stateDir=/tmp), and a 0700
/tmp breaks access(2)-based checks machine-wide for every other process.

The POSIX branch now refuses shared sticky dirs, world-writable mounts
under root, foreign-owned dirs, and symlinked state dirs; refusals warn
once per process instead of failing silent; owned-but-unreadable dirs
keep their chmod self-repair; and the check-then-act race is closed with
fd-anchored O_NOFOLLOW + fstat/fchmod on a single inode.

Regression tests cover the sticky-dir, foreign-uid, mkdirSecure-reapply,
and symlinked-dir shapes.

* fix: hash with sha256sum before shasum on Linux (config slugs + setup verify)

shasum is perl/macOS; coreutils-only Linux ships sha256sum. Two call
sites hard-coded shasum: gstack-config's sha8_of/sha16 (so
resolve-user-slug exited 127 for any Linux user with a git email, the
Layer-3 fallback) and the generated bun-installer checksum snippet in
the browse/qa NEEDS_SETUP flow (spurious "checksum mismatch" on the
same distros). Both now resolve sha256sum first and fall back to
shasum -a 256.

New shim-PATH tests pin BOTH hasher branches of sha8_of to a known
vector and cover the sha8->sha16 collision escalation end to end.

* feat(contract): Aside is the recommended driver for third-party web actions

The Third-Party Web Actions contract (ship, spec, office-hours,
land-and-deploy, setup-deploy) now names the Aside AI browser as the
recommended driver: it acts across the user's real logged-in sessions,
which is what vendor-dashboard moments need. Supersedes the v1.65.0.0
de-Aside stance by explicit user directive (2026-08-27).

Detection is a runtime probe (command -v + aside --version under a
portable gtimeout/timeout/bare guard; nonzero exit = not detected).
Consent options render per detection state with Aside recommended and
the first-party stack ($B headed + handoff, GStack Browser) as the
universal fallback. Absent on macOS, the contract mentions the
aside.com download (macOS 15+) once per task; gstack never runs an
installer and binary presence is never consent. Drive discipline:
step-wise over whole-task delegation, vendor confirm mode on, vendor
skill/--help text scoped to operational syntax only, secrets minimized
(autofill / human-used copy buttons), Apple credential creation never a
drive target in any skill, failure path quotes redacted errors and
falls back only with fresh consent.

test/third-party-actions.test.ts pins every load-bearing sentence (21
tests) plus repo-wide tripwires: an aside command allowlist
(--version/--help only, code spans AND prose) and a ban on Aside
installer invocations across all generated docs. Budget ratchet
fixture and carve skeleton ceilings refreshed in this commit per the
ratchet protocol.

* chore: regenerate remaining browse-setup snippet consumers

The sha256sum-first checksum fallback in the generated NEEDS_SETUP
snippet renders into every browse-consuming skill, not just browse/qa.
Mechanical regen of the other ten consumers; no template changes here.

* test: consent-gate E2E suite + functional fs-capability probes

Five hermetic gate-tier E2E cases (tpa-present / absent-linux / broken /
absent-darwin / apple-ban) drive the real contract section through
claude -p with PATH shims for aside and uname; the absent cases filter
any REAL aside binary out of the child PATH and assert absence with
Bun.which before spawning, so dev machines cannot leak into detection.
Registered per-case in E2E_TOUCHFILES/E2E_TIERS with template-level
deps (ship/SKILL.md.tmpl, gen-skill-docs.ts) and added to the evals.yml
matrix with tier: gate. eval:bg:periodic's detach timeout rises to
36000s for the grown periodic shard census (floor-enforced by
test/eval-detach-timeout-floor.test.ts); CLAUDE.md doc updated to match.

test/helpers/fs-caps.ts adds canRevokeWrites/canRevokeReads functional
probes; 13 chmod-based tests swap their uid-0-only guards for the
probes so suites skip honestly on CAP_DAC_OVERRIDE containers (this
sandbox: uid 1000 with full caps) instead of asserting revocations the
kernel ignores. path-validation's symlink test targets /etc/passwd
(exists everywhere; /etc/crontab is absent on Amazon Linux).

* docs: file the Aside follow-ups in TODOS

Phase-2 QA logged-in-evidence path (P3), a hostile-vendor-skill E2E for
the contract's override sentence (P2), and fd-anchoring the file-level
permission writes to match the directory hardening (P3).

* chore: bump version and changelog (v1.72.0.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update project documentation for v1.72.0.0

docs/skills.md: Third-Party Web Actions subsection under /ship (Aside
recommended driver, consent rules, credential boundaries). BROWSER.md:
"Aside and third-party drives" subsection under Real-browser mode + ToC
entry, including the no-gstack-side-audit-trail caveat (ship adversarial
finding 12). TODOS.md: mark the finding-12 doc note done.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: apply cross-model doc review fixes for v1.72.0.0

docs/skills.md: restore the /ship closing line above the new subsection.
BROWSER.md: ToC label matches the heading; BROWSE_STATE_FILE env row
documents the new dir-hardening refusal + one-time warning. CHANGELOG:
correct the hasher precedence wording (sha256sum first, shasum fallback)
and the fs-caps count (14 test files, verified against the diff).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: close cross-model doc-review gaps for v1.72.0.0

setup's manual bun-verify instruction gets the same sha256sum-first
fallback the automated snippet got (coreutils-only Linux); BROWSER.md's
BROWSE_STATE_FILE row now lists the under-root world-writable refusal;
test-cost ceilings in CLAUDE.md/CONTRIBUTING.md updated for the five
new gate E2E cases (~$4.20 E2E / ~$4.35 evals).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): gate the symlink-refusal test to POSIX and drop the umask assumption

The symlink regression test exercised the POSIX O_NOFOLLOW branch but ran
on Windows, where restrictDirectoryPermissions takes the icacls branch and
stat has no POSIX modes (0o666 always) — windows-free-tests failed on
mode 493 vs 438. Early-return on win32 like every sibling test in the
file, and assert the target's mode is UNCHANGED (captured post-mkdir)
instead of hardcoding 0o755, which a strict umask would also break.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 11:43:13 -07:00

260 lines
12 KiB
TypeScript

/**
* Egress receipts — chain, fail-closed, verify, shell bridge. Free tier, no network.
*
* THREAT MODEL: the egress ledger is forensic observability — it records
* ATTEMPTED egress so accidents are auditable; it is not an exfiltration
* control.
*
* Pins the auditor contract:
* - receipt-before-send fail-closed (EGRESS_RECEIPT_FAILED, no ledger = no send)
* - content-free lines chained by prev = sha256(previous raw line)
* - tail-read correctness (last line found without loading the whole file)
* - abandoned-lock reclaim (stale lock dir >10s old is removed, not fatal)
* - WARN-at-size (one self-explanatory stderr warning per process >25MB)
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { canRevokeWrites } from './helpers/fs-caps';
import {
EGRESS_RECEIPT_FAILED,
LEDGER_WARN_BYTES,
egressLedgerPath,
ledgerSizeWarning,
listReceipts,
resetLedgerSizeWarningForTests,
sha256Hex,
verifyLedger,
writeOutcome,
writeReceipt,
} from '../lib/egress-receipt';
const ROOT = path.resolve(import.meta.path, '..', '..');
let home: string;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-egress-'));
});
afterEach(() => {
try { fs.chmodSync(path.join(home, 'security'), 0o700); } catch {} // undo fail-closed fixtures
fs.rmSync(home, { recursive: true, force: true });
});
describe('egress receipt library', () => {
test('receipts chain: prev = sha256 of the previous raw line, "" for line 1', () => {
writeReceipt({ home, sink: 'a', host: 'h1', payloadClass: 'c', bytes: 3, sha256: sha256Hex('abc'), consent: 'k=v' });
writeReceipt({ home, sink: 'b', host: 'h2', payloadClass: 'c', bytes: 0, sha256: null, consent: 'k=v' });
const lines = fs.readFileSync(egressLedgerPath(home), 'utf-8').trim().split('\n');
expect(lines.length).toBe(2);
const first = JSON.parse(lines[0]);
const second = JSON.parse(lines[1]);
expect(first.prev).toBe('');
expect(second.prev).toBe(sha256Hex(lines[0]));
expect(first.sha256).toBe(sha256Hex('abc'));
expect(second.sha256).toBeNull();
expect(verifyLedger(home)).toMatchObject({ ok: true, count: 2 });
});
test('ledger is 0600 and the security dir 0700', () => {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
const ledger = egressLedgerPath(home);
expect(fs.statSync(ledger).mode & 0o777).toBe(0o600);
expect(fs.statSync(path.dirname(ledger)).mode & 0o777).toBe(0o700);
});
test('fail-closed: unwritable security dir throws typed EGRESS_RECEIPT_FAILED', () => {
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
fs.chmodSync(path.join(home, 'security'), 0o500);
try {
expect(() =>
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' }),
).toThrow();
try {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
} catch (err: any) {
expect(err.code).toBe(EGRESS_RECEIPT_FAILED);
}
} finally {
fs.chmodSync(path.join(home, 'security'), 0o700);
}
});
test('outcome records join back onto their receipt in listReceipts', () => {
const { id } = writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
writeOutcome({ home, receipt: id, status: 204 });
const receipts = listReceipts(home);
expect(receipts.length).toBe(1);
expect(receipts[0].status).toBe('204');
expect(verifyLedger(home)).toMatchObject({ ok: true, count: 2 });
});
test('tampering with a middle line breaks verification at that line', () => {
for (let i = 0; i < 3; i += 1) {
writeReceipt({ home, sink: `s${i}`, host: 'h', payloadClass: 'c', consent: 'telemetry=community' });
}
const ledger = egressLedgerPath(home);
const lines = fs.readFileSync(ledger, 'utf-8').trim().split('\n');
lines[1] = lines[1].replace('community', 'communitX');
fs.writeFileSync(ledger, `${lines.join('\n')}\n`);
// Line 2's edited bytes no longer hash to line 3's recorded prev.
expect(verifyLedger(home)).toMatchObject({ ok: false, brokenLine: 3 });
});
test('validation rejects garbage before touching the ledger', () => {
expect(() => writeReceipt({ home, sink: '', host: 'h', payloadClass: 'c', consent: 'k' } as any)).toThrow();
expect(() => writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'c', consent: 'k', bytes: -1 })).toThrow();
expect(() => writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'c', consent: 'k', sha256: 'nothex' })).toThrow();
expect(fs.existsSync(egressLedgerPath(home))).toBe(false);
});
test('abandoned lock: a stale lock dir (>10s-old mtime) is reclaimed by the next writer', () => {
// First write creates the security dir so the lock path's parent exists.
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
const lock = `${egressLedgerPath(home)}.lock`;
fs.mkdirSync(lock);
const old = new Date(Date.now() - 60_000);
fs.utimesSync(lock, old, old);
const started = Date.now();
const { id } = writeReceipt({ home, sink: 'b', host: 'h', payloadClass: 'c', consent: 'k=v' });
expect(id).toMatch(/^[0-9a-f]{64}$/);
// Spin budget is 2.5s; reclaim happens right after budget exhaustion.
expect(Date.now() - started).toBeLessThan(10_000);
expect(fs.existsSync(lock)).toBe(false);
expect(verifyLedger(home)).toMatchObject({ ok: true, count: 2 });
});
test('a fresh (recent-mtime) lock held past the budget fails closed instead of being stolen', () => {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
const lock = `${egressLedgerPath(home)}.lock`;
fs.mkdirSync(lock);
// Keep the mtime fresh so the reclaim path never fires: refresh it in the
// background while the writer spins out its 2.5s budget.
const refresher = setInterval(() => {
const now = new Date();
try { fs.utimesSync(lock, now, now); } catch { /* test teardown race */ }
}, 1000);
try {
expect(() =>
writeReceipt({ home, sink: 'b', host: 'h', payloadClass: 'c', consent: 'k=v' }),
).toThrow(/locked/);
} finally {
clearInterval(refresher);
fs.rmdirSync(lock);
}
}, 15_000);
test('tail-read: last line is found correctly on a multi-record ledger larger than the tail window', () => {
// 30 records ≈ 9KB > the 4KB tail window, so the append path must find
// the true last line from a partial read.
for (let i = 0; i < 30; i += 1) {
writeReceipt({
home,
sink: `sink-${i}`,
host: 'h',
payloadClass: `class-${'x'.repeat(200)}-${i}`,
consent: 'k=v',
});
}
const ledger = egressLedgerPath(home);
expect(fs.statSync(ledger).size).toBeGreaterThan(4096);
const result = verifyLedger(home);
expect(result).toMatchObject({ ok: true, count: 30 });
});
test('chain verify stays intact across 100+ records', () => {
for (let i = 0; i < 120; i += 1) {
writeReceipt({ home, sink: `s${i}`, host: 'h', payloadClass: 'c', consent: 'k=v' });
if (i % 10 === 0) writeOutcome({ home, receipt: 'f'.repeat(64), status: 200 });
}
const result = verifyLedger(home);
expect(result.ok).toBe(true);
expect(result.count).toBe(132);
expect(listReceipts(home).length).toBe(120);
});
test('WARN-at-size: >25MB ledger emits one self-explanatory stderr warning per process', () => {
const ledger = egressLedgerPath(home);
fs.mkdirSync(path.dirname(ledger), { recursive: true, mode: 0o700 });
// Grow the file past the threshold with valid-looking filler; the warning
// keys off file size only.
const filler = `${JSON.stringify({ type: 'egress', pad: 'x'.repeat(1024) })}\n`;
const chunk = filler.repeat(1024); // ~1MB
const writes = Math.ceil(LEDGER_WARN_BYTES / chunk.length) + 1;
for (let i = 0; i < writes; i += 1) fs.appendFileSync(ledger, chunk);
expect(fs.statSync(ledger).size).toBeGreaterThan(LEDGER_WARN_BYTES);
resetLedgerSizeWarningForTests();
const captured: string[] = [];
const originalWrite = process.stderr.write.bind(process.stderr);
(process.stderr as any).write = (chunk: string) => { captured.push(String(chunk)); return true; };
try {
writeReceipt({ home, sink: 'a', host: 'h', payloadClass: 'c', consent: 'k=v' });
writeReceipt({ home, sink: 'b', host: 'h', payloadClass: 'c', consent: 'k=v' });
} finally {
(process.stderr as any).write = originalWrite;
}
const warnings = captured.filter((c) => c.includes('egress ledger is large'));
expect(warnings.length).toBe(1); // once per process, not per write
// Self-explanatory shape: what the ledger is, how to inspect, what's coming.
expect(warnings[0]).toContain('ATTEMPTS to send off-machine');
expect(warnings[0]).toContain('gstack-egress list');
expect(warnings[0]).toContain('rotation');
expect(warnings[0]).toContain(ledger);
// verifyLedger surfaces the same warning as data.
const message = ledgerSizeWarning(ledger, fs.statSync(ledger).size);
expect(message).toContain('MB');
expect(verifyLedger(home).sizeWarning).toBe(message);
});
});
describe('gstack-egress-receipt shell bridge', () => {
const bin = path.join(ROOT, 'bin', 'gstack-egress-receipt');
test('write hashes the exact payload file, prints the receipt id; outcome joins', () => {
const payload = path.join(home, 'payload.json');
fs.writeFileSync(payload, '[{"v":1}]');
const write = spawnSync(bin, ['write', '--sink', 'telemetry-sync', '--host', '127.0.0.1:8399',
'--class', 'telemetry-events', '--payload-file', payload, '--consent', 'telemetry=community'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(write.status).toBe(0);
const id = write.stdout.trim();
expect(id).toMatch(/^[0-9a-f]{64}$/);
const outcome = spawnSync(bin, ['outcome', id, '204'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(outcome.status).toBe(0);
const receipts = listReceipts(home);
expect(receipts.length).toBe(1);
expect(receipts[0].bytes).toBe(9);
expect(receipts[0].sha256).toBe(sha256Hex('[{"v":1}]'));
expect(receipts[0].status).toBe('204');
});
test('--no-payload records sha256:null (git-class: a subprocess owns the bytes)', () => {
const write = spawnSync(bin, ['write', '--sink', 'brain-sync', '--host', 'github.com',
'--class', 'git-push', '--no-payload', '--consent', 'artifacts_sync_mode=auto'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(write.status).toBe(0);
const receipts = listReceipts(home);
expect(receipts.length).toBe(1);
expect(receipts[0].sha256).toBeNull();
expect(receipts[0].bytes).toBe(0);
});
test('write exits 3 with EGRESS_RECEIPT_FAILED when the ledger is unwritable', () => {
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
fs.mkdirSync(path.join(home, 'security'), { recursive: true, mode: 0o500 });
const write = spawnSync(bin, ['write', '--sink', 's', '--host', 'h', '--class', 'c', '--no-payload'],
{ encoding: 'utf-8', env: { ...process.env, GSTACK_HOME: home } });
expect(write.status).toBe(3);
expect(write.stderr).toContain('EGRESS_RECEIPT_FAILED');
fs.chmodSync(path.join(home, 'security'), 0o700);
});
});