From 1cab5e11083a37ea0bc62117e9a9c5d05d68785e Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:53:31 -0700 Subject: [PATCH] =?UTF-8?q?v1.66.1.0=20feat:=20content=20binding=20?= =?UTF-8?q?=E2=80=94=20evidence=20ledger,=20wtree=20staleness,=20tracker?= =?UTF-8?q?=20trust=20envelope,=20fail-closed=20hooks=20(#2603)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(hooks): fail-closed freeze + shared extractor + careful HIGH tier Freeze boundary hook had four verified bugs: the grep-first JSON extractor truncated at escaped quotes and failed OPEN on unparseable payloads; the deny JSON was printf-interpolated so a quote- or newline-bearing path silently no-oped the block; the freeze path read stripped INTERNAL spaces (a boundary like ~/My Project could never match); and the path resolver skipped the final component, letting an in-boundary symlink write through to an out-of-boundary target. Fixes, structurally: one shared sourced helper (careful/bin/hook-extract.sh) now owns JSON extraction and JSON-encoded decision envelopes for BOTH hooks -- the two-copy drift is how freeze kept a broken extractor after careful's was fixed. Freeze is now deny-tier fail-closed (unparseable payload denies, parsed-but-no-file_path still allows), trims only leading/trailing whitespace, and resolves symlinks through the final path component. Careful gains a HIGH tier (hard deny, simple commands only): recursive delete of /, ~, or $HOME, and force-push to the repo's default branch. Compound commands always fall through to the MEDIUM ask; --force-with-lease is never HIGH. Documented as a best-effort advisory hard-stop, not a policy boundary. Plus additive-only project patterns (~/.gstack/careful-patterns.txt + per-project file): config can only ADD warn rules, never suppress a baseline family. test/hook-scripts.test.ts: 89 tests incl. malformed-payload deny, parseable deny JSON for hostile paths, space-bearing boundaries, symlink escape, HIGH tier splits, additive invariant, invalid-regex resilience. Co-Authored-By: Claude Fable 5 * feat(review): content-addressed staleness via working-tree fingerprint Review records now bind to the content they were made on. bin/gstack-review-log stamps every appended record with commit_full, tree, dirty (informational) and wtree — a working-tree fingerprint from the new bin/gstack-wtree (temp index seeded from HEAD + git add -A + write-tree). The binding fields are computed authoritatively; caller-supplied values for those keys are ignored, so a stale rendered template or a forged field can't bind a record to content it wasn't made on. Why a working-tree fingerprint instead of HEAD^{tree}: committing identical content doesn't change it (a record made on a dirty tree stays valid after the same content is committed), untracked new source files DO change it (new code can't hide from freshness), and gitignored scratch stays out. Rebase, amend and squash with identical content grade CURRENT instead of stale. Grading: the dashboard (scripts/resolvers/review.ts) and /land-and-deploy Step 3.5a apply a content-first rule to diff-scoped review rows — wtree match with both sides clean is CURRENT, full stop. Plan-tier reviews grade a plan file, not the repo tree, so they keep the 7-day logic (optional plan_sha256 caller field noted). The rev-list fallback no longer errors when the stored commit was rebased away: it grades UNKNOWN and treats it as stale. bin/gstack-review-read emits ---WTREE---/---TREE---/---DIRTY--- so graders consume one tool output. Old records without wtree fall back to the existing heuristics; no migration. Co-Authored-By: Claude Fable 5 * feat(evidence): verification-evidence ledger mechanizes /ship's IRON LAW New bin/gstack-evidence: a transparent wrapper that records every verification run as {ts, label, command, cmd_sha256, exit, duration_s, commit, tree, dirty, wtree, log_path} in ~/.gstack/projects//-evidence.jsonl, plus a read-only `check` that grades FRESH/STALE/MISSING per label. "Tests passed" now binds to the exact working-tree content it ran on (bin/gstack-wtree fingerprint), so evidence recorded on uncommitted code stays FRESH after the exact tested content is committed — the /ship Step 5 -> Step 16 case — while an untracked new source file or any content change invalidates it. Check semantics: every named label's latest record must be green, within --max-age, matching --expect-cmd's hash when given, and fingerprint-identical (or diff confined to --allow-paths — mechanizing Step 16's existing "CHANGELOG edits don't count" carve-out). No --any mode: a green lane can never mask a red sibling. Any git failure inside check (gc'd tree object, not a repo) degrades to STALE/MISSING, never an error into the calling skill flow. Transparency invariant (load-bearing, test-pinned): the child's exit code is ALWAYS the wrapper's exit code; ledger/log/redact failures are stderr warnings. Logs are per-run (0600, exclusive-open, 2MB truncation marker, 30-day opportunistic prune) — no more shared /tmp collisions between concurrent ships. Command strings are redact-scanned before recording (HIGH credential -> stored redacted). Machine-local by design: neither ledger nor logs brain-sync. Wired: ship Step 5 lanes run wrapped (per-lane labels), ship Step 16 and land-and-deploy 3.5b check the ledger first and cite FRESH evidence instead of re-running; a failed CHECK never blocks (run live), a failed RUN does. test/evidence.test.ts: 21 tests incl. the keystone dirty-record -> commit -> FRESH case. Co-Authored-By: Claude Fable 5 * feat(security): trust envelope for tracker text at every model-context ingress Web page content has had a trust envelope since v1.38; tracker text did not — PR bodies, PR/issue comment bodies, and model-judged issue titles entered agent context raw. Anyone who can comment on a PR could put instructions in front of the agent. New lib/tracker-guard.ts + bin/gstack-issue-guard: every tracker-text read now emits inside a "BEGIN UNTRUSTED TRACKER CONTENT" envelope. Content is enveloped even when clean (a pattern scan is not proof of safety); injection-shaped lines get a visible [INJECTION-PATTERN] label; NFKC + zero-width normalization runs for DETECTION only (fullwidth/invisible evasion caught, content bytes never rewritten); forged END banners are zero-width-spliced so they can't close the envelope early. Fetch failure exits non-zero with NO envelope — never a fake-trusted empty one. Issue numbers are validated and gh is spawned via argv arrays. Patterns reuse lib/jsonl-store's INJECTION_PATTERNS single copy plus a separate TRACKER_EXTRA list (kept separate so decision/learning store write-rejection semantics don't change). 8 sites wired: greptile findings + replies fetches (metadata/body split — ids and paths stay machine-raw for reply POSTs), review.ts PR-body reads x2, land-and-deploy 3.5c, document-release PR/MR body (two-artifact flow: the enveloped rendering is what the agent READS, the raw tempfile is what the pipeline mutates, and a write-side banner tripwire aborts any edit that leaked envelope markup), and spec's issue-title dedupe (titles are model-judged for similarity, so they're ingress). Title-prefix rewrites and state-routing fetches are mechanical, not ingress — deliberately not enveloped. test/tracker-guard-wiring.test.ts is the CI tripwire: raw tracker-text reads outside the guard fail the suite unless carried by a reasoned SCANNER_EXEMPT entry; exemptions are liveness-checked so a moved site forces a re-audit. Co-Authored-By: Claude Fable 5 * chore(binding-wave): drift tripwire, golden fixtures, TODOS follow-ups test/binding-template-drift.test.ts pins the load-bearing prose rules in the GENERATED templates (ship Step 16 evidence check, per-lane wrapped test lanes, land-and-deploy wtree-first grading + UNKNOWN fallback, dashboard content-first rule, release-body banner tripwire, greptile guard pipes) so a template refactor can't silently drop a rule while the bins keep passing their unit tests. Golden ship fixtures re-pinned to the new intentional output (claude/codex/ factory variants). TODOS.md gains the five deferred follow-ups from the review wave: eval-run evidence records, spec-spawn outcome ledger, merge-SHA custody, default-if-silent escalations, and the paid eval case proving agents apply the staleness grading rule. Co-Authored-By: Claude Fable 5 * fix(careful): trim HIGH-tier + project-pattern docs under the size budget The new sections pushed careful/SKILL.md to 2551 -> 3879 bytes (x1.52, gate caps growth at x1.5 of the v1.47 baseline). Same content, tighter prose: 3516 bytes (x1.38). Co-Authored-By: Claude Fable 5 * fix(tests): scratch-repo fixtures never invoke the operator's gpg The evidence/review-log/hook fixtures inherited global commit.gpgsign, so fixture commits called the operator's gpg-agent — which fails with "Cannot allocate memory" under parallel shard load, breaking test SETUP (not the code under test). All fixture git invocations now pass -c commit.gpgsign=false -c tag.gpgsign=false. Hermetic repos, no pinentry. Co-Authored-By: Claude Fable 5 * fix: pre-landing review fixes (27 specialist findings, 3 critical) Specialist army findings, all quote-verified before fixing: Security: careful force-push guard now catches git's plus-refspec force syntax (git push origin +main carried force with no flag — silently allowed before) and refspec-form targets (HEAD:main); default-branch matching is tokenized FIXED-STRING comparison on the full branch path (slashed defaults like release/2.0 work; no ERE interpolation), glob-safe via noglob. HIGH rm tier is tokenized too: trailing long options (--no-preserve-root) and /* are root-class. Stored evidence fingerprints are 40-hex re-validated before reaching git argv. normalizeForDetection sweeps ALL Unicode format chars (\p{Cf}: soft hyphens, bidi marks, tag chars) instead of five enumerated zero-widths. The wiring scanner gains flagless gh pr/issue view patterns. The release-body banner tripwire diffs against the fetched original so a hostile pre-existing banner string can't permanently DoS doc updates. Ship/land evidence checks now pass --expect-cmd (a green `echo ok` recorded under the label can never mint FRESH); package.json stays allow-listed with the residual documented. Performance: gstack-wtree seeds its temp index by COPYING the real index (stat cache preserved — measured 40x faster than read-tree seeding, identical hash) with read-tree fallback; evidence uses findLast and one gstack-slug spawn; the stream pump honors backpressure via drain; careful's pattern block short-circuits before slug resolution when no pattern file exists. Testing: the gh-failure envelope test was VACUOUS (killing PATH killed the bun shebang before the code under test ran) — replaced with a PATH gh shim that exercises the real branch, plus shimmed happy paths (issue/pr-body/ unparseable JSON); evidence check --all + empty ledger + non-numeric --max-age (now a usage error, was silent fail-open) covered; HIGH-tier variants pinned; hook analytics respect GSTACK_HOME so tests stop writing the operator's real skill-usage.jsonl. Maintainability: dead exit ternary removed; flagValue deduped into bin-context; sentinel defusal derived from the banner constants (no invisible literals — \u escapes only); scratch-repo git fixture extracted to test/helpers/scratch-repo.ts (one hermetic incantation, three consumers); shared gstack_hook_log_fire in hook-extract.sh; the dashboard/land diff-scoped row lists are aligned (codex-review) and drift-pinned. Co-Authored-By: Claude Fable 5 * fix: red-team review fixes (9 findings, 2 critical) Red team reviewed what four specialists missed — cross-cutting and self-contradiction class: CRITICAL: the release-body banner tripwire failed OPEN on the exact leak it guards (grep -c prints 0 AND exits 1 on no-match, so a fallback echo double-emitted "0" twice and the -gt comparison fell into the clean branch) — counts now default via parameter expansion, and a functional drift test executes the rendered tripwire block against a 0->1 banner delta to prove the ABORT branch fires. CRITICAL: evidence fingerprints were captured AFTER the child exited, so a working-tree edit made DURING a long suite was certified as tested content — wtree is now captured before spawn and re-checked after; mid-run drift omits the fingerprint (grades STALE) with a warning. Also: the review-grading rule dropped its dirty-gates (they nullified the keystone dirty-record->commit->CURRENT property that evidence checks already honor — wtree equality alone proves identical content); careful's HIGH force-push tier falls back to probing origin/main|master when the origin/HEAD symbolic ref is absent (Conductor worktrees — the tier was silently inert in the primary deploy environment); quoted tokens (rm -rf "/", push "main") no longer dodge the deny; freeze fails CLOSED when its own helper file is missing (bash makes a missing source target fatal non-interactively, so an existence pre-check guards it); spec dedupe distinguishes pipeline failure from zero matches instead of silently skipping dedupe on gh/jq breakage; land 3.5b sets the cross-session --expect-cmd mismatch expectation; hook analytics JSON fields are encoder-built per this wave's own rule. Co-Authored-By: Claude Fable 5 * test: re-pin codex/factory golden fixtures post-regeneration The suite regenerates .agents/.factory in place mid-run; the prior pin snapshotted them before the dashboard-rule regen landed. Co-Authored-By: Claude Fable 5 * chore: bump version and changelog (v1.66.1.0) Co-Authored-By: Claude Fable 5 * fix: adversarial review fixes (Claude pass, 14 findings, 1 verified-live critical) The fresh-context adversarial pass caught a live bug in this branch's own performance fix: gstack-wtree exported GIT_INDEX_FILE BEFORE resolving the real index path, so `git rev-parse --git-path index` returned the temp index itself, the stat-cache copy self-copied and failed, and every invocation fell back to the full re-hash — the fast path was dead code (verified with bash -x). Resolution now happens before the export; measured 0.08s per call on this repo. Also fixed: careful fails to an ASK (not silence) when its own helper file is missing (same partial-install state freeze already defends against); the --source label is sanitized inside the envelope lib (newline-stripped, sentinel-defused, length-capped — it sits in trusted framing); the HIGH rm tokenizer skips redirections/backgrounding/`--` (rm -rf / 2>/dev/null now denies) and knows ${HOME}; user pattern lines starting with a dash work (grep --); greptile bodies carry per-comment id headers inside the envelope so multi-comment PRs stay attributable (ids verified against raw metadata, never trusted in-body); the release-body tripwire fails CLOSED when its input files are missing (separate-shell $$ reality); land 3.5b gets the same allow-paths as ship; the "either side dirty" fallback leftover is gone from both grading surfaces; the evidence pump races drain against error (EPIPE consumers can't hang the wrapper); an unset HOME skips bookkeeping instead of creating a literal ~ dir inside the repo; a write-failure log ends with a visible marker; freeze expands a literal leading ~ in the boundary; review-log documents its log-time binding window. Co-Authored-By: Claude Fable 5 * test: pin golden fixtures from --host all generation `bun run gen:skill-docs` generates the claude host only; .agents/.factory regenerate when the suite's --host codex/factory tests run in place. Fixture pins must come from `gen-skill-docs --host all` output or they lag one resolver edit behind and fail the next full-suite run. Co-Authored-By: Claude Fable 5 * test: assemble the fixture PAT by concatenation (no live-format literal) The repo's own pre-push credential guard (correctly) blocked the push: the redaction test's fabricated GitHub PAT was a live-format literal in the diff. The token is now concatenated at runtime — the source carries nothing the scanner can match, the engine still receives a live-format value. Co-Authored-By: Claude Fable 5 * docs: update project documentation for v1.66.1.0 CLAUDE.md: add gstack-wtree/gstack-evidence/gstack-issue-guard to the bin/ structure line and tracker-guard.ts to the lib/ line. README.md + docs/skills.md: /careful descriptions no longer claim every warning is overridable — the HIGH tier hard-denies root/home recursive deletes and default-branch force-pushes; skills.md also documents the additive-only careful-patterns.txt warn rules. Co-Authored-By: Claude Fable 5 * docs: doc-review fixes — new bins in README table, careful claims precise README.md: add gstack-wtree, gstack-evidence, and gstack-issue-guard to the Standalone binaries table (they shipped in v1.66.1.0 with no user-facing reference outside CHANGELOG). docs/skills.md: the safety-skills intro said "no configuration files" which the optional careful-patterns.txt now contradicts, and the hard-deny description undersold the deny set (the hook also denies /*, ~/, and $HOME/ forms, not just bare / and ~). Co-Authored-By: Claude Fable 5 * docs: guard reflects the hard-deny tier; changelog stats current guard/SKILL.md claimed every destructive warning was overridable — the shared careful hook now hard-denies the catastrophic shapes. CHANGELOG numbers updated to the final measured state (0.09s fingerprint, 50 findings/6 critical across all review passes). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 53 +++ CLAUDE.md | 4 +- README.md | 5 +- TODOS.md | 56 +++ VERSION | 2 +- bin/gstack-evidence | 445 ++++++++++++++++++ bin/gstack-issue-guard | 98 ++++ bin/gstack-review-log | 49 +- bin/gstack-review-read | 13 + bin/gstack-wtree | 50 ++ careful/SKILL.md | 20 +- careful/SKILL.md.tmpl | 20 +- careful/bin/check-careful.sh | 203 ++++++-- careful/bin/hook-extract.sh | 81 ++++ devex-review/SKILL.md | 7 +- docs/skills.md | 6 +- document-release/sections/release-body.md | 68 ++- .../sections/release-body.md.tmpl | 68 ++- freeze/SKILL.md | 14 +- freeze/SKILL.md.tmpl | 14 +- freeze/bin/check-freeze.sh | 85 +++- guard/SKILL.md | 2 +- guard/SKILL.md.tmpl | 2 +- land-and-deploy/SKILL.md | 53 ++- land-and-deploy/SKILL.md.tmpl | 53 ++- lib/tracker-guard.ts | 117 +++++ package.json | 2 +- plan-ceo-review/sections/review-sections.md | 7 +- .../sections/review-sections.md | 7 +- plan-devex-review/sections/review-sections.md | 7 +- plan-eng-review/sections/review-sections.md | 7 +- review/SKILL.md | 4 +- review/greptile-triage.md | 21 +- scripts/resolvers/review.ts | 11 +- ship/SKILL.md | 31 +- ship/SKILL.md.tmpl | 24 +- ship/sections/plan-completion.md | 2 +- ship/sections/tests.md | 15 +- ship/sections/tests.md.tmpl | 15 +- spec/SKILL.md | 18 +- spec/SKILL.md.tmpl | 18 +- test/binding-template-drift.test.ts | 96 ++++ test/evidence.test.ts | 316 +++++++++++++ test/fixtures/golden/claude-ship-SKILL.md | 31 +- test/fixtures/golden/codex-ship-SKILL.md | 48 +- test/fixtures/golden/factory-ship-SKILL.md | 48 +- test/helpers/scratch-repo.ts | 78 +++ test/hook-scripts.test.ts | 378 ++++++++++++++- test/review-log.test.ts | 135 ++++++ test/tracker-guard-wiring.test.ts | 140 ++++++ test/tracker-guard.test.ts | 162 +++++++ 51 files changed, 3029 insertions(+), 180 deletions(-) create mode 100755 bin/gstack-evidence create mode 100755 bin/gstack-issue-guard create mode 100755 bin/gstack-wtree create mode 100644 careful/bin/hook-extract.sh create mode 100644 lib/tracker-guard.ts create mode 100644 test/binding-template-drift.test.ts create mode 100644 test/evidence.test.ts create mode 100644 test/helpers/scratch-repo.ts create mode 100644 test/tracker-guard-wiring.test.ts create mode 100644 test/tracker-guard.test.ts 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