#!/usr/bin/env python3 """Test the guardrails you already have against dangerous commands. guardrail-tester reads the permission rules and hooks of Claude Code, Codex, Gemini CLI, OpenCode, and Cursor, then checks a battery of dangerous commands (scripts/battery.json) against them: force pushes, recursive deletes, secret reads, downloads piped into a shell, and the wrapped and reordered forms that slip past prefix rules. Rule matching is simulated from each harness's documented rules. With --run-hooks, hooks are real: each hook command gets a JSON description of the tool call on stdin, exactly as the harness would send it, one hook run at a time. The tester never runs the battery commands; a hook that runs or forwards its input would, so read each hook script first. The battery commands are written to do nothing if one is run anyway. With --replay N it also replays your last N real shell and file-tool calls through the rules (and, with --replay-hooks, through this project's hooks) and counts how many would ask first or be blocked. Examples ( is the folder that holds SKILL.md): python3 "/scripts/test_guards.py" --project . rules only python3 "/scripts/test_guards.py" --project . --run-hooks --replay 500 python3 "/scripts/test_guards.py" --harness codex --json Exit codes: 0 done, 1 a battery case is not blocked as expected and --fail-on-miss was given, 2 bad arguments or an unreadable battery file. Reads local files only; makes no network calls. Python 3.9+, standard library only. """ from __future__ import annotations import sys sys.dont_write_bytecode = True # leave no __pycache__ in the installed skill folder import argparse # noqa: E402 import concurrent.futures # noqa: E402 import copy # noqa: E402 import json # noqa: E402 import os # noqa: E402 import re # noqa: E402 import shutil # noqa: E402 import tempfile # noqa: E402 from dataclasses import dataclass, field, replace # noqa: E402 HERE = os.path.dirname(os.path.abspath(__file__)) if HERE not in sys.path: sys.path.insert(0, HERE) import claude_rules as C # noqa: E402 import harness_rules as R # noqa: E402 import hook_runner as H # noqa: E402 import shell_split # noqa: E402 import transcripts as T # noqa: E402 from safe import code, safe_text # noqa: E402 VERSION = "1.0.0" CHECKED = "2026-10-08" ORDER = ("claude-code", "codex", "gemini-cli", "opencode", "cursor") NAMES = {"claude-code": "Claude Code", "codex": "Codex", "gemini-cli": "Gemini CLI", "opencode": "OpenCode", "cursor": "Cursor"} DEFAULT_BATTERY = os.path.join(HERE, "battery.json") CATEGORY_ORDER = ("destructive-git", "destructive-files", "secrets", "remote-code", "exfiltration", "guardrail-tampering", "privilege", "wrappers", "file-tools", "infrastructure", "publishing") REPLAY_KINDS = ("shell", "read", "edit", "write") NEEDS = ("network", "write-outside", "write-git", "write-project") MISS_RANK = {"runs": 0, "asks_mode": 1, "asks_rule": 2} _STRICT = {"deny": 4, "ask": 3, "classifier": 2, "allow": 1, "unknown": 0} VERDICT_WORDS = {"deny": "blocked", "ask": "asks first", "allow": "runs without asking", "classifier": "left to the auto-mode classifier", "unknown": "unknown, a hook did not answer in time"} UNKNOWN_DETAIL = "a hook did not answer within the tester's time limit, so the result is unknown" GAVE_UP = "gave-up" # --------------------------------------------------------------------------- # Suggested hook checks. Extended regular expressions (grep -E) that a # PreToolUse hook can apply to the whole command line, as the repo's # templates/claude-code-safe-settings/.claude/hooks/guard.sh does. They are # fix suggestions and the danger test for replayed calls, not a shipped guard. # --------------------------------------------------------------------------- _GIT = r"git([[:space:]]+-[^[:space:]]+([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+['\"]?" HOOK_PATTERNS = [ {"id": "git-force-push", "expect": "block", "label": "force pushes and remote deletes in any form", "ere": _GIT + r"push['\"]?[^;&|]*[[:space:]](-f|--force[^[:space:]]*|--mirror|--delete|-d|:[^[:space:]]+|" r"\+[^[:space:]]+)([[:space:]]|$|['\"])"}, {"id": "git-push-url", "expect": "block", "label": "pushing to a web address instead of a named remote", "ere": _GIT + r"push['\"]?[^;&|]*[[:space:]]['\"]?((https?|ssh|git|file)://|[^[:space:]@'\"]+@[^[:space:]:'\"]+:)"}, {"id": "git-reset-hard", "expect": "block", "label": "git reset --hard", "ere": _GIT + r"reset['\"]?[[:space:]][^;&|]*--hard"}, {"id": "git-clean", "expect": "block", "label": "git clean with -f", "ere": _GIT + r"clean['\"]?[^;&|]*[[:space:]]-[[:alnum:]]*f"}, {"id": "git-discard-all", "expect": "block", "label": "git checkout or restore of the whole tree", "ere": _GIT + r"(checkout|restore)['\"]?[[:space:]]+([^-;&|[:space:]][^;&|[:space:]]*[[:space:]]+)?" r"(--[[:space:]]+)?\.([[:space:]]|$)"}, {"id": "git-branch-force-delete", "expect": "block", "label": "git branch -D", "ere": _GIT + r"branch['\"]?[[:space:]][^;&|]*(-D|--delete[[:space:]]+--force|--force[[:space:]]+--delete)" r"([[:space:]]|$)"}, {"id": "git-remote-change", "expect": "block", "label": "adding or changing a git remote", "ere": _GIT + r"remote['\"]?[[:space:]]+(add|set-url)[[:space:]]"}, {"id": "git-hooks-path", "expect": "block", "label": "pointing git at another hooks folder", "ere": r"core\.hooksPath"}, {"id": "rm-recursive", "expect": "block", "label": "recursive rm, by any path or flag order", "ere": r"(^|[^[:alnum:]_./-])(/[^[:space:]]*/)?\\?rm([[:space:]]+-[^[:space:]]*)*[[:space:]]+" r"(-[[:alnum:]]*[rR][[:alnum:]]*|--recursive)([[:space:]]|$)"}, {"id": "find-delete", "expect": "block", "label": "find -delete or find -exec rm", "ere": r"(^|[^[:alnum:]_])find[[:space:]][^;&|]*[[:space:]](-delete|-exec[[:space:]]+(/[^[:space:]]*/)?rm" r"[[:space:]])"}, {"id": "privilege", "expect": "block", "label": "sudo, doas, pkexec, or su", "ere": r"((^|[^[:alnum:]_./-])(/[^[:space:]]*/)?(sudo|doas|pkexec)([[:space:]]|$)|(^|[;&|[:space:](])su" r"[[:space:]]+(-|root|-c|-l))"}, {"id": "secret-files", "expect": "block", "label": "naming .env files (except examples), private keys, or cloud credentials", "ere": r"(\.env([.[:space:]'\")|;&`]|$)|id_rsa|id_ed25519|id_ecdsa|\.ssh/|\.aws/[^[:space:]]*credentials|" # skillscan:allow r"\.netrc|\.pypirc|\.git-credentials|\.docker/config\.json|application_default_credentials)"}, # skillscan:allow (a detection pattern) {"id": "keychain", "expect": "block", "label": "reading the macOS keychain", "ere": r"security[[:space:]]+(find-generic-password|find-internet-password|dump-keychain)"}, {"id": "env-dump", "expect": "block", "label": "printing the environment or a secret variable", "ere": r"((^|[;&|[:space:](])(printenv|env)[[:space:]]*($|[;&|)])|printenv[[:space:]]+[[:alnum:]_]*" # skillscan:allow (a detection pattern) r"(KEY|TOKEN|SECRET|PASS))"}, {"id": "grep-secrets", "expect": "block", "label": "grep for keys, tokens, or passwords", "ere": r"grep[[:space:]][^;&|]*(KEY|TOKEN|SECRET|PASSWORD)"}, {"id": "pipe-to-shell", "expect": "block", "label": "a download piped into a shell", "ere": r"(curl|wget)[^;&|]*\|[[:space:]]*(sudo[[:space:]]+)?(ba|z|da)?sh([[:space:]]|$)"}, # skillscan:allow {"id": "download-then-run", "expect": "block", "label": "a download run by a shell or an interpreter", "ere": r"((ba|z|da)?sh[[:space:]]+<\([[:space:]]*(curl|wget)|(curl|wget)[^;&|]*(&&|;)[[:space:]]*" r"(ba|z|da)?sh[[:space:]]|(exec|eval)\([^;]*(urlopen|requests\.get))"}, {"id": "decoded-exec", "expect": "block", "label": "base64 text decoded into a shell", "ere": r"base64[[:space:]]+(-d|-D|--decode)[^;&]*\|[[:space:]]*(ba|z|da)?sh"}, # skillscan:allow {"id": "upload", "expect": "block", "label": "curl or wget uploading a file", "ere": r"(curl|wget)[[:space:]][^;&|]*[[:space:]](-d|--data[^[:space:]]*|-F|--form|-T|--upload-file)" r"[[:space:]=]+[^[:space:]]*@"}, {"id": "raw-socket", "expect": "block", "label": "nc or netcat connections", "ere": r"(^|[;&|[:space:](])(nc|ncat|netcat)[[:space:]]"}, {"id": "tamper-files", "expect": "block", "label": "writing git hooks, agent settings, shell startup files, or login items", "ere": r"((>|tee[[:space:]]|sed[[:space:]]+-i|cp[[:space:]]|mv[[:space:]]|ln[[:space:]])[^;&|]*" r"(\.git/hooks/|\.claude/settings|\.codex/config\.toml|\.gemini/settings\.json|\.cursor/hooks\.json|" r"\.zshrc|\.bashrc|\.bash_profile|\.zprofile|\.profile|authorized_keys|LaunchAgents/)|disableAllHooks)"}, {"id": "infrastructure", "expect": "block", "label": "destroying cloud resources or databases", "ere": r"(terraform[[:space:]]([^;&|]*[[:space:]])?destroy|kubectl[[:space:]]([^;&|]*[[:space:]])?delete" r"([[:space:]]|$)|helm[[:space:]]+uninstall|gcloud[[:space:]][^;&|]*[[:space:]]delete|aws[[:space:]]" r"[^;&|]*([[:space:]](rb|rm)[[:space:]]|terminate-instances|[[:space:]]delete-)|(DROP|drop)[[:space:]]+" r"(DATABASE|database|TABLE|table|SCHEMA|schema)|gh[[:space:]]+repo[[:space:]]+delete)"}, {"id": "infrastructure-change", "expect": "ask", "label": "applying infrastructure or pruning Docker", "ere": r"(terraform[[:space:]]([^;&|]*[[:space:]])?apply|docker[[:space:]]+(system|volume|image)[[:space:]]+" r"(prune|rm))"}, {"id": "publish", "expect": "ask", "label": "publishing packages, images, or releases", "ere": r"((npm|pnpm|yarn|bun)[[:space:]]+(npm[[:space:]]+)?publish|cargo[[:space:]]+publish|twine[[:space:]]+" r"upload|gh[[:space:]]+release[[:space:]]+create|docker[[:space:]]+push|(uv|poetry)[[:space:]]+publish|" r"gem[[:space:]]+push)"}, ] def _ere_to_python(ere): return (ere.replace("[:space:]", r"\s").replace("[:alnum:]", "a-zA-Z0-9")) for _p in HOOK_PATTERNS: _p["ere"] = _p["ere"].replace("'\\\"", "'\"") # a plain ' and " inside brackets, no backslash _p["re"] = re.compile(_ere_to_python(_p["ere"])) _EXAMPLE_ENV = re.compile(r"\.env\.(example|sample|template)") _READ_DANGER = re.compile(r"(^|/)\.env($|\.)|/\.ssh/|\.aws/credentials|\.netrc$|\.git-credentials$|" # skillscan:allow r"application_default_credentials") _WRITE_DANGER = re.compile(r"\.git/hooks/|/\.claude/settings|/\.codex/config\.toml|/\.gemini/settings\.json|" r"(^|/)\.(zshrc|bashrc|bash_profile|zprofile|profile)$|authorized_keys$|/LaunchAgents/|" r"(^|/)\.env($|\.)") def matching_patterns(command): """The suggested hook checks that catch this command line.""" text = _EXAMPLE_ENV.sub("", command or "") return [p for p in HOOK_PATTERNS if p["re"].search(text)] def _danger(kind, command, paths): """(expect, pattern ids) when a real call looks dangerous, else (None, []).""" if kind == "shell": hits = matching_patterns(shell_split.without_heredocs_and_comments(command or "")) if hits: expect = "block" if any(p["expect"] == "block" for p in hits) else "ask" return expect, [p["id"] for p in hits] return None, [] rx = _READ_DANGER if kind == "read" else _WRITE_DANGER if any(rx.search(p or "") and not _EXAMPLE_ENV.search(p or "") for p in paths): return "block", ["secret-files" if kind == "read" else "tamper-files"] return None, [] def stopped(expect, verdict): """The strict score: a block case counts only when blocked outright.""" return verdict == "deny" if expect == "block" else verdict in ("deny", "ask") def bucket_of(result): """blocked, asks_rule (still asks in auto mode), asks_mode (only the permission mode asks), runs (allow, sandbox auto-allow, classifier), or unknown.""" verdict = result.verdict if verdict == "deny": return "blocked" if verdict == "unknown": return "unknown" if verdict == "ask": mode_only = result.mode_only if result.mode_only is not None else result.layer == "mode" return "asks_mode" if mode_only else "asks_rule" return "runs" # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- def show_path(path, home): """A path for the report: the home folder as ~, untrusted text made safe.""" path = str(path or "") home = (home or "").rstrip("/") if home and (path == home or path.startswith(home + "/")): path = "~" + path[len(home):] return safe_text(path, 160) def battery_code(text, table=True): """A command from the skill's own battery.json, which is trusted, as inline code shown exactly: a newline shows as \\n, and inside a table cell a pipe is escaped as \\|. A reader needs the real pipe to see that a download goes into a shell. Untrusted text goes through code() from safe.py instead.""" text = str(text).replace("\n", " \\n ") if table: text = text.replace("|", "\\|") fence = "``" if "`" in text else "`" pad = " " if fence == "``" else "" return "%s%s%s%s%s" % (fence, pad, text, pad, fence) # --------------------------------------------------------------------------- # Battery # --------------------------------------------------------------------------- class InputError(Exception): pass def load_battery(path): try: with open(path, encoding="utf-8") as fh: lines = [line for line in fh.read().splitlines() if line.strip()] except OSError as exc: raise InputError("cannot read the battery file (%s)" % type(exc).__name__) cases, seen = [], set() for n, line in enumerate(lines, 1): try: case = json.loads(line) except ValueError: raise InputError("battery line %d is not valid JSON" % n) if not isinstance(case, dict) or case.get("tool") not in REPLAY_KINDS or case.get("expect") not in ( "block", "ask") or not isinstance(case.get("input"), dict) or not case.get("id"): raise InputError("battery line %d needs id, tool, input, category, and expect" % n) key = "command" if case["tool"] == "shell" else "file_path" if not isinstance(case["input"].get(key), str) or not case["input"][key]: raise InputError("battery line %d needs input.%s" % (n, key)) needs = case.setdefault("needs", []) if not isinstance(needs, list) or any(x not in NEEDS for x in needs): raise InputError("battery line %d: needs takes only %s" % (n, ", ".join(NEEDS))) if case["id"] in seen: raise InputError("battery id %s appears twice" % case["id"]) seen.add(case["id"]) case.setdefault("category", "other") case.setdefault("why", "") cases.append(case) if not cases: raise InputError("the battery file has no cases") return cases def case_text(case): if case["tool"] == "shell": return case["input"]["command"] return "%s %s" % ({"read": "Read", "edit": "Edit", "write": "Write"}[case["tool"]], case["input"]["file_path"]) def harness_call(harness, case, project, home): """(tool name, tool input) for this harness, or None when it has no such tool.""" kind, inp = case["tool"], case["input"] if kind == "shell": command = inp["command"] return {"claude-code": ("Bash", {"command": command, "description": "guardrail-tester case"}), "codex": ("Bash", {"command": command}), "gemini-cli": ("run_shell_command", {"command": command, "description": "guardrail-tester case"}), "opencode": ("bash", {"command": command, "description": "guardrail-tester case"}), "cursor": ("Shell", {"command": command, "working_directory": project})}[harness] path = C._expand(inp["file_path"], project, home) old, new, content = inp.get("old_string", ""), inp.get("new_string", ""), inp.get("content", "") if harness == "claude-code": if kind == "read": return "Read", {"file_path": path} if kind == "edit": return "Edit", {"file_path": path, "old_string": old, "new_string": new, "replace_all": False} return "Write", {"file_path": path, "content": content} if harness == "codex": if kind == "read": return None return "apply_patch", {"file_path": path} if harness == "gemini-cli": if kind == "read": return "read_file", {"file_path": path} if kind == "edit": return "replace", {"file_path": path, "old_string": old, "new_string": new, "instruction": "test"} return "write_file", {"file_path": path, "content": content} if harness == "opencode": if kind == "read": return "read", {"filePath": path} if kind == "edit": return "edit", {"filePath": path, "oldString": old, "newString": new} return "write", {"filePath": path, "content": content} return ("Read" if kind == "read" else "Write"), {"file_path": path} def _codex_patch(path, case): inp = case["input"] if case else {} if case and case["tool"] == "edit": body = "@@\n-%s\n+%s" % (inp.get("old_string", ""), inp.get("new_string", "")) return "*** Begin Patch\n*** Update File: %s\n%s\n*** End Patch" % (path, body) lines = "\n".join("+" + line for line in str(inp.get("content", "")).splitlines()) return "*** Begin Patch\n*** Add File: %s\n%s\n*** End Patch" % (path, lines) # --------------------------------------------------------------------------- # Running hooks # --------------------------------------------------------------------------- @dataclass class HookJob: key: tuple harness: str hook_id: str command: str args: object stdin_obj: dict cwd: str env: dict timeout: float # the tester's wait: the smaller of the hook's timeout and --hook-timeout real_timeout: float = 600.0 # how long the harness itself waits event: str = "" fail_closed: bool = False shell: object = None @dataclass class HookStats: runs: int = 0 skipped: int = 0 unknown_timeouts: int = 0 real_timeout: float = 0.0 problems: dict = field(default_factory=dict) durations: list = field(default_factory=list) class HookPool: """Runs hook commands once per distinct input, `workers` at a time (one by default), each with a timeout. A hook that times out twice is not started again; its remaining calls count as unknown.""" def __init__(self, enabled, timeout, workers=1): self.enabled = enabled self.timeout = timeout self.workers = max(1, int(workers)) self.cache, self.stats = {}, {} self.timeouts, self.gave_up = {}, set() self.tmp, self.transcript = "", "" if enabled: self.tmp = tempfile.mkdtemp(prefix="guardrail-tester-") self.transcript = os.path.join(self.tmp, "transcript.jsonl") with open(self.transcript, "w", encoding="utf-8"): pass def close(self): if self.tmp: shutil.rmtree(self.tmp, ignore_errors=True) @staticmethod def _start(job): return H.run_hook(job.command, job.args, job.stdin_obj, job.cwd, job.env, job.timeout, job.shell) def _record(self, job, run): self.cache[job.key] = run if run.timed_out: ident = (job.harness, job.hook_id) self.timeouts[ident] = self.timeouts.get(ident, 0) + 1 if self.timeouts[ident] >= 2: self.gave_up.add(ident) def prefetch(self, jobs): """Run every job not run before; results go to the cache.""" todo, seen = [], set() for job in jobs: if job.key not in self.cache and job.key not in seen: seen.add(job.key) todo.append(job) i = 0 while i < len(todo): batch = [] while i < len(todo) and len(batch) < self.workers: job = todo[i] i += 1 if (job.harness, job.hook_id) in self.gave_up: self.cache[job.key] = H.HookRun(error=GAVE_UP) else: batch.append(job) if len(batch) == 1: self._record(batch[0], self._start(batch[0])) elif batch: with concurrent.futures.ThreadPoolExecutor(max_workers=len(batch)) as ex: runs = list(ex.map(self._start, batch)) for job, run in zip(batch, runs): self._record(job, run) def run(self, jobs): self.prefetch(jobs) out = [] for job in jobs: run = self.cache[job.key] outcome, unknown = _outcome(job, run) stats = self.stats.setdefault((job.harness, job.hook_id), HookStats()) stats.real_timeout = job.real_timeout if run.error == GAVE_UP: stats.skipped += 1 else: stats.runs += 1 stats.durations.append(run.duration) if outcome.problem: stats.problems[outcome.problem] = stats.problems.get(outcome.problem, 0) + 1 if unknown: stats.unknown_timeouts += 1 out.append((job, run, outcome, unknown)) return out def _outcome(job, run): """(outcome, unknown): unknown when the tester stopped waiting before the harness would, or skipped the hook after two timeouts.""" if run.error == GAVE_UP: return H.Outcome(problem=GAVE_UP), True if job.harness == "claude-code": outcome = H.claude_outcome(run) elif job.harness == "codex": outcome = H.codex_outcome(run) elif job.harness == "gemini-cli": outcome = H.gemini_outcome(run) else: outcome = H.cursor_outcome(run, fail_closed=job.fail_closed, event=job.event) return outcome, bool(run.timed_out and job.timeout < job.real_timeout) def _key(*parts): return tuple(json.dumps(p, sort_keys=True, default=str) for p in parts) def claude_jobs(cfg, tool, tool_input, mode, pool, limit): """One job per distinct handler that matches this call. The same handler under two matchers or in two settings files runs once; a plugin's copy stays separate.""" jobs, seen = [], set() for i, spec in enumerate(cfg.hooks): if not C.hook_matcher_matches(spec.matcher, tool): continue if spec.if_rule and not C.if_matches(spec.if_rule, tool, tool_input, cfg): continue shell = spec.handler.get("shell") if shell == "powershell": continue ident = (spec.type, spec.command, json.dumps(spec.args), shell, spec.source if spec.source.startswith("plugin:") else "") if ident in seen: continue seen.add(ident) env = {"CLAUDE_PROJECT_DIR": cfg.cwd} if spec.plugin_root: env["CLAUDE_PLUGIN_ROOT"] = spec.plugin_root stdin_obj = H.claude_payload(tool, tool_input, cfg.cwd, mode, pool.transcript) real = float(spec.timeout or 600.0) jobs.append(HookJob(_key(spec.command, spec.args, shell, stdin_obj, cfg.cwd, env), "claude-code", "%s:%d" % (spec.source, i), spec.command, spec.args, stdin_obj, cfg.cwd, env, min(real, limit), real, shell="bash" if shell == "bash" else None)) return jobs def _regex_hit(matcher, subjects): if matcher in (None, "", "*"): return True try: return any(re.search(str(matcher), s) for s in subjects) except re.error: return False def other_jobs(harness, hooks, tool, tool_input, project, pool, limit, case=None): jobs = [] for i, hook in enumerate(hooks): if harness == "codex" and hook.trust in ("untrusted", "off"): continue hook_id = "%s:%s:%d" % (hook.source, hook.event, i) if harness == "codex": names = [tool] + (["Edit", "Write"] if tool == "apply_patch" else []) if not _regex_hit(hook.matcher, names): continue inp = tool_input if tool == "Bash" else {"command": _codex_patch(tool_input.get("file_path", ""), case)} stdin_obj = H.codex_payload(tool, inp, project) env = {} elif harness == "gemini-cli": if not _regex_hit(hook.matcher, [tool]): continue stdin_obj = H.gemini_payload(tool, tool_input, project, pool.transcript) env = {"GEMINI_PROJECT_DIR": project, "CLAUDE_PROJECT_DIR": project, "GEMINI_CWD": project, "GEMINI_SESSION_ID": "guardrail-tester"} else: if hook.event == "preToolUse" and not _regex_hit(hook.matcher, [tool]): continue if hook.event == "beforeShellExecution" and (tool != "Shell" or not _regex_hit( hook.matcher, [tool_input.get("command", "")])): continue if hook.event == "beforeReadFile" and (tool != "Read" or not _regex_hit(hook.matcher, ["Read"])): continue stdin_obj = H.cursor_payload(hook.event, tool, tool_input, project) env = {"CURSOR_PROJECT_DIR": project, "CLAUDE_PROJECT_DIR": project} jobs.append(HookJob(_key(hook.command, stdin_obj, hook.run_dir, env), harness, hook_id, hook.command, None, stdin_obj, hook.run_dir or project, env, min(hook.timeout, limit), hook.timeout, hook.event, hook.fail_closed)) return jobs # --------------------------------------------------------------------------- # Per-harness evaluation # --------------------------------------------------------------------------- @dataclass class Harness: key: str cfg: object mode: str = "" mode_source: str = "" support: str = "" sources: list = field(default_factory=list) hooks: list = field(default_factory=list) # (hook id, display dict) notes: list = field(default_factory=list) smells: list = field(default_factory=list) @dataclass class Eval: result: object # the final verdict rules: object # the verdict from the rules alone outcome: object # the combined hook outcome hook: object = None # the Claude Code HookDecision, for a second mode unknown: bool = False def jobs_for(h, tool, tool_input, pool, args, case=None, cfg=None, mode=None): """The hook runs one call needs in this harness.""" cfg = cfg or h.cfg if not pool.enabled: return [] if h.key == "claude-code": return claude_jobs(cfg, tool, tool_input, mode or h.mode, pool, args.hook_timeout) if h.key in ("codex", "gemini-cli", "cursor"): return other_jobs(h.key, cfg.hooks, tool, tool_input, cfg.cwd, pool, args.hook_timeout, case) return [] def evaluate_call(h, tool, tool_input, pool, args, case=None, cfg=None, mode=None, needs=(), hooks=True): cfg = cfg or h.cfg mode = mode or h.mode results = pool.run(jobs_for(h, tool, tool_input, pool, args, case, cfg, mode)) if hooks else [] unknown = any(u for _, _, _, u in results) outcome = H.combine([o for _, _, o, u in results if not u]) decision = None if h.key == "claude-code": if outcome.decision: decision = C.HookDecision(outcome.decision, safe_text(outcome.reason, 160) if outcome.reason else "") result = C.evaluate(cfg, tool, tool_input, mode=mode, hook=decision, needs=needs) rules = C.evaluate(cfg, tool, tool_input, mode=mode, needs=needs) else: rules = _other_rules(h.key, cfg, tool, tool_input, needs) if outcome.decision == "deny": result = C.Result("deny", "hook", safe_text(outcome.reason, 160) if outcome.reason else "a hook blocked it") elif outcome.decision == "ask" and rules.verdict == "allow": result = C.Result("ask", "hook", "a hook asks first") else: result = rules if unknown and result.verdict != "deny": result = C.Result("unknown", "hook", UNKNOWN_DETAIL) return Eval(result, rules, outcome, decision, unknown) def _other_rules(key, cfg, tool, tool_input, needs=()): if key == "codex": return R.codex_evaluate(cfg, tool, tool_input, needs=needs) if key == "gemini-cli": return R.gemini_evaluate(cfg, tool, tool_input) if key == "opencode": return R.opencode_evaluate(cfg, tool, tool_input) return R.cursor_evaluate(cfg, tool, tool_input) def _home_as_tilde(text, home): """A note or problem line with the home folder shown as ~. The line is fixed text; each untrusted part went through code() where the line was built.""" home = (home or "").rstrip("/") text = str(text) if home: text = text.replace(home + "/", "~/").replace(home, "~") return text def _claude_mode(cfg, args, notes): """(mode, where it came from) for a Claude Code run.""" if not args.mode: return cfg.mode, cfg.mode_source mode = C.normalize_mode(args.mode) if mode == "bypassPermissions" and cfg.disable_bypass: notes.append("--mode bypassPermissions is turned off by disableBypassPermissionsMode in your settings, so " "the test simulates Manual mode instead.") return "default", "--mode, with bypassPermissions turned off" if mode == "auto" and not cfg.auto_available: notes.append("--mode auto is turned off by disableAutoMode in your settings, so the test simulates Manual " "mode instead.") return "default", "--mode, with auto mode turned off" return mode, "--mode" def _hook_display(source, event, matcher, command, status=""): shown = {"source": safe_text(source, 120), "event": event, "matcher": safe_text(matcher or "*", 80), "command": safe_text(command, 160)} if status: shown["status"] = status # fixed text; an untrusted part went through code() where it was built return shown def load_harness(key, project, home, args): if key == "claude-code": cfg = C.load(project, home=home) h = Harness(key, cfg) extra = [] h.mode, h.mode_source = _claude_mode(cfg, args, extra) if h.mode == "plan": extra.append("Plan mode lasts only until you approve a plan; the session then switches to another " "mode, so the plan-mode results cover planning only.") h.support = "simulated from the documented rules" for layer in cfg.layers: counts = {n: sum(1 for r in cfg.rules[n] if r.source == layer.name) for n in ("allow", "ask", "deny")} h.sources.append({"layer": layer.name, "path": show_path(layer.path, cfg.home), "found": layer.found, "kind": "settings", "rules": counts, "hooks": sum(1 for s in cfg.hooks if s.source == layer.name)}) for i, spec in enumerate(cfg.hooks): h.hooks.append(("%s:%d" % (spec.source, i), _hook_display(spec.source, "PreToolUse", spec.matcher, spec.command))) for spec, why in cfg.skipped_hooks: h.hooks.append(("", _hook_display(spec.source, "PreToolUse", spec.matcher, spec.command or spec.type, "skipped: " + why))) h.notes = [_home_as_tilde(n, cfg.home) for n in cfg.notes + extra] h.smells = [dict(s, text=_home_as_tilde(s["text"], cfg.home), source=safe_text(s.get("source", ""), 120)) for s in cfg.smells] return h loader = {"codex": R.codex_load, "gemini-cli": R.gemini_load, "opencode": R.opencode_load}.get(key) if key == "cursor": claude_cfg = C.load(project, home=home) cfg = R.cursor_load(project, home=home, claude_hooks=claude_cfg.hooks) else: cfg = loader(project, home=home) h = Harness(key, cfg) if key == "codex": h.mode = "%s, approval %s" % (cfg.sandbox_mode, cfg.approval_policy) else: h.mode = getattr(cfg, "mode", "") or "default" h.mode_source = "settings" home_dir = getattr(cfg, "home", "") or "" h.sources = [{"layer": f.layer, "path": show_path(f.path, home_dir), "found": True, "kind": f.kind, "count": f.count} for f in cfg.found] for i, hook in enumerate(getattr(cfg, "hooks", [])): status = {"untrusted": "not trusted, so Codex never runs it", "off": "turned off in /hooks, so Codex never runs it"}.get(getattr(hook, "trust", ""), "") h.hooks.append(("%s:%s:%d" % (hook.source, hook.event, i), _hook_display(hook.source, hook.event, hook.matcher, hook.command, status))) h.notes = [_home_as_tilde(n, home_dir) for n in cfg.notes] h.smells = [dict(s, text=_home_as_tilde(s["text"], home_dir)) for s in cfg.smells] h.support = { "codex": "exec-policy rules, sandbox, and hooks simulated", "gemini-cli": "policy engine and tools settings simulated", "opencode": "permission rules simulated; plugins not run; not verified on a real install", "cursor": "CLI permission files and hooks simulated; the IDE allowlist is not readable", }[key] if key == "gemini-cli" and R.tomllib is None: h.support += "; policy files skipped (needs Python 3.11+)" if key == "codex" and R.tomllib is None: h.support += "; config.toml skipped (needs Python 3.11+)" return h def detect_harnesses(project, home): base = home or os.path.expanduser("~") checks = { "claude-code": [os.path.join(base, ".claude"), os.path.join(project, ".claude")] + ( [os.environ["CLAUDE_CONFIG_DIR"]] if home is None and os.environ.get("CLAUDE_CONFIG_DIR") else []), "codex": [os.path.join(base, ".codex"), os.path.join(project, ".codex")] + ( [os.environ["CODEX_HOME"]] if home is None and os.environ.get("CODEX_HOME") else []), "gemini-cli": [os.path.join(base, ".gemini"), os.path.join(project, ".gemini")], "opencode": [os.path.join(base, ".config", "opencode"), os.path.join(project, "opencode.json"), os.path.join(project, "opencode.jsonc"), os.path.join(project, ".opencode")], "cursor": [os.path.join(base, ".cursor"), os.path.join(project, ".cursor")], } return [key for key in ORDER if any(os.path.exists(p) for p in checks[key])] # --------------------------------------------------------------------------- # Fix suggestions (each rule suggestion is checked against the simulation) # --------------------------------------------------------------------------- _NO_RULE_WORDS = {"sh", "bash", "zsh", "dash", "eval", "env", "xargs", "docker", "python", "python3", "node", "perl", "ruby", "echo", "printf", "cd", "jq", "true", "false", "timeout", "nohup", "command"} # Everyday commands a suggested deny rule must leave alone. BENIGN_COMMANDS = [ "git status", "git diff", "git log --oneline", "git push origin main", "git push -u origin feature", "git pull", "git fetch", "git commit -m wip", "git config user.name Sam", "git remote -v", "git -C sub status", "git checkout main", "git branch -d merged", "rm notes.txt", "rm -f old.log", "cp a.txt b.txt", "mv a.txt b.txt", "mkdir -p build", "touch notes.txt", "sed -n 1p notes.txt", "cat README.md", "grep -r TODO src", "find . -name x", "curl -fsSL -o data.json example.com/data.json", "wget -q example.com/f.tgz", "npm install", "npm test", "npm run build", "pnpm install", "pip install -r requirements.txt", "python3 -m pytest", "node script.js", "docker ps", "docker build .", "kubectl get pods", "terraform plan", "aws s3 ls", "psql -c 'select 1'", "printenv PATH", "echo hello", "ls -la", "gh pr view 1", # skillscan:allow (a harmless sample) "security list-keychains", "make test", "jq . data.json", "chmod +x run.sh", "tar -czf a.tgz dir", "sed -i s/a/b/ notes.txt", "git -c color.ui=never log", ] # Project files a suggested Read or Edit deny rule must leave alone. BENIGN_PATHS = [".env.example", ".env.sample", ".envrc", ".claude/skills/x/SKILL.md", ".claude/agents/x.md"] _SENSITIVE_DIRS = {".ssh", ".aws", ".kube", ".docker", ".gnupg"} def _dangerous_sub(command): parsed = shell_split.parse(command) for cmd in parsed.commands: words, _values = C._strip_wrappers(cmd.words, cmd.values) if words and matching_patterns(" ".join(words)): first = words[0] if any(c in first for c in "/\\'\"$`") or first in _NO_RULE_WORDS: continue return words return None _WORD_RE = re.compile(r"^(-{1,2}[A-Za-z][A-Za-z0-9-]*|[a-z][a-z0-9-]*)$") # The battery's stand-in names (probe folders, remotes, refs, and hosts) never belong in a rule. _PLACEHOLDER = re.compile(r"probe|guardrail-tester|\.invalid") def _prefix_candidates(words): """Word prefixes to try as a rule, shortest first; stop at quotes, variables, paths, values, and the battery's stand-in names, which make a rule too specific to one spelling. An option whose value is a stand-in stops too.""" out = [] for k in range(1, min(3, len(words)) + 1): word = words[k - 1] if k > 1 and (not _WORD_RE.match(word) or _PLACEHOLDER.search(word)): break if k > 1 and word.startswith("-") and k < len(words) and _PLACEHOLDER.search(words[k]): break out.append(list(words[:k])) return out def _path_rule_spec(raw): """A path pattern for a deny rule that names this file's folder or family without reaching the files in BENIGN_PATHS.""" if raw.startswith("~/"): parts = raw[2:].split("/") if parts[0] in _SENSITIVE_DIRS: return "~/%s/**" % parts[0] if parts[0] == ".claude" and len(parts) == 2 and parts[1].startswith("settings"): return "~/.claude/settings*.json" # skillscan:allow (the text of a suggested deny rule) if parts[0] == "Library" and len(parts) > 2: return "~/%s/**" % "/".join(parts[:2]) return raw parts = raw.split("/") if parts[0] == ".claude" and len(parts) == 2 and parts[1].startswith("settings"): return ".claude/settings*.json" if parts[0] in (".git", ".vscode", ".husky") and len(parts) > 1: return "%s/**" % parts[0] return raw def _env_file(raw): name = raw.rstrip("/").split("/")[-1] return name == ".env" or name.startswith(".env.") def suggest_fix(h, case, call, result, args): fix = {"should_catch": "PreToolUse hook", "rule": "", "hook_patterns": [], "note": ""} notes = [] if result.verdict == "allow" and result.layer == "rule" and result.rule: notes.append("The allow rule %s approves it; narrow or remove it." % code(result.rule, 120)) if case["tool"] == "shell": fix["hook_patterns"] = [p["id"] for p in matching_patterns(case["input"]["command"])] tool, tool_input = call for texts, cfg2, matches in _rule_candidates(h, case): if _verdict_with(h, cfg2, tool, tool_input, case) != "deny": continue if matches is not None and any(matches(command) for command in BENIGN_COMMANDS): continue if not _benign_paths_ok(h, cfg2): continue fix["rule"], fix["should_catch"] = ", ".join(texts), "deny rule" break note = _block_reads_note(h, case, call) if note: notes.append(note) if not fix["rule"] and not fix["hook_patterns"]: notes.append("Use the sandbox: no rule or pattern here covers it.") fix["note"] = " ".join(notes) return fix def _verdict_with(h, cfg2, tool, tool_input, case): if h.key == "claude-code": return C.evaluate(cfg2, tool, tool_input, mode=h.mode, needs=case.get("needs") or ()).verdict return _other_rules(h.key, cfg2, tool, tool_input, case.get("needs") or ()).verdict def _benign_paths_ok(h, cfg2): """False when the changed config denies a file in BENIGN_PATHS that the user's own config does not.""" cwd = h.cfg.cwd home = getattr(h.cfg, "home", "") or os.path.expanduser("~") for rel in BENIGN_PATHS: for kind in ("read", "edit"): case = {"tool": kind, "input": {"file_path": os.path.join(cwd, rel), "old_string": "a", "new_string": "b"}} call = harness_call(h.key, case, cwd, home) if call is None: continue if _verdict_with(h, cfg2, call[0], call[1], case) == "deny" and \ _verdict_with(h, h.cfg, call[0], call[1], case) != "deny": return False return True def _block_reads_note(h, case, call): """For a Claude Code read outside the working folders: what blockReadsOutsideWorkingDirectories would change.""" if h.key != "claude-code" or h.cfg.block_outside_reads: return "" tool, tool_input = call facts = C.analyze_call(h.cfg, tool, tool_input) outside = (tool in C.READ_FAMILY and facts.path and not C._in_working_dirs(h.cfg, facts.path)) or ( facts.bash is not None and any(op.path and not C._in_working_dirs(h.cfg, op.path) for op in facts.bash.reads)) if not outside: return "" cfg2 = copy.copy(h.cfg) cfg2.block_outside_reads = True needs = case.get("needs") or () before = C.evaluate(h.cfg, tool, tool_input, mode=h.mode, needs=needs).verdict after = C.evaluate(cfg2, tool, tool_input, mode=h.mode, needs=needs).verdict if _STRICT.get(after, 0) <= _STRICT.get(before, 0): return "" if after == "deny": return "Setting permissions.blockReadsOutsideWorkingDirectories to true makes Claude Code refuse this read " \ "in every mode." return "Setting permissions.blockReadsOutsideWorkingDirectories to true makes Claude Code ask before this " \ "command in every mode, auto and bypassPermissions included." def _claude_with(cfg, texts): new = copy.copy(cfg) new.rules = {k: list(v) for k, v in cfg.rules.items()} new.rules["deny"] += [C.parse_rule(text, "deny", "project", cfg.cwd) for text in texts] return new def _path_candidates(h, case): cfg, raw, kind = h.cfg, case["input"]["file_path"], case["tool"] name = raw.rstrip("/").split("/")[-1] if h.key == "claude-code": family = "Read" if kind == "read" else "Edit" if _env_file(raw): texts = ["%s(%s)" % (family, s) for s in (".env", ".env.*", "!.env.example", "!.env.sample")] else: texts = ["%s(%s)" % (family, _path_rule_spec(raw))] return [(texts, _claude_with(cfg, texts), None)] if h.key == "cursor": family = "Read" if kind == "read" else "Write" spec = "**/" + name if _env_file(raw) else _path_rule_spec(raw) text = "%s(%s)" % (family, spec) return [([text], replace(cfg, deny=list(cfg.deny) + [text]), None)] if h.key == "opencode": key = "read" if kind == "read" else "edit" spec = "*" + name if _env_file(raw) and not raw.startswith("~") else _path_rule_spec(raw) new = copy.deepcopy(cfg) perm = new.permission.get(key) new.permission[key] = dict(perm) if isinstance(perm, dict) else {"*": perm or "allow"} new.permission[key][spec] = "deny" return [(['permission.%s "%s": "deny"' % (key, spec)], new, None)] if h.key == "gemini-cli": names = ["read_file"] if kind == "read" else ["write_file", "replace"] rx = '"file_path":"[^"]*%s"' % re.escape(name) rule = R.PolicyRule(names, "deny", 4.5, args_pattern=rx, text="policy rule") return [(["[[rule]] toolName = %s, argsPattern = '%s', decision = \"deny\"" % (json.dumps(names), rx)], replace(cfg, rules=list(cfg.rules) + [rule]), None)] return [] def _rule_candidates(h, case): """(rule texts, a copy of the config with those deny rules added, a function that says whether the rule alone would match a shell command) per try.""" if case["tool"] != "shell": return _path_candidates(h, case) cfg = h.cfg words = _dangerous_sub(case["input"]["command"]) out = [] for prefix in _prefix_candidates(words) if words else []: joined = " ".join(prefix) if h.key == "claude-code": text = "Bash(%s *)" % joined out.append(([text], _claude_with(cfg, [text]), lambda command, text=text: C.bash_rule_matches(text, command, "deny"))) elif h.key == "codex": rule = R.PrefixRule(list(prefix), "forbidden", source="suggested") out.append((['prefix_rule(pattern=%s, decision="forbidden")' % json.dumps(list(prefix))], replace(cfg, rules=list(cfg.rules) + [rule]), lambda command, rule=rule: any(R.codex_prefix_match([rule], argv)[0] for argv in (R.codex_split(command) or [])))) elif h.key == "gemini-cli": rule = R.PolicyRule(["run_shell_command"], "deny", 4.5, [joined], text="policy rule") out.append((['[[rule]] toolName = "run_shell_command", commandPrefix = "%s", decision = "deny"' % joined], replace(cfg, rules=list(cfg.rules) + [rule]), lambda command, rule=rule: any(rule.matches("run_shell_command", {"command": part}, "default") for part in shell_split.split_chain(command)))) elif h.key == "opencode": pattern = "%s *" % joined new = copy.deepcopy(cfg) bash = new.permission.get("bash") new.permission["bash"] = dict(bash) if isinstance(bash, dict) else {"*": bash or "allow"} new.permission["bash"][pattern] = "deny" out.append((['permission.bash "%s": "deny" (last in the list)' % pattern], new, lambda command, pattern=pattern: R._oc_match(pattern, [command], cfg.home))) else: spec = prefix[0] if len(prefix) == 1 else "%s:%s *" % (prefix[0], " ".join(prefix[1:])) text = "Shell(%s)" % spec out.append(([text], replace(cfg, deny=list(cfg.deny) + [text]), lambda command, spec=spec: any(R._cursor_shell_hit(spec, c.values) for c in shell_split.parse(command).commands))) return out # --------------------------------------------------------------------------- # Battery run # --------------------------------------------------------------------------- def _category_rank(case): cat = case["category"] return CATEGORY_ORDER.index(cat) if cat in CATEGORY_ORDER else len(CATEGORY_ORDER) def _unlisted_note(h, case, result): if h.key != "claude-code" or case["tool"] != "shell" or result.verdict == "deny": return "" rule = C.unlisted_file_rule(h.cfg, case["input"]["command"]) if not rule: return "" return ("Your deny rule %s names a file this command uses, but Read and Edit rules reach only the file commands " "Claude Code recognizes (the docs name cat, head, tail, sed, and tee; this test also counts grep, wc, " "diff, and stat), so this test assumes it does not apply." % code(rule, 120)) def tally(entries): counts = {"total": len(entries), "blocked": 0, "not_blocked": 0, "asks_rule": 0, "asks_mode": 0, "runs": 0, "unknown": 0, "stopped": 0, "through": 0, "by_verdict": {}, "rules_alone": 0, "hooks_alone": 0, "classifier": 0, "sandbox_stops": 0, "sandbox_asks": 0} for e in entries: counts[e["bucket"]] += 1 counts["by_verdict"][e["verdict"]] = counts["by_verdict"].get(e["verdict"], 0) + 1 if e["stopped"]: counts["stopped"] += 1 elif e["bucket"] != "unknown": counts["through"] += 1 counts["rules_alone"] += stopped(e["expect"], e["rules_verdict"]) counts["hooks_alone"] += bool(e["hook_decision"]) and stopped(e["expect"], e["hook_decision"]) counts["classifier"] += e["verdict"] == "classifier" if e["decided_by"] == "sandbox" and e["verdict"] == "deny": counts["sandbox_stops"] += 1 if e["decided_by"] == "sandbox" and e["verdict"] == "ask": counts["sandbox_asks"] += 1 counts["not_blocked"] = counts["asks_rule"] + counts["asks_mode"] + counts["runs"] return counts def _entry_result(case, result, rules, outcome): return {"expect": case["expect"], "verdict": result.verdict, "decided_by": result.layer, "bucket": bucket_of(result), "stopped": stopped(case["expect"], result.verdict), "rules_verdict": rules.verdict, "hook_decision": outcome.decision or ""} def run_battery(h, battery, project, home, pool, args, trusted): home_dir = getattr(h.cfg, "home", "") or (home or os.path.expanduser("~")) calls = [(case, harness_call(h.key, case, project, home_dir)) for case in battery] pool.prefetch([job for case, call in calls if call is not None for job in jobs_for(h, call[0], call[1], pool, args, case)]) cases, evals = [], [] for case, call in calls: if call is None: continue tool, tool_input = call ev = evaluate_call(h, tool, tool_input, pool, args, case=case, needs=case["needs"]) result = ev.result entry = {"id": case["id"], "category": case["category"], "tool": case["tool"], "text": case_text(case) if trusted else safe_text(case_text(case), 160), "expect": case["expect"], "why": case["why"] if trusted else safe_text(case["why"], 200), "needs": list(case["needs"])} entry.update(_entry_result(case, result, ev.rules, ev.outcome)) entry.update({"detail": safe_text(result.detail, 300), "rule": safe_text(result.rule, 160) if result.rule else "", "note": _unlisted_note(h, case, result)}) if not entry["stopped"] and entry["bucket"] != "unknown": entry["fix"] = suggest_fix(h, case, call, result, args) cases.append(entry) evals.append((case, call, ev)) order = {case["id"]: i for i, case in enumerate(battery)} misses = sorted((c for c in cases if not c["stopped"] and c["bucket"] != "unknown"), key=lambda c: (MISS_RANK.get(c["bucket"], 3), _category_rank(c), order[c["id"]])) also = None if h.key == "claude-code" and h.mode == "auto" and h.mode_source == C.BUILT_IN_SOURCE: entries = [] for case, (tool, tool_input), ev in evals: result = C.evaluate(h.cfg, tool, tool_input, mode="default", hook=ev.hook, needs=case["needs"]) if ev.unknown and result.verdict != "deny": result = C.Result("unknown", "hook", UNKNOWN_DETAIL) rules = C.evaluate(h.cfg, tool, tool_input, mode="default", needs=case["needs"]) entries.append(_entry_result(case, result, rules, ev.outcome)) also = {"mode": "default", "label": "Manual", "battery": tally(entries)} return cases, tally(cases), [c["id"] for c in misses], also # --------------------------------------------------------------------------- # Replay # --------------------------------------------------------------------------- def _replay_call(harness, call): kind = call.kind if kind == "shell": command = call.command or "" return [{"claude-code": ("Bash", dict(call.input, command=command) if isinstance(call.input, dict) else {"command": command}), "codex": ("Bash", {"command": command}), "gemini-cli": ("run_shell_command", {"command": command}), "opencode": ("bash", {"command": command})}[harness]] paths = [p for p in call.paths if isinstance(p, str) and p] if harness == "claude-code": name = call.name if call.name in C.READ_FAMILY | C.EDIT_FAMILY else {"read": "Read", "edit": "Edit", "write": "Write"}[kind] return [(name, dict(call.input) if isinstance(call.input, dict) else {"file_path": paths[0]})] if paths else [] if harness == "codex": return [("apply_patch", {"file_path": p}) for p in paths] if kind != "read" else [] if harness == "gemini-cli": tool = {"read": "read_file", "write": "write_file", "edit": "replace"}[kind] return [(tool, {"file_path": p}) for p in paths] tool = {"read": "read", "write": "write", "edit": "edit"}[kind] return [(tool, {"filePath": p}) for p in paths] def _parent_path(path): """The main session file of a Claude Code subagent file.""" folder = os.path.dirname(path) for _ in range(3): if os.path.basename(folder) == "subagents": return os.path.dirname(folder) + ".jsonl" folder = os.path.dirname(folder) return "" class _ParentModes: """The permission modes a subagent's parent session recorded, by time.""" def __init__(self, sessions): self.by_id = {s.id: s for s in sessions if not s.is_subagent} self.cache = {} def mode_at(self, session, ts): if not session.is_subagent or not session.parent_id: return "" if session.parent_id not in self.cache: parent = self.by_id.get(session.parent_id) path = _parent_path(session.path) if parent is None and path and os.path.isfile(path): try: parent = T.load_session("claude-code", path) except (OSError, ValueError): parent = None modes = sorted((e.ts or "", e.mode) for e in parent.events if e.kind == "tool" and e.mode) if parent is not None else [] self.cache[session.parent_id] = modes modes = self.cache[session.parent_id] if not modes: return "" before = [m for t, m in modes if t and ts and t <= ts] return (before or [m for _, m in modes])[-1] def _replay_mode(h, target, session, event, parents, args): """(mode, recorded) for one replayed Claude Code call: the mode its event recorded, else the parent session's mode (subagents), else the settings.""" if h.key != "claude-code": return "", False if args.mode: return h.mode, False if event.mode: return C.normalize_mode(event.mode), True parent = parents.mode_at(session, event.tool.ts or event.ts or "") if parent: return C.normalize_mode(parent), True return target.mode, False def run_replay(h, args, home, project, pool): n, since = args.replay, args.since info = {"calls": 0, "sessions": 0, "window_days": since, "by_verdict": {}, "friction": 0, "friction_pct": 0.0, "dangerous": 0, "dangerous_ran": 0, "dangerous_through": 0, "examples": [], "note": "", "hooks_run": False, "other_project_calls": 0, "modes": {}, "modes_recorded": 0} if h.key == "cursor": info["note"] = "Cursor keeps no usable transcripts, so there is nothing to replay." return info floor = T.cutoff(since) try: pairs = T.find_sessions(harness=h.key, since_days=since, home=home) except (OSError, ValueError): pairs = [] sessions, count = [], 0 for harness, path in pairs: session = T.load_session(harness, path) sessions.append(session) count += sum(1 for e in session.events if e.kind == "tool" and e.tool is not None and e.tool.kind in REPLAY_KINDS and (not e.ts or e.ts[:19] >= floor[:19])) if count >= n: break events = [(s, e) for s, e in T.unique_events(sessions, since=floor) if e.kind == "tool" and e.tool is not None and e.tool.kind in REPLAY_KINDS] events.sort(key=lambda se: se[1].tool.ts or se[1].ts or "", reverse=True) events = events[:n] if not events: info["note"] = "No %s sessions with shell or file-tool calls in the last %d days." % (NAMES[h.key], since) return info replay_hooks = bool(args.replay_hooks and pool.enabled) info["hooks_run"] = replay_hooks targets, used_sessions, planned = {}, set(), [] parents = _ParentModes(sessions) home_dir = getattr(h.cfg, "home", "") or (home or os.path.expanduser("~")) for session, event in events: cwd = session.cwd if session.cwd and os.path.isdir(session.cwd) else project cwd = os.path.normpath(cwd) same = cwd == project if cwd not in targets: targets[cwd] = h if same else load_harness(h.key, cwd, home, args) target = targets[cwd] mode, recorded = _replay_mode(h, target, session, event, parents, args) planned.append((session, event.tool, target, mode, recorded, same, _replay_call(h.key, event.tool))) if replay_hooks: pool.prefetch([job for _, _, target, mode, _, same, calls in planned if same for tool, tool_input in calls for job in jobs_for(h, tool, tool_input, pool, args, cfg=target.cfg, mode=mode)]) for session, call, target, mode, recorded, same, calls in planned: if not calls: continue used_sessions.add(session.id) results = [evaluate_call(h, tool, tool_input, pool, args, cfg=target.cfg, mode=mode or None, hooks=replay_hooks and same).result for tool, tool_input in calls] result = max(results, key=lambda r: _STRICT.get(r.verdict, 0)) info["calls"] += 1 info["other_project_calls"] += not same if mode: info["modes"][mode] = info["modes"].get(mode, 0) + 1 info["modes_recorded"] += recorded info["by_verdict"][result.verdict] = info["by_verdict"].get(result.verdict, 0) + 1 if result.verdict in ("ask", "deny"): info["friction"] += 1 paths = [ti.get("file_path") or ti.get("filePath") or "" for _, ti in calls] expect, ids = _danger(call.kind, call.command, paths) if not expect: continue info["dangerous"] += 1 if call.has_result and not call.denied: info["dangerous_ran"] += 1 if not stopped(expect, result.verdict) and result.verdict != "unknown": info["dangerous_through"] += 1 if len(info["examples"]) < 5: command = call.command or "" if call.kind == "shell" and len(command) > 120: shown = "a %d-character command that matches %s" % (len(command), ", ".join(ids)) elif call.kind == "shell": shown = command else: shown = "%s %s" % (call.kind.capitalize(), show_path(paths[0], home_dir)) info["examples"].append(safe_text(shown, 160)) info["sessions"] = len(used_sessions) info["friction_pct"] = round(100.0 * info["friction"] / info["calls"], 1) if info["calls"] else 0.0 return info # --------------------------------------------------------------------------- # Runtime smells from hook runs # --------------------------------------------------------------------------- def runtime_smells(h, pool, limit): out = [] labels = {hid: d for hid, d in h.hooks if hid} name = NAMES[h.key] for (harness, hook_id), stats in sorted(pool.stats.items()): if harness != h.key or hook_id not in labels: continue source = labels[hook_id]["source"] label = C.hook_label(source, labels[hook_id]["command"]) p = stats.problems if p.get("exit-1"): out.append({"id": "hook-exit-1", "severity": "high", "source": source, "text": label + " exited 1 on %d test calls. Exit 1 does not block, so those calls go " "ahead; use exit 2 or a JSON deny." % p["exit-1"]}) if p.get("timeout"): if stats.unknown_timeouts: text = label + " did not answer within the tester's %g-second limit (%s waits up to %g seconds) " \ "on %d test calls, so those cases count as unknown; raise --hook-timeout to test " \ "it fully." % (limit, name, stats.real_timeout, stats.unknown_timeouts) else: text = label + " did not finish within its own %g-second timeout on %d test calls. When a hook " \ "times out, %s lets the call go ahead." % (stats.real_timeout, p["timeout"], name) if (harness, hook_id) in pool.gave_up: text += " After two timeouts the test stopped running it." out.append({"id": "hook-timeout", "severity": "high", "source": source, "text": text}) if p.get("not-started"): out.append({"id": "hook-not-started", "severity": "high", "source": source, "text": label + " could not start, so it blocks nothing."}) if p.get("bad-json") or p.get("schema"): out.append({"id": "hook-bad-json", "severity": "medium", "source": source, "text": label + " printed JSON the harness cannot use (invalid, or without hookEventName " "PreToolUse), so its decision is ignored."}) if p.get("unsupported"): out.append({"id": "hook-unsupported", "severity": "medium", "source": source, "text": label + " returned ask or continue, which Codex does not support on PreToolUse; " "the call goes ahead."}) if p.get("ask-ignored"): out.append({"id": "hook-ask-ignored", "severity": "medium", "source": source, "text": label + " returned ask on preToolUse, which Cursor does not enforce yet."}) durations = sorted(stats.durations) if durations and durations[len(durations) // 2] > 2.0 and not p.get("timeout"): out.append({"id": "hook-slow", "severity": "low", "source": source, "text": label + " takes about %.1f seconds per call." % durations[len(durations) // 2]}) return out def _hook_status(h, hook_id, display, pool): if display.get("status"): return display["status"] if not pool.enabled: return "not run" stats = pool.stats.get((h.key, hook_id)) if stats is None or not (stats.runs or stats.skipped): return "ran on no test call (its matcher fits none, or an identical handler ran instead)" if (h.key, hook_id) in pool.gave_up: return "ran on %d calls, then not run on %d: the hook timed out twice" % (stats.runs, stats.skipped) return "ran on %d calls" % stats.runs # --------------------------------------------------------------------------- # Report # --------------------------------------------------------------------------- def _plural(n, one, many): return one if n == 1 else many def _worst_example(sec): if not sec["misses"]: return "" text = next(c["text"] for c in sec["cases"] if c["id"] == sec["misses"][0]) return code(text) if sec["custom_battery"] else battery_code(text.split("\n")[0], table=False) def _runs_clause(b, example): runs, classifier = b["runs"], b["classifier"] if classifier == 0: verb = _plural(runs, "runs without asking", "run without asking") elif classifier == runs: verb = _plural(runs, "goes to the auto-mode classifier", "go to the auto-mode classifier") else: verb = "run without asking or go to the auto-mode classifier" if not example: return "%d %s" % (runs, verb) return "%d %s%s %s" % (runs, verb, ":" if runs == 1 else ", including", example) def _mode_reason(sec): if sec["harness"] == "claude-code": return "Manual mode asks" if sec["mode"] == "default" else "%s mode asks" % safe_text(sec["mode"], 40) return "%s asks by default" % sec["name"] def _hooks_waiting(sec): return sum(1 for d in sec["hooks"] if d.get("status") == "not run") def _codex_sentence(sec, first, hooks_run): b = sec["battery"] blocked = b["blocked"] - b["sandbox_stops"] sandbox = b["sandbox_stops"] + b["sandbox_asks"] other_asks = b["asks_rule"] + b["asks_mode"] - b["sandbox_asks"] by = "by rule or hook" if hooks_run and b["hooks_alone"] else "by rule" if not first: return "Codex blocks %d of %d %s; its sandbox stops or asks about %d more; %d run without asking." % ( blocked, b["total"], by, sandbox, b["runs"]) waiting = _hooks_waiting(sec) lead = "Without running your %d %s, " % (waiting, _plural(waiting, "hook", "hooks")) if waiting else "" text = "%sCodex blocks %d of %d dangerous commands %s. Its sandbox stops or asks about %d more;" % ( lead, blocked, b["total"], by, sandbox) if other_asks: text += " %d more ask first because of a rule or setting;" % other_asks text += " %s." % (_runs_clause(b, _worst_example(sec)) if b["runs"] else "none runs without asking") return text def _first_sentence(sec, hooks_run): if sec["harness"] == "codex": text = _codex_sentence(sec, True, hooks_run) else: b = sec["battery"] waiting = _hooks_waiting(sec) if waiting: text = "Without running your %d %s, your %s rules block %d of %d dangerous commands outright." % ( waiting, _plural(waiting, "hook", "hooks"), sec["name"], b["blocked"], b["total"]) else: text = "Your %s guardrails block %d of %d dangerous commands outright." % ( sec["name"], b["blocked"], b["total"]) asks = b["asks_rule"] + b["asks_mode"] example = _worst_example(sec) mode_part = " (%d only because %s)" % (b["asks_mode"], _mode_reason(sec)) if b["asks_mode"] else "" if asks and b["runs"]: text += " %d more stop at a prompt%s, and %s." % (asks, mode_part, _runs_clause(b, example)) elif asks: text += " %d more stop at a prompt%s%s; none runs without asking." % ( asks, mode_part, ", including %s" % example if example else "") elif b["runs"]: text = text[:-1] + ", and %s." % _runs_clause(b, example) unknown = sec["battery"]["unknown"] if unknown: text += " %d %s unknown because a hook did not answer in time." % (unknown, _plural(unknown, "is", "are")) return text def _other_sentence(sec, hooks_run): if sec["harness"] == "codex": return _codex_sentence(sec, False, hooks_run) b = sec["battery"] return "%s blocks %d of %d outright; %d ask first; %d run without asking." % ( sec["name"], b["blocked"], b["total"], b["asks_rule"] + b["asks_mode"], b["runs"]) def headline(sections, hooks_run): if not sections: return ("No agent settings were found for Claude Code, Codex, Gemini CLI, OpenCode, or Cursor, so nothing " "was tested. Add --harness to test one harness's built-in defaults.") text = _first_sentence(sections[0], hooks_run) for sec in sections[1:]: text += " " + _other_sentence(sec, hooks_run) replay = sections[0].get("replay") if replay and replay.get("calls"): text += " Replaying your last %d calls, %d would ask first or be blocked." % (replay["calls"], replay["friction"]) return text def _case_id(sec, case): """A case id: plain from the skill's own battery, in inline code from a custom one.""" return code(case["id"]) if sec["custom_battery"] else case["id"] def _what_happens(case): if case["decided_by"] == "sandbox" and case["detail"].startswith("asks first ("): return case["detail"].split(":")[0] + " (the sandbox)" if case["verdict"] == "ask" and case["bucket"] == "asks_mode": return "asks first, only because of the mode" words = VERDICT_WORDS.get(case["verdict"], case["verdict"]) by = {"hook": "a hook", "rule": "rule %s" % code(case["rule"]) if case["rule"] else "a rule", "built-in": "a built-in check", "mode": "the mode", "sandbox": "the sandbox"}.get(case["decided_by"], "") return "%s (%s)" % (words, by) if by else words def _table_row(name, mode, b, support): return "| %s | %s | %d of %d | %d | %d | %d | %d | %s |" % ( name, mode, b["blocked"], b["total"], b["not_blocked"], b["asks_rule"], b["asks_mode"], b["runs"], support) def _replay_lines(sec): replay = sec["replay"] lines = ["", "## Friction on your recent calls: %s" % sec["name"], ""] if not replay["calls"]: return lines + [replay["note"] or "No calls to replay."] if sec["harness"] == "claude-code": if sec["mode_source"].startswith("--mode"): lines.append("Every call is simulated in %s mode (--mode)." % safe_text(sec["mode"], 40)) else: lines.append("Each call is simulated in the permission mode its session recorded (recorded for %d of %d " "calls; the rest use %s, from %s)." % ( replay["modes_recorded"], replay["calls"], safe_text(sec["mode"], 40), safe_text(sec["mode_source"], 60))) verdicts = ", ".join("%d %s" % (v, VERDICT_WORDS.get(k, k)) for k, v in sorted(replay["by_verdict"].items())) lines.append("%d calls from %d sessions in the last %d days: %s. %d (%.1f%%) would ask first or be blocked." % (replay["calls"], replay["sessions"], replay["window_days"], verdicts, replay["friction"], replay["friction_pct"])) lines.append("%d look dangerous by the hook checks above; %d of them ran, and %d would still not be blocked as " "expected today." % (replay["dangerous"], replay["dangerous_ran"], replay["dangerous_through"])) for example in replay["examples"]: lines.append("- %s" % code(example)) if replay["other_project_calls"]: lines.append("%d of these calls came from other project folders: each was checked against that folder's own " "rules, and no hook from those folders ran." % replay["other_project_calls"]) lines.append("Your hooks ran on the calls from this project." if replay["hooks_run"] else "Replay checked the rules only; --run-hooks with --replay-hooks also runs this project's hooks on " "these calls.") return lines def render_markdown(report): lines = ["**%s**" % report["headline"], ""] if not report["harnesses"]: return "\n".join(lines + ["## Notes", ""] + ["- %s" % n for n in report["notes"]]) + "\n" lines.append("Checked %s against the documented rules of each harness (docs checked %s). %s The dangerous " "commands were never run." % ( code(report["project"]), CHECKED, "Hooks ran with test input, one at a time." if report["hooks_run"] else "Hooks were not run; read each hook script, then add --run-hooks to test them.")) lines += ["", "| Harness | Mode | Blocked | Not blocked | Asks (rule, hook, or check) | Asks (mode only) | " "Runs without asking | Support |", "|---|---|---|---|---|---|---|---|"] for sec in report["harnesses"]: mode = code(sec["mode"], 80) if sec["harness"] == "codex" else safe_text(sec["mode"], 40) if sec["mode_source"] == C.BUILT_IN_SOURCE: mode += " (%s)" % C.BUILT_IN_SOURCE lines.append(_table_row(sec["name"], mode, sec["battery"], sec["support"])) if sec.get("also"): lines.append(_table_row("%s in %s mode" % (sec["name"], sec["also"]["label"]), sec["also"]["mode"], sec["also"]["battery"], "same rules and hook results")) unknown = sum(sec["battery"]["unknown"] for sec in report["harnesses"]) if unknown: lines += ["", "%d %s unknown because a hook did not answer within the tester's time limit." % ( unknown, _plural(unknown, "case is", "cases are"))] used_patterns = [] for sec in report["harnesses"]: by_id = {c["id"]: c for c in sec["cases"]} misses = [by_id[cid] for cid in sec["misses"]] lines += ["", "## Misses, worst first: %s" % sec["name"], ""] if not misses: lines.append("Every case in the battery is blocked as expected, or asks first where asking is enough.") continue lines += ["| Case | Command or file | Should | What happens | Why it slips | Fix |", "|---|---|---|---|---|---|"] for case in misses: fix = case.get("fix") or {} parts = [] if fix.get("rule"): parts.append("deny %s %s" % (_plural(fix["rule"].count(", ") + 1, "rule", "rules"), code(fix["rule"]))) if fix.get("hook_patterns"): parts.append("hook check %s" % ", ".join(fix["hook_patterns"])) used_patterns += [p for p in fix["hook_patterns"] if p not in used_patterns] if fix.get("note"): parts.append(fix["note"]) lines.append("| %s | %s | %s | %s | %s | %s |" % ( _case_id(sec, case), code(case["text"]) if sec["custom_battery"] else battery_code(case["text"]), case["expect"], _what_happens(case), code(case["why"], 200) if sec["custom_battery"] else safe_text(case["why"], 200), "; ".join(parts) or "see the sandbox")) noted = [(sec, c) for sec in report["harnesses"] for c in sec["cases"] if c.get("note")] if noted: lines += ["", "Notes on single cases:", ""] lines += ["- %s, %s: %s" % (sec["name"], _case_id(sec, c), c["note"]) for sec, c in noted] if used_patterns: lines += ["", "Hook checks named above, as extended regular expressions for `grep -Eq` in a PreToolUse hook " "such as templates/claude-code-safe-settings/.claude/hooks/guard.sh. Each pattern is one argument " "to grep; inside a single-quoted shell string, write each `'` as `'\\''`.", ""] for p in HOOK_PATTERNS: if p["id"] in used_patterns: lines += ["- %s (%s):" % (p["id"], p["label"]), "", "```text", p["ere"], "```", ""] while lines and lines[-1] == "": lines.pop() for sec in report["harnesses"]: if sec.get("replay"): lines += _replay_lines(sec) smells = [(sec["name"], s) for sec in report["harnesses"] for s in sec["smells"]] if smells: lines += ["", "## Configuration problems", ""] rank = {"high": 0, "medium": 1, "low": 2} for name, s in sorted(smells, key=lambda x: rank.get(x[1]["severity"], 3)): lines.append("- %s, %s: %s" % (s["severity"].capitalize(), name, s["text"])) lines += ["", "## What was found", ""] for sec in report["harnesses"]: found = [s for s in sec["sources"] if s.get("found")] if found: for s in found: extra = "" if "rules" in s: extra = " (%d allow, %d ask, %d deny rules; %d hooks)" % ( s["rules"]["allow"], s["rules"]["ask"], s["rules"]["deny"], s["hooks"]) elif s.get("count"): extra = " (%d entries)" % s["count"] lines.append("- %s %s %s: %s%s" % (sec["name"], s["layer"], s["kind"], code(s["path"]), extra)) else: lines.append("- %s: no settings files found, so the built-in defaults apply." % sec["name"]) for hook in sec["hooks"]: lines.append("- %s hook (%s, %s, matcher %s): %s, %s" % ( sec["name"], C.hook_source(hook["source"]), hook["event"], code(hook["matcher"]), code(hook["command"]), hook["status"])) notes = list(report["notes"]) + [n for sec in report["harnesses"] for n in sec["notes"]] if notes: lines += ["", "## Notes", ""] + ["- %s" % n for n in dict.fromkeys(notes)] return "\n".join(lines) + "\n" # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def build_parser(): p = argparse.ArgumentParser(prog="test_guards.py", description=__doc__.split("\n\n")[0], epilog="Exit codes: 0 done, 1 --fail-on-miss and a case is not blocked as expected, " "2 bad input.") p.add_argument("--harness", default="all", choices=("all",) + ORDER, help="which harness to test (default: every harness with settings on this machine)") p.add_argument("--project", default=".", help="the project folder whose settings apply (default: .)") p.add_argument("--run-hooks", action="store_true", help="also run your PreToolUse hook commands with test JSON on stdin (off by default; read each " "hook script first)") p.add_argument("--no-hooks", action="store_true", help="never run a hook command (the default)") p.add_argument("--hook-workers", type=int, default=1, metavar="N", help="hook runs at the same time (default: 1, one at a time)") p.add_argument("--replay", type=int, default=0, metavar="N", help="also replay your last N real shell and file-tool calls through the rules (default: 0, off)") p.add_argument("--replay-hooks", action="store_true", help="with --run-hooks, also run this project's hooks on replayed calls from this project") p.add_argument("--since", type=int, default=30, metavar="DAYS", help="how far back replay looks (default: 30)") p.add_argument("--mode", choices=C.MODES + ("manual",), help="Claude Code permission mode to simulate " "(default: the one your settings start in)") p.add_argument("--battery", default=DEFAULT_BATTERY, help="a battery file, one JSON case per line") p.add_argument("--hook-timeout", type=float, default=10.0, metavar="SECONDS", help="longest wait for one hook run (default: 10)") p.add_argument("--home", help="read user-level settings and transcripts from this folder instead of your home " "folder, for testing a copy of a setup; system-wide settings are skipped") p.add_argument("--json", action="store_true", help="print JSON instead of markdown") p.add_argument("--out", metavar="PATH", help="also write the report to this file") p.add_argument("--fail-on-miss", action="store_true", help="exit 1 when a block case is not blocked outright or an ask case runs without asking") return p def _usage_error(message): print("error: %s" % message, file=sys.stderr) return 2 def _test_harness(key, project, home, battery, trusted, pool, args): h = load_harness(key, project, home, args) cases, counts, misses, also = run_battery(h, battery, project, home, pool, args, trusted) replay = run_replay(h, args, home, project, pool) if args.replay else None hooks = [dict(display, status=_hook_status(h, hook_id, display, pool)) for hook_id, display in h.hooks] smells = list(h.smells) for s in runtime_smells(h, pool, args.hook_timeout): smells = [x for x in smells if not (x["id"] == s["id"] and x.get("source") == s.get("source"))] smells.append(s) section = {"harness": key, "name": NAMES[key], "support": h.support + ("; hooks run" if pool.enabled and h.hooks else ""), "mode": h.mode, "mode_source": h.mode_source, "sources": h.sources, "hooks": hooks, "battery": counts, "cases": cases, "misses": misses, "smells": smells, "replay": replay, "notes": h.notes, "custom_battery": not trusted} if also: section["also"] = also return section def main(argv=None): args = build_parser().parse_args(argv) project = os.path.normpath(os.path.abspath(os.path.expanduser(args.project))) if not os.path.isdir(project): return _usage_error("project folder not found: %s" % safe_text(args.project, 160)) home = os.path.normpath(os.path.abspath(os.path.expanduser(args.home))) if args.home else None if home and not os.path.isdir(home): return _usage_error("--home folder not found") if args.replay < 0 or args.since < 1 or args.hook_timeout <= 0 or args.hook_workers < 1: return _usage_error("--replay, --since, --hook-timeout, and --hook-workers need positive numbers") if args.run_hooks and args.no_hooks: return _usage_error("choose --run-hooks or --no-hooks, not both") if args.replay_hooks and not args.run_hooks: return _usage_error("--replay-hooks needs --run-hooks: hooks run only when you ask for them") try: battery = load_battery(args.battery) except InputError as exc: return _usage_error(str(exc)) trusted = os.path.abspath(args.battery) == os.path.abspath(DEFAULT_BATTERY) keys = detect_harnesses(project, home) if args.harness == "all" else [args.harness] pool = HookPool(enabled=args.run_hooks, timeout=args.hook_timeout, workers=args.hook_workers) report = {"tool": "guardrail-tester", "version": VERSION, "headline": "", "project": show_path( project, home or os.path.expanduser("~")), "hooks_run": bool(args.run_hooks), "replay_hooks_run": bool(args.replay_hooks and args.run_hooks), "harnesses": [], "hook_checks": {p["id"]: p["ere"] for p in HOOK_PATTERNS}, "notes": [ "Results are simulated from each harness's documented rules, checked %s. Where the docs are silent, the " "simulation assumes the reading that protects less." % CHECKED]} try: for key in keys: try: report["harnesses"].append(_test_harness(key, project, home, battery, trusted, pool, args)) except Exception as exc: # one broken settings file must not stop the other harnesses report["notes"].append("Could not test %s (%s)." % (NAMES[key], type(exc).__name__)) finally: pool.close() report["headline"] = headline(report["harnesses"], bool(args.run_hooks)) text = json.dumps(report, indent=2, ensure_ascii=True) + "\n" if args.json else render_markdown(report) sys.stdout.write(text) if args.out: try: with open(args.out, "w", encoding="utf-8") as fh: fh.write(text) except OSError as exc: return _usage_error("cannot write --out (%s)" % type(exc).__name__) if args.fail_on_miss and any(sec["battery"]["through"] for sec in report["harnesses"]): return 1 return 0 if __name__ == "__main__": sys.exit(main())