Guarding the Turn Boundary in Non-Interactive Runs: A Command Table and a Fail-Closed Gate
A scheduled job meant only to record usage was calling the model on every run. Here is how I split commands into ones that start a turn and ones that do not, built a gate that refuses anything unclassified, and measured it against real scripts.
A scheduled job I wrote to record usage turned out to be driving that usage up.
When the graph started climbing, my first suspicion fell on other jobs. Lining up the timestamps told a different story: the increments landed neatly on the monitoring job's schedule. A command I had been treating as a read was starting a turn.
Non-interactive runs let this kind of mistake pile up quietly. When you type a command yourself, a turn starting is visible on screen. A scheduled run gives you none of that feedback.
As an indie developer leaning more and more on scheduled execution, that blind spot compounds. What follows is how I moved this boundary out of my memory and into something a machine enforces.
Write the turn boundary down as a table
In print mode (-p), read-only slash commands answer without starting a turn, while interaction-only commands are explicitly rejected. In practice that splits commands three ways.
Class
Behavior in a non-interactive run
Safe from a monitoring job?
readonly
Answers without starting a turn
Yes
turn
Starts a turn (model call, side effects)
No
interactive
Rejected as interaction-only
Cannot work at all
The important part is not to run on the assumption that you know this split. I thought I did, and I was wrong. Human memory does not keep up as the command surface grows.
So it goes into a table that code can read.
Refuse anything unclassified: the fail-closed gate
Owning a table is not enough. What you do with a command that is missing from it determines the character of the whole design.
I went fail-closed. Unclassified means the command does not run and the process exits non-zero. Having a job break when a new command appears is annoying, but a broken job is far easier to deal with than a silent charge.
#!/usr/bin/env python3"""Turn-boundary gate for non-interactive runs.Anything missing from the table is refused rather than executed."""import re, shlex, subprocess, sysREADONLY = {"usage", "status", "model", "help", "cost", "context", "mcp", "config"}TURN = {"init", "review", "compact", "run", "agent", "fix"}INTERACTIVE_ONLY = {"settings", "login", "logout", "clear", "resume", "quit"}EXIT_UNCLASSIFIED = 78 # not in the table: do not runEXIT_WOULD_SPEND = 79 # starts a turn: not allowed from a monitoring jobEXIT_INTERACTIVE = 80 # interaction-only: cannot work here_norm_re = re.compile(r'^/?([a-z][a-z0-9_-]*)')def normalize(raw): """Reduce '/usage --json', ' /Usage', '/mcp:list' to a canonical name. Only the first token is inspected. Anything unparseable returns None.""" if raw is None: return None s = raw.strip() if not s: return None try: head = shlex.split(s)[0] except ValueError: return None # unbalanced quotes: refuse as-is head = head.split(':', 1)[0].lower() # /mcp:list -> mcp m = _norm_re.match(head) return m.group(1) if m else Nonedef classify(raw): name = normalize(raw) if name is None: return "unclassified", None if name in READONLY: return "readonly", name if name in TURN: return "turn", name if name in INTERACTIVE_ONLY: return "interactive", name return "unclassified", namedef run_readonly(raw, binary="antigravity", timeout=60): kind, name = classify(raw) if kind == "turn": sys.stderr.write(f"[turn-gate] '{name}' would spend a turn; refusing\n") return EXIT_WOULD_SPEND, None if kind == "interactive": sys.stderr.write(f"[turn-gate] '{name}' is interaction-only\n") return EXIT_INTERACTIVE, None if kind == "unclassified": sys.stderr.write(f"[turn-gate] unclassified: {raw!r}; add it to the table first\n") return EXIT_UNCLASSIFIED, None cmd = [binary, "-p", f"/{name}", "--output-format", "json"] try: p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) except FileNotFoundError: sys.stderr.write(f"[turn-gate] {binary} not found\n") return 127, None except subprocess.TimeoutExpired: sys.stderr.write(f"[turn-gate] timed out after {timeout}s\n") return 124, None return p.returncode, p.stdoutif __name__ == "__main__": code, out = run_readonly(sys.argv[1] if len(sys.argv) > 1 else "") if out: sys.stdout.write(out) sys.exit(code)
Three decisions in that code are worth spelling out.
Three distinct exit codes
78, 79, and 80 are separate because they mean different things once you start aggregating logs.
A 78 means your table is stale. A 79 means the job itself is written wrong. An 80 means you are trying to force something interactive into a place it cannot live. Collapsing three different causes into one exit code leaves you with logs that tell you nothing about what to fix.
Normalize only the first token
If you try to interpret flags like --output-format json or arguments like /config get model, your classifier ends up chasing the full command specification forever.
Deciding the turn boundary needs only the leading name. Refusing to look further is a deliberate constraint, not an oversight.
Treat broken quoting as unclassified
shlex.split raises ValueError on an unbalanced quote. Swallowing that exception and assuming the command is probably read-only would hollow out the whole fail-closed premise.
Broken input stays broken and gets refused. That turned out to be the single most useful rule in practice.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A three-way classification of slash commands plus a fail-closed gate that refuses to run anything not in the table, with the full implementation
✦Scanning 1,430 command invocations across 20 production shell scripts showed 58.1% build their argument strings at runtime, which is why static auditing alone falls short
✦Comparing a naive exact-match table against a normalizing classifier over 29 cases revealed that its errors skew toward over-blocking rather than unsafe misclassification, the opposite of what I expected
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Before writing the gate I tried something lighter: grep the scripts, list which commands they invoke, and be done.
It did not hold up. To put a number on why, I wrote a scanner that extracts invocation lines and classifies whether each one is statically readable as a literal string.
#!/usr/bin/env python3"""Extract command invocations from shell scripts and classify whether astatic scanner can read them as literal strings."""import re, sys, os, jsonCMD_TOKEN = re.compile(r'(?:^|[;|&]{1,2}\s*|\$\(\s*|`\s*)\s*([A-Za-z_][\w./-]*)\s')VAR_REF = re.compile(r'\$\{?[A-Za-z_][\w]*\}?')HEREDOC = re.compile(r'<<-?\s*[\'"]?([A-Za-z_][\w]*)')def classify_line(line): """literal / dynamic / None""" s = line.strip() if not s or s.startswith('#'): return None m = CMD_TOKEN.search(' ' + s) if not m: return None head = m.group(1) parts = s.split() if parts and VAR_REF.search(parts[0]): return ('dynamic', head) # the command name itself expands rest = s[m.end():] if VAR_REF.search(rest) or '$(' in rest or '`' in rest: return ('dynamic', head) # arguments resolved at runtime return ('literal', head)def scan(path): stats = {'literal': 0, 'dynamic': 0, 'heredoc_blocks': 0, 'lines': 0} heads = {} try: text = open(path, encoding='utf-8', errors='replace').read() except OSError: return stats, heads stats['heredoc_blocks'] = len(HEREDOC.findall(text)) for line in text.splitlines(): stats['lines'] += 1 r = classify_line(line) if not r: continue kind, head = r stats[kind] += 1 heads.setdefault(head, {'literal': 0, 'dynamic': 0})[kind] += 1 return stats, headsdef main(roots): total = {'literal': 0, 'dynamic': 0, 'heredoc_blocks': 0, 'lines': 0} allheads, files = {}, 0 for root in roots: for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in ('.git', 'node_modules', '.next')] for fn in filenames: if not fn.endswith(('.sh', '.bash', '.md', '.txt')): continue p = os.path.join(dirpath, fn) try: if os.path.getsize(p) > 400_000: continue except OSError: continue s, h = scan(p) if s['literal'] + s['dynamic'] == 0: continue files += 1 for k in total: total[k] += s[k] for head, c in h.items(): a = allheads.setdefault(head, {'literal': 0, 'dynamic': 0}) a['literal'] += c['literal'] a['dynamic'] += c['dynamic'] print(json.dumps({'files': files, 'total': total})) tops = sorted(allheads.items(), key=lambda kv: -(kv[1]['literal'] + kv[1]['dynamic']))[:12] for head, c in tops: n = c['literal'] + c['dynamic'] print(f"{head:14s} n={n:5d} dynamic={c['dynamic'] / n * 100:5.1f}%")if __name__ == '__main__': main(sys.argv[1:])
I ran it against the shell scripts behind my own scheduled jobs. The measurement environment was Python 3.10.12 on Linux x86_64.
Metric
Value
Files scanned
20
Invocation lines extracted
1,430
Statically readable (literal)
599 (41.9%)
Assembled at runtime (dynamic)
831 (58.1%)
Heredoc blocks
25
Broken down per command, the skew was stark.
Command
Count
Dynamic share
cat
207
100.0%
awk
60
100.0%
cd
46
91.3%
python3
80
85.0%
grep
58
79.3%
echo
216
53.7%
git
156
34.6%
ls
63
20.6%
More than half of all invocations only resolve their real argument string at runtime. Paths, repository names, dates. All of them live in variables.
Which means that auditing scripts statically to inventory "which commands do we invoke" leaves more than half of them invisible. That is exactly what my initial grep-based shortcut missed.
Static auditing still helps as a supplement. But enforcement has to live in a runtime gate. That was the fork in the design.
Where an exact-match table breaks down
The other thing I wanted to measure was how much a naive exact-match table actually misses.
Listing /usage feels sufficient. Real scripts, though, contain /usage, /usage --output-format json, and /mcp:list side by side.
I assembled 29 comparison cases. These are not production logs; they are spellings I collected by hand from real scripts and labeled with an expected class. Please read the numbers with that caveat.
#!/usr/bin/env python3"""Compare a naive exact-match table against a normalizing classifier."""import time, statisticsfrom turn_gate import classify, READONLY, TURN, INTERACTIVE_ONLYCASES = [ ("/usage", "readonly"), ("usage", "readonly"), (" /usage ", "readonly"), ("/usage --output-format json", "readonly"), ("/Usage", "readonly"), ("/mcp:list", "readonly"), ("/mcp list", "readonly"), ("/status", "readonly"), ("/cost", "readonly"), ("/context", "readonly"), ("/config get model", "readonly"), ("/model", "readonly"), ("/help", "readonly"), ("/init", "turn"), ("init", "turn"), ("/review --base main", "turn"), ("/compact", "turn"), ("/agent run x", "turn"), ("/fix", "turn"), ("/settings", "interactive"), ("/login", "interactive"), ("/resume", "interactive"), ("/clear", "interactive"), ("/telemetry", "unclassified"), ("/doctor", "unclassified"), ("", "unclassified"), (" ", "unclassified"), ('/usage "unclosed', "unclassified"), ("--output-format json", "unclassified"),]def naive(raw): """Exact-match table with no normalization""" if raw in {"/" + c for c in READONLY}: return "readonly" if raw in {"/" + c for c in TURN}: return "turn" if raw in {"/" + c for c in INTERACTIVE_ONLY}: return "interactive" return "unclassified"def score(fn): ok = sum(1 for raw, exp in CASES if fn(raw) == exp) # unsafe: a turn-spending or interactive command read as readonly unsafe = sum(1 for raw, exp in CASES if exp in ("turn", "interactive") and fn(raw) == "readonly") # over-block: a readonly command refused as unclassified overblock = sum(1 for raw, exp in CASES if exp == "readonly" and fn(raw) == "unclassified") return ok, unsafe, overblockN = len(CASES)for label, fn in (("exact ", naive), ("normal ", lambda r: classify(r)[0])): ok, unsafe, over = score(fn) print(f"{label}: correct {ok}/{N} ({ok / N * 100:.1f}%) " f"unsafe {unsafe} over-block {over}")t = []for _ in range(5): s = time.perf_counter() for _ in range(20000): for raw, _e in CASES: classify(raw) t.append((time.perf_counter() - s) / (20000 * N) * 1e6)print(f"per-decision cost: {statistics.median(t):.3f} us (median of 5)")
The results:
Approach
Correct
Unsafe
Over-blocked
Exact-match table
19/29 (65.5%)
0
7
Normalizing classifier
29/29 (100.0%)
0
0
A single classification decision took a median of 15.0 microseconds.
The part that ran counter to my expectations
I had expected the naive table to produce unsafe calls. Something like /init --dry-run failing to match /init and falling through to the read-only side.
Measured, the unsafe count was zero. All ten misses from the exact-match table landed on the other side: read-only commands refused as unclassified, seven of them over-blocks.
The reason is obvious in hindsight. Anything that falls out of an exact match lands in the unclassified bucket, and under fail-closed, unclassified never runs. A coarse table costs you availability, not safety.
That asymmetry changed the order of operations for me.
You do not need a complete table before you ship the gate. Ship it coarse and fail-closed, then grow the table by watching what exits with 78. This is the sequence I would recommend. Choose fail-open instead, and that same coarse table maps directly onto charges and side effects. One design decision moves the bar for how finished your table has to be.
One trap is worth naming: the asymmetry exists only because the gate is fail-closed. Add a single line that permits unclassified commands and it inverts. When you feel tempted to loosen the gate, that is the thing to remember.
The cost of refusing is negligible
A fair concern is whether wrapping every call in a gate slows things down. So I measured it.
Path
Median duration
Refusal (no process spawned)
18.4 microseconds (n=2000)
Reference: spawning one child process
1,054 microseconds (n=60)
The refusal path costs one fifty-seventh of a single process spawn, roughly 1.7%.
Put differently, the gate repays its own cost many times over simply by not spawning a process that would otherwise have run. This was not a close call.
Putting it into operation
Here is the order I would follow when introducing this.
Write the three-way table. Do not aim for completeness yet
Put the fail-closed gate at the entry point of every non-interactive run, with distinct exit codes per cause
Collect 78s for a week or so and promote the legitimate ones into the table
For every 78 or 79, fix the right thing: a 78 means the table is short, a 79 means the job is wrong
Keeping steps 3 and 4 separate is the crux. A missing table entry and a badly written job need repairs in different files.
If you want a concrete next step, pick one command your scheduled jobs invoke and verify that it genuinely does not start a turn. In my case, that check found one.
Behavior and specifications shift between versions, so please build your own table against primary sources rather than copying mine. I am still growing this table as I operate it, and I would be glad if it saves someone the same detour.
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.