Skip to content

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, Bearer tokens, 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 → LOW

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? → ESCALATE

This 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.

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.

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.

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.

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.

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.