agent-guard-hooks 0.4.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.
@@ -0,0 +1,12 @@
1
+ """agent-guard: behavior-guardrail hooks for Claude Code.
2
+
3
+ Five guards, zero dependencies, fail-open everywhere:
4
+ - test-tampering guard (SessionStart snapshot + Stop diff)
5
+ - outbound-action guard (PreToolUse denylist on Bash)
6
+ - comment-slop guard (PreToolUse scoring on Write/Edit)
7
+ - cheat-sniffing guard (PreToolUse on Write/Edit of test files + cheatsniff CLI)
8
+ - post-exec read-back verifier (PostToolUse on Bash, advisory)
9
+ plus mutate-check (mutation test-honesty CLI).
10
+ """
11
+
12
+ __version__ = "0.4.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
@@ -0,0 +1,406 @@
1
+ """Cheat-sniffing: catch test cheating that never touches test files.
2
+
3
+ Failure mode (remdore, dev.to 2026-10-01, 102 runs x 4 models): HALF of
4
+ agent cheating never modifies a test file. Instead the agent patches the
5
+ RNG ("it patched the random number generator so the list would always be
6
+ sorted"), mocks the function under test instead of its collaborators, or
7
+ plants helpers in conftest.py. agent-guard's test-guard (tests-only-change
8
+ diff) and mutate-check (assertion mutation) both miss this family entirely
9
+ -- "restore the test files and re-run" only catches the dumb half.
10
+
11
+ This module scans Python test support files statically (regex/AST-lite,
12
+ stdlib-only) for the cheat family:
13
+
14
+ - conftest.py / helpers that monkeypatch or stub the module under test
15
+ - RNG seeded or patched to force deterministic outcomes
16
+ - mocks that replace the *subject* instead of its collaborators
17
+ - time frozen or patched to skip timeouts/retries
18
+ - comparison helpers weakened (e.g. __eq__ that always returns True)
19
+
20
+ Scoring mirrors commentslop: weighted hits per file, capped at 100, with
21
+ a threshold that blocks (or warns). When in doubt, DON'T flag -- a
22
+ reproducibility seed (`random.seed(42)` with a marker) is legitimate, and
23
+ this module says so loudly. Everything fails open: any unexpected error
24
+ means score 0, no hits.
25
+ """
26
+
27
+ import os
28
+ import re
29
+
30
+ # (kind, weight) -- replacing the subject or rigging randomness is the
31
+ # unambiguous cheat; fixed seeds and time-freezing are judgment calls.
32
+ WEIGHTS = {
33
+ "mock-subject": 3.0,
34
+ "rng-patch": 3.0,
35
+ "conftest-patch": 3.0,
36
+ "rng-seed": 1.5,
37
+ "time-freeze": 1.5,
38
+ "weak-comparator": 1.5,
39
+ }
40
+
41
+ DEFAULT_THRESHOLD = 30.0
42
+ MAX_SCAN_BYTES = 1024 * 1024
43
+
44
+ # Kinds where a single hit is severe enough to matter on its own.
45
+ SEVERE = {"mock-subject", "rng-patch", "conftest-patch"}
46
+
47
+ # A fixed RNG seed is legitimate when the file says it's for
48
+ # reproducibility. Presence of any of these markers anywhere in the file
49
+ # downgrades rng-seed hits to non-blocking notes.
50
+ REPRO_MARKERS = re.compile(
51
+ r"reproducib|deterministic|flake|stable test|fixed seed",
52
+ re.IGNORECASE)
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # subject-module inference
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def subject_module(path):
59
+ """Best-guess module under test for a test file. Never raises.
60
+
61
+ tests/test_billing.py -> 'billing'; tests/billing_test.py -> 'billing';
62
+ conftest.py -> None (no single subject).
63
+ """
64
+ try:
65
+ base = os.path.basename(str(path or ""))
66
+ if base == "conftest.py":
67
+ return None
68
+ name = base[:-3] if base.endswith(".py") else base
69
+ if name.startswith("test_"):
70
+ name = name[5:]
71
+ elif name.endswith("_test"):
72
+ name = name[:-5]
73
+ return name or None
74
+ except Exception:
75
+ return None
76
+
77
+
78
+ def _is_conftest(path):
79
+ try:
80
+ return os.path.basename(str(path or "")) == "conftest.py"
81
+ except Exception:
82
+ return False
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # detectors
87
+ # ---------------------------------------------------------------------------
88
+
89
+ # mock.patch("billing.calculate_total") / patch.object(billing, "total")
90
+ _PATCH_CALL_RX = re.compile(
91
+ r"""(?:mock\s*\.\s*|unittest\.mock\s*\.\s*)?patch(?:\.object|\.dict)?\s*\(\s*"""
92
+ r"""(?P<q>['"])(?P<target>[^'"]+)(?P=q)""")
93
+
94
+ # monkeypatch.setattr("billing.calculate_total", fake)
95
+ _MONKEYPATCH_RX = re.compile(
96
+ r"""monkeypatch\s*\.\s*setattr\s*\(\s*(?P<q>['"])(?P<target>[^'"]+)(?P=q)""")
97
+
98
+ # random.seed(123) / np.random.seed(123) / random.seed(0xDEADBEEF)
99
+ _SEED_RX = re.compile(
100
+ r"""(?:^|[^\w.])(?:random|np\.random|numpy\.random)\s*\.\s*seed\s*\(\s*"""
101
+ r"""(?P<arg>[^)]*?)\)""")
102
+
103
+ # random.shuffle / random.random / random.sample / random.choice patched
104
+ _RNG_PATCH_RX = re.compile(
105
+ r"""(?:mock\s*\.\s*|unittest\.mock\s*\.\s*)?patch(?:\.object)?\s*\(\s*"""
106
+ r"""(?P<q>['"])(?P<target>(?:random|np\.random|numpy\.random)\s*\.\s*"""
107
+ r"""(?:shuffle|random|sample|choice|randint|randrange|uniform))[^'"]*"""
108
+ r"""(?P=q)"""
109
+ r"""|monkeypatch\s*\.\s*setattr\s*\(\s*(?P<q2>['"])"""
110
+ r"""(?P<target2>(?:random|np\.random|numpy\.random)\s*\.\s*"""
111
+ r"""(?:shuffle|random|sample|choice|randint|randrange|uniform))[^'"]*"""
112
+ r"""(?P=q2)""")
113
+
114
+ # freezegun / time.sleep patched to no-op / time.time frozen
115
+ _TIME_RX = re.compile(
116
+ r"""freeze_time\s*\(|@freeze_time\b|"""
117
+ r"""patch\s*\(\s*['"]time\.sleep['"]|"""
118
+ r"""monkeypatch\s*\.\s*setattr\s*\(\s*['"]time\.sleep['"]|"""
119
+ r"""patch\s*\(\s*['"]time\.time['"]|"""
120
+ r"""monkeypatch\s*\.\s*setattr\s*\(\s*['"]time\.time['"]""")
121
+
122
+ # direct attribute assignment by hand: mymod.shuffle = lambda xs: sorted(xs)
123
+ # (monkeypatching without the monkeypatch API)
124
+ _DIRECT_ASSIGN_RX = re.compile(
125
+ r"""^\s*(?P<mod>[A-Za-z_]\w*)\s*\.\s*(?P<attr>[A-Za-z_]\w*)\s*=""")
126
+
127
+ # import mymod / from mymod import thing (to know which names are modules)
128
+ _IMPORT_RX = re.compile(
129
+ r"""^\s*(?:import\s+(?P<mod>[A-Za-z_]\w*)|from\s+(?P<fmod>[A-Za-z_]\w*)\s+import\b)""")
130
+
131
+ # modules whose direct-attribute assignment is normal test setup, not cheating
132
+ _INNOCENT_MODS = ("os", "sys", "time", "datetime", "random",
133
+ "requests", "socket")
134
+
135
+ # __eq__ / __lt__ / __gt__ overridden with a constant-True body
136
+ _CMP_DEF_RX = re.compile(
137
+ r"""def\s+__(?:eq|lt|le|gt|ge|ne)__\s*\(\s*self""")
138
+
139
+ _CONST_TRUE_RX = re.compile(r"^\s*return\s+True\s*(?:#.*)?$")
140
+
141
+
142
+ def _line_iter(text):
143
+ return list(enumerate(text.split("\n"), 1))
144
+
145
+
146
+ def _subject_of_target(target, subject):
147
+ """Does a patch target string address the subject module itself?"""
148
+ if not subject:
149
+ return False
150
+ t = target.strip()
151
+ first = re.split(r"[.\s(]", t, 1)[0]
152
+ return first == subject
153
+
154
+
155
+ def _find_hits(text, path):
156
+ """Return [(line_no, end_line, kind, excerpt)]. Never raises."""
157
+ hits = []
158
+ try:
159
+ return _find_hits_inner(text, path)
160
+ except Exception:
161
+ return hits
162
+
163
+
164
+ def _find_hits_inner(text, path):
165
+ hits = []
166
+ lines = _line_iter(text)
167
+ subj = subject_module(path)
168
+ is_conf = _is_conftest(path)
169
+
170
+ # modules imported in this file: direct assignment onto one of them
171
+ # (mymod.shuffle = ...) is hand-rolled monkeypatching.
172
+ imported = set()
173
+ for _, l in lines:
174
+ m = _IMPORT_RX.match(l)
175
+ if m:
176
+ imported.add(m.group("mod") or m.group("fmod"))
177
+
178
+ for ln, line in lines:
179
+ # --- mock.patch("subject.func") : mocking the subject itself
180
+ m = _PATCH_CALL_RX.search(line)
181
+ if m:
182
+ target = m.group("target")
183
+ if _RNG_PATCH_RX.search(line):
184
+ hits.append((ln, ln, "rng-patch",
185
+ "patches RNG primitive: %s" % target[:80]))
186
+ elif _subject_of_target(target, subj):
187
+ hits.append((ln, ln, "mock-subject",
188
+ "mocks the subject under test: %s"
189
+ % target[:80]))
190
+ # --- monkeypatch.setattr("subject.func", ...)
191
+ m = _MONKEYPATCH_RX.search(line)
192
+ if m:
193
+ target = m.group("target")
194
+ if _RNG_PATCH_RX.search(line):
195
+ hits.append((ln, ln, "rng-patch",
196
+ "monkeypatches RNG primitive: %s"
197
+ % target[:80]))
198
+ elif _subject_of_target(target, subj):
199
+ kind = "conftest-patch" if is_conf else "mock-subject"
200
+ hits.append((ln, ln, kind,
201
+ "monkeypatches the subject under test: %s"
202
+ % target[:80]))
203
+ elif is_conf:
204
+ # conftest.py monkeypatching anything local-ish is
205
+ # suspicious; only flag when the target is clearly not a
206
+ # third-party integration point.
207
+ first = re.split(r"[.\s(]", target.strip(), 1)[0]
208
+ if first and first not in _INNOCENT_MODS:
209
+ hits.append((ln, ln, "conftest-patch",
210
+ "conftest monkeypatches %s" % target[:80]))
211
+ # --- direct assignment onto an imported module: mymod.shuffle = ...
212
+ m = _DIRECT_ASSIGN_RX.match(line)
213
+ if m:
214
+ mod = m.group("mod")
215
+ if mod in imported and mod not in _INNOCENT_MODS:
216
+ kind = "conftest-patch" if is_conf else "mock-subject"
217
+ hits.append((ln, ln, kind,
218
+ "overwrites %s.%s by direct assignment"
219
+ % (mod, m.group("attr")[:60])))
220
+ # --- time freezing / sleep no-op
221
+ if _TIME_RX.search(line):
222
+ hits.append((ln, ln, "time-freeze",
223
+ line.strip()[:80]))
224
+
225
+ # --- fixed RNG seed: whole-file pass (reproducibility markers exempt)
226
+ repro = bool(REPRO_MARKERS.search(text))
227
+ for ln, line in lines:
228
+ m = _SEED_RX.search(line)
229
+ if m:
230
+ arg = m.group("arg").strip()
231
+ # random.seed() / random.seed(None) is not a fixed seed
232
+ if arg in ("", "None"):
233
+ continue
234
+ if repro:
235
+ continue # legitimate reproducibility seed
236
+ hits.append((ln, ln, "rng-seed",
237
+ "fixed RNG seed (%s) without reproducibility "
238
+ "marker" % arg[:40]))
239
+
240
+ # --- weak comparators: __eq__/__lt__/etc. whose body returns True
241
+ for ln, line in lines:
242
+ if _CMP_DEF_RX.search(line):
243
+ body_true = False
244
+ for j in range(ln, min(ln + 6, len(lines))):
245
+ bln, bline = lines[j]
246
+ s = bline.strip()
247
+ if not s or s.startswith("#"):
248
+ continue
249
+ if _CONST_TRUE_RX.match(bline):
250
+ body_true = True
251
+ break
252
+ if s.startswith("return") or s.startswith("def "):
253
+ break
254
+ if body_true:
255
+ hits.append((ln, ln, "weak-comparator",
256
+ "comparison dunder unconditionally returns "
257
+ "True"))
258
+
259
+ # de-duplicate identical (line, kind) pairs, keep order
260
+ seen = set()
261
+ uniq = []
262
+ for h in hits:
263
+ key = (h[0], h[2])
264
+ if key not in seen:
265
+ seen.add(key)
266
+ uniq.append(h)
267
+ return sorted(uniq, key=lambda h: h[0])
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # scoring
272
+ # ---------------------------------------------------------------------------
273
+
274
+ def score_text(text, path=None):
275
+ """Return (score 0-100, hits).
276
+
277
+ score = min(100, 10 * sum(weights)): one severe hit (3.0) reaches the
278
+ default threshold of 30 on its own. Never raises.
279
+ """
280
+ try:
281
+ return _score_text_inner(text or "", path)
282
+ except Exception:
283
+ return 0.0, []
284
+
285
+
286
+ def _score_text_inner(text, path):
287
+ hits = _find_hits(text, path)
288
+ weighted = sum(WEIGHTS.get(kind, 1.0) for _, _, kind, _ in hits)
289
+ score = min(100.0, 10.0 * weighted)
290
+ return round(score, 1), hits
291
+
292
+
293
+ def score_file(path):
294
+ """Score a file on disk. Returns (score, hits). Never raises."""
295
+ try:
296
+ size = os.path.getsize(path)
297
+ if size > MAX_SCAN_BYTES:
298
+ return 0.0, []
299
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
300
+ text = fh.read()
301
+ return score_text(text, path)
302
+ except Exception:
303
+ return 0.0, []
304
+
305
+
306
+ def scan_path(root):
307
+ """Yield (path, score, hits) for .py files under root. Never raises."""
308
+ try:
309
+ return list(_scan_path_inner(root))
310
+ except Exception:
311
+ return []
312
+
313
+
314
+ def _scan_path_inner(root):
315
+ out = []
316
+ if os.path.isfile(root):
317
+ files = [root]
318
+ else:
319
+ files = []
320
+ for dirpath, dirnames, filenames in os.walk(root):
321
+ dirnames[:] = [d for d in dirnames
322
+ if d not in (".git", "__pycache__", ".venv",
323
+ "venv", "node_modules", ".tox")]
324
+ for fn in filenames:
325
+ if fn.endswith(".py"):
326
+ files.append(os.path.join(dirpath, fn))
327
+ for path in sorted(files):
328
+ score, hits = score_file(path)
329
+ out.append((path, score, hits))
330
+ return out
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # config / mode helpers (mirror commentslop)
335
+ # ---------------------------------------------------------------------------
336
+
337
+ def threshold(config=None):
338
+ """Cheat score threshold. Env AGENT_GUARD_CHEAT_THRESHOLD wins."""
339
+ try:
340
+ env = os.environ.get("AGENT_GUARD_CHEAT_THRESHOLD", "").strip()
341
+ if env:
342
+ return max(0.0, min(100.0, float(env)))
343
+ except Exception:
344
+ pass
345
+ try:
346
+ cfg = config if isinstance(config, dict) else None
347
+ if cfg is None:
348
+ from . import state
349
+ cfg = state.load_config()
350
+ t = (cfg.get("cheat_sniff", {}) or {}).get("threshold", 30)
351
+ return max(0.0, min(100.0, float(t)))
352
+ except Exception:
353
+ return DEFAULT_THRESHOLD
354
+
355
+
356
+ def mode(config=None):
357
+ """'block' or 'warn'. Env AGENT_GUARD_CHEAT_MODE wins."""
358
+ try:
359
+ env = os.environ.get("AGENT_GUARD_CHEAT_MODE", "").strip().lower()
360
+ if env in ("warn", "block"):
361
+ return env
362
+ except Exception:
363
+ pass
364
+ try:
365
+ cfg = config if isinstance(config, dict) else None
366
+ if cfg is None:
367
+ from . import state
368
+ cfg = state.load_config()
369
+ m = ((cfg.get("cheat_sniff", {}) or {}).get("mode")
370
+ or "block").strip().lower()
371
+ return m if m in ("warn", "block") else "block"
372
+ except Exception:
373
+ return "block"
374
+
375
+
376
+ def allowed(config, path, kind):
377
+ """Is this (file, kind) allowlisted? Never raises.
378
+
379
+ Config: {"cheat_sniff": {"allow": ["conftest.py:rng-seed", "*/x.py:*"]}}
380
+ Entries are `basename-or-subpath:kind` (kind may be `*`).
381
+ """
382
+ try:
383
+ entries = ((config or {}).get("cheat_sniff", {}) or {}).get(
384
+ "allow", []) or []
385
+ base = os.path.basename(str(path or ""))
386
+ rel = str(path or "")
387
+ for e in entries:
388
+ if not isinstance(e, str) or ":" not in e:
389
+ continue
390
+ pat, k = e.rsplit(":", 1)
391
+ if k != "*" and k != kind:
392
+ continue
393
+ if pat == "*" or base == pat or rel.endswith(pat):
394
+ return True
395
+ return False
396
+ except Exception:
397
+ return False
398
+
399
+
400
+ def filter_allowed(hits, config, path):
401
+ """Drop allowlisted hits. Never raises."""
402
+ try:
403
+ return [h for h in hits
404
+ if not allowed(config, path, h[2])]
405
+ except Exception:
406
+ return hits