Files
msd-core/tests/bug-260-worktree-path-guard.test.cjs
Tom Boucher 07adeb50a0 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>
2026-06-17 08:50:28 -04:00

661 lines
28 KiB
JavaScript

/**
* Regression tests for bug #260 — gsd-worktree-path-guard.js
*
* Executor agents spawned with isolation="worktree" sometimes issue Edit/Write
* calls with absolute paths rooted at the MAIN repository instead of the
* worktree. The prose guard in gsd-executor.md step 0b is skipped under load,
* so we enforce the constraint at the tooling layer with a PreToolUse hook.
*
* This file verifies all guard behaviours:
* 1. No-op in the main repo (.git is a directory)
* 2. Relative path always passes
* 3. Non-Edit/Write tools always pass
* 4. Absolute path inside worktree root passes
* 5. Absolute path outside worktree root is BLOCKED (exit 2)
* 6. Sibling path that merely shares a prefix is BLOCKED (/ boundary check)
* 7. install.js has an fs.existsSync guard for gsd-worktree-path-guard.js
*/
'use strict';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync, execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-worktree-path-guard.js');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
/**
* Resolve symlinks in a path so that we compare the same canonical form
* that `git rev-parse --show-toplevel` returns. On macOS /tmp is a symlink
* to /private/tmp, which causes path prefix checks to fail without this.
*/
function realp(p) {
try { return fs.realpathSync(p); } catch { return p; }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function git(cwd, args) {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
}
/**
* Create a plain git repo (main repo — .git is a directory).
*/
function makeMainRepo() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-main-'));
git(dir, ['init', '-q']);
git(dir, ['config', 'user.email', 'test@example.com']);
git(dir, ['config', 'user.name', 'Test User']);
git(dir, ['config', 'commit.gpgsign', 'false']);
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
git(dir, ['add', 'README.md']);
git(dir, ['commit', '-q', '-m', 'chore: init']);
return dir;
}
/**
* Create a worktree off mainRepo and return its path.
* In the worktree, .git is a FILE (the gitdir pointer).
* @param {string} mainRepo - path to the main repo
* @param {string} [branchName] - branch name to use (default: 'worktree-agent-test')
*/
function makeWorktree(mainRepo, branchName) {
const branch = branchName || 'worktree-agent-test';
const wtDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-wt-'));
fs.rmdirSync(wtDir); // git worktree add creates the dir itself
git(mainRepo, ['worktree', 'add', '-q', '-b', branch, wtDir]);
return wtDir;
}
/**
* Run the hook with a given payload, returning the spawnSync result.
*/
function runHook(cwd, payload) {
return spawnSync(process.execPath, [HOOK_PATH], {
cwd,
input: JSON.stringify(payload),
encoding: 'utf8',
});
}
// ---------------------------------------------------------------------------
// Fixture lifecycle
// ---------------------------------------------------------------------------
let mainRepo;
let worktreeDir;
before(() => {
mainRepo = realp(makeMainRepo());
worktreeDir = realp(makeWorktree(mainRepo));
});
after(() => {
// Remove worktree registration before deleting the directory
try { git(mainRepo, ['worktree', 'remove', '--force', worktreeDir]); } catch { /* ignore */ }
cleanup(mainRepo);
cleanup(worktreeDir);
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('bug #260: gsd-worktree-path-guard.js', () => {
// 1. No-op in main repo
describe('no-op in main repo', () => {
test('Edit call in main repo (.git is a directory) exits 0', () => {
const payload = {
cwd: mainRepo,
tool_name: 'Edit',
tool_input: { file_path: path.join(mainRepo, 'src', 'foo.ts') },
};
const result = runHook(mainRepo, payload);
assert.strictEqual(result.status, 0, `Expected exit 0 in main repo, got ${result.status}. stderr: ${result.stderr}`);
assert.strictEqual(result.stdout, '', 'Expected no stdout in main repo no-op');
});
test('Write call in main repo exits 0', () => {
const payload = {
cwd: mainRepo,
tool_name: 'Write',
tool_input: { file_path: path.join(mainRepo, 'out.txt') },
};
const result = runHook(mainRepo, payload);
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
});
});
// 2. Relative path always passes
describe('relative path', () => {
test('Edit with relative file_path exits 0 even in worktree', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: 'src/foo.ts' },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0, `Relative path should always pass. stderr: ${result.stderr}`);
assert.strictEqual(result.stdout, '');
});
test('Write with relative file_path exits 0 in worktree', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Write',
tool_input: { file_path: 'dist/bundle.js' },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0);
assert.strictEqual(result.stdout, '');
});
});
// 3. Non-Edit/Write tools always pass
describe('non-Edit/Write tools', () => {
test('Bash tool exits 0', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Bash',
tool_input: { command: 'ls' },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0);
});
test('Read tool exits 0', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Read',
tool_input: { file_path: path.join(mainRepo, 'README.md') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0);
});
test('Grep tool exits 0', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Grep',
tool_input: { pattern: 'foo', path: mainRepo },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0);
});
});
// 4. Absolute path inside worktree passes
describe('path inside worktree', () => {
test('Edit with absolute path inside worktree root exits 0', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0, `Path inside worktree should pass. stderr: ${result.stderr}`);
assert.strictEqual(result.stdout, '');
});
test('Edit targeting exactly the worktree root exits 0', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: worktreeDir },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0);
});
});
// 5. Absolute path outside worktree is BLOCKED
describe('path outside worktree is blocked', () => {
test('Edit targeting main repo root exits 2 with block decision', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 2, `Expected exit 2 (block), got ${result.status}. stderr: ${result.stderr}`);
let parsed;
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
});
test('Write targeting main repo root exits 2 with block decision', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'Write',
tool_input: { file_path: path.join(mainRepo, 'out.txt') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 2);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block');
});
test('block output includes the offending path in reason', () => {
const offendingPath = path.join(mainRepo, 'src', 'leak.ts');
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: offendingPath },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 2);
const parsed = JSON.parse(result.stdout);
assert.ok(
parsed.reason && parsed.reason.includes(offendingPath),
`block reason should include the offending path. Got: ${parsed.reason}`
);
});
});
// 6. Sibling directory path is BLOCKED (validates the '/' boundary check AND prefix-overlap)
describe('sibling path is blocked', () => {
test('path that shares prefix with worktree root but is a sibling exits 2', () => {
// This test exercises BOTH the prefix-overlap boundary check AND the different-git-root block:
// worktree = <base>/wt
// sibling = <base>/wt-sibling ← shares "wt" prefix with the worktree root
// target = <base>/wt-sibling/file.ts
//
// A naive startsWith(wtRoot) check would wrongly classify "<base>/wt-sibling/..." as inside
// the worktree (it doesn't include the '/' boundary). The hook resolves the sibling's git
// toplevel (a different repo) so the different-git-root block fires regardless.
// (#1342: paths outside all git repos now fail open; only different-git-root blocks.)
const base = realp(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-260-sib-base-')));
const wtDir = path.join(base, 'wt');
const siblingRepoDir = path.join(base, 'wt-sibling');
// We need a genuine linked worktree at <base>/wt and a separate git repo at <base>/wt-sibling.
// Create a fresh main repo to host this worktree (the fixture worktree is already allocated).
const sibMainRepo = realp(makeMainRepo());
try {
fs.mkdirSync(base, { recursive: true });
// Create linked worktree at <base>/wt (using sibMainRepo as its host).
git(sibMainRepo, ['worktree', 'add', '-q', '-b', 'worktree-agent-sib-test', wtDir]);
// Create a separate git repo at <base>/wt-sibling (shares "wt" prefix).
fs.mkdirSync(siblingRepoDir, { recursive: true });
git(siblingRepoDir, ['init', '-q']);
git(siblingRepoDir, ['config', 'user.email', 'test@example.com']);
git(siblingRepoDir, ['config', 'user.name', 'Test User']);
git(siblingRepoDir, ['config', 'commit.gpgsign', 'false']);
fs.writeFileSync(path.join(siblingRepoDir, 'README.md'), '# sibling\n');
git(siblingRepoDir, ['add', 'README.md']);
git(siblingRepoDir, ['commit', '-q', '-m', 'chore: sibling init']);
// Confirm prefix-overlap: siblingRepoDir starts with wtDir (without trailing sep).
assert.ok(
siblingRepoDir.startsWith(wtDir),
`Sibling "${siblingRepoDir}" must share a string prefix with worktree "${wtDir}" for this test to be meaningful`
);
// Confirm they are genuinely distinct (different toplevel).
assert.notStrictEqual(
realp(siblingRepoDir), realp(wtDir),
'sibling and worktree must be different directories'
);
const siblingPath = path.join(realp(siblingRepoDir), 'file.ts');
const payload = {
cwd: realp(wtDir),
tool_name: 'Edit',
tool_input: { file_path: siblingPath },
};
const result = runHook(realp(wtDir), payload);
assert.strictEqual(result.status, 2,
`Path inside a prefix-sibling git repo "${siblingPath}" must be blocked (exit 2), got ${result.status}. ` +
`This validates both the prefix-overlap boundary and the different-git-root block. stderr: ${result.stderr}`
);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block');
} finally {
try { git(sibMainRepo, ['worktree', 'remove', '--force', wtDir]); } catch { /* ignore */ }
cleanup(sibMainRepo);
cleanup(base);
}
});
});
// 7. Adversarial: subdirectory cwd still guards correctly (Codex finding #2)
describe('subdirectory cwd', () => {
test('hook fires when cwd is a subdirectory of the worktree, not just its root', () => {
// The orchestrator may set cwd to a subdirectory. The hook must still
// detect the worktree context via git rev-parse --git-dir and block.
const subDir = path.join(worktreeDir, 'src');
fs.mkdirSync(subDir, { recursive: true });
const payload = {
cwd: subDir,
tool_name: 'Edit',
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
};
const result = runHook(subDir, payload);
assert.strictEqual(result.status, 2,
`Hook must block even when cwd is a subdirectory of the worktree. ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block');
});
test('path inside worktree passes even when cwd is a subdirectory', () => {
const subDir = path.join(worktreeDir, 'src');
fs.mkdirSync(subDir, { recursive: true });
const payload = {
cwd: subDir,
tool_name: 'Edit',
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
};
const result = runHook(subDir, payload);
assert.strictEqual(result.status, 0,
`Absolute path inside worktree should pass regardless of cwd. ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
});
});
// 8. Adversarial: `..` traversal is normalised before the containment check (Codex finding #1)
describe('dot-dot traversal is blocked', () => {
test('path with .. that escapes the worktree is blocked', () => {
// Construct the traversal target inside a SEPARATE git repo that is
// guaranteed to be outside the worktree on every platform (no symlink
// ambiguity). The hook finds the external dir's git toplevel (a different
// repo → different-git-root block).
// (#1342: paths outside all git repos now fail open; only different-git-root blocks,
// so externalDir must be inside a real different git repo to exercise the block.)
const externalDir = realp(makeMainRepo());
try {
// Sanity: the external directory must not be inside the worktree.
assert.ok(
!externalDir.startsWith(worktreeDir + path.sep) && externalDir !== worktreeDir,
`externalDir "${externalDir}" must be outside worktreeDir "${worktreeDir}"`
);
// Build a traversal path that uses ../ segments to climb out of the
// worktree and into externalDir. path.resolve() will normalise it to
// externalDir/file.ts, which is outside the worktree by construction.
// We compute the number of segments needed to reach the filesystem root
// from worktreeDir so the traversal always lands at the right level
// regardless of how deep the worktree path is.
// Build a file_path containing literal `..` segments that climb out of the
// worktree into externalDir. path.relative() yields a ..-laden relative path
// between two same-drive absolute paths (both live under os.tmpdir()); we
// re-anchor it at worktreeDir via STRING CONCAT (NOT path.join, which would
// normalise the `..` away) so the hook's path.resolve() must collapse it.
// Windows-safe: avoids the drive-letter doubling that
// path.join(worktreeDir, '..', absolutePath) produces on win32 (#1342).
const externalTarget = path.join(externalDir, 'file.ts');
const traversalPath = worktreeDir + path.sep + path.relative(worktreeDir, externalTarget);
// Confirm the resolved path is truly outside the worktree (test integrity guard).
const resolved = path.resolve(traversalPath);
assert.ok(
!resolved.startsWith(worktreeDir + path.sep) && resolved !== worktreeDir,
`Traversal resolved to "${resolved}" which is still inside worktreeDir "${worktreeDir}". ` +
`This means the test itself is broken, not a production bug.`
);
const payload = {
cwd: worktreeDir,
tool_name: 'Edit',
tool_input: { file_path: traversalPath },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 2,
`Traversal path "${traversalPath}" resolves to "${resolved}" which is outside the worktree. ` +
`Must be blocked (exit 2). Got exit ${result.status}. stderr: ${result.stderr}`
);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block',
`Expected decision:"block", got: ${JSON.stringify(parsed)}`
);
} finally {
cleanup(externalDir);
}
});
});
// 9. MultiEdit is also guarded (Codex finding #5)
describe('MultiEdit tool is guarded', () => {
test('MultiEdit with outside absolute path is blocked', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'MultiEdit',
tool_input: { file_path: path.join(mainRepo, 'src', 'index.ts') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 2,
`MultiEdit targeting outside path must be blocked. Got ${result.status}. stderr: ${result.stderr}`
);
const parsed = JSON.parse(result.stdout);
assert.strictEqual(parsed.decision, 'block');
});
test('MultiEdit with inside absolute path passes', () => {
const payload = {
cwd: worktreeDir,
tool_name: 'MultiEdit',
tool_input: { file_path: path.join(worktreeDir, 'src', 'foo.ts') },
};
const result = runHook(worktreeDir, payload);
assert.strictEqual(result.status, 0,
`MultiEdit inside worktree should pass. Got ${result.status}. stderr: ${result.stderr}`
);
});
});
});
// ---------------------------------------------------------------------------
// #1342 — GSD-activity gate + fail-open for no-repo targets
// ---------------------------------------------------------------------------
describe('#1342 — GSD-activity gate + fail-open for no-repo targets', () => {
// Fixtures: one non-agent linked worktree (plain user branch) + one agent worktree
let mainRepo1342;
let nonAgentWorktree; // on branch 'feature-x' — non-GSD
let agentWorktree; // on branch 'worktree-agent-foo' — GSD-managed
before(() => {
mainRepo1342 = realp(makeMainRepo());
nonAgentWorktree = realp(makeWorktree(mainRepo1342, 'feature-x'));
agentWorktree = realp(makeWorktree(mainRepo1342, 'worktree-agent-foo'));
});
after(() => {
try { git(mainRepo1342, ['worktree', 'remove', '--force', nonAgentWorktree]); } catch { /* ignore */ }
try { git(mainRepo1342, ['worktree', 'remove', '--force', agentWorktree]); } catch { /* ignore */ }
cleanup(mainRepo1342);
cleanup(nonAgentWorktree);
cleanup(agentWorktree);
});
// Test 1 — reporter repro: non-agent worktree writing outside all git repos → exit 0
test('(1) non-agent linked worktree: Write to a path outside all git repos exits 0 (no block)', () => {
// Simulates Claude Code plan-mode writing ~/.claude/plans/<slug>.md from a
// manually-created linked worktree that is NOT on a worktree-agent-* branch.
const plansDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1342-plans-'));
try {
const targetPath = path.join(plansDir, 'my-plan.md');
const payload = {
cwd: nonAgentWorktree,
tool_name: 'Write',
tool_input: { file_path: targetPath },
};
const result = runHook(nonAgentWorktree, payload);
assert.strictEqual(result.status, 0,
`Non-agent linked worktree writing outside git repos must exit 0 (reporter repro). ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
assert.strictEqual(result.stdout, '', 'Expected no block output');
} finally {
cleanup(plansDir);
}
});
// Test 2 — non-agent linked worktree: Edit targeting MAIN repo root → exit 0 (gate no-op)
test('(2) non-agent linked worktree: Edit targeting main repo root exits 0 (gate no-op, not #260 block)', () => {
const payload = {
cwd: nonAgentWorktree,
tool_name: 'Edit',
tool_input: { file_path: path.join(mainRepo1342, 'src', 'index.ts') },
};
const result = runHook(nonAgentWorktree, payload);
assert.strictEqual(result.status, 0,
`Non-agent linked worktree must exit 0 (GSD-activity gate fires before #260 check). ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
assert.strictEqual(result.stdout, '', 'Expected no block output');
});
// Test 3 — GSD-managed worktree (worktree-agent-foo): Edit targeting main repo root → exit 2 (block)
test('(3) GSD-managed worktree: Edit targeting main repo root exits 2 with block decision', () => {
const payload = {
cwd: agentWorktree,
tool_name: 'Edit',
tool_input: { file_path: path.join(mainRepo1342, 'src', 'index.ts') },
};
const result = runHook(agentWorktree, payload);
assert.strictEqual(result.status, 2,
`GSD-managed worktree targeting main repo root must be blocked (exit 2). ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
let parsed;
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
});
// Test 4 — GSD-managed worktree: absolute target INSIDE the active worktree → exit 0
test('(4) GSD-managed worktree: absolute target inside the active worktree exits 0', () => {
const payload = {
cwd: agentWorktree,
tool_name: 'Edit',
tool_input: { file_path: path.join(agentWorktree, 'src', 'foo.ts') },
};
const result = runHook(agentWorktree, payload);
assert.strictEqual(result.status, 0,
`GSD-managed worktree targeting its own subtree must pass. ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
assert.strictEqual(result.stdout, '', 'Expected no block output');
});
// Test 5 — GSD-managed worktree: target OUTSIDE all git repos (tmpdir) → exit 0 (fail open)
test('(5) GSD-managed worktree: target outside all git repos exits 0 (fail open, not #260 vector)', () => {
// Create a temp dir that is NOT a git repository (no .git).
// This is the ~/.claude/plans/ scenario — a path that has a real ancestor
// directory but is outside every git repo.
// IMPORTANT: this dir must NOT be inside any .git directory — it must be a plain tempdir
// so the fail-open path (truly outside all repos) is exercised, not the .git-internals block.
const externalDir = realp(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1342-ext-')));
try {
const targetPath = path.join(externalDir, 'notes.md');
const payload = {
cwd: agentWorktree,
tool_name: 'Write',
tool_input: { file_path: targetPath },
};
const result = runHook(agentWorktree, payload);
assert.strictEqual(result.status, 0,
`GSD-managed worktree writing to a path outside all git repos must fail open (exit 0). ` +
`Only the different-git-root vector (#260) blocks; no-repo targets are not that vector. ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
assert.strictEqual(result.stdout, '', 'Expected no block output');
} finally {
cleanup(externalDir);
}
});
// Test 6 — GSD-managed worktree: Write to .git/config of the MAIN repo → exit 2 (block)
test('(6) blocks absolute writes into the main repo .git internals from a GSD worktree (#1342)', () => {
// A target like /main-repo/.git/config or /main-repo/.git/hooks/pre-commit causes
// `git rev-parse --show-toplevel` to FAIL (a .git dir is not a work tree), so the
// "file not in any git repo" branch fires. Previously that branch failed open — but
// writing into repository internals via an absolute path is still a #260-class escape
// (and dangerous, e.g. injecting a git hook). The fix checks --is-inside-git-dir and
// blocks when true.
const gitConfigPath = path.join(mainRepo1342, '.git', 'config');
const payload = {
cwd: agentWorktree,
tool_name: 'Write',
tool_input: { file_path: gitConfigPath },
};
const result = runHook(agentWorktree, payload);
assert.strictEqual(result.status, 2,
`GSD-managed worktree targeting .git/config of another repo must be blocked (exit 2). ` +
`Got exit ${result.status}. stderr: ${result.stderr}`
);
let parsed;
assert.doesNotThrow(() => { parsed = JSON.parse(result.stdout); }, 'stdout must be valid JSON');
assert.strictEqual(parsed.decision, 'block', 'Expected decision:"block" in output');
assert.ok(
parsed.reason && parsed.reason.includes('.git'),
`Block reason should mention .git internals. Got: ${parsed.reason}`
);
});
});
// ---------------------------------------------------------------------------
// Static analysis: install.js guard
// ---------------------------------------------------------------------------
describe('install.js guard for gsd-worktree-path-guard.js', () => {
let src;
before(() => {
// ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts.
// Concatenate both sources so structural assertions find patterns in either file.
const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8');
let hooksSurfaceSrc = '';
try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }
src = installSrc + '\n' + hooksSurfaceSrc;
});
test('install.js has hasWorktreePathGuardHook variable', () => {
assert.ok(
src.includes('hasWorktreePathGuardHook'),
'hasWorktreePathGuardHook variable not found in install.js'
);
});
test('install.js checks fs.existsSync before registering gsd-worktree-path-guard.js', () => {
const anchorIdx = src.indexOf('hasWorktreePathGuardHook');
assert.ok(anchorIdx !== -1, 'hasWorktreePathGuardHook not found in install.js');
const blockStart = anchorIdx;
const blockEnd = Math.min(src.length, anchorIdx + 1200);
const block = src.slice(blockStart, blockEnd);
assert.ok(
block.includes('fs.existsSync') || block.includes('existsSync'),
'install.js must call fs.existsSync on the target path before registering ' +
'gsd-worktree-path-guard.js in settings.json. Without this guard, the hook ' +
'is registered even when the .js file was never copied (root cause of #1754).'
);
});
test('install.js emits a skip warning when gsd-worktree-path-guard.js is missing', () => {
const anchorIdx = src.indexOf('hasWorktreePathGuardHook');
assert.ok(anchorIdx !== -1, 'hasWorktreePathGuardHook not found in install.js');
const block = src.slice(anchorIdx, Math.min(src.length, anchorIdx + 1200));
assert.ok(
block.includes('Skipped') && block.includes('gsd-worktree-path-guard'),
'install.js must emit a skip warning mentioning gsd-worktree-path-guard when the file is not found'
);
});
});