taskproof 0.3.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 (51) hide show
  1. taskproof/__init__.py +23 -0
  2. taskproof/__main__.py +8 -0
  3. taskproof/adapters/__init__.py +101 -0
  4. taskproof/adapters/base.py +228 -0
  5. taskproof/adapters/claude.py +46 -0
  6. taskproof/adapters/codex.py +140 -0
  7. taskproof/adapters/custom.py +61 -0
  8. taskproof/adapters/gemini.py +45 -0
  9. taskproof/adapters/opencode.py +46 -0
  10. taskproof/api/__init__.py +1 -0
  11. taskproof/api/server.py +822 -0
  12. taskproof/cli.py +1400 -0
  13. taskproof/concurrency.py +329 -0
  14. taskproof/dispatch.py +2289 -0
  15. taskproof/errors.py +55 -0
  16. taskproof/ledger.py +185 -0
  17. taskproof/models.py +155 -0
  18. taskproof/registry.py +1585 -0
  19. taskproof/schemas/result.json +58 -0
  20. taskproof/sql/schema.sql +70 -0
  21. taskproof/storage.py +372 -0
  22. taskproof/verify.py +615 -0
  23. taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
  24. taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
  25. taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
  26. taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
  27. taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
  28. taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
  29. taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
  30. taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
  31. taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
  32. taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
  33. taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
  34. taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
  35. taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
  36. taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
  37. taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
  38. taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
  39. taskproof/web/assets/TasksView-Bn08731V.css +30 -0
  40. taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
  41. taskproof/web/assets/index-B-PLdDbR.js +75421 -0
  42. taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
  43. taskproof/web/assets/index-NKj7kbYl.css +1234 -0
  44. taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
  45. taskproof/web/index.html +23 -0
  46. taskproof-0.3.0.dist-info/METADATA +470 -0
  47. taskproof-0.3.0.dist-info/RECORD +51 -0
  48. taskproof-0.3.0.dist-info/WHEEL +5 -0
  49. taskproof-0.3.0.dist-info/entry_points.txt +2 -0
  50. taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
  51. taskproof-0.3.0.dist-info/top_level.txt +1 -0
