Threat model — the honest version.
v0.2 · 2026-08-03 · scoped to the Phase C egress broker
This is the un-marketing-cleaned version an external security reviewer will read. Every named gap has either a compensating control or a shipped roadmap reference. Nothing is hidden. The canonical markdown source lives in the station repo — this page reflows it for the web; when the two disagree, the source wins.
1. Executive summary
RailCall Station is a Layer-2 governance server that runs locally on the operator’s machine, binds 127.0.0.1:8799, and sits inside the workflow — between the LLM/agent and the destination API. Every model call goes through station.llm.complete(), and every call emits a signed egress receipt into the same hash-chained audit log as action receipts.
The receipt is metadata only — no prompt content, no response body, no user identifiers — but records which module made what call to which provider, when, under which policy version. Two frameworks (SOC 2 authorization + HIPAA §164.312(b) audit), one artifact, one verification path.
2. Data flow diagram
┌──────────────────────────────────────────────────────────────────┐ │ OPERATOR'S MACHINE │ │ │ │ ┌────────────┐ ┌───────────────┐ ┌─────────────────────┐ │ │ │ VS Code │ │ Studio HTTP │ │ MCP Server │ │ │ │ Extension │──▶│ 127.0.0.1:8799│──│ (stdio to Claude │ │ │ │ │ │ │◀──│ Desktop / Cursor) │ │ │ └────────────┘ └───────┬───────┘ └──────────┬──────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────────────────────────────────┐ │ │ │ Airlock + Egress Broker (station) │ │ │ │ · signed approval receipts │ │ │ │ · signed egress receipts │ │ │ │ · hash-linked audit chain │ │ │ └──────────┬─────────────┬─────────────┘ │ │ │ │ │ │ │ │ station.llm.complete│ │ ▼ ▼ │ │ ┌────────────┐ ┌──────────────┐ │ │ │ Cooperative│ │ Provider SDKs│ │ │ │ Modules │ │ (Anthropic, │ │ │ │ (signed) │ │ OpenAI, …) │ │ │ └────┬───────┘ └──────┬───────┘ │ │ │ │ │ │ │ (adversarial │ │ │ │ module bypass │ │ │ │ via ctypes / │ │ │ │ raw socket) │ │ │ │ ─ ─ ─ ─ ─ ─ ─ │ │ │ ▼ ▼ │ ├──────────────────────────────────────────────────────────────────┤ │ NETWORK BOUNDARY │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ LLM Provider │ │ Destination │ │ Marketplace │ │ │ │ (OpenAI, …) │ │ APIs (Slack, │ │ Backend │ │ │ │ │ │ Discord, …) │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ RailCall servers touch NOTHING here — │ │ marketplace backend is the ONLY │ │ RailCall-operated egress, and it │ │ handles listings + license state, │ │ never prompt content or receipts. │ └──────────────────────────────────────────────────────────────────┘
Key structural facts: everything the operator considers sensitive stays on the operator’s machine. RailCall-operated servers never see prompts, responses, or receipts. The marketplace backend is the one RailCall-operated egress — it serves listings + license state; prompt content and receipts never touch it.
3. Assets to protect
| Asset | Where | Sensitivity |
|---|---|---|
| BYOK provider API keys | WS/keys.local.json (0600) | HIGH |
| Named credentials | WS/credentials.local.json (0600) | HIGH |
| Ed25519 signing seed | OS keychain OR WS/*.json (0600) | CRITICAL — install identity |
| Session token | WS/session_token.json (0600) | HIGH — grants API access |
| APPROVE token (2nd credential) | WS/approve_token.json (0600) | HIGH — dual-control |
| Audit chain | WS/audit_log.jsonl (append-only) | INTEGRITY-CRITICAL |
| Action receipts | WS/receipts/** | INTEGRITY-CRITICAL |
| Egress receipts | WS/receipts/egress/ | INTEGRITY-CRITICAL |
| Policy doc | WS/policy.json | INTEGRITY-CRITICAL |
| Prompt content (in-flight) | Python process memory (never persisted) | HIGH — out of scope after call |
| Provider response body | Python process memory (never persisted) | HIGH |
4. LINDDUN — privacy threats
payload_hmac_hex is deterministic per (install, prompt). Two identical prompts on the same install → identical HMAC — that IS information (they know the same prompt was sent twice). Cross-install correlation is broken by install-bound HMAC key. Residual: metadata timing + shape correlate to identity if an observer has enough context.
Receipt has no user names, emails, session ids, or IPs. module_id identifies WHICH module, not WHO. caller_kind is coarse-grained channel only.
By design. Ed25519 signature is verifiable off-box against the install pubkey. HIPAA §164.312(b) audit requirement satisfied. Applies to metadata of the call — not to prompt content (which is never persisted).
Every call is detectable + attributable. That's the point — SOC 2 auditors want this. An attacker who steals the audit log learns frequency + shape + provider, not content.
Enforced by construction: receipt never contains prompt or response bytes; HMAC is one-way keyed. Residual (documented): module_version reveals version, endpoint_url_host is host-only precisely because URL paths carry tenant IDs, token_count_actual correlates weakly with response type.
Every receipt is visible in Studio's Receipts tab and via /api/receipts/list. Operator can inspect any receipt on-disk. Third-party subjects (users of an application built on the station) are the application operator's responsibility to notify.
Metadata-only design keeps us out of PHI/PII scope by construction. We do NOT claim 'PHI-safe by construction' — that's overreach. Correct framing: HIPAA §164.312(b) audit requirement is satisfied; SOC 2 authorization + confidentiality are supported. The receipt is not a substitute for the operator's BAA with the model provider.
5. STRIDE — security threats
| Attack | Mitigation | Residual |
|---|---|---|
| Cross-origin browser POST (Spoofing) | CSRF gate requires Origin/Referer; session token in same-origin fetch only. | Localhost `null` origin blocked by explicit check. |
| Impersonate a signed module (Spoofing) | Publisher Ed25519 signature verified at load via publisher_trust allowlist (v0.25 Phase 4a). | Publisher key compromise → new module installs are refused; existing signed modules stay valid until unpinned. |
| Edit an on-disk receipt (Tampering) | Ed25519 signature over canonical bytes; verify rejects any bit-flip. Test coverage: tests/test_egress_receipt.py. | Deleting the entire receipt file goes through the audit_chain integrity check — but tail-truncation by the install-key holder needs an off-box witness (Phase D2). |
| Delete a receipt to hide it (Tampering) | audit_chain.append writes a fingerprint entry; verify walks the chain and flags gaps as first_break. | Tamper-evident for in-place edits + mid-chain deletion; tail truncation by the install-key holder needs off-box witness (Phase D2). |
| Edit the policy doc to fabricate an allow (Tampering) | policy_version_hash embedded in the receipt at emit time; verifier compares to a fresh hash of the current doc. | If policy_doc + receipt are altered together and re-signed with the install key, tamper is undetectable without off-box policy attestation (Phase D2). |
| Exhaust disk with runaway egress receipts (DoS) | Every call = ~1 KB receipt; audit chain uses append-only + transactional writes. | Per-module receipt-rate cap is not yet in-shim (v2 roadmap). |
| Cooperative module escapes to write outside WS (Elevation) | C2.1/C2.2 OS sandbox refuses connect(2) except loopback; C2.0 CONNECT proxy filters destinations. Publisher trust is the compensating control for the same-process reads that remain. | An adversarial module can still open() any 0600 file the station user can read — same-user attacker is out of scope (they own the machine). |
| Module reads BYOK credentials it wasn't declared for (Elevation) | v0.43 credential-spec collision handler auto-namespaces per module. | Same-user attacker limitation: any process running as the station user can open the credential file directly. |
6. Cooperative vs adversarial — the honest scope
This is the single most important scoping call in the deckand it’s stated here up front so no reviewer feels ambushed by it.
A cooperative module imports station.llm.complete and calls it. Every call produces a signed receipt. The receipt IS the proof of what happened. For cooperative modules, the guarantee is real.
An adversarial module could open a raw socket via ctypes and bypass the broker. On macOS (v0.46+), the sandbox-exec profile makes the kernel refuse connect(2) except to the loopback broker port. On Linux (v0.46+), systemd-run --scope with IPAddressDeny=any installs a BPF cgroup filter with the same effect. Modules spawned outside those wrappers still fall under the cooperative + publisher-trust framing.
Compensating controls that make cooperative-scope acceptable: every module ships with an Ed25519 publisher signature verified at load; publisher trust is explicit-opt-in (allowlist); marketplace-published modules pass a human pre-publish review. Auditors accept named gaps with compensating controls; they reject undisclosed gaps or claims of coverage that doesn’t exist.
7. Roadmap — what closes each named gap
C2.0: env-var CONNECT proxy (env-respecting clients caught). C2.1 (macOS): sandbox-exec kernel-refuses connect(2) except loopback broker. C2.2 (Linux): systemd-run --scope + IPAddressDeny (BPF cgroup filter). Raw-socket / ctypes bypass closed on macOS and modern-systemd Linux.
C1b: RFC 3161 anchor via freetsa.org, embedded on every receipt.
C1c: `allowed_destinations` in module.json + load-time verification; violations emit a signed refusal receipt.
C3.0: pattern-count monitor. C3.1: `redacted` decisions actually scrub the forwarded prompt. C3.4: optional Presidio ML NER adapter — soft dep, higher-recall on US_SSN, IP_ADDRESS, IBAN_CODE, MEDICAL_LICENSE, etc. Regex fallback when Presidio absent.
C3.0/C3.1: flat-atom rules → enforcement. C3.2: `approved` blocks in-process for human decision (fail-closed timeout → deny). C3.3: compound conditions (all/any/not), named rules, bundle files. OPA/Cedar as external subprocess remains optional future work — compound engine covers the common cases.
C4: reversible `[TOK_<cat>_<hash8>]` tokens replace the one-way `[REDACTED:*]` placeholder. Provider sees tokens, per-workspace map lets the response detokenize before it reaches the workflow user. Deterministic HMAC per install.
C5: rules can declare `then: rerouted` with a `route_to: {provider, model}` target. Receipt records BOTH the caller-requested destination AND the actual (rerouted) one. `destination.trust_tier` (local / baa_covered / frontier / unknown) stamped on every receipt.
D2: stations POST signed `railcall_witness_anchor.v1` anchors — chain-head sha256 + policy hash + install pubkey + station wall clock — to POST /witness/anchor on the marketplace backend. Manual trigger via POST /api/witness/anchor; background timer opt-in via RAILCALL_WITNESS_URL + RAILCALL_WITNESS_AUTO=1 (default OFF — the station stays byte-identical to pre-D2 for operators who don't opt in). Auditors GET /witness/anchors?pubkey=... to fetch the anchor history and compare against the station's live chain head; mismatch = evidence someone rewrote history between anchors.
D2: same anchor carries policy_hash (sha256 of policy.json + egress_policy.json) + policy_id so an auditor can prove a specific policy doc was in force at a specific witness-side receive time. Auditor computes sha256(policy.json) locally, walks the anchor history until match.
Phase D3: a `verify at railcall.ai` surface any third party can point at a receipt file + install pubkey to run the same 4-check pass the station's /api/receipts/verify runs, without needing shell access.
8. Auditor cheatsheet — what each receipt field proves
Every field is either a cryptographic derivative of content, or metadata that does not disclose content. This table is what a healthcare lawyer needs to satisfy themselves that the receipt does not enter §164.514 scope.
| Receipt field | What it proves |
|---|---|
| signature (Ed25519, key_id) | Non-repudiation: these exact bytes came from this install |
| workflow_id + step_id | Attribution: which authored workflow triggered the call |
| module_id + module_version | Provenance: which signed publisher's code made the call |
| caller_kind | Channel: workflow / mcp / vscode / studio / cli |
| destination.provider + .model + .endpoint_url_host | Where the data went (metadata only — host, not URL) |
| destination.trust_tier | Whether the target was local / BAA / frontier / unknown |
| destination.requested_provider + .requested_model | When present: what the module asked for BEFORE a policy reroute |
| payload_hmac_hex | Fingerprint of the forwarded prompt without disclosing content |
| payload_hmac_original_hex | When present (decision=redacted): fingerprint of the ORIGINAL prompt — comparing to payload_hmac_hex proves redaction happened |
| byte_count + field_count | Shape of the prompt (structural metadata only) |
| token_count_estimate + token_count_actual | Volume of the call, both pre-flight estimate and post-response actual |
| policy_id + policy_version_hash | Governance: which policy doc was in force at emit time |
| decision | Enforcement outcome: allow / redacted / approved / denied / rerouted / would_* |
| approver_ref + approver_channel | Dual-control: who approved (if the approval path fired) |
| classification | What the classifier detected in the prompt (aggregate counts, never content) |
| timestamp_utc | When the call happened (self-asserted) |
| timestamp_rfc3161 | TSA-anchored proof of when — verifiable off-box against freetsa.org |
| sequence_number + prev_hash | Position in the audit chain (rebuild-able index) |
9. Verify a receipt end-to-end
External verification, from a different machine than the operator’s (an auditor, an internal SOC, or a customer’s security team):
Both paths land the same verdict. The endpoint is a convenience; the receipt is verifiable off-box by anyone with the pubkey using standard Ed25519 tooling — no RailCall dependencies required.
Full canonical source (with changelog + open decisions): docs/phase_c_threat_model.md. Reviewers running procurement questionnaires can request the compliance bundle (SRA, adopted policies, subprocessors) under NDA from /trust.