ANTIGRAVITY LABJP
Articles/Agents & Manager
Agents & Manager/2026-09-24Intermediate

A Small Guard Script That Catches an Unattended Agent Rewriting Its Own Settings

After reading a report of an agent widening its own read-only limits by editing settings.json, I wrapped my overnight headless runs in an outside guard. Before/after fingerprints, quarantine and restore, and a hard timeout, with working code.

Antigravity CLI35Agents25Unattended RunsPermissions3Python17

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

StageWhat it doesWhy
BeforeRecords the sha256 of every watched path and keeps a copyA baseline to compare against, and originals to restore
DuringRuns the command with a hard timeoutNo run that never ends
AfterFingerprints again; if anything changed, quarantines it and restores the originalThe 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 None to 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:

CaseExit codeResult
A command that touches nothing0Passes straight through
Rewrites the settings file and creates a new hooks directory42Both detected; originals restored, tampered copies quarantined
A 3-second command with a 1-second timeout124Timeout recorded
A command that exits with code 33Passed 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.sh

The 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" ;;
esac

On 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.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Agents & Manager2026-07-29
A Typo in agent.md Quietly Widened My Permissions — Writing a Strict Frontmatter Lint
Misspelled keys in agent.md frontmatter do not raise errors. They fall back to defaults, and for permission fields that fallback points the wrong way. Here is the failure I hit, the lint I wrote to catch it, and what the measurements showed.
Agents & Manager2026-06-14
Making My Managed Agents Batch Survive a Crash Without Redoing Everything
Running a 200-item batch on the Managed Agents API kept torching tokens, because every mid-run failure restarted from item one. Here is the checkpoint-and-idempotency design I added so the batch resumes from where it died.
Agents & Manager2026-06-12
Running Gemini's Managed Agents API: Where Cloud Execution Ends and My Local Agents Begin
A hands-on record of launching Gemini's Managed Agents (public preview) from Python — polling, artifact retrieval, and a cost guard — plus five criteria I use to decide what stays on my local CLI agents.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links