bite-check 0.4.1__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.
bite.py ADDED
@@ -0,0 +1,817 @@
1
+ """
2
+ Bite's engine for recent.py: reading the settings, running a project's tests (optionally in a Docker box),
3
+ copying a project safely, and the command filter that keeps the claim-checking worker inside its copy.
4
+ You shouldn't need to edit this file.
5
+ """
6
+
7
+
8
+
9
+
10
+ import configparser
11
+
12
+
13
+ import fnmatch
14
+
15
+
16
+
17
+
18
+ import os
19
+
20
+
21
+ import re
22
+
23
+
24
+ import shlex
25
+
26
+
27
+ import subprocess
28
+
29
+
30
+ import sys
31
+
32
+
33
+ import time
34
+
35
+
36
+ from pathlib import Path
37
+
38
+
39
+ KIT_DIR = Path(__file__).resolve().parent
40
+
41
+
42
+ for _s in (sys.stdout, sys.stderr): # a Windows console or log file can't print every character a worker writes
43
+ try:
44
+ _s.reconfigure(errors="replace")
45
+ except (AttributeError, ValueError):
46
+ pass
47
+
48
+
49
+ CONFIG_FILE = Path(os.environ["BITE_CONFIG"]).resolve() if os.environ.get("BITE_CONFIG") else KIT_DIR / "bite_config.ini"
50
+
51
+
52
+ KEY_FILE = KIT_DIR / "key.txt"
53
+
54
+
55
+ def _list(value: str) -> list:
56
+ return [v.strip() for v in re.split(r"[,\n]", value or "") if v.strip()]
57
+
58
+
59
+ DEFAULT_FAIL = r"\bFAIL|SCRIPT ERROR|SHADER ERROR|Parse Error|parse error|ignoring script|Traceback"
60
+
61
+
62
+ class Config:
63
+ def __init__(self, path: Path = CONFIG_FILE):
64
+ if not path.exists():
65
+ raise SystemExit(f"No {path.name} found. Settings are optional: run bite in your repo "
66
+ "to guess the test command, or create .bite.ini there with a [kit] section.")
67
+ cp = configparser.ConfigParser(interpolation=None)
68
+ cp.read(path, encoding="utf-8")
69
+ g = cp["kit"] if "kit" in cp else {}
70
+ self.project = Path(os.path.expandvars(os.path.expanduser(g.get("project_folder", "")))).resolve()
71
+ mb = g.get("mailbox_folder", "") or str(KIT_DIR / "mailbox")
72
+ self.mailbox = Path(os.path.expandvars(os.path.expanduser(mb))).resolve()
73
+ self.about = g.get("about_project", "").strip()
74
+ self.model = g.get("model", "sonnet").strip() or "sonnet"
75
+ self.budget = float(g.get("budget_per_run_usd", "3.00"))
76
+ self.job_budget = float(g.get("budget_per_job_usd", "1.00"))
77
+ self.max_turns = int(g.get("max_turns_per_job", "30"))
78
+ self.poll = float(g.get("poll_seconds", "5"))
79
+ self.programs = _list(g.get("allowed_programs", ""))
80
+ self.secrets = [s.lower() for s in _list(g.get("secret_files", "key.txt, .env"))]
81
+ self.protected = [p.lower() for p in _list(g.get("protected_files", ""))]
82
+ self.extra_rules = g.get("extra_rules", "").strip()
83
+ self.org_id = g.get("expected_org_id", "").strip()
84
+ self.test_cmd = g.get("test_command", "").strip()
85
+ self.fail_pattern = g.get("test_fail_pattern", "").strip() or DEFAULT_FAIL
86
+ self.count_pattern = g.get("test_count_pattern", "").strip()
87
+ self.test_glob = _list(g.get("test_files", "")) or DEFAULT_TEST_FILES
88
+ self.test_timeout = int(g.get("test_timeout_seconds", "600"))
89
+ self.copy_ignore = _list(g.get("bite_copy_ignore", "")) or ["__pycache__", "node_modules"]
90
+ self.bite_prepare = g.get("bite_prepare_command", "").strip()
91
+ lk = g.get("heavy_lock_folder", "").strip()
92
+ self.lock_dir = Path(os.path.expandvars(os.path.expanduser(lk))).resolve() if lk else None
93
+ self.lane = g.get("proof_lane", "cpu").strip() or "cpu"
94
+ self.look_cmd = g.get("look_command", "").strip()
95
+ self.look_lane = g.get("look_lane", "gpu").strip() or "gpu"
96
+ self.judge_model = g.get("judge_model", "sonnet").strip() or "sonnet"
97
+ self.judge_budget = float(g.get("judge_budget_usd", "0.25"))
98
+ self.proofs = dict(cp["proofs"]) if "proofs" in cp else {}
99
+ self.sandbox = g.get("sandbox", "copy").strip().lower() != "off"
100
+ self.sandbox_ignore = _list(g.get("sandbox_ignore", "")) or ["__pycache__", "node_modules"]
101
+ self.apply_when = g.get("apply_when", "pass").strip().lower() or "pass"
102
+ self.review_only = _list(g.get("review_only_files", "")) or REVIEW_ONLY_DEFAULT
103
+ self.contain = g.get("contain_command", "").strip()
104
+ self.box_image = g.get("box_image", "python:3.12").strip() or "python:3.12"
105
+ self.box_setup = g.get("box_setup_command", "").strip()
106
+ self.box_test_cmd = g.get("box_test_command", "").strip()
107
+ self.retries = int(g.get("retries_per_job", "2") or 0)
108
+ self.mutants = int(g.get("planted_bugs", "4") or 0)
109
+ self.mutant_seconds = int(g.get("planted_bugs_seconds", "300") or 300)
110
+ self.repeats = int(g.get("repeat_runs", "0") or 0)
111
+ self.ok_codes = {int(x) for x in _list(g.get("ok_exit_codes", "0")) if x.strip().lstrip("-").isdigit()} or {0}
112
+ self.day_budget = float(g.get("budget_per_day_usd", "0") or 0)
113
+ self.sync_before = g.get("sync_before", "").strip()
114
+ self.sync_after = g.get("sync_after", "").strip()
115
+ self.sync_seconds = float(g.get("sync_seconds", "60") or 60)
116
+ self.facts_file = g.get("facts_file", "").strip()
117
+ n = cp["night"] if "night" in cp else {}
118
+ self.intent_file = n.get("intent_file", "").strip()
119
+ self.crowd_cmd = n.get("crowd_command", "").strip()
120
+ self.personas = _list(n.get("personas", "")) or ["speedrunner", "first_timer", "masher", "quitter"]
121
+ self.crowd_seeds = int(n.get("crowd_seeds", "20") or 20)
122
+ self.boredom_seconds = float(n.get("boredom_seconds", "45") or 45)
123
+ self.immune_folder = (n.get("immune_folder", "").strip() or guess_immune(self.project)).strip("/")
124
+ self.night_fixes = int(n.get("max_fixes", "5") or 5)
125
+ self.night_budget = float(n.get("budget_per_night_usd", "0") or 0) or self.budget
126
+ self.night_without_box = n.get("night_without_box", "no").strip().lower() in ("yes", "true", "1", "on")
127
+ self.run_without_box = g.get("run_without_box", "no").strip().lower() in ("yes", "true", "1", "on")
128
+ # persona: event it lives for, e.g. humor_hunter: funny (the hook logs a "funny" event per joke shown)
129
+ self.seeks = {k.strip(): v.strip() for k, v in (x.split(":", 1) for x in _list(n.get("persona_seeks", "")) if ":" in x)}
130
+ self.seek_seconds = float(n.get("seek_seconds", "120") or 120)
131
+ pn = cp["panel"] if "panel" in cp else {}
132
+ self.panel = _list(pn.get("members", ""))
133
+ self.panel_files = _list(pn.get("material_files", ""))
134
+ self.panel_cmd = pn.get("material_command", "").strip()
135
+ self.panel_stills = pn.get("stills_folder", "").strip()
136
+ self.panel_taste = pn.get("taste_file", "").strip()
137
+ self.panel_briefs = {k[6:].strip(): v.strip() for k, v in pn.items() if k.startswith("brief ")}
138
+ self.scratch = _list(g.get("scratch_files", "")) or [
139
+ "__pycache__/*", "*.pyc", ".pytest_cache/*", "node_modules/*", ".godot/*", "*.import", "*.log",
140
+ "*.tmp", ".cache/*", "build/*", "dist/*", "target/*", "bin/*", "obj/*", ".hypothesis/*", ".mypy_cache/*",
141
+ ".ruff_cache/*", ".tox/*", ".nox/*", ".coverage", ".coverage.*", "htmlcov/*", ".nyc_output/*", "coverage/*", "coverage.xml", "coverage.json", "lcov.info", "*.lcov", "junit*.xml", "test-results/*"]
142
+ self.test_setup = _list(g.get("test_setup_files", "")) or [
143
+ "conftest.py", "pytest.ini", "tox.ini", "setup.cfg", "pyproject.toml", "package.json",
144
+ "run_tests*", "*test_runner*", "*testrunner*", "tests/helpers/*", "test/helpers/*"]
145
+
146
+ @property
147
+ def inbox(self):
148
+ return self.mailbox / "to_agent"
149
+
150
+ @property
151
+ def outbox(self):
152
+ return self.mailbox / "from_agent"
153
+
154
+
155
+ SAFE_COMMANDS = {
156
+ "cd", "ls", "dir", "cat", "head", "tail", "grep", "egrep", "rg", "wc", "echo", "printf", "pwd",
157
+ "date", "sleep", "which", "where", "test", "sort", "uniq", "diff", "file", "stat", "tr", "cut",
158
+ "basename", "dirname", "true", "tasklist", "find", "du", "realpath", "readlink", "type",
159
+ "for", "do", "done", "then", "fi", "if", "else",
160
+ }
161
+
162
+
163
+ REVIEW_ONLY_DEFAULT = [ # files that can run code or steer agents: a person looks first (any depth)
164
+ ".claude/*", "CLAUDE.md", "CLAUDE.local.md", "AGENTS.md", ".mcp.json", ".cursor/*", ".github/*",
165
+ ".gitlab-ci.yml", ".circleci/*", ".vscode/*", ".idea/*", ".husky/*", ".githooks/*",
166
+ ".pre-commit-config.yaml", ".gitattributes", ".gitmodules", ".envrc", ".npmrc", ".yarnrc*",
167
+ ".pypirc", "Makefile", "*.mk", "Dockerfile", "docker-compose*", "sitecustomize.py",
168
+ "usercustomize.py", "*.pth", "*.bat", "*.cmd", "*.ps1", "*.sh", "*.desktop", "*.lnk"]
169
+
170
+
171
+ SAFE_GIT = {"status", "log", "diff", "show", "ls-files", "rev-parse", "blame", "shortlog", "describe", "grep", "branch"}
172
+
173
+
174
+ DANGER = re.compile(
175
+ r"(\brm\b|\brmdir\b|\bdel\b|\brd\b|\bmv\b|(?<![-=%])\bformat\b|\bshutdown\b|\btaskkill\b|\bsudo\b"
176
+ r"|\bcurl\b|\bwget\b|\bpip3?\b|\bnpm\b|\bnpx\b|\bwinget\b|\bchoco\b"
177
+ r"|\bgit\s+(push|reset|clean|rebase|checkout|restore|remote|config|commit|add|stash|merge|pull)"
178
+ r"|--force|--hard|Remove-Item|rmtree|os\.remove|os\.unlink|shutil\.|\bsubprocess\b"
179
+ r"|\bfind\b[^|;&]*(-delete|-exec|-ok|-fprint|-fls)"
180
+ r"|--output|--to-file|--ext-diff|--pre\b|--set\b|\bsort\b[^|;&]*\s-\w*o"
181
+ r"|\bdate\b[^|;&]*\s-s\b|(?-i:\bgit\s+(-\S+\s+)*-[cp]\b)|--compress-program|--open-files-in-pager|--paginate|(?-i:\s-O\S*)|/proc\b|/dev/(tcp|udp)\b)",
182
+ re.IGNORECASE,
183
+ )
184
+
185
+
186
+ def split_commands(s: str) -> list:
187
+ """Split on && || ; | and newlines, but not inside quotes."""
188
+ parts, cur, q, i = [], "", "", 0
189
+ while i < len(s):
190
+ c = s[i]
191
+ if q:
192
+ cur += c
193
+ if c == q:
194
+ q = ""
195
+ elif c in "'\"":
196
+ q = c
197
+ cur += c
198
+ elif c in "&|" and s[i:i + 2] in ("&&", "||"):
199
+ parts.append(cur)
200
+ cur = ""
201
+ i += 1
202
+ elif c in ";|&\n":
203
+ parts.append(cur)
204
+ cur = ""
205
+ else:
206
+ cur += c
207
+ i += 1
208
+ parts.append(cur)
209
+ return parts
210
+
211
+
212
+ ENV_PEEK = re.compile(r"environ|\{[^}\s]*(,|\.\.)[^}\s]*\}", re.I) # /proc/self/environ, {k,x}ey.txt
213
+
214
+
215
+ def unquoted(cmd: str) -> str:
216
+ """The command with the inside of each quoted word blanked, except words that are options
217
+ ("--output=x" stays visible, so quoting a flag hides nothing)."""
218
+ def keep(m):
219
+ inner = m.group(0)[1:-1]
220
+ return m.group(0) if inner.lstrip().startswith("-") or "/proc" in inner else '""'
221
+ return re.sub(r"'[^']*'|\"[^\"]*\"", keep, cmd)
222
+
223
+
224
+ def var_refs_ok(cmd: str) -> bool:
225
+ """$NAME is refused (it could print the API key or reach another folder), except the
226
+ variable of a `for NAME in ...` loop in the same command. Inside '...' nothing expands."""
227
+ loop_vars = set(re.findall(r"\bfor\s+([A-Za-z_]\w*)\s+in\b", cmd))
228
+ q, i = "", 0
229
+ while i < len(cmd):
230
+ c = cmd[i]
231
+ if q == "'":
232
+ q = "" if c == "'" else q
233
+ elif c == "'" and not q:
234
+ q = "'"
235
+ elif c == '"':
236
+ q = "" if q == '"' else '"'
237
+ elif c == "\\":
238
+ i += 1
239
+ elif c == "$" and not q and i + 1 < len(cmd) and cmd[i + 1] in "'\"":
240
+ return False # $'\x6bey.txt' spells key.txt
241
+ elif c == "$" and i + 1 < len(cmd) and re.match(r"[A-Za-z0-9_{(!@#*?$-]", cmd[i + 1]):
242
+ m = re.match(r"\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))", cmd[i:])
243
+ if not m or (m.group(1) or m.group(2)) not in loop_vars:
244
+ return False
245
+ i += 1
246
+ return True
247
+
248
+
249
+ def path_ok(tok: str, here: Path, cfg: Config) -> bool:
250
+ """A word that names a path must stay inside the project (the job's copy of it)."""
251
+ if tok.startswith("-"):
252
+ if "=" not in tok:
253
+ return True
254
+ tok = tok.split("=", 1)[1]
255
+ elif "=" in tok and not path_ok(tok.split("=", 1)[1], here, cfg):
256
+ return False # -o cache_dir=/anywhere: the value after = can be a path too
257
+ if not tok:
258
+ return True
259
+ if tok.startswith("~"):
260
+ return False
261
+ base = re.split(r"[*?\[]", tok, maxsplit=1)[0] or "."
262
+ globbed = base != tok
263
+ if globbed and any(c == ".." or (c.startswith(".") and fnmatch.fnmatchcase("..", c)) for c in re.split(r"[/\\]", tok)):
264
+ return False # */../../x or .*/x (.* matches ..): the part before the pattern is inside, the pattern climbs out
265
+ if re.match(r"^[A-Za-z]:[\\/]?", base) or base.startswith(("/", "\\")):
266
+ if not re.match(r"^[A-Za-z]:", base) or WINDOWS:
267
+ target = Path(base)
268
+ if inside(cfg.project, str(target)) is not None:
269
+ return True
270
+ if globbed:
271
+ return False # /pro[c]/... or /e*/passwd: a pattern aimed outside the project
272
+ first = Path(target.anchor) / target.parts[1] if len(target.parts) > 1 else Path(target.anchor)
273
+ return not first.exists() # "/api/" is a search word; "/etc/x" and "C:\Users" are places
274
+ return False
275
+ if not re.search(r"[/\\]", base) and base not in ("..", "."):
276
+ return True # a plain name in the current folder (or a search word)
277
+ return inside(cfg.project, str(here / base)) is not None
278
+
279
+
280
+ def segment_ok(seg: str, cfg: Config, here: list) -> bool:
281
+ try:
282
+ toks = shlex.split(seg.strip(), posix=True)
283
+ except ValueError:
284
+ return False
285
+ if toks and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", toks[0]):
286
+ return False # GIT_PAGER=, GIT_EXTERNAL_DIFF=, LD_PRELOAD=... turn any tool into "run this"
287
+ while toks and toks[0] in ("do", "then", "else"): # before timeout: "do timeout 580 ..."
288
+ toks.pop(0)
289
+ if toks and toks[0] == "timeout":
290
+ toks = toks[1:]
291
+ if toks and re.match(r"^\d+[smh]?$", toks[0]):
292
+ toks = toks[1:]
293
+ if not toks:
294
+ return True
295
+ for i, t in enumerate(toks):
296
+ low = t.lower()
297
+ if any(sec in low for sec in cfg.secrets) or is_secret(Path(low.replace("\\", "/")).name, cfg):
298
+ return False # catches 'key'.txt and key''.txt once the quotes are gone
299
+ glob = Path(low.replace("\\", "/")).name
300
+ if re.search(r"[*?\[]", glob):
301
+ lead = re.split(r"[*?\[]", glob, maxsplit=1)[0]
302
+ if any(fnmatch.fnmatch(sec, glob) and (lead or not cfg.sandbox) for sec in cfg.secrets):
303
+ return False # k*.txt aims at key.txt (and with no copy, even *.txt could reach it)
304
+ if i and not path_ok(t, here[0], cfg):
305
+ return False # the program itself is checked below; its arguments must stay inside
306
+ head = toks[0].replace("\\", "/")
307
+ name = head.rsplit("/", 1)[-1]
308
+ if name == "cd":
309
+ if len(toks) != 2:
310
+ return False # bare cd goes home, cd - goes back out
311
+ new = inside(cfg.project, str(here[0] / toks[1]))
312
+ if new is None:
313
+ return False
314
+ here[0] = new
315
+ return True
316
+ if name == "uniq" and len([t for t in toks[1:] if not t.startswith("-")]) > 1:
317
+ return False # uniq IN OUT writes OUT
318
+ if name in SAFE_COMMANDS:
319
+ return True
320
+ if name in ("git", "git.exe"):
321
+ args = toks[1:]
322
+ while len(args) > 1 and args[0] == "-C": # git -C sub status: sub was path-checked above
323
+ args = args[2:]
324
+ rest = [t for t in args if not t.startswith("-") and "=" not in t]
325
+ if rest and rest[0] == "branch":
326
+ return all(t in ("branch", "-a", "-r", "-v", "-vv", "--list", "--show-current", "--all") for t in args)
327
+ return bool(rest) and rest[0] in SAFE_GIT
328
+ if name.lower() in ("python", "python3", "py", "bash", "sh", "cmd", "powershell", "pwsh"):
329
+ return False # no scripts: they could do anything
330
+ return any(fnmatch.fnmatch(name.lower(), p.lower()) for p in cfg.programs)
331
+
332
+
333
+ def command_ok(cmd: str, cfg: Config) -> bool:
334
+ if any(t in cmd for t in ("<<", ">", "`", "$(")):
335
+ return False # no heredocs, redirects or command substitution
336
+ low = cmd.lower()
337
+ if any(s in low for s in cfg.secrets):
338
+ return False
339
+ bare = unquoted(cmd) # "rm" or "format(" inside quotes is a search word, not a command
340
+ if DANGER.search(bare) or ENV_PEEK.search(bare) or not var_refs_ok(cmd):
341
+ return False
342
+ here = [cfg.project]
343
+ return all(segment_ok(seg, cfg, here) for seg in split_commands(cmd))
344
+
345
+
346
+ def uses_program(cmd: str) -> bool:
347
+ """True if the command runs an allowed program, not just a read-only built-in or git."""
348
+ for seg in split_commands(cmd):
349
+ try:
350
+ toks = shlex.split(seg.strip(), posix=True)
351
+ except ValueError:
352
+ return True
353
+ while toks and (toks[0] in ("do", "then", "else", "timeout") or re.match(r"^\d+[smh]?$", toks[0])):
354
+ toks.pop(0)
355
+ if toks and toks[0].replace("\\", "/").rsplit("/", 1)[-1] not in SAFE_COMMANDS | {"git", "git.exe"}:
356
+ return True
357
+ return False
358
+
359
+
360
+ def guess_immune(project: Path) -> str:
361
+ """Where the night's immune tests go when the config doesn't say: inside the folder the project's
362
+ own test run collects (a project with testpaths = test never sees tests/immune)."""
363
+ for name, pat in (("pytest.ini", r"(?ms)^\[pytest\].*?^testpaths\s*=\s*([^\s#;]+)"),
364
+ ("pyproject.toml", r"(?ms)^\[tool\.pytest\.ini_options\].*?^testpaths\s*=\s*\[?\s*[\"']?([^\"'\s,\]]+)"),
365
+ ("tox.ini", r"(?ms)^\[pytest\].*?^testpaths\s*=\s*([^\s#;]+)"),
366
+ ("setup.cfg", r"(?ms)^\[tool:pytest\].*?^testpaths\s*=\s*([^\s#;]+)")):
367
+ try:
368
+ m = re.search(pat, (project / name).read_text(encoding="utf-8", errors="replace"))
369
+ except OSError:
370
+ continue
371
+ if m and (project / m.group(1)).is_dir():
372
+ return m.group(1).strip("./").rstrip("/") + "/immune"
373
+ if not (project / "tests").is_dir() and (project / "test").is_dir():
374
+ return "test/immune"
375
+ return "tests/immune"
376
+
377
+
378
+ def program_wrapper(cfg: Config, cmd: str) -> str:
379
+ """The worker's program runs under the kit: no API key in its environment, killed with
380
+ everything it started if it overruns, and one per lane when a heavy-run lock is set."""
381
+ secs = max(30, min(cfg.test_timeout, 590)) # the Bash tool moves runs past 600 s to the background
382
+ return f"{shlex.quote(sys.executable)} {shlex.quote(str(KIT_DIR / 'bite.py'))} --run {secs} -- {shlex.quote(cmd)}"
383
+
384
+
385
+ def contain(cfg: Config, cmd: str, folder: Path, posix: bool | None = None) -> str:
386
+ """With contain_command set, a command that runs project code goes into that box
387
+ (for example a container with no network and only the job's copy mounted).
388
+ posix: the command is handed to a bash-like shell (the worker's), not cmd.exe."""
389
+ if not cfg.contain:
390
+ return cmd
391
+ if posix is None:
392
+ posix = not WINDOWS
393
+ quoted = shlex.quote(cmd) if posix else '"' + cmd.replace('"', '\\"') + '"'
394
+ box = cfg.contain
395
+ if box.lstrip().startswith("docker run ") and "--name" not in box and "--rm" in box: # without --rm, a reused name would clash
396
+ # Named, so a timeout can remove it: killing the docker client leaves the box running,
397
+ # and a stuck box's test process then blocks every later proof ("one at a time").
398
+ _BOX_N[0] += 1
399
+ box = box.replace("docker run ", f"docker run --name bite-{os.getpid()}-{_BOX_N[0]} ", 1)
400
+ return box.replace("{project}", str(folder)).replace("{cmd}", quoted)
401
+
402
+
403
+ _BOX_N = [0]
404
+
405
+
406
+ def inside(folder: Path, file_path: str) -> Path | None:
407
+ try:
408
+ target = Path(folder, file_path).resolve()
409
+ target.relative_to(folder.resolve())
410
+ return target
411
+ except (ValueError, OSError):
412
+ return None
413
+
414
+
415
+ def kit_owned(target: Path) -> bool:
416
+ try:
417
+ target.relative_to(KIT_DIR)
418
+ return True
419
+ except ValueError:
420
+ return False
421
+
422
+
423
+ def edit_ok(file_path: str, cfg: Config) -> bool:
424
+ target = inside(cfg.project, file_path)
425
+ if target is None or kit_owned(target):
426
+ return False
427
+ name = target.name.lower()
428
+ if ".git" in target.parts or is_secret(name, cfg) or name in cfg.protected:
429
+ return False
430
+ if not cfg.sandbox and review_only(target.relative_to(cfg.project.resolve()).as_posix(), cfg):
431
+ return False # no copy to hold it for review, so it is not written at all
432
+ return name not in ("bite.py", "bite_config.ini", "key.txt")
433
+
434
+
435
+ def review_only(rel: str, cfg: Config) -> bool:
436
+ parts = rel.replace("\\", "/").lower().split("/")
437
+ for pat in (p.lower() for p in cfg.review_only):
438
+ if pat.endswith("/*") and pat[:-2] in parts[:-1]:
439
+ return True # .claude/* also catches sub/.claude/settings.json
440
+ if fnmatch.fnmatch("/".join(parts), pat) or fnmatch.fnmatch(parts[-1], pat):
441
+ return True
442
+ return False
443
+
444
+
445
+ def is_secret(name: str, cfg: Config) -> bool:
446
+ """Secret names match in any case, and .env also covers .env.local and friends."""
447
+ n = name.lower()
448
+ return any(n == s or n.startswith(s + ".") or fnmatch.fnmatch(n, s) for s in cfg.secrets)
449
+
450
+
451
+ def read_ok(tool_input: dict, cfg: Config) -> bool:
452
+ p = tool_input.get("file_path") or tool_input.get("path") or str(cfg.project)
453
+ names = [p, tool_input.get("glob") or ""] # a search WORD may mention .env; a path or glob may not
454
+ if any(is_secret(Path(n.replace("\\", "/").lower()).name, cfg) or any(s in n.lower() for s in cfg.secrets)
455
+ for n in names if n):
456
+ return False
457
+ if ".git" in Path(p).parts:
458
+ return False # git internals (and any hidden tests kept there) stay unseen
459
+ return inside(cfg.project, p) is not None # the job text is in the prompt; the mailbox stays shut
460
+
461
+
462
+ JOB_EDITS = {} # path -> the file's bytes before the agent first touched it (None = it was new)
463
+
464
+
465
+ RUN_BASE = {} # (project, relative path) -> the file as it was the first time a worker touched it this run
466
+
467
+
468
+ def remember_original(cfg: Config, file_path: str):
469
+ target = inside(cfg.project, file_path)
470
+ if target is not None and str(target) not in JOB_EDITS:
471
+ JOB_EDITS[str(target)] = target.read_bytes() if target.is_file() else None
472
+ RUN_BASE.setdefault((str(getattr(cfg, "origin", cfg.project)), target.relative_to(cfg.project).as_posix()),
473
+ JOB_EDITS[str(target)])
474
+
475
+
476
+ def refusal_hint(tool: str, cmd: str, cfg: Config) -> str:
477
+ """Say what to use instead, so a refusal costs one turn, not five."""
478
+ if tool != "Bash":
479
+ return f"{tool} isn't available to workers. Use Read/Glob/Grep to look and Edit/Write inside the project."
480
+ if any(t in cmd for t in (">", "<<")):
481
+ return "No redirects or heredocs: write files with the Edit or Write tool."
482
+ if "$" in cmd:
483
+ return "No variables ($NAME): write the value out, or use a for loop's own variable."
484
+ if re.search(r"(^|\s)(~|/|\.\.|[A-Za-z]:\\)", cmd):
485
+ return "Paths must stay inside the project folder (your working directory)."
486
+ if re.match(r"\s*(python3?|py|bash|sh|cmd|powershell|pwsh|node)\b", cmd):
487
+ return ("No scripts. To check behaviour, write a test file and let the kit run the tests"
488
+ + (f", or run an allowed program ({', '.join(cfg.programs)})." if cfg.programs else "."))
489
+ if re.search(r"\bgit\b", cmd):
490
+ return "Only read-only git (status, log, diff, show, blame, grep, ls-files). The planners commit."
491
+ if re.search(r"\b(rm|del|mv|cp)\b", cmd):
492
+ return "No deleting or moving files. Edit or Write a file instead; leave deletions to the person."
493
+ return ("Only read-only commands (ls, cat, grep, find, head, wc, diff, sort...), read-only git"
494
+ + (f" and {', '.join(cfg.programs)}" if cfg.programs else "") + " work.")
495
+
496
+
497
+ def make_permission(cfg: Config, log=print):
498
+ async def permission(tool_name, tool_input, context):
499
+ from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
500
+ if tool_name in ("Read", "Glob", "Grep"):
501
+ if read_ok(tool_input, cfg):
502
+ return PermissionResultAllow(updated_input=tool_input)
503
+ log(f" [REFUSED] {tool_name} outside the project or a secret file")
504
+ return PermissionResultDeny(message="Only files inside the project may be read, and never secret files.")
505
+ if tool_name in ("Edit", "Write", "NotebookEdit") and edit_ok(
506
+ tool_input.get("file_path") or tool_input.get("notebook_path") or "", cfg):
507
+ log(f" [edit] {tool_input.get('file_path', '')}")
508
+ remember_original(cfg, tool_input.get("file_path") or tool_input.get("notebook_path") or "")
509
+ return PermissionResultAllow(updated_input=tool_input)
510
+ if tool_name in ("TodoWrite",):
511
+ return PermissionResultAllow(updated_input=tool_input)
512
+ if tool_name == "Bash" and tool_input.get("run_in_background"):
513
+ log(" [REFUSED] background command")
514
+ return PermissionResultDeny(message="Background commands are refused. Run it in the foreground and wait.")
515
+ if tool_name == "Bash" and cfg.test_cmd and tool_input.get("command", "").strip() == "bite-test":
516
+ log(" [run] bite-test (the kit's own test command)")
517
+ command = tests_cmd(cfg).replace("{project}", str(cfg.project)).replace("{seed}", SEED[0])
518
+ if cfg.contain: # run by the kit's --run, so quoted for this OS's shell (cmd.exe on Windows)
519
+ command = contain(cfg, command, cfg.project)
520
+ return PermissionResultAllow(updated_input={**tool_input, "command": program_wrapper(cfg, command)})
521
+ if tool_name == "Bash" and command_ok(tool_input.get("command", ""), cfg):
522
+ first = (tool_input.get("command", "").strip().splitlines() or [""])[0]
523
+ log(f" [run] {first[:110]}")
524
+ command = tool_input["command"]
525
+ wrapped = uses_program(tool_input["command"])
526
+ if cfg.contain: # with a box, every worker command runs in it
527
+ # a wrapped command ends up in this OS's shell (cmd.exe on Windows), the rest in the
528
+ # worker's bash, where Git Bash on Windows would turn -w /work into a Windows path
529
+ command = contain(cfg, command, cfg.project, posix=None if wrapped else True)
530
+ if WINDOWS and not wrapped:
531
+ command = "MSYS_NO_PATHCONV=1 " + command
532
+ if wrapped:
533
+ command = program_wrapper(cfg, command)
534
+ if command != tool_input["command"]:
535
+ tool_input = {**tool_input, "command": command}
536
+ return PermissionResultAllow(updated_input=tool_input)
537
+ target = tool_input.get("command") or tool_input.get("file_path") or tool_input.get("url") or ""
538
+ log(f" [REFUSED] {tool_name}: {str(target)[:110]}")
539
+ return PermissionResultDeny(message="Refused. " + refusal_hint(tool_name, str(target), cfg) +
540
+ " Don't work around it; if it's truly needed, say in your report what the "
541
+ "person would need to run.")
542
+ return permission
543
+
544
+
545
+ DEFAULT_TEST_FILES = ["test_*", "*_test.*", "test.*", "tests.*", "*.test.*", "*.spec.*", "*_spec.*",
546
+ "tests/*", "test/*", "__tests__/*", "spec/*",
547
+ "*tests.cs", "*test.cs", "*tests.fs", "*test.fs", "*.tests/*", "*.test/*"] # C#/F# (.NET) names
548
+
549
+
550
+ def is_test_file(rel: str, cfg: Config) -> bool:
551
+ """A pattern without a slash names a file (test_*); one with a slash names a folder (tests/*), which
552
+ must be a whole folder name, so src/testkit/core.py and src/testfixtures/x.py stay code and get the proof."""
553
+ rel = rel.replace("\\", "/").lower()
554
+ name = rel.rsplit("/", 1)[-1]
555
+ for g in (g.lower().replace("\\", "/") for g in cfg.test_glob):
556
+ if "/" not in g:
557
+ if fnmatch.fnmatchcase(name, g):
558
+ return True
559
+ elif any(fnmatch.fnmatchcase(tail, g) for tail in
560
+ ["/".join(rel.split("/")[i:]) for i in range(rel.count("/") + 1)]):
561
+ return True
562
+ return False
563
+
564
+
565
+ WINDOWS = os.name == "nt"
566
+
567
+
568
+ def run_cmd(cmd: str, cwd: Path, timeout: int):
569
+ """Run one command; on timeout kill it AND its children by PID (a killed shell would
570
+ otherwise leave the game engine running). Returns (exit code or None on timeout, output)."""
571
+ flags = subprocess.CREATE_NEW_PROCESS_GROUP if WINDOWS else 0
572
+ env = {k: v for k, v in os.environ.items() if not k.upper().startswith(("ANTHROPIC_", "CLAUDE_"))}
573
+ env["PYTHONDONTWRITEBYTECODE"] = "1" # stale bytecode once faked a pass
574
+ env["BITE_SEED"] = SEED[0]
575
+ env.update(EXTRA_ENV)
576
+ src = Path(cwd) / "src"
577
+ if (src.is_dir() and not (src / "__init__.py").exists()
578
+ and any((d / "__init__.py").exists() for d in src.iterdir() if d.is_dir())):
579
+ # A src/ layout installed with pip install -e . would load the checkout's code, not this copy's.
580
+ env["PYTHONPATH"] = os.pathsep.join(x for x in (str(src), env.get("PYTHONPATH", "")) if x)
581
+ p = subprocess.Popen(cmd, shell=True, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env,
582
+ text=True, errors="replace", creationflags=flags, start_new_session=not WINDOWS)
583
+ try:
584
+ out, _ = p.communicate(timeout=timeout)
585
+ return p.returncode, out or ""
586
+ except subprocess.TimeoutExpired:
587
+ if WINDOWS:
588
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(p.pid)], capture_output=True)
589
+ else:
590
+ import signal
591
+ try:
592
+ os.killpg(p.pid, signal.SIGKILL)
593
+ except OSError:
594
+ pass
595
+ for name in re.findall(r"--name (bite-\d+-\d+)", cmd): # the box outlives its killed client
596
+ try:
597
+ subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=60)
598
+ except (OSError, subprocess.SubprocessError):
599
+ pass # the timeout is still reported; a box left behind shows in docker ps
600
+ out, _ = p.communicate()
601
+ return None, out or ""
602
+
603
+
604
+ class HeavyLock:
605
+ def __init__(self, folder, lane: str, max_hold: int, who: str = "bite"):
606
+ self.path = Path(folder) / f"{lane}.lock" if folder else None
607
+ self.lane, self.max_hold, self.who = lane, max_hold, who
608
+
609
+ def _holder(self):
610
+ try:
611
+ pid, start, _who = self.path.read_text(encoding="utf-8").split(" ", 2)
612
+ return int(pid), float(start), _who.strip()
613
+ except (OSError, ValueError):
614
+ return None
615
+
616
+ def acquire(self, wait: int) -> str:
617
+ """'' when held; otherwise why not. Takes over a lock whose holder died or overran."""
618
+ if not self.path:
619
+ return ""
620
+ self.path.parent.mkdir(parents=True, exist_ok=True)
621
+ end = time.time() + wait
622
+ while True:
623
+ try:
624
+ fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
625
+ os.write(fd, f"{os.getpid()} {time.time():.0f} {self.who}".encode())
626
+ os.close(fd)
627
+ return ""
628
+ except FileExistsError:
629
+ h = self._holder()
630
+ if h is None or not pid_alive(h[0]) or time.time() - h[1] > self.max_hold + 60:
631
+ self.path.unlink(missing_ok=True) # dead or overran its own limit: take over
632
+ continue
633
+ if time.time() >= end:
634
+ return f"{self.lane} lane busy ({h[2]}, pid {h[0]}) for over {wait}s"
635
+ time.sleep(3)
636
+
637
+ def release(self):
638
+ if self.path:
639
+ h = self._holder()
640
+ if h and h[0] == os.getpid():
641
+ self.path.unlink(missing_ok=True)
642
+
643
+
644
+ def locked_run(cfg: Config, cmd: str, cwd: Path, lane: str = ""):
645
+ """run_cmd under the lane lock (if configured), with the watchdog timeout."""
646
+ lock = HeavyLock(cfg.lock_dir, lane or cfg.lane, cfg.test_timeout)
647
+ why = lock.acquire(cfg.test_timeout)
648
+ if why:
649
+ return None, f"not run: {why}"
650
+ try:
651
+ return run_cmd(contain(cfg, cmd, cwd), cwd, cfg.test_timeout)
652
+ finally:
653
+ lock.release()
654
+
655
+
656
+ EXTRA_ENV = {} # e.g. BITE_PERSONA and BITE_OUT for a crowd run
657
+
658
+
659
+ SEED = ["0"] # BITE_SEED for the next test run: 0 = the normal run, 1..N = the repeat runs
660
+
661
+
662
+ AUTO_COUNTS = [ # common runners' summary lines, so the test count works without test_count_pattern
663
+ (r"(?:^=+ .*|^)\b\d+ (?:passed|failed|errors?)\b.* in [\d.]+s\b", r"\b(\d+) (?:passed|failed|errors?|xfailed|xpassed)\b"), # pytest
664
+ (r"^Ran (\d+) tests? in ", r"^Ran (\d+) tests? in "), # unittest
665
+ (r"^Tests:.* (\d+) total", r"^Tests:.* (\d+) total"), # jest / vitest
666
+ (r"^\s*(\d+) (?:passing|failing|pending)\b", r"^\s*(\d+) (?:passing|failing|pending)\b"), # mocha
667
+ (r"^\s*(?:#|ℹ) tests (\d+)\s*$", r"^\s*(?:#|ℹ) tests (\d+)"), # node --test, tap, tape
668
+ (r"^\s*Tests\s+.*\((\d+)\)\s*$", r"\((\d+)\)\s*$"), # vitest
669
+ (r"^\s*(\d+) tests? (?:passed|failed)\b", r"^\s*(\d+) tests? (?:passed|failed)\b"), # ava
670
+ (r"^\s*Total:\s+(\d+)\s*$", r"(\d+)"), # uvu
671
+ (r"^test result: \w+\. (\d+) passed", r"(\d+) (?:passed|failed)(?=;)"), # cargo
672
+ ]
673
+
674
+
675
+ def auto_count(out: str):
676
+ for find, add in AUTO_COUNTS:
677
+ lines = [ln for ln in out.splitlines() if re.search(find, ln, re.M)]
678
+ if lines:
679
+ return sum(int(x) for ln in lines for x in re.findall(add, ln))
680
+ return None
681
+
682
+
683
+ def tests_cmd(cfg: Config) -> str:
684
+ """Inside the box the project is at /work and only the copy is mounted, so a test command that
685
+ names files outside the project (a Windows runner script, say) needs its own box version."""
686
+ return cfg.box_test_cmd if cfg.contain and cfg.box_test_cmd else cfg.test_cmd
687
+
688
+
689
+ def run_tests(cfg: Config, folder: Path) -> dict:
690
+ cmd = tests_cmd(cfg).replace("{project}", str(folder)).replace("{seed}", SEED[0])
691
+ rc, out = locked_run(cfg, cmd, folder)
692
+ if rc is None:
693
+ why = out if out.startswith("not run:") else f"timed out after {cfg.test_timeout}s (test_command must exit on its own)"
694
+ return {"ok": False, "why": why,
695
+ "fails": [], "count": None, "out": out}
696
+ fails = [ln.strip() for ln in out.splitlines() if re.search(cfg.fail_pattern, ln)]
697
+ count = None
698
+ if cfg.count_pattern:
699
+ m = re.findall(cfg.count_pattern, out)
700
+ count = sum(int(x) for x in m) if m else 0
701
+ else:
702
+ count = auto_count(out)
703
+ ok = rc in cfg.ok_codes and not fails and (count is None or count > 0)
704
+ why = (f"exit {rc}" if rc else "") or (fails[0][:160] if fails else "") or ("no tests ran" if count == 0 else "")
705
+ return {"ok": ok, "why": why, "fails": fails, "count": count, "out": out}
706
+
707
+
708
+ def safe_write(root: Path, target: Path, data: bytes):
709
+ """Write a file inside root, never through a symlink (the file or any folder above it)."""
710
+ rel = target.relative_to(root)
711
+ cur = root
712
+ for part in rel.parts:
713
+ cur = cur / part
714
+ if cur.is_symlink():
715
+ raise OSError(f"refused to write through a link: {rel.as_posix()}")
716
+ target.parent.mkdir(parents=True, exist_ok=True)
717
+ target.write_bytes(data)
718
+
719
+
720
+ LOAD_ERRORS = re.compile(r"ImportError|ModuleNotFoundError|SyntaxError|IndentationError|error(?:s)? during collection|"
721
+ r"Cannot find module|Parse Error|Failed to load script|could not compile|unresolved import|"
722
+ r"error CS\d{4}|error FS\d{4}")
723
+
724
+
725
+ CHECK_FAILS = re.compile(r"(?m)^\s*FAIL\b|AssertionError|\bassert\b|Expected|expected|FAILED .*::|✕|\bFAIL\b.*(?:!=|==|got|want)|panicked at")
726
+
727
+
728
+ def load_failure(out: str) -> bool:
729
+ """True when a failing run failed only because code couldn't load, with no failing check in it."""
730
+ return bool(LOAD_ERRORS.search(out)) and not CHECK_FAILS.search(out)
731
+
732
+
733
+ HASH_COMMENTS = {".py", ".pyw", ".rb", ".sh", ".gd", ".r", ".pl", ".toml", ".yaml", ".yml", ".ini", ".cfg"}
734
+
735
+
736
+ def strip_comment(line: str, suffix: str) -> str:
737
+ """Cut a trailing comment the way the file's language writes one: in Python // is floor division,
738
+ and in any language a # or // inside a string (a URL) isn't a comment."""
739
+ out, quote, i = [], "", 0
740
+ hash_ = suffix in HASH_COMMENTS
741
+ while i < len(line):
742
+ c = line[i]
743
+ if quote:
744
+ if c == "\\":
745
+ out.append(line[i:i + 2]); i += 2; continue
746
+ if c == quote:
747
+ quote = ""
748
+ elif c in "'\"`":
749
+ quote = c
750
+ elif (hash_ and c == "#") or (not hash_ and line.startswith("//", i)):
751
+ break
752
+ out.append(c)
753
+ i += 1
754
+ return "".join(out)
755
+
756
+
757
+ def pid_alive(pid: int) -> bool:
758
+ if WINDOWS:
759
+ out = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
760
+ capture_output=True, text=True).stdout
761
+ return f'"{pid}"' in out
762
+ try:
763
+ os.kill(pid, 0)
764
+ return True
765
+ except OSError:
766
+ return False
767
+
768
+
769
+ def load_key() -> str:
770
+ if os.environ.get("ANTHROPIC_API_KEY"):
771
+ return os.environ["ANTHROPIC_API_KEY"]
772
+ if KEY_FILE.exists():
773
+ return clean_key(KEY_FILE.read_text(encoding="utf-8-sig"))
774
+ raise SystemExit("No API key. Set ANTHROPIC_API_KEY, or put your key in key.txt next to bite.py.")
775
+
776
+
777
+ def guess_test_command(folder: Path) -> str:
778
+ """The usual test command for what's in the folder, or "" if nothing is obvious."""
779
+ import json as _json
780
+ f = Path(folder)
781
+ if (f / "package.json").is_file():
782
+ try:
783
+ t = (_json.loads((f / "package.json").read_text(encoding="utf-8")).get("scripts") or {}).get("test", "")
784
+ except ValueError:
785
+ t = ""
786
+ if t and "no test specified" not in t:
787
+ return "npm test"
788
+ if (f / "Cargo.toml").is_file():
789
+ return "cargo test"
790
+ if (f / "go.mod").is_file():
791
+ return "go test ./..."
792
+ if (f / "ProjectSettings" / "ProjectVersion.txt").is_file():
793
+ return "" # Unity: its tests run inside the Unity editor (-runTests), not with dotnet test
794
+ dotnet = dotnet_target(f)
795
+ if dotnet:
796
+ return "dotnet test " + (f'"{dotnet}"' if " " in dotnet else dotnet)
797
+ if any((f / n).exists() for n in ("pytest.ini", "conftest.py", "tox.ini")) or \
798
+ any(f.glob("test*/test_*.py")) or any(f.glob("test_*.py")) or any(f.glob("*/tests/test_*.py")):
799
+ return ("python" if WINDOWS else "python3") + " -m pytest -q"
800
+ return ""
801
+
802
+
803
+ def dotnet_target(folder: Path) -> str:
804
+ """The solution or project file dotnet test should run (it needs one named when there are several)."""
805
+ f = Path(folder)
806
+ for found in (sorted(f.glob("*.sln")) + sorted(f.glob("*.slnx")), sorted(f.glob("*.csproj")) + sorted(f.glob("*.fsproj"))):
807
+ if found:
808
+ return found[0].name
809
+ tests = sorted(p for pat in ("*/*.csproj", "*/*/*.csproj", "*/*.fsproj", "*/*/*.fsproj") for p in f.glob(pat)
810
+ if "test" in p.name.lower())
811
+ return tests[0].relative_to(f).as_posix() if len(tests) == 1 else ""
812
+
813
+
814
+ def clean_key(text: str) -> str:
815
+ return text.replace("\ufeff", "").strip().strip("'\" ").split()[0] if text.strip() else ""
816
+
817
+