-
Notifications
You must be signed in to change notification settings - Fork 110
Expand file tree
/
Copy pathguard.py
More file actions
119 lines (99 loc) · 3.51 KB
/
Copy pathguard.py
File metadata and controls
119 lines (99 loc) · 3.51 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
#!/usr/bin/env python3
"""Block `git commit` until /simplify runs or the user requests a one-time bypass.
The marker lives in the per-worktree Git directory because /simplify reviews
the worktree's index. Claude Code and Codex mint it through an explicit
completion signal carrying the reviewed worktree. An agent can mint the same
one-use permission when the user explicitly asks to skip /simplify. Other
runtimes remain unblocked because they have no supported path.
"""
import json
import os
import shlex
import subprocess
import sys
from pathlib import Path
data = json.load(sys.stdin)
event = data.get("hook_event_name", "")
tool_input = data.get("tool_input") or {}
COMPLETION_SIGNAL = ("echo", "simplify-guard:complete")
BYPASS_SIGNAL = ("echo", "simplify-guard:bypass")
SHELL_OPERATORS = {";", "&&", "||", "|", "(", ")"}
GIT_OPTIONS_WITH_VALUES = {"-C", "-c", "--config-env", "--git-dir", "--namespace", "--work-tree"}
GIT_OPTIONS_WITHOUT_SUBCOMMAND = {
"-h",
"--exec-path",
"--help",
"--html-path",
"--info-path",
"--man-path",
"--version",
}
def marker(git=("git",)):
"""Return the one-use marker for a Git worktree.
Args:
git (tuple[str, ...], optional): Git executable and global options.
Returns:
(Path | None): Marker path when the command resolves a Git directory.
"""
git_dir = subprocess.run(
[*git, "rev-parse", "--path-format=absolute", "--git-dir"],
capture_output=True,
cwd=data.get("cwd") or ".",
text=True,
).stdout.strip()
return Path(git_dir) / "simplify-guard.ok" if git_dir else None
def commit_prefix(tokens):
"""Return the safe Git prefix for a commit command.
Args:
tokens (list[str]): Shell tokens to inspect.
Returns:
(list[str] | None): Git executable and global options before `commit`.
"""
for start, arg in enumerate(tokens):
if Path(arg).name != "git":
continue
git = [arg]
for token in tokens[start + 1 :]:
if token in SHELL_OPERATORS:
break
if git[-1] in GIT_OPTIONS_WITH_VALUES:
git.append(token)
elif token == "commit":
return git
elif token in GIT_OPTIONS_WITHOUT_SUBCOMMAND or not token.startswith("-"):
break
else:
git.append(token)
def main():
"""Run the guard for one hook event."""
if event != "PreToolUse" or not (os.environ.get("CLAUDECODE") == "1" or data.get("turn_id")):
return
command = tool_input.get("command", "")
lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|()")
lexer.whitespace_split = True
tokens = list(lexer)
if len(tokens) == 3 and tuple(tokens[:2]) == COMPLETION_SIGNAL:
if m := marker(("git", "-C", tokens[2])):
m.touch()
return
if tuple(tokens) == BYPASS_SIGNAL:
if m := marker():
m.touch()
return
if not (git := commit_prefix(tokens)) or not (m := marker(git)):
return
if m.exists():
m.unlink() # spend the token before Git runs. Every attempt needs a fresh review
return
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "simplify-guard: run /simplify on the staged diff first, then retry the commit.",
}
}
)
)
main()