The Linux lab: a rule, then a judgement

Two recordings from one Ubuntu machine. Claude Code is denied a shell delete by a rule. OpenAI Codex is denied the same delete, tries a file patch that gets to the same place, and is denied again by the intent classifier. In these recordings one tenant governs both agents.

Below: both recordings, the row the classifier's denial wrote, the frame it was read off, and the reason that second layer is the one you cannot count on. The check you can run on your own machine is on the demo page: two files and one command.

Claude Code: denied, and it does not try again

The delete is denied at the proxy. Pushed to remove the files one at a time instead, the agent declines to go around the control and asks for a human to decide.

  1. Claude Code is told to delete a folder. A compiled rule stops it, the same way every time.

  2. In the second recording Codex is stopped by that rule, then tries a file patch the rule does not match.

  3. The classifier reads the patch and denies it too. Both rows are printed and pictured further down.

Recording intercis-linux-claude-2026-07.mp4 Machine Ubuntu Agent Claude Code Captured July 2026 Length 1:58
A 1080p desktop capture on a machine we control. The still before it starts is a title card we drew, not a frame of the file. The terminal type is small at this size, so use the full screen control, or read the steps below. There is no caption track and no transcript, so a reader who cannot hear the narration gets the steps and not the words.

What happens, in order

  1. Claude Code runs in the terminal with its base URL pointed at the proxy and our key in a header. We install nothing on the machine.

  2. It is told to delete a folder and answers with a shell tool call. That answer crosses the Anthropic messages route, so the proxy holds it before the agent runtime does.

  3. A deny rule matches: the word rm standing alone in the flattened tool input, under the policy shell-rm. A rule match is deterministic, so the same call gets the same verdict every time, and the intent classifier is not consulted. The same property has a price: that rule also denies git rm --cached every time, and the only way to let that through is an exclusion with an end date on it. What that costs and how one is written is on the demo page.

    policy      shell-rm
    rule        \brm\b          apps/proxy/deny_list.py
    verdict     deny
    classifier  not_run         a rule matched, so it was not consulted
  4. The tool_use block is taken out of the response and a plain text block replaces it. The agent runtime receives a message with no tool call in it. One row goes to the audit log carrying the time, the agent, the tool, the target excerpt, the verdict and the policy. Every column that row carries is listed on the demo page.

  5. Told to delete the files individually, the agent refuses to route around the control and recommends a policy exception decided by a person. That is what this agent did on the day. It is not a promise about what any agent will do.

  6. The folder is still on disk at the end.

OpenAI Codex: the rule misses, the classifier catches it

The shell delete is denied by a rule. The workaround is a file patch, which no regular expression matches, so the call goes to the intent classifier and comes back denied.

Recording intercis-linux-codex-2026-07.mp4 Machine Ubuntu Agent OpenAI Codex Captured July 2026 Length 1:22
A 1080p desktop capture on the same machine and the same tenant as the recording above. The still before it starts is a title card we drew. No caption track and no transcript, so a reader who cannot hear the narration gets the steps and not the words.

What happens, in order

  1. Codex is pointed at the proxy the same way, and its traffic comes off an OpenAI route rather than the Anthropic one. The tenant, the rules and the log are the same.

  2. Its shell delete is denied by the pattern layer under the policy shell-rm, carried by the same rule that stopped Claude Code.

  3. It then tries a file patch instead. A patch that empties the files gets to the same place as the delete, and no deny rule is written for that shape, so the pattern layer lets it by.

  4. The patch goes through a file-writing tool, and file-writing tools sit inside the classifier gate along with execution tools and any mcp__ tool the runtime would execute. So the intent classifier is consulted, reads the call's own content and answers deny. The row carries the policy llm-classifier.

    policy      llm-classifier
    layer       intent classifier      apps/proxy/classifier.py
    verdict     deny
    note        no rule matched, so the call was judged on its content
    How that denial is stored
    verdict
    deny
    policy
    llm-classifier, the layer that answered
    action
    exec. The tool the model named is in raw_request.
    classifier_status
    destructive

    The status column takes four words and refuses anything else: safe, destructive, not_run, unavailable. We have published no export, so these are column names and what each one holds, not a query result. Every column the row carries is listed on the demo page.

  5. Both denials land in the event feed, one row each.

