An agent asks to delete a folder. It does not happen.
Three screen recordings from lab machines we own. A coding agent is pointed at the Intercis proxy by changing its base URL and adding 2 headers, 1 of them required: the key. Then it is told to delete a folder. The delete is taken out of the model's reply before the agent runtime reads it, and the folder is still there at the end.
These two rows came off our lab tenant, in a session we ran on purpose so there would be deny rows to photograph. Under them: a check you can run, the recording, the rule, and what this stops that you did not mean to stop. None of it is behind a form.
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.
The picture is wider than this screen. Drag or swipe it sideways for the columns the transcription above prints in full.
These two rows are Codex, from the second recording on the Linux lab page. The Claude
denial walked through below is not pictured anywhere on this site: we did not photograph
it and we did not transcribe it, so the two commands above are the only captured denied
payloads the site shows you in full. The row above them in the picture is a directory
listing from ubuntu-claude-agent, allowed. LOW and
MEDIUM are not a column;
severity is drawn, not stored.
Open the full frame. It is the whole dashboard from that session, so the counters on it describe a session we made hostile on purpose and nothing else on this page. What ordinary traffic does to the rules is further down, with the labels it was measured under.
Check the chain on your own machine
Three files. Three fetches and one check. No form and no account.
curl -O https://intercis.io/evidence/verify_event_chain.py
curl -O https://intercis.io/evidence/sample-chain.json
curl -O https://intercis.io/evidence/event_hash_trigger.sql
python3 verify_event_chain.py sample-chain.json
This is the whole of what it prints, and it exits with code 1:
tenant 6f5c1a9e-3d24-4a7b-9f01-2c8e7b4d5a60: FIRST BREAK at event id 2d8f3e04-6b52-4c17-90a3-8e1d5b7c4f26 — event_hash mismatch under format v1: stored 6befbb993a0a61b2…, recomputed 712dc0ec4922331a… (row fields altered) (5 events checked; 2 v1, 0 v2 verified before the break)
verified 2 rows under format v1 and 0 under v2.
This export carries no chain_version column, so every row was read as format v1.
An ok verifies, for a v1 row, the ten chained fields (id, tenant_id, agent_id, action, target, verdict, policy, injected, source, created_at) and nothing else about that row; for a v2 row, every events column except raw_request.
Ceiling, unchanged: there is no external anchor. A writer who can rewrite a row and every hash after it — or drop the trigger — is not detected here, in either format.
It fails because we broke it. The third row in the file is a
terraform destroy -auto-approve under the policy
infra-terraform-destroy, and we changed its verdict to allow
after its hash was written. The first two rows verify from genesis, the third does not, and
the verifier stops there and names it. Anyone with the right database rights can rewrite a
row like that. The chain does not stop them. What it does is make this rewrite show,
because it left the hashes after it alone. One that recomputed them, or that dropped the
last rows instead, would print ok: the line above says so.
sample-chain.json is five rows we built to be read, not five rows out of anybody's tenant, and the hashes in it are real SHA-256 over the fields the database trigger hashes. Those five rows carry the earlier of the two chain formats, the ten named fields the trigger hashed before 2026-09-20, which is the format the output above calls v1; a row written today is hashed over every column of the event except the tool input. The verifier recomputes each row's hash from that row's own fields and the hash of the row before it. It is standard library Python with no network calls, so you can read it before you run it.
That is our script against our file, which on its own proves nothing. So the database code that writes the hash is the third file: event_hash_trigger.sql, the five functions and the trigger copied out of the two migrations that define the formats. Read one against the other and check that the formula matches.
What you cannot do yet is run this on your own chain. There is no self-serve export of the hashes: the CSV the dashboard writes has eight columns and neither hash column is one of them, and no endpoint hands out the head hash. So today the check runs on the file we publish. Your own rows wait on an export we do not write.
Claude Code on Ubuntu
1:58. One agent, one denied delete, and what it tries next.
An agent is pointed at the proxy by its base URL and 2 headers, then told to delete a folder.
The model answers with a shell tool call. A rule matches it on the way back.
The tool call is gone from the reply. The folder is listed at the end and it is still there.
What happens, in order
-
Claude Code is running in the terminal. Its base URL points at the Intercis proxy and it sends the key we issued in a header. We install nothing on the machine and no code inside the agent changes.
-
It is asked to delete a folder. The model answers with a shell tool call, and that answer crosses the Anthropic messages route, so the proxy has it in hand before the agent does. We did not capture that line, so this page cannot print it. The rule that caught it is in the next step, and the two payloads at the top of the page are the same job from the other agent on the same lab.
-
A deny rule matches on the way back: the word
rmstanding alone in the flattened tool input, under the policyshell-rm.policy shell-rm rule \brm\b apps/proxy/deny_list.py verdict deny classifier not_run a rule matched, so it was not consultedThe rule is blunt. It matches that word anywhere in the flattened tool input, so
git rm --cachedandnpm rm lodashmeet it too. What that costs a team, and how one of those gets released, is further down this page. -
The
tool_useblock is removed from the response and a plain assistant text block takes its place. What the agent runtime receives has no tool call in it, so there is nothing for it to run. -
Pushed to delete the files one at a time instead, the agent declines to go around the control and says a human should decide whether the policy has an exception. That is what this agent did on the day. It is not a promise about what agents do. The folder is listed at the end and it is still there.
The row a denial writes
The two rows at the top of this page are stored in these columns. This is what each one holds.
- created_at
- When the proxy judged the call.
- agent_id
- Which registered agent sent it.
- action
- The tool the model asked for.
Bashin the recording above,execon the OpenAI route. - target
-
The command when the call carries one, otherwise the whole tool input serialised as JSON,
capped at 500 characters. Credential material is masked in it. It is not part of the 90-day
scrub below:
raw_requestgoes,targetstays, so up to 500 characters of the same payload are kept for as long as the row is. - verdict
-
deny here. Postgres takes four words in this
column and refuses anything else:
allow,deny,observe,pending. - policy
- The rule that matched.
shell-rmfor the delete above. - classifier_status
not_run. A rule matched, so the classifier was not consulted.- source
anthropic-messages, the wire route the call came off.- injected
false. The injection scanner matched nothing in the request.- command_fingerprint
- A fingerprint of the command.
- raw_request
- The structured tool input. Credential material is masked before the row is stored; if the masker itself fails, the row is stored unmasked and the failure is logged. The column is set to null 90 days later by a nightly job. The rest of the row stays.
- event_hash, prev_event_hash
-
A SHA-256 over the previous row's hash and the fields of this one, written by a trigger
on insert. One chain per tenant. On a row recorded from 2026-09-20 it covers every column
of the row except
raw_request, which is outside the chain on purpose: the nightly job that nulls it would otherwise break every chain, so the one column holding the tool input is the one column the chain does not cover. Rows recorded before that date are hashed over ten named fields and are never re-hashed, so the rest of an old row stays outside its hash. And the chain makes the log tamper evident, not tamper proof. Anyone with database owner rights can rewrite rows and recompute the hashes.
Severity is not one of these columns. The dashboard draws it and the database never stores
it. An allow is info. A row the injection scanner flagged is high,
or critical when the policy is a credential one. Otherwise the policy's prefix
picks the level, and a prefix we have not mapped falls to low.
llm-classifier is unmapped, which is why the classifier's own deny reads LOW at
the top of this page while the shell rule's deny reads MEDIUM. It is a display default, not
a judgement about the call. None of it is in the hash, and the CSV the dashboard exports
carries the drawn value anyway.
Columns and the four-word verdict constraint:
infra/supabase/migrations/000_events_base.sql. Chain and trigger:
040_event_hash_chain.sql, widened to every column but the tool input by
054_events_chain_v2.sql, applied 2026-09-20. The 90-day scrub and the note that
target is kept: 035_events_raw_request_retention.sql. Severity:
apps/dashboard/lib/severity.js. The export columns:
apps/dashboard/components/ExportButton.jsx. Read in the repository on
12 September 2026. The repository is not public, so a path here is a thing to hold us to, not a
link you can open.
What to notice
- The denial arrives with the response, not after the command ran. Nothing was done, so there is nothing to roll back.
- The control sits outside the agent process. That is the point, and it is the limit.
-
The rule that matched is one of 111 compiled rules in
a single file, counted in the repository on
6 October 2026, at commit
7024101. They carry 106 policy names across 18 category prefixes, a count read on 12 September 2026. The repository is not public, so that is our count and you cannot check it from outside today. - A policy that stops something you need can be released for a narrow scope, and the release expires. What writing one takes, and the four classes it cannot touch, is below.
What these recordings do not show
A recording proves the mechanism runs. It does not prove the rules are right.
-
It will stop things you meant to run
The rule does not read the command around the word it matches.
cargo rm serdemeets it. So does a shell line that only mentions thermman page. We ship it blunt on purpose. A pattern that always fires is worth more than one that sometimes misses a delete, and a stopped package uninstall is what that costs.On our own traffic, over the window we publish, 737 of 4,764 governed calls are flagged by today's rules. That is 15.47%, with a 95% interval of 14.47% to 16.52%. 516 of the 737 are calls enforce mode would have removed; the other 221 are injection-scan rows, and that scanner never blocks in any mode. Of the deny-list flags, this rule wrote 138. Every bucket in that window was read and labelled benign in context, by bucket rather than call by call. The window runs from 25 August to 4 September 2026, with 31 August taken out because a four-hour classifier outage voided it. The page behind that figure carries the labels, the excluded day and the count for every policy family.
The way to release one is an exclusion, and an exclusion has to expire. It can be scoped to a tenant, an agent, a single command fingerprint, a path, or a literal substring of the call. Every one needs an end date and a written reason, and rows are revoked rather than deleted, so the list of what was excused and when is part of the log.
infra/supabase/migrations/028through031.Four classes cannot be released at any scope: credential reads, secret stores, log tampering and obfuscated execution. Nine Windows policies are named one by one in the same file for the same reason. A team that needs one of those allowed narrows what tools the agent may call instead. And a pilot starts in observe mode, so nothing is blocked until you turn enforcement on, which the pilot plan puts in week 5. In weeks 1 to 4 you read the log, and the rows say observe.
-
It is a lab, and all of it is ours
Our machine, our agent, our command, our tenant, our corpus, our counts. No third-party test, no audit of the log, no customer you can call. The one figure we can defend comes from that corpus: the pattern layer catches 40 of the 47 payloads in it, which is 85.1%, with 7 documented bypasses, in the corpus run of 12 September 2026. We wrote the corpus, so it measures what we thought to test for and not what an attacker would try. The validation report carries the figures with their labels. Until this runs on your traffic, the rest is a claim, and that is what the 90 days are for.
-
What we do not govern
We govern tool calls that cross the Anthropic and OpenAI API wire. Tools the provider runs on its own servers have already run by the time the response reaches us, so hosted MCP calls, web search, file search, code interpreter and image generation are observed, not governed: each writes one observe row and is never judged or blocked. Being in-path is a setting, not a law: the agent routes through us because its base URL says so, and a fully compromised process can point it somewhere else, so pair us with an egress rule. A streaming request is buffered here before it is judged, which adds latency equal to the whole stream. And we are a new trust boundary that sees your prompts in plaintext, with no self-hosted build today. The five costs, the three gaps and the one chain limit are written out in full. Read them before you spend a week on us.
The other two recordings
One tenant and one set of rules govern both agents. Both were captured in July 2026.
Linux lab
The recording above, and a second one where OpenAI Codex is denied a shell delete, tries a file patch to get the same result, and is denied again by the intent classifier. The two rows at the top of this page came off that recording's tenant.
Windows lab
One recording: install, config, denied delete. Both agents, one Windows 11 machine, the PowerShell rule that matched printed in full, and a diagram of where the delete is removed.
Run it against your own agents
A 90-day pilot starts in observe mode: the same judgement, the row written with observe in place of deny, and nothing blocked until you say so.