obey-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- obey_cli/__init__.py +3 -0
- obey_cli/__main__.py +3 -0
- obey_cli/_data/demo/README.md +12 -0
- obey_cli/_data/demo/obey.yaml +287 -0
- obey_cli/_data/demo/repo/.gitignore +8 -0
- obey_cli/_data/demo/repo/AGENTS.md +38 -0
- obey_cli/_data/demo/repo/README.md +36 -0
- obey_cli/_data/demo/repo/eslint.config.js +24 -0
- obey_cli/_data/demo/repo/index.html +21 -0
- obey_cli/_data/demo/repo/openapi.yaml +86 -0
- obey_cli/_data/demo/repo/package.json +26 -0
- obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
- obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
- obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
- obey_cli/_data/demo/repo/src/api/client.ts +71 -0
- obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
- obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
- obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
- obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
- obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
- obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
- obey_cli/_data/demo/repo/src/logger.ts +36 -0
- obey_cli/_data/demo/repo/src/main.ts +30 -0
- obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
- obey_cli/_data/demo/repo/src/render-table.ts +48 -0
- obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
- obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
- obey_cli/_data/demo/repo/tsconfig.json +20 -0
- obey_cli/_data/demo/repo/vitest.config.ts +8 -0
- obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/meta.json +10 -0
- obey_cli/_data/schemas/obey-config-v1.json +116 -0
- obey_cli/_data/schemas/report-v1.json +55 -0
- obey_cli/_data/schemas/survey-record-v1.json +30 -0
- obey_cli/checkers/__init__.py +30 -0
- obey_cli/checkers/samples.py +95 -0
- obey_cli/cli.py +201 -0
- obey_cli/commands.py +676 -0
- obey_cli/config.py +90 -0
- obey_cli/discover.py +232 -0
- obey_cli/enforce/__init__.py +114 -0
- obey_cli/enforce/engine/__init__.py +0 -0
- obey_cli/enforce/engine/obey_engine.py +2005 -0
- obey_cli/enforce/merge.py +196 -0
- obey_cli/enforce/targets/__init__.py +354 -0
- obey_cli/enforce/templates/obey_hook.py +452 -0
- obey_cli/enforce/templates/pre_commit.py +116 -0
- obey_cli/extract.py +1342 -0
- obey_cli/lint.py +125 -0
- obey_cli/llm.py +129 -0
- obey_cli/parse_md.py +260 -0
- obey_cli/probes/__init__.py +0 -0
- obey_cli/probes/facts.py +162 -0
- obey_cli/probes/templates.py +326 -0
- obey_cli/py.typed +0 -0
- obey_cli/report/__init__.py +314 -0
- obey_cli/resources.py +35 -0
- obey_cli/rules.py +75 -0
- obey_cli/runner/__init__.py +0 -0
- obey_cli/runner/agents/__init__.py +22 -0
- obey_cli/runner/agents/base.py +96 -0
- obey_cli/runner/agents/claude_code.py +203 -0
- obey_cli/runner/agents/codex.py +152 -0
- obey_cli/runner/agents/copilot.py +142 -0
- obey_cli/runner/agents/gemini.py +125 -0
- obey_cli/runner/agents/opencode.py +156 -0
- obey_cli/runner/core.py +140 -0
- obey_cli/runner/egress_proxy.py +166 -0
- obey_cli/runner/gateway.py +529 -0
- obey_cli/runner/ledger.py +132 -0
- obey_cli/runner/local.py +176 -0
- obey_cli/runner/orchestrate.py +227 -0
- obey_cli/runner/sandbox/Dockerfile +41 -0
- obey_cli/runner/sandbox/entrypoint.sh +38 -0
- obey_cli/runner/sandbox.py +341 -0
- obey_cli/runner/sidecar.py +38 -0
- obey_cli/runner/workspace.py +187 -0
- obey_cli/score.py +150 -0
- obey_cli/study/__init__.py +0 -0
- obey_cli/study/analysis.py +277 -0
- obey_cli/study/design.py +98 -0
- obey_cli/study/experiment.py +198 -0
- obey_cli/study/report.py +519 -0
- obey_cli/study/survey.py +438 -0
- obey_cli-0.1.0.dist-info/METADATA +393 -0
- obey_cli-0.1.0.dist-info/RECORD +90 -0
- obey_cli-0.1.0.dist-info/WHEEL +4 -0
- obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
- obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""obey hook: blocks agent actions that break a rule in .obey/hooks/rules.json.
|
|
3
|
+
|
|
4
|
+
Generated by obey (https://github.com/B0yko/obey); remove with `obey enforce --remove`. Standard library only,
|
|
5
|
+
Python 3.9+. Usage, as the agents' hook configs call it:
|
|
6
|
+
|
|
7
|
+
python3 .obey/hooks/obey_hook.py <claude|codex|cursor|gemini|copilot|opencode> [stop]
|
|
8
|
+
|
|
9
|
+
The payload arrives on stdin. A violation produces the agent's own deny format. Any internal error allows the
|
|
10
|
+
action and prints a warning (fail open), so a bug here can never lock you out of your agent.
|
|
11
|
+
|
|
12
|
+
Paths are taken relative to the project this script is installed in; a relative path is resolved against the
|
|
13
|
+
payload's cwd, and symlinks are followed. The stop event checks only what the agent itself changed in the session
|
|
14
|
+
(from a Claude Code transcript), and gives up after MAX_STOP_BLOCKS blocked stops in a row.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shlex
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
27
|
+
sys.path.insert(0, HERE)
|
|
28
|
+
|
|
29
|
+
# Blocked Stop events in a row that end the blocking, so a rule that cannot be met never traps an agent.
|
|
30
|
+
MAX_STOP_BLOCKS = 3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _project_root():
|
|
34
|
+
"""The project this hook was installed into (<root>/.obey/hooks/obey_hook.py), wherever the agent's cwd is."""
|
|
35
|
+
return os.path.dirname(os.path.dirname(HERE))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _payload_cwd(payload, root):
|
|
39
|
+
cwd = payload.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or os.environ.get("GEMINI_PROJECT_DIR")
|
|
40
|
+
if not isinstance(cwd, str) or not cwd:
|
|
41
|
+
return root
|
|
42
|
+
return cwd if os.path.isabs(cwd) else os.path.join(root, cwd)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _under(path, root):
|
|
46
|
+
try:
|
|
47
|
+
rel = os.path.relpath(path, root)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
if rel == ".." or rel.startswith(".." + os.sep):
|
|
51
|
+
return None
|
|
52
|
+
return rel.replace(os.sep, "/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _rel_forms(path, root, cwd, follow=True):
|
|
56
|
+
"""Repo-relative spellings of a path: as written (lexically normalised), then with symlinks resolved.
|
|
57
|
+
|
|
58
|
+
A relative path is taken against `cwd`. Both spellings are checked against the rules, so a link into a protected
|
|
59
|
+
directory (src/link -> ../secrets) and a repo reached through an alias are both seen. `follow=False` keeps the
|
|
60
|
+
last component as it is (rm and mv act on the link itself).
|
|
61
|
+
"""
|
|
62
|
+
if not path:
|
|
63
|
+
return [""]
|
|
64
|
+
full = path if os.path.isabs(path) else os.path.join(cwd or root, path)
|
|
65
|
+
if follow:
|
|
66
|
+
resolved = os.path.realpath(full)
|
|
67
|
+
else:
|
|
68
|
+
resolved = os.path.join(os.path.realpath(os.path.dirname(full)), os.path.basename(full))
|
|
69
|
+
real_root = os.path.realpath(root)
|
|
70
|
+
forms = []
|
|
71
|
+
for cand in (os.path.normpath(full), resolved):
|
|
72
|
+
for base in (root, real_root):
|
|
73
|
+
rel = _under(cand, base)
|
|
74
|
+
if rel is not None and rel not in forms:
|
|
75
|
+
forms.append(rel)
|
|
76
|
+
return forms or [os.path.relpath(resolved, real_root).replace(os.sep, "/")]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read(path):
|
|
80
|
+
try:
|
|
81
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
82
|
+
return fh.read()
|
|
83
|
+
except OSError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _args(value):
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
try:
|
|
90
|
+
value = json.loads(value)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return {"command": value}
|
|
93
|
+
return value if isinstance(value, dict) else {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _file_actions(kind, path, root, cwd, old=None, new=None, content=None):
|
|
97
|
+
"""Actions for a file edit or write: one per spelling of the path (see _rel_forms)."""
|
|
98
|
+
actions = []
|
|
99
|
+
for rel in _rel_forms(path, root, cwd):
|
|
100
|
+
current = _read(os.path.join(os.path.realpath(root), rel))
|
|
101
|
+
action = {"kind": kind, "path": rel, "exists": current is not None}
|
|
102
|
+
if kind == "edit":
|
|
103
|
+
action.update({"old": old or "", "new": new or ""})
|
|
104
|
+
if current is not None and old and old in current:
|
|
105
|
+
action["new_content"] = current.replace(old, new or "", 1)
|
|
106
|
+
action["current"] = current
|
|
107
|
+
elif kind == "write":
|
|
108
|
+
action["content"] = content or ""
|
|
109
|
+
action["current"] = current
|
|
110
|
+
actions.append(action)
|
|
111
|
+
return actions
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _delete_actions(path, root, cwd):
|
|
115
|
+
return [{"kind": "delete", "path": rel, "exists": True} for rel in _rel_forms(path, root, cwd, follow=False)]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _shell_action(command, root, cwd):
|
|
119
|
+
"""A shell action with the files it writes, resolved against the working directory and symlinks.
|
|
120
|
+
|
|
121
|
+
Writes to files that exist are what an existing_only rule forbids, so the action also lists the targets that
|
|
122
|
+
exist on disk.
|
|
123
|
+
"""
|
|
124
|
+
import obey_engine
|
|
125
|
+
|
|
126
|
+
action = {"kind": "shell", "command": command}
|
|
127
|
+
targets = obey_engine.shell_write_targets(command)
|
|
128
|
+
if targets:
|
|
129
|
+
real_root = os.path.realpath(root)
|
|
130
|
+
resolved, existing = [], []
|
|
131
|
+
for target in targets:
|
|
132
|
+
if target["path"] == "*":
|
|
133
|
+
resolved.append(target)
|
|
134
|
+
continue
|
|
135
|
+
for rel in _rel_forms(target["path"], root, cwd, follow=target["op"] == "write"):
|
|
136
|
+
resolved.append({"path": rel, "op": target["op"]})
|
|
137
|
+
if os.path.lexists(os.path.join(real_root, rel)):
|
|
138
|
+
existing.append(rel)
|
|
139
|
+
action["targets"] = resolved
|
|
140
|
+
action["existing_paths"] = existing
|
|
141
|
+
return action
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _patch_actions(patch, root, cwd):
|
|
145
|
+
"""Actions from an apply_patch envelope (*** Begin Patch / *** Update File: path / +line / -line)."""
|
|
146
|
+
actions, current, kind, added, removed = [], None, None, [], []
|
|
147
|
+
|
|
148
|
+
def flush():
|
|
149
|
+
if current is not None:
|
|
150
|
+
if kind == "delete":
|
|
151
|
+
actions.extend(_delete_actions(current, root, cwd))
|
|
152
|
+
elif kind == "add":
|
|
153
|
+
actions.extend(_file_actions("write", current, root, cwd, content="\n".join(added)))
|
|
154
|
+
else:
|
|
155
|
+
actions.extend(_file_actions("edit", current, root, cwd, old="\n".join(removed), new="\n".join(added)))
|
|
156
|
+
|
|
157
|
+
for line in patch.splitlines():
|
|
158
|
+
for marker, k in (("*** Add File: ", "add"), ("*** Update File: ", "update"), ("*** Delete File: ", "delete")):
|
|
159
|
+
if line.startswith(marker):
|
|
160
|
+
flush()
|
|
161
|
+
current, kind, added, removed = line[len(marker) :].strip(), k, [], []
|
|
162
|
+
break
|
|
163
|
+
else:
|
|
164
|
+
if line.startswith("*** Move to: ") and current is not None:
|
|
165
|
+
actions.extend(_delete_actions(current, root, cwd))
|
|
166
|
+
current = line[len("*** Move to: ") :].strip()
|
|
167
|
+
elif line.startswith("+") and not line.startswith("+++"):
|
|
168
|
+
added.append(line[1:])
|
|
169
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
170
|
+
removed.append(line[1:])
|
|
171
|
+
flush()
|
|
172
|
+
return actions
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def actions_from(agent, payload, root, cwd=None):
|
|
176
|
+
"""Normalise one agent's pre-tool payload into engine actions."""
|
|
177
|
+
cwd = cwd or root
|
|
178
|
+
if agent in ("claude", "codex", "gemini") or (agent == "copilot" and "tool_name" in payload):
|
|
179
|
+
tool = payload.get("tool_name", "")
|
|
180
|
+
data = _args(payload.get("tool_input"))
|
|
181
|
+
elif agent == "copilot":
|
|
182
|
+
tool, data = payload.get("toolName", ""), _args(payload.get("toolArgs"))
|
|
183
|
+
elif agent == "cursor":
|
|
184
|
+
if "command" in payload and "tool_name" not in payload:
|
|
185
|
+
return [_shell_action(_command_text(payload.get("command", "")), root, cwd)]
|
|
186
|
+
tool, data = payload.get("tool_name", ""), _args(payload.get("tool_input"))
|
|
187
|
+
elif agent == "opencode":
|
|
188
|
+
tool, data = payload.get("tool", ""), _args(payload.get("args"))
|
|
189
|
+
else:
|
|
190
|
+
return []
|
|
191
|
+
name = str(tool)
|
|
192
|
+
low = name.lower()
|
|
193
|
+
command = data.get("command")
|
|
194
|
+
if low in ("bash", "shell", "run_shell_command", "powershell") or (low == "" and command):
|
|
195
|
+
return [_shell_action(_command_text(command), root, cwd)]
|
|
196
|
+
if low == "apply_patch":
|
|
197
|
+
patch = data.get("command") or data.get("patch") or data.get("input") or data.get("patchText") or ""
|
|
198
|
+
if isinstance(patch, list):
|
|
199
|
+
patch = "\n".join(str(p) for p in patch)
|
|
200
|
+
return _patch_actions(str(patch), root, cwd)
|
|
201
|
+
path = (
|
|
202
|
+
data.get("file_path")
|
|
203
|
+
or data.get("filePath")
|
|
204
|
+
or data.get("path")
|
|
205
|
+
or data.get("notebook_path")
|
|
206
|
+
or data.get("target_file")
|
|
207
|
+
or ""
|
|
208
|
+
)
|
|
209
|
+
if low in ("write", "write_file", "create"):
|
|
210
|
+
content = data.get("content") or data.get("file_text") or data.get("fileText") or data.get("contents") or ""
|
|
211
|
+
return _file_actions("write", path, root, cwd, content=str(content))
|
|
212
|
+
if low in ("edit", "replace", "str_replace_editor", "notebookedit", "multiedit"):
|
|
213
|
+
if isinstance(data.get("edits"), list):
|
|
214
|
+
old = "\n".join(str(e.get("old_string", "")) for e in data["edits"] if isinstance(e, dict))
|
|
215
|
+
new = "\n".join(str(e.get("new_string", "")) for e in data["edits"] if isinstance(e, dict))
|
|
216
|
+
else:
|
|
217
|
+
old = data.get("old_string") or data.get("oldString") or data.get("old_str") or ""
|
|
218
|
+
new = data.get("new_string") or data.get("newString") or data.get("new_str") or data.get("new_source") or ""
|
|
219
|
+
return _file_actions("edit", path, root, cwd, old=str(old), new=str(new))
|
|
220
|
+
if low in ("delete", "delete_file"):
|
|
221
|
+
return _delete_actions(path, root, cwd)
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _command_text(command):
|
|
226
|
+
"""The command line: a string, or an argv list (a Codex shell call) joined the way a shell would read it."""
|
|
227
|
+
if isinstance(command, (list, tuple)):
|
|
228
|
+
return shlex.join(str(c) for c in command)
|
|
229
|
+
return str(command or "")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def deny_output(agent, reason):
|
|
233
|
+
if agent in ("claude", "codex"):
|
|
234
|
+
return {
|
|
235
|
+
"hookSpecificOutput": {
|
|
236
|
+
"hookEventName": "PreToolUse",
|
|
237
|
+
"permissionDecision": "deny",
|
|
238
|
+
"permissionDecisionReason": reason,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if agent == "copilot":
|
|
242
|
+
return {"permissionDecision": "deny", "permissionDecisionReason": reason}
|
|
243
|
+
if agent == "gemini":
|
|
244
|
+
return {"decision": "deny", "reason": reason}
|
|
245
|
+
if agent == "cursor":
|
|
246
|
+
return {"permission": "deny", "user_message": reason, "agent_message": reason}
|
|
247
|
+
if agent == "opencode":
|
|
248
|
+
return {"deny": reason}
|
|
249
|
+
return {"deny": reason}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def stop_output(agent, reason):
|
|
253
|
+
if agent in ("claude", "codex", "copilot"):
|
|
254
|
+
return {"decision": "block", "reason": reason}
|
|
255
|
+
if agent == "gemini":
|
|
256
|
+
return {"decision": "deny", "reason": reason}
|
|
257
|
+
if agent == "cursor":
|
|
258
|
+
return {"followup_message": reason}
|
|
259
|
+
return {}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def warn_output(agent, message):
|
|
263
|
+
if agent in ("claude", "codex"):
|
|
264
|
+
return {"systemMessage": message}
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _git(root, *args):
|
|
269
|
+
try:
|
|
270
|
+
out = subprocess.run(["git", *args], cwd=root, capture_output=True, timeout=20, check=False)
|
|
271
|
+
return out.stdout.decode("utf-8", "replace") if out.returncode == 0 else ""
|
|
272
|
+
except (OSError, subprocess.SubprocessError):
|
|
273
|
+
return ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _working_tree_diff(root, paths=None):
|
|
277
|
+
"""The uncommitted changes as a git diff, untracked files as new-file diffs; only those in `paths` when given."""
|
|
278
|
+
import obey_engine
|
|
279
|
+
|
|
280
|
+
if paths is not None and not paths:
|
|
281
|
+
return ""
|
|
282
|
+
spec = ["--", *sorted(paths)] if paths is not None else []
|
|
283
|
+
diff = _git(root, "--literal-pathspecs", "diff", "HEAD", "--relative", "--no-color", "--no-ext-diff", *spec)
|
|
284
|
+
for path in _git(root, "ls-files", "--others", "--exclude-standard", "-z").split("\0"):
|
|
285
|
+
if path and not path.startswith(".obey/") and (paths is None or path in paths):
|
|
286
|
+
text = _read(os.path.join(root, path))
|
|
287
|
+
if text is not None:
|
|
288
|
+
diff += obey_engine.new_file_diff(path, text)
|
|
289
|
+
return diff
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _agent_changes(events, root):
|
|
293
|
+
"""Repo-relative paths that the agent's edits and shell commands wrote; None when a command's targets are unknown.
|
|
294
|
+
|
|
295
|
+
Event paths are rewritten to repo-relative ones, so the rules' globs match them.
|
|
296
|
+
"""
|
|
297
|
+
import obey_engine
|
|
298
|
+
|
|
299
|
+
paths = set()
|
|
300
|
+
for ev in events:
|
|
301
|
+
if ev.get("denied"):
|
|
302
|
+
continue
|
|
303
|
+
kind = ev.get("type")
|
|
304
|
+
if kind in ("edit", "write", "delete") and ev.get("path"):
|
|
305
|
+
forms = _rel_forms(ev["path"], root, root)
|
|
306
|
+
ev["path"] = forms[0]
|
|
307
|
+
paths.update(forms)
|
|
308
|
+
elif kind == "shell":
|
|
309
|
+
for target in obey_engine.shell_write_targets(ev.get("command") or ""):
|
|
310
|
+
if target["path"] == "*":
|
|
311
|
+
return None
|
|
312
|
+
paths.update(_rel_forms(target["path"], root, root, follow=target["op"] == "write"))
|
|
313
|
+
return paths
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _stop_run(root, payload):
|
|
317
|
+
"""(run record for the stop-time checks, whether the agent's commands are known).
|
|
318
|
+
|
|
319
|
+
With a Claude Code transcript the record holds the agent's own edits and commands, and only the files they
|
|
320
|
+
touched count as changed, so the user's uncommitted work never triggers a block. Without one (other agents) it
|
|
321
|
+
is the whole working tree and the commands are unknown.
|
|
322
|
+
"""
|
|
323
|
+
import obey_engine
|
|
324
|
+
|
|
325
|
+
events, known, paths = [], False, None
|
|
326
|
+
transcript = payload.get("transcript_path") or payload.get("transcriptPath")
|
|
327
|
+
if isinstance(transcript, str) and os.path.exists(transcript):
|
|
328
|
+
text = _read(transcript) or ""
|
|
329
|
+
if obey_engine.transcript_recognized(text):
|
|
330
|
+
events, known = obey_engine.events_from_transcript(text, root), True
|
|
331
|
+
paths = _agent_changes(events, root)
|
|
332
|
+
final = payload.get("last_assistant_message") or payload.get("prompt_response") or ""
|
|
333
|
+
run = {
|
|
334
|
+
"events": events,
|
|
335
|
+
"diff": _working_tree_diff(root, paths),
|
|
336
|
+
"base_files": [p for p in _git(root, "ls-files", "-z").split("\0") if p],
|
|
337
|
+
"commits": [],
|
|
338
|
+
"pushed_refs": [],
|
|
339
|
+
"final_message": final if isinstance(final, str) else "",
|
|
340
|
+
}
|
|
341
|
+
return run, known
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _log(entry):
|
|
345
|
+
path = os.environ.get("OBEY_HOOK_LOG")
|
|
346
|
+
if not path:
|
|
347
|
+
return
|
|
348
|
+
try:
|
|
349
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
350
|
+
fh.write(json.dumps(dict(entry, ts=round(time.time(), 3))) + "\n")
|
|
351
|
+
except OSError:
|
|
352
|
+
pass
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _stop_state(payload, root):
|
|
356
|
+
"""The file that counts a session's consecutive blocked Stop events (in the temp dir)."""
|
|
357
|
+
key = str(payload.get("session_id") or payload.get("sessionId") or payload.get("conversation_id") or "")
|
|
358
|
+
if not key:
|
|
359
|
+
key = "default-" + hashlib.sha256(root.encode("utf-8", "replace")).hexdigest()[:12]
|
|
360
|
+
safe = "".join(c for c in key if c.isalnum() or c in "-_")[:80]
|
|
361
|
+
return os.path.join(tempfile.gettempdir(), "obey-stop-" + safe)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _blocked_stops(path):
|
|
365
|
+
try:
|
|
366
|
+
with open(path) as fh:
|
|
367
|
+
return int(fh.read().strip() or 0)
|
|
368
|
+
except (OSError, ValueError):
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _set_blocked_stops(path, count):
|
|
373
|
+
try:
|
|
374
|
+
if count:
|
|
375
|
+
with open(path, "w") as fh:
|
|
376
|
+
fh.write(str(count))
|
|
377
|
+
elif os.path.exists(path):
|
|
378
|
+
os.remove(path)
|
|
379
|
+
except OSError:
|
|
380
|
+
pass
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def main(argv):
|
|
384
|
+
agent = argv[1] if len(argv) > 1 else "claude"
|
|
385
|
+
event = argv[2] if len(argv) > 2 else "pre"
|
|
386
|
+
started = time.monotonic()
|
|
387
|
+
try:
|
|
388
|
+
raw = sys.stdin.read()
|
|
389
|
+
payload = json.loads(raw) if raw.strip() else {}
|
|
390
|
+
if not isinstance(payload, dict):
|
|
391
|
+
payload = {}
|
|
392
|
+
import obey_engine
|
|
393
|
+
|
|
394
|
+
with open(os.path.join(HERE, "rules.json"), encoding="utf-8") as fh:
|
|
395
|
+
rules = json.load(fh)["rules"]
|
|
396
|
+
root = _project_root()
|
|
397
|
+
cwd = _payload_cwd(payload, root)
|
|
398
|
+
if event == "stop":
|
|
399
|
+
state = _stop_state(payload, root)
|
|
400
|
+
blocked = _blocked_stops(state)
|
|
401
|
+
if blocked >= MAX_STOP_BLOCKS:
|
|
402
|
+
_set_blocked_stops(state, 0) # a fresh start for the next turn
|
|
403
|
+
return 0 # do not keep an agent in a loop; the run report still shows the violation
|
|
404
|
+
run, known = _stop_run(root, payload)
|
|
405
|
+
for rule in rules:
|
|
406
|
+
kind = rule["checker"]["type"]
|
|
407
|
+
if kind not in obey_engine.STOP_TYPES or (kind == "command_required" and not known):
|
|
408
|
+
continue # without a transcript the agent's commands are unknown, so none can be required
|
|
409
|
+
verdict = obey_engine.check(rule, run)
|
|
410
|
+
if verdict["verdict"] != "violated":
|
|
411
|
+
continue
|
|
412
|
+
if kind == "path_required_with" and known:
|
|
413
|
+
# The agent's changes lack the paired change; one the user made earlier may supply it.
|
|
414
|
+
whole = obey_engine.check(rule, dict(run, diff=_working_tree_diff(root)))
|
|
415
|
+
if whole["verdict"] != "violated":
|
|
416
|
+
continue
|
|
417
|
+
reason = obey_engine.reason(rule, verdict)
|
|
418
|
+
_set_blocked_stops(state, blocked + 1)
|
|
419
|
+
_log({"agent": agent, "event": "stop", "decision": "block", "rule_id": rule["id"]})
|
|
420
|
+
print(json.dumps(stop_output(agent, reason)))
|
|
421
|
+
return 0
|
|
422
|
+
_set_blocked_stops(state, 0) # this Stop passes
|
|
423
|
+
return 0
|
|
424
|
+
for action in actions_from(agent, payload, root, cwd):
|
|
425
|
+
decision = obey_engine.evaluate_action(rules, action)
|
|
426
|
+
if decision:
|
|
427
|
+
_log(
|
|
428
|
+
{
|
|
429
|
+
"agent": agent,
|
|
430
|
+
"event": "pre",
|
|
431
|
+
"decision": "deny",
|
|
432
|
+
"rule_id": decision["rule_id"],
|
|
433
|
+
"tool": payload.get("tool_name") or payload.get("toolName") or payload.get("tool"),
|
|
434
|
+
"ms": round((time.monotonic() - started) * 1000, 1),
|
|
435
|
+
}
|
|
436
|
+
)
|
|
437
|
+
print(json.dumps(deny_output(agent, decision["reason"])))
|
|
438
|
+
return 0
|
|
439
|
+
_log({"agent": agent, "event": event, "decision": "allow"})
|
|
440
|
+
return 0
|
|
441
|
+
except Exception as exc:
|
|
442
|
+
message = "obey hook error, action allowed: {}: {}".format(type(exc).__name__, exc)
|
|
443
|
+
sys.stderr.write(message + "\n")
|
|
444
|
+
out = warn_output(agent, message)
|
|
445
|
+
if out:
|
|
446
|
+
print(json.dumps(out))
|
|
447
|
+
_log({"agent": agent, "event": event, "decision": "error", "error": message})
|
|
448
|
+
return 0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
if __name__ == "__main__":
|
|
452
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""obey pre-commit check: the staged diff and the commit message against .obey/hooks/rules.json.
|
|
3
|
+
|
|
4
|
+
Generated by obey (https://github.com/B0yko/obey). Standard library only, Python 3.9+. Works with any agent and
|
|
5
|
+
with people. Install as git hooks:
|
|
6
|
+
|
|
7
|
+
ln -s ../../.obey/pre-commit .git/hooks/pre-commit
|
|
8
|
+
ln -s ../../.obey/pre-commit .git/hooks/commit-msg
|
|
9
|
+
|
|
10
|
+
or through the pre-commit framework (see the obey README). With a commit-message file argument it checks the
|
|
11
|
+
message; without one it checks the staged changes. Exit code 1 blocks the commit. An internal error (the engine
|
|
12
|
+
cannot be loaded, a crash) allows the commit and says so on stderr, so a bug here can never lock you out of git.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _find_root():
|
|
21
|
+
"""The project root: git's top level when it holds obey's files, else where this script really lives.
|
|
22
|
+
|
|
23
|
+
Through the symlink install, __file__ is .git/hooks/pre-commit; its real path is <root>/.obey/pre-commit.
|
|
24
|
+
"""
|
|
25
|
+
real = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
|
26
|
+
try:
|
|
27
|
+
out = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, timeout=10, check=False)
|
|
28
|
+
top = out.stdout.decode("utf-8", "replace").strip() if out.returncode == 0 else ""
|
|
29
|
+
except (OSError, subprocess.SubprocessError):
|
|
30
|
+
top = ""
|
|
31
|
+
for candidate in (top, real):
|
|
32
|
+
if candidate and os.path.isfile(os.path.join(candidate, ".obey", "hooks", "rules.json")):
|
|
33
|
+
return candidate
|
|
34
|
+
return real
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
ROOT = _find_root()
|
|
38
|
+
sys.path.insert(0, os.path.join(ROOT, ".obey", "hooks"))
|
|
39
|
+
|
|
40
|
+
STAGED_TYPES = {
|
|
41
|
+
"path_forbidden",
|
|
42
|
+
"dependency_unchanged",
|
|
43
|
+
"content_forbidden_added",
|
|
44
|
+
"content_required_added",
|
|
45
|
+
"file_naming",
|
|
46
|
+
"path_required_with",
|
|
47
|
+
"command_forbidden",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Files `obey enforce` writes: they quote the rules and the engine's own code, so they are not checked against them.
|
|
52
|
+
OWN = (
|
|
53
|
+
".obey",
|
|
54
|
+
".claude/settings.json",
|
|
55
|
+
".codex/hooks.json",
|
|
56
|
+
".cursor/hooks.json",
|
|
57
|
+
".gemini/settings.json",
|
|
58
|
+
".github/hooks/obey.json",
|
|
59
|
+
".opencode/plugins/obey.ts",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _git(*args):
|
|
64
|
+
out = subprocess.run(["git", *args], cwd=ROOT, capture_output=True, timeout=30, check=False)
|
|
65
|
+
return out.stdout.decode("utf-8", "replace")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv):
|
|
69
|
+
try:
|
|
70
|
+
import json
|
|
71
|
+
|
|
72
|
+
import obey_engine
|
|
73
|
+
|
|
74
|
+
with open(os.path.join(ROOT, ".obey", "hooks", "rules.json"), encoding="utf-8") as fh:
|
|
75
|
+
rules = json.load(fh)["rules"]
|
|
76
|
+
message_file = next((a for a in argv[1:] if not a.startswith("-")), None)
|
|
77
|
+
problems = []
|
|
78
|
+
if message_file:
|
|
79
|
+
with open(message_file, encoding="utf-8", errors="replace") as fh:
|
|
80
|
+
message = "\n".join(line for line in fh.read().splitlines() if not line.startswith("#")).strip()
|
|
81
|
+
run = {
|
|
82
|
+
"events": [],
|
|
83
|
+
"diff": "",
|
|
84
|
+
"base_files": [],
|
|
85
|
+
"commits": [{"sha": "staged", "message": message, "files": []}],
|
|
86
|
+
"pushed_refs": [],
|
|
87
|
+
"final_message": "",
|
|
88
|
+
}
|
|
89
|
+
selected = [r for r in rules if r["checker"]["type"] == "commit_message"]
|
|
90
|
+
else:
|
|
91
|
+
run = {
|
|
92
|
+
"events": [],
|
|
93
|
+
"diff": _git(
|
|
94
|
+
"diff", "--cached", "--no-color", "--no-ext-diff", "--", ".", *[":(exclude)" + p for p in OWN]
|
|
95
|
+
),
|
|
96
|
+
"base_files": [p for p in _git("ls-files", "-z").split("\0") if p],
|
|
97
|
+
"commits": [],
|
|
98
|
+
"pushed_refs": [],
|
|
99
|
+
"final_message": "",
|
|
100
|
+
}
|
|
101
|
+
selected = [r for r in rules if r["checker"]["type"] in STAGED_TYPES]
|
|
102
|
+
for rule in selected:
|
|
103
|
+
verdict = obey_engine.check(rule, run)
|
|
104
|
+
if verdict["verdict"] == "violated":
|
|
105
|
+
problems.append(obey_engine.reason(rule, verdict))
|
|
106
|
+
if problems:
|
|
107
|
+
sys.stderr.write("obey: commit blocked\n" + "".join(" - " + p + "\n" for p in problems))
|
|
108
|
+
return 1
|
|
109
|
+
return 0
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
sys.stderr.write("obey pre-commit error, commit allowed: {}: {}\n".format(type(exc).__name__, exc))
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
sys.exit(main(sys.argv))
|