Architecture diagrams¶
These diagrams describe the current release's production READ path (95
tools, 0 default WRITE) and the protected WRITE architecture built and
independently verified per ADR-026. Solid paths are active in
production; not-default-reachable paths are explicitly labeled as such
rather than as "future" or "inert" — the WRITE architecture is real,
implemented, and live-verified code, just never reachable without an
explicit, separate operator opt-in.
Overall architecture¶
flowchart LR
Caller[Trusted local MCP client] -->|stdio| App[Application / FastMCP]
App --> Registry[ToolRegistry]
Registry --> Tools[95 thin READ tools]
Tools --> Domain[PfSenseClient]
Domain --> Rest[RestApiClient\nGET-only]
Rest --> Transport[HttpTransport]
Transport -->|HTTPS GET| PfSense[pfSense REST API]
Rest --> Models[Typed Pydantic models]
Models --> Tools
Profile[Auditor capability profile] --> Registry
Endpoints[Verified endpoint registry] --> Rest
Mock[MockTransport] -. offline tests .-> Rest
Tier0[Dormant Tier 0 WRITE modules] -. not constructed .-> App
READ request flow¶
sequenceDiagram
participant C as MCP client
participant T as MCP tool
participant P as PfSenseClient
participant R as RestApiClient
participant H as HttpTransport
participant F as pfSense
C->>T: tool(arguments)
T->>T: bind/audit disclosure choice
T->>P: semantic READ method
P->>P: validate bounded parameters
P->>R: get(verified endpoint, params)
R->>R: enforce API version and GET
R->>H: request("GET", path)
H->>F: HTTPS GET + API-key header
F-->>H: HTTP response
H-->>R: TransportResponse
R->>R: parse JSON and map status/errors
R-->>P: untrusted dict
P->>P: shape checks + typed model mapping
P-->>T: model/list
T-->>C: serialized typed output
MCP tool registration¶
flowchart TD
Start[Application bootstrap] --> ProfileName[Load PFSENSE_PROFILE]
ProfileName --> Profiles[get_profile]
Profiles --> CapSet[Immutable capability set]
CapSet --> Registry[ToolRegistry.register_all]
Registry --> C1{Capability present?}
C1 -->|yes| Build[Build thin tool callable]
C1 -->|no| Skip[Do not register]
Build --> Audit[Wrap with audit_logged]
Audit --> MCP[FastMCP.tool registration]
Registry --> WriteDispatch[register_all_write]
WriteDispatch --> Empty[No branches / no WRITE registration]
Tests[Registry and profile tests] -. assert 95 READ / 0 WRITE .-> MCP
Configuration loading¶
flowchart TD
Env[Process environment] --> Required{Required values present?}
Required -->|no| Fail[Sanitized ConfigurationError\nprocess exits closed]
Required -->|yes| URL[Validate HTTPS origin]
URL --> Identity[Validate bounded identity]
Identity --> TLS[Validate TLS mode and CA file]
TLS --> Logs[Validate log bounds]
Logs --> KeyOpen[Open key descriptor\nO_NOFOLLOW]
KeyOpen --> KeyChecks{fstat: regular, current owner,\nno group/other bits, bounded?}
KeyChecks -->|no| Fail
KeyChecks -->|yes| KeyRead[Read bounded first line\nfrom same descriptor]
KeyRead -->|empty/control/too long/read failure| Fail
KeyRead --> Config[Immutable PfSenseConfig]
Config --> SecretFilter[Register key with log redaction]
SecretFilter --> Factory[Construct transport and READ client]
REST client and error boundary¶
flowchart LR
Domain[PfSenseClient] --> Endpoint[EndpointInfo]
Endpoint --> Version{API version supported?}
Version -->|no| Unsupported[UnsupportedOperationError]
Version -->|yes| Method[GET-only request path]
Method --> Transport[Transport.request]
Transport --> Response{Outcome}
Response -->|connection/timeout| Connection[PfSenseConnectionError]
Response -->|401/403| Auth[PfSenseAuthError\nsanitary identity-free message]
Response -->|other failure| API[PfSenseApiError\nstatus + response ID only]
Response -->|success| JSON[Parse JSON object]
JSON --> Shape[Domain shape/model validation]
Shape -->|invalid| ShapeError[PfSenseResponseShapeError]
Shape -->|valid| Public[Typed public model]
Audit logging¶
flowchart TD
Invoke[Tool invocation] --> Binder[Signature binding]
Binder --> Context[tool name\nupstream identity\nmetadata supported/requested]
Context --> Start[tool_invoked]
Start --> Call[Execute tool]
Call -->|success| Success[tool_succeeded\nduration + no values]
Call -->|PfSenseMCPError| Domain[tool_failed\nfailure_class=domain\nexception class only]
Call -->|other Exception| Unexpected[tool_failed\nfailure_class=unexpected\nexception class only]
Domain --> Reraise[Re-raise unchanged]
Unexpected --> Reraise
Filter[SecretRedactionFilter] --> Sink[Rotating owner-only local log]
Start --> Filter
Success --> Filter
Domain --> Filter
Unexpected --> Filter
Never[Arguments, responses, payloads,\nexception messages, credentials] -. never logged .-> Sink
Security boundaries¶
flowchart LR
subgraph TrustedLocal[Trusted local account boundary]
Client[MCP client]
Server[stdio MCP server]
Key[Owner-only API-key file]
Logs[Value-minimized logs]
Client --> Server
Key --> Server
Server --> Logs
end
subgraph UntrustedInput[Untrusted data boundary]
Appliance[pfSense API]
JSON[Raw JSON / HTTP status]
Appliance --> JSON
end
Server -->|HTTPS GET| Appliance
JSON --> Validators[Status + shape + Pydantic validation]
Validators --> Public[Credential-free public schemas/output]
Public --> Server
Repo[Public repository / CI] -->|MockTransport + sanitized fixtures only| Server
Private[Private live acceptance] -. separately approved .-> Appliance
READ security path (summary)¶
Source: src/pfsense_mcp/tools/registry.py, capabilities.py, profiles.py,
pfsense_client.py. Every one of the 95 registered READ tools takes this
exact path — no exceptions.
flowchart LR
A["AI / MCP client"] -->|"stdio (trust boundary)"| B["Explicit registered<br/>MCP tool<br/>(1 of 95, no dispatcher)"]
B --> C["Capability / profile gate<br/>(auditor: READ only)"]
C --> D["Least-privilege mapping<br/>(exact pfSense privilege)"]
D --> E["One fixed typed<br/>client method"]
E --> F["pfREST GET<br/>(GET-only, enforced)"]
F --> G[("pfSense appliance")]
G --> H["Typed model boundary<br/>(secret fields excluded<br/>by construction)"]
H --> I["Safe MCP result"]
style A fill:#eee,stroke:#333
style G fill:#eee,stroke:#333
style C fill:#fff3cd,stroke:#856404
style D fill:#fff3cd,stroke:#856404
style H fill:#d1e7dd,stroke:#0f5132
Yellow boxes are hard, fail-closed gates (a capability not in the active profile is never registered at all — not merely hidden). The green box is where confirmed secret-bearing fields are structurally excluded from the Pydantic model, not filtered post hoc. See "READ request flow" above for the full call-by-call sequence this summarizes.
Protected WRITE authorization path (ADR-026)¶
Source: src/pfsense_mcp/tier1/execution_coordinator.py,
alias_description_execution.py, executor.py, state_machine.py,
SECURITY_MODEL.md, and
ADR-026 (accepted,
live-verified evidence). This describes the one capability that exists
today, set_firewall_alias_description_v1 — not a general WRITE
framework covering arbitrary mutations.
flowchart TD
Default["Default auditor profile:<br/>0 WRITE tools registered<br/>(this entire diagram is unreachable)"]
Profile{"Operator explicitly selects<br/>PFSENSE_PROFILE=write_protected?"}
Runtime{"Full Tier 1 material provisioned?<br/>(pinned Ed25519 authorities,<br/>encrypted contract store,<br/>live TPM witness connectivity)"}
NoTool["build_production_runtime returns None -<br/>tool still not registered,<br/>regardless of profile"]
Default -.-> Profile
Profile -->|"no (default)"| Default
Profile -->|"yes, explicit opt-in"| Runtime
Runtime -->|"no - any piece missing"| NoTool
Runtime -->|"yes"| Reachable["Exactly one WRITE tool registered:<br/>set_firewall_alias_description_v1<br/>IMPLEMENTED: yes. VERIFIED: yes (ADR-026).<br/>DEFAULT-REACHABLE: NO - requires this<br/>explicit, non-default opt-in every time"]
Reachable --> Op["Operator intent<br/>(human, off-host)"]
Op --> Plan["Deterministic security-posture plan<br/>+ canonical, independently-<br/>recomputable plan digest"]
Plan --> Sign1["Off-host Ed25519 signature<br/>(PlanAuthorizationV2 -<br/>never signed by the executing process)"]
Sign1 --> Gate["ExecutionCoordinator: 6 gates,<br/>fixed order, fail closed on first miss"]
subgraph Gate6["Pre-execution gates (IMPLEMENTED + VERIFIED)"]
direction TB
G1["1. Structural validation"]
G2["2. Signature verification"]
G3["3. Expiry / currentness"]
G4["4. Exact plan digest +<br/>authorized step membership"]
G5["5. Freshness re-check<br/>(capability posture +<br/>TPM anchor assurance)"]
G6["6. Atomic one-time<br/>authorization consumption"]
G1-->G2-->G3-->G4-->G5-->G6
end
Gate --> Gate6
Gate6 -->|"any gate fails"| Deny["Denied - sanitized, uniform<br/>(no earlier gate's pass/fail leaked)"]
Gate6 -->|"all 6 pass"| Contract["RecoveryContract created<br/>(encrypted, HMAC-authenticated,<br/>closed state machine)"]
Contract --> Confirm["Off-host ConfirmationEvidence<br/>(separate signer identity,<br/>bound to same contract/intent digests)"]
Confirm --> CAS["Atomic PREPARED -> EXECUTING"]
CAS --> Exec["MutationExecutor<br/>(sealed - the ONLY code path<br/>that ever sends a mutating request)"]
Exec --> Send["Exactly one bounded HTTPS request<br/>to one allow-listed endpoint"]
Send --> ReadBack["Authoritative read-back"]
ReadBack --> Outcome{"Semantic outcome?"}
Outcome -->|"confirmed"| Verified["VERIFIED"]
Outcome -->|"ambiguous/uncertain"| Reconcile["RECONCILIATION<br/>(never a blind retry)"]
Outcome -->|"proven no effect"| Failed["FAILED"]
TPM[("TPM monotonic witness -<br/>the only anti-rollback anchor<br/>backend implemented today<br/>(a software-only posture is<br/>modeled but not yet built)")] -. "required for gate 5 to pass;<br/>advances once per VERIFIED mutation" .-> G5
Audit[("Integrity-protected<br/>audit trail (HMAC chain)")] -. "every state transition" .-> Contract
style Deny fill:#f8d7da,stroke:#842029
style Verified fill:#d1e7dd,stroke:#0f5132
style Reconcile fill:#fff3cd,stroke:#856404
style Failed fill:#f8d7da,stroke:#842023
style Exec fill:#cfe2ff,stroke:#084298
Implemented, verified, and default-reachable are three different
claims. This path is real, committed code (IMPLEMENTED), exercised
end-to-end twice against a real disposable LAB appliance with
independent verification each time, never production (VERIFIED), and
requires an explicit non-default profile opt-in plus a full Tier 1
material provisioning step before the one tool is even registered
(NOT DEFAULT-REACHABLE). None of the six pre-execution gates, the
RecoveryContract state machine, or the sealed executor are
hypothetical — but none of them are reachable by an AI model deciding,
on its own, to call a tool. The off-host Ed25519 signature step is the
concrete reason why: the running MCP server process never holds a
private key capable of producing a valid PlanAuthorizationV2 or
ConfirmationEvidence, by construction.
Defense in depth / trust boundaries¶
This is the high-level model: where each class of failure is actually
stopped, derived from the same source as the two diagrams above. Verbs
are deliberately specific (STOPS/LIMITS/CONSTRAINS/DETECTS/PROVIDES)
rather than a blanket "secure" — a defense-in-depth layer is not an
absolute guarantee, and this diagram does not claim otherwise.
flowchart TD
AI["Untrusted / fallible AI reasoning<br/>(not treated as a security authority)"]
L1["Explicit MCP surface<br/>STOPS: arbitrary endpoint selection -<br/>95 named tools, no generic dispatcher"]
L2["Capability / profile boundary<br/>STOPS: unauthorized capability reachability -<br/>auditor grants 0 WRITE capabilities"]
L3["Least-privilege pfSense identity<br/>LIMITS: blast radius even if an upper<br/>layer fails - scoped credential, not admin"]
L4["Typed model / secret-exclusion boundary<br/>STOPS: confirmed credential/private-key<br/>fields from reaching AI output"]
L5["READ / WRITE separation<br/>STOPS: ordinary observation from<br/>ever becoming mutation by itself"]
L6["Authorization + confirmation boundary<br/>(WRITE path only)<br/>CONSTRAINS: intent - off-host signatures,<br/>separate identities, one-time use"]
L7["Freshness / state validation<br/>(WRITE path only)<br/>STOPS: stale plans, concurrent-state<br/>mismatch, replay"]
L8["Deterministic execution + read-back<br/>(WRITE path only)<br/>DETECTS: ambiguous mutation outcomes -<br/>never a blind retry"]
L9["RecoveryContract state machine<br/>(WRITE path only)<br/>GOVERNS: full mutation lifecycle,<br/>durable and auditable"]
L10["Integrity-protected audit trail<br/>DETECTS: state/audit tampering -<br/>HMAC-authenticated chain"]
L11["TPM monotonic witness<br/>(WRITE path only)<br/>PROVIDES: hardware-backed anti-rollback<br/>evidence - the only anchor backend<br/>implemented today; required for<br/>production WRITE activation"]
AI --> L1 --> L2 --> L3 --> L4 --> L5
L5 -.->|"READ ends here for all 95 tools"| Done["Typed result returned"]
L5 --> L6 --> L7 --> L8 --> L9 --> L10
L9 -. "required before any plan is<br/>considered safe_to_proceed" .-> L11
style AI fill:#f8d7da,stroke:#842029
style L2 fill:#fff3cd,stroke:#856404
style L6 fill:#fff3cd,stroke:#856404
style Done fill:#d1e7dd,stroke:#0f5132
style L11 fill:#cfe2ff,stroke:#084298
Layers L1–L5 apply to every request, READ or WRITE. Layers L6–L11 exist only on the WRITE path and are unreachable under the default profile — see the authorization-path diagram above for how an AI model is kept out of that path entirely, not merely discouraged from using it.