mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-31 18:30:39 +02:00
* 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>
138 lines
5.5 KiB
TypeScript
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");
|
|
});
|
|
});
|