diff --git a/CHANGELOG.md b/CHANGELOG.md index fc80fa507..3e0bc893e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [1.66.1.0] - 2026-08-16 + +**Every claim gstack makes now binds to the content it was made on.** +**Tracker text is data. Guard hooks actually guard.** + +Reviews and test results used to be prose claims: "review is recent" meant a commit-count guess that a rebase could crash, and "tests passed" meant trusting output from a tree that may have changed since. Both now carry a working-tree content fingerprint (`bin/gstack-wtree`, ~0.2s). A review of identical content grades CURRENT through rebases, amends, and squashes. A test run recorded by the new `bin/gstack-evidence` ledger stays citable at /ship's verification gate only while the content is byte-identical (release files carve out), the command hash matches, and nothing edited the tree mid-run. /ship and /land-and-deploy cite fresh evidence instead of re-running, and re-run live when anything moved. + +PR bodies, PR comments, and model-judged issue titles now enter agent context only through a trust envelope (`bin/gstack-issue-guard`): content is data even when clean, injection-shaped lines get labeled through fullwidth and invisible-character evasion, forged envelope banners are defused, and a CI scanner fails the suite on any raw tracker-text read at all 8 ingress points. Write-backs keep a raw artifact so envelope markup can never reach a live PR. + +/freeze now fails closed: unparseable payloads, quote or newline paths (the deny used to silently no-op on them), boundaries with spaces, symlinks pointing outside the boundary, and a broken install all block instead of passing. /careful gains a hard-deny tier for `rm -rf /`-class deletes and force-pushes to the default branch — including the flag-less `git push origin +main` form and quoted or refspec targets — plus additive-only custom warn patterns that can never weaken the built-ins. + +### The numbers that matter + +Measured on this branch; re-run with `bun test`, `time bin/gstack-wtree`, and the commands in each bin's header. + +| Metric | Before | After | Δ | +|---|---|---|---| +| Review staleness on rebased/amended identical content | crash or STALE | CURRENT | correct | +| "Tests passed" binding | none (prose) | content fingerprint + command hash + max-age | new | +| Tracker-text ingress points enveloped | 0 | 8, CI-scanner enforced | new | +| /freeze deny on hostile/edge paths | silent no-op | blocks, fail-closed | fixed | +| Working-tree fingerprint cost | — | ~0.09s warm (stat-cache seeded, 40x vs naive) | new | +| Adversarial findings fixed pre-merge | — | 50 (4 specialists + red team + fresh-context pass), 6 critical | — | + +The fingerprint survives commits of identical content, so the common flow — test on a dirty tree, commit, ship — keeps its evidence valid, while one untracked new source file invalidates it. + +### What this means for you + +/ship stops re-running suites the content already proved green and stops trusting suites the content has outgrown — the IRON LAW is now a mechanical check, not an honor system. A hostile PR comment can no longer speak to your agent with authority, and /guard's boundary actually holds on the paths where it used to silently fail. Nothing to configure: the bins ship wired into /ship, /land-and-deploy, /review, /spec, and /document-release. + +### Itemized changes + +### Added +- `bin/gstack-wtree` — working-tree content fingerprint (temp-index, stat-cache-seeded; identical hash to a full re-hash at ~40x less cost). +- `bin/gstack-evidence` — verification-evidence ledger: `run` wraps any command transparently (exit code always passes through; 0600 per-run logs with 2MB cap and 30-day prune; HIGH credentials in commands stored redacted; mid-run tree edits void the fingerprint) and `check` grades FRESH/STALE/MISSING per label with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. +- `lib/tracker-guard.ts` + `bin/gstack-issue-guard` — trust envelope for tracker text: envelope-always, detection-only NFKC + full Unicode format-character sweep, banner-forgery defusal, no-envelope-on-fetch-failure, numeric argv validation. +- `/careful` HIGH tier (hard deny: root/home recursive deletes incl. `--no-preserve-root` and `/*` forms; default-branch force-pushes incl. plus-refspec, refspec-colon, and quoted targets; simple commands only, `--force-with-lease` never matches) and additive-only project warn patterns (`~/.gstack/careful-patterns.txt`, per-project variant). +- CI wiring scanner (`test/tracker-guard-wiring.test.ts`) failing the suite on raw tracker-text reads outside the guard, with reasoned, liveness-checked exemptions; template-drift tripwire pinning the grading rules and the write-side banner tripwire. + +### Changed +- Review records (`bin/gstack-review-log`) stamp `commit_full`/`tree`/`dirty`/`wtree` authoritatively — caller-supplied binding fields are ignored; `bin/gstack-review-read` emits `---WTREE---`/`---TREE---`/`---DIRTY---`; the /ship dashboard and /land-and-deploy grade diff-scoped reviews content-first (plan-tier reviews keep time-based logic), and a rebased-away commit grades UNKNOWN instead of erroring. +- /ship Step 5 test lanes run wrapped with per-lane labels and per-run logs (no shared /tmp collisions between concurrent ships); Step 16 and /land-and-deploy 3.5b check the ledger first and cite fresh evidence, advisory-never-blocking. +- /document-release PR/MR body updates use a two-artifact flow (enveloped copy for reading, raw copy for the splice-and-write-back) with a banner tripwire that compares against the fetched original. +- /spec issue-title dedupe reads titles through the envelope and distinguishes pipeline failure from zero matches instead of silently skipping. + +### Fixed +- /freeze: five boundary defects — deny JSON silently no-oped on quote/newline paths, internal spaces in the boundary path were stripped (space-bearing project dirs could never match), symlink final components weren't resolved (in-boundary symlink wrote outside the boundary), the JSON extractor truncated at escaped quotes and failed open, and a missing helper file passed edits through instead of blocking. +- /careful and /freeze now share one JSON extractor and one analytics writer (both honor `GSTACK_HOME`), ending the two-copy drift that let one hook keep a bug the other had fixed. + +### For contributors +- `test/helpers/scratch-repo.ts` — shared hermetic git fixture (identity pinned, gpg signing disabled so fixture commits never invoke the operator's gpg-agent) and a PATH `gh` shim for exercising real gh success/failure branches. +- ~150 new tests across six files, including the keystone case: evidence recorded on a dirty tree stays FRESH after committing the exact tested content. + ## [1.66.0.0] - 2026-08-15 **The full ~7,000-test suite in about 90 seconds, verified honest.** diff --git a/CLAUDE.md b/CLAUDE.md index ac661a717..316f60bcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,7 @@ gstack/ ├── investigate/ # /investigate skill (systematic root-cause debugging) ├── spec/ # /spec skill (five-phase spec → GitHub issue, optional agent spawn, /ship auto-closes) ├── retro/ # Retrospective skill (includes /retro global cross-project mode) -├── bin/ # CLI utilities (gstack-repo-mode, gstack-slug, gstack-config, etc.) +├── bin/ # CLI utilities (gstack-repo-mode, gstack-slug, gstack-config, gstack-wtree, gstack-evidence, gstack-issue-guard, etc.) ├── document-release/ # /document-release skill (post-ship doc updates + Diataxis coverage map) ├── document-generate/ # /document-generate skill (Diataxis doc generator: tutorial/how-to/reference/explanation) ├── cso/ # /cso skill (OWASP Top 10 + STRIDE security audit) @@ -166,7 +166,7 @@ gstack/ │ ├── test/ # Integration tests │ └── dist/ # Compiled binary ├── extension/ # Chrome extension (side panel + activity feed + CSS inspector) -├── lib/ # Shared libraries (worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, code-intelligence/) +├── lib/ # Shared libraries (worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, code-intelligence/) ├── docs/designs/ # Design documents ├── setup-deploy/ # /setup-deploy skill (one-time deploy config) ├── .github/ # CI workflows + Docker image diff --git a/README.md b/README.md index d7478897d..26ad1bfbb 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan- | Skill | What it does | |-------|-------------| | `/codex` | **Second Opinion** — independent code review from OpenAI Codex CLI. Three modes: review (pass/fail gate), adversarial challenge, and open consultation. Cross-model analysis when both `/review` and `/codex` have run. | -| `/careful` | **Safety Guardrails** — warns before destructive commands (rm -rf, DROP TABLE, force-push). Say "be careful" to activate. Override any warning. | +| `/careful` | **Safety Guardrails** — warns before destructive commands (rm -rf, DROP TABLE, force-push). Say "be careful" to activate. Override any MEDIUM warning; root/home recursive deletes and default-branch force-pushes are hard-denied. | | `/freeze` | **Edit Lock** — restrict file edits to one directory. Prevents accidental changes outside scope while debugging. | | `/guard` | **Full Safety** — `/careful` + `/freeze` in one command. Maximum safety for prod work. | | `/unfreeze` | **Unlock** — remove the `/freeze` boundary. | @@ -247,6 +247,9 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that | `gstack-context-bill` | **Token bill-of-materials** — read-only, offline audit of what an installed skills tree costs in tokens: always-on frontmatter every session pays vs per-invocation SKILL.md + forced references. `--diff` compares two trees, `--budget` enforces a ceiling, `--exact` opts into Anthropic `count_tokens` (sends file text off-machine; writes an egress receipt first, degrades to the offline estimate if the receipt can't be written). | | `gstack-code-intelligence` | **Code-intelligence provider picker** — wraps GBrain, Sourcebot, and Graphify behind one interface: `options`/`status` to see what's available, `select` to pick one, `index`/`search` to use it, `suggest` to check whether the one-time indexing offer should fire here. The offer triggers on large repos (1,000+ tracked files; a decline is persisted). Non-local providers refuse to index *or search* until you record per-repo consent (`consent yes\|no` — the query text is repo-derived content), the per-repo trust policy's deny and read-only tiers veto write-class operations regardless of consent, and every off-machine send writes an egress receipt. Fully optional — with nothing selected, gstack falls back to grep. | | `gstack-verify-gate` | **Verification stop hook (opt-in)** — blocks a Claude Code turn from ending until the project's declared verify command passes (after 3 blocked re-entries it yields with a loud still-RED warning instead of looping forever). Declare it on one line in CLAUDE.md: ``. Hooks bypass the permission system, so a declared command never runs until you trust it once per repo (`gstack-verify-gate --trust`); editing the command invalidates trust until re-granted, and every grant is audit-logged. `./setup` never registers it for you — opt in with `gstack-settings-hook add-event --event Stop --command ~/.claude/skills/gstack/bin/gstack-verify-gate --source verify-gate`, remove with `gstack-settings-hook remove-source --source verify-gate`. | +| `gstack-wtree` | **Working-tree fingerprint** — prints a content hash of what's actually on disk (temp index seeded from the stat cache, ~40x cheaper than a full re-hash; untracked source counts, gitignored scratch doesn't). Identical content fingerprints identically through commits, rebases, amends, and squashes — it's what binds reviews and test evidence to content instead of commit SHAs. | +| `gstack-evidence` | **Verification-evidence ledger** — `run --label -- ` transparently wraps any test command (the child's exit code always passes through) and records what ran against which working-tree fingerprint; `check` grades each label FRESH/STALE/MISSING with `--expect-cmd`, `--max-age`, and `--allow-paths` binding. /ship and /land-and-deploy cite fresh evidence instead of re-running suites. Per-run logs are 0600, capped at 2MB, pruned after 30 days; the ledger and logs stay machine-local by design. | +| `gstack-issue-guard` | **Tracker-text trust envelope** — fetches GitHub issue/PR text (`issue `, `pr-body`, `pr-comments`, or `--stdin`) and wraps it in a labeled envelope so agents treat it as data: injection-shaped lines get labeled even through fullwidth and invisible-character evasion, and forged envelope banners are defused. Every tracker-text ingress in gstack routes through it, enforced by a CI scanner. | | `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). | | `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. | | `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. | diff --git a/TODOS.md b/TODOS.md index ebd180ef6..a081bb1a3 100644 --- a/TODOS.md +++ b/TODOS.md @@ -40,6 +40,62 @@ evidence-before-claimed-limitations rule. **Effort:** S per run. **Priority:** P3. **Depends on:** a paid ADP account. +### P2: Eval-run evidence records (extend the content-binding lattice to E2E/evals) + +**What:** Wire `bin/gstack-evidence run` into the eval entrypoints (`eval:bg*`, +`scripts/test-paid-shards.ts`) so E2E/eval claims carry the same +working-tree-fingerprint binding as free tests, and /land-and-deploy 3.5b reads +evidence records instead of `~/.gstack-dev/evals` file mtimes. + +**Why:** Today "E2E ran today" is an mtime heuristic that proves nothing about +what content the run tested. **Effort:** M → S with CC. **Priority:** P2. +**Depends on:** the content-binding wave; touches the sharded runner that +concurrent worktrees share — coordinate timing. + +### P2: Spec-spawn outcome ledger + +**What:** `/spec`'s spawned `claude -p` agents are fire-and-forget: nothing +records whether the spawn finished, died, or stalled. Add a runs.jsonl +(spawn id, branch, worktree, pid, outcome) written at spawn + updated by a +lease/heartbeat check, surfaced as a /landing-report row. + +**Why:** A dead spawn is currently invisible until someone hunts the PID. +**Effort:** M → S with CC. **Priority:** P2. **Depends on:** nothing; the +lease + heartbeat liveness pattern is documented in the local CEO plan record +(2026-08-15, binding wave). + +### P3: Merge-SHA chain of custody in /land-and-deploy + +**What:** Post-merge, record {merge sha, merged tree, reviewed wtree match?} +so a deployed artifact traces back to a reviewed content state. + +**Why:** Pre-merge checks bind reviews to content; after a squash-merge onto a +moved base the linkage is unrecorded. Needs a noise model (base movement +legitimately changes the tree) before it can alert rather than log. +**Effort:** M → S with CC. **Priority:** P3. **Depends on:** content-binding +wave fields (wtree in review records). + +### P3: default-if-silent escalation contract for background loops + +**What:** Long-running/background skill loops (/canary first) get an +escalation shape that carries options + a default-if-silent choice with a +timeout, so an unattended loop never stalls on a question a human isn't +around to answer. + +**Why:** Autonomy currently either blocks on AskUserQuestion or guesses. +**Effort:** S/M → S with CC. **Priority:** P3. **Depends on:** consent-model +review (changes AskUserQuestion semantics — needs its own design pass). + +### P3: E2E eval case — staleness grading actually applied + +**What:** A paid gate/periodic eval asserting an agent following the rendered +/ship dashboard + /land 3.5a text applies the wtree content-first rule (grades +CURRENT on identical content, falls back on mismatch). + +**Why:** The grading rule is prompt-followed prose pinned only by a free +template-drift tripwire; this proves agents actually execute it. **Effort:** S. +**Priority:** P3. **Depends on:** content-binding wave. + ### P2: office-hours design-doc dual-write functional E2E (fork port wave 2 review shortfall) **What:** A paid E2E (claude -p) that runs the office-hours Phase 5 handoff in diff --git a/VERSION b/VERSION index 0ea99c033..4790d6fbd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.66.0.0 +1.66.1.0 diff --git a/bin/gstack-evidence b/bin/gstack-evidence new file mode 100755 index 000000000..9d72bf45b --- /dev/null +++ b/bin/gstack-evidence @@ -0,0 +1,445 @@ +#!/usr/bin/env bun +/** + * gstack-evidence — verification-evidence ledger: the mechanical arm of /ship's + * IRON LAW ("no completion claims without fresh verification evidence"). + * + * gstack-evidence run --label -- + * gstack-evidence check [--label [--expect-cmd ]]... | --all + * [--max-age ] [--allow-paths ] + * + * `run` is a TRANSPARENT wrapper: it streams the child's output through + * unchanged, tees it to a 0600 log (2MB cap with a truncation marker), and + * appends {ts, label, command, cmd_sha256, exit, duration_s, commit, tree, + * dirty, wtree, log_path} to ~/.gstack/projects//-evidence.jsonl. + * + * TRANSPARENCY INVARIANT (load-bearing): the child's exit code is ALWAYS the + * wrapper's exit code. Every bookkeeping failure — ledger append, log dir, + * non-git context, redact scan — is a stderr warning, never a failure. The + * wrapper must never turn green tests red. + * + * Freshness binds to `wtree`, the working-tree content fingerprint from + * bin/gstack-wtree: evidence recorded on uncommitted code stays FRESH after + * the exact tested content is committed, and an untracked new source file + * invalidates it. `cmd_sha256` = sha256 of the exact command string, no + * normalization — the same convention as bin/gstack-verify-gate (which hashes + * for TRUST; this ledger hashes for FRESHNESS). + * + * MACHINE-LOCAL by design: neither the ledger nor the logs are brain-synced. + * A synced record citing an unsynced log would grade FRESH on a machine where + * the log doesn't exist. + * + * `check` is read-only and never throws into the calling skill flow: any git + * failure (gc'd stored tree, not a repo) degrades to STALE/MISSING. Call sites + * must name expected labels explicitly — `--all` checks only labels that exist + * in the ledger; it cannot prove that an expected lane ever ran. + */ + +import { mkdirSync, openSync, writeSync, closeSync, readdirSync, statSync, unlinkSync, chmodSync } from "fs"; +import { join, dirname } from "path"; +import { spawnSync } from "child_process"; +import { appendJsonl, readJsonl } from "../lib/jsonl-store"; +import { scan, applyRedactions } from "../lib/redact-engine"; + +const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url)); +const LOG_MAX_BYTES = 2 * 1024 * 1024; +const LOG_PRUNE_DAYS = 30; + +interface EvidenceRecord { + ts: string; + label: string; + command: string; + cmd_sha256: string; + exit: number; + duration_s: number; + commit?: string; + tree?: string; + dirty?: boolean; + wtree?: string; + log_path?: string; + redacted?: boolean; +} + +function warn(msg: string): void { + console.error(`gstack-evidence: warning: ${msg}`); +} + +function sha256(text: string): string { + const h = new Bun.CryptoHasher("sha256"); + h.update(text); + return h.digest("hex"); +} + +function git(args: string[]): string | undefined { + try { + const r = spawnSync("git", args, { encoding: "utf-8", timeout: 15000 }); + if (r.status !== 0) return undefined; + const out = (r.stdout || "").trim(); + return out || undefined; + } catch { + return undefined; + } +} + +function currentWtree(): string | undefined { + try { + const r = spawnSync(join(BIN_DIR, "gstack-wtree"), { encoding: "utf-8", timeout: 30000 }); + if (r.status !== 0) return undefined; + const out = (r.stdout || "").trim(); + return /^[0-9a-f]{40}$/.test(out) ? out : undefined; + } catch { + return undefined; + } +} + +function ledgerPath(): { dir: string; file: string; logsDir: string } { + const home = process.env.GSTACK_HOME || (process.env.HOME ? join(process.env.HOME, ".gstack") : undefined); + // No resolvable home: skip bookkeeping (a literal "~" dir in cwd would land + // inside the repo and perturb the fingerprint it exists to compute). + if (!home) throw new Error("no GSTACK_HOME/HOME — bookkeeping skipped"); + // ONE gstack-slug spawn: its output carries both SLUG= and BRANCH= lines + // (same branch→filename sanitization as reviews.jsonl). + const slugOut = spawnSync(join(BIN_DIR, "gstack-slug"), { encoding: "utf-8" }); + const sm = (slugOut.stdout || "").match(/^SLUG=(.+)$/m); + const bm = (slugOut.stdout || "").match(/^BRANCH=(.+)$/m); + const slug = sm ? sm[1].trim() : "unknown"; + const branch = bm ? bm[1].trim() : "no-branch"; + const dir = join(home, "projects", slug); + return { dir, file: join(dir, `${branch}-evidence.jsonl`), logsDir: join(dir, "logs") }; +} + +/** Redact-engine pass over the command string. HIGH finding → store redacted. */ +function safeCommandForRecord(command: string): { command: string; redacted: boolean } { + try { + const { findings } = scan(command); + const high = findings.filter((f) => f.tier === "HIGH"); + if (high.length === 0) return { command, redacted: false }; + const redactedBody = applyRedactions(command, findings.map((f) => f.id)).body; + const still = scan(redactedBody).findings.some((f) => f.tier === "HIGH"); + return { command: still ? "" : redactedBody, redacted: true }; + } catch { + return { command, redacted: false }; + } +} + +/** Opportunistic prune of logs older than LOG_PRUNE_DAYS. Best-effort. */ +function pruneOldLogs(logsDir: string): void { + try { + const cutoff = Date.now() - LOG_PRUNE_DAYS * 24 * 3600 * 1000; + for (const name of readdirSync(logsDir)) { + const p = join(logsDir, name); + try { + if (statSync(p).mtimeMs < cutoff) unlinkSync(p); + } catch {} + } + } catch {} +} + +/** Exclusive-open a collision-safe log file. Returns undefined on failure. */ +function openLog(logsDir: string, label: string, cmdSha: string): { fd: number; path: string } | undefined { + try { + mkdirSync(logsDir, { recursive: true }); + pruneOldLogs(logsDir); + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const base = `${ts}-${label}-${process.pid}-${cmdSha.slice(0, 8)}`; + for (let i = 0; i < 3; i++) { + const p = join(logsDir, i === 0 ? `${base}.log` : `${base}-${i}.log`); + try { + const fd = openSync(p, "ax", 0o600); + return { fd, path: p }; + } catch {} + } + } catch (e: any) { + warn(`log setup failed (${e?.message ?? e}) — running unlogged`); + } + return undefined; +} + +async function cmdRun(argv: string[]): Promise { + let label = "default"; + const li = argv.indexOf("--label"); + const sep = argv.indexOf("--"); + if (li >= 0 && li + 1 < argv.length && (sep < 0 || li < sep)) label = argv[li + 1]; + if (sep < 0 || sep + 1 >= argv.length) { + console.error("usage: gstack-evidence run --label -- "); + return 2; + } + const cmdArgv = argv.slice(sep + 1); + // Compound/piped commands pass as ONE string via bash -c; a multi-token argv + // runs directly. The hashed command string is exact, no normalization. + const commandString = cmdArgv.length === 1 ? cmdArgv[0] : cmdArgv.join(" "); + const spawnArgv = cmdArgv.length === 1 ? ["bash", "-c", cmdArgv[0]] : cmdArgv; + const cmdSha = sha256(commandString); + label = label.replace(/[^a-zA-Z0-9._-]/g, "_"); + + // Bookkeeping context — every piece is optional; failures only warn. + let paths: ReturnType | undefined; + try { + paths = ledgerPath(); + mkdirSync(paths.dir, { recursive: true }); + } catch (e: any) { + warn(`ledger setup failed (${e?.message ?? e}) — result will not be recorded`); + } + const log = paths ? openLog(paths.logsDir, label, cmdSha) : undefined; + + // Fingerprint the content BEFORE the child runs: a working-tree edit made + // DURING a long suite must not be certified as "the tested content". + const wtreeBefore = currentWtree(); + + const started = Date.now(); + let exitCode: number; + let proc: ReturnType | undefined; + try { + proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe" }); + } catch (e: any) { + // Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127. + exitCode = 127; + warn(`spawn failed: ${e?.message ?? e}`); + record(paths, log?.path, label, commandString, cmdSha, exitCode, started, wtreeBefore); + return exitCode; + } + + // Stream-tee: forward chunks as they arrive (never buffer — E2E logs are MBs). + let logBytes = 0; + let truncated = false; + const teeToLog = (chunk: Uint8Array) => { + if (!log || truncated) return; + try { + if (logBytes + chunk.byteLength > LOG_MAX_BYTES) { + const room = LOG_MAX_BYTES - logBytes; + if (room > 0) writeSync(log.fd, chunk.subarray(0, room)); + writeSync(log.fd, Buffer.from("\n\n[gstack-evidence: log truncated at 2MB — output continued on console]\n")); + truncated = true; + } else { + writeSync(log.fd, chunk); + logBytes += chunk.byteLength; + } + } catch { + truncated = true; // stop teeing on any write failure; console stream continues + try { + writeSync(log.fd, Buffer.from("\n\n[gstack-evidence: log ended early (write failure) — output continued on console]\n")); + } catch {} + } + }; + const pump = async (stream: ReadableStream | undefined, out: NodeJS.WriteStream) => { + if (!stream) return; + for await (const chunk of stream) { + // Honor backpressure: when the console consumer is slower than the child + // (piped into a pager/log collector), wait for drain instead of queueing + // unbounded chunks in the WriteStream buffer. + if (!out.write(chunk)) { + // Race drain against error: a dying consumer (EPIPE from `| head`) + // never drains — resolve either way and stop forwarding on error. + await new Promise((r) => { + const done = () => { + out.off("drain", done); + out.off("error", done); + r(); + }; + out.once("drain", done); + out.once("error", done); + }); + } + teeToLog(chunk); + } + }; + try { + await Promise.all([pump(proc.stdout as any, process.stdout), pump(proc.stderr as any, process.stderr)]); + exitCode = await proc.exited; + if (exitCode === null || exitCode === undefined) exitCode = 1; + } catch (e: any) { + warn(`stream error: ${e?.message ?? e}`); + try { + exitCode = await proc.exited; + } catch { + exitCode = 1; + } + } finally { + if (log) { + try { + closeSync(log.fd); + } catch {} + } + } + + record(paths, log?.path, label, commandString, cmdSha, exitCode, started, wtreeBefore); + return exitCode; +} + +function record( + paths: { dir: string; file: string } | undefined, + logPath: string | undefined, + label: string, + commandString: string, + cmdSha: string, + exitCode: number, + startedMs: number, + wtreeBefore: string | undefined, +): void { + if (!paths) return; + try { + const { command, redacted } = safeCommandForRecord(commandString); + const rec: EvidenceRecord = { + ts: new Date().toISOString(), + label, + command, + cmd_sha256: cmdSha, + exit: exitCode, + duration_s: Math.round((Date.now() - startedMs) / 100) / 10, + }; + if (redacted) rec.redacted = true; + const commit = git(["rev-parse", "HEAD"]); + if (commit) { + rec.commit = commit; + rec.tree = git(["rev-parse", "HEAD^{tree}"]); + rec.dirty = (git(["status", "--porcelain", "-uno"]) ?? "") !== ""; + // TOCTOU guard: the fingerprint is only trustworthy when the content was + // IDENTICAL before and after the run. A mid-run edit omits wtree, so + // check grades STALE instead of certifying content the suite never ran. + const wtreeAfter = currentWtree(); + if (wtreeBefore && wtreeAfter && wtreeBefore === wtreeAfter) { + rec.wtree = wtreeAfter; + } else if (wtreeBefore || wtreeAfter) { + warn("working-tree content changed during the run — evidence recorded without a content fingerprint (will grade STALE)"); + } + } + if (logPath) rec.log_path = logPath; + appendJsonl(paths.file, rec, { mode: 0o600 }); + try { + chmodSync(paths.file, 0o600); + } catch {} + // Summary line on stderr so calling agents get the exit + log path even + // when the lane ran backgrounded. Never on stdout (stays transparent). + console.error(`gstack-evidence: recorded label=${label} exit=${exitCode} log=${logPath ?? "-"}`); + } catch (e: any) { + warn(`ledger append failed (${e?.message ?? e}) — the command result stands`); + } +} + +function cmdCheck(argv: string[]): number { + // Parse: repeated --label, each optionally followed (anywhere later) by its + // own --expect-cmd; pairing is positional — an --expect-cmd binds to the most + // recent --label before it. + const wanted: { label: string; expectCmd?: string }[] = []; + let all = false; + let maxAgeHours: number | undefined; + let allowPaths: string[] = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--label") wanted.push({ label: argv[++i] ?? "" }); + else if (a === "--expect-cmd") { + if (wanted.length === 0) { + console.error("gstack-evidence: --expect-cmd requires a preceding --label"); + return 2; + } + wanted[wanted.length - 1].expectCmd = argv[++i] ?? ""; + } else if (a === "--all") all = true; + else if (a === "--max-age") { + maxAgeHours = Number(argv[++i]); + if (!Number.isFinite(maxAgeHours) || maxAgeHours <= 0) { + // A typo must never silently drop the age gate (fail open) on a + // freshness checker: it is a usage error. + console.error(`gstack-evidence: --max-age must be a positive number of hours, got: ${JSON.stringify(argv[i])}`); + return 2; + } + } + else if (a === "--allow-paths") allowPaths = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean); + } + if (!all && wanted.length === 0) { + console.error("usage: gstack-evidence check [--label [--expect-cmd ]]... | --all [--max-age ] [--allow-paths ]"); + return 2; + } + + let records: EvidenceRecord[] = []; + try { + records = readJsonl(ledgerPath().file); + } catch { + records = []; + } + + const labels = all + ? [...new Set(records.map((r) => r.label))].map((label) => ({ label, expectCmd: undefined as string | undefined })) + : wanted; + if (all && labels.length === 0) { + console.log("EVIDENCE: MISSING (ledger empty — no labels recorded)"); + return 1; + } + + const wtreeNow = currentWtree(); + let allFresh = true; + for (const { label, expectCmd } of labels) { + const latest = records.findLast((r) => r.label === label); + if (!latest) { + console.log(`EVIDENCE: MISSING label=${label}`); + allFresh = false; + continue; + } + const detail = `label=${label} exit=${latest.exit} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}`; + let verdict: "FRESH" | "STALE" = "FRESH"; + let reason = ""; + if (latest.exit !== 0) { + verdict = "STALE"; + reason = "recorded run failed"; + } else if (maxAgeHours !== undefined) { + const ageMs = Date.now() - Date.parse(latest.ts); + if (!(ageMs >= 0 && ageMs <= maxAgeHours * 3600 * 1000)) { + verdict = "STALE"; + reason = `older than ${maxAgeHours}h`; + } + } + if (verdict === "FRESH" && expectCmd !== undefined && sha256(expectCmd) !== latest.cmd_sha256) { + verdict = "STALE"; + reason = "command changed (cmd_sha256 mismatch)"; + } + if (verdict === "FRESH") { + // Content binding: identical working-tree fingerprint, or a diff confined + // to the allow-list. Any git failure (gc'd tree, not a repo) → STALE — + // never an error into the calling flow. + if (!latest.wtree || !/^[0-9a-f]{40}$/.test(latest.wtree) || !wtreeNow) { + // Stored fingerprints are re-validated before reaching git argv — a + // forged/corrupt ledger line must degrade, never inject options. + verdict = "STALE"; + reason = !latest.wtree + ? "record has no content fingerprint" + : !/^[0-9a-f]{40}$/.test(latest.wtree) + ? "record has malformed fingerprint" + : "current fingerprint unavailable"; + } else if (latest.wtree !== wtreeNow) { + const diff = git(["diff", "--name-only", latest.wtree, wtreeNow]); + if (diff === undefined) { + verdict = "STALE"; + reason = "content changed (fingerprint diff unavailable)"; + } else { + const changed = diff.split("\n").map((s) => s.trim()).filter(Boolean); + const outside = changed.filter((f) => !allowPaths.some((a) => f === a || f.startsWith(a.replace(/\/$/, "") + "/"))); + if (changed.length === 0 || outside.length === 0) { + reason = changed.length ? `diff confined to allow-paths (${changed.length} file(s))` : ""; + } else { + verdict = "STALE"; + reason = `content changed: ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? ", ..." : ""}`; + } + } + } + } + console.log(`EVIDENCE: ${verdict} ${detail}${reason ? ` reason=${reason}` : ""}`); + if (verdict !== "FRESH") allFresh = false; + } + return allFresh ? 0 : 1; +} + +const [, , sub, ...rest] = process.argv; +try { + if (sub === "run") { + process.exit(await cmdRun(rest)); + } else if (sub === "check") { + process.exit(cmdCheck(rest)); + } else { + console.error("usage: gstack-evidence run|check ..."); + process.exit(2); + } +} catch (e: any) { + // Never let the wrapper's own failure look like a command failure in a way + // that breaks a skill flow: `run` propagates the child's code from inside + // cmdRun; reaching here means bookkeeping blew up outside it. + warn(`unexpected error: ${e?.message ?? e}`); + process.exit(1); +} diff --git a/bin/gstack-issue-guard b/bin/gstack-issue-guard new file mode 100755 index 000000000..a9c15276d --- /dev/null +++ b/bin/gstack-issue-guard @@ -0,0 +1,98 @@ +#!/usr/bin/env bun +/** + * gstack-issue-guard — fetch tracker text and emit it inside the untrusted + * trust envelope (lib/tracker-guard.ts). The ONLY sanctioned path for reading + * PR/issue body text into an agent's context — the wiring scanner + * (test/tracker-guard-wiring.test.ts) fails CI on raw reads outside it. + * + * gstack-issue-guard issue # gh issue: title + body + comments + * gstack-issue-guard pr-body # gh: current PR body + * gstack-issue-guard pr-comments # gh: current PR issue-comments + * gstack-issue-guard --stdin [--source