File size: 8,034 Bytes
a6b96c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
#!/usr/bin/env node
// gsd-hook-version: 1.5.0
// GSD Worktree Path Guard β€” PreToolUse hook
// Blocks Edit/Write/MultiEdit tool calls that target absolute paths outside the worktree root.
//
// Problem: gsd-executor agents spawned with isolation="worktree" sometimes issue
// Edit/Write calls with absolute paths rooted at the MAIN repository instead of
// the worktree (issue #260). The prose guard in agents/gsd-executor.md step 0b
// is never enforced because the model under load skips it.
//
// This hook enforces the constraint at the tooling layer, making it HARD-BLOCKING.
//
// Triggers on: Edit, Write, and MultiEdit tool calls
// Action: BLOCK (exit 2) if file_path is absolute and outside the worktree root
// No-op: relative paths, non-worktree CWDs, hook errors (silent fail)

const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');

const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, windowsHide: true };

function git(args, cwd) {
  return spawnSync('git', args, { ...SPAWNOPT, cwd });
}

// Walk up from `start` to find the nearest existing directory.
// Returns null if we reach the filesystem root without finding one.
function nearestExistingDir(start) {
  let dir = start;
  let prev;
  do {
    prev = dir;
    try { fs.accessSync(dir, fs.constants.F_OK); return dir; } catch { /* keep walking */ }
    dir = path.dirname(dir);
  } while (dir !== prev);
  return null;
}

let input = '';
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
  clearTimeout(stdinTimeout);
  try {
    const data = JSON.parse(input);
    const toolName = data.tool_name;

    // Only guard Edit, Write, and MultiEdit tool calls
    if (toolName !== 'Edit' && toolName !== 'Write' && toolName !== 'MultiEdit') {
      process.exit(0);
    }

    const cwd = data.cwd || process.cwd();

    // Detect whether CWD is inside a linked git worktree by inspecting
    // the git-dir path. In a linked worktree, git rev-parse --git-dir
    // returns a path containing .git/worktrees/ as a component.
    // In the main repo or a submodule it returns .git (or a path without /worktrees/).
    // This approach works even when cwd is a subdirectory of the worktree.
    const gitDirResult = git(['rev-parse', '--git-dir'], cwd);
    if (gitDirResult.status !== 0 || !gitDirResult.stdout) {
      process.exit(0); // not a git repo β€” pass through
    }

    const gitDir = gitDirResult.stdout.trim();
    // A linked worktree's --git-dir contains .git/worktrees/ as a path component
    const isLinkedWorktree = /[/\\]\.git[/\\]worktrees[/\\]/.test(gitDir);
    if (!isLinkedWorktree) {
      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.
    const wtTopResult = git(['rev-parse', '--show-toplevel'], cwd);
    if (wtTopResult.status !== 0 || !wtTopResult.stdout) {
      process.exit(0); // can't determine root β€” fail open
    }
    const wtTopRaw = wtTopResult.stdout.trim();

    const rawFilePath = data.tool_input?.file_path || '';
    if (!rawFilePath) {
      process.exit(0);
    }

    // Relative paths are always safe β€” they resolve relative to CWD inside the worktree
    if (!path.isAbsolute(rawFilePath)) {
      process.exit(0);
    }

    // Normalise .. traversal so /worktree/src/../../../main/file
    // resolves to its true location before we check containment.
    const filePath = path.resolve(rawFilePath);

    // Find the nearest existing ancestor of filePath so we can ask git
    // for its toplevel. The file itself may not exist yet (Write creates
    // new files), but at least one ancestor directory must exist.
    // We check the file itself first in case it already exists.
    const checkDir = nearestExistingDir(
      (() => {
        try {
          return fs.statSync(filePath).isDirectory() ? filePath : path.dirname(filePath);
        } catch {
          return path.dirname(filePath);
        }
      })()
    );

    if (!checkDir) {
      // Walked to root without finding any directory β€” path is synthetic.
      // 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.
    // Comparing two raw git --show-toplevel outputs avoids every
    // platform-specific path normalisation pitfall (Windows 8.3 short names,
    // case differences between realpathSync and path.resolve, forward- vs
    // back-slash inconsistencies) β€” both values come from the same git binary
    // in the same format by definition.
    const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);

    if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
      // 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. ~/.opencode/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();

    // Same git toplevel β†’ file is inside the worktree β†’ allow
    if (fileTopRaw === wtTopRaw) {
      process.exit(0);
    }

    // BLOCK: file resolves to a different git root than the active worktree
    const output = {
      decision: 'block',
      reason:
        `Worktree path guard: '${filePath}' resolves to git root '${fileTopRaw}' which ` +
        `differs from the active worktree root '${wtTopRaw}'. This likely means an ` +
        `absolute path was derived from the orchestrator's main repository instead of ` +
        `the active worktree. To fix: use a relative path, or re-derive the base ` +
        `directory with \`git rev-parse --show-toplevel\` from within the worktree ` +
        `(hook cwd: '${cwd}').`,
    };

    process.stdout.write(JSON.stringify(output));
    process.exit(2);
  } catch {
    // Silent fail β€” never block valid tool calls due to hook errors
    process.exit(0);
  }
});