Claude Code can run a prompt without an interactive session. For a repository maintainer, that makes a scheduled review possible, but the schedule is the easy part. The useful result is a private report tied to the exact committed files the model was allowed to inspect, followed by a human check of each finding.
This guide gives an authorized maintainer one bounded route: capture a clean Git revision, run a restricted review against its tracked regular files, accept one manual report, then authorize a Cronie entry and check the first run that actually comes due. The Python runner below is complete and uses only the standard library. BIG CHANGE checked the documentation and exercised local Git and Python fixtures. We did not run Claude Code, make a provider request, install a cron entry, or inspect an account bill.
Earlier BIG CHANGE guides cover a native cloud README and npm-script check and a report from GitHub records; this route reviews a pinned local repository snapshot through Cronie.
The big change
- What changed: Claude Code's programmatic
-pmode and current CLI controls let a script request a noninteractive review with a restricted built-in tool list, denied MCP tools, turn limit, and estimated spending limit. The runner adds a pinned Git blob snapshot and a private JSON receipt. - Why it matters: A scheduled model answer by itself gives the maintainer little evidence about what was examined. This route records the commit and per-file identities, then requires a person to check the report before the schedule and again after its first due run.
- What to watch: The tool and prompt controls do not establish host isolation or prove that a finding is correct. The CLI budget is an estimate, cron can miss a run, and the checkout may advance between runs. Each report must be judged against its own recorded revision and the account's actual usage.
Decide the input and authority
Use an owned Git repository and a Linux host with Python 3, Git, Cronie, network access, and Claude Code v2.1.286 or later. The runner needs an approved API-key route to the provider, read access to the checkout, and a private output directory outside it. The owner must approve the repository contents, data destination and region, runner identity, retention, and spending controls. Check organization managed settings and the installed CLI's effective behavior before paying for a run. Do not place credentials in the repository, command line, crontab, or receipt.
The script requires a clean checkout at a known commit. It reads the exact Git tree and blob objects at that commit into a temporary snapshot, then points Claude Code at that snapshot. Its receipt records a tree-listing hash and each included path, mode, Git blob ID, and SHA-256. Ignored and untracked files stay out. It holds on symlinks, submodules, other nonregular entries, more than 10,000 files, or more than 100 MB of tracked content. It also holds if the snapshot changes during the run. A change to the original checkout after snapshot creation does not enter that run's model input. Review the tracked files for sensitive content before use: approved file reads still send material to the model service.
The --bare flag prevents automatic discovery of repository instructions, hooks, skills, plugins, and MCP servers, so the prompt carries the review task itself. The runner combines restricted mode, the built-in Read,Glob,Grep tool list, explicit mcp__* denial, dontAsk, and no permission prompts. --allowedTools on its own is an approval list, not the exclusive list used here. These are CLI tool controls; use a dedicated account or container with only the necessary repository and output access when host isolation is required. The task permits no edit, shell command, web access, push, external message, or deployment.
1. Copy the runner and run the unpaid preflight
Save this full script as /opt/agent-review/checked_headless_review.py on the intended host. Review its location and ownership. Make /srv/agent-review/private a private directory outside the repository, accessible only to the runner and authorized reviewer. Replace the example absolute paths and OWNER_APPROVED_NUMBER in the commands. Keep the runner source and output path under your own change control.
"""Linux cron-ready Claude Code review of an immutable tracked-file snapshot.
Python 3 standard library. A provider request happens only with --run-paid.
The owner must inspect a manual result before installing a cron entry.
"""
import argparse
import datetime as dt
import fcntl
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import subprocess
import tempfile
PROMPT = (
"Review the available tracked-file snapshot for up to five concrete correctness "
"or security issues. Read relevant files; do not modify files, execute code, "
"use the network, or invoke external tools. Return plain text in this exact format: "
"STATUS: FINDINGS or NO_FINDINGS or INCOMPLETE; a newline; "
"INSPECTED_PATHS: comma-separated relative paths actually read; a newline; "
"FINDINGS: integer 0 to 5; then one line per finding beginning FINDING N: "
"with path, line, observed code and a falsifiable explanation. "
"Use INCOMPLETE if evidence, access or budget is insufficient. "
"NO_FINDINGS still requires inspected paths. Do not claim tests ran."
)
MAX_ARCHIVE_BYTES = 100_000_000
MAX_FILES = 10_000
MAX_OUTPUT_BYTES = 100_000
def utc_now():
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def sha(data):
return hashlib.sha256(data).hexdigest()
def local_git(repo, *args):
result = subprocess.run(["git", "-C", str(repo), *args], text=True,
capture_output=True, timeout=30, check=False)
if result.returncode:
raise RuntimeError("Local git preflight failed: " + " ".join(args))
return result.stdout.strip()
def bounded_output(value):
if value is None:
value = b""
data = value.encode("utf-8", "replace") if isinstance(value, str) else value
return {"text": data[:MAX_OUTPUT_BYTES].decode("utf-8", "replace"),
"originalBytes": len(data), "truncated": len(data) > MAX_OUTPUT_BYTES}
def durable_json(path, value):
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent,
prefix=".pending-", delete=False) as handle:
tmp = Path(handle.name)
json.dump(value, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
dir_fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
def snapshot(repo, revision, destination):
# Read the pinned Git tree and its blob OIDs directly. git archive applies
# export-ignore/export-subst (including mutable info/attributes), so its
# output is not necessarily the exact tracked content named by the commit.
result = subprocess.run(["git", "-C", str(repo), "ls-tree", "-r", "-z", "--full-tree", revision],
capture_output=True, timeout=30, check=False)
if result.returncode or len(result.stdout) > MAX_ARCHIVE_BYTES:
raise RuntimeError("Cannot read bounded pinned Git tree")
entries = [item for item in result.stdout.split(b"\0") if item]
if len(entries) > MAX_FILES:
raise RuntimeError("Too many tracked tree entries")
files = []
seen = set()
total_bytes = 0
for entry in entries:
try:
header, raw_name = entry.split(b"\t", 1)
mode, kind, raw_oid = header.split(b" ", 2)
name = raw_name.decode("utf-8", "strict")
oid = raw_oid.decode("ascii", "strict")
except (ValueError, UnicodeDecodeError) as exc:
raise RuntimeError("Unsupported Git tree path/entry encoding") from exc
if mode not in (b"100644", b"100755") or kind != b"blob":
raise RuntimeError("Tracked link, submodule or special entry needs separate approval")
if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", oid):
raise RuntimeError("Invalid pinned blob OID")
parts = PurePosixPath(name).parts
if not name or name.startswith("/") or ".." in parts or "." in parts or "\\" in name:
raise RuntimeError("Unsafe tracked path")
if name in seen:
raise RuntimeError("Duplicate tracked path")
seen.add(name)
target = destination.joinpath(*parts)
if destination not in target.parents:
raise RuntimeError("Tracked path escaped snapshot")
size_result = subprocess.run(["git", "-C", str(repo), "cat-file", "-s", oid],
text=True, capture_output=True, timeout=30, check=False)
if size_result.returncode or not size_result.stdout.strip().isdigit():
raise RuntimeError("Cannot size pinned blob")
size = int(size_result.stdout.strip())
total_bytes += size
if size > MAX_ARCHIVE_BYTES or total_bytes > MAX_ARCHIVE_BYTES:
raise RuntimeError("Tracked snapshot exceeds 100 MB bound")
blob = subprocess.run(["git", "-C", str(repo), "cat-file", "blob", oid],
capture_output=True, timeout=120, check=False)
if blob.returncode or len(blob.stdout) != size:
raise RuntimeError("Cannot read complete pinned blob")
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("xb") as handle:
handle.write(blob.stdout)
files.append({"path": name, "gitBlobOid": oid, "gitMode": mode.decode("ascii"),
"bytes": size, "sha256": sha(blob.stdout)})
if not files:
raise RuntimeError("No tracked files to inspect")
files.sort(key=lambda item: item["path"])
return {"commit": revision, "treeListingSha256": sha(result.stdout),
"fileCount": len(files), "files": files,
"boundary": "Exact pinned tree blobs; Git export attributes cannot omit/substitute files; ignored/untracked excluded; submodules and links rejected"}
def snapshot_unchanged(destination, manifest):
found = []
for path in destination.rglob("*"):
if path.is_symlink():
return False
if path.is_file():
data = path.read_bytes()
found.append({"path": path.relative_to(destination).as_posix(),
"bytes": len(data), "sha256": sha(data)})
expected = [{"path": item["path"], "bytes": item["bytes"], "sha256": item["sha256"]}
for item in manifest["files"]]
return sorted(found, key=lambda item: item["path"]) == expected
def validate_result(envelope, available_paths):
if not isinstance(envelope, dict) or envelope.get("type") != "result":
return "wrong-envelope"
if envelope.get("is_error") is not False or envelope.get("subtype") != "success":
return "provider-error-or-incomplete"
if envelope.get("error") or envelope.get("error_code") or envelope.get("stop_reason") in {"max_tokens", "max_turns", "max_budget_usd"}:
return "provider-error-or-incomplete"
if envelope.get("permission_denials"):
return "permission-denial"
report = envelope.get("result")
if not isinstance(report, str) or not report.strip():
return "empty-or-wrong-type-report"
lines = [line.strip() for line in report.splitlines() if line.strip()]
if len(lines) < 3:
return "incomplete-report"
status = re.fullmatch(r"STATUS: (FINDINGS|NO_FINDINGS|INCOMPLETE)", lines[0])
paths = re.fullmatch(r"INSPECTED_PATHS: (.+)", lines[1])
count = re.fullmatch(r"FINDINGS: ([0-5])", lines[2])
if not status or not paths or not count:
return "invalid-report-contract"
inspected = [p.strip() for p in paths.group(1).split(",")]
if not inspected or any(p not in available_paths for p in inspected):
return "inspected-path-not-in-snapshot"
number = int(count.group(1))
if status.group(1) == "INCOMPLETE":
return "incomplete-report"
if (status.group(1) == "NO_FINDINGS") != (number == 0):
return "inconsistent-finding-count"
if len(lines) != 3 + number:
return "inconsistent-finding-lines"
for index, line in enumerate(lines[3:], 1):
if not re.fullmatch(rf"FINDING {index}: .+", line):
return "invalid-finding-line"
return "valid-report-needs-human-review"
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("repo", type=Path, help="Absolute owned repository path")
parser.add_argument("private_output", type=Path, help="Absolute private output directory, outside repo")
parser.add_argument("--budget-usd", type=float, required=True,
help="Owner-approved per-run estimated API cap; not a hard billing limit")
parser.add_argument("--run-paid", action="store_true", help="Actually invoke Claude")
args = parser.parse_args(argv)
repo, out = args.repo.resolve(), args.private_output.resolve()
if not args.repo.is_absolute() or not args.private_output.is_absolute():
parser.error("Use absolute paths for repo and private output")
if not repo.is_dir() or not out.is_dir() or out == repo or repo in out.parents:
parser.error("Repo and an existing private output directory outside it are required")
if os.name == "posix" and stat.S_IMODE(out.stat().st_mode) & 0o077:
parser.error("Private output directory must deny group/other access (mode 0700)")
if not 0 < args.budget_usd <= 100:
parser.error("Enter a positive, owner-approved per-run estimate cap")
if local_git(repo, "rev-parse", "--show-toplevel") != str(repo):
parser.error("Repo path must be its Git root")
revision = local_git(repo, "rev-parse", "HEAD")
if local_git(repo, "status", "--porcelain"):
parser.error("Use a clean reviewed checkout; ignored files are excluded from the snapshot")
claude = shutil.which("claude")
if not claude:
parser.error("Claude Code executable is unavailable on this runner")
version = subprocess.run([claude, "--version"], text=True, capture_output=True,
timeout=15, check=False)
if version.returncode:
parser.error("Cannot read Claude Code version")
match = re.search(r"\b(\d+)\.(\d+)\.(\d+)\b", version.stdout)
if not match or tuple(map(int, match.groups())) < (2, 1, 286):
parser.error("This route needs Claude Code v2.1.286+ for its full flag contract")
command = [claude, "--bare", "--restricted", "-p", PROMPT,
"--tools", "Read,Glob,Grep", "--disallowedTools", "mcp__*",
"--permission-mode", "dontAsk", "--permission-prompts", "none",
"--output-format", "json", "--no-session-persistence",
"--max-turns", "6", "--max-budget-usd", str(args.budget_usd)]
if not args.run_paid:
with tempfile.TemporaryDirectory(dir=out, prefix=".snapshot-") as temp:
manifest = snapshot(repo, revision, Path(temp))
print(json.dumps({"dryRun": True, "repo": str(repo), "revision": revision,
"snapshotTreeListingSha256": manifest["treeListingSha256"],
"trackedFileCount": manifest["fileCount"],
"claudeVersion": version.stdout.strip(), "privateOutput": str(out),
"tools": "Read,Glob,Grep only; MCP denied", "providerCalled": False}, indent=2))
return 0
if not os.environ.get("ANTHROPIC_API_KEY"):
parser.error("Supply ANTHROPIC_API_KEY via the runner's approved secret mechanism")
with (out / ".headless-review.lock").open("a+") as lock:
os.chmod(out / ".headless-review.lock", 0o600)
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError("Another review holds the lock; do not start another run")
started = utc_now()
run_id = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
target = out / ("review-" + run_id + ".json")
record = {"startedAtUtc": started, "completedAtUtc": None,
"repo": str(repo), "revision": revision,
"claudeVersion": version.stdout.strip(), "budgetUsdEstimateCap": args.budget_usd,
"status": "request-uncertain", "exitCode": None, "providerResult": None}
try:
with tempfile.TemporaryDirectory(dir=out, prefix=".snapshot-") as temp:
snapshot_dir = Path(temp)
manifest = snapshot(repo, revision, snapshot_dir)
record["inputSnapshot"] = manifest
try:
result = subprocess.run(command, cwd=snapshot_dir, text=True,
capture_output=True, timeout=900, check=False)
record["exitCode"] = result.returncode
record["stderr"] = bounded_output(result.stderr)
record["stdout"] = bounded_output(result.stdout)
try:
record["providerResult"] = json.loads(result.stdout)
except json.JSONDecodeError:
pass
reason = validate_result(record["providerResult"],
{f["path"] for f in manifest["files"]})
record["reportGate"] = reason
record["status"] = ("needs-human-review" if result.returncode == 0
and reason == "valid-report-needs-human-review" else "hold")
except subprocess.TimeoutExpired as exc:
record["errorType"] = type(exc).__name__
record["stdout"] = bounded_output(exc.stdout)
record["stderr"] = bounded_output(exc.stderr)
record["status"] = "request-uncertain"
if not snapshot_unchanged(snapshot_dir, manifest):
record["status"] = "hold"
record["inputDrift"] = "Snapshot mutated or gained files during run"
except Exception as exc:
record["status"] = "hold" if "inputSnapshot" not in record else "request-uncertain"
record["localError"] = f"{type(exc).__name__}: {exc}"
finally:
record["completedAtUtc"] = utc_now()
durable_json(target, record)
print(target)
if record["status"] != "needs-human-review":
raise RuntimeError("Run held or billing uncertain; inspect private receipt before any retry")
return 0
if __name__ == "__main__":
raise SystemExit(main())Run its default preflight under the intended runner identity:
python3 /opt/agent-review/checked_headless_review.py /srv/owned-repo /srv/agent-review/private --budget-usd OWNER_APPROVED_NUMBERThis checks the local root, clean commit, CLI presence and version, output destination, and bounded tree/blob snapshot, then prints the commit, snapshot identity, and tool policy. It makes no model request. It does not prove every paid-path flag works on the installed CLI. Stop if the version or effective policy differs, the checkout is dirty, an entry is unsupported, access fails, or the repository's tracked content is outside the approved scope. Confirm the actual API account and billing destination before proceeding; claude auth status may help identify the account, but this bare route requires the approved API-key configuration.
2. Make one approved manual request and inspect the receipt
After the owner approves a paid manual run, repeat the command with --run-paid:
python3 /opt/agent-review/checked_headless_review.py /srv/owned-repo /srv/agent-review/private --budget-usd OWNER_APPROVED_NUMBER --run-paidThe runner takes a nonblocking advisory lock on the output directory and invokes Claude Code with JSON output, no session persistence, at most six turns, and the approved client-estimated budget. It saves a uniquely named private receipt by atomic rename and file/directory sync. The receipt includes the revision and file identities, CLI version, start and end times, return code, JSON result, and bounded stdout and stderr with truncation indicators. It sends no report elsewhere. A lock collision fails rather than making a second provider request.
A needs-human-review gate means the JSON result had no explicit error or permission denial and contained a nonempty report with actual snapshot paths under INSPECTED_PATHS, plus numbered findings or NO_FINDINGS. An INCOMPLETE report, malformed result, denial, timeout, or changed snapshot is a hold. This is a structural check. It cannot establish that the model read each named path or that a finding is true.
Open the private receipt and verify the repo, commit, tree-listing hash and per-file blob/hash manifest against the approved input. Check denials, errors, truncation, return code and the reported estimated total_cost_usd. Read each cited file and line at the saved commit; reproduce a proposed issue with an owner-approved test or a human trace. An explicit no-findings answer still needs a scope check and does not prove that the whole repository was examined. Compare actual account usage or billing with the client estimate when cost matters. If the request timed out or its response is uncertain, preserve the receipt and reconcile provider usage before considering another paid run. A crash before receipt creation also needs output-directory and account reconciliation; no receipt does not establish no charge. The runner never retries automatically.
Use this blank private record for the manual decision and the later scheduled check:
Blank headless review run record
Field | Actual reader evidence |
|---|---|
Owner, repo authorization, allowed provider/region and input classification |
|
Runner identity, repo-only boundary, output permissions and retention |
|
Installed Claude Code version, effective settings and account route |
|
Account spend control, owner-approved per-run estimate cap and schedule |
|
Manual dry-run repo root, commit, pinned tree-listing SHA/count and per-file Git blob IDs/hashes, excluded ignored/untracked files and tool-policy readback |
|
Manual paid run receipt, input snapshot per-file hashes, start/end, commit, exit, report gate, denials/errors, truncated output |
|
Actual account usage/bill versus client estimated cost |
|
Inspected paths; FINDINGS or NO_FINDINGS versus INCOMPLETE; each path/line claim independently checked at saved commit |
|
Manual decision: accepted, rejected, unresolved; reason |
|
Owner authorization; Cronie implementation; MAILTO/private log and permissions/retention; installed one-entry readback; effective CRON_TZ, daemon log zone and next due UTC instant |
|
First scheduled run cron log, receipt ID, commit, result and review |
|
Failure/uncertainty recovery, disabled/re-enabled entry and fresh readback |
|
Keep receipts and this filled record private. A CLI result, schedule entry or model claim alone is not evidence of a correct review or a successful first due run.
3. Authorize one Cronie entry
Install a schedule only after accepting the manual result and obtaining the owner's approval for the precise recurrence and spend exposure. Confirm the installed daemon is Cronie. Use the dedicated runner's crontab, a private log, and restrictive directory and file permissions, such as 0700 for the output directory and 0600 for its log and receipts. Define retention and private log rotation. The example is Sunday at 03:15 UTC:
MAILTO=""
CRON_TZ=Etc/UTC
15 3 * * 0 umask 077; /usr/bin/python3 /opt/agent-review/checked_headless_review.py /srv/owned-repo /srv/agent-review/private --budget-usd OWNER_APPROVED_NUMBER --run-paid >> /srv/agent-review/private/cron.log 2>&1Read back the whole installed crontab, including any inherited CRON_TZ, and record the next due UTC instant. Ensure Cronie's environment can find Git and Claude Code and can use the approved secret mechanism. MAILTO="" suppresses cron mail; output and tracebacks go to the private log. CRON_TZ controls the scheduled time, while daemon logs may use the daemon's local zone. UTC avoids local daylight-saving gaps and repeats for this entry, but daemon downtime may still miss it. No automatic replay is provided. Every recurrence can incur provider usage until the entry is disabled.
4. Verify the first actual due run
At the first due time, check Cronie's own execution record and the private log, then locate the new receipt. Confirm its start time, commit, input manifest, status, permissions, and cost state. Inspect its claimed paths and findings against that run's commit, which may differ from the manual run's. Fill in the scheduled-run fields of the record. A saved crontab entry or a successful manual report is not evidence that the scheduled run happened.
If no run started, check the installed entry, daemon, time zone and runner environment. If it started but failed, distinguish local preflight, denied tool, malformed JSON, turn or budget stop, timeout, and poor analysis. Disable only this entry while repairing it; preserve receipts and logs. Removing an entry does not cancel a process already running, so check its process and lock before intervening. After repair, obtain approval for a fresh manual paid run, restore the same reviewed schedule, read it back, and inspect its next actual due result. Never automatically repeat an uncertain request.
Sources and further reading
- Claude Code programmatic CLI documents
-p, bare mode, JSON output and result behavior. It does not validate this repository or a scheduled report. - CLI reference and permissions document the restricted tool and permission flags. Actual effective policy still needs a check on the installed version.
- Setup and costs cover installation, authentication, estimated usage and billing controls; no account charge was measured for this guide.
- Git archive attributes explains why archive output can omit or transform tracked files. This runner instead extracts pinned tree blobs directly.
- Cronie crontab manual documents
MAILTO,CRON_TZ, schedule and log time zones, and daylight-saving behavior. The installed host was not tested here.
Documentation checked 23 October 2026. The runner's Python syntax and disposable local Git fixtures were checked independently; Claude Code, provider behavior, Linux Cronie execution, account billing, and report quality remain reader verification steps.



