Threat model
Every defense in Steelmoth exists because of a specific way things could go wrong. This page lists those ways — and names the exact layer that stops each one.
There are two separate worlds to defend. The first is the workshop: coding agents (Claude Code, Codex, Morpheus) working on Steelmoth itself, with shell access to the operator’s machine. The second is the product: the assistant inside Steelmoth acting for its users — sending email, driving a browser. Different attackers, different damage, different layers.
The incident that shaped the model
Section titled “The incident that shaped the model”On 2026-05-23, an agent wrote into /etc/agent-vault/ — the secrets directory — using
a plain shell command. The hook-based guard never saw it. That is the founding lesson, written up in
ADR 0112: hooks only see what the agent reports as a tool call. A write hidden inside
tee, dd, python -c "open(...)", or a subshell redirect looks like
an innocent command to a hook. Pattern-matching the command line can never be complete.
The answer was to stop trusting the command line and ask the kernel instead. That gave the system its two-layer shape:
Workshop threats — agents working on Steelmoth
Section titled “Workshop threats — agents working on Steelmoth”| If an agent tried to… | What stops it | Layer |
|---|---|---|
Edit a secret file — .env, a private key, a vault token |
The path category has no grantable scope: no manifest can ever allow it. Landlock has no write rule for those folders, so the kernel refuses too. If something slips through anyway, the post-hoc sweep reverts it. | Protected paths + Landlock + Sweep |
Hide a write inside tee, dd, a Python one-liner, or a shell redirect |
Landlock doesn’t read the command — it blocks the write itself, at the kernel, wherever it comes from. | Landlock |
Switch off its own guardrails (.claude/settings*.json, the hooks) |
Those files are a never-grantable protected category — flagged in the policy as a self-disable risk. No scope token unlocks them. | Agent policy |
Rewrite the rules to allow itself more (AGENT_POLICY.yaml, the standards) |
Needs the meta scope from a human, then a CODEOWNERS review on the pull request, then CI checks that the policy still parses and cross-validates. |
Policy + CODEOWNERS + CI |
Push to main, or force-push to rewrite history |
Both are denied commands (Hard Rule 9). GitHub branch protection refuses force pushes outright, and Hard Rule 14 voids any approval given before a force-push. | Gate + Branch protection |
| Rebuild or restart containers quietly | Compose lifecycle verbs are deny-listed or approval-required (Hard Rules 1 and 6). The only sanctioned dashboard deploy is one named script, and even script runs wait in the approval queue. | Gate + Approvals |
Reach the secrets host directly (ssh prontera) |
Prontera is on the off-limits hosts list; the SSH command needs the auth scope, which is only granted by a human for a reason. |
Agent policy |
| Start a stray container outside the known stack | Bare docker run without --rm, docker create, and any compose project that isn’t steelmoth_private are denied without the container-new scope. |
Gate + Canonical stack |
Drop a new secret-looking file inside the repo mid-session (even a gitignored .env) |
The sweep lists changed and untracked files without honoring gitignore — so a gitignored secret is still caught, fingerprinted, and reverted. | Post-hoc sweep |
| Erase its own tracks | The violations file is append-only in practice (everything else is read-only to the agent), and its database mirror grants INSERT and SELECT only — no update, no delete, for anyone connecting as the runtime role. |
Audit trails |
| Run before the protections are in place | The shim is a single process that installs Landlock on itself before launching the agent. If the install fails, it exits with an error instead — the agent never runs unrestrained. | Agent shims |
| Escape through a child process | Landlock rules are inherited by every child the shim launches, and no-new-privileges is set first, so a child can’t gain rights the parent didn’t have. | Landlock |
Product threats — the assistant acting for users
Section titled “Product threats — the assistant acting for users”| If something tried to… | What stops it | Layer |
|---|---|---|
| Talk the system into more authority — the model describing its own action as harmless (the “confused deputy” problem) | The server builds the request envelope, not the model. The capability an action requires comes from trusted code; the model never fills in its own paperwork. | Pipeline (govern.py) |
| Let a secret leave in the output — an API key, a password, a card number | Fifteen leak patterns are scanned on every outbound payload, every time; six heavier personal-data patterns are added at high risk. A hit blocks the action and the value gets masked. | DLP |
Make the assistant fetch an internal address — the cloud metadata service, a private IP, localhost |
Ten address ranges are blocked outright (private networks, loopback, link-local including 169.254.169.254), only http/https are allowed, and internal-looking hostnames are refused by name. |
Egress rules |
| Let one user’s request touch another user’s data | The very first check on every tool call in a chain is “does the call’s owner match the request’s owner?” — before any budget counting. Underneath, the scoped database tables enforce row-level security that the app role cannot switch off. | Chain limits + DB roles |
| Loop forever, or move huge amounts of data through tools | Hard budgets per request: at most 20 tool calls and 10 MiB moved. Over budget means refused. | Chain limits |
| Quietly edit the record afterwards | Every decision is appended to a hash chain — each entry carries the fingerprint of the one before it. Change, drop, reorder, or forge an entry and verification fails for everything after it. | Hash-chained ledger |
| Sneak in an action type nobody has classified yet | No classification means no required capability — and that is an automatic deny, not a shrug. Unknown risk bands escalate to a human. | Policy precedence |
| Forge admin identity by faking proxy headers | Identity headers are only believed when the request’s first network hop comes from a known proxy address — and even then the email must exactly match the one canonical operator. Admin is decided by group membership alone, with no fallback list. | Identity & authority |

