Authorization internals
The pipeline page shows the shape — four checks per action. This page opens each module and shows the parts: every pattern, every limit, every precedence rule.
Everything here lives in steelmoth-runtime/src/steelmoth_runtime/authz/. The modules
share three design properties: they are pure (no file or network access — the same
input always gives the same answer), their results are frozen (immutable once
created), and every error path lands on the strict side.
risk.py — scoring what’s being attempted
Section titled “risk.py — scoring what’s being attempted”Risk scoring reads the action’s description and content and assigns a band:
low / medium / high / critical, plus a side-effect
class: none, reversible, or irreversible. It looks for four kinds of signal:
- 9 secret patterns — API-key assignments,
Bearertokens, AWS key IDs, passwords, private-key blocks, database connection strings, GitHub tokens, JWTs, and the literal phrase “my secret is …”. - 6 personal-data patterns — social security numbers, card numbers, emails, US phone numbers, IPv4 addresses, dates of birth.
- 19 egress indicators — words and shapes that suggest data is about to leave (send, upload, post, external addresses…).
- 14 irreversible / 10 reversible indicators — “delete permanently” vs “draft”, “wire transfer” vs “preview”.
The band comes from a fixed precedence ladder — first match wins:
secrets present → CRITICAL (always, no exceptions) personal data AND leaving → HIGH irreversible side effect → HIGH personal data OR egress OR reversible→ MEDIUM it's a command or a delete → MEDIUM otherwise → LOWpolicy.py — the decision
Section titled “policy.py — the decision”Policy turns (risk, capability check) into one of three words: ALLOW,
DENY, ESCALATE (wait for a human). Its precedence:
no required capability set? → DENY "unclassified action" caller doesn't hold it? → DENY secrets found, or CRITICAL band? → ESCALATE HIGH band, or irreversible? → ESCALATE capability held + LOW/MEDIUM → ALLOW band the code doesn't recognize? → ESCALATEThis module is a hardened rewrite of an upstream design (Bifrost), with three deliberate changes: it fails closed for every kind of caller (upstream failed open for human users), it is fully deterministic (upstream sampled randomly), and it does zero I/O.
dlp.py — scanning the content itself
Section titled “dlp.py — scanning the content itself”Risk scoring judges the description; DLP scans the actual payload about to go out. Two tiers:
- 15 lightweight patterns, always on — named leak shapes: OpenAI keys
(
sk-…), Stripe live and test keys, bearer tokens, passwords, AWS access and secret keys, private-key blocks, connection strings, generic secrets, card numbers, SSNs, GitHub tokens, Slack tokens (xox…). - 6 heavy personal-data patterns, only at high or critical risk — the expensive scans run only when the stakes already justify them.
A hit blocks the payload and reports sorted, de-duplicated categories. Redaction keeps the first
four and last two characters (sk-a…7Q style), or replaces the whole value with
*** if it’s six characters or shorter — and a redacted payload no longer triggers the
scanner, so masking is terminal, not a loop.
egress.py — where the assistant may reach
Section titled “egress.py — where the assistant may reach”| Rule | Detail |
|---|---|
| Blocked address ranges (10) | The three private IPv4 ranges, loopback, link-local 169.254.0.0/16 (which includes the cloud metadata address), 0.0.0.0/8, IPv6 loopback, IPv6 private (fc00::/7), IPv6 link-local, and IPv4-mapped IPv6 (so the mapping trick doesn’t bypass the IPv4 rules). |
| Allowed schemes | http and https only — no file:, ftp:, gopher:, anything else. |
| Refused hostnames | localhost, metadata, metadata.google.internal, and any name ending .internal, .local, .localhost, .cluster.local. |
| Stable reason codes | Every refusal carries a fixed REASON_* code, so logs and tests don’t depend on prose. |
One honest limit: this module checks literal IPs in the URL. A hostname that resolves to a private address must be re-checked at fetch time, after DNS — that recheck belongs to the fetch layer, which is part of the unwired list below.
chain.py — budgets for tool-call chains
Section titled “chain.py — budgets for tool-call chains”One user request can fan out into many tool calls. Two budgets cap the fan-out: at most 20 calls and 10 MiB moved per request. But the very first check is neither of those — it’s ownership: if a call’s owner doesn’t match the request’s owner, it’s refused before anything is counted. Tenant isolation outranks budget arithmetic. The module makes pure decisions; the running counters live in the wiring layer.
capabilities.py — what a user’s groups are worth
Section titled “capabilities.py — what a user’s groups are worth”Eleven base capabilities (seven inherited, four Steelmoth-proposed for email triage and browser
sessions) expand through group membership into 25 distinct capabilities total:
prt-admins holds 16, prt-org-managers and
prt-workspace-managers hold 2 each, and dashboard-admins is an alias for the
full admin set. A group the system doesn’t know contributes nothing.
The lookup function is the designed seam for the future trust kernel: today it resolves login-provider groups; later it will read claims from a signed trust token. Either way, the rest of the pipeline never changes.
audit.py — the hash chain
Section titled “audit.py — the hash chain” genesis: sha256:000…000 (64 zeros) │ ▼ record 0 { owner, action, capability, decision, risk, sequence: 0, prev_hash: genesis }──► event_hash A │ │ ▼ ▼ record 1 { …, sequence: 1, prev_hash: A }──────► event_hash B │ ▼ record 2 { …, sequence: 2, prev_hash: B }──────► …Each record’s fingerprint is computed over its fields serialized one canonical way (keys sorted,
no extra spaces) — so the same record always hashes the same. Verification walks the chain and checks
three things: sequence numbers are in position, each prev_hash matches the previous
record’s fingerprint, and each fingerprint recomputes correctly. Tamper with one record, drop one,
swap two, or forge one, and verification fails from that point on. Records are frozen objects;
sequence numbers count per owner from zero.
signing.py — signatures over the chain
Section titled “signing.py — signatures over the chain”On top of the chain, each record’s fingerprint can be signed with an RSA key (RS256 — the same scheme JWTs use). Verification fails closed: any error while checking — wrong key, mangled input, missing library — verifies as invalid. The signing key’s custody (the prontera vault) is a separate, approval-gated wiring step; the math is ready, the key management isn’t wired.
govern.py — the conductor
Section titled “govern.py — the conductor”The pipeline runs the stages in a fixed order — risk, then policy, then DLP, then audit — with two properties worth repeating from the overview: the server builds the request envelope (the required capability never comes from the model — that’s the confused-deputy defense), and DLP may only tighten the decision, never loosen it. When several reasons apply, the decisive one is listed first, so the first line of any log entry tells you why.

