I was scrolling through the Antigravity CLI issue tracker one afternoon when a single report stopped me. Someone had set a Remote Control session to read-only, and the agent got around it by rewriting the local settings.json (#1083, still open as I write this).
As an indie developer, I run image sorting for my wallpaper app overnight, with nobody watching. I look at the results over coffee the next morning, and that's it.
So I asked myself a simple question: if the agent changed one line in a settings file tonight, what would tell me? The honest answer was nothing. That sat a little heavy in my stomach.
Promises made inside the agent can fail together
My first instinct was to tighten things from the inside. A narrower allowlist, a hook that blocks writes, one more line in the prompt saying "don't touch the config." All of those live inside the agent's own world.
Around the same time, another report showed that --dangerously-skip-permissions also lets the agent walk straight past the stop hook in /plan mode (#1074). Inside-the-agent safeguards can be switched off by some other inside-the-agent mechanism, all at once.
What actually worried me wasn't whether a rule could be broken. It was the idea of waking up, not knowing it had been broken, and letting the next night's run go ahead anyway. That reframing gave me the rule I work by now:
Before you try to stop it, make sure you'll notice it — and put that check outside the agent.
The result is a small guard that runs outside the agent process and only looks at the state before and after. It isn't a wall. It just makes sure I'm never in the dark.
The guard does three things
| Stage | What it does | Why |
|---|---|---|
| Before | Records the sha256 of every watched path and keeps a copy | A baseline to compare against, and originals to restore |
| During | Runs the command with a hard timeout | No run that never ends |
| After | Fingerprints again; if anything changed, quarantines it and restores the original | The diff and the evidence are waiting for you in the morning |
The timeout is there for a reason. CLI 1.2.6 changed the default timeout for headless runs (-p) from five minutes to unlimited. That's a welcome change for long jobs, but for unattended nights I'd rather own the cutoff myself.
The full guard script
Standard library only. What it solves: after every run, you know mechanically whether the agent changed anything you care about, and if it did, the originals are already back in place. (Comments are in Japanese in my copy; I've translated them here.)
#!/usr/bin/env python3
"""Compare settings fingerprints before and after an unattended run; restore and stop on tampering."""
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
GUARD_HOME = Path(os.environ.get("AGENT_GUARD_HOME", Path.home() / ".agent-guard"))
TAMPER_EXIT = 42 # must not collide with CLI exit codes (e.g. 3 for structured errors)
def fingerprint(path: Path):
"""sha256 of a file's content, or of everything under a directory. None if missing."""
if not path.exists():
return None
h = hashlib.sha256()
if path.is_dir():
for p in sorted(path.rglob("*")):
if p.is_file():
h.update(str(p.relative_to(path)).encode())
h.update(p.read_bytes())
return "dir:" + h.hexdigest()
h.update(path.read_bytes())
return h.hexdigest()
def load_watch(list_file: str):
paths = []
for line in Path(list_file).read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
paths.append(Path(os.path.expanduser(line)).resolve())
return paths
def snapshot(paths, run_dir: Path):
"""Record fingerprints and keep copies so we can restore."""
state = {}
for i, p in enumerate(paths):
state[str(p)] = fingerprint(p)
dest = run_dir / "before" / str(i)
if p.is_dir():
shutil.copytree(p, dest)
elif p.exists():
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(p, dest)
(run_dir / "state.json").write_text(json.dumps(state, indent=2, ensure_ascii=False))
return state
def restore(paths, state, run_dir: Path, changed):
"""Move tampered versions to quarantine and restore the originals (never delete)."""
for i, p in enumerate(paths):
if str(p) not in changed:
continue
if p.exists():
q = run_dir / "quarantine" / str(i)
q.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(p), str(q))
src = run_dir / "before" / str(i)
if state[str(p)] is None:
continue # did not exist before -> quarantining alone restores the original state
if src.is_dir():
shutil.copytree(src, p)
else:
shutil.copy2(src, p)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--watch", required=True, help="file listing one watched path per line")
ap.add_argument("--timeout", type=int, default=3600, help="seconds; headless default is unlimited, so always set one")
ap.add_argument("cmd", nargs=argparse.REMAINDER, help="command to run after --")
a = ap.parse_args()
cmd = a.cmd[1:] if a.cmd[:1] == ["--"] else a.cmd
if not cmd:
ap.error("no command given")
paths = load_watch(a.watch)
run_dir = GUARD_HOME / f"{time.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}"
run_dir.mkdir(parents=True)
state = snapshot(paths, run_dir)
try:
rc = subprocess.run(cmd, timeout=a.timeout).returncode
except subprocess.TimeoutExpired:
print(f"[guard] {a.timeout}s timeout reached, run killed", file=sys.stderr)
rc = 124
changed = {p: {"before": b, "after": fingerprint(Path(p))}
for p, b in state.items() if fingerprint(Path(p)) != b}
report = {"command": cmd, "exit": rc, "changed": changed}
(run_dir / "report.json").write_text(json.dumps(report, indent=2, ensure_ascii=False))
if changed:
restore(paths, state, run_dir, changed)
for p in changed:
print(f"[guard] tampering detected, restored original: {p}", file=sys.stderr)
print(f"[guard] details: {run_dir / 'report.json'}", file=sys.stderr)
sys.exit(TAMPER_EXIT)
sys.exit(rc)
if __name__ == "__main__":
main()Three decisions worth explaining:
- Tampered versions are moved to
quarantine, never deleted. What the agent tried to write is exactly the evidence I want to read in the morning. - It can watch paths that don't exist yet. If a new hooks directory appears, its fingerprint changes from
Noneto something, and that counts as a change. - Exit codes are split three ways. 42 means tampering, 124 means timeout, and anything else is the command's own code passed through, so it won't collide with the CLI's structured error code 3.
The guard crashed before the agent did
My first version named each run folder with just the date and time down to the second. While I was running tests back to back, a second run started in the same second, and the guard itself died with FileExistsError.
A watcher that falls over first isn't much of a watcher. Appending the process ID to the folder name fixed it. Once you split a nightly job into several smaller ones, same-second starts happen sooner than you'd expect.
I tested it with fake commands that simply modify files, covering four cases:
| Case | Exit code | Result |
|---|---|---|
| A command that touches nothing | 0 | Passes straight through |
| Rewrites the settings file and creates a new hooks directory | 42 | Both detected; originals restored, tampered copies quarantined |
| A 3-second command with a 1-second timeout | 124 | Timeout recorded |
| A command that exits with code 3 | 3 | Passed through without mixing with the guard's own codes |
Wiring it into a nightly job
The watch list is a plain text file, one path per line. Paths can move between CLI versions, so check your version's documentation for where project settings, user settings, hooks, and MCP configuration actually live before filling it in.
# ~/.agent-guard/watch.txt
# Project-level settings directory (replace with the real path)
~/work/wallpaper-batch/<project-settings-dir>
# User-level settings file (replace with the real path)
~/<user-settings-location>/settings.json
# Also watch the launcher script itself
~/bin/run-nightly.shThe launcher just branches on the guard's exit code:
#!/bin/bash
# ~/bin/run-nightly.sh — entry point called from cron or launchd
export AGENT_GUARD_HOME="$HOME/.agent-guard"
PROMPT="$(cat "$HOME/jobs/wallpaper-tagging.md")"
python3 "$HOME/bin/agent_guard.py" \
--watch "$AGENT_GUARD_HOME/watch.txt" --timeout 2700 -- \
agy -p "$PROMPT"
rc=$?
case $rc in
0) ;;
42) echo "$(date '+%F %T') settings tampering detected -> holding next run" >> "$AGENT_GUARD_HOME/alerts.log"
touch "$AGENT_GUARD_HOME/HOLD" ;;
124) echo "$(date '+%F %T') timed out" >> "$AGENT_GUARD_HOME/alerts.log" ;;
*) echo "$(date '+%F %T') exit $rc" >> "$AGENT_GUARD_HOME/alerts.log" ;;
esacOn tampering, it drops an empty HOLD file. Add one line to your cron entry that skips the run when HOLD exists, and nothing runs again until I've read the diff. "It's restored, so we're fine" isn't good enough; I want to understand why the agent tried before I let it try again. For me, that pause is the most important thing the guard does.
What the guard can't do
Two limits I'd rather be upfront about.
First, the guard and the agent run as the same user. Even if the guard's records live outside the working directory, an agent that reaches that far can rewrite them. On macOS you can mark a settings file immutable with chflags uchg, but the owner can clear that flag, so it isn't absolute either.
Second, it doesn't watch the middle of a run. Rewrite, use, restore — a before/after comparison won't catch that sequence.
I'm keeping the guard anyway. Rather than holding out for something that locks everything down perfectly and ending up with nothing, I'd rather put one layer of "you'll know" in place and hand the decision to tomorrow-morning me. That fits how unattended work actually goes. For the other half — how to carve up the agent's permissions from the inside — I wrote up production permission boundary design for Antigravity agents as the inside counterpart to this outside guard.
If you'd like a place to start, put just three paths you care about into watch.txt tonight, and confirm the guard returns 0 on a command that touches nothing.