A guard I built for Claude Code detonated on its own commit message — redrawing the boundary between checker and checked by what the content actually is

AI Claude Code Operations テスト設計 検証

Introduction

I have written before about building a “mechanism that stops an AI coding assistant before it acts”.

Building a mechanism that makes an AI coding assistant “not do it” — from advice to enforcement, and the side-door left open in the gate itself

An extension of that led to building one more guard, because typing a connection port number from memory alone and breaking shell quoting kept happening inside the same stretch of work. The guard cuts into execution and mechanically inspects the contents of the command about to run. While building it, the guard hit an unglamorous but troublesome defect twice: it pulled itself into its own inspection scope. This article records those defects and the adversarial tests written to fix them.


What the guard inspects

What I built cuts in immediately before a command runs and inspects two kinds of value.

  • A. Port numbers with no source. If a host:port form appears in the command, use git grep to check whether that port number is actually written somewhere in the workspace’s source. If not one hit is found, stop execution on suspicion that it was typed from memory. But if the inspection itself fails (search unavailable), do not stop; let it through (fail-open). Treating “cannot inspect” as a violation would stop the work itself in an environment where the tool is unavailable.
  • B. Long code embedded directly in the shell. Stop when more than 200 characters of code are embedded directly inside shell quotes, in a form like python -c "...". Accidents with nested quotes and variable expansion are almost entirely prevented simply by stopping this and writing to a file instead.

The actual accident that prompted it went like this. I wrote the connection port number from memory, and every connectivity check was refused. The correct value was a different number one character away, and it was written as a constant in another file. It sat somewhere a one-second look would have settled — and I wrote it from memory instead of going to the source.


The guard reacted to itself (1) — explanatory prose inside a heredoc

When committing the change to this guard, I wrote into the commit message body the guard’s own explanation: “stops when more than 200 characters of code are embedded in a form like python -c”. The commit message ran to many lines, so I wrote it in the shell using the syntax that “passes the following lines through as data” (a heredoc).

There the guard false-flagged itself. A heredoc’s contents are not code the shell executes but plain data passed to the command (here, the commit-message string) — yet the guard included those contents in its code inspection. The result: prose that merely explained my own change was mistaken for “long code embedded in the shell”, and execution stopped.

the boundary of what is inspected (vertical = a command passing through inspection,
                horizontal = branching by the nature of the string, nesting = before and after the fix)
 the command string

   ├─▶ outside the heredoc ── code the shell actually interprets
   │        ├─ before ── inspected ── ✅ correct
   │        └─ after  ── inspected ── ✅ unchanged ─────────────────┐
   │                                                                 │
   └─▶ inside the heredoc ── plain data passed to the command        │
            ├─ before ── inspected ◀── false positive ①              │
            │      └─ the words "python -c" simply happened to       │
            │         appear in the commit message's prose           │
            │              └─ the prose explaining my own change     │
            │                 stopped my own execution               │
            │                    │                                   │
            │                    └──(return edge)──▶ redraw the boundary
            │                        from "path" to "the nature of the content"
            │                                          │             │
            └─ after ── strip from the opening marker to the closing marker, out of scope
                              │                                       │
                              └───────────┬───────────────────────────┘

                    split one and the same command string in two by nature, then inspect

The fix was to strip the range from the heredoc’s opening marker to its matching closing marker out of the inspection scope first. This was the same shape as a lesson I had established earlier in a different context — “the term you are extracting can appear in an unintended position inside a quoted string or a message and match by mistake”. A lesson I had supposedly learned once, hit again in a different implementation.


The guard reacted to itself (2) — verification values inside test code

The other false positive occurred while writing the adversarial test code for confirming the guard’s correctness.

A test confirming that “port numbers with no source are stopped” needs, deliberately, “a port number that should exist nowhere in the workspace”. Writing that number as a literal in the test code meant that, because the guard’s search range was the whole workspace (including the file the test code itself lives in), the guard’s search picked up that very statement in the test code as a hit. A test that wanted to confirm “this port exists nowhere” was, by the guard’s own search, judged to “exist (inside the test code)” — and the property under test was destroyed.

the range searched for a source (vertical = the search running, horizontal = which files count as a source,
                nesting = what the value placed there really is)
 pick up one port number

   └─▶ git grep across the whole workspace

          ├─▶ production code ── values actually used as connection targets
          │        └─ hit ─▶ ✅ may pass as "a verified value" ─────────┐
          │                                                             │
          ├─▶ configuration files ── likewise values actually used      │
          │        └─ hit ─▶ ✅ may pass ────────────────────────────────┤
          │                                                             │
          └─▶ test code ── fake values that look real, written          │
                   │        deliberately to confirm "there is no source"│
                   ├─ before ── hit ─▶ ⛔ false positive ② ──────────────┤
                   │      └─ a test wanting to confirm "exists nowhere"
                   │         self-affirms, taking its own statement as a source
                   │              └─ that test passes forever afterwards │
                   │                    (= the property under test is gone)
                   │                       │                            │
                   │                       └──(return edge)──▶ redraw the definition
                   │                           of the search range itself
                   │                                                    │
                   └─ after ── exclude files matching the test naming convention ─┤

                                    only what is actually used may be called "a source"

The fix was to exclude test files (those placed under naming conventions like test_*.py) from the search scope explicitly. A value written in test code is not a source for a value actually used as a connection target.


What the two false positives had in common

Both had the same root. I had drawn the boundary between “the checker” and “the checked” purely by file path and directory, without looking at the nature of the content (code that will be executed or plain data; a production value or a fake written for verification). A heredoc’s contents “look like code but are data”; a literal in test code “looks real but is fake”. Both look on the surface like part of a command string or a source file, but their actual roles differ.


Confirming both directions with adversarial tests

After the fix I wrote 24 checks covering both directions: whether things that should pass are being wrongly stopped (over-detection), and whether things that should be stopped are being missed (under-detection). Run in the environment as actually deployed, the results were:

[OK ] 許可: ソースに存在するポート(<実在する番号>)  rc=0
[OK ] 拒否: ソースに存在しないポート(<非在の番号>)  rc=2 contains=True
[OK ] 許可: well-known ポート(443)  rc=0
[OK ] 誤爆しない: 時刻表記 12:34  rc=0
[OK ] 誤爆しない: バージョン番号 1.5.8  rc=0
[OK ] 誤爆しない: Windows パス C:\Users  rc=0
[OK ] 拒否: python -c に長いコードを埋めている  rc=2 contains=True
[OK ] 誤爆しない: メッセージ内に 'python -c' の語が出るだけ  rc=0
[OK ] 誤爆しない: heredoc 本体の説明文に 'python -c' と長文が入っている  rc=0
[OK ] 誤爆しない: heredoc 本体にソース非在のポート番号が語として出てくる  rc=0
[OK ] 拒否: heredoc の外側にある実行対象のインラインコードは検査される  rc=2 contains=True
[OK ] 空 stdin で落ちない  rc=0
[OK ] 壊れた JSON で落ちない  rc=0
[OK ] command 欠落で落ちない  rc=0

failures=0

(An extract of the real output. Only the port number that actually exists is masked with <…>. The full 24 divide into: confirming what should be allowed, confirming what should be refused, confirming there are no false alarms, confirming heredoc boundaries, and confirming resilience to malformed input.)

What I kept in mind here was not simply lining up “cases that work” but making the shape of each false positive I had once hit into the name of a test item. The test “a port number absent from the source happens to appear as a word in a heredoc body” was written precisely to reproduce defect ②. It serves as a regression test against embedding the same defect again.


The limits of this design

The guard chooses a design in which “if the search fails, do not stop” (fail-open). That is a judgement made so as not to halt the work itself, but the flip side is that when the search fails it does raise a warning, yet exerts no mechanical stopping effect. How often this route is taken is not aggregated, so the reality is unknown.

Also, what I found and fixed here is only the two self-references I actually hit and noticed mid-work. Whether defects of the same shape remain elsewhere can only be confirmed within the scope of these tests. The boundary between checker and checked turned out to be the kind of thing you can only redraw each time you find it.

Feel free to send a message

Job offers, project referrals, feedback, questions — anything is welcome. I sincerely hope to connect with people who share high ambitions. I will keep taking on the challenges I have staked my life on. Thank you very much.