taskproof/verify.py ADDED
@@ -0,0 +1,615 @@
1
+ """Acceptance verification — the part that does not trust the worker.
2
+
3
+ Rules
4
+ -----
5
+ 1. The agent's self-report is NEVER evidence. Only this module decides success.
6
+ 2. The acceptance command comes from the registry, and is run by taskproof in
7
+ the project workdir after the adapter exits.
8
+ 3. Artifact self-check: if the run claims to have changed files, the worktree is
9
+ inspected to confirm it. (One observed failure mode: an agent reported
10
+ success while the tree was untouched.)
11
+ 4. Forbidden paths are checked AFTER the run — a violation fails the task even
12
+ if the agent reported success. The check does not rely on the git change list
13
+ alone: git cannot see inside `.git/` and omits every ignored path, so the
14
+ declared paths are fingerprinted before and after the run as well. The one
15
+ special case is a rule that targets the repository's `.git` directory:
16
+ `.git` state (HEAD, refs, and stash) is compared instead of its bookkeeping
17
+ files, so `git status` refreshing `.git/index` is not mistaken for a
18
+ forbidden change.
19
+ 5. Python byte-code is never a breach, whatever the rule says: a directory
20
+ segment named `__pycache__`, or a path ending in `.pyc` / `.pyo`, is skipped
21
+ before any rule is applied. It is a byproduct of *running* the tool (an
22
+ `import`), not authored work, and is regenerated on demand — see
23
+ `is_python_bytecode`. The exemption is deliberately narrow and path-based.
24
+ """
25
+
26
+ import os
27
+ import subprocess
28
+ from dataclasses import dataclass, field
29
+ from typing import Dict, List, Optional, Tuple
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ForbiddenRule:
34
+ """A parsed forbidden-path rule.
35
+
36
+ The registry stores rules as strings so old registries keep working. The
37
+ optional ``file:`` / ``git:`` / ``presence:`` prefixes select the signal;
38
+ an unprefixed rule is the historical ``file`` form.
39
+ """
40
+
41
+ kind: str
42
+ path: str
43
+ raw: str
44
+
45
+
46
+ @dataclass
47
+ class VerifyOutcome:
48
+ ran: bool
49
+ passed: bool
50
+ exit_code: Optional[int] = None
51
+ command: Optional[str] = None
52
+ output_tail: str = ""
53
+ violations: List[str] = field(default_factory=list)
54
+ files_changed: Optional[int] = None
55
+ note: str = ""
56
+
57
+
58
+ def _tail(text: str, lines: int) -> str:
59
+ """Last `lines` lines of `text` (no trailing newline). Long output must
60
+ never be dumped wholesale into the ledger."""
61
+ if not text:
62
+ return ""
63
+ if not lines or lines <= 0:
64
+ return text
65
+ return "\n".join(text.splitlines()[-lines:])
66
+
67
+
68
+ def _as_text(value) -> str:
69
+ if value is None:
70
+ return ""
71
+ if isinstance(value, bytes):
72
+ return value.decode("utf-8", "replace")
73
+ return str(value)
74
+
75
+
76
+ def run_acceptance(
77
+ workdir: str,
78
+ command: Optional[str],
79
+ *,
80
+ timeout: int = 1800,
81
+ env: Optional[dict] = None,
82
+ tail_lines: int = 40,
83
+ ) -> VerifyOutcome:
84
+ """Run the registry's acceptance command.
85
+
86
+ * `command` empty/None -> `ran=False, passed=True, note="no acceptance command"`.
87
+ Skipping is not a pass; the caller must surface that distinction.
88
+ * Output is truncated to the last `tail_lines` for the ledger.
89
+ * A timeout is a FAILURE, not an error: exit code is recorded as None and
90
+ `note` says so.
91
+ """
92
+ if command is None or not str(command).strip():
93
+ # Skipping is NOT passing: ran=False lets the caller record SKIPPED
94
+ # rather than DONE even though `passed` is trivially true.
95
+ return VerifyOutcome(ran=False, passed=True, note="no acceptance command")
96
+
97
+ command = str(command)
98
+ # Registry acceptance commands are shell one-liners ("exit 0",
99
+ # "for i in $(seq 1 200); do ...; done", "npm run build && npm test") that
100
+ # need real shell features. We therefore choose the EXPLICIT shell string
101
+ # form, `/bin/sh -c <command>`, over `shlex.split`. This keeps argv a list
102
+ # (no `shell=True`), makes the use of a shell visible and auditable, and is
103
+ # required because shell builtins like `exit` are not executables.
104
+ argv = ["/bin/sh", "-c", command]
105
+
106
+ run_env = None
107
+ if env:
108
+ # Merge so the command still inherits PATH etc.
109
+ run_env = {**os.environ, **env}
110
+
111
+ try:
112
+ proc = subprocess.run(
113
+ argv,
114
+ cwd=workdir,
115
+ env=run_env,
116
+ stdout=subprocess.PIPE,
117
+ stderr=subprocess.STDOUT,
118
+ text=True,
119
+ timeout=timeout,
120
+ )
121
+ except subprocess.TimeoutExpired as exc:
122
+ return VerifyOutcome(
123
+ ran=True,
124
+ passed=False,
125
+ exit_code=None,
126
+ command=command,
127
+ output_tail=_tail(_as_text(exc.output), tail_lines),
128
+ note=f"acceptance timeout: command exceeded {timeout}s (treated as failure)",
129
+ )
130
+ except FileNotFoundError as exc:
131
+ return VerifyOutcome(
132
+ ran=True,
133
+ passed=False,
134
+ exit_code=None,
135
+ command=command,
136
+ note=f"acceptance command could not run: {exc}",
137
+ )
138
+
139
+ output = proc.stdout or ""
140
+ passed = proc.returncode == 0
141
+ note = "" if passed else f"acceptance command exited {proc.returncode}"
142
+ return VerifyOutcome(
143
+ ran=True,
144
+ passed=passed,
145
+ exit_code=proc.returncode,
146
+ command=command,
147
+ output_tail=_tail(output, tail_lines),
148
+ note=note,
149
+ )
150
+
151
+
152
+ def _git_porcelain(workdir: str, timeout=None) -> Optional[str]:
153
+ """Read-only `git status --porcelain`; None when workdir is not a repo.
154
+
155
+ ``timeout`` bounds the subprocess (seconds); ``None`` keeps the original
156
+ unbounded semantics, so every existing caller is unaffected. A timeout
157
+ raises ``subprocess.TimeoutExpired`` (a ``SubprocessError``) and surfaces as
158
+ ``None`` exactly like the other probe failures.
159
+ """
160
+ try:
161
+ proc = subprocess.run(
162
+ ["git", "status", "--porcelain"],
163
+ cwd=workdir,
164
+ stdout=subprocess.PIPE,
165
+ stderr=subprocess.DEVNULL,
166
+ text=True,
167
+ timeout=timeout,
168
+ )
169
+ except (OSError, subprocess.SubprocessError):
170
+ return None
171
+ if proc.returncode != 0:
172
+ return None
173
+ return proc.stdout
174
+
175
+
176
+ def detect_changes(workdir: str, timeout=None) -> Optional[int]:
177
+ """Count changed files in the worktree (git only; None when not a repo).
178
+
179
+ Read-only: `git status --porcelain` and nothing else. taskproof never runs
180
+ a git write command.
181
+
182
+ ``timeout`` is an optional short probe bound (seconds) used by the live API;
183
+ ``None`` (the default, and every pre-existing caller) preserves the original
184
+ no-timeout behaviour.
185
+ """
186
+ porcelain = _git_porcelain(workdir, timeout=timeout)
187
+ if porcelain is None:
188
+ return None
189
+ return len([line for line in porcelain.splitlines() if line.strip()])
190
+
191
+
192
+ def changed_files(workdir: str) -> List[str]:
193
+ """Relative paths reported by `git status --porcelain` (read-only)."""
194
+ porcelain = _git_porcelain(workdir)
195
+ return _changed_files_from_porcelain(porcelain)
196
+
197
+
198
+ def _changed_files_from_porcelain(porcelain: Optional[str]) -> List[str]:
199
+ if not porcelain:
200
+ return []
201
+
202
+ files: List[str] = []
203
+ for line in porcelain.splitlines():
204
+ if not line.strip():
205
+ continue
206
+ files.append(_porcelain_path(line))
207
+ return files
208
+
209
+
210
+ def changed_files_snapshot(workdir: str) -> Optional[Dict[str, str]]:
211
+ """Map current porcelain paths to their two-column status.
212
+
213
+ Keeping the status beside the path lets the dispatcher distinguish a path
214
+ that was merely present before the run from one whose state changed. The
215
+ public ``changed_files`` / ``detect_changes`` functions intentionally keep
216
+ their historical current-state semantics.
217
+ """
218
+ porcelain = _git_porcelain(workdir)
219
+ if porcelain is None:
220
+ return None
221
+
222
+ entries: Dict[str, str] = {}
223
+ for line in porcelain.splitlines():
224
+ if not line.strip():
225
+ continue
226
+ entries[_porcelain_path(line)] = line[:2]
227
+ return entries
228
+
229
+
230
+ def diff_changed_files(
231
+ before: Optional[Dict[str, str]], after: Optional[Dict[str, str]]
232
+ ) -> List[str]:
233
+ """Return paths added, removed, or whose porcelain status changed.
234
+
235
+ Two full ``git status`` snapshots are required: a pre-existing dirty path
236
+ that the adapter never touched is absent from both and therefore cannot be
237
+ mistaken for this run's work.
238
+ """
239
+ before = before or {}
240
+ after = after or {}
241
+ return sorted(
242
+ path
243
+ for path in set(before) | set(after)
244
+ if before.get(path) != after.get(path)
245
+ )
246
+
247
+
248
+ def _porcelain_path(line: str) -> str:
249
+ # Format: two status columns, a space, then the path.
250
+ path = line[3:] if len(line) > 3 else line.strip()
251
+ # Renames/copies read "R old -> new"; we care about the new path.
252
+ if " -> " in path:
253
+ path = path.split(" -> ", 1)[1]
254
+ return path.strip().strip('"')
255
+
256
+
257
+ def _normalise(workdir: str, path: str) -> str:
258
+ """Normalise a path to a POSIX-style relative path from `workdir`.
259
+
260
+ Handles relative (`dist/x`), `./`-prefixed (`./dist/x`) and absolute
261
+ (`/repo/dist/x`) spellings so they all compare on the same footing.
262
+ """
263
+ text = str(path).replace("\\", "/")
264
+ base = os.path.abspath(workdir)
265
+ if os.path.isabs(text):
266
+ try:
267
+ text = os.path.relpath(os.path.normpath(text), base)
268
+ except ValueError:
269
+ # Different drive (Windows); fall through using the raw text.
270
+ pass
271
+ normalised = os.path.normpath(text).replace(os.sep, "/")
272
+ # Strip any leading "./" that survived normpath (e.g. on odd inputs).
273
+ while normalised.startswith("./"):
274
+ normalised = normalised[2:]
275
+ if normalised == ".":
276
+ normalised = ""
277
+ return normalised
278
+
279
+
280
+ def parse_forbidden_rule(raw) -> Optional[ForbiddenRule]:
281
+ """Parse the optional ``file:`` / ``git:`` / ``presence:`` prefix.
282
+
283
+ Unprefixed strings are the legacy spelling and mean ``file``. A literal
284
+ path beginning with one of these prefixes can be protected as a file by
285
+ spelling it with an explicit extra ``file:`` prefix (for example,
286
+ ``file:presence:notes.txt``).
287
+ """
288
+ if raw is None:
289
+ return None
290
+ text = str(raw)
291
+ for kind in ("file", "git", "presence"):
292
+ prefix = kind + ":"
293
+ if text.startswith(prefix):
294
+ return ForbiddenRule(
295
+ kind=kind,
296
+ path=text[len(prefix):].replace("\\", "/"),
297
+ raw=text,
298
+ )
299
+ return ForbiddenRule(kind="file", path=text.replace("\\", "/"), raw=text)
300
+
301
+
302
+ def _git_output(workdir: str, args: List[str]) -> Optional[Tuple[int, str]]:
303
+ """Run one read-only git probe; None when git cannot be executed."""
304
+ try:
305
+ proc = subprocess.run(
306
+ ["git", *args],
307
+ cwd=workdir,
308
+ stdout=subprocess.PIPE,
309
+ stderr=subprocess.DEVNULL,
310
+ text=True,
311
+ )
312
+ except (OSError, subprocess.SubprocessError):
313
+ return None
314
+ return proc.returncode, proc.stdout or ""
315
+
316
+
317
+ def is_git_forbidden_rule(workdir: str, raw_rule) -> bool:
318
+ """Whether a forbidden rule targets the repository's `.git` entry.
319
+
320
+ Both ``.git`` and ``.git/`` resolve to the same protected target. A more
321
+ specific rule such as ``.git/config`` intentionally stays on the file
322
+ fingerprint path.
323
+ """
324
+ if raw_rule is None:
325
+ return False
326
+ rule_path = _normalise(workdir, str(raw_rule)).rstrip("/")
327
+ return rule_path == ".git"
328
+
329
+
330
+ def _branches_checked_out_elsewhere(workdir: str) -> set:
331
+ """Full branch refs checked out by *other* worktrees of the same repo.
332
+
333
+ ``refs/heads/*`` is shared by every worktree of a repository, so a commit,
334
+ checkout or reset in a sibling checkout moves the very same branch ref the
335
+ probed worktree sees. That is the point of worktree isolation: it is not
336
+ this card's doing. ``git worktree list --porcelain`` names each checkout
337
+ (``worktree <path>``) and the branch it has checked out (``branch
338
+ refs/heads/<b>``; a detached checkout has no ``branch`` line). We collect
339
+ the branches of every entry whose path is not the probed worktree's own
340
+ root, so the caller can drop exactly those refs from the snapshot.
341
+
342
+ Returns an empty set when git cannot be run, the path is not a worktree, or
343
+ the listing is unavailable (an older git, for instance). An empty set means
344
+ "exclude nothing", which is byte-for-byte today's behaviour.
345
+ """
346
+ listing = _git_output(workdir, ["worktree", "list", "--porcelain"])
347
+ if listing is None or listing[0] != 0:
348
+ return set()
349
+
350
+ # The probed checkout, resolved to its real top: ``workdir`` may be a
351
+ # subdirectory of the worktree (a lane can point at one) and the listing
352
+ # always reports the top. Compare real paths so symlinked spellings match.
353
+ top = _git_output(workdir, ["rev-parse", "--show-toplevel"])
354
+ if top is None or top[0] != 0:
355
+ return set()
356
+ current = os.path.realpath(top[1].strip())
357
+
358
+ excluded: set = set()
359
+
360
+ def _flush(path: Optional[str], branch: Optional[str]) -> None:
361
+ if not branch or not path:
362
+ return
363
+ if os.path.realpath(path) != current:
364
+ excluded.add(branch)
365
+
366
+ path: Optional[str] = None
367
+ branch: Optional[str] = None
368
+ for line in listing[1].splitlines():
369
+ if line.startswith("worktree "):
370
+ _flush(path, branch)
371
+ path = line[len("worktree "):].strip()
372
+ branch = None
373
+ elif line.startswith("branch "):
374
+ branch = line[len("branch "):].strip()
375
+ _flush(path, branch)
376
+ return excluded
377
+
378
+
379
+ def git_state_snapshot(workdir: str) -> Dict[str, str]:
380
+ """Read the repository state guarded by a forbidden ``.git`` rule.
381
+
382
+ The probe is deliberately about *history state*, not git's bookkeeping
383
+ files: HEAD, the symbolic branch, all refs, and stashed commits. Commits,
384
+ amends, resets, checkouts, branch/tag changes, and stash changes move one of
385
+ these values; a ``git status`` index refresh does not.
386
+
387
+ One deliberate narrowing: ``refs/heads/*`` is shared across a repository's
388
+ worktrees, so branches another worktree has checked out are left out of the
389
+ snapshot (see :func:`_branches_checked_out_elsewhere`). A commit in a
390
+ sibling checkout must not read as this card's doing. The probed worktree's
391
+ own branch, every ``refs/remotes/*`` and ``refs/tags/*`` ref, and the stash
392
+ stay in, because those are the moves this card can really make.
393
+
394
+ Returns ``{}`` when ``workdir`` is not a repository, git is not on PATH, or
395
+ git cannot be executed. In that case there is no state to claim changed, so
396
+ the caller must not treat absence as a violation.
397
+ """
398
+ repo = _git_output(workdir, ["rev-parse", "--git-dir"])
399
+ if repo is None or repo[0] != 0:
400
+ return {}
401
+
402
+ # Branches a sibling worktree has checked out; exclude them from the refs
403
+ # below. Empty when there is no other worktree, which keeps the snapshot
404
+ # byte-for-byte identical to the pre-worktree behaviour.
405
+ elsewhere = _branches_checked_out_elsewhere(workdir)
406
+
407
+ state: Dict[str, str] = {"HEAD": "", "symbolic-ref": "", "stash": ""}
408
+
409
+ head = _git_output(workdir, ["rev-parse", "--verify", "HEAD"])
410
+ if head is not None and head[0] == 0:
411
+ state["HEAD"] = head[1].strip()
412
+
413
+ symbolic = _git_output(workdir, ["symbolic-ref", "-q", "HEAD"])
414
+ if symbolic is not None and symbolic[0] == 0:
415
+ state["symbolic-ref"] = symbolic[1].strip()
416
+
417
+ refs = _git_output(
418
+ workdir,
419
+ [
420
+ "for-each-ref",
421
+ "--format=%(refname) %(objectname)",
422
+ "refs/heads",
423
+ "refs/remotes",
424
+ "refs/tags",
425
+ ],
426
+ )
427
+ if refs is not None and refs[0] == 0:
428
+ for line in refs[1].splitlines():
429
+ fields = line.strip().split(None, 1)
430
+ if len(fields) == 2:
431
+ if fields[0] in elsewhere:
432
+ continue
433
+ state[fields[0]] = fields[1]
434
+
435
+ stashes = _git_output(workdir, ["stash", "list", "--format=%H"])
436
+ if stashes is not None and stashes[0] == 0:
437
+ state["stash"] = "\n".join(
438
+ line.strip() for line in stashes[1].splitlines() if line.strip()
439
+ )
440
+
441
+ return state
442
+
443
+
444
+ #: The directory Python writes byte-code into, and the file suffixes it uses.
445
+ _BYTECODE_DIR = "__pycache__"
446
+ _BYTECODE_SUFFIXES = (".pyc", ".pyo")
447
+
448
+
449
+ def is_python_bytecode(path: str) -> bool:
450
+ """Whether `path` is Python byte-code the interpreter wrote, not authored work.
451
+
452
+ Byte-code is a byproduct of *running* Python (an ``import``), carries no
453
+ human intent, and is regenerated on demand. A rule that forbids a Python
454
+ source directory would otherwise fire the first time anyone imports the
455
+ package from inside the guarded tree, which makes the rule unusable. A path
456
+ is exempt when either:
457
+
458
+ * any path segment is literally ``__pycache__``, or
459
+ * its final segment ends in ``.pyc`` or ``.pyo``.
460
+
461
+ The match is on the *path itself*, independent of the rule's ``kind``
462
+ (``file`` / ``git`` / ``presence``), so all three signals skip it alike. It
463
+ is deliberately narrow: only these two spellings are exempt. Every other
464
+ ignored path (``node_modules/``, ``dist/``) is still watched, and a name
465
+ that merely contains the substring — ``a__pycache__b.py`` — is not exempt.
466
+ """
467
+ text = str(path).replace("\\", "/")
468
+ segments = [seg for seg in text.split("/") if seg]
469
+ if not segments:
470
+ return False
471
+ if any(seg == _BYTECODE_DIR for seg in segments):
472
+ return True
473
+ return segments[-1].endswith(_BYTECODE_SUFFIXES)
474
+
475
+
476
+ def check_forbidden(workdir: str, forbidden_paths: List[str],
477
+ changed_files: List[str]) -> List[str]:
478
+ """Return the subset of `changed_files` that violates `forbidden_paths`.
479
+
480
+ Matching is on normalised relative paths and must catch a change nested
481
+ under a forbidden directory (e.g. `dist/app.js` for `dist/`). A rule that
482
+ ends with `/` is a directory rule (matches the directory and everything
483
+ below it); a rule without a trailing slash must match that exact path.
484
+ """
485
+ rules = []
486
+ for raw in forbidden_paths or []:
487
+ parsed = parse_forbidden_rule(raw)
488
+ if parsed is None or parsed.kind == "git":
489
+ continue
490
+ rule_text = parsed.path
491
+ is_dir = rule_text.endswith("/")
492
+ rule_path = _normalise(workdir, rule_text)
493
+ if not rule_path:
494
+ continue
495
+ rules.append((rule_path, is_dir))
496
+
497
+ violations: List[str] = []
498
+ for original in changed_files or []:
499
+ candidate = _normalise(workdir, original)
500
+ # Byte-code is the interpreter's byproduct, not authored work; it is a
501
+ # boundary breach for no rule kind (see `is_python_bytecode`).
502
+ if is_python_bytecode(candidate):
503
+ continue
504
+ for rule_path, is_dir in rules:
505
+ if is_dir:
506
+ if candidate == rule_path or candidate.startswith(rule_path + "/"):
507
+ violations.append(original)
508
+ break
509
+ else:
510
+ if candidate == rule_path:
511
+ violations.append(original)
512
+ break
513
+ return violations
514
+
515
+
516
+ # ---------------------------------------------------------------------------
517
+ # Forbidden paths, part two: watching what git cannot see.
518
+ #
519
+ # `check_forbidden` matches against the `git status` change list, which is a
520
+ # cheap and good change detector but a blind one for two whole classes of path:
521
+ #
522
+ # * nothing inside `.git/` is ever reported -- git does not list its own
523
+ # directory;
524
+ # * every ignored path is omitted -- and build output and dependency
525
+ # directories are exactly the ignored ones.
526
+ #
527
+ # This was measured, not assumed. With `forbidden_paths = ["dist/"]` and `dist/`
528
+ # in `.gitignore`, an adapter wrote `dist/app.js` and the run was recorded as
529
+ # `done`. The same happened for `.git/`. Those rules, and `node_modules/`, are
530
+ # what the shipped example declares -- so the guard was inert for the three
531
+ # paths people most expect it to cover.
532
+ #
533
+ # So the declared paths are also fingerprinted before and after the run. That
534
+ # costs one walk of the declared set and nothing else.
535
+ #
536
+ # `.git/` is the exception: git's index/logs/object files are bookkeeping that a
537
+ # read-only `git status` (or a worker's commit) can refresh, while the useful
538
+ # signal is history state. That rule is compared with `git_state_snapshot`
539
+ # instead; every other rule continues through the fingerprint path below.
540
+ # ---------------------------------------------------------------------------
541
+
542
+ #: Above this many entries a rule's walk stops and only its root is compared.
543
+ #: Reported in the audit event when it happens, so a partial guarantee is
544
+ #: visible rather than implied.
545
+ SNAPSHOT_LIMIT = 20000
546
+
547
+
548
+ def _fingerprint(path: str) -> str:
549
+ try:
550
+ stat = os.stat(path)
551
+ except OSError:
552
+ return "gone"
553
+ return f"{stat.st_size}:{stat.st_mtime_ns}"
554
+
555
+
556
+ def snapshot_forbidden(
557
+ workdir: str, forbidden_paths: List[str]
558
+ ) -> Tuple[Dict[str, str], bool]:
559
+ """Fingerprint everything at or under each forbidden rule.
560
+
561
+ Returns `(entries, truncated)`. A rule whose path does not exist yet is
562
+ skipped rather than recorded: if the run creates it, that shows up as an
563
+ addition in the diff, which is the point.
564
+ """
565
+ entries: Dict[str, str] = {}
566
+ truncated = False
567
+
568
+ for raw in forbidden_paths or []:
569
+ if raw is None:
570
+ continue
571
+ rule_path = _normalise(workdir, str(raw))
572
+ if not rule_path:
573
+ continue
574
+ target = os.path.join(workdir, rule_path)
575
+
576
+ if os.path.isfile(target):
577
+ entries[rule_path] = _fingerprint(target)
578
+ continue
579
+ if not os.path.isdir(target):
580
+ continue
581
+
582
+ for root, dirs, names in os.walk(target):
583
+ dirs.sort()
584
+ for name in sorted(names):
585
+ if len(entries) >= SNAPSHOT_LIMIT:
586
+ truncated = True
587
+ return entries, truncated
588
+ full = os.path.join(root, name)
589
+ entries[_normalise(workdir, full)] = _fingerprint(full)
590
+
591
+ return entries, truncated
592
+
593
+
594
+ def diff_snapshots(before: Dict[str, str], after: Dict[str, str]) -> List[str]:
595
+ """Paths under a protected rule that appeared, changed or disappeared."""
596
+ changed = set()
597
+ for key, fingerprint in after.items():
598
+ if before.get(key) != fingerprint:
599
+ changed.add(key)
600
+ for key in before:
601
+ if key not in after:
602
+ changed.add(key)
603
+ return sorted(changed)
604
+
605
+
606
+ def diff_presence_snapshots(
607
+ before: Dict[str, str], after: Dict[str, str]
608
+ ) -> List[str]:
609
+ """Paths that appeared or disappeared, ignoring fingerprint changes.
610
+
611
+ This is the ``presence`` signal: a devserver may rewrite the content of an
612
+ existing build artifact without adding or removing a file, and that must
613
+ not be reported as a forbidden change.
614
+ """
615
+ return sorted(set(before) ^ set(after))