Files
gstack/design/test/receipted-fetch.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

138 lines
5.5 KiB
TypeScript

/**
* receipted-fetch — egress receipts for the design binary's OpenAI calls.
*
* Pins the sink contract (fail-OPEN polarity, amendment T3/C8):
* - receipt is written BEFORE the send (ordering observable via the
* ledger's existence at fetch time)
* - the sha256 recorded is the hash of the JSON body; the body itself is
* never stored
* - streaming response bodies pass through the wrapper intact
* - an unwritable ledger warns on stderr and the call still proceeds
*/
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { canRevokeWrites } from "../../test/helpers/fs-caps";
import fs from "fs";
import os from "os";
import path from "path";
import { receiptedFetch } from "../src/receipted-fetch";
import { egressLedgerPath, listReceipts, sha256Hex } from "../../lib/egress-receipt";
let home: string;
let savedHome: string | undefined;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "design-receipt-"));
savedHome = process.env.GSTACK_HOME;
process.env.GSTACK_HOME = home;
});
afterEach(() => {
if (savedHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = savedHome;
try { fs.chmodSync(path.join(home, "security"), 0o700); } catch {}
fs.rmSync(home, { recursive: true, force: true });
});
describe("receiptedFetch", () => {
test("writes the receipt BEFORE the send; sha256 is the hash of the JSON body", async () => {
const body = JSON.stringify({ model: "gpt-4o", input: "a prompt" });
let receiptsAtFetchTime = -1;
const stub = (async (_url: any, init?: any) => {
// Receipt-before-send: by the time fetch runs, the receipt exists.
receiptsAtFetchTime = listReceipts(home).length;
expect(init.body).toBe(body); // body passes through untouched
return new Response("{}", { status: 200 });
}) as typeof globalThis.fetch;
const response = await receiptedFetch("generate-image-request", "https://api.openai.com/v1/responses", {
method: "POST",
body,
}, stub);
expect(response.status).toBe(200);
expect(receiptsAtFetchTime).toBe(1);
const receipts = listReceipts(home);
expect(receipts.length).toBe(1);
expect(receipts[0].sink).toBe("design-openai");
expect(receipts[0].host).toBe("api.openai.com");
expect(receipts[0].payload_class).toBe("generate-image-request");
expect(receipts[0].sha256).toBe(sha256Hex(body));
expect(receipts[0].bytes).toBe(Buffer.byteLength(body));
// Hash only — the ledger never contains the body text.
const raw = fs.readFileSync(egressLedgerPath(home), "utf-8");
expect(raw).not.toContain("a prompt");
});
test("streaming response body arrives intact through the wrapper", async () => {
const chunks = ["data: one\n", "data: two\n", "data: [DONE]\n"];
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const c of chunks) controller.enqueue(new TextEncoder().encode(c));
controller.close();
},
});
const stub = (async () => new Response(stream, { status: 200 })) as typeof globalThis.fetch;
const response = await receiptedFetch("evolve-image-request", "https://api.openai.com/v1/responses", {
method: "POST",
body: JSON.stringify({ stream: true }),
}, stub);
expect(response.body).toBeInstanceOf(ReadableStream);
expect(await response.text()).toBe(chunks.join(""));
});
test("non-string request body (ReadableStream) is receipted as sha256:null, not consumed", async () => {
const requestStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("streamed-bytes"));
controller.close();
},
});
let receivedBody: any = null;
const stub = (async (_url: any, init?: any) => {
receivedBody = init.body;
return new Response("{}", { status: 200 });
}) as typeof globalThis.fetch;
await receiptedFetch("stream-upload", "https://api.openai.com/v1/responses", {
method: "POST",
body: requestStream,
}, stub);
expect(receivedBody).toBe(requestStream); // same stream object, untouched
const receipts = listReceipts(home);
expect(receipts[0].sha256).toBeNull();
// The stream is still readable by the consumer (was not drained to hash).
expect(await new Response(receivedBody).text()).toBe("streamed-bytes");
});
test("fail-open: unwritable ledger warns on stderr and the call proceeds", async () => {
if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers)
fs.mkdirSync(path.join(home, "security"), { recursive: true, mode: 0o500 });
let fetched = false;
const stub = (async () => { fetched = true; return new Response("{}", { status: 200 }); }) as typeof globalThis.fetch;
const captured: string[] = [];
const originalWrite = process.stderr.write.bind(process.stderr);
(process.stderr as any).write = (chunk: string) => { captured.push(String(chunk)); return true; };
let response: Response;
try {
response = await receiptedFetch("check-screenshot-request", "https://api.openai.com/v1/chat/completions", {
method: "POST",
body: "{}",
}, stub);
} finally {
(process.stderr as any).write = originalWrite;
}
expect(fetched).toBe(true); // the call proceeded
expect(response.status).toBe(200);
const warning = captured.join("");
expect(warning).toContain("egress receipt could not be written");
expect(warning).toContain("fail-open");
expect(warning).toContain("gstack-egress");
});
});