-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathworktree_guard.py
More file actions
281 lines (233 loc) · 9.72 KB
/
Copy pathworktree_guard.py
File metadata and controls
281 lines (233 loc) · 9.72 KB
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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
#!/usr/bin/env python3
"""
Location: pact-plugin/hooks/worktree_guard.py
Summary: PreToolUse hook matching Edit|Write that blocks edits to application
code outside the active worktree boundary.
Used by: hooks.json PreToolUse hook (matcher: Edit|Write)
Only active when PACT_WORKTREE_PATH env var is set (by /PACT:worktree-setup).
Complete no-op otherwise.
Input: JSON from stdin with tool_input.file_path
Output: JSON with hookSpecificOutput.permissionDecision if blocking
"""
from __future__ import annotations
import json
import sys
import os
from pathlib import Path
from typing import NoReturn
_SUPPRESS_OUTPUT = json.dumps({"suppressOutput": True})
def _emit_load_failure_deny(stage: str, error: BaseException) -> NoReturn:
"""Stdlib-only fail-closed deny. Mirrors the ``dispatch_gate`` /
``bootstrap_gate`` analogue.
This module imports only stdlib, so there is no cross-package import to
wrap (an import wrapper here would be vacuous). The live fail-open
exposure is RUNTIME: an uncaught gate-logic exception crashes the hook
(exit 1), which the platform treats as a NON-blocking PreToolUse hook —
the Edit/Write would PROCEED outside the worktree boundary. main() wraps
everything after stdin parsing in ``except Exception`` routing here
(stdin-parse failure stays fail-open: input-side failure is the
harness's domain and cannot be evaluated, mirroring bootstrap_gate).
Residual not covered here: an in-file SyntaxError / parse-time crash
still exits 1 → fail-open; no in-file code can close that (a file cannot
wrap its own parse). That class is held closed by the static
annotation-compat guard in tests/ (Python 3.9 importability rules).
hookEventName MUST be present.
"""
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
f"PACT worktree_guard {stage} failure — blocking for safety. "
f"{type(error).__name__}: {error}. Check hook installation "
"and shared module availability."
),
}
}))
print(
f"Hook load error (worktree_guard / {stage}): {error}",
file=sys.stderr,
)
sys.exit(2)
# Paths always allowed regardless of worktree
ALLOW_PATTERNS = [
"/.claude/",
"/docs/",
"CLAUDE.md",
".gitignore",
]
# Directories that indicate application code
APP_CODE_DIRS = ["src/", "lib/", "app/", "test/", "tests/", "scripts/"]
# File extensions that indicate application code
APP_CODE_EXTENSIONS = {
".py", ".ts", ".js", ".tsx", ".jsx", ".rb", ".go", ".rs",
".java", ".sh", ".yml", ".yaml", ".tf", ".sql",
}
def is_allowed_path(file_path: str) -> bool:
"""Check if path is in the allow-list (always permitted).
Uses path component matching instead of substring matching to avoid
false positives (e.g., a directory named 'mydocs' matching '/docs/').
"""
p = Path(file_path)
parts = p.parts
name = p.name
for pattern in ALLOW_PATTERNS:
clean = pattern.strip("/")
# Match as path component (e.g., ".claude", "docs") or filename (e.g., "CLAUDE.md")
if clean in parts or name == clean:
return True
return False
def is_application_code(file_path: str) -> bool:
"""Heuristic: is this file application code?"""
# Check directory indicators
for dir_pattern in APP_CODE_DIRS:
if f"/{dir_pattern}" in file_path:
return True
# Check extension
suffix = Path(file_path).suffix.lower()
return suffix in APP_CODE_EXTENSIONS
def _find_project_root(worktree_path: str) -> str | None:
"""
Find the project root for a worktree by locating the .worktrees ancestor.
Args:
worktree_path: Resolved worktree path
Returns:
Project root path, or None if not found
"""
worktree_p = Path(worktree_path)
for parent in worktree_p.parents:
worktrees_dir = parent / ".worktrees"
if worktrees_dir.is_dir():
return str(parent)
return None
def _suggest_worktree_path(file_path: str, worktree_path: str) -> str | None:
"""
Compute the corrected worktree path for a file outside the worktree.
Attempts to find the project root by looking for the .worktrees ancestor,
validates that the file belongs to the same project, then replaces the
project root prefix with the worktree path.
Args:
file_path: Path of the file being edited (outside worktree)
worktree_path: Active worktree path
Returns:
Suggested corrected path, or None if unable to compute
"""
try:
resolved_file = str(Path(file_path).resolve())
resolved_worktree = str(Path(worktree_path).resolve())
# Find project root from worktree path
project_root = _find_project_root(resolved_worktree)
if project_root:
# Only suggest if the file is under the SAME project root.
# This prevents false matches from nested worktree projects.
if resolved_file.startswith(project_root + "/"):
relative = resolved_file[len(project_root) + 1:]
return str(Path(resolved_worktree) / relative)
# File is not under this project root — don't suggest
return None
# Fallback: try to find common path segments between file and worktree.
# If worktree is /a/b/.worktrees/feat/x and file is /a/b/src/foo.py,
# the relative part after the common ancestor is src/foo.py.
#
# Validate: the common ancestor must be a plausible project root
# (contains .git, .worktrees, or similar). Without this check, two
# unrelated paths like /usr/local/bin/tool and /usr/share/lib/x would
# match on /usr and produce a nonsensical suggestion.
file_parts = Path(resolved_file).parts
wt_parts = Path(resolved_worktree).parts
common_len = 0
for i, (fp, wp) in enumerate(zip(file_parts, wt_parts)):
if fp == wp:
common_len = i + 1
else:
break
if common_len > 1: # Must share more than just "/" root
common_ancestor = Path(*file_parts[:common_len])
# Validate: common ancestor looks like a project directory.
# Accepts CLAUDE.md at either supported location (.claude/ is the
# new default, ./CLAUDE.md is legacy).
is_project_dir = (
(common_ancestor / ".git").exists()
or (common_ancestor / ".worktrees").exists()
or (common_ancestor / "CLAUDE.md").exists()
or (common_ancestor / ".claude" / "CLAUDE.md").exists()
)
if is_project_dir:
relative = str(Path(*file_parts[common_len:]))
return str(Path(resolved_worktree) / relative)
except (ValueError, OSError):
pass
return None
def check_worktree_boundary(file_path: str, worktree_path: str) -> str | None:
"""
Check if a file edit is within the worktree boundary.
Args:
file_path: Path of the file being edited
worktree_path: Active worktree path (empty = inactive)
Returns:
Error message if blocked, None if allowed
"""
if not worktree_path:
return None # No worktree active, no-op
# Always allow certain paths
if is_allowed_path(file_path):
return None
# Check if inside worktree
try:
resolved_file = str(Path(file_path).resolve())
resolved_worktree = str(Path(worktree_path).resolve())
if resolved_file.startswith(resolved_worktree):
return None # Inside worktree, OK
except (ValueError, OSError):
return None # Can't resolve, allow by default
# Outside worktree — only block if it's application code
if is_application_code(file_path):
suggestion = _suggest_worktree_path(file_path, worktree_path)
msg = (
f"Edit blocked: {file_path} is outside the active worktree "
f"at {worktree_path}."
)
if suggestion:
msg += f"\nDid you mean: {suggestion}"
return msg
return None # Non-app-code outside worktree is fine
def main():
worktree_path = os.environ.get("PACT_WORKTREE_PATH", "")
if not worktree_path:
print(_SUPPRESS_OUTPUT)
sys.exit(0) # No worktree active, complete no-op
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError:
print(_SUPPRESS_OUTPUT)
sys.exit(0)
try:
file_path = input_data.get("tool_input", {}).get("file_path", "")
if not file_path:
print(_SUPPRESS_OUTPUT)
sys.exit(0)
error = check_worktree_boundary(file_path, worktree_path)
except Exception as e:
# Runtime fail-CLOSED — a gate-logic exception must DENY. Uncaught it
# would exit 1, which PreToolUse treats as NON-blocking, and the
# Edit/Write would proceed outside the worktree boundary. The wrap
# starts at tool_input extraction: a valid-JSON-but-non-dict
# tool_input raises AttributeError right here. except Exception (not
# BaseException) so SystemExit from the legitimate decision paths
# above passes through.
_emit_load_failure_deny("runtime", e)
if error:
# hookEventName is required by the harness; missing it silently fails open
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": error,
}
}
print(json.dumps(output))
sys.exit(2)
print(_SUPPRESS_OUTPUT)
sys.exit(0)
if __name__ == "__main__":
main()