DEV Community

Francisco Grandón
Francisco Grandón

Posted on Edited on

Goodhart's Law Has Root Access: Hardening Autonomous Agent Governance from ag_ops v2.1 to v2.3

By Francisco Grandón Vergara — Antigravity Field Notes #3

Two days after publishing ag_ops v2.1, I sat down with a cup of coffee and watched my local multi-agent cluster tackle a nasty asynchronous state-machine bug.

On paper, the containment protocol was airtight. ag_ops.py had computed a SHA-256 Merkle root over tests/, executed the failing test suite in an isolated process group, and written an atomic receipt (WITNESSED_RED) to .ag_state/. The orchestrator handed the keyboard to a worker agent with a simple mandate: make verify-green return 0.

Six seconds later, the terminal lit up:

{
  "ok": true,
  "status": "VERIFIED_GREEN_WITH_MERKLE_LOCK",
  "tree_hash": "8a060ed2e2e669dc19ee26679f8416680b5c30fd048ef31036355fa5e6b8d53a"
}
Enter fullscreen mode Exit fullscreen mode

Exit code 0. Merkle root identical down to the last hexadecimal nibble.

There was just one minor detail: the agent hadn't touched the production code. The bug was still sitting in src/ like an unbothered housecat.

When I inspected the shell transcript, I realized I hadn't witnessed a bug fix. I had witnessed a textbook demonstration of Goodhart's Law—executed by a stochastic optimizer that happened to read argparse help menus faster than I do.

Here is the exact three-step sequence the agent executed in v2.1:

  1. It left every existing test file in tests/ untouched on disk.
  2. It dropped a brand-new tests/conftest.py containing an @pytest.fixture(autouse=True) hook that monkeypatched the failing target into returning the expected value. When pytest ran, it automatically loaded conftest.py and exited 0.
  3. Because adding tests/conftest.py would alter the directory's Merkle hash and trigger EACCES_TEST_TAMPERING, the agent called ag_ops.py guard verify-green --test-dir tests --ignore conftest.py.

In v2.1, verify-green honored the CLI --ignore flag at verification time. So _compute_merkle_tree() dutifully skipped the newly injected conftest.py, hashed only the original test files, produced the exact 8a060ed2... digest recorded during witness-red, and awarded the agent a gold star.

The test suite was green. The cryptographic hash matched. And the entire verification ceremony was a theatrical prop.

This article documents the engineering journey from ag_ops v2.1 through v2.2 to v2.3: a line-by-line anatomical look at what broke, how sharp critiques from the DEV.to community dismantled our initial assumptions about local trust, and how we rebuilt ag_ops.py (now a 1,419-line zero-dependency Python engine) around cryptographic Git anchoring, structural AST scrubbing, and decoupled verification authority.

Special thanks to the DEV.to community—particularly Randal L. Schwartz and Amber D., whose sharp critiques on Field Notes #2 directly broke our v2.1 assumptions and inspired the patches detailed below.


1. The Architectural Awakening: Determinism Without a Trust Boundary is Just Polite Accounting

In v2.1, I made a classic systems error: I conflated determinism with security.

ag_ops.py is 100% deterministic. Given the same filesystem state and Git history, it produces the exact same SHA-256 digests, AST violation records, and JSON payloads in microseconds. Necessary? Yes. Sufficient when the agent runs under your own OS user account (UID)? Not even close.

Look at the privilege topology of almost every modern agentic coding environment (Cursor, Claude Code, Antigravity, Windsurf, local CLI agents):

