fix(#1342): scope worktree-path-guard to GSD executor runs; fail open for no-repo targets (#1361)

* fix(#1342): scope worktree-path-guard to GSD executor runs; fail open for no-repo targets

The PreToolUse worktree-path-guard fired for any Write/Edit in any linked git
worktree, with no check for active GSD work — so Claude Code plan-mode writing
~/.claude/plans/<slug>.md from a manually-created worktree was hard-blocked.

- Gate enforcement on the GSD isolated-executor branch namespace
  (^worktree-agent-[A-Za-z0-9._/-]+$, per worktree-branch-check.md #2924); the
  guard is a no-op in non-GSD linked worktrees.
- Fail open when a target resolves to no git repository (e.g. ~/.claude/plans/)
  instead of blocking — that is not the #260 main-repo vector. A target inside
  a .git directory still blocks (git rev-parse --is-inside-git-dir).
- The #260 different-git-root hard block (escape to the main repo) is preserved.

Detached-HEAD executors no-op the gate; this is accepted because they are
fail-closed by worktree-branch-check.md (exit 42) before committing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1342): add changeset for worktree-path-guard scoping fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1342): build dot-dot traversal path portably (Windows drive-letter fix)

The traversal test built its file_path by stripping a leading slash from an
absolute externalDir and path.join-ing it after a `..` chain. On Windows the
drive letter (C:\) is not a leading slash, so it survived and path.resolve
produced an invalid doubled-drive path (C:\C:\Users\...), which resolves to no
git repo — the hook failed open (exit 0) and the test expected a block (exit 2).

Use path.relative(worktreeDir, externalTarget) + string concat so the file_path
carries literal `..` segments that resolve to externalTarget on both posix and
win32 (no drive doubling). Verified with path.win32/path.posix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-17 08:50:28 -04:00
committed by GitHub
parent c53fd1f654
commit 07adeb50a0
3 changed files with 269 additions and 50 deletions

View File

@@ -71,6 +71,17 @@ process.stdin.on('end', () => {
process.exit(0); // main repo, submodule, or separate-git-dir — no-op
}
// #1342: Only enforce inside a GSD-managed isolated executor worktree. Those
// are always on a `worktree-agent-*` branch (the positive allow-list enforced
// by worktree-branch-check.md, #2924). A manually-created linked worktree (plain
// non-GSD work, e.g. Claude Code plan-mode) is on the user's own branch, so the
// guard must be a no-op there. Detached HEAD / error → not GSD-managed → no-op.
const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
if (!/^worktree-agent-[A-Za-z0-9._/-]+$/.test(branch)) {
process.exit(0); // not a GSD-managed executor worktree — no-op
}
// Get the raw --show-toplevel output for the worktree (cwd).
// We keep it raw (not path.resolve'd) to compare directly with the
// file's toplevel — same git binary, same format, no normalization needed.
@@ -110,15 +121,9 @@ process.stdin.on('end', () => {
if (!checkDir) {
// Walked to root without finding any directory — path is synthetic.
// Block conservatively.
const output = {
decision: 'block',
reason:
`Worktree path guard: '${filePath}' has no existing ancestor directory — ` +
`cannot verify it is inside the worktree '${wtTopRaw}'. Use a relative path instead.`,
};
process.stdout.write(JSON.stringify(output));
process.exit(2);
// A path with no existing ancestor is not the #260 main-repo vector;
// #260 is caught by the different-git-root branch below. Fail open. (#1342)
process.exit(0);
}
// Ask git for the toplevel of the file's location.
@@ -130,15 +135,26 @@ process.stdin.on('end', () => {
const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);
if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
// checkDir is not inside any git repo → cannot be inside the worktree.
const output = {
decision: 'block',
reason:
`Worktree path guard: '${filePath}' is not inside any git repository — ` +
`it cannot be inside the worktree at '${wtTopRaw}'. Use a relative path instead.`,
};
process.stdout.write(JSON.stringify(output));
process.exit(2);
// The target's location is not a git work tree. Two sub-cases:
// - Inside a .git directory (e.g. /main-repo/.git/config or .git/hooks/*)
// → an absolute write into a repository's internals; still a #260-class
// escape (and dangerous) → BLOCK.
// - Truly outside all git repositories (e.g. ~/.claude/plans/) → not the
// main-repo vector → fail open. (#1342)
const insideGitDir = git(['rev-parse', '--is-inside-git-dir'], checkDir);
if (insideGitDir.status === 0 && insideGitDir.stdout && insideGitDir.stdout.trim() === 'true') {
const output = {
decision: 'block',
reason:
`Worktree path guard: '${filePath}' is inside a git internal (.git) directory, ` +
`not the active worktree at '${wtTopRaw}'. Writing to repository internals via an ` +
`absolute path is not permitted from an isolated executor worktree. Use a relative path.`,
};
process.stdout.write(JSON.stringify(output));
process.exit(2);
}
// Outside all git repositories — fail open (#1342).
process.exit(0);
}
const fileTopRaw = fileTopResult.stdout.trim();