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.
Files changed (90) hide show
  1. obey_cli/__init__.py +3 -0
  2. obey_cli/__main__.py +3 -0
  3. obey_cli/_data/demo/README.md +12 -0
  4. obey_cli/_data/demo/obey.yaml +287 -0
  5. obey_cli/_data/demo/repo/.gitignore +8 -0
  6. obey_cli/_data/demo/repo/AGENTS.md +38 -0
  7. obey_cli/_data/demo/repo/README.md +36 -0
  8. obey_cli/_data/demo/repo/eslint.config.js +24 -0
  9. obey_cli/_data/demo/repo/index.html +21 -0
  10. obey_cli/_data/demo/repo/openapi.yaml +86 -0
  11. obey_cli/_data/demo/repo/package.json +26 -0
  12. obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
  13. obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
  14. obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
  15. obey_cli/_data/demo/repo/src/api/client.ts +71 -0
  16. obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
  17. obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
  18. obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
  19. obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
  20. obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
  21. obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
  22. obey_cli/_data/demo/repo/src/logger.ts +36 -0
  23. obey_cli/_data/demo/repo/src/main.ts +30 -0
  24. obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
  25. obey_cli/_data/demo/repo/src/render-table.ts +48 -0
  26. obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
  27. obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
  28. obey_cli/_data/demo/repo/tsconfig.json +20 -0
  29. obey_cli/_data/demo/repo/vitest.config.ts +8 -0
  30. obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
  31. obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
  32. obey_cli/_data/demo/runs/meta.json +10 -0
  33. obey_cli/_data/schemas/obey-config-v1.json +116 -0
  34. obey_cli/_data/schemas/report-v1.json +55 -0
  35. obey_cli/_data/schemas/survey-record-v1.json +30 -0
  36. obey_cli/checkers/__init__.py +30 -0
  37. obey_cli/checkers/samples.py +95 -0
  38. obey_cli/cli.py +201 -0
  39. obey_cli/commands.py +676 -0
  40. obey_cli/config.py +90 -0
  41. obey_cli/discover.py +232 -0
  42. obey_cli/enforce/__init__.py +114 -0
  43. obey_cli/enforce/engine/__init__.py +0 -0
  44. obey_cli/enforce/engine/obey_engine.py +2005 -0
  45. obey_cli/enforce/merge.py +196 -0
  46. obey_cli/enforce/targets/__init__.py +354 -0
  47. obey_cli/enforce/templates/obey_hook.py +452 -0
  48. obey_cli/enforce/templates/pre_commit.py +116 -0
  49. obey_cli/extract.py +1342 -0
  50. obey_cli/lint.py +125 -0
  51. obey_cli/llm.py +129 -0
  52. obey_cli/parse_md.py +260 -0
  53. obey_cli/probes/__init__.py +0 -0
  54. obey_cli/probes/facts.py +162 -0
  55. obey_cli/probes/templates.py +326 -0
  56. obey_cli/py.typed +0 -0
  57. obey_cli/report/__init__.py +314 -0
  58. obey_cli/resources.py +35 -0
  59. obey_cli/rules.py +75 -0
  60. obey_cli/runner/__init__.py +0 -0
  61. obey_cli/runner/agents/__init__.py +22 -0
  62. obey_cli/runner/agents/base.py +96 -0
  63. obey_cli/runner/agents/claude_code.py +203 -0
  64. obey_cli/runner/agents/codex.py +152 -0
  65. obey_cli/runner/agents/copilot.py +142 -0
  66. obey_cli/runner/agents/gemini.py +125 -0
  67. obey_cli/runner/agents/opencode.py +156 -0
  68. obey_cli/runner/core.py +140 -0
  69. obey_cli/runner/egress_proxy.py +166 -0
  70. obey_cli/runner/gateway.py +529 -0
  71. obey_cli/runner/ledger.py +132 -0
  72. obey_cli/runner/local.py +176 -0
  73. obey_cli/runner/orchestrate.py +227 -0
  74. obey_cli/runner/sandbox/Dockerfile +41 -0
  75. obey_cli/runner/sandbox/entrypoint.sh +38 -0
  76. obey_cli/runner/sandbox.py +341 -0
  77. obey_cli/runner/sidecar.py +38 -0
  78. obey_cli/runner/workspace.py +187 -0
  79. obey_cli/score.py +150 -0
  80. obey_cli/study/__init__.py +0 -0
  81. obey_cli/study/analysis.py +277 -0
  82. obey_cli/study/design.py +98 -0
  83. obey_cli/study/experiment.py +198 -0
  84. obey_cli/study/report.py +519 -0
  85. obey_cli/study/survey.py +438 -0
  86. obey_cli-0.1.0.dist-info/METADATA +393 -0
  87. obey_cli-0.1.0.dist-info/RECORD +90 -0
  88. obey_cli-0.1.0.dist-info/WHEEL +4 -0
  89. obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
  90. 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))