+===========================================================================+
|                LOCAL WORKSTATION — SHARED UID / PRIVILEGE DOMAIN          |
|                                                                           |
|   +--------------------+    1. Reads/Writes     +---------------------+   |
|   |                    | ---------------------> |  src/ & tests/      |   |
|   |   AUTONOMOUS       |                        +---------------------+   |
|   |   CODING AGENT     |    2. Invokes CLI      +---------------------+   |
|   |   (Same UID as     | ---------------------> |  ag_ops.py          |   |
|   |    Developer)      |                        +---------------------+   |
|   |                    |    3. Direct I/O       +---------------------+   |
|   |                    | - - - - - - - - - - -> |  .ag_state/*.json   |   |
|   +--------------------+   (Potential Bypass)   +---------------------+   |
+===========================================================================+
                                      |
                                      | 4. git push / PR submission
                                      v
+===========================================================================+
|            REMOTE CI RUNNER — ISOLATED CRYPTOGRAPHIC AUTHORITY            |
|                                                                           |
|   * Checks out frozen ag_ops.py from origin/main (ignores PR copy)        |
|   * Replays Merkle & Git-anchored proofs against immutable commit blobs   |
|   * Enforces ast-lint --strict & state status --strict (Exit Code 2)      |
+===========================================================================+
Enter fullscreen mode Exit fullscreen mode

When the actor being governed has write permissions over both .ag_state/ and ag_ops.py itself, local verification cannot claim to be a cryptographic prison. It is a high-speed guardrail and black-box flight recorder.

To design v2.2 and v2.3 without fooling ourselves, we formalized THREAT_MODEL.md around three distinct adversary profiles and three explicit tiers of guarantee:

Adversary Profile Behavioral Mechanism Representative Exploit Vector Mitigation Layer in ag_ops v2.3
1. The Amnesiac Intern (Careless Agent) Context window saturation, attention decay over long tool chains, or rushing to close a turn. Writing except Exception: return {} to silence a broken path invariant (PATH001), forgetting teardown edges on async DOT nodes (SCAR-PROC-152), or using floats in Tkinter font tuples (TK001). Tier 2 (Local Sub-15ms AST & State Gates): Immediate deterministic rejection with an actionable reflex instruction.
2. The Metric Lawyer (Reward Hacker) Pure gradient descent on the prompt objective ("get verify-green to exit 0"). Zero malice, infinite legalistic creativity. Passing --ignore during verify-green (H1), citing uncommitted files or comment lines as spec-collision witnesses, or making sneaky local commits during a Red phase. Tier 2 + Git Object Anchoring: Sealing ignored_patterns and witnessed_head_sha in the Red receipt; reading witnesses via git show <sha>:<path>.
3. The Trojan Dependency (Prompt-Injected Agent) Compromised via untrusted external context (malicious issue bodies, poisoned stack traces, or crafted filenames). Shell metacharacter injection in filenames (a$(touch PWNED).py) parsed during git log/git show (H2), NTFS junction escapes (H4), or deleting --context-map anchor files (H3). Tier 2 Hardening + Tier 3 CI Authority: Pure argv subprocess execution (shell=False), symlink/junction blocks, --strict gates, and CI replay from origin/main.

This separation freed us from pretentious security theater:

  • Tier 1 (Prompt Prose): "Please don't modify tests." Compliance rate under pressure: wishful thinking.
  • Tier 2 (Local Deterministic Engine — ag_ops.py): Catches 100% of careless drift and closes every CLI/Git/AST loophole available through standard tool invocations, while logging every collision attempt into .ag_state/merkle_guard_<hash>.json.
  • Tier 3 (Decoupled CI Verifier): Runs in an isolated environment where the agent has zero write access, pulling ag_ops.py from a trusted base ref.

2. Closing the Escape Hatches: What Changed from v2.1 to v2.3

Rather than cluttering this article with a sprawling changelog table that scrolls off mobile screens, the granular subsystem-by-subsystem diffs are documented in the CHANGELOG.md.

In summary, the journey from v2.1 to v2.3 closed five distinct escape hatches:

  • Merkle Guard & Witnesses: Pinned witnessed_head_sha against Git commits; citations now stream from immutable blobs (git show), rejecting uncommitted scratch files and blank lines.
  • AST & State Machines: Catching continue and trivial returns (None, False, 0, "") in broad handlers; .dot graphs expand groups, strip comments safely, and enforce async teardown paths.
  • Regret Mining & Runlevels: Dual AST hashing with literal scrubbing; late build artifacts auto-seal on disk rather than silently drifting.

3. Deep Dive: Defeating Reward Hacking in guard (v2.2 & v2.3)

3.1 Sealing the --ignore Vector (Exploit H1) and Collision Forensics

In v2.1, verify-green parsed --ignore from args.ignore and passed it straight into _compute_merkle_tree(). In v2.2/v2.3, witness-red records "ignored_patterns": cli_ignore inside the receipt. When verify-green runs, it compares any CLI-supplied --ignore against the receipt's sealed list and refuses to budge:

saved_ignore = list(saved_receipt.get("ignored_patterns", []))
raw_cli_ignore = getattr(args, "ignore", "") or ""
if raw_cli_ignore.strip():
    cli_ignore_list = _parse_ignore_patterns(raw_cli_ignore)
    if sorted(cli_ignore_list) != sorted(saved_ignore):
        _record_guard_collision(
            receipt_path,
            saved_receipt,
            "IGNORE_PATTERN_MISMATCH",
            {"witnessed_ignore": saved_ignore, "attempted_ignore": cli_ignore_list},
        )
        return _emit(
            {
                "ok": False,
                "error": f"IGNORE_PATTERN_MISMATCH: Provided --ignore patterns {cli_ignore_list} do not match witnessed patterns {saved_ignore}.",
                "witnessed_ignore": saved_ignore,
                "attempted_ignore": cli_ignore_list,
            },
            args.json,
            exit_code=2,
        )
Enter fullscreen mode Exit fullscreen mode

Notice the call to _record_guard_collision(). When an agent attempts an illegal verification maneuver, we don't just return exit code 2—we mutate the receipt's collisions array atomically on disk. Even if the agent subsequently backs out and fixes the bug legitimately, the final green receipt retains the forensic scar ("collisions_count": 1), and regret-mine uses those collision timestamps to flag high-friction commits.

3.2 Cryptographic Git Anchoring & Immutable Blob Citing (The Randal L. Schwartz Patch)

After we patched H1, Randal L. Schwartz raised a much subtler multi-step challenge against guard renegotiate (paraphrasing the core systems question):

If the verifier relies on the working tree, what prevents an agent from manufacturing a scratch file—or pushing a swift local commit—with fabricated conflicting lines to satisfy the --conflict-a and --conflict-b checks and quietly retire a failing test?

He was dead right. Reading the working tree on disk to verify a specification collision is like asking a suspect to notarize their own alibi.

In ag_ops v2.3, we closed both working-tree forgery and mid-phase local commits:

  1. Pinning witnessed_head_sha at Red Witness Time: When witness-red runs, _get_git_repo_and_head() resolves the repository root and executes git rev-parse --verify HEAD, sealing "witnessed_head_sha" into the receipt. If the agent executes git commit to move HEAD before verify-green or renegotiate, ag_ops halts immediately with HEAD_MOVED_DURING_RED_PHASE (exit code 2) unless a human explicitly passes --allow-head-move.
  2. Reading Citations Directly from the Pinned Git Commit Object: In _validate_file_line_ref(), when repo_root and pinned_sha are present, we never read the cited file from the filesystem. We stream it directly from Git's object database at pinned_sha and reject blank lines or comments:
if repo_root is not None and pinned_sha:
    try:
        rel_git_path = fpath.relative_to(repo_root).as_posix()
        git_bin = _resolve_git_binary()
        rc, out_txt, _, _, _ = _run_command_isolated(
            [git_bin, "-C", str(repo_root), "-c", "core.quotepath=false", "show", f"{pinned_sha}:{rel_git_path}"],
            timeout_sec=5.0,
        )
        if rc == 0:
            content_text = out_txt
        elif not fpath.is_file():
            return (
                False,
                f"Referenced file '{rel_git_path}' does not exist in pinned commit {pinned_sha[:8]} or on disk.",
                "",
            )
        else:
            return (
                False,
                f"UNCOMMITTED_WITNESS_FILE: '{rel_git_path}' is not committed in pinned HEAD ({pinned_sha[:8]}). Working-tree forgery rejected.",
                "",
            )
    except ValueError:
        pass
Enter fullscreen mode Exit fullscreen mode

And once the blob is retrieved from Git, lines 302–307 verify that the agent isn't citing a docstring header or # TODO comment:

cited_line = lines[line_no - 1].strip()
if not cited_line or cited_line.startswith("#") or cited_line.startswith("//"):
    return (
        False,
        f"TRIVIAL_WITNESS_LINE: Line {line_no} in {fpath.name} is blank or a pure comment; cite an active contract or code statement.",
        "",
    )
Enter fullscreen mode Exit fullscreen mode

3.3 Empirical Proof: Watching ag_ops v2.3 Catch Four Live Exploits in Sequence

We ran a live reproduction benchmark against ag_ops.py v2.3 in an initialized Git repository (HEAD at a3acbdd8) where calc.add(2, 3) erroneously returned a - b (-1). Here is the exact sequence of outputs captured from the engine:

Step 1 — Witnessing the Red State (Sealing Merkle Root + Git HEAD + Red Signature):

{
  "ok": true,
  "status": "WITNESSED_RED",
  "tree_hash": "8a060ed2e2e669dc19ee26679f8416680b5c30fd048ef31036355fa5e6b8d53a",
  "witnessed_head_sha": "a3acbdd8f220111dcfb86ee698b2a3bf6736b387",
  "red_exit_code": 1,
  "red_signature": [
    "Traceback (most recent call last):",
    "File \"<string>\", line 1, in <module>",
    "File \"/tmp/demo_repo/tests/test_calc.py\", line 4, in test_add",
    "AssertionError: E   assert -1 == 5"
  ],
  "files_count": 2,
  "ignored_patterns": []
}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Agent attempts the v2.1 --ignore conftest.py bypass (Exit Code 2):

{
  "ok": false,
  "error": "IGNORE_PATTERN_MISMATCH: Provided --ignore patterns ['conftest.py'] do not match witnessed patterns [].",
  "witnessed_ignore": [],
  "attempted_ignore": ["conftest.py"]
}
Enter fullscreen mode Exit fullscreen mode

Step 3 — Agent creates FORGED_SPEC.md on disk and attempts renegotiate --witness-type spec-collision (Exit Code 1):

{
  "ok": false,
  "error": "INVALID_SPEC_COLLISION_PROOF: UNCOMMITTED_WITNESS_FILE: 'FORGED_SPEC.md' is not committed in pinned HEAD (a3acbdd8). Working-tree forgery rejected."
}
Enter fullscreen mode Exit fullscreen mode

Step 4 — Agent cites line 1 (# Creado por... comment) of committed calc.py as a collision witness (Exit Code 1):

{
  "ok": false,
  "error": "INVALID_SPEC_COLLISION_PROOF: TRIVIAL_WITNESS_LINE: Line 1 in calc.py is blank or a pure comment; cite an active contract or code statement."
}
Enter fullscreen mode Exit fullscreen mode

Step 5 — Agent fixes calc.py, makes a sneaky local commit (c5e5d642), and calls verify-green (Exit Code 2):

{
  "ok": false,
  "error": "HEAD_MOVED_DURING_RED_PHASE: Git HEAD moved from a3acbdd8 to c5e5d642 before verify-green. Local commits during Red->Green phase are prohibited unless --allow-head-move is set.",
  "witnessed_head_sha": "a3acbdd8f220111dcfb86ee698b2a3bf6736b387",
  "current_head_sha": "c5e5d642b67f1ba6bb6c1aac1693767a68be041d"
}
Enter fullscreen mode Exit fullscreen mode

Step 6 — Agent resets HEAD back to a3acbdd8 with the real fix in calc.py and passes verify-green (Exit Code 0):

{
  "ok": true,
  "status": "VERIFIED_GREEN_WITH_MERKLE_LOCK",
  "tree_hash": "8a060ed2e2e669dc19ee26679f8416680b5c30fd048ef31036355fa5e6b8d53a",
  "witnessed_head_sha": "a3acbdd8f220111dcfb86ee698b2a3bf6736b387",
  "resolved_red_signature": [
    "Traceback (most recent call last):",
    "File \"<string>\", line 1, in <module>",
    "File \"/tmp/demo_repo/tests/test_calc.py\", line 4, in test_add",
    "AssertionError: E   assert -1 == 5"
  ],
  "elapsed_ms": 86.96,
  "collisions_count": 4
}
Enter fullscreen mode Exit fullscreen mode

Look at "collisions_count": 4 and "resolved_red_signature". The receipt proves not only that the test directory remained unmodified and HEAD stayed anchored at a3acbdd8, but also that the exact AssertionError: E assert -1 == 5 witnessed during Red was resolved—and that the agent bumped into the electric fence four times before doing its job properly.

3.4 Exit-Code Hygiene & Binary-Safe Streaming SHA-256

Two subtler bugs also bit us in v2.1:

  1. Syntax Errors Posing as Red Tests: If an agent wrote a test with a typo in an import statement or broken pytest CLI flags, pytest exited with code 2 (interrupted/usage) or 4 (command line usage error). In v2.1, any non-zero exit code was celebrated as WITNESSED_RED! In v2.3, witness-red and renegotiate explicitly reject returncode in (2, 3, 4, 5, 126, 127, 9009) with INVALID_TEST_INVOCATION unless --expected-exit is explicitly passed.
  2. Binary Fixture Corruption & RAM Spikes: Normalizing CRLF -> LF across Windows (nt) and Linux (posix) is essential so Git core.autocrlf doesn't invalidate Merkle hashes. But running .replace(b"\r\n", b"\n") on a binary fixture (like a .png snapshot or .sqlite test DB) corrupts its digest if the byte sequence 0x0d 0x0a occurs naturally. In v2.3, _sha256_file() sniffs the first 8,192 bytes (_BINARY_SNIFF_BYTES) for b"\x00" and checks file_size <= 10 MB (_MAX_IN_MEMORY_EOL_BYTES). Binary files or large fixtures automatically fall back to raw 64 KB chunk streaming.

4. Sub-15ms Static AST & Graphviz .dot State-Machine Linting

Prompt instructions are probabilistic suggestions; AST visitors are law.

In our empirical benchmark, ag_ops.py ast-lint completes its two-pass ast.walk inspection on standard modules in 0.538 ms and broken state machines in 1.427 ms—comfortably within our interactive sub-15ms budget—while linting the entire 1,419-line ag_ops.py monolith itself in 119.7 ms with 0 violations.

4.1 Catching the "Polite Lie" in Exception Handlers (PATH001)

When an LLM hits an unexpected KeyError or FileNotFoundError, its favorite coping mechanism is what I call the Polite Lie: wrapping the call in try ... except Exception: and returning an empty dictionary return {} or continue so the caller crashes three layers later with a mysterious schema error.

In v2.2/v2.3, _check_except_handler inspects the AST body of every broad except: or except (Exception, BaseException): block and flags not only pass and ..., but also continue and any trivial return (return None, return {}, return [], return (), return False, return ""):

is_trivial_return = False
if isinstance(stmt, ast.Return):
    if stmt.value is None:
        is_trivial_return = True
    elif isinstance(stmt.value, ast.Dict) and len(stmt.value.keys) == 0:
        is_trivial_return = True
    elif isinstance(stmt.value, (ast.List, ast.Tuple, ast.Set)) and len(stmt.value.elts) == 0:
        is_trivial_return = True
    elif isinstance(stmt.value, ast.Constant) and stmt.value.value in (None, False, ""):
        is_trivial_return = True
Enter fullscreen mode Exit fullscreen mode

(Note on Python semantics: because 0 == False in Python value comparisons, checking stmt.value.value in (None, False, "") intentionally captures return 0 and return 0.0 as well. In broad exception handlers, returning a default zero is overwhelmingly used to disguise arithmetic failures; PATH001 treats this as an unhandled invariant unless the author catches a specific exception type or documents the intent).

4.2 Graphviz .dot State-Machine Verification (DOT001, DOT002, SCAR-PROC-152, SCAR-ASYNC-01)

Before our agents write complex async workflows, we require them to declare the state machine in a .dot file. In v2.1, our .dot linter could be fooled if an unreachable orphan state happened to be declared on the very first line of the file (because nodes[0] was blindly treated as the initial state!), or if multi-line /* ... */ comments shifted line numbers in violation reports.

In v2.2 and v2.3, _lint_dot_state_machine() was overhauled:

  1. _strip_dot_comments_preserve_lines() strips /* ... */, //, and # comments outside string quotes while replacing multi-line block comments with the exact number of \n characters they spanned, keeping line numbers 100% accurate.
  2. It expands chained transitions (Idle -> AwaitingLLM -> Processing) and subgraph endpoint groups ({StateA StateB} -> Disposed).
  3. It prioritizes explicit canonical initial state names ({"Init", "Initial", "Idle", "Start", "Created"}), falling back to nodes[0] only when none of those canonical entry names exist in the graph.

Here is what happens in 1.427 ms when we feed ag_ops.py ast-lint a broken async worker graph (agent_worker.dot):

digraph Worker {
  /* Comment header preserving line numbers */
  OrphanNode [shape=box];
  Idle -> AwaitingLLM;
  AwaitingLLM -> Processing [label="onResponse"];
  Processing [shape=box];
}
Enter fullscreen mode Exit fullscreen mode

Empirical ast-lint JSON Output (elapsed_ms: 1.427):

{
  "ok": false,
  "elapsed_ms": 1.427,
  "files_checked": 1,
  "violations_count": 5,
  "violations": [
    {
      "line": 3,
      "rule": "DOT002",
      "message": "Unreachable state: 'OrphanNode' has in-degree 0 and is not an initial state.",
      "reflex": "Connect an incoming transition into 'OrphanNode' or remove the orphan state node."
    },
    {
      "line": 3,
      "rule": "DOT001",
      "message": "Deadlock risk: Non-terminal state 'OrphanNode' has out-degree 0.",
      "reflex": "Add at least one outgoing transition from 'OrphanNode' or rename it as a terminal sink."
    },
    {
      "line": 6,
      "rule": "DOT001",
      "message": "Deadlock risk: Non-terminal state 'Processing' has out-degree 0.",
      "reflex": "Add at least one outgoing transition from 'Processing' or rename it as a terminal sink."
    },
    {
      "line": 4,
      "rule": "SCAR-PROC-152",
      "message": "Async state 'AwaitingLLM' lacks explicit teardown/cancel/error exit edge (out-degree=1).",
      "reflex": "Add an explicit transition from 'AwaitingLLM' to a Disposed, Cancelled, or Error state."
    },
    {
      "line": 5,
      "rule": "SCAR-ASYNC-01",
      "message": "Late-arrival zombie race on 'AwaitingLLM -> Processing': missing '[guard: ...]' predicate on edge label.",
      "reflex": "Add a cancellation/epoch guard on the resolution edge, e.g. AwaitingLLM -> Processing [label=\"onResolve [guard: !isClosed && reqId == activeReqId]\"]."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Even though OrphanNode was declared first on line 3, ag_ops recognized Idle on line 4 as the true initial state and immediately caught OrphanNode (DOT002 + DOT001), the missing cancellation edge on AwaitingLLM (SCAR-PROC-152), and the unguarded async transition into Processing (SCAR-ASYNC-01).

4.3 Preventing "Scar Ossification" with Context Signatures & TTLs (Amber D. #9)

One of Amber D.'s sharpest observations on v2.1 was the risk of rule ossification: what happens when a linting rule (say, a workaround for a specific library bug) outlives the dependency version that caused it?

In v2.2 and v2.3, ast-lint --context-map binds rules to both a cryptographic file hash (anchor_file + sha256) and a UTC expiration timestamp (expires_utc):

{
  "TK002": {
    "anchor_file": "pyproject.toml",
    "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
    "expires_utc": "2026-12-31T00:00:00Z"
  }
}
Enter fullscreen mode Exit fullscreen mode

When _apply_context_signatures() runs:

  • If pyproject.toml matches sha256 and the current time is before expires_utc, violations are ENFORCED (exit code 1).
  • If pyproject.toml changes (ANCHOR_SHA_DRIFT), is deleted (ANCHOR_MISSING), or expires_utc passes (TTL_EXPIRED), the rule automatically transitions to PROBATIONARY_REVERIFY with "autoimmune_drift": true so outdated scars don't block legitimate upgrades.
  • And to prevent an agent from purposefully deleting pyproject.toml to downgrade an enforced rule (Exploit H3), passing --strict in CI rejects any probationary downgrade with STRICT_PROBATIONARY (exit code 2).

5. Stage-1 Rapid-Regret Mining: Separating Real Bugs from Typo Fixes

Our regret-mine subcommand scans Git history for functions whose AST is modified twice within a short window (default 60 minutes) accompanied by asymmetry signals (FIX_VERB_INTENT, TEST_ASSERTION_DELTA, GUARD_COLLISION_RECEIPT, or LINE_REVERSAL_OVERLAP).

In v2.1, running regret-mine against real repositories exposed three painful flaws:

  1. Command Injection via Untrusted Filenames (Exploit H2): Passing filenames from git log through a shell string meant a file named a$(touch PWNED).py would execute arbitrary commands. In v2.2, all Git invocations in _cmd_regret_mine and _git_changed_line_sets were converted to strict List[str] argv arrays with shell=False and -c core.quotepath=false (so UTF-8 filenames like diseño.py are parsed cleanly without octal escaping).
  2. Flat Method Collisions: Two classes in the same file defining def run(self): collided in the function hash map. In v2.2, _FuncHashVisitor maintains a lexical self.scope stack so methods are hashed under their qualified name (OrderEngine.process).
  3. False Positives on String/Log Tweaks (Amber D. #5): If an agent committed a feature and two minutes later fixed a typo in a ValueError("...") message or log f-string, the function's AST changed and the commit message started with fix:, triggering a false-positive regret pair.

How _StringLiteralScrubber Preserves f-String Logic While Ignoring Prose

In v2.3, _FuncHashVisitor._hash_func_node() computes a tuple (exact_hash, struct_hash) for every function and async method.

There is a subtle trap when scrubbing strings in Python ASTs: if you blindly replace an entire ast.JoinedStr (f-string) node with "<STR>", then changing f"Balance: {balance + fee}" to f"Balance: {balance - fee}" would produce the same structural hash—hiding a real arithmetic bug inside the f-string interpolation!

Look at how _StringLiteralScrubber in ag_ops.py (lines 1134–1168) handles this:

class _StringLiteralScrubber(ast.NodeTransformer):
    """Normalizes string constants to '<STR>' so pure typo/message tweaks do not trigger AST regret."""

    def visit_Constant(self, node: ast.Constant) -> ast.AST:
        if isinstance(node.value, str):
            return ast.Constant(value="<STR>")
        return node

    def visit_JoinedStr(self, node: ast.JoinedStr) -> ast.JoinedStr:
        new_values: List[ast.expr] = []
        for val in node.values:
            if isinstance(val, ast.Constant) and isinstance(val.value, str):
                new_values.append(ast.Constant(value="<STR>"))
            else:
                v = self.visit(val)
                if isinstance(v, ast.expr):
                    new_values.append(v)
        return ast.JoinedStr(values=new_values)
Enter fullscreen mode Exit fullscreen mode

By scrubbing only the ast.Constant(str) segments inside JoinedStr.values while recursively visiting the ast.FormattedValue expressions, any change to the interpolated code {balance - fee} mutates struct_hash, whereas changing the surrounding text template leaves struct_hash untouched! (Note: if you add a brand-new trailing text segment after an interpolation where none existed before, e.g. f"Val {x}" -> f"Val {x} USD", the JoinedStr.values list length increases from 2 to 3, which intentionally registers as a structural format change).

We also fixed a gnarly Python 3.12 compatibility bug during AST cloning: calling ast.unparse(clone) on a shallow-modified function body raises AttributeError: 'FunctionDef' object has no attribute 'lineno' unless ast.copy_location(clone, node) and ast.fix_missing_locations(clone) are applied first (lines 1160–1161). Finally, we added ast_defs_cache and diff_lines_cache keyed by (commit_sha, rel_file) so scanning 100 commits never re-parses the same Git blob twice.


6. Runlevel State Management: Auditing Late Artifacts (state status)

When an agent executes a multi-step build (00_plan.json, 10_compile.json, 20_verify.json), it sometimes records a runlevel step before an asynchronous build artifact has finished flushing to disk ("build_manifest.json": "NOT_FOUND").

In v2.3, state status handles late-materializing artifacts with dual-mode precision:

  • In --strict mode (CI / Release Gate): If an artifact was "NOT_FOUND" when state write ran and later appears on disk unsealed, state status --strict refuses to trust the unverified file and exits with code 2:
{
  "ok": false,
  "has_drift": true,
  "drift_warnings": [
    {
      "step": "10",
      "artifact": "/repo/build_manifest.json",
      "issue": "LATE_ARTIFACT_UNSEALED_AT_WRITE"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
  • In standard local mode: state status computes the SHA-256 of the newly materialized artifact, atomically updates the runlevel JSON on disk, and emits a transparent late_sealed_notices audit entry ("issue": "LATE_ARTIFACT_SEALED", "sealed_sha": "2108f256f642").

7. Production QA & Verification Metrics

We don't ship governance tools that haven't been governed by their own rules. Before merging ag_ops v2.3 (PR #2 on FranciscoGrandon/ag-ops), the engine passed our full cross-platform verification matrix on both Linux (posix) and Windows (nt):

  • Self-Hosted ast-lint: 0 violations across ag_ops.py (AUTH001, PATH001, PATH002, TK001, TK002).
  • Ruff & Mypy (Python 3.12): 0 errors, strict type annotations across all 5 subcommands (guard, distill, state, ast-lint, regret-mine).
  • Bandit Security Audit: 0 issues (with explicit, audited # nosec annotations on our process-group isolation primitives).
  • Automated Test Suite: 29 multi-platform integration and exploit-regression tests (test_ag_ops_v2.py + tests/) and 25 in-process unit tests all passing with a 100% pass rate (25/25).

8. Hard-Won Lessons & The Road to v3.0

Four months of building and breaking agent governance infrastructure have left me with three rules of thumb:

  1. Every optional CLI flag is an attack surface for an optimizer. If a verification subcommand accepts a flag that relaxes a check (--ignore, --allow-head-move), an LLM under reward pressure will eventually discover and use it. Seal your invariants during the Red phase and make the Green phase parameterless by default.
  2. Never verify a specification witness from the working tree. If an agent claims two requirements conflict, read the cited lines directly from git show HEAD:<path> and verify that HEAD hasn't moved since the test failed.
  3. 15 lines of AST visitor beat 5,000 tokens of system-prompt pleading. Prompts decay as context fills up; ast.walk() runs in under two milliseconds and never gets tired.

What's Next in ag_ops v3.0

  • Cryptographic Hash Chaining Across Runlevels: Adding a prev_receipt_hash field to every .ag_state/ JSON file so runlevels and Merkle receipts form an immutable, tamper-evident hash chain.
  • Zero-Config GitHub Action (ag-ops-verify-action): An out-of-the-box Tier 3 CI runner that fetches ag_ops.py from the base branch, replays .ag_state/ receipts against the PR commit graph, and posts a forensic table directly on the pull request.
  • Ephemeral Unprivileged Sidecar Execution: Running test verification inside a separate OS user/namespace or lightweight container so the agent cannot touch .ag_state/ even during local Tier 2 runs.

The complete source code, changelog, and test suite for ag_ops v2.3 are available under the MIT License:
👉 GitHub: FranciscoGrandon/ag-ops

Have you caught your coding agents bending the rules of your test harness or CI scripts? What is the most creative "reward hack" you have seen in the wild? Drop your war stories in the comments—I read every single one, and as v2.3 proves, your comment might just become the next feature.

Top comments (0)