Testing Stratium against a real agent¶
How to put this system in front of a live coding agent and watch it deny something, using the enforcement surfaces that actually exist.
Everything in Parts 1–3 was executed end to end on 2026-08-20 against a
live stack, a real Claude Code hook contract, and the real
@modelcontextprotocol/server-filesystem. The output shown is what was
observed. Part 4 (ChatGPT) is the honest exception and is marked as such.
Read this first: what can and cannot be intercepted¶
Stratium does not integrate with a model. It integrates with the harness
that executes the model's tool calls, and it can only enforce where that harness
gives it a hook. stratium-pep ships four enforcement surfaces:
| Surface | What it is | Fits |
|---|---|---|
hook |
A Claude Code PreToolUse hook. Reads tool_name/tool_input on stdin; exit 0 allows, exit 2 denies with a reason. |
Claude Code |
mcp |
An MCP proxy over stdio. The harness launches it in place of a real MCP server; it spawns the real one as a child and gates tools/call. |
Any harness that runs a local stdio MCP server |
extauthz |
A gRPC server answering Envoy's ext_authz Check. Envoy calls it before forwarding any HTTP request. |
Any agent calling an HTTP API you control |
proxy |
A gRPC proxy in front of a target service. | gRPC tools |
The consequence for the two products in the title is asymmetric, and worth being blunt about:
- Claude Code has a first-class integration — the
hooksurface exists specifically for it, and it intercepts every tool call includingBash. - ChatGPT has no equivalent. There is no ChatGPT tool-call hook, and this project ships nothing ChatGPT-specific. What you can do is gate the tools it reaches: an MCP server (Part 3, if your client runs local stdio MCP) or an HTTP API behind Envoy (Part 4). See Part 4 before planning around it.
Part 0 — shared prerequisites¶
Bring the stack up and set the environment exactly as in the resource-labelling
plan (docs/testing/2026-08-19-resource-labelling-manual-test.md, §0 and §1) —
dc, psqlm, $PAP, $PDP, $TENANT, and an $EDITOR token. Then:
0a — Build the PEP¶
0b — A policy that lets your principal hold tools¶
Without this, nothing else works. session-start calls CreateDelegation,
which is clamped by what the principal may delegate; with no policy it fails
with none of the requested tools are held by this principal.
curl -sS -w '\n%{http_code}\n' -X POST "http://$PAP/v1/policies" \
-H "Authorization: Bearer $EDITOR" -H 'Content-Type: application/json' \
-d '{"name":"agent-trial-grant","description":"grant for an agent session",
"language":"rego","effect":"deny","enabled":true,
"policy_content":"package stratium.policy\n\nallow if {\n\tinput.principal.subject == \"agent-tester@example.com\"\n}"}'
Expected: 201, echoing the policy back.
"effect"must be"deny"for a Rego policy, and that is not a typo. A Rego policy can produce either outcome depending on the request, so the column holds a fixed placeholder a display screen must not read as behaviour. Sending"allow"returns a400that says exactly this.
0c — Seed the tool catalog for the agent you are testing¶
The catalog is what tells the PDP how to read a tool's arguments — which field is the resource, which is the command. Two seeds ship:
# Claude Code's twelve built-in tools
dc exec -T -e PGPASSWORD=stratium_migrate_local_dev_only postgres \
psql -h 127.0.0.1 -U stratium_migrate -d stratium -v tenant_id="$TENANT" -q \
-f /dev/stdin < deploy/seed/tool-catalog-claude-code.sql
# @modelcontextprotocol/server-filesystem's tools
dc exec -T -e PGPASSWORD=stratium_migrate_local_dev_only postgres \
psql -h 127.0.0.1 -U stratium_migrate -d stratium -v tenant_id="$TENANT" -q \
-f /dev/stdin < deploy/seed/tool-catalog-mcp-filesystem.sql
PASS if the row count rises:
psqlm -tAc "SELECT set_config('app.tenant_id','$TENANT',false);
SELECT count(*) FROM tool_catalog;" | tail -1
Claude Code's seed alone gives 12. Note two things in it that matter later:
Readusesfile_path, notpath.Glob,Grep,WebFetch,WebSearchandTodoWritehave noresource_field— deliberately. They cannot be resource-labelled at all.Bashhas no tier and acommand_fieldinstead: its tier is derived from the command text, server-side, per call.
0d — An agent row¶
export AGENT=$(curl -sS -X POST "http://$PAP/v1/agents" \
-H "Authorization: Bearer $EDITOR" -H 'Content-Type: application/json' \
-d '{"name":"claude-code-trial","provider":"anthropic","model_id":"claude",
"trust_tier":3,"enabled":true,"suspended":false,"allowed_tools":[]}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "AGENT=$AGENT"
Use a fresh name on each run — UNIQUE (tenant_id, name) applies.
Part 1 — Claude Code, by hand¶
Prove the hook works before wiring it into a real session. Everything here is exactly what Claude Code itself will do.
1a — Start a session¶
export SESSION=/tmp/stratium-session.json
STRATIUM_PEP_PDP_ADDR="$PDP" \
STRATIUM_PEP_AGENT_ID="$AGENT" \
STRATIUM_PEP_SUBJECT="agent-tester@example.com" \
STRATIUM_PEP_SESSION_FILE="$SESSION" \
STRATIUM_PEP_CONNECT_TIMEOUT=5s \
STRATIUM_PEP_REQUESTED_TOOLS="Read,Grep,Glob,Write,Bash" \
STRATIUM_PEP_REQUESTED_TIER=3 \
STRATIUM_PEP_PURPOSE="agent trial" \
STRATIUM_PEP_TTL_SECONDS=3600 \
/tmp/stratium-pep session-start; echo "exit=$?"
cat "$SESSION"
Expected — exit=0 and a session file holding the granted scope (which
may be narrower than requested) plus a token:
{
"delegation_id": "9031097c-45fd-4265-a4f4-9e90994a9825",
"granted_scope": { "max_action_tier": 3,
"tools": ["Read","Grep","Glob","Write","Bash"] },
"token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."
}
PASS if granted_scope.tools is non-empty. An empty list, or
none of the requested tools are held by this principal, means 0b's policy is
missing or its subject does not match STRATIUM_PEP_SUBJECT.
1b — Call the hook directly¶
hook() {
echo "$1" | STRATIUM_PEP_PDP_ADDR="$PDP" STRATIUM_PEP_AGENT_ID="$AGENT" \
STRATIUM_PEP_SUBJECT="agent-tester@example.com" \
STRATIUM_PEP_SESSION_FILE="$SESSION" \
STRATIUM_PEP_CONNECT_TIMEOUT=5s \
STRATIUM_PEP_APPROVAL_POLL_INTERVAL=2s STRATIUM_PEP_APPROVAL_TIMEOUT=30s \
STRATIUM_PEP_CACHE_STALENESS_WINDOW=5s \
/tmp/stratium-pep hook
echo " exit=$?"
}
hook '{"tool_name":"Read","tool_input":{"file_path":"/tmp/notes.txt"}}'
hook '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}'
hook '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}'
hook '{"tool_name":"Write","tool_input":{"file_path":"/tmp/x.txt","content":"hi"}}'
Expected — three silent allows and one denial:
exit=0
exit=0
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"action tier DESTRUCTIVE exceeds delegation max_action_tier EXTERNAL_COMMS at depth 0"}}
exit=2
exit=0
PASS if lines 2 and 3 differ. That pair is the whole demonstration: both
are the same tool with the same delegation, and the only difference is the
command text. ls -la derives READ_ONLY and passes; rm -rf / derives
DESTRUCTIVE and is refused. The tier is computed in the PDP from
raw_input — the hook sends the arguments verbatim and classifies nothing, so
there is no local rule to bypass.
If every call denies with
missing required configuration: …, add the variables it names. The hook needs eight, and a missing one fails closed with the reason on stdout rather than allowing. That is correct behaviour, and it is why the reason text matters: a configuration failure and a policy denial look different.
1c — Make it enforce a classification¶
This is where the resource-labelling feature meets a real agent.
for r in '{"prefix":"","hierarchy":"nato","level":"UNCLASSIFIED"}' \
'{"prefix":"/secret/","hierarchy":"nato","level":"SECRET"}'; do
curl -sS -o /dev/null -w " rule -> %{http_code}\n" \
-X POST "http://$PAP/v1/resource-label-rules" \
-H "Authorization: Bearer $EDITOR" -H 'Content-Type: application/json' -d "$r"
done
DELEG=$(python3 -c "import json;print(json.load(open('$SESSION'))['delegation_id'])")
psqlm -q -c "BEGIN; SELECT set_config('app.tenant_id','$TENANT',true);
UPDATE delegations SET classification_caps='{\"nato\":\"CONFIDENTIAL\"}'
WHERE id='$DELEG'; COMMIT;"
hook '{"tool_name":"Read","tool_input":{"file_path":"/tmp/notes.txt"}}'
hook '{"tool_name":"Read","tool_input":{"file_path":"/secret/plans.txt"}}'
Expected:
rule -> 201
rule -> 201
exit=0
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"resource classification SECRET exceeds delegation cap CONFIDENTIAL in hierarchy \"nato\" (depth 0)"}}
exit=2
PASS if the two Read calls differ. Same tool, same session — the path is
the only variable, and the label came from a rule registered through the admin
API seconds earlier.
The cap is set with SQL because no route can set one. See the resource-labelling plan's §0. This is the single largest gap in the feature and is recorded in the spec's open items.
1d — Wire it into a real Claude Code session¶
Add a PreToolUse hook to your Claude Code settings, with the eight variables
in its environment. In .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "stratium-pep hook", "timeout": 10 }
]
}
]
}
}
Keep the timeout. Every tool call becomes a synchronous round trip to the
PDP; without one, an unreachable PDP hangs the session instead of failing.
stratium-pep hook reads its configuration from the environment, so those eight
variables must be exported in the shell that launches Claude Code. Run
session-start (1a) first — the hook reads the session file fresh on every
call, so a revocation between two tool calls takes effect on the next one.
docs/pep.mdis the operator reference for this surface and covers installation, every configuration value, and a "Reading a denial" section for telling a policy denial apart from a misconfiguration. It also states the rule worth internalising before you panic: every tool call is denied until a session is started and a policy permits it — so if you install the hook and see everything denied, that is most likely correct behaviour for an agent with no session, not a bug.
PASS if asking Claude to read /secret/plans.txt produces a refusal quoting
the classification reason, while reading another file succeeds. Claude will
surface permissionDecisionReason as the reason it could not proceed.
Start with a narrow matcher ("Read") rather than "*" on a session you
care about: every tool call becomes a synchronous round trip to the PDP, and a
misconfigured hook denies everything.
Part 2 — what the hook does not cover¶
Worth knowing before you trust a demo:
- Only tool calls. Anything the model does that is not a tool call is not seen. The hook is not a content filter.
- Fieldless tools cannot be labelled.
Glob,Grep,WebFetch,WebSearchhave noresource_field, so they produce no resource id and no rule matches them — not even the empty-prefix catch-all. Under a classification cap they deny outright. Test this before enabling a cap on a session that needs them. tools/listis not filtered on the MCP surface (Part 3); an agent can see a tool it cannot call and finds out at call time.
Part 3 — Any MCP harness (Claude Desktop, Claude Code, others)¶
The mcp surface wraps a real MCP server. The harness launches
stratium-pep mcp; that process launches the true server as a child and relays
JSON-RPC both ways, calling the PDP for tools/call only.
Verified below against the real @modelcontextprotocol/server-filesystem.
3a — A session for the MCP tool names¶
The tool names are the upstream server's, not Claude Code's:
mkdir -p /tmp/mcp-sandbox/classified
echo hello > /tmp/mcp-sandbox/notes.txt
echo "top secret plans" > /tmp/mcp-sandbox/classified/plans.txt
export MCP_SESSION=/tmp/stratium-mcp-session.json
STRATIUM_PEP_PDP_ADDR="$PDP" STRATIUM_PEP_AGENT_ID="$AGENT" \
STRATIUM_PEP_SUBJECT="agent-tester@example.com" \
STRATIUM_PEP_SESSION_FILE="$MCP_SESSION" \
STRATIUM_PEP_CONNECT_TIMEOUT=5s \
STRATIUM_PEP_REQUESTED_TOOLS="read_text_file,write_file,list_directory" \
STRATIUM_PEP_REQUESTED_TIER=3 STRATIUM_PEP_PURPOSE="mcp trial" \
STRATIUM_PEP_TTL_SECONDS=3600 \
/tmp/stratium-pep session-start && echo "exit=0"
Expected: granted: ['read_text_file', 'write_file', 'list_directory']
3b — Label a path inside the server's own sandbox¶
This is the important detail. The filesystem server refuses anything outside its allowed directory on its own — so to prove Stratium denied something, the path must be one the upstream server would happily have served.
curl -sS -o /dev/null -w " rule -> %{http_code}\n" \
-X POST "http://$PAP/v1/resource-label-rules" \
-H "Authorization: Bearer $EDITOR" -H 'Content-Type: application/json' \
-d '{"prefix":"/tmp/mcp-sandbox/classified/","hierarchy":"nato","level":"SECRET"}'
MDELEG=$(python3 -c "import json;print(json.load(open('$MCP_SESSION'))['delegation_id'])")
psqlm -q -c "BEGIN; SELECT set_config('app.tenant_id','$TENANT',true);
UPDATE delegations SET classification_caps='{\"nato\":\"CONFIDENTIAL\"}'
WHERE id='$MDELEG'; COMMIT;"
3c — Drive it¶
cat > /tmp/mcp-frames.jsonl <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"trial","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_text_file","arguments":{"path":"/tmp/mcp-sandbox/notes.txt"}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_text_file","arguments":{"path":"/tmp/mcp-sandbox/classified/plans.txt"}}}
EOF
{ cat /tmp/mcp-frames.jsonl; python3 -c "import time; time.sleep(25)"; } \
| STRATIUM_PEP_PDP_ADDR="$PDP" STRATIUM_PEP_AGENT_ID="$AGENT" \
STRATIUM_PEP_SUBJECT="agent-tester@example.com" \
STRATIUM_PEP_SESSION_FILE="$MCP_SESSION" \
STRATIUM_PEP_CONNECT_TIMEOUT=5s STRATIUM_PEP_APPROVAL_POLL_INTERVAL=2s \
STRATIUM_PEP_APPROVAL_TIMEOUT=30s STRATIUM_PEP_CACHE_STALENESS_WINDOW=5s \
STRATIUM_PEP_MCP_UPSTREAM_COMMAND="npx" \
STRATIUM_PEP_MCP_UPSTREAM_ARGS="-y,@modelcontextprotocol/server-filesystem,/tmp/mcp-sandbox" \
/tmp/stratium-pep mcp 2>/dev/null
Expected — id=2 returns the file, id=3 returns a JSON-RPC error:
id=1 initialize -> ok
id=3 DENIED: resource classification SECRET exceeds delegation cap CONFIDENTIAL in hierarchy "nato" (depth 0)
id=2 ALLOWED: {"content": [{"type": "text", "text": "hello\n"}], ...}
PASS if id=2 returns hello and id=3 is an error naming the
classification. Responses arrive out of order — that is correct, the relay
is concurrent.
The trailing
sleepkeeps stdin open. Closing it immediately shuts the proxy down before the gated calls complete, and you see onlyid=1. A real harness holds the pipe open for the session's life.
3d — Wire it into a harness¶
Replace the real MCP server's command with stratium-pep mcp, moving the
original into STRATIUM_PEP_MCP_UPSTREAM_COMMAND/_ARGS. In an
MCP client config:
{
"mcpServers": {
"filesystem": {
"command": "/tmp/stratium-pep",
"args": ["mcp"],
"env": {
"STRATIUM_PEP_MCP_UPSTREAM_COMMAND": "npx",
"STRATIUM_PEP_MCP_UPSTREAM_ARGS": "-y,@modelcontextprotocol/server-filesystem,/tmp/mcp-sandbox",
"STRATIUM_PEP_PDP_ADDR": "localhost:PORT",
"STRATIUM_PEP_AGENT_ID": "…",
"STRATIUM_PEP_SUBJECT": "agent-tester@example.com",
"STRATIUM_PEP_SESSION_FILE": "/tmp/stratium-mcp-session.json",
"STRATIUM_PEP_CONNECT_TIMEOUT": "5s",
"STRATIUM_PEP_APPROVAL_POLL_INTERVAL": "2s",
"STRATIUM_PEP_APPROVAL_TIMEOUT": "30s",
"STRATIUM_PEP_CACHE_STALENESS_WINDOW": "5s"
}
}
}
}
The trust model depends on the harness launching this process, not the agent. That is why the surface is stdio and not HTTP: the session file it reads is not something the agent supplied.
Part 4 — ChatGPT¶
This part was not executed. Everything above ran; this did not, and the reason is the finding.
There is no ChatGPT tool-call hook. Claude Code exposes a PreToolUse
extension point and this project implements it; ChatGPT exposes no equivalent,
and nothing here is ChatGPT-specific. So you cannot intercept ChatGPT's tool
calls the way Part 1 intercepts Claude Code's. Any document claiming otherwise
is describing something that does not exist.
What you can do is gate what it reaches. Two routes, in order of confidence:
4a — HTTP tools behind Envoy (works regardless of client)¶
If ChatGPT reaches a tool through an HTTP API you control — a custom GPT action,
a function whose implementation is your service, an agent framework calling your
endpoint — put Envoy in front of it with ext_authz pointed at
stratium-pep extauthz. Every request is checked before it is forwarded.
The compose stack already runs this shape: envoy (port 10000) → ext_authz →
pep-extauthz (9191) → echo-upstream. Use deploy/docker/envoy.yaml as the
worked example.
This does not depend on ChatGPT at all — it enforces at your API, so it works for any caller. It is the route to plan around.
What it costs you: the PDP sees an HTTP request, not a tool call with named
arguments, so tier derivation and resource labelling are only as good as what
decideRequestFromCheck can extract from the method, path and body. Expect to
catalog the endpoint as a "tool" and choose a resource_field deliberately.
4b — MCP, if your client runs local stdio servers¶
Part 3's proxy is protocol-level, not client-specific: it works with any client that launches a local stdio MCP server. If your ChatGPT client does, it will work there too, unchanged.
Verify that before designing around it, because two things must both hold: the client must support MCP and support local stdio servers rather than only remote HTTP/SSE connectors. This proxy is stdio-only by design — the trust model in 3d depends on it. A remote-MCP-only client cannot use it.
The check: configure it exactly as in 3d and see whether the client lists the tools. If it does, Part 3's test applies verbatim.
4c — What will not work¶
- Pointing ChatGPT's web connectors at
stratium-pep mcp. It speaks stdio, not HTTP/SSE. There is no remote-MCP mode. - Expecting
Bash-style interception. ChatGPT's own code interpreter runs in OpenAI's sandbox; nothing local sees those calls.
What this proves, and what it does not¶
Proven live:
- A real Claude Code hook contract denies
rm -rf /and allowsls -lathrough the same tool, with the tier derived server-side. - A resource-label rule registered through the admin API changes what a live agent may read, one call later.
- A real third-party MCP server is gated at
tools/call, with a denial the agent receives as a JSON-RPC error.
Not proven, and not claimed:
- Anything about ChatGPT. Part 4 is analysis of the surfaces, not a test result.
- Any behaviour of a real Claude Code session — 1d is a configuration recipe; 1a–1c are what was executed.
- Approval flows. A tool call that escalates rather than denies will block on a human approval; none was exercised here.
- Performance. Every tool call is a synchronous PDP round trip.
Cleaning up¶
docker compose -f deploy/docker/docker-compose.yml down -v --remove-orphans
rm -f /tmp/stratium-pep /tmp/stratium-session.json /tmp/stratium-mcp-session.json \
/tmp/mcp-frames.jsonl
rm -rf /tmp/mcp-sandbox
Remove the PreToolUse hook from your Claude Code settings if you added it in
1d — it denies every tool call once the stack is down.