From 3b6502f79c570c42552e4a43123a4c12754c5d73 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 04:48:39 -0700 Subject: [PATCH 1/9] v1.91.207.0 fix(global-discover): count removed Claude worktree sessions with their repository (port of #3334) (#3387) * fix(global-discover): attribute sessions of removed Claude worktrees to their repository Claude Code creates worktrees at /.claude/worktrees/ and they are removed once the branch merges, but the sessions recorded under ~/.claude/projects stay. The scanner now discovers those sessions (#1315), but gitProject() returns null for a cwd that no longer exists, so resolveAndDeduplicate drops them: they are in tools.claude_code.total_sessions and in no repository's count. When the cwd is gone and sits at /.claude/worktrees/[/...], resolve the project from the repository it was created from. Any other missing directory is still unattributed, and a worktree that still exists resolves exactly as before. Fixture with 6 sessions in a main checkout and 40 in a removed worktree (46 discovered): repository count before 6, after 46. * docs(changelog): global-discover counts removed Claude worktree sessions with their repo (port of #3334) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Shweta-Mishra-ai Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-global-discover.ts | 15 ++++++ package.json | 2 +- test/global-discover.test.ts | 99 ++++++++++++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c73e8784a..5a5072300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.207.0] - 2026-10-25 + +**Per-repository session counts include sessions from removed Claude Code worktrees (ported from #3334 by @Shweta-Mishra-ai).** Claude Code creates worktrees at `/.claude/worktrees/` and removes them once their branch merges, but their session history stays. `gstack-global-discover` resolved each session's recorded directory with git, so a removed worktree resolved to nothing: its sessions counted in the tool total and in no repository. Reproduced on main: 2 sessions in a repo plus 3 in a removed worktree gave `total_sessions 5` but 2 for the repo. + +### Fixed +- **`bin/gstack-global-discover.ts`**: when a session's directory no longer exists and sits inside `/.claude/worktrees/` (or a subdirectory of it), the session counts with ``. Any other missing directory, and a worktree whose repository is gone too, still count in no repository. A live worktree keeps its own path, as before. + +### Tests +- `test/global-discover.test.ts` gains six end-to-end cases built with real `git worktree add` and `remove` (removed worktree, a subdirectory of one, a repo with no remote, plus the live-worktree, gone-repo and plain-missing-directory controls) and table tests for the path matcher. The three positive cases and the matcher tests fail on main. + ## [1.91.206.0] - 2026-10-25 **A repo that keeps `.gstack/` out of git can give pregate a policy, and name the CI lanes a local run satisfies (#3286).** On a shared repo where gstack is one contributor's tool, pregate fell back to defaults, so every platform lane the machine could not run (an `ubuntu-latest` suite on a Mac, say) became a `requires-remote` obligation that never cleared, even when that suite runs the same here. Ported from #3286 by arlophoenix, whose 13 commits carry the feature (merged with their history kept); our commits on top close two findings from a security-focused review of the port. diff --git a/VERSION b/VERSION index 32e1b690a..c5d5571bc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.206.0 +1.91.207.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 216383b07..2ce846c8e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.206.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.207.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-global-discover.ts b/bin/gstack-global-discover.ts index d8d8d0e9f..88cfbfdbe 100644 --- a/bin/gstack-global-discover.ts +++ b/bin/gstack-global-discover.ts @@ -186,11 +186,26 @@ function gitProject(cwd: string): GitProject | null { const up = prefix.split("/").filter(Boolean).map(() => ".."); project = { root: resolve(cwd, ...up), main: basename(common) === ".git" ? dirname(common) : common }; } + } else { + // A merged branch's worktree is removed but its session history stays: + // count those sessions with the repository the worktree was created from. + const parent = parentRepoOfClaudeWorktree(cwd); + if (parent) project = gitProject(parent); } PROJECT_MEMO.set(cwd, project); return project; } +/** + * Claude Code creates worktrees at `/.claude/worktrees/`. Given a + * cwd inside one (the worktree root or any subdirectory), the repository root + * it was created from; null for any other path. + */ +export function parentRepoOfClaudeWorktree(cwd: string): string | null { + const m = cwd.match(/^(.+?)[\\/]\.claude[\\/]worktrees[\\/][^\\/]+(?:[\\/].*)?$/); + return m ? m[1] : null; +} + function getGitRemote(root: string): string | null { const r = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf-8", diff --git a/package.json b/package.json index afd5b5d31..53b6f1d2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.206", + "version": "1.91.207", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/global-discover.test.ts b/test/global-discover.test.ts index fd0c80631..8e56a6b42 100644 --- a/test/global-discover.test.ts +++ b/test/global-discover.test.ts @@ -644,5 +644,104 @@ describe("gstack-global-discover", () => { expect(json.repos).toHaveLength(1); expect(json.repos[0].sessions.claude_code).toBe(1); }); + + // Claude Code worktrees live at /.claude/worktrees/ and are removed once + // their branch merges; the session history under ~/.claude/projects stays behind. + function removedWorktree(main: string, name = "feat-x"): string { + const wt = join(main, ".claude", "worktrees", name); + git(main, "worktree", "add", "-q", wt); + return wt; + } + + test("sessions of a removed Claude worktree count with the repository it was created from", () => { + const main = repo("work/ai-blogger-lab", "https://github.com/acme/ai-blogger-lab.git"); + const wt = removedWorktree(main); + session(main, "main-1"); + session(main, "main-2"); + session(wt, "wt-1"); + session(wt, "wt-2"); + session(wt, "wt-3"); + git(main, "worktree", "remove", "--force", wt); + expect(existsSync(wt)).toBe(false); + const json = discover(); + expect(json.tools.claude_code.total_sessions).toBe(5); + expect(json.repos).toHaveLength(1); + expect(json.repos[0].sessions.claude_code).toBe(5); + expect(json.repos[0].paths).toEqual([main]); + }); + + test("a session started in a subdirectory of a removed Claude worktree counts too", () => { + const main = repo("work/app", "git@github.com:acme/app.git"); + const wt = removedWorktree(main); + mkdirSync(join(wt, "packages", "web"), { recursive: true }); + session(join(wt, "packages", "web"), "sub-1"); + git(main, "worktree", "remove", "--force", wt); + const r = discover().repos.find((x) => x.remote === "https://github.com/acme/app")!; + expect(r.sessions.claude_code).toBe(1); + expect(r.paths).toEqual([main]); + }); + + test("a removed Claude worktree of a repository with no remote counts with its repository", () => { + const main = repo("work/local-only"); + const wt = removedWorktree(main); + session(main, "l-1"); + session(wt, "l-2"); + git(main, "worktree", "remove", "--force", wt); + const local = discover().repos.filter((x) => x.remote.startsWith("local:")); + expect(local).toHaveLength(1); + expect(local[0].sessions.claude_code).toBe(2); + }); + + test("a live Claude worktree keeps its own path next to its repository's", () => { + const main = repo("work/live", "https://github.com/acme/live.git"); + const wt = removedWorktree(main); + session(main, "m-1"); + session(wt, "w-1"); + const r = discover().repos.find((x) => x.remote === "https://github.com/acme/live")!; + expect(r.sessions.claude_code).toBe(2); + expect(r.paths.sort()).toEqual([main, wt].sort()); + }); + + test("a removed Claude worktree whose repository is also gone is not counted as a repository", () => { + const gone = join(home, "work", "gone-repo"); + session(join(gone, ".claude", "worktrees", "feat-x"), "g-1"); + const json = discover(); + expect(json.tools.claude_code.total_sessions).toBe(1); + expect(json.repos).toHaveLength(0); + }); + + test("a removed directory that is not a Claude worktree is not counted as a repository", () => { + const main = repo("work/real", "https://github.com/acme/real.git"); + session(join(main, "some", "deleted-dir"), "d-1"); + session(join(main, ".claude", "worktrees"), "d-2"); + session(main, "r-1"); + const json = discover(); + expect(json.tools.claude_code.total_sessions).toBe(3); + expect(json.repos).toHaveLength(1); + expect(json.repos[0].sessions.claude_code).toBe(1); + }); + + describe("parentRepoOfClaudeWorktree", () => { + let parentRepoOfClaudeWorktree: (cwd: string) => string | null; + beforeEach(async () => { + parentRepoOfClaudeWorktree = (await import("../bin/gstack-global-discover.ts")).parentRepoOfClaudeWorktree; + }); + + test("returns the repository root for a worktree path, with or without a subdirectory", () => { + expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x")).toBe("/Users/me/code/app"); + expect(parentRepoOfClaudeWorktree("/Users/me/code/app/.claude/worktrees/feat-x/src/lib")).toBe("/Users/me/code/app"); + expect(parentRepoOfClaudeWorktree("C:\\code\\app\\.claude\\worktrees\\feat-x")).toBe("C:\\code\\app"); + }); + + test("returns null for any other path", () => { + for (const p of [ + "/Users/me/code/app", + "/Users/me/code/app/.claude/worktrees", + "/Users/me/code/app/.claude/worktrees/", + "/Users/me/code/not.claude/worktrees/x", + "/.claude/worktrees/x", + ]) expect(parentRepoOfClaudeWorktree(p)).toBeNull(); + }); + }); }); }); From e2fffee3114e90ce2a7d165f09d3efce3555e252 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 05:01:22 -0700 Subject: [PATCH 2/9] v1.91.208.0 docs(capy-install): session-start updater honors workflow leases (#3363, GSTA-75) (#3399) * docs(capy-install): session-start updater honors workflow leases (#3363, GSTA-75) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.208.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/capy-install.md | 6 +++++- package.json | 2 +- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5072300..652d0f91a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.91.208.0] - 2026-10-25 + +**The Capy install guide says that the team-mode session-start updater also waits for live workflow leases.** With #3385 (#3363), `bin/gstack-session-update` skips its background fast-forward while another session holds a live workflow lease, the same leases `bin/gstack-upgrade` already honors. `docs/capy-install.md` described only the upgrade side. + +### Documentation +- **`docs/capy-install.md`**: the workflow-lease paragraph now says the team-mode SessionStart hook (`bin/gstack-session-update`) honors the same leases. It logs `SKIP leased pid=

/m` to `analytics/session-update.log` and tries again at the next session start, without writing its hourly throttle stamp. + ## [1.91.207.0] - 2026-10-25 **Per-repository session counts include sessions from removed Claude Code worktrees (ported from #3334 by @Shweta-Mishra-ai).** Claude Code creates worktrees at `/.claude/worktrees/` and removes them once their branch merges, but their session history stays. `gstack-global-discover` resolved each session's recorded directory with git, so a removed worktree resolved to nothing: its sessions counted in the tool total and in no repository. Reproduced on main: 2 sessions in a repo plus 3 in a removed worktree gave `total_sessions 5` but 2 for the repo. diff --git a/VERSION b/VERSION index c5d5571bc..b3d823cb4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.207.0 +1.91.208.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2ce846c8e..2283fc59f 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.207.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.208.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/capy-install.md b/docs/capy-install.md index a52e9aa20..c2d0ce97b 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -109,7 +109,11 @@ and `/proc` start time; elsewhere `ps -o lstart`), because a pid is reused after a reboot or a machine wake. `bin/gstack-upgrade`, and so the session-start self-upgrade, refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy) while a lease held by a live process outside its own chain exists, naming each -holder as `pid

(m )`. A lease whose pid now belongs to another +holder as `pid

(m )`. The team-mode SessionStart hook +(`bin/gstack-session-update`) honors the same leases: it logs +`SKIP leased pid=

/m` to `analytics/session-update.log` and tries again at +the next session start, without writing its hourly throttle stamp. A lease whose +pid now belongs to another process, or an identity-less lease from an older gstack past 120 minutes, is stale: `bin/gstack-lease live` reclaims it (`gstack-lease: reclaimed stale lease …`). Stale needs proof: two well-formed identities from the same backend diff --git a/package.json b/package.json index 53b6f1d2f..cd2c1099d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.207", + "version": "1.91.208", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", From 0ea3dc8986b7a6d43127d5d6c249ff05b5ea6672 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 05:14:02 -0700 Subject: [PATCH 3/9] test(redact): fixture git and hook installs share gitEnv(), so a global core.hooksPath is never rewritten (#3353) (#3384) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- test/fs-utils.test.ts | 4 +- test/helpers/git-env.ts | 20 ++++ test/redact-prepush-hook.test.ts | 31 +++--- test/redact-prepush-rebase-force-push.test.ts | 12 +-- test/redact-prepush-scan-range.test.ts | 16 +-- test/redact-prepush-target.test.ts | 101 ++++++++++++------ 10 files changed, 127 insertions(+), 75 deletions(-) create mode 100644 test/helpers/git-env.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 652d0f91a..7cdb8f066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.91.209.0] - 2026-10-25 + +**The free test suite no longer rewrites a contributor's global git pre-push hook (#3353).** On a machine with a global `core.hooksPath`, `test/redact-prepush-target.test.ts` and `test/fs-utils.test.ts` ran `gstack-redact install-prepush-hook` with the parent environment. Git resolved the hooks directory to the global one, so the install moved the developer's own `pre-push` to `pre-push.local` and left a managed wrapper pointing into the test checkout, which breaks every later push once that checkout is gone. The same config also failed 4 tests in the target file, 5 in `test/redact-prepush-hook.test.ts` and 1 in `fs-utils`. CI has no global hooks path, so it stayed green. + +### Fixed +- **`test/helpers/git-env.ts`** (new): `gitEnv()` is the one fixture environment for git and hook installs: `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_COUNT=0` and no `GIT_CONFIG_PARAMETERS`. Config written inside the fixture repo still applies. +- `test/redact-prepush-target.test.ts`, `test/redact-prepush-hook.test.ts` and `test/fs-utils.test.ts` pass it to every fixture git, install and hook spawn. `test/redact-prepush-scan-range.test.ts` and `test/redact-prepush-rebase-force-push.test.ts` drop their private copies (#2967) and import it. + +### Tests +- A new case in `test/redact-prepush-target.test.ts` sets a global, system and environment `core.hooksPath` to a hook that fails, builds the fixture, and checks the developer's hooks directory is untouched, the managed hook landed in the fixture repo, and a push never runs the developer's hook. It fails without the shared environment. +- Rerun of the issue's repro with `HOME` pointing at a gitconfig with a global hooks dir: on main, two files rewrote it and three files had failures; with this change all six `redact-prepush-*` and `fs-utils` files pass and the directory is unchanged. + ## [1.91.208.0] - 2026-10-25 **The Capy install guide says that the team-mode session-start updater also waits for live workflow leases.** With #3385 (#3363), `bin/gstack-session-update` skips its background fast-forward while another session holds a live workflow lease, the same leases `bin/gstack-upgrade` already honors. `docs/capy-install.md` described only the upgrade side. diff --git a/VERSION b/VERSION index b3d823cb4..32b3c2d81 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.208.0 +1.91.209.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2283fc59f..bfdfaa808 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.208.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.209.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/package.json b/package.json index cd2c1099d..da6af4fc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.208", + "version": "1.91.209", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/fs-utils.test.ts b/test/fs-utils.test.ts index f15c45199..e2caa2f53 100644 --- a/test/fs-utils.test.ts +++ b/test/fs-utils.test.ts @@ -14,6 +14,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { spawnSync } from "child_process"; +import { gitEnv } from "./helpers/git-env"; import { mkdirpSync } from "../lib/fs-utils"; const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact"); @@ -93,7 +94,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", ( const base = tmpdir(); try { const repo = path.join(base, "repo"); - spawnSync("git", ["init", "-q", repo], { timeout: 30_000 }); + spawnSync("git", ["init", "-q", repo], { timeout: 30_000, env: gitEnv() }); const hookDir = path.join(repo, ".git", "hooks"); fs.mkdirSync(hookDir, { recursive: true }); const hookPath = path.join(hookDir, "pre-push"); @@ -106,6 +107,7 @@ describe("install-prepush-hook under bun-on-Windows EEXIST semantics (#2635)", ( cwd: repo, encoding: "utf8", timeout: 30_000, + env: gitEnv(), }); expect(r.status).toBe(0); expect(r.stderr ?? "").not.toContain("EEXIST"); diff --git a/test/helpers/git-env.ts b/test/helpers/git-env.ts new file mode 100644 index 000000000..48b4a9eff --- /dev/null +++ b/test/helpers/git-env.ts @@ -0,0 +1,20 @@ +/** + * Environment for fixture git commands and `gstack-redact install-prepush-hook`. + * + * With the parent environment, a developer's global or system core.hooksPath + * (or the same key via GIT_CONFIG_COUNT / GIT_CONFIG_PARAMETERS) reaches the + * fixture: `git rev-parse --git-path hooks` resolves to that directory, so the + * install rewrites the developer's own pre-push (#3353), and their hook runs + * on fixture pushes and rejects the local remote (#2967). /dev/null drops the + * global file on every platform: Git for Windows rejects NUL (see + * EMPTY_GLOBAL_GIT_CONFIG in lib/cso/process.ts). Config written inside the + * fixture repo still applies. + */ +export function gitEnv(extra: Record = {}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, ...extra }; + env.GIT_CONFIG_GLOBAL = "/dev/null"; + env.GIT_CONFIG_NOSYSTEM = "1"; + env.GIT_CONFIG_COUNT = "0"; + delete env.GIT_CONFIG_PARAMETERS; + return env; +} diff --git a/test/redact-prepush-hook.test.ts b/test/redact-prepush-hook.test.ts index 964e4c511..84c07690b 100644 --- a/test/redact-prepush-hook.test.ts +++ b/test/redact-prepush-hook.test.ts @@ -13,6 +13,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { spawnSync } from "child_process"; +import { gitEnv } from "./helpers/git-env"; const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush"); const REDACT = path.resolve(import.meta.dir, "..", "bin", "gstack-redact"); @@ -21,7 +22,7 @@ let repo: string; let stateHome: string; function git(args: string[], cwd = repo): string { - const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 }); + const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() }); return r.stdout?.trim() ?? ""; } @@ -40,7 +41,7 @@ function runHook( cwd: repo, input: Buffer.from(stdinLines), encoding: "utf8", - env: { ...process.env, ...env }, + env: gitEnv(env), timeout: 30_000, }); return { code: r.status ?? 0, stderr: r.stderr ?? "" }; @@ -439,7 +440,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => { const r = spawnSync("bash", ["-c", script], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], // stdin not a TTY - env: { ...process.env, GSTACK_HOME: home }, + env: gitEnv({ GSTACK_HOME: home }), timeout: 15_000, }); expect(r.status).toBe(0); @@ -467,7 +468,7 @@ describe("install UX surfaces (#1946 / eng review D3+D10)", () => { const r = spawnSync("bash", ["-c", script], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, GSTACK_HOME: home }, + env: gitEnv({ GSTACK_HOME: home }), timeout: 15_000, }); expect(r.status).toBe(0); @@ -502,7 +503,7 @@ describe("install / chaining", () => { const existing = path.join(hookDir, "pre-push"); fs.writeFileSync(existing, "#!/usr/bin/env bash\necho mine\n", { mode: 0o755 }); - const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const r = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(r.status).toBe(0); const installed = fs.readFileSync(existing, "utf8"); expect(installed).toContain("gstack-redact pre-push (managed)"); @@ -516,7 +517,7 @@ describe("install / chaining", () => { test("chained pre-push.local receives the final ref line (trailing newline preserved)", () => { const hookDir = path.join(repo, ".git", "hooks"); fs.mkdirSync(hookDir, { recursive: true }); - spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 }); + spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() }); const seen = path.join(repo, "seen.txt"); fs.writeFileSync( @@ -531,7 +532,7 @@ describe("install / chaining", () => { cwd: repo, input: Buffer.from(line), encoding: "utf8", - env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }, + env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }), timeout: 30_000, }); expect(r.status).toBe(0); @@ -545,7 +546,7 @@ describe("install / chaining", () => { test("a blocking pre-push.local still short-circuits the push", () => { const hookDir = path.join(repo, ".git", "hooks"); fs.mkdirSync(hookDir, { recursive: true }); - spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 }); + spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() }); fs.writeFileSync( path.join(hookDir, "pre-push.local"), "#!/usr/bin/env bash\nwhile read -r _a _b _c _d || [ -n \"${_a:-}\" ]; do exit 1; done\nexit 0\n", @@ -555,7 +556,7 @@ describe("install / chaining", () => { cwd: repo, input: Buffer.from(`refs/heads/main ${"b".repeat(40)} refs/heads/main ${ZERO}\n`), encoding: "utf8", - env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }, + env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }), timeout: 30_000, }); expect(r.status).toBe(1); @@ -603,6 +604,7 @@ describe("install / chaining", () => { timeout: 30_000, cwd: repo, encoding: "utf8", + env: gitEnv(), }); expect(r.status).toBe(0); expect(r.stdout).toContain("refreshed stale managed pre-push hook"); @@ -618,7 +620,7 @@ describe("install / chaining", () => { cwd: repo, input: Buffer.from(`refs/heads/main ${sha} refs/heads/main ${ZERO}\n`), encoding: "utf8", - env: { ...process.env, GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }, + env: gitEnv({ GSTACK_REDACT_PREPUSH: "skip", GSTACK_HOME: stateHome }), }); expect(run.status).toBe(0); expect(fs.readFileSync(path.join(stateHome, "security", "prepush-skip.jsonl"), "utf8")).toContain("env-skip"); @@ -630,13 +632,14 @@ describe("install / chaining", () => { fs.mkdirSync(hookDir, { recursive: true }); const hook = path.join(hookDir, "pre-push"); - spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo }); + spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, env: gitEnv() }); const first = fs.readFileSync(hook, "utf8"); const stamp = fs.statSync(hook).mtimeMs; const again = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", + env: gitEnv(), }); expect(again.status).toBe(0); expect(again.stdout).toContain("already installed"); @@ -651,8 +654,8 @@ describe("install / chaining", () => { fs.writeFileSync(path.join(hookDir, "pre-push"), "#!/usr/bin/env bash\necho mine\n", { mode: 0o755, }); - spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000 }); - spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo, timeout: 30_000 }); + spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() }); + spawnSync("bun", [REDACT, "uninstall-prepush-hook"], { cwd: repo, timeout: 30_000, env: gitEnv() }); const restored = fs.readFileSync(path.join(hookDir, "pre-push"), "utf8"); expect(restored).toContain("echo mine"); expect(restored).not.toContain("managed"); @@ -671,7 +674,7 @@ describe("base resolution when the default branch is neither main nor master", ( // blocks the push having scanned NOTHING — the "scans more, never less" // fallback inverting into "scans nothing". const bare = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-remote-")); - spawnSync("git", ["init", "-q", "--bare", "-b", "trunk", bare], { timeout: 30_000 }); + spawnSync("git", ["init", "-q", "--bare", "-b", "trunk", bare], { timeout: 30_000, env: gitEnv() }); git(["branch", "-M", "trunk"]); const old = commit("legacy.txt", FAKE_AWS_KEY + "\n", "secret already on the remote"); diff --git a/test/redact-prepush-rebase-force-push.test.ts b/test/redact-prepush-rebase-force-push.test.ts index 5b9af16d2..c6e0da0cd 100644 --- a/test/redact-prepush-rebase-force-push.test.ts +++ b/test/redact-prepush-rebase-force-push.test.ts @@ -31,23 +31,13 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { spawnSync } from "child_process"; +import { gitEnv } from "./helpers/git-env"; const PREPUSH = path.resolve(import.meta.dir, "..", "bin", "gstack-redact-prepush"); let repo: string; let remote: string; -// Same blanking as test/redact-prepush-scan-range.test.ts (#2967). A -// hooksPath written into the fixture repo still runs. -function gitEnv(): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env }; - env.GIT_CONFIG_GLOBAL = "/dev/null"; - env.GIT_CONFIG_NOSYSTEM = "1"; - env.GIT_CONFIG_COUNT = "0"; - delete env.GIT_CONFIG_PARAMETERS; - return env; -} - function git(args: string[], cwd = repo): string { const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() }); if (r.status !== 0) throw new Error(`git ${args.join(" ")}\n${r.stderr}`); diff --git a/test/redact-prepush-scan-range.test.ts b/test/redact-prepush-scan-range.test.ts index 316bf7787..c29003b01 100644 --- a/test/redact-prepush-scan-range.test.ts +++ b/test/redact-prepush-scan-range.test.ts @@ -17,25 +17,11 @@ */ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { spawnSync } from "child_process"; +import { gitEnv } from "./helpers/git-env"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { dirname, join } from "path"; -// Fixture git was spawned with the parent environment. A global or system -// core.hooksPath, or the same key via GIT_CONFIG_COUNT / -// GIT_CONFIG_PARAMETERS, runs on these pushes and rejects the local remote -// (#2967). /dev/null drops the global file on every platform: Git for -// Windows rejects NUL (see EMPTY_GLOBAL_GIT_CONFIG in lib/cso/process.ts). -// Config written inside the fixture repo still applies. -function gitEnv(): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...process.env }; - env.GIT_CONFIG_GLOBAL = "/dev/null"; - env.GIT_CONFIG_NOSYSTEM = "1"; - env.GIT_CONFIG_COUNT = "0"; - delete env.GIT_CONFIG_PARAMETERS; - return env; -} - let dir: string; const run = (args: string[], cwd = dir): string => { const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() }); diff --git a/test/redact-prepush-target.test.ts b/test/redact-prepush-target.test.ts index 4635722ee..250512d8c 100644 --- a/test/redact-prepush-target.test.ts +++ b/test/redact-prepush-target.test.ts @@ -4,13 +4,14 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { scan } from "../lib/redact-engine"; +import { gitEnv } from "./helpers/git-env"; const REDACT = path.resolve(import.meta.dir, "../bin/gstack-redact"); const roots: string[] = []; const key = ["AKIA", "1234567890ABCDEF"].join(""); function git(cwd: string, ...args: string[]): string { - const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000 }); + const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: gitEnv() }); if (result.status !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr}`); return result.stdout.trim(); } @@ -35,7 +36,7 @@ function fixture(initialSecret = true, sha256 = false): { repo: string; origin: git(repo, "remote", "add", "origin", origin); git(repo, "push", "-q", "-u", "origin", "main"); git(repo, "remote", "add", "publish", publish); - const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const install = spawnSync("bun", [REDACT, "install-prepush-hook"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(install.status).toBe(0); return { repo, origin, publish, head }; } @@ -58,11 +59,49 @@ afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); +describe("fixture installs ignore the developer's hook config (#3353)", () => { + test("a global, system or environment core.hooksPath is neither rewritten nor run", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "prepush-target-global-")); + roots.push(root); + const hooks = path.join(root, "global-hooks"); + fs.mkdirSync(hooks); + const original = "#!/bin/sh\necho developer-hook >&2\nexit 1\n"; + fs.writeFileSync(path.join(hooks, "pre-push"), original, { mode: 0o755 }); + const config = path.join(root, "gitconfig"); + fs.writeFileSync(config, `[core]\n\thooksPath = ${JSON.stringify(hooks)}\n`); + const keys = ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM", "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0", "GIT_CONFIG_PARAMETERS"] as const; + const prev = Object.fromEntries(keys.map(key => [key, process.env[key]])); + try { + process.env.GIT_CONFIG_GLOBAL = config; + process.env.GIT_CONFIG_SYSTEM = config; + delete process.env.GIT_CONFIG_NOSYSTEM; + process.env.GIT_CONFIG_COUNT = "1"; + process.env.GIT_CONFIG_KEY_0 = "core.hooksPath"; + process.env.GIT_CONFIG_VALUE_0 = hooks; + process.env.GIT_CONFIG_PARAMETERS = `'core.hooksPath=${hooks}'`; + const { repo } = fixture(false); + expect(fs.readdirSync(hooks)).toEqual(["pre-push"]); + expect(fs.readFileSync(path.join(hooks, "pre-push"), "utf8")).toBe(original); + const installed = git(repo, "rev-parse", "--path-format=absolute", "--git-path", "hooks/pre-push"); + expect(fs.readFileSync(installed, "utf8")).toContain("gstack-redact pre-push (managed)"); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); + expect(push.status, push.stderr).toBe(0); + expect(push.stderr).not.toContain("developer-hook"); + } finally { + for (const [key, value] of Object.entries(prev)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); +}); + describe("installed pre-push guard uses the actual destination", () => { test("origin tracking at HEAD cannot excuse the same credential on first push to publish", () => { const { repo, publish, head } = fixture(); expect(git(repo, "rev-parse", "origin/main")).toBe(head); - const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe(""); @@ -71,7 +110,7 @@ describe("installed pre-push guard uses the actual destination", () => { test("a configured remote's different push URL cannot reuse its fetch tracking tip", () => { const { repo, publish } = fixture(); git(repo, "remote", "set-url", "--push", "origin", publish); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe(""); @@ -92,7 +131,7 @@ describe("installed pre-push guard uses the actual destination", () => { git(repo, "update-ref", "refs/heads/publish/main", tip); expect(git(repo, "rev-parse", "publish/main")).toBe(tip); expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(base); - const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe(""); @@ -108,7 +147,7 @@ describe("installed pre-push guard uses the actual destination", () => { expect(git(repo, "rev-parse", "refs/remotes/publish/main")).toBe(git(repo, "rev-parse", "HEAD")); git(repo, "remote", "set-url", "--add", "publish", second); expect(git(repo, "remote", "get-url", "publish")).toBe(publish); - const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(second, "for-each-ref", "--format=%(refname)")).toBe(""); @@ -116,7 +155,7 @@ describe("installed pre-push guard uses the actual destination", () => { test("direct URL push cannot borrow origin's tracking history", () => { const { repo, publish } = fixture(); - const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", publish, "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(publish, "for-each-ref", "--format=%(refname)")).toBe(""); @@ -125,8 +164,8 @@ describe("installed pre-push guard uses the actual destination", () => { test("an advertised remote tip absent from local objects cannot justify a guessed base", () => { const { repo, publish } = fixture(); const remoteTip = seedIndependentRemote(repo, publish); - expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0); - const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000, env: gitEnv() }).status).not.toBe(0); + const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(publish, "rev-parse", "refs/heads/main")).toBe(remoteTip); @@ -135,21 +174,21 @@ describe("installed pre-push guard uses the actual destination", () => { test("an absent advertised tip permits a clean forced update after conservative scanning", () => { const { repo, publish, head } = fixture(false); const remoteTip = seedIndependentRemote(repo, publish); - expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000 }).status).not.toBe(0); - const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + expect(spawnSync("git", ["cat-file", "-e", remoteTip], { cwd: repo, timeout: 30_000, env: gitEnv() }).status).not.toBe(0); + const push = spawnSync("git", ["push", "--force", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(git(publish, "rev-parse", "refs/heads/main")).toBe(head); }); test("new SHA-256 branch scans the correct empty tree and allows a clean push", () => { const { repo, publish } = fixture(false, true); - const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "publish", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(git(publish, "rev-parse", "refs/heads/main")).toHaveLength(64); fs.writeFileSync(path.join(repo, "leak.txt"), `key ${key}\n`); git(repo, "add", "leak.txt"); git(repo, "commit", "-qm", "new secret"); - const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const blocked = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(blocked.status).toBe(1); expect(blocked.stderr).toContain("aws.access_key"); }); @@ -164,7 +203,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "clean.txt"), "safe update\n"); git(repo, "add", "clean.txt"); git(repo, "commit", "-qm", "clean branch"); - const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main", "leak"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head); @@ -183,7 +222,7 @@ describe("installed pre-push guard uses the actual destination", () => { git(repo, "add", "clean.txt"); git(repo, "commit", "-qm", "clean update"); const head = git(repo, "rev-parse", "HEAD"); - const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main", ":disposable"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head); expect(git(origin, "for-each-ref", "--format=%(refname)", "refs/heads/disposable")).toBe(""); @@ -193,7 +232,7 @@ describe("installed pre-push guard uses the actual destination", () => { const { repo, origin, head } = fixture(false); const hook = git(repo, "rev-parse", "--git-path", "hooks/pre-push"); const result = spawnSync("bash", [hook, "origin", origin], { - cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000, + cwd: repo, input: "refs/heads/main malformed\n", encoding: "utf8", timeout: 30_000, env: gitEnv(), }); expect(result.status, result.stderr || result.error?.message).toBe(1); expect(result.stderr).toContain("could not parse a pre-push ref line"); @@ -208,7 +247,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "near seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.secret_key"); }); @@ -221,7 +260,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "unlabeled seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(push.stderr).not.toContain("aws.secret_key"); }); @@ -232,7 +271,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "one finding at seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1); }); @@ -247,7 +286,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "supplementary normalization seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr.match(/HIGH aws\.access_key/g)).toHaveLength(1); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head); @@ -264,7 +303,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "mid-line control"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(push.stderr).not.toContain("MEDIUM finding"); }); @@ -277,7 +316,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "embedded key control"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(push.stderr).not.toContain("aws.access_key"); expect(git(origin, "rev-parse", "refs/heads/main")).not.toBe(head); @@ -294,7 +333,7 @@ describe("installed pre-push guard uses the actual destination", () => { git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "long bearer advisory"); const tip = git(repo, "rev-parse", "HEAD"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(push.stderr).toContain("MEDIUM finding"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip); @@ -308,7 +347,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "invisible seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.secret_key"); }); @@ -320,7 +359,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "unscannable context"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("engine.input_too_large"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head); @@ -339,7 +378,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "payload.txt"), payload); git(repo, "add", "payload.txt"); git(repo, "commit", "-qm", "normalization seam"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(expected ? 1 : 0); expect(push.stderr.includes("aws.secret_key")).toBe(expected); }); @@ -350,19 +389,19 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "large.txt"), "ordinary content\n".repeat(160_000)); git(repo, "add", "large.txt"); git(repo, "commit", "-qm", "large clean diff"); - let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 }); + let push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() }); expect(push.status).toBe(0); fs.writeFileSync(path.join(repo, "long.txt"), "x".repeat(900_000) + "\n"); git(repo, "add", "long.txt"); git(repo, "commit", "-qm", "long clean line"); - push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 }); + push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() }); expect(push.status).toBe(0); const priorTip = git(origin, "rev-parse", "refs/heads/main"); expect(priorTip).not.toBe(head); fs.writeFileSync(path.join(repo, "too-long.txt"), "x".repeat(1_100_000) + "\n"); git(repo, "add", "too-long.txt"); git(repo, "commit", "-qm", "over cap line"); - push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000 }); + push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("engine.input_too_large"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(priorTip); @@ -373,7 +412,7 @@ describe("installed pre-push guard uses the actual destination", () => { fs.writeFileSync(path.join(repo, "long.txt"), `${"x".repeat(900_000)} key ${key}\n`); git(repo, "add", "long.txt"); git(repo, "commit", "-qm", "long line with key"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(1); expect(push.stderr).toContain("aws.access_key"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(head); @@ -385,7 +424,7 @@ describe("installed pre-push guard uses the actual destination", () => { git(repo, "add", "long.txt"); git(repo, "commit", "-qm", "neighboring long lines"); const tip = git(repo, "rev-parse", "HEAD"); - const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000 }); + const push = spawnSync("git", ["push", "origin", "main"], { cwd: repo, encoding: "utf8", timeout: 30_000, env: gitEnv() }); expect(push.status).toBe(0); expect(push.stderr).not.toContain("engine.input_too_large"); expect(git(origin, "rev-parse", "refs/heads/main")).toBe(tip); From c279ce045d138285a24a0ad84b6ed2096f534812 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 05:27:01 -0700 Subject: [PATCH 4/9] fix(autoplan): linked worktrees resolve from a project directory below the repo root (#3296) (#3383) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 ++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- autoplan/bin/phase-publication-hook.ts | 17 +++++-- package.json | 2 +- test/autoplan-publication-hook.test.ts | 62 +++++++++++++++++++++++++- 6 files changed, 90 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cdb8f066..10a868365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.91.210.0] - 2026-10-25 + +**/autoplan works when Claude Code's project directory is a subfolder of the repository (#3296).** When the git root sits above the project (for example the root is `$HOME` and the session starts in `$HOME/projects/app`), a worktree session's journal lives in `.claude/worktrees/` under that subfolder. The publication guard only looked for `/.git/worktrees`, found nothing, and denied every phase Read with `foreign_cwd`, so the reporter had to restart the session from the repository root. + +### Fixed +- **`autoplan/bin/phase-publication-hook.ts`**: `linkedWorktrees` walks up from the project directory to the first `.git` and reads that repository's worktree registry. A `.git` file (the project is itself a linked worktree or a submodule) stops the walk with no owners, as before. Git's two-way link is still required: the registry entry must name the worktree's `.git` file and that file must name the same entry back. No `git` process is started inside the hook. + +### Tests +- `test/autoplan-publication-hook.test.ts` builds the reporter's layout with real `git worktree add`: a subfolder project directory (and a deeper one) owns the worktree under it, and the root still does. It fails on main. Controls that still get no owners: a nested independent repository (its own `.git` stops the walk), a project directory inside a linked worktree, a registry entry whose worktree names another repository, and a directory with no `.git` above it. + +### Gate integrity +- One accepted-input change to review: from a project directory below the repository root, the guard now accepts the journal of a linked worktree of that same repository, which it already accepted when the project directory was the root. The seven existing `foreign_cwd` refusals in `test/autoplan-publication-guard.test.ts` (no link, one-sided links, another repository, a submodule gitdir, a project that is itself a worktree, a bare repository) still deny. No threshold, budget, timeout, trial count or case kind changed. + ## [1.91.209.0] - 2026-10-25 **The free test suite no longer rewrites a contributor's global git pre-push hook (#3353).** On a machine with a global `core.hooksPath`, `test/redact-prepush-target.test.ts` and `test/fs-utils.test.ts` ran `gstack-redact install-prepush-hook` with the parent environment. Git resolved the hooks directory to the global one, so the install moved the developer's own `pre-push` to `pre-push.local` and left a managed wrapper pointing into the test checkout, which breaks every later push once that checkout is gone. The same config also failed 4 tests in the target file, 5 in `test/redact-prepush-hook.test.ts` and 1 in `fs-utils`. CI has no global hooks path, so it stayed green. diff --git a/VERSION b/VERSION index 32b3c2d81..e41ac1723 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.209.0 +1.91.210.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index bfdfaa808..6035c125d 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.209.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.210.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/autoplan/bin/phase-publication-hook.ts b/autoplan/bin/phase-publication-hook.ts index 41b95ec83..d8cf0c214 100644 --- a/autoplan/bin/phase-publication-hook.ts +++ b/autoplan/bin/phase-publication-hook.ts @@ -627,12 +627,21 @@ function phaseDecision(input: PublicationHookInput, root: string, before: Event[ * A worktree session gets the repository root as CLAUDE_PROJECT_DIR while its * journal is rooted in the linked worktree. Only git's own two-way link makes * a directory that worktree: /.git/worktrees//gitdir names - * /.git, and that file names the same admin entry back. + * /.git, and that file names the same admin entry back. A project + * directory below the root (#3296) uses the first .git found walking up; a + * .git file there means a linked worktree or submodule, which has no registry. */ export function linkedWorktrees(projectDir: string): string[] { - let entries: fs.Dirent[]; - const admin = path.join(projectDir, '.git', 'worktrees'); - try { entries = fs.readdirSync(admin, { withFileTypes: true }); } catch { return []; } + let entries: fs.Dirent[], admin: string | undefined; + try { + for (let dir = path.resolve(projectDir); ; dir = path.dirname(dir)) { + const dotGit = fs.statSync(path.join(dir, '.git'), { throwIfNoEntry: false }); + if (dotGit) { if (dotGit.isDirectory()) admin = path.join(dir, '.git', 'worktrees'); break; } + if (path.dirname(dir) === dir) break; + } + if (!admin) return []; + entries = fs.readdirSync(admin, { withFileTypes: true }); + } catch { return []; } return entries.filter(entry => entry.isDirectory()).slice(0, 256).flatMap(entry => { try { const link = path.join(admin, entry.name); diff --git a/package.json b/package.json index da6af4fc8..77850610a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.209", + "version": "1.91.210", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/autoplan-publication-hook.test.ts b/test/autoplan-publication-hook.test.ts index de4eebfc9..9635e38fa 100644 --- a/test/autoplan-publication-hook.test.ts +++ b/test/autoplan-publication-hook.test.ts @@ -4,7 +4,7 @@ import * as path from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts'; +import { linkedWorktrees, runPublicationHook } from '../autoplan/bin/phase-publication-hook.ts'; import { OWNED_READ_APPROVAL } from '../autoplan/bin/owned-read'; const ROOT = path.join(import.meta.dir, '..'); @@ -192,3 +192,63 @@ describe('Autoplan hook journal-root verdicts', () => { for (const detail of ['unrecognized_shape:cwd_spelling', 'analytics/autoplan-guard.jsonl']) expect(guide).toContain(detail); }); }); + +describe('linkedWorktrees from a project directory below the repository root (#3296)', () => { + const gitEnv = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_COUNT: '0' }; + const git = (cwd: string, ...args: string[]) => { + const child = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000, env: gitEnv }); + expect(child.status).toBe(0); + }; + function repo(): string { + const dir = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(dir); + git(dir, 'init', '-q', '-b', 'main'); + git(dir, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed'); + return dir; + } + const real = (dir: string) => fs.realpathSync(dir); + + test.skipIf(process.platform === 'win32')('the reporter layout: a subfolder project directory owns its worktree under that subfolder', () => { + const root = repo(), sub = path.join(root, 'sub'); fs.mkdirSync(path.join(sub, '.claude', 'worktrees'), { recursive: true }); + const worktree = path.join(sub, '.claude', 'worktrees', 'w'); + git(root, 'worktree', 'add', '-q', worktree); + expect(linkedWorktrees(sub)).toEqual([real(worktree)]); + expect(linkedWorktrees(path.join(sub, 'deeper', 'still'))).toEqual([real(worktree)]); + expect(linkedWorktrees(root)).toEqual([real(worktree)]); + }); + + test.skipIf(process.platform === 'win32')('a nested independent repository stops the walk at its own .git', () => { + const outer = repo(), inner = path.join(outer, 'vendor', 'lib'); + fs.mkdirSync(inner, { recursive: true }); + git(inner, 'init', '-q', '-b', 'main'); + git(inner, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'seed'); + git(outer, 'worktree', 'add', '-q', path.join(outer, 'outer-wt')); + expect(linkedWorktrees(inner)).toEqual([]); + expect(linkedWorktrees(path.join(inner, 'src'))).toEqual([]); + }); + + test.skipIf(process.platform === 'win32')('a project directory inside a linked worktree has no registry of its own', () => { + const root = repo(), worktree = path.join(root, 'wt'); + git(root, 'worktree', 'add', '-q', worktree); + fs.mkdirSync(path.join(worktree, 'pkg')); + expect(linkedWorktrees(worktree)).toEqual([]); + expect(linkedWorktrees(path.join(worktree, 'pkg'))).toEqual([]); + }); + + test('a registry entry whose worktree names another repository is still refused from a subfolder', () => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-'))); dirs.push(root); + const other = fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), 'autoplan-nested-other-'))); dirs.push(other); + const worktree = path.join(root, 'sub', 'wt'); fs.mkdirSync(worktree, { recursive: true }); + const entry = path.join(root, '.git', 'worktrees', 'wt'); fs.mkdirSync(entry, { recursive: true }); + const foreign = path.join(other, '.git', 'worktrees', 'wt'); fs.mkdirSync(foreign, { recursive: true }); + fs.writeFileSync(path.join(entry, 'gitdir'), path.join(worktree, '.git') + '\n'); + fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${foreign}\n`); + expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([]); + fs.writeFileSync(path.join(worktree, '.git'), `gitdir: ${entry}\n`); + expect(linkedWorktrees(path.join(root, 'sub'))).toEqual([worktree]); + }); + + test('a directory with no .git anywhere above it has no worktrees', () => { + expect(linkedWorktrees(path.parse(tmpdir()).root)).toEqual([]); + }); +}); + From cb99df4a2fc45bc983e0a64f862f79fab0ce899a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 05:39:53 -0700 Subject: [PATCH 5/9] fix(diff-scope): count Python test_*.py and nested test dirs as tests (#3335) (#3382) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 ++++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-diff-scope | 8 ++++++++ package.json | 2 +- test/diff-scope.test.ts | 28 +++++++++++++++++++++++++++- 6 files changed, 48 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a868365..52714c8ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.211.0] - 2026-10-25 + +**`gstack-diff-scope` sees Python tests and nested test directories (#3335).** A branch that changed `components/orch/services/shared/tests/test_native_store_custody.py` reported `SCOPE_TESTS=false`, because the Tests rule only knew `*.test.*`-style suffixes and `test/`, `tests/`, `spec/` and `__tests__/` at the repository root. /ship and /review use that flag to route the testing specialist, so a backend change whose tests follow pytest's `test_*.py` convention skipped it. The test-bootstrap resolver already counted these files, so the two detectors disagreed. + +### Fixed +- **`bin/gstack-diff-scope`**: a changed path is a test when it matches `TEST_PATH_RE`, the same regex the test-bootstrap resolver uses for its TESTFILES count (`scripts/resolvers/testing.ts`): test dirs at any depth, `test_*.py`, `tests.py`, `_test.`, `.test.`/`.spec.` JS and TS, `_spec.rb` and `*Test.java`/`.kt`. `conftest.py` counts too. The existing globs stay. + +### Tests +- `test/diff-scope.test.ts` adds the issue's three paths, `pkg/test_store.py`, Django's `tests.py`, `conftest.py`, and nested `__tests__/` and `spec/` dirs; all eight fail on main. Controls that stay `SCOPE_TESTS=false`: `latest.py`, `contest_rules.py`, `attest_signer.py` and a `testing/` dir. A new test checks that `TEST_PATH_RE` is byte-identical to the resolver's regex, so the two can't drift again. + ## [1.91.210.0] - 2026-10-25 **/autoplan works when Claude Code's project directory is a subfolder of the repository (#3296).** When the git root sits above the project (for example the root is `$HOME` and the session starts in `$HOME/projects/app`), a worktree session's journal lives in `.claude/worktrees/` under that subfolder. The publication guard only looked for `/.git/worktrees`, found nothing, and denied every phase Read with `foreign_cwd`, so the reporter had to restart the session from the repository root. diff --git a/VERSION b/VERSION index e41ac1723..6811b3c0a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.210.0 +1.91.211.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 6035c125d..ad700de53 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.210.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.211.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index b923f737a..2fa10b41d 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -42,6 +42,12 @@ MIGRATIONS=false API=false AUTH=false +# Test-file paths, the same rule the test-bootstrap resolver counts as +# TESTFILES (scripts/resolvers/testing.ts; test/diff-scope.test.ts keeps the two +# identical). Nested test dirs and Python's test_*.py / tests.py were missed by +# the root-anchored globs alone (#3335). +TEST_PATH_RE='(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' + _print_flags() { echo "SCOPE_FRONTEND=$FRONTEND" echo "SCOPE_BACKEND=$BACKEND" @@ -126,7 +132,9 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do case "$f" in *.test.*|*.spec.*|*_test.*|*_spec.*) m_tests=true ;; test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) m_tests=true ;; + conftest.py|*/conftest.py) m_tests=true ;; esac + if [[ "$f" =~ $TEST_PATH_RE ]]; then m_tests=true; fi # Docs case "$f" in diff --git a/package.json b/package.json index 77850610a..f806401c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.210", + "version": "1.91.211", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index b5bc4fd10..9597362a9 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -5,7 +5,7 @@ * the correct SCOPE_* variables are output. */ import { describe, test, expect, afterAll } from 'bun:test'; -import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { spawnSync } from 'child_process'; @@ -245,6 +245,20 @@ describe('glob classes (table-driven, #2526 + #2455)', () => { { name: 'plain backend', file: 'server.go', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_FRONTEND'] }, { name: 'docs', file: 'docs/guide.md', expects: ['SCOPE_DOCS'] }, { name: 'prompts', file: 'app/services/prompt_builder.rb', expects: ['SCOPE_PROMPTS', 'SCOPE_BACKEND'] }, + // Python tests and nested test dirs (#3335): the issue's three paths. + { name: 'nested tests/test_*.py (#3335)', file: 'components/orch/services/shared/tests/test_native_store_custody.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] }, + { name: 'second nested tests/test_*.py (#3335)', file: 'components/orch/services/shared/tests/test_infra_promoted_first_apply.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] }, + { name: 'hyphenated service tests/test_*.py (#3335)', file: 'components/orch/services/api-gateway/tests/test_native_chat_checkpoint_store.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] }, + { name: 'test_*.py outside a tests dir', file: 'pkg/test_store.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] }, + { name: 'Django tests.py', file: 'shop/tests.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] }, + { name: 'pytest conftest.py', file: 'services/api/conftest.py', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] }, + { name: 'nested __tests__ dir', file: 'packages/ui/__tests__/render.ts', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] }, + { name: 'nested spec dir', file: 'engines/billing/spec/invoice_checker.rb', expects: ['SCOPE_TESTS', 'SCOPE_BACKEND'] }, + // Controls: a name that only contains "test" is not a test file. + { name: 'latest.py is not a test', file: 'pkg/latest.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] }, + { name: 'contest_rules.py is not a test', file: 'pkg/contest_rules.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] }, + { name: 'testing/ dir is not a test dir', file: 'pkg/testing/fixtures_loader.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] }, + { name: 'attest_*.py is not a test', file: 'pkg/attest_signer.py', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_TESTS'] }, ]; for (const row of TABLE) { @@ -262,6 +276,18 @@ describe('glob classes (table-driven, #2526 + #2455)', () => { } }); +describe('one test-path rule (#3335)', () => { + test('TEST_PATH_RE is byte-identical to the test-bootstrap TESTFILES regex', () => { + const script = readFileSync(SCRIPT, 'utf8'); + const scriptRe = script.match(/^TEST_PATH_RE='([^']+)'$/m)?.[1]; + const resolver = readFileSync(join(import.meta.dir, '..', 'scripts', 'resolvers', 'testing.ts'), 'utf8'); + const resolverRe = resolver.match(/git ls-files \| grep -cE '([^']+)'/)?.[1]?.replace(/\\\\/g, '\\'); + expect(scriptRe).toBeDefined(); + expect(resolverRe).toBeDefined(); + expect(scriptRe).toBe(resolverRe); + }); +}); + describe('exit-code contract (#2526)', () => { test('clean tree, no changes → all false, exit 0, no SCOPE_ERROR', () => { const dir = createRepo([]); From f00b8bc79b6cb9cd4d3f43019c180ccaa37fce2c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 08:44:07 -0700 Subject: [PATCH 6/9] v1.91.212.0 fix(careful): mask git commit's own message before the MEDIUM checks (#1060, port of #3221) (#3389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(careful): mask git commit -m content to prevent MEDIUM-tier false positives (#1060) MEDIUM-tier checks matched the full command string including quoted -m text. A commit like 'git commit -m fix: prevent drop table in migration' triggered the SQL DROP warning even though no SQL was being executed. Build CMD_MEDIUM by stripping -m quoted content from git commit commands before MEDIUM checks. Real destructive commands outside the quoted string still match, so true positives are unaffected. Add tests: commit message patterns do not warn; chained real DROP/TRUNCATE still warns. * fix(careful): replace sed masking with quote-aware awk scanner (#1060) The sed-based -m content masker had two bypass paths that capy-ai identified: 1. Single-quoted message containing a double-quote boundary: git commit -m 'note -m "' && rm -rf build && echo '"' The double-quote pass matched '-m "' inside the single-quoted string, swallowing '&& rm -rf build && echo '; the rm vanished from CMD_MEDIUM. 2. Double-quoted message with a command substitution: git commit -m "$(rm -rf build)" sed replaced the content including the $(...), so the substitution — which executes in the shell — was masked away. Replace the sed substitution with a quote-aware awk scanner that: - Walks the command string tracking single-quote, double-quote, and backslash state, so it only treats a delimiter as a message boundary when the shell would. - Masks single-quoted messages (always literal) and double-quoted messages that contain no $, backtick, or backslash (safe to mask — no expansion). - Leaves double-quoted messages with $, backtick, or backslash unmasked so command substitutions stay visible to the MEDIUM checks. - Handles -m, -am, --message, and --message= forms. Four new tests added as requested (alongside the original six): - single-quoted message with embedded double-quote + chained rm → warns - commit message with $(rm -rf build) → warns - git push --force chained after commit → warns - git commit -am "drop table notes" → silent (message masked) * fix(careful): read the commit-message masker from a file, so bash 3.2 can parse the hook (#1060) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(careful): scan the command as one string, so a newline still separates commands from the message (#1060) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(careful): mask only git commit's own message; a piped commit or another command's -m stays visible (#1060) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(careful): mask a quoted-delimiter heredoc message; any other substitution or heredoc leaves the command unmasked (#1060) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(careful): a pipe, process substitution or parameter expansion after the message unmasks it; redirections and comments parse like bash (#1060) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): careful masks git commit's own message (#1060, port of #3221) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Bhumika-1432006 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- careful/bin/check-careful.sh | 37 +++-- careful/bin/mask-commit-message.awk | 125 +++++++++++++++++ package.json | 2 +- test/hook-scripts.test.ts | 209 ++++++++++++++++++++++++++++ 7 files changed, 381 insertions(+), 13 deletions(-) create mode 100644 careful/bin/mask-commit-message.awk diff --git a/CHANGELOG.md b/CHANGELOG.md index 52714c8ff..8d5046bce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.91.212.0] - 2026-10-25 + +**/careful no longer asks about words inside a commit message (#1060; ported from #3221 by @Bhumika-1432006).** The careful hook matched its MEDIUM patterns against the whole command, so `git commit -m "fix: prevent drop table in migration"` asked for approval as an SQL DROP. The hook now masks git commit's own literal message before those checks, including the `-m "$(cat <<'EOF' … EOF +)"` form agents use most. Our commits on top of the contributor's scanner fix four problems found in review: the inline awk program made the whole hook a bash syntax error on bash 3.2 (macOS `/bin/bash`), so every Bash call failed the hook there; a newline after the message was dropped, so `-m x⏎truncate …` stopped asking; any `-m '…'` after a commit was masked, including another command's argument; and the heredoc form was never masked. + +### Fixed +- **`careful/bin/mask-commit-message.awk`** (new, from the contributor's scanner): reads the command as one string and keeps its newlines. It splits at unquoted `;` `&` `|` `(` `)` and newlines (a redirection's `&` does not split) and masks `-m`, `-am`, `--message` and `--message=` only in a segment whose first word is `git` with a `commit` word after it. Single-quoted messages, double-quoted messages without `$`, backtick or backslash, and a double-quoted `$(cat <<'DELIM' … DELIM )` with a quoted delimiter are masked. Quotes, backslashes and comments are parsed as bash does. The command is left unmasked when it contains any other command, parameter or process substitution, a backtick, `$'…'`, a heredoc, an unterminated quote, or a pipe after a masked message (git prints the message on stdout). +- **`careful/bin/check-careful.sh`**: runs the program with `awk -f` from the hook's own directory, so bash 3.2 parses the hook again. A missing program or an awk error leaves the command unmasked. The HIGH deny tier, the PowerShell table and the obfuscation tripwire still read the unmasked command. + +### Tests +- `test/hook-scripts.test.ts` keeps the contributor's 10 cases and adds 31. Masked (no prompt): every flag form, `git -C repo commit`, `--amend`, multi-line messages and four heredoc spellings (`'EOF'`, `"MSG"`, `<<-` with a tab, a quoted word in the body). Still asking: a command on the next line, a quote that spans a line break, another command's `-m`, `echo git commit -m '…'`, an escaped quote, a backslash-newline, a heredoc with an unquoted delimiter, a substitution that closes the message early, a backtick, `git commit -F - <<'EOF'`, `$'…'`, a pipe after the commit (direct, after `2>&1`, or from a subshell), a process substitution and a parameter expansion. A partial install without the awk file still asks. +- The same cases pass with mawk and the one-true-awk (`original-awk`, the awk macOS ships). In a `bash:3.2` container the hook as submitted in #3221 exits 2 with `unexpected EOF while looking for matching '"'` on every command; with this change it answers normally. + +### Gate integrity +- One accepted-input change to review, in a safety hook: MEDIUM checks (and custom `careful-patterns.txt` rules) no longer see the masked text of git commit's own literal message. That is a weakening by design, limited to text that bash passes to git as a literal argument. The negative controls listed under Tests still ask. Commands that combine a commit with anything that could execute the message, or that the scanner cannot read with certainty, are checked unmasked, exactly as on main. No threshold, budget, timeout, trial count or case kind changed. +- Known limit, still a false positive as on main: `git commit -F - <<'EOF'` messages and commits after an environment assignment or `sudo` are not masked. + ## [1.91.211.0] - 2026-10-25 **`gstack-diff-scope` sees Python tests and nested test directories (#3335).** A branch that changed `components/orch/services/shared/tests/test_native_store_custody.py` reported `SCOPE_TESTS=false`, because the Tests rule only knew `*.test.*`-style suffixes and `test/`, `tests/`, `spec/` and `__tests__/` at the repository root. /ship and /review use that flag to route the testing specialist, so a backend change whose tests follow pytest's `test_*.py` convention skipped it. The test-bootstrap resolver already counted these files, so the two detectors disagreed. diff --git a/VERSION b/VERSION index 6811b3c0a..698433897 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.211.0 +1.91.212.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index ad700de53..23ea8af24 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.211.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.212.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/careful/bin/check-careful.sh b/careful/bin/check-careful.sh index 3080e10c0..364806502 100755 --- a/careful/bin/check-careful.sh +++ b/careful/bin/check-careful.sh @@ -224,6 +224,23 @@ esac WARN="" PATTERN="" +# Mask quoted -m/-am/--message argument content in git commit commands so that +# descriptive commit messages do not trigger false positives (#1060). The +# quote-aware scanner lives in mask-commit-message.awk beside this hook: as a +# heredoc inside a command substitution, its quote characters are mis-parsed by +# bash 3.2 (macOS /bin/bash), which made this whole hook a syntax error there. +# A missing program or an awk failure leaves the command unmasked. +CMD_MEDIUM="$CMD" +CMD_MEDIUM_LOWER="$CMD_LOWER" +_MASK_AWK="$_HOOK_DIR/mask-commit-message.awk" +case "$CMD" in *git*commit*) _MAYBE_COMMIT=1 ;; *) _MAYBE_COMMIT=0 ;; esac +if [ "$_MAYBE_COMMIT" -eq 1 ] && [ -f "$_MASK_AWK" ]; then + if _cmd_masked=$(printf '%s' "$CMD" | awk -f "$_MASK_AWK" 2>/dev/null); then + CMD_MEDIUM="$_cmd_masked" + CMD_MEDIUM_LOWER=$(printf '%s' "$CMD_MEDIUM" | tr '[:upper:]' '[:lower:]') + fi +fi + # --- PowerShell and cmd table (#3067) --- # Runs on the PowerShell tool's command, and on the part of any command from a # nested `powershell`/`pwsh`/`cmd` launcher onward (`pwsh -c "..."`, @@ -303,50 +320,50 @@ if [ -n "$_WIN_SCAN" ]; then fi # rm -rf / rm -r / rm -R / rm --recursive (capital -R is BSD/macOS recursive) -if [ -z "$WARN" ] && grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD" 2>/dev/null; then +if [ -z "$WARN" ] && grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD_MEDIUM" 2>/dev/null; then WARN="Destructive: recursive delete (rm -r). This permanently removes files." PATTERN="rm_recursive" fi # DROP TABLE / DROP DATABASE -if [ -z "$WARN" ] && grep -qE 'drop\s+(table|database)' <<< "$CMD_LOWER" 2>/dev/null; then +if [ -z "$WARN" ] && grep -qE 'drop\s+(table|database)' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then WARN="Destructive: SQL DROP detected. This permanently deletes database objects." PATTERN="drop_table" fi # TRUNCATE -if [ -z "$WARN" ] && grep -qE '\btruncate\b' <<< "$CMD_LOWER" 2>/dev/null; then +if [ -z "$WARN" ] && grep -qE '\btruncate\b' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then WARN="Destructive: SQL TRUNCATE detected. This deletes all rows from a table." PATTERN="truncate" fi # git push --force / git push -f / plus-refspec force (git push origin +ref) -if [ -z "$WARN" ] && grep -qE 'git\s+push\s' <<< "$CMD" 2>/dev/null \ - && grep -qE '(-f\b|--force|(^|[[:space:]])\+[^[:space:]])' <<< "$CMD" 2>/dev/null; then +if [ -z "$WARN" ] && grep -qE 'git\s+push\s' <<< "$CMD_MEDIUM" 2>/dev/null \ + && grep -qE '(-f\b|--force|(^|[[:space:]])\+[^[:space:]])' <<< "$CMD_MEDIUM" 2>/dev/null; then WARN="Destructive: git force-push rewrites remote history. Other contributors may lose work." PATTERN="git_force_push" fi # git reset --hard -if [ -z "$WARN" ] && grep -qE 'git\s+reset\s+--hard' <<< "$CMD" 2>/dev/null; then +if [ -z "$WARN" ] && grep -qE 'git\s+reset\s+--hard' <<< "$CMD_MEDIUM" 2>/dev/null; then WARN="Destructive: git reset --hard discards all uncommitted changes." PATTERN="git_reset_hard" fi # git checkout . / git restore . -if [ -z "$WARN" ] && grep -qE 'git\s+(checkout|restore)\s+\.' <<< "$CMD" 2>/dev/null; then +if [ -z "$WARN" ] && grep -qE 'git\s+(checkout|restore)\s+\.' <<< "$CMD_MEDIUM" 2>/dev/null; then WARN="Destructive: discards all uncommitted changes in the working tree." PATTERN="git_discard" fi # kubectl delete -if [ -z "$WARN" ] && grep -qE 'kubectl\s+delete' <<< "$CMD" 2>/dev/null; then +if [ -z "$WARN" ] && grep -qE 'kubectl\s+delete' <<< "$CMD_MEDIUM" 2>/dev/null; then WARN="Destructive: kubectl delete removes Kubernetes resources. May impact production." PATTERN="kubectl_delete" fi # docker rm -f / docker system prune -if [ -z "$WARN" ] && grep -qE 'docker\s+(rm\s+-f|system\s+prune)' <<< "$CMD" 2>/dev/null; then +if [ -z "$WARN" ] && grep -qE 'docker\s+(rm\s+-f|system\s+prune)' <<< "$CMD_MEDIUM" 2>/dev/null; then WARN="Destructive: Docker force-remove or prune. May delete running containers or cached images." PATTERN="docker_destructive" fi @@ -386,7 +403,7 @@ $_GSTACK_HOME_DIR/projects/$SLUG/careful-patterns.txt" _PAT_RC=0 printf '' | grep -qE -- "$_PAT" 2>/dev/null || _PAT_RC=$? [ "$_PAT_RC" -eq 2 ] && continue # invalid ERE — skip the line - if grep -qE -- "$_PAT" <<< "$CMD" 2>/dev/null; then + if grep -qE -- "$_PAT" <<< "$CMD_MEDIUM" 2>/dev/null; then WARN="Project rule matched: $_PAT" PATTERN="project_rule" break diff --git a/careful/bin/mask-commit-message.awk b/careful/bin/mask-commit-message.awk new file mode 100644 index 000000000..5a1358428 --- /dev/null +++ b/careful/bin/mask-commit-message.awk @@ -0,0 +1,125 @@ +# mask-commit-message.awk — read by careful/bin/check-careful.sh (#1060). +# Prints the command with the quoted message argument of git commit +# (-m, -am, --message) replaced by an empty quoted string. The command is +# scanned as one string: newlines are kept, since bash runs each line as its +# own command, and quote state carries across lines. +# +# Only git commit's own arguments are masked: the scanner splits the command +# at unquoted ; & | ( ) and newlines, and masks a message flag only in a +# segment whose first word is git and that has a commit word after it. The & +# of a redirection (2>&1, &>) does not split. Any pipe after a masked message +# prints the command unchanged, because git prints the message on stdout. +# Quotes, backslashes and comments follow bash: a backslash escapes the next +# character outside single quotes, and nothing inside single quotes; an +# unquoted # starting a word runs to the end of its line. An unterminated +# quote or escape prints the command unchanged. +# +# A message written as "$(cat <<'EOF' ... EOF )" (a quoted delimiter, so the +# body is never expanded) is masked whole. Any other command, parameter or +# process substitution, backtick, $'...' string or heredoc changes how bash +# reads the quotes after it, so the command is printed unchanged. +# heredoc_msg S N I — S[I] opens a double-quoted "$(cat <<'DELIM' ... DELIM )" +# message: the index after its closing quote, else 0. +function heredoc_msg(s, n, i, p, rest, strip, delim, e, line) { + rest = substr(s, i + 1) + if (!match(rest, /^\$\(cat[ \t]+<<-?[ \t]*/)) return 0 + strip = substr(rest, 1, RLENGTH) ~ /<<-/ + p = i + 1 + RLENGTH + rest = substr(s, p) + if (match(rest, /^'[A-Za-z_][A-Za-z0-9_]*'/) || match(rest, /^"[A-Za-z_][A-Za-z0-9_]*"/)) delim = substr(rest, 2, RLENGTH - 2) + else if (match(rest, /^\\[A-Za-z_][A-Za-z0-9_]*/)) delim = substr(rest, 2, RLENGTH - 1) + else return 0 + p += RLENGTH + while (p <= n && substr(s, p, 1) ~ /[ \t]/) p++ + if (substr(s, p, 1) != "\n") return 0 + p++ + for (;;) { + if (p > n) return 0 + e = index(substr(s, p), "\n") + if (!e) return 0 + line = substr(s, p, e - 1) + if (strip) sub(/^\t+/, "", line) + p += e + if (line == delim) break + } + while (p <= n && substr(s, p, 1) ~ /[ \t\n]/) p++ + if (substr(s, p, 2) != ")\"") return 0 + return p + 2 +} +function handle_msg(s, n, i, j, c, safe) { + if (i > n) return i + if (substr(s, i, 1) == "'") { + j = i + 1 + while (j <= n && substr(s, j, 1) != "'") j++ + if (j > n) return i + result = result "''" + return j + 1 + } + if (substr(s, i, 1) == "\"") { + if ((j = heredoc_msg(s, n, i)) > 0) { result = result "\"\""; return j } + j = i + 1; safe = 1 + while (j <= n && (c = substr(s, j, 1)) != "\"") { + if (c == "$" || c == "`" || c == "\\") safe = 0 + j++ + } + if (!safe || j > n) return i + result = result "\"\"" + return j + 1 + } + return i +} +# flag S I — length of the message flag (with its trailing blanks) at S[I], or 0 +function flag(s, i, rest, m) { + rest = substr(s, i) + if (rest ~ /^--message=/) return 10 + if (match(rest, /^(--message|-am|-m)[ \t]+/)) return RLENGTH + return 0 +} +{ cmd = (NR > 1) ? cmd "\n" $0 : $0 } +END { + s = cmd + n = length(s); sq = 0; dq = 0; bs = 0; i = 1 + atword = 1; words = 0; git = 0; commit = 0; masked = 0; piped = 0; unsure = 0 + while (i <= n) { + c = substr(s, i, 1) + if (bs) { result = result c; bs = 0; i++; continue } + if (sq) { if (c == "'") sq = 0; result = result c; i++; continue } + two = substr(s, i, 2) + if (c == "`" || two == "$(" || two == "${") unsure = 1 + if (dq) { + if (c == "\\") bs = 1 + else if (c == "\"") dq = 0 + result = result c; i++; continue + } + if (two == "<<" || two == "$'" || two == "<(" || two == ">(") unsure = 1 + if (atword && c == "#") { + while (i <= n && substr(s, i, 1) != "\n") { result = result substr(s, i, 1); i++ } + continue + } + if (c == "&" && (substr(s, i - 1, 1) ~ /[<>]/ || substr(s, i + 1, 1) == ">")) { atword = 0; result = result c; i++; continue } + if (c ~ /[;&|()\n]/) { + if (c == "|" && masked && substr(s, i + 1, 1) != "|" && substr(s, i - 1, 1) != "|") piped = 1 + atword = 1; words = 0; git = 0; commit = 0 + result = result c; i++; continue + } + if (c == " " || c == "\t") { atword = 1; result = result c; i++; continue } + if (atword) { + atword = 0; words++ + rest = substr(s, i) + if (words == 1 && rest ~ /^git([ \t\n;&|()]|$)/) git = 1 + else if (git && !commit && rest ~ /^commit([ \t\n;&|()]|$)/) commit = 1 + else if (commit && (len = flag(s, i)) > 0) { + result = result substr(s, i, len); i += len + j = handle_msg(s, n, i) + if (j > i) masked = 1 + atword = (j == i); i = j + continue + } + } + if (c == "\\") bs = 1 + else if (c == "'") sq = 1 + else if (c == "\"") dq = 1 + result = result c; i++ + } + printf "%s", (sq || dq || bs || piped || unsure) ? s : result +} diff --git a/package.json b/package.json index f806401c5..6a5e5d4ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.211", + "version": "1.91.212", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/hook-scripts.test.ts b/test/hook-scripts.test.ts index 44ac80437..621bf5143 100644 --- a/test/hook-scripts.test.ts +++ b/test/hook-scripts.test.ts @@ -342,6 +342,215 @@ describe('check-careful.sh', () => { expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason); }); + // --- Commit message false positives (#1060) --- + + describe('commit message false positives (#1060)', () => { + // Quoted -m content describes destructive patterns but does not run them. + test.each([ + 'git commit -m "fix: prevent accidental drop table in migration"', + 'git commit -m "docs: explain truncate vs delete semantics"', + 'git commit -m "feat: add guard for recursive delete patterns"', + 'git add . && git commit -m "fix: handle drop database edge case"', + ])('does not warn on descriptive text in commit message: %s', (command) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); + }); + + // Real destructive commands chained with a commit still warn. + test('warns when a real DROP TABLE follows git commit in a chain', () => { + const { exitCode, output } = runHook( + CAREFUL_SCRIPT, + carefulInput('git commit -m "release" && psql -c "DROP TABLE staging"'), + ); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL DROP'); + }); + + test('warns when a real TRUNCATE follows git commit in a chain', () => { + const { exitCode, output } = runHook( + CAREFUL_SCRIPT, + carefulInput('git commit -m "cleanup" && psql -c "TRUNCATE audit_log"'), + ); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL TRUNCATE'); + }); + + // Quote-aware awk scanner: a single-quoted message whose content looks like + // a flag boundary must not swallow real commands that follow via &&. + test('warns when rm follows a single-quoted message that embeds a double-quote', () => { + const { exitCode, output } = runHook( + CAREFUL_SCRIPT, + carefulInput(`git commit -m 'note -m "' && rm -rf build && echo '"'`), + ); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); + }); + + // A double-quoted message containing $(...) is not masked — the substitution + // content remains visible to the MEDIUM checks. + test('warns when a commit message contains a command substitution with rm', () => { + const { exitCode, output } = runHook( + CAREFUL_SCRIPT, + carefulInput('git commit -m "$(rm -rf build)"'), + ); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); + }); + + // A force-push chained after a commit must still warn even after the message is masked. + test('warns on force-push chained after git commit', () => { + const { exitCode, output } = runHook( + CAREFUL_SCRIPT, + carefulInput('git commit -m "x" && git push --force'), + ); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push'); + }); + + // -am shorthand: the message is masked, so "drop table" in the message text + // does not trigger the SQL DROP warning. + test('does not warn on drop-table text in -am commit message', () => { + const { exitCode, output } = runHook( + CAREFUL_SCRIPT, + carefulInput('git commit -am "drop table notes"'), + ); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); + }); + + // bash 3.2 (macOS /bin/bash) mis-parses quote characters in a heredoc + // inside $( ), so the awk program inline made the whole hook a syntax error + // there. It is a file beside the hook, read with awk -f. + test('the masking program is a file beside the hook, not a heredoc inside $( )', () => { + const source = fs.readFileSync(CAREFUL_SCRIPT, 'utf8'); + expect(source).not.toMatch(/\$\(cat < { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason); + }); + + test('a multi-line quoted message is masked as one message', () => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git commit -m "fix: guard migrations\n\nNo more drop table in the seed script."')); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); + }); + + // Only git commit's own message is masked: a -m argument of another + // command in the chain, a commit whose output is piped into a shell, and + // an escaped quote inside the message all stay visible. + test.each([ + ["git commit -m a && ls | parallel -m 'rm -rf build'", 'recursive delete'], + ["git commit -m 'rm -rf build' | sh", 'recursive delete'], + ["echo git commit -m 'drop table users'", 'SQL DROP'], + ['git commit -m "a\\"b" && rm -rf build', 'recursive delete'], + ["git commit -m wip \\\n&& rm -rf build", 'recursive delete'], + ])('text outside commit\'s own message still asks: %j', (command, reason) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason); + }); + + test.each([ + 'git -C repo commit --message="docs: truncate the changelog intro"', + "git commit --message 'chore: drop database fixtures from the seed'", + "git commit --amend -m 'fix: git reset --hard in the docs'", + ])('commit\'s own message is masked in every flag form: %s', (command) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); + }); + + // The form agents use most: a quoted-delimiter heredoc inside "$( )". Its + // body is never expanded, so it is masked whole. Any other substitution, + // backtick, $'...' or heredoc leaves the command unmasked. + const heredocMessage = (body: string, delimiter = "'EOF'", end = 'EOF') => + `git commit -m "$(cat <<${delimiter}\n${body}\n${end}\n)"`; + test.each([ + heredocMessage('fix: guard the migration that could drop table users\n\nCo-Authored-By: A '), + heredocMessage('docs: why we never truncate "audit" rows (see #12)'), + heredocMessage('chore: git reset --hard is documented', '"MSG"', 'MSG'), + heredocMessage('chore: rm -rf build in the clean script', "-'EOF'", '\tEOF'), + ])('a quoted-delimiter heredoc message is masked: %j', (command) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); + }); + + test.each([ + [heredocMessage('docs: drop table notes') + ' && git push --force', 'force-push'], + [heredocMessage('drop table $(rm -rf build)', 'EOF'), 'recursive delete'], + [`git commit -m "$(echo " -m 'x" ; rm -rf build ; echo ')"`, 'recursive delete'], + ['git commit -m "\`rm -rf build\`"', 'recursive delete'], + ["git commit -F - <<'EOF'\ndocs: drop table notes\nEOF", 'SQL DROP'], + ["git commit -m $'drop table notes\\n'", 'SQL DROP'], + ])('other substitutions and heredocs stay unmasked: %j', (command, reason) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason); + }); + + // git prints the message on stdout, so any pipe after a masked message, + // a process substitution or a parameter expansion leaves it unmasked; a + // redirection's & is not a separator, and a comment's quote is not a quote. + test.each([ + "git commit -m 'rm -rf build' 2>&1 | sh", + "(git commit -m 'rm -rf build') | sh", + "git commit -m 'rm -rf build' > >(sh)", + 'git commit -m "${MSG:-rm -rf build}"', + ])('a message that can reach a shell stays unmasked: %j', (command) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete'); + }); + + test.each([ + "git commit -m 'drop table notes' 2>&1 && git log -1 --oneline", + "git commit -m 'drop table notes' || echo failed", + "git commit -m 'drop table notes' # it's the docs commit", + ])('redirections, || and comments keep the message masked: %j', (command) => { + const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command)); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined(); + }); + + test('a partial install without the masking program still asks on the message text', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-nomask-')); + try { + for (const file of ['check-careful.sh', 'hook-extract.sh']) { + fs.copyFileSync(path.join(path.dirname(CAREFUL_SCRIPT), file), path.join(dir, file)); + } + const { exitCode, output } = runHook(path.join(dir, 'check-careful.sh'), carefulInput('git commit -m "drop table notes"')); + expect(exitCode).toBe(0); + expect(output.hookSpecificOutput?.permissionDecision).toBe('ask'); + expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL DROP'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + }); + // --- Shell obfuscation --- describe('shell obfuscation', () => { From 5bbe538bdc21312a43791949a19dfb05b9b31c3b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 08:57:06 -0700 Subject: [PATCH 7/9] v1.91.213.0 fix(ci): Node.js 22.23.4 in the CI image and Ubicloud free-suite VMs, SHA-256 verified (port #3355, fixes #3354) (#3400) * fix(ci): bump Node.js to 22.23.4 for CVE-2026-111497 * fix(ci): pin Node.js 22.23.4 in the Ubicloud free-suite bootstrap too, and verify both archives by SHA-256 #3355 bumped Dockerfile.ci only; scripts/ubicloud/setup-free-suite.sh still installed 22.20.0. Both now pin the same version and the linux-x64 SHA-256 from the release's signed SHASUMS256.txt, check the archive before unpacking, and test/ci-image-cli-pin.test.ts keeps the two pins equal (#3354). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> Co-authored-by: Michael Sumner <75835774+michael-sumner@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): Node.js 22.23.4 CI pin (#3354) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(ci-image): name Dockerfile.ci in a form pregate resolves; assert the image hash in the parity case Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Michael Sumner <75835774+michael-sumner@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .github/docker/Dockerfile.ci | 7 ++++- CHANGELOG.md | 16 ++++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- scripts/ubicloud/setup-free-suite.sh | 11 +++++-- test/ci-image-cli-pin.test.ts | 44 ++++++++++++++++++++++++++-- 7 files changed, 75 insertions(+), 9 deletions(-) diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci index d45ad6a75..efe5aca83 100644 --- a/.github/docker/Dockerfile.ci +++ b/.github/docker/Dockerfile.ci @@ -90,8 +90,13 @@ RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL https://cli.github. # and "gnupg" was renamed to "gpg" on Ubuntu 24.04 anyway, so NodeSource's script # fails before it can add its own repo. Direct tarball download is network-simpler # (one host: nodejs.org) and doesn't touch apt at all. -ENV NODE_VERSION=22.20.0 +# The hash comes from the release's signed SHASUMS256.txt, and the archive is +# checked before it is unpacked. scripts/ubicloud/setup-free-suite.sh pins the +# same version and hash (test/ci-image-cli-pin.test.ts keeps them equal). +ENV NODE_VERSION=22.23.4 +ARG NODE_SHA256=1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" -o /tmp/node.tar.xz \ + && echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c - \ && tar -xJ -C /usr/local --strip-components=1 --no-same-owner -f /tmp/node.tar.xz \ && rm -f /tmp/node.tar.xz \ && node --version \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d5046bce..bc0482acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.91.213.0] - 2026-10-25 + +**The CI image and the Ubicloud free-suite VMs move off Node.js 22.20.0 to 22.23.4, and both now check the Node archive's SHA-256 before unpacking it (#3354, port of #3355 by @michael-sumner).** 22.20.0 is affected by the October 21, 2026 Node.js security releases, including the Brotli decoder out-of-bounds write (CVE-2026-111497, high) and the TLS hostname bypass for leading-zero IPv4 hosts (CVE-2026-106069, high). 22.23.4 is the patched 22.x release. #3355 bumped `.github/docker/Dockerfile.ci`, but `scripts/ubicloud/setup-free-suite.sh` pins Node separately and would have kept installing 22.20.0 on every remote free-suite run. + +### Fixed +- **`.github/docker/Dockerfile.ci`**: `NODE_VERSION` is 22.23.4 (Michael Sumner's commit). The archive is checked against `NODE_SHA256`, the linux-x64 hash from the release's SHASUMS256.txt, whose signature verifies against the Node.js release keyring, before it is unpacked. +- **`scripts/ubicloud/setup-free-suite.sh`**: pins the same 22.23.4 and hash, and downloads the archive to a temporary file and verifies it instead of piping `curl` straight into `sudo tar`. + +### Tests +- `test/ci-image-cli-pin.test.ts` gains four cases: Dockerfile.ci pins an exact Node version and a SHA-256, both files verify before they unpack, and `setup-free-suite.sh` pins the same version and hash as the image. On #3355's head alone, all four fail; on main, three fail. +- The Bun ordering check in `setup-free-suite.sh` now looks for `sha256sum -c` after the Bun download instead of anywhere in the file, because the Node check now comes first. +- The test names `Dockerfile.ci` as `join(__dirname, '..', '.github', …)`, the form pregate's literal scan resolves, so a change to the image now selects it (before, pregate refused a Dockerfile.ci-only change with `PREGATE_NO_LANE`). + +### Gate integrity +- One accepted-input change to review: the Bun ordering check searches for its `sha256sum -c` from the Bun download onward. It now accepts one input it used to reject: a file with some other `sha256sum -c` before the Bun download and the Bun check after it, which is this file. The property it guards, Bun verified after download and before unpack, is unchanged. A file whose only `sha256sum -c` sits before the download still fails, and so does removing the Bun hash or moving it after `unzip` (negative controls run). No threshold, budget, timeout, trial count or case kind changed. + ## [1.91.212.0] - 2026-10-25 **/careful no longer asks about words inside a commit message (#1060; ported from #3221 by @Bhumika-1432006).** The careful hook matched its MEDIUM patterns against the whole command, so `git commit -m "fix: prevent drop table in migration"` asked for approval as an SQL DROP. The hook now masks git commit's own literal message before those checks, including the `-m "$(cat <<'EOF' … EOF diff --git a/VERSION b/VERSION index 698433897..5e7f1cab2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.212.0 +1.91.213.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 23ea8af24..9a23cd137 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.212.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.213.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/package.json b/package.json index 6a5e5d4ca..345d9566f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.212", + "version": "1.91.213", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/ubicloud/setup-free-suite.sh b/scripts/ubicloud/setup-free-suite.sh index dba34076d..aed51c2c9 100755 --- a/scripts/ubicloud/setup-free-suite.sh +++ b/scripts/ubicloud/setup-free-suite.sh @@ -5,7 +5,8 @@ set -euo pipefail BUN_VERSION=1.4.2 -NODE_VERSION=22.20.0 +NODE_VERSION=22.23.4 +NODE_SHA256=1805bcd35e6815559bd2eb97db438d3fdb0eb3a5e6a977b7c1badca76337e9ce export DEBIAN_FRONTEND=noninteractive sudo -E apt-get update -qq @@ -17,9 +18,13 @@ sudo -E apt-get install -y -qq --no-install-recommends \ # needs; GitHub-hosted runners ship with this relaxed. sudo sysctl -qw kernel.apparmor_restrict_unprivileged_userns=0 +# The same verified release archive as .github/docker/Dockerfile.ci. if ! command -v node >/dev/null; then - curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \ - | sudo tar -xJ -C /usr/local --strip-components=1 + node_tmp=$(mktemp -d) + curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" -o "$node_tmp/node.tar.xz" + echo "$NODE_SHA256 $node_tmp/node.tar.xz" | sha256sum -c - >/dev/null + sudo tar -xJ -C /usr/local --strip-components=1 -f "$node_tmp/node.tar.xz" + rm -rf "$node_tmp" fi # The exact release archive, verified by SHA-256 before it is unpacked, as in diff --git a/test/ci-image-cli-pin.test.ts b/test/ci-image-cli-pin.test.ts index de3fb649a..c4a84f21a 100644 --- a/test/ci-image-cli-pin.test.ts +++ b/test/ci-image-cli-pin.test.ts @@ -24,7 +24,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; const ROOT = path.resolve(__dirname, '..'); -const DOCKERFILE = path.join(ROOT, '.github', 'docker', 'Dockerfile.ci'); +const DOCKERFILE = path.join(__dirname, '..', '.github', 'docker', 'Dockerfile.ci'); /** Package specs from every `npm i -g` / `npm install -g` in the Dockerfile. */ export function globalNpmInstallSpecs(source: string): string[] { @@ -193,7 +193,7 @@ describe('other CI Bun installs match the image (#1713, #1706)', () => { test(`${rel} downloads the verified linux-x64 archive instead of piping an installer into a shell`, () => { expect(curlPipedToShell(source)).toEqual([]); const download = source.indexOf('/releases/download/bun-v$BUN_VERSION/bun-linux-x64.zip'); - const verify = source.indexOf('sha256sum -c'); + const verify = source.indexOf('sha256sum -c', download); const unpack = source.indexOf('unzip -q'); expect(download).toBeGreaterThan(-1); expect(verify).toBeGreaterThan(download); @@ -216,3 +216,43 @@ describe('other CI Bun installs match the image (#1713, #1706)', () => { }); } }); + +describe('CI Node.js installs match the image and are verified (#3354)', () => { + const docker = fs.readFileSync(DOCKERFILE, 'utf-8'); + const imageVersion = docker.match(/^ENV NODE_VERSION=(\S+)$/m)?.[1]; + const imageSha = docker.match(/^ARG NODE_SHA256=(\S+)$/m)?.[1]; + const nodeRun = logicalLines(docker).find((line) => line.includes('nodejs.org/dist/v${NODE_VERSION}/')) ?? ''; + + test('Dockerfile.ci pins an exact Node.js version and the SHA-256 of its linux-x64 archive', () => { + expect(imageVersion).toMatch(/^\d+\.\d+\.\d+$/); + expect(imageSha).toMatch(/^[0-9a-f]{64}$/); + }); + + test('Dockerfile.ci checks the Node.js archive before it is unpacked', () => { + expect(nodeRun, 'Node.js must come from the nodejs.org release archive').not.toBe(''); + const download = nodeRun.indexOf('-o /tmp/node.tar.xz'); + const verify = nodeRun.indexOf('echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c -'); + const unpack = nodeRun.indexOf('tar -xJ'); + expect(download).toBeGreaterThan(-1); + expect(verify).toBeGreaterThan(download); + expect(unpack).toBeGreaterThan(verify); + }); + + const rel = 'scripts/ubicloud/setup-free-suite.sh'; + const source = fs.readFileSync(path.join(ROOT, rel), 'utf-8'); + + test(`${rel} pins the same Node.js version and SHA-256 as Dockerfile.ci`, () => { + expect(imageSha).toMatch(/^[0-9a-f]{64}$/); + expect(source.match(/^NODE_VERSION=(\S+)$/m)?.[1]).toBe(imageVersion!); + expect(source.match(/^NODE_SHA256=(\S+)$/m)?.[1]).toBe(imageSha!); + }); + + test(`${rel} checks the Node.js archive before it is unpacked`, () => { + const download = source.indexOf('/node-v$NODE_VERSION-linux-x64.tar.xz" -o "$node_tmp/node.tar.xz"'); + const verify = source.indexOf('echo "$NODE_SHA256 $node_tmp/node.tar.xz" | sha256sum -c -'); + const unpack = source.indexOf('tar -xJ'); + expect(download).toBeGreaterThan(-1); + expect(verify).toBeGreaterThan(download); + expect(unpack).toBeGreaterThan(verify); + }); +}); From 08301328e91208fb1c399c997eed3950700f505f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 09:09:15 -0700 Subject: [PATCH 8/9] v1.91.214.0 fix(brain-sync): --publish-removals cleans up its temp list and lock without 'list: unbound variable' (GSTA-87) (#3405) * fix(brain-sync): --publish-removals trap expands its temp paths at install, so cleanup and lock release run after the function returns (GSTA-87) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.214.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 ++++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-brain-sync | 2 +- package.json | 2 +- test/brain-sync-removals.test.ts | 22 ++++++++++++++++++++-- 6 files changed, 34 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc0482acc..0bc6ef79e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.214.0] - 2026-10-25 + +**`gstack-brain-sync --publish-removals --yes` no longer prints `list: unbound variable` when it finishes, and it now removes its temp list and releases the sync lock.** The command's EXIT trap named the function's local `$list`. When a publish succeeded, the function returned before the script exited, so the trap ran after `list` had gone out of scope. Under `set -u` it stopped at the unbound variable and never reached the cleanup, which left `brain-sync-removals.*` in `$TMPDIR` and `.brain-sync.lock.d` in `~/.gstack` until a later run cleared the lock as stale (found by GSTA-44). + +### Fixed +- **`bin/gstack-brain-sync`**: `--publish-removals` writes the temp file paths into its EXIT/INT/TERM trap when it installs the trap (quoted with `printf %q`), so the cleanup and lock release work however the function ends. The drain's trap in `--once` also names locals, but every path in that function ends with `exit` while they are still in scope, so it's unchanged. The other trap sites (the drain's lock-only trap and `gstack-brain-restore`'s, which uses a global) don't have the bug. + +### Tests +- `test/brain-sync-removals.test.ts`: a new case runs `--publish-removals`, then `--publish-removals --yes` twice (publishing, then nothing to publish) with a private `TMPDIR`. After each run, stderr has no `unbound variable`, the exit status is 0, `TMPDIR` is empty and the lock directory is gone. It failed before the fix. + ## [1.91.213.0] - 2026-10-25 **The CI image and the Ubicloud free-suite VMs move off Node.js 22.20.0 to 22.23.4, and both now check the Node archive's SHA-256 before unpacking it (#3354, port of #3355 by @michael-sumner).** 22.20.0 is affected by the October 21, 2026 Node.js security releases, including the Brotli decoder out-of-bounds write (CVE-2026-111497, high) and the TLS hostname bypass for leading-zero IPv4 hosts (CVE-2026-106069, high). 22.23.4 is the patched 22.x release. #3355 bumped `.github/docker/Dockerfile.ci`, but `scripts/ubicloud/setup-free-suite.sh` pins Node separately and would have kept installing 22.20.0 on every remote free-suite run. diff --git a/VERSION b/VERSION index 5e7f1cab2..d3a2d850d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.213.0 +1.91.214.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 9a23cd137..6042037f3 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.213.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.214.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 883e717e4..50f377b0b 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -1068,7 +1068,7 @@ subcmd_publish_removals() { sync_active || { echo "BRAIN_SYNC: artifacts sync is not active; nothing to publish" >&2; exit 1; } gstack_brain_lock_acquire "$GSTACK_HOME" try || { echo "BRAIN_SYNC: another sync is running; try again" >&2; exit 1; } list=$(mktemp "${TMPDIR:-/tmp}/brain-sync-removals.XXXXXX") || exit 1 - trap 'rm -f "$list" "$list.gone"; gstack_brain_lock_release' EXIT INT TERM + trap "rm -f $(printf '%q ' "$list" "$list.gone"); gstack_brain_lock_release" EXIT INT TERM removal_candidates "$list" || { echo "BRAIN_SYNC: could not list removed files" >&2; exit 1; } n=$(grep -c . "$list" 2>/dev/null || true) if [ "$n" -eq 0 ]; then diff --git a/package.json b/package.json index 345d9566f..045b6c41d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.213", + "version": "1.91.214", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/brain-sync-removals.test.ts b/test/brain-sync-removals.test.ts index 66b457375..405de494d 100644 --- a/test/brain-sync-removals.test.ts +++ b/test/brain-sync-removals.test.ts @@ -16,9 +16,9 @@ const ROOT = path.resolve(import.meta.dir, '..'); let home: string; let remote: string; -function run(argv: string[]) { +function run(argv: string[], extraEnv: Record = {}) { const r = spawnSync(path.join(ROOT, 'bin', argv[0]), argv.slice(1), { - env: { ...process.env, HOME: home, GSTACK_HOME: home }, + env: { ...process.env, HOME: home, GSTACK_HOME: home, ...extraEnv }, encoding: 'utf-8', timeout: 60_000, }); @@ -145,6 +145,24 @@ describe('artifacts_sync_removals', () => { expect(run(['gstack-brain-sync', '--publish-removals', '--yes']).stdout).toContain('no removals to publish'); }); + test('--publish-removals cleans up its temp files and lock on every exit path', () => { + syncAll(['projects/p/designs/a.md', 'projects/p/designs/b.md']); + fs.rmSync(path.join(home, 'projects/p/designs/a.md')); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-removals-tmp-')); + try { + for (const argv of [['--publish-removals'], ['--publish-removals', '--yes'], ['--publish-removals', '--yes']]) { + const r = run(['gstack-brain-sync', ...argv], { TMPDIR: tmp }); + expect(r.stderr).not.toContain('unbound variable'); + expect(r.status).toBe(0); + expect(fs.readdirSync(tmp)).toEqual([]); + expect(fs.existsSync(path.join(home, '.brain-sync.lock.d'))).toBe(false); + } + expect(remoteFiles()).toEqual(['projects/p/designs/b.md']); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + test('config rejects values other than on and off', () => { const r = run(['gstack-config', 'set', 'artifacts_sync_removals', 'yes']); expect(r.stderr).toContain("artifacts_sync_removals 'yes' not recognized"); From 5ac0f569347f20a7f2c1fef9f276f1eb97ba8afd Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 25 Oct 2026 09:22:14 -0700 Subject: [PATCH 9/9] fix(ship): unattended handoff writes the receipt to a file the parent reads (GSTA-88) (#3409) Step 18-H's receipt write gains --out .gstack/tmp/ship-receipt.md; skill-start prints a ship-only UNATTENDED_SHIP line; docs/unattended.md sends the parent to read --body on that file and to compare head to the branch SHA. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 +++++++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-skill-start | 3 ++- docs/unattended.md | 11 ++++++++--- package.json | 2 +- ship/sections/handoff.md | 6 +++--- ship/sections/handoff.md.tmpl | 6 +++--- test/gstack-ship-receipt.test.ts | 26 ++++++++++++++++++++++++++ test/gstack-skill-start.test.ts | 14 ++++++++++++++ 10 files changed, 72 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bc6ef79e..f7d049cbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.91.215.0] - 2026-10-25 + +**An unattended `/ship` leaves its receipt in a file the parent can read, and the parent is told to read that file (GSTA-88).** Before, Step 18's handoff section wrote the ship receipt only to stdout, and `docs/unattended.md` told the parent to read it from the PR body with `gstack-ship-receipt read --pr --json`. An unattended run must not push, so there is no PR: the parent got a usage error or `RECEIPT_MISSING`, or, if an older PR existed, read that PR's receipt with exit 0 and a stale `head`. Now the handoff writes `.gstack/tmp/ship-receipt.md`, the parent reads it with `read --body` in the child's checkout, and it compares the receipt's `head` to the branch SHA either way. + +### Fixed +- **`ship/sections/handoff.md.tmpl`**: the receipt write carries `--out .gstack/tmp/ship-receipt.md`. The completion-message step drops `(\`pr: none\`)`, which was wrong (the block omits `pr`; only the `SHIP_RECEIPT:` line shows `pr=-`), and the "nothing precedes the identifiers" clause, which repeated "identifiers first". Ship's skeleton plus sections is 313,151 bytes, 13 below main and under its 313,281 cap; no cap or pin moved. +- **`bin/gstack-skill-start`**: an unattended `/ship` prints one status line, `UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md`. No other skill or session kind prints it, and no instruction block was added. The unattended block's `/ship` record sentence names the file for a run that did not push and the PR body only once a PR exists. +- **`docs/unattended.md`**: for `/ship`, read `gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json` in the child's checkout when the run did not push, use `read --pr --json` only once a PR exists, and in both cases compare the receipt's `head` to the branch SHA, because `read` does not check that the receipt is current. + +### Tests +- `test/gstack-ship-receipt.test.ts` runs the handoff template's write command as written in a fixture repository, then reads the file back with `read --body`: `head` is HEAD, `session_kind` is `unattended` and there is no `pr`. It also checks that the template and the generated section carry `--out` and that `docs/unattended.md` names the same file with `read --body`. +- `test/gstack-skill-start.test.ts` checks that the `UNATTENDED_SHIP` line appears exactly once for an unattended `/ship`, alongside only the `unattended-session` block, and never for `/review`, `/qa`, `/autoplan` or for `/ship` in an interactive, spawned or headless session. + ## [1.91.214.0] - 2026-10-25 **`gstack-brain-sync --publish-removals --yes` no longer prints `list: unbound variable` when it finishes, and it now removes its temp list and releases the sync lock.** The command's EXIT trap named the function's local `$list`. When a publish succeeded, the function returned before the script exited, so the trap ran after `list` had gone out of scope. Under `set -u` it stopped at the unbound variable and never reached the cleanup, which left `brain-sync-removals.*` in `$TMPDIR` and `.brain-sync.lock.d` in `~/.gstack` until a later run cleared the lock as stale (found by GSTA-44). diff --git a/VERSION b/VERSION index d3a2d850d..42f68a055 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.214.0 +1.91.215.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 6042037f3..4784a3a02 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.214.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.215.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-skill-start b/bin/gstack-skill-start index 680a645b9..81ccda14d 100755 --- a/bin/gstack-skill-start +++ b/bin/gstack-skill-start @@ -322,6 +322,7 @@ echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE" # GSTACK_SESSION_KIND=spawned override must light this up too. [ "$_SESSION_KIND" = "spawned" ] && echo "SPAWNED_SESSION: true" || true [ "$_SESSION_KIND" = "unattended" ] && echo "UNATTENDED_SESSION: true" || true +[ "$_SESSION_KIND" = "unattended" ] && [ "$SKILL_NAME" = "ship" ] && echo "UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at .gstack/tmp/ship-receipt.md" || true # --------------------------------------------------------------------------- # Artifacts sync (the former "Artifacts Sync (skill start)" fence, verbatim @@ -647,7 +648,7 @@ if [ "$_SESSION_KIND" = "unattended" ]; then case "$SKILL_NAME" in autoplan) _UA_RECORD="This skill writes a run directory through gstack-autoplan (plan.md, review-record.md, tasks.jsonl, decisions.jsonl, findings.jsonl, timing.json, run.json, gate.json): append each recommended choice to decisions.jsonl with kind \"auto\" and each pending one with kind \"approval\" and status \"pending\". The final approval gate is written as a gate list (gstack-gate render) and this run ENDS there with \`GSTACK_RESULT: skill=autoplan status=gate_pending run=

\`." ;; eval-plan) _UA_RECORD="This run ENDS at the Step 4 spend approval, left pending, with \`GSTACK_RESULT: skill=eval-plan status=gate_pending run=\`, where is the pilot's --out directory. No helper writes a gate.json for this skill yet, so the parent reads the pilot report and spend ledger in ." ;; - ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt in the PR body (\`gstack-ship-receipt read --pr --json\`)." ;; + ship) _UA_RECORD="$_UA_NONE The parent reads the ship receipt from .gstack/tmp/ship-receipt.md when nothing was pushed, and from the PR body (\`gstack-ship-receipt read --pr --json\`) only once a PR exists." ;; review) _UA_RECORD="$_UA_NONE The parent reads the review-log rows (\`gstack-review-read\`)." ;; qa) _UA_RECORD="$_UA_NONE The parent reads the \`qa-report-*.md\` file." ;; *) _UA_RECORD="$_UA_NONE The parent reads this skill's own record (docs/unattended.md)." ;; diff --git a/docs/unattended.md b/docs/unattended.md index b4f1c1fc1..249ff535c 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -88,9 +88,14 @@ Not every workflow writes this line or a run directory today: helper writes its `gate.json` yet (`gstack-gate` renders and parses a gate list; it does not create one). `/cso` skips the shared startup, so these session rules do not reach it at all. Read each skill's own record instead: - the ship receipt in the PR body (`gstack-ship-receipt read --pr --json`) - for `/ship`, the review-log rows (`gstack-review-read`) for `/review`, and the - `qa-report-*.md` file for `/qa`. + the review-log rows (`gstack-review-read`) for `/review`, the + `qa-report-*.md` file for `/qa`, and the ship receipt for `/ship`. When + the run did not push (an unattended run must not), read the receipt file in + the child's checkout with `gstack-ship-receipt read --body .gstack/tmp/ship-receipt.md --json`; + use `gstack-ship-receipt read --pr --json` only once a PR exists. In both + cases compare the receipt's `head` to the branch SHA (`git rev-parse HEAD` in + the child's checkout): `read` does not check that the receipt is current, so + an older PR's receipt reads as valid. Every workflow command shares one exit table: `0` ok, `1` fail, `2` usage, `3` refused or needs a flag. Error lines end with a result code in parentheses, diff --git a/package.json b/package.json index 045b6c41d..d03901193 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.214", + "version": "1.91.215", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/ship/sections/handoff.md b/ship/sections/handoff.md index 22f915991..046f585e2 100644 --- a/ship/sections/handoff.md +++ b/ship/sections/handoff.md @@ -10,16 +10,16 @@ automation lane, an unattended parent said so). Nothing here publishes. section does, then (with its Issue links flags; ask nothing): ```bash ~/.claude/skills/gstack/bin/gstack-issue-links --base --out .gstack/tmp/issues.json - ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json + ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json --out .gstack/tmp/ship-receipt.md ``` 2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:` - line and receipt block (`pr: none`); `branch:` and `sha:` (`git rev-parse HEAD`), + line and receipt block; `branch:` and `sha:` (`git rev-parse HEAD`), pushed or `not pushed`; `inputs:` the plan or issue, the base SHA and the spend cap; `before/after:` one row per claimed metric, measured on base and on this SHA with the command used; `escalation:` the one condition that stops the merge (a `requires-remote` lane, an open gate finding, an unverified claim) or `none`. `issues:` its lines, unpromoted candidates and lookup failures. - Full thread ids (`jam_…`) cross-thread; nothing precedes the identifiers. + Full thread ids (`jam_…`) cross-thread. 3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied. ### Multi-lane runs diff --git a/ship/sections/handoff.md.tmpl b/ship/sections/handoff.md.tmpl index efdc0937c..3c39b9674 100644 --- a/ship/sections/handoff.md.tmpl +++ b/ship/sections/handoff.md.tmpl @@ -8,16 +8,16 @@ automation lane, an unattended parent said so). Nothing here publishes. section does, then (with its Issue links flags; ask nothing): ```bash ~/.claude/skills/gstack/bin/gstack-issue-links --base --out .gstack/tmp/issues.json - ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json + ~/.claude/skills/gstack/bin/gstack-ship-receipt write --base --gate @.gstack/tmp/ship-gate.json --issues @.gstack/tmp/issues.json --out .gstack/tmp/ship-receipt.md ``` 2. Send the completion message, identifiers first, in this order: the `SHIP_RECEIPT:` - line and receipt block (`pr: none`); `branch:` and `sha:` (`git rev-parse HEAD`), + line and receipt block; `branch:` and `sha:` (`git rev-parse HEAD`), pushed or `not pushed`; `inputs:` the plan or issue, the base SHA and the spend cap; `before/after:` one row per claimed metric, measured on base and on this SHA with the command used; `escalation:` the one condition that stops the merge (a `requires-remote` lane, an open gate finding, an unverified claim) or `none`. `issues:` its lines, unpromoted candidates and lookup failures. - Full thread ids (`jam_…`) cross-thread; nothing precedes the identifiers. + Full thread ids (`jam_…`) cross-thread. 3. Skip Steps 19 and 21. Run Step 20 with `"version":null` when NO_VERSION applied. ### Multi-lane runs diff --git a/test/gstack-ship-receipt.test.ts b/test/gstack-ship-receipt.test.ts index 5e5e0e7d4..c345cbbe1 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -119,6 +119,32 @@ describe('gstack-ship-receipt read', () => { expect(runBin(repo, 'gstack-ship-receipt', ['read', '--pr', '9'], env).status).toBe(EXIT.fail); expect(runBin(repo, 'gstack-ship-receipt', ['read']).status).toBe(EXIT.usage); }); + + test('the unattended handoff write lands in the file docs/unattended.md tells the parent to read --body, at HEAD and with no pr (GSTA-88)', () => { + const root = path.resolve(import.meta.dir, '..'); + const tmpl = fs.readFileSync(path.join(root, 'ship/sections/handoff.md.tmpl'), 'utf8'); + const write = tmpl.match(/gstack-ship-receipt (write [^\n`]*)/)![1].trim(); + const out = write.match(/--out (\S+)/)?.[1]; + expect(out).toBe('.gstack/tmp/ship-receipt.md'); + expect(fs.readFileSync(path.join(root, 'ship/sections/handoff.md'), 'utf8')).toContain(write); + const docs = fs.readFileSync(path.join(root, 'docs/unattended.md'), 'utf8').replace(/\s+/g, ' '); + expect(docs).toContain(`gstack-ship-receipt read --body ${out} --json`); + expect(docs).toMatch(/read --pr --json` only once a PR exists/); + + const repo = fixture(); + const env = { GSTACK_SESSION_KIND: 'unattended' }; + fs.mkdirSync(path.join(repo.work, '.gstack/tmp'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, '.gstack/tmp/ship-gate.json'), '{"free":{"pass":1,"fail":0,"skip":0}}'); + expect(runBin(repo, 'gstack-issue-links', ['--base', 'main', '--out', '.gstack/tmp/issues.json'], env).status).toBe(EXIT.ok); + const w = runBin(repo, 'gstack-ship-receipt', write.replace('', 'main').split(' '), env); + expect(w.status).toBe(EXIT.ok); + expect(fs.readFileSync(path.join(repo.work, out!), 'utf8')).toBe(w.out); + const r = runBin(repo, 'gstack-ship-receipt', ['read', '--body', out!, '--json'], env); + expect(r.status).toBe(EXIT.ok); + const receipt = JSON.parse(r.out); + expect(receipt).toMatchObject({ head: repo.git('rev-parse', 'HEAD'), base: 'main', session_kind: 'unattended' }); + expect(receipt).not.toHaveProperty('pr'); + }); }); describe('gstack-ship-receipt census (P7)', () => { diff --git a/test/gstack-skill-start.test.ts b/test/gstack-skill-start.test.ts index 17d0528b8..6669cb711 100644 --- a/test/gstack-skill-start.test.ts +++ b/test/gstack-skill-start.test.ts @@ -418,6 +418,20 @@ describe('gstack-skill-start behavior', () => { } }, 60_000); + test('unattended /ship alone prints the UNATTENDED_SHIP status line naming the receipt file (GSTA-88)', () => { + const line = /^UNATTENDED_SHIP: no push, no PR; Step 18 takes the handoff section; receipt at \.gstack\/tmp\/ship-receipt\.md$/gm; + const ship = runStart(['--skill', 'ship'], { GSTACK_SESSION_KIND: 'unattended' }); + expect(ship.match(line)).toHaveLength(1); + expect((ship.match(/^GSTACK_INSTRUCTION_BEGIN: (\S+)/gm) ?? []).map((h) => h.replace(/^GSTACK_INSTRUCTION_BEGIN: /, ''))).toEqual(['unattended-session']); + expect(ship).toContain('.gstack/tmp/ship-receipt.md when nothing was pushed'); + for (const skill of ['review', 'qa', 'autoplan']) { + expect(runStart(['--skill', skill], { GSTACK_SESSION_KIND: 'unattended' })).not.toContain('UNATTENDED_SHIP'); + } + for (const kind of ['interactive', 'spawned', 'headless']) { + expect(runStart(['--skill', 'ship'], { GSTACK_SESSION_KIND: kind })).not.toContain('UNATTENDED_SHIP'); + } + }, 60_000); + test('unattended session makes no egress call even with telemetry and artifacts sync enabled and a queue pending (plan B1)', () => { // Shadow bin: every helper is the real one except the egress sinks, which // record their invocation. Bins resolve siblings through dirname "$0", so a