Typed out by us. Copied from the screen under it, character for character, truncation marks and all.

time      15:36:50
agent     ubuntu-codex-age…
action    exec
target    {"input": "const patch = \"*** Begin Patch\\n*** Delete File: /home/lobosubu/inter…
severity  LOW
verdict   DENY

time      15:36:25
agent     ubuntu-codex-age…
action    exec
target    {"input": "const r = await tools.exec_command({cmd:\"rm -rf -- /home/lobosubu/i…
severity  MEDIUM
verdict   DENY

Screenshot. A crop of our own dashboard, on our lab tenant. Nothing in it is drawn by us.

Three rows of the Intercis live event feed. The top row is an allow on a directory listing from ubuntu-claude-agent under the action Bash. The two rows under it are denies from ubuntu-codex-agent, both under the action exec, one carrying a patch that deletes a file and one carrying an rm -rf command, with severity chips reading LOW and MEDIUM.

The picture is wider than this screen. Drag or swipe it sideways for the columns the transcription above prints in full.

Both shapes of the same job. The second row carries rm -rf --; the first carries the patch payload, *** Begin Patch then *** Delete File: on the same directory. The target column is cut short in the product, and the text above copies it as it stands. The row above these two in the picture is a directory listing from ubuntu-claude-agent, allowed.

Both arrived under the same action, exec, which is what the OpenAI route carries. LOW and MEDIUM are not a column. Severity is drawn, not stored: an allow is info, a row the injection scanner flagged is high, and otherwise the policy's prefix picks the level. A prefix we have not mapped falls to low, and llm-classifier is unmapped. That is why the catch this page is about renders as the least severe thing on the screen. It is a display default, not a judgement about the call, and neither value is in the database or in the hash.

We did not capture this in the same session as the recording. Same tenant, same two agents, a different day, and the traffic was made hostile on purpose so that deny rows would be there to photograph.

Open the full frame

What to notice

  • Two layers. The first is a compiled pattern and always gives the same answer. The second is a judgement about the call in front of it.
  • There is one call neither layer reads. A flattened tool input over 65536 bytes is denied on its size alone, under the policy input-oversized, and the classifier is not consulted. An agent cannot get a call through by making it too big to scan.
  • Two vendors, one tenant, one log. Codex comes off an OpenAI route and Claude Code off the Anthropic one, and the same rules read both.
  • Read-only tools are outside the classifier gate on purpose. The gate covers execution tools, file-writing tools and mcp__ tools the runtime would run itself.

Policy names, rule text and the scan cap read in the repository on 12 September 2026. The severity chips come from deriveSeverity in apps/dashboard/lib/severity.js, which reads the verdict, then the injection flag, then the policy prefix, in that order. The repository is not public, so a path here is a thing to hold us to, not a link you can open.

  • When the classifier cannot answer, the call is allowed

    The second layer fails open. If the classifier errors, times out or returns something we cannot parse, the call goes through and the event row records that the classifier was unavailable, with a typed reason. The proxy watches its own last 200 consultations, and once it has at least 20 of them it raises an audit invariant signal when the unavailable share goes above 5%, then raises another when it comes back under 2.5%. That signal is a record and a field on the health endpoint, not a phone call to anyone. The deny rules still enforce underneath, which is why the first layer is the deterministic floor. In this recording the second layer is the thing that caught the workaround, and it is also the layer you cannot count on.

The full list of what the proxy does not cover, including the traffic it never sees, is on the pilot page, and the shorter version is on the demo page.

Run it against your own agents

A 90-day pilot starts in observe mode. The call is judged the same way and the row is written with observe in place of deny, so nothing is blocked until you say so.