Quickstart — deploy/stratium¶
How to get from a fresh clone to a coding agent whose tool calls are actually being authorized, and back out again.
Every command below was run against a live stack while writing this. The output under each step is what was observed, not what the code suggests should happen. Ids, ports and timings will differ on your run; everything else should match.
deploy/stratium is a single Python 3 file with no third-party dependencies. It
drives the product but is not part of it — you can delete it and the system still
works, you just have to do all of this by hand.
0. Before you start¶
You need Docker (with docker compose, v2 — not the old docker-compose), a Go
toolchain, and Python 3. The script checks all three before doing anything else
and names what is missing.
Everything below runs from the repository root.
1. Bring it up¶
The harness flag is required and has no default. The two harnesses write
different files, and a hook installed on the one you are not running is
silently no enforcement at all — so the script makes you say which. Use
--harness codex, or --harness both.
Observed (about 80 seconds warm; the first run is longer because it builds images):
[1] Preflight
ok docker, docker compose and go are present
[2] Stack up (this is the slow one -- it builds images)
ok compose project: docker
ok pap=localhost:51465 pdp=localhost:51458 kc=localhost:64634
resolved now, never stored -- they change on every compose up
[3] Build the PEP
ok /Users/you/stratium/bin/stratium-pep
[4] Policy so the principal may delegate
ok already present (subject agent-tester@example.com)
[5] Seed the tool catalog
ok tool_catalog rows: 29 -> 29
[6] Agent row
ok reused: quickstart-46384b (0f8f833a-ef4c-4e7e-bbb5-00d259cf5c19)
[7] Classification caps
ok already present (catch-all, so every other probe stays UNCLASSIFIED)
ok already present ("/secret-*" -> SECRET)
ok created ("/tmp/secret-*" -> SECRET)
ok created ("/private/tmp/secret-*" -> SECRET)
ok quickstart-46384b capped at {'nato': 'CONFIDENTIAL'}
verify's classification case needs this; state['caps'] is how it knows
[8] Delegation
ok 1f9a667e-1e50-426d-a2c9-f9e9b0321cdb grants Read,Grep,Glob tier 3
[9] Wire the hooks
...
[10] Verify -- onboarding is not done until a denial is observed
PASS if step 10 ends with four PASS lines and Enforcement proven. See §3.
The run above is a warm one —
already present,reused,29 -> 29. On a genuinely first run those readcreated,created, and0 -> 29, and the whole thing takes minutes rather than 80 seconds because it builds the images. Every step is idempotent, so re-runningupis safe and is the fastest way to repair a half-configured stack.
What those ten steps actually did¶
Steps 4 and 5 are the two that look like boilerplate and are not. Without the
policy in step 4, session-start fails with "none of the requested tools are
held by this principal" — the delegation cannot be issued at all. Without the
catalog in step 5 the PDP has no way to know which argument of a tool call names
the resource, so nothing can be classified.
Step 7 registers two label rules and caps the agent. The catch-all ("" prefix →
nato UNCLASSIFIED) matters more than it looks: once any cap is set, a resource
whose label cannot be resolved is denied. Without a catch-all, capping an agent
denies essentially everything. The script refuses to set a cap without one — see
§7.
Step 8 issues a root delegation granting a deliberately narrow Read,Grep,Glob.
That narrowness is what makes step 10 able to prove anything.
Open the console¶
Step 2 above printed a pap=host:port line — up and status both do, and
neither ever writes it to disk (ports are ephemeral; §3 covers why the tool
persists identity and never addresses). Open http://<that address>/ in a
browser and sign in with one of the seeded Keycloak accounts, e.g. pap-admin
/ pap_admin_local_dev_only (docs/user-guide.md §2.2 lists all three). The
label rules and the classification cap step 7 just registered are editable
there too, alongside policies, agents, the tool catalog, and approvals —
docs/user-guide.md §5 is the full reference.
2. Finish the wiring¶
If you ran up from an ordinary terminal, step 9 wired the hooks and you can
skip to §3.
If you ran it from inside a Claude Code or Codex session, step 9 stopped:
[9] Wire the hooks
SKIPPED -- this is running inside a claude session.
Installing now would deny every tool call in it, including the ones
you would need to recover: the session picks the hook up live, but it
was not launched with the environment the hook reads.
Finish from a plain terminal:
deploy/stratium install --claude --bin /Users/you/stratium/bin/stratium-pep
deploy/stratium run claude
Or re-run with --wire-anyway if you meant to disable this session.
This is deliberate, and it is the trap most worth understanding. A PreToolUse
hook with matcher: "*" takes effect in an already-running session immediately.
That session was not launched with the eleven environment variables the hook
reads, so every tool call in it starts denying — including the ones you would use
to undo it. Run the two commands from a plain terminal instead.
Observed:
installed SessionStart + PreToolUse (matcher '*', command '/Users/you/stratium/bin/stratium-pep') in .claude/settings.json
This gags every agent session already open in this project — including one you may be reading this guide with. Installation takes effect live, and a session started any other way lacks the environment the hook reads, so its next tool call denies with
missing required configuration. That is the hook working correctly, not a fault.Restart those sessions through
deploy/stratium run claude(§3), or back the hooks out withdeploy/stratium --claude uninstall— removal takes effect live too, and the session recovers on its next call.
3. Launch an agent¶
Always start the agent through the launcher:
Anything you would pass to claude goes after it: deploy/stratium run claude --resume.
The launcher resolves the Docker ports and the agent id fresh, exports the eleven
STRATIUM_PEP_* variables, and execs the real binary. Starting claude
directly leaves it denying every tool call for want of configuration.
Why a launcher and not a config file¶
Two of the eleven values cannot be stored:
STRATIUM_PEP_PDP_ADDRis a Docker ephemeral port. It changed three times in one afternoon while this guide was being written —64645, then60087, then51458. Anything written to a file is wrong after the nextcompose up.- Operator tokens expire after five minutes (Keycloak's default; the realm
sets no
accessTokenLifespan). The script mints them per request rather than holding one.
So the rule the whole tool is built around: persist identity, never
addresses. .stratium-quickstart.json holds the agent id and file paths.
Ports and tokens are never written to disk.
4. See it enforce¶
up registered three prefixes at nato SECRET — /secret-, /tmp/secret- and
/private/tmp/secret- — so any path starting one of those ways is above the
agent's CONFIDENTIAL ceiling. Use a /tmp one: macOS seals the root volume, so
mkdir /secret-demo fails with Read-only file system.
Make the file first — see the warning below — then ask for it in the launched session:
The tool call is denied before it runs, and the agent is told why:
Reading anything else — /tmp/notes.txt — succeeds, because the catch-all rule
labels it UNCLASSIFIED, which is under the cap.
Why both
/tmpand/private/tmpare registered. The label match is on the literal path string the tool call carries, and macOS resolves/tmpto/private/tmp. Whichever spelling the agent happens to send has to hit a rule, or the path falls through to the catch-all and is allowed — a silent non-enforcement that looks exactly like a broken cap. Observed: both spellings deny,exit=2.Ask for a tool, not an outcome, and make the file real. This is the step most likely to look like a failure when nothing is wrong. Point a capable model at a path that does not exist and it lists the directory instead; point it at an empty file and it may
catit throughBash. Either wayReadnever fires, the hook is never consulted, and you have proven nothing. Name the tool explicitly, give the file content, or skip the model entirely and useverify(§5).Note that
Bash(cat /secret-demo/plans.txt)is a different check, not an absent one.Bashis catalogued with a command field and no resource field, so its tier is derived from the command text — and, since 2026-08-28, its resource identity is too: every path the command names is extracted and the strictest label of the set decides. So the shell route denies here as well, and a benign path prepended to the secret one does not help. A command naming no path at all still has nothing to label, and still denies under any cap.
5. verify — proof, on demand¶
Observed:
delegation grants Read,Grep,Glob
PASS Read exit=0 (want 0) granted -- must be allowed
PASS LS exit=2 (want 2) catalogued, held tool not in the grant -- must be denied
tool "LS" not in delegation approved_tools at depth 0
PASS Frobnicate05946b8f exit=2 (want 2) not even catalogued -- must be denied
tool "Frobnicate05946b8f" not in delegation approved_tools at depth 0
PASS classification exit=2 (want 2) SECRET over a CONFIDENTIAL cap
resource classification SECRET exceeds delegation cap CONFIDENTIAL in hierarchy "nato" (depth 0)
Enforcement proven: a held tool allowed, an unheld tool denied.
Exit 0 if every case passes, 1 otherwise. It feeds decisions straight to the
real binary — no model, no guessing what a tool call will look like.
Each case asserts on the reason text, not just the exit code. That matters
because this system has four independent ways to deny — action tier, tool
holding, classification, missing configuration — and all four exit 2. A case
that checked only the exit code would stay green while silently testing something
else. That happened during development: a Write probe labelled "not in the
grant" was actually denying on action tier, because of a stale tier_overrides
row left on the shared tenant by an unrelated test suite.
Each run uses a fresh uuid-tagged resource path, so the PDP's 30-second decision cache cannot answer for it.
6. doctor — when something is wrong¶
Observed, run from a shell that is not the launcher:
OK toolchain docker, docker compose, go
OK stack 18 services running
OK ports pap=localhost:51465 pdp=localhost:51458
OK state quickstart-46384b (0f8f833a-ef4c-4e7e-bbb5-00d259cf5c19)
OK binary /Users/you/stratium/bin/stratium-pep
-- hooks none in any scope
XX environment 11 of 11 missing in this shell
-> you are outside the launcher -- use: deploy/stratium run claude
OK delegation 1f9a667e-1e50-426d-a2c9-f9e9b0321cdb grants Read,Grep,Glob
OK caps {"nato": "CONFIDENTIAL"} -- classification enforcement is on
It walks the layers in dependency order and names the fix for whatever is broken.
Exit 1 if anything is wrong.
The environment XX above is not a fault — it is doctor correctly reporting
that this shell is not the launcher. That line is green only inside
deploy/stratium run.
hooks is checked across every scope both harnesses merge — project
settings.json, project settings.local.json, user settings.json, and
~/.codex/hooks.json — and prints the source of each. A hook hiding in a scope
you did not check denies every tool call in every project, while the one file you
did check reports clean.
7. Reading the refusals¶
The script refuses four things. Each refusal is a real hazard, not fussiness.
No harness.
A binary that does not resolve.
stratium: 'stratium-pep' is not runnable (not on PATH). A PreToolUse hook that
cannot run exits 127, which Claude Code treats as a non-blocking error — the tool
call proceeds unchecked. Pass --bin with a real path, or --force if you are
writing settings for another machine.
This is the important one. A missing hook binary is not a broken enforcement
point, it is no enforcement point — the transcript shows a hook error and
every tool call proceeds. --force exists for writing settings destined for
another machine.
A cap with no catch-all rule — 409, from the API rather than the script.
With any cap set, an unresolvable label denies, so a cap without a catch-all
denies nearly everything. Register a ""-prefix rule first, or pass
?allow_uncovered=true if a deny-by-default posture is what you want.
A teardown of the wrong compose project.
stratium: this would tear down compose project 'docker' with -v (volumes and
all), but `up` created 'stratium-throwaway'. Refusing.
Docker derives the project name from the compose file's parent directory —
docker here, which is not distinctive. Two checkouts resolve to the same
project, so up records what it created and down refuses anything else.
8. Tear it down¶
Order matters, and the script enforces it: hooks come off first, then the stack. Reverse that and every tool call in an open agent session denies against a PDP that is no longer listening.
down removes the hooks from every scope, stops the stack with -v, and deletes
the state file, the built binary and the session file. Volumes go with it, so
agents, delegations and label rules do not survive.
To keep the stack and only unwire the agent:
9. Command reference¶
| Command | What it does |
|---|---|
up --harness {claude,codex,both} |
Everything, ending on an observed denial. --wire-anyway overrides the in-session guard. |
run {claude,codex} [args...] |
Resolves ports and env, then execs the agent. |
verify |
Four decisions through the real binary. Exit 0 on all pass. |
doctor |
Layer-by-layer diagnosis across every hook scope. Exit 0 when healthy. |
down [--force] |
Hooks, then stack, then local files. Refuses a foreign compose project. |
install / uninstall / status |
Hook wiring only. Requires --claude or --codex. |
selftest |
The script's own assertions. No stack required. |
Shared flags work on either side of the subcommand — --claude install and
install --claude are the same. Useful ones: --bin PATH, --matcher Read (start
narrow), --only PreToolUse, --user, -n/--dry-run.
--only PreToolUse¶
up installs both hooks: SessionStart issues a delegation per session,
PreToolUse authorizes each call. If you are working through the manual test
plan, install only PreToolUse — a SessionStart hook re-issues a fresh
delegation on every session start, discarding one you built by hand.
10. Things that will bite you¶
- A hook installed inside a running session takes effect immediately. So does
a removal. Do not install
matcher: "*"from a session you are using. session-start's eight variables are nothook's eight. They overlap in five. Export only the first set and every tool call denies withmissing required configuration— a denial that reads exactly like a policy denial until you open the reason. The launcher sets all eleven.- Every tool call is denied until a session is started and a policy permits it. Seeing everything denied after installing a hook is usually correct.
- Five tools can never satisfy a cap.
Glob,Grep,WebFetch,WebSearchandTodoWritedeclare no resource field, so they produce nothing to label and deny under any cap. The caps API lists them in its response. - Integration suites leave rows on the shared tenant —
tier_overridesin particular — and do not clean up. If a decision denies for a reason you did not expect, check that table before suspecting your code.
See also¶
docs/architecture.md— how the PEP, PDP and PAP fit together.docs/pep.md— the operator reference for the enforcement point: every configuration value, and how to read a denial.docs/testing/2026-08-20-agent-integration-test.md— the manual walkthrough this script automates, for when you want to see the moving parts.