Security posture provisioning — design specification (pfsense-mcp-security setup)¶
Status: companion specification to
ADR-021, Accepted
(2026-08-10, owner — see ADR-021's "Acceptance note"). Read ADR-021
first, including its "Revision note," "Second revision note," "Model
comparison," and "Acceptance note" sections; this document expands the
accepted two-axis model's mechanics. Acceptance is architectural
only — nothing here is implemented. No wizard code, no CLI
entrypoint, no new environment variable, and no runtime behavior exists
yet. Building any of it is a separate, future, explicitly-scoped
authorization this document does not grant.
Purpose¶
ADR-021 decided that a future pfsense-mcp-security setup
CLI/wizard should be built around two independent axes — capability
posture (read_only/write_protected) and anchor assurance
(none/software/hardware_witness) — rather than one linear ladder,
because the ladder cannot represent this project's own real deployment
state (read_only capability with hardware_witness assurance
already fully provisioned and verified). This document works out the
mechanics: the full per-axis requirement set, the detailed state
machine, which existing code areas a real implementation would
eventually touch (for future scoping — none are modified by this
document), and a phased implementation plan for if/when building the
wizard is separately authorized.
The two axes, in detail¶
Capability posture axis¶
| Value | Capability profile (ADR-004) |
Config | WRITE |
|---|---|---|---|
read_only |
auditor |
PFSENSE_PROFILE=auditor (today's default) |
Inactive |
write_protected |
engineer, populated |
PFSENSE_PROFILE=engineer + WriteEndpoints allow-list entries per the separately-governed WRITE endpoint risk process (WRITE_ENDPOINT_RISK_MATRIX.md, ADR-020) |
Active — requires anchor assurance ≠ none (validity constraint) and its own Milestone-9-class activation decision (TIER1_ROADMAP.md) |
Recovery Contract machinery (authoritative contracts ADR-006,
confirmation authority ADR-012, reconciliation authority ADR-013,
sealed executor ADR-014, rate/blast-radius defaults ADR-015) is
already implemented and tested, currently unreachable from production.
write_protected posture is what would first make it reachable —
identically, regardless of which anchor-assurance value accompanies it.
Anchor assurance axis¶
| Value | ADR-011 backend |
Config | Daemon |
|---|---|---|---|
none |
No anchor | N/A | N/A |
software |
Remote append-only witness (non-hardware) — ADR-011's own accepted "mandatory fallback" category |
Not yet designed in detail — no remote-witness implementation exists in this repository today, only the TPM-backed one | N/A |
hardware_witness |
TPM-backed host witness (ADR-011's backend decision) |
The seven PFSENSE_TIER1_*/WITNESS_* variables already in real operational use (PFSENSE_TIER1_STORE_PATH, PFSENSE_TIER1_STORE_KEY_FILE, PFSENSE_TIER1_EXPECTED_HANDLE, PFSENSE_TIER1_WITNESS_BASE_URL, PFSENSE_TIER1_WITNESS_CLIENT_CERT_FILE, PFSENSE_TIER1_WITNESS_CLIENT_KEY_FILE, PFSENSE_TIER1_WITNESS_SERVER_CA_FILE) |
Persistent, systemd-managed (ADR-011's "Deployment model decision") — not a manually-started process |
Note: software (remote witness) is named in ADR-011's own
architecture as the mandatory fallback where no TPM exists, but no
implementation of it exists in this codebase — only the TPM-backed
witness has been built. A real write_protected + software
combination is therefore currently designed but not implementable
until that backend exists; this is a real, named implementation gap,
not an oversight of this document.
Validity constraint¶
write_protected requires anchor assurance ≠ none — directly
sourced from ADR-011's own accepted text ("if neither [TPM nor
remote witness] is available, mutation must stay blocked"). This is
the one rule a real implementation must enforce; see "State machine"
below for exactly where.
Fail-closed enforcement — orthogonal to both axes¶
Reaching hardware_witness anchor assurance's ACTIVE state means the
anchor is provisioned, deployed, and read-verified — it does not
mean store.py's anti_rollback_anchor=None → hard-refusal fail-closed
behavior is enabled. That remains its own, separate, future,
explicitly-scoped decision, unaffected by either axis, exactly as
ADR-021's safety invariants state.
Recommended UX presets¶
The wizard's default, simple front door (not the full grid):
| Preset | Capability posture | Anchor assurance |
|---|---|---|
| READ-only (default) | read_only |
none |
| Software-protected WRITE | write_protected |
software (blocked until the remote-witness backend exists — see note above) |
| Hardened hardware TPM witness | write_protected |
hardware_witness |
Advanced/staged path (not a default preset): pre-provision
hardware_witness anchor assurance while remaining read_only —
exactly this project's own real deployment history. Should be
discoverable but not forced on operators who only want a simple
three-choice front door.
read_only + software is deliberately not offered as a preset,
and not offered behind the advanced path either —
ADR-021's question 6 resolved this as intentionally hidden until the
software backend exists (Phase G) and a concrete operator need is
identified. The advanced path therefore surfaces exactly one extra
combination beyond the three presets: read_only + hardware_witness.
State machine — per axis¶
Each axis runs its own independent instance of the six-state
lifecycle (DISCOVERED → SELECTED → PREREQUISITES_VERIFIED →
PROVISIONING → ACTIVE, plus DOWNGRADING). They are not
synchronized — either can be at any state while the other is at any
other state, and this is intentional, not an inconsistency to resolve.
| State | Capability-posture axis | Anchor-assurance axis |
|---|---|---|
DISCOVERED |
Current PFSENSE_PROFILE value, WriteEndpoints contents |
TPM device presence, existing Tier 1 store, existing daemon reachability |
SELECTED |
Operator names target (read_only/write_protected) |
Operator names target (none/software/hardware_witness) |
PREREQUISITES_VERIFIED |
If target is write_protected: re-check the anchor-assurance axis's current value here — this is where the validity constraint is enforced. If anchor assurance is none, halt and direct the operator to the anchor axis first (or accept a combined preset that provisions both) |
Re-derive TPM presence/store state fresh, never trust a prior DISCOVERED result without re-checking (mirrors the TPM provisioning spec's "state is derived, not logged" discipline) |
PROVISIONING |
Populate WriteEndpoints, set PFSENSE_PROFILE=engineer — each step individually confirmed |
For hardware_witness: the already-specified provisioning state machine in anti_rollback_tpm_host_witness.md (generate secret, define NV index, first increment, seed store, mark complete), reused not reinvented — each step individually confirmed |
ACTIVE |
Requires the Milestone-9-class activation decision (TIER1_ROADMAP.md) — its own explicit approval, separate from every PROVISIONING step's confirmation |
For hardware_witness: does not require the Milestone-9 decision — that gate is specific to WRITE activation, not anchor readiness. This is the axis independence's clearest consequence: this project already reached anchor-assurance ACTIVE without WRITE ever reaching it |
DOWNGRADING (= DEACTIVATE, per ADR-021 question 4 — never DEPROVISION) |
Deactivates WRITE; does not touch the anchor-assurance axis, including the daemon/service state, not only the abstract value — a provisioned anchor and a running-or-stopped daemon are both left exactly as they are | Independent of capability posture; stops/disables the witness daemon but leaves TPM NV counter value and guest-side store/high-water-mark untouched (fully reversible: re-enable, resume); downgrading to none while capability posture is write_protected ACTIVE must itself be rejected or forced to jointly downgrade capability posture (never leave the disallowed combination reachable, even momentarily) |
DEPROVISION is explicitly not part of this table. TPM NV index
deletion and guest-side store/integrity-key deletion are a separate,
rare, manually-authorized procedure outside the routine per-axis
lifecycle entirely — see ADR-021's "Resolving open questions 3–6"
(question 4) for the full DEACTIVATE-vs-DEPROVISION distinction and
what must never happen automatically.
Interruption behavior (either axis): re-derive current state from the environment itself on the next invocation, never from a separate, potentially-stale progress log — matching the TPM provisioning state machine's own already-established discipline. Every ambiguous state halts for human review; none auto-resolves.
Affected code areas (identified for future scoping — none modified by this document)¶
| Area | Current state (verified by reading, not modified) | Eventual relevance |
|---|---|---|
src/pfsense_mcp/profiles.py |
Profile/AuditorProfile/EngineerProfile, get_profile() (ADR-004) |
Capability-posture axis maps 1:1 to this; wizard would set PFSENSE_PROFILE accordingly, not modify this module |
src/pfsense_mcp/config.py |
Loads PFSENSE_PROFILE (default auditor), fail-closed validation (ADR-008) |
Wizard-generated config must still pass this same validation unchanged — no bypass. Anchor-assurance axis config is currently read only by the inert tier1_anchor_check.py path, not config.py itself |
src/pfsense_mcp/write_endpoints.py |
WriteEndpoints, currently zero entries |
write_protected PROVISIONING would populate this per the separately-governed allow-list process — independent of which anchor-assurance value accompanies it |
src/pfsense_mcp/application.py |
Calls only tier1_anchor_check.run_anchor_startup_check(); imports nothing else from pfsense_mcp.tier1 |
A real fail-closed enforcement wiring decision would be separate from, and later than, either axis reaching ACTIVE |
src/pfsense_mcp/tier1/production_store.py, scripts/tier1_store_bootstrap.py |
Inert operator tooling, read-only status by default, --provision requires explicit flags |
The anchor-assurance axis's hardware_witness PROVISIONING state would invoke this existing tooling, not reimplement it — independently of capability-posture axis state |
witness_daemon/ + its systemd unit |
Implemented, real-hardware-verified, deployable | An anchor-assurance-axis "install the daemon" provisioning step would automate what is today a manual deployment |
tests/tier1/test_isolation.py |
Narrow, named exemptions only (tier1_anchor_check.py, anti_rollback_tpm_witness.py) |
Any new wizard-side import of pfsense_mcp.tier1 for provisioning would need its own narrow, reviewed exemption — same discipline, not relaxed |
docs/CONFIGURATION.md |
Documents only the currently-production-relevant env vars | Would eventually need the PFSENSE_TIER1_*/WITNESS_* vars documented once the anchor-assurance axis is real — independent of whether the capability-posture axis has also advanced |
Makefile's write-allow-list-check/write-capability-check |
Assert zero entries / 0-of-3 active | Would need to evolve from "assert empty" to "assert matches the declared capability-posture axis state" — no equivalent check exists yet for anchor assurance and one would need designing |
Declarative vs. interactive provisioning (resolves ADR-021 question 5)¶
Both modes are supported, but not symmetrically:
| Axis / step | Interactive | Declarative/non-interactive |
|---|---|---|
Either axis's DISCOVERED/PREREQUISITES_VERIFIED (read-only) |
Supported | Supported freely — no consent needed beyond invoking the tool |
Capability-posture axis PROVISIONING/DOWNGRADING (PFSENSE_PROFILE, WriteEndpoints — software-only) |
Supported | Supported, with itemized, named authorization (e.g. an explicit list of exactly which steps are authorized) — never a blanket authorize: true flag |
Anchor-assurance axis PROVISIONING/DOWNGRADING that touches physical TPM state |
Supported (the only mode) | Not supported — matches this project's standing practice of never automating TPM-facing commands (CURRENT_MISSION.md's "Standing SSH constraint") |
| Anchor-assurance DEPROVISION (TPM/store deletion) | Supported, its own separate authorization, outside the routine lifecycle entirely | Not supported |
A first declarative/non-interactive invocation should require a
--dry-run/preview mode showing exactly what would be authorized and
executed without executing it, matching this project's general
practice of never running a live-host command without a prior
read-only preview. A real implementation would need a declarative
config format (new code area, not yet designed) capable of the same
per-step itemization the interactive flow already requires — sketching
that format is future implementation work, not part of this design
phase.
Phased implementation plan (for if/when separately authorized — not scheduled, not committed)¶
Ordering reflects the two axes' independence — anchor-assurance work is no longer gated behind capability-posture work, correcting this document's earlier draft (which had implicitly assumed hardware provisioning follows WRITE protection, contradicting this project's own actual history).
- Phase A — design closure — complete, and accepted.
ADR-021's open questions 3–6 (allow-list sharing, decommissioning path, interactive-vs-declarative UX, whether to exposeread_only+software) are resolved; seeADR-021's "Resolving open questions 3–6" section.ADR-021is Status: Accepted (owner, 2026-08-10 — see its "Acceptance note"). Acceptance is architectural only — Phases B onward below each remain their own separate, future, explicitly-scoped implementation authorization; none is granted by acceptance. - Phase B — read-only discovery only — implemented (2026-08-10).
DISCOVERED, and where evidence allowsPREREQUISITES_VERIFIED, detail for both axes, independently: thepfsense-mcp-security discoverCLI reports current capability posture and anchor assurance, writes nothing. See "Phase B — implemented" below for the actual commands, example output, and exact files. No provisioning/setup subcommand exists yet — Phase C onward remain future, separately-authorized work. 2b. Planning slice —SELECT TARGET → EVALUATE VALIDITY → ASSESS PREREQUISITES → GENERATE PLAN, stopping beforePROVISIONING— implemented (2026-08-10). Not itself one of Phases C–F below (none of those are complete — no axis has moved pastDISCOVERED/PREREQUISITES_VERIFIEDtoward a realPROVISIONING/ACTIVEtransition). Instead, this is a read-only planning layer over Phase B's own discovery evidence:pfsense-mcp-security plan --capability-posture <value> --anchor-assurance <value>compares current state against an explicit target, enforcesADR-021's validity constraint, and generates an ordered, structured, never-executed description of what would need to happen — drawing on the same requirement/state-machine detail Phases C–F describe below, without performing any of it. See "Planning slice — implemented" below for the actual commands, example output, exact files, and the full mutation-free argument. A generated plan is never authorization to execute it — selecting a target is intent, not execution authorization; noselect/provision/applysubcommand exists yet. - Phase C — capability-posture axis,
read_only. Trivial by construction (already the default) but completes theSELECTED → ACTIVEpath end to end for the simplest case, proving the state machine and confirmation UX. - Phase D — anchor-assurance axis,
hardware_witness, independent of capability posture. Automates what this project has already done once by hand: TPM provisioning + persistent daemon deployment. Not gated on Phase E — can run before, after, or without it, as this project's own history already demonstrates. - Phase E — capability-posture axis,
write_protected. Gated on Milestone 9's own activation decision being separately reached, and on the validity constraint (anchor assurance≠ none) already being satisfied by prior Phase D work (or by provisioning it as part of this phase, for an operator who skipped D). - Phase F — downgrade paths for both axes, built last, once upgrade paths for each are proven independently.
- Phase G —
softwareanchor-assurance backend, if ever prioritized: the remote append-only witnessADR-011names as the mandatory non-TPM fallback has no implementation in this repository today. Its own separate, future design/implementation effort, unblocked by and independent of every phase above.
Each phase is its own future authorization; nothing above is scheduled.
Phase B — implemented (2026-08-10)¶
A real, installed pfsense-mcp-security CLI, registered the same way
pfsense-mcp-server is ([project.scripts] in pyproject.toml) and
shipped in the same wheel (it lives in src/pfsense_mcp/, unlike
witness_daemon//scripts/tier1_store_bootstrap.py, which are
deliberately excluded from the package). One subcommand exists:
discover. It is genuinely read-only — see "Read-only guarantees"
below.
Usage¶
$ pfsense-mcp-security discover
pfsense-mcp-security: security posture discovery (read-only)
Capability posture: read_only
configured profile name: auditor (valid=True)
write capabilities active: 0 of 3
allow-list entries: 0
- PFSENSE_PROFILE='auditor', 0 WRITE capabilities active.
Anchor assurance: hardware_witness
evidence state: provisioned_verified
store configured: True
store exists: True
seeded / complete: True / True
handle: 0x01500000
baseline: 2
provisioned_at: 2026-08-10T15:10:16.416050+00:00
witness configured: True
witness reachable: True
witness value: 2
witness matches baseline: True
- Store provisioning record: handle=0x01500000 baseline=2 provisioned_at=2026-08-10T15:10:16.416050+00:00.
- Witness value (2) matches persisted high-water mark (2).
Note: read_only + hardware_witness is a valid, representable combination
in the accepted ADR-021 two-axis model -- not one of the three curated
setup presets, but fully supported.
This report is read-only discovery only (ADR-021 Phase B). No
provisioning/setup subcommand exists yet.
The example above is real output, captured against this project's own
real production environment (the seven PFSENSE_TIER1_*/WITNESS_*
variables already in operational use) — proving Phase B's own
requirement that read_only + hardware_witness be recognized
accurately, not treated as a special case.
pfsense-mcp-security discover --json emits the same information as
deterministic, sorted-key JSON (capability_posture, anchor_assurance,
notes) for automation — verified byte-identical across repeated
invocations of the same environment.
Exit codes: 0 on a clean discovery result (including "nothing
configured" — that is not a failure); 2 if the anchor-assurance axis's
evidence state is provisioned_mismatch (a security-relevant anomaly:
the live witness value disagrees with the persisted high-water mark) —
signalling automation without conflating "just unconfigured" with
"something is actually wrong."
Read-only guarantees¶
- Never calls
provision_anchor_baseline(),TpmHostWitnessAnchor.advance(), or anything that constructs aRecoveryContract/MutationExecutor/WriteApiClient. Proven structurally (AST inspection of the actual shipped source, not the module's own docstring) bytests/test_security_discovery_isolation.py, and behaviorally bytests/test_security_discovery.py's dedicated mutation-proof tests (a fake anchor whoseadvance()raises if ever called; a monkeypatchedprovision_anchor_baselinethat raises if ever called — both tests pass because discovery never reaches either). - Never calls
open_production_store()/ constructsSqliteRecoveryContractStoreat all — its__init__always runs_initialize_schema()(CREATE TABLE IF NOT EXISTS ...), which is harmless for a healthy store but capable of creating missing tables as a side effect of merely looking, against a legacy/partial/foreign SQLite file. Instead calls the dedicatedread_only_anchor_provisioning_status()(src/pfsense_mcp/tier1/production_store.py), which opens the store via SQLite's ownmode=roURI — the database engine itself refuses any DDL/DML attempt, a structural guarantee rather than a convention this module's own code happens to follow. A missing/incomplete/ malformed schema surfaces asstore_errorevidence, never repaired. Found via pre-commit call-graph review (the original Phase B implementation went throughopen_production_store()) and fixed before this feature was ever committed; proven by dedicated regression tests asserting a foreign SQLite file (and a file with an incompleteanchor_statetable) is left byte-for-byte unchanged and gains no new tables after discovery runs. - Also never calls
read_only_anchor_provisioning_status()(or anything else) against a store path that has not already been created on disk — SQLite's ownmode=rowould refuse to create one, but the existence check happens first anyway, to keep "not provisioned" evidence accurate and avoid an avoidable error path. Mirrorsscripts/tier1_store_bootstrap.py's own existence check exactly; proven by a dedicated test asserting the store file (and its parent directory) still does not exist after discovery runs against an unconfigured-but-named path. security_discovery.pyis the second, narrow, explicit exception topfsense_mcp.tier1never being imported from outside its own package (tier1_anchor_check.pyremains the first) — the isolation exemption list intests/tier1/test_isolation.pynow names both, andsecurity_cli.pyitself does not importpfsense_mcp.tier1at all, matchingapplication.py's own established pattern of only calling the exempted module's public functions.- Evidence strings (which flow directly into
--jsonoutput, intended for logging/automation) never embed a raw configured file path, URL, or unaudited third-party exception message. SeveralTier1Error/OSError/ssl.SSLErrormessages from lower layers do embed absolute filesystem paths (e.g. a failedssl.SSLContext.load_cert_chain()); discovery reports only the exception's class name in those cases, neverstr(exc)verbatim. Found during pre-commit review and fixed before this feature was ever committed; proven by dedicated regression tests asserting the raw configured path/URL never appears in evidence, for every failure state that could otherwise expose one.
Files¶
src/pfsense_mcp/security_discovery.py(new) — the read-only discovery data model and logic, structured dataclasses/enums, no logging or other side effects.src/pfsense_mcp/security_cli.py(new) — the actualpfsense-mcp-securityentrypoint: argument parsing, human/--jsonformatting. Does not importpfsense_mcp.tier1.src/pfsense_mcp/tier1/production_store.py(modified) — addedread_only_anchor_provisioning_status(), the genuinely read-only primitivesecurity_discovery.pyuses instead ofopen_production_store().open_production_store()andSqliteRecoveryContractStorethemselves are unchanged and remain the correct choice for every caller that needs a real, schema-guaranteed store (scripts/tier1_store_bootstrap.py,tier1_anchor_check.py).pyproject.toml— newpfsense-mcp-securityconsole-script entry.tests/test_security_discovery.py,tests/test_security_cli.py,tests/test_security_discovery_isolation.py(new).tests/tier1/test_isolation.py— exemption list extended.
What Phase B deliberately does not do¶
- No
select/provision/downgradesubcommand — Phase C onward. - No interactive prompting or confirmation flow — discovery needs none
(it performs no mutating action), and the granular per-step consent
model (
ADR-021's "User consent boundaries") only applies once a mutating subcommand exists. - No declarative/config-file input —
discovertakes no target to authorize; the declarative-vs-interactive scoping table above applies starting at Phase C/D. AnchorAssurance.SOFTWAREis never resolved by Phase B — no remote-witness backend exists in this repository (Phase G); Phase B reportsunknown/nonerather than asserting a capability that cannot currently be verified.
Planning slice — implemented (2026-08-10)¶
A second pfsense-mcp-security subcommand, plan, layered entirely on
top of Phase B's own discover_security_posture() — no new source of
live evidence, no new pfsense_mcp.tier1 isolation exemption (this
module never imports pfsense_mcp.tier1 at all; see "Read-only
guarantees" below). Bridges "what state do I have?" to "what would need
to happen to reach a selected target?" without performing any of it:
DISCOVER → SELECT TARGET → EVALUATE VALIDITY → ASSESS PREREQUISITES →
GENERATE PLAN, then stop, before PROVISIONING.
Usage¶
$ pfsense-mcp-security plan --capability-posture write_protected --anchor-assurance hardware_witness
pfsense-mcp-security: security posture plan (analysis only -- not authorization)
Plan digest (schema v1): bff0326a38a3e8a3f8d2c9b72a6518c4129fef70b43282cabc70ca6f94f47f89 (plan identity only -- not authorization)
Current: capability_posture=read_only anchor_assurance=hardware_witness (provisioned_verified)
Target: capability_posture=write_protected anchor_assurance=hardware_witness
Target validity: valid
Overall status: plan_generated
Safe to proceed: True (plan validity only -- not authorization or execution readiness; see notes below)
capability_posture: upgrade
anchor_assurance: no_change
Steps (ordered; none executed):
[1] (anchor_assurance) No change required
...
[2] (capability_posture) Populate WriteEndpoints allow-list
...
blocked: False
[3] (capability_posture) Set PFSENSE_PROFILE=engineer
...
blocked: False
[4] (capability_posture) Obtain Milestone-9-class WRITE activation decision
...
implementation_available: False
blocked: True
blocked_reason: src/pfsense_mcp/tools/write/ is a deliberately empty placeholder and
SUPPORTED_CAPABILITIES_THIS_BUILD excludes every *_WRITE Capability in
this build -- no WRITE tool implementation exists to register, regardless
of configuration or authorization state.
This plan is analysis only. It is NOT authorization to execute any step listed below. [...]
The example above is abbreviated for length (.../[...] mark omitted
lines; every value shown is drawn unaltered from a real, captured run
against this project's own real production environment). It also
demonstrates a finding this slice
made by reading the actual code, not asserting it: even after every
mechanically-real configuration step (WriteEndpoints, PFSENSE_PROFILE),
the final activation step is honestly reported as implementation_available:
False — src/pfsense_mcp/tools/write/ is a deliberately empty
placeholder and no *_WRITE Capability is active anywhere in this
build, so there is currently no WRITE tool to register regardless of
authorization. Valid design state is not the same as currently
implementable target — the same distinction this slice also applies
to anchor_assurance=software (docs/SECURITY_POSTURE_PROVISIONING.md's
own Phase G note), now shown to apply to WRITE activation itself.
pfsense-mcp-security plan --json emits the same information as
deterministic, sorted-key JSON for automation. Exit codes: 0 whenever
a plan was generated (including "already satisfied" and "valid target,
backend not implemented" — neither is a usage error); 2 if the
requested target combination is invalid per ADR-021, if the current
state shows a store/witness mismatch, or if the current anchor-assurance
state is indeterminate (e.g. a malformed/foreign file already at the
configured store path) — reusing discover's own exit-code-2 meaning
rather than reinventing it.
Read-only guarantees¶
- Never imports
pfsense_mcp.tier1in any form — its only source of live evidence is the onediscover_security_posture()call at the top ofgenerate_security_posture_plan(); everything after that is pure, deterministic computation over already-collected evidence. Proven structurally (AST inspection) bytests/test_security_plan_isolation.py, and behaviorally by a test that replacessqlite3.connect/builtins.openwith functions that raiseAssertionErrorif called, then generates plans for every target combination — passing only because plan generation performs no I/O of its own. - A generated plan is never authorization to execute it — every
SecurityPosturePlancarries this statement in its ownnotesfield (machine-readable, not only documentation), proven present across every reachable target combination by a dedicated test. No field in the plan's schema could be mistaken for a "go ahead" signal: every prospective mutating step declares its ownauthorization_requiredvalue, and none is evernone_required. safe_to_proceedmeans only "the plan itself is safe to present/ continue reasoning about," never authorization. Clarified explicitly (ADR-022 owner review, 2026-08-11; behavior and the published JSON schema unchanged) with aSecurityPosturePlanclass docstring, an inline CLI caveat on the human-output line, and aplan --helpepilog sentence —Truemeans only that the target is architecturally valid and current evidence shows no detected anomaly; it does not mean approved, executable, that mutation is permitted, or that every step is unblocked or implemented.- Hardware witness never implies WRITE: selecting
anchor_assurance=hardware_witnessnever changescapability_posture_transition; reachingwrite_protectedalways requires its own explicit--capability-posture write_protectedtarget, proven by dedicated tests. - Unavailable/indeterminate evidence is never treated as a clean
slate. Found during this session's own adversarial self-review: an
early version of this slice, given a current anchor-assurance state
of
unknown(evidence_statestore_error/configuration_invalid-- e.g. a malformed/legacy/foreign file already at the configured store path), silently generated an ordinary "provision from scratch" plan, papering over the fact that something unexplained already occupies that path. Fixed: an indeterminate current anchor-assurance value now short-circuits toPlanOverallStatus.BLOCKED_INDETERMINATE_CURRENT_STATE(safe_to_proceed=False, no steps generated) before any transition logic runs, proven by dedicated regression tests. - Store/witness mismatch blocks progression, never treated as an
ordinary prerequisite gate. A detected mismatch forces
PlanOverallStatus.BLOCKED_ANOMALY_DETECTEDand every prospective mutating step toblocked=Truewith a mismatch-specific reason -- distinct from the ordinary "the anchor-assurance axis must reach its target first" sequencing block an upgrade plan can otherwise show. - Raw string targets cannot bypass the validity constraint. Found
during adversarial self-review:
CapabilityPosture/AnchorAssuranceare(str, Enum)hybrids, and this module's internal logic compares them withis. A caller passing a plain, value-equal string instead of the actual enum member would satisfy every==check but silently fail everyischeck -- including the one guardingwrite_protected none-- without raising. Fixed: both targets are coerced through theirEnumconstructor (idempotent for an already-correct member, raisesValueErrorfor anything invalid) at the very top ofgenerate_security_posture_plan(), closing this for every caller, not only this slice's own CLI (which already only ever constructed real enum members). Proven by a dedicated regression test.- Downgrade is DEACTIVATE, never DEPROVISION. Every downgrade step
this slice generates stops/disables the witness daemon only -- TPM NV
counter value and the guest-side store/high-water-mark are described
as untouched, and the step's own description states that TPM NV
index deletion and guest-side store/integrity-key deletion are not
included in this plan and would require their own separate
authorization (
ADR-021question 4).MutationClass.DESTRUCTIVE_DEPROVISIONING/AuthorizationLevel.SEPARATE_DEPROVISION_AUTHORIZATIONare declared in the schema for future forward-compatibility only and are never emitted by this slice -- proven both statically (AST) and behaviorally (a sweep over every reachable target combination and a representative set of current states). - Joint downgrades never pass through the disallowed
write_protected+nonecombination, even momentarily. When both axes downgrade at once, the capability-posture axis's steps are ordered before the anchor-assurance axis's, so WRITE deactivates first -- proven by a dedicated test. - Evidence strings never introduce a new raw configured path/URL beyond
what
security_discovery.py(already audited) supplies viacurrent.*.evidence-- this slice's own new text (step descriptions,blocking_findings,notes) never embeds one either, proven by dedicated regression tests against both an unreachable-witness and a malformed-store-path scenario.
Files¶
src/pfsense_mcp/security_plan.py(new) — the planning data model and logic: target validity evaluation, per-axis transition classification, orderedPlanStepgeneration, cross-axis ordering. Nopfsense_mcp.tier1import.src/pfsense_mcp/security_cli.py(modified) — newplansubcommand: argument parsing (--capability-posture/--anchor-assurancewithchoices=excludingunknown), human/--jsonformatting, exit-code handling.tests/test_security_plan.py,tests/test_security_plan_isolation.py(new);tests/test_security_cli.py(modified,plan-subcommand coverage added).
What the planning slice deliberately does not do¶
- No
select/apply/provisionsubcommand — selecting a target here is intent, not execution authorization; no later command in this build turns a plan into action. - No interactive prompting or confirmation flow, and no
--dry-runflag — deliberately: this entire slice already behaves as a mandatory dry-run, and introducing--dry-runterminology without a corresponding non-dry-run mode would wrongly imply one exists. - No
AnchorAssurance.SOFTWAREprovisioning capability — a target naming it is honestly reported asTargetValidity.VALID_NOT_IMPLEMENTED(a valid design-state, not a currently implementable one), never silently treated as invalid or silently treated as available. - No repair/reconciliation of a detected mismatch or indeterminate current state — both are reported as blocking findings, never acted on.
Open design questions¶
All six of ADR-021's original open questions are resolved — see
its "Open design questions" and "Resolving open questions 3–6"
sections. This document does not duplicate the decisions, only
provides their mechanical grounding (the per-axis state-machine table,
the declarative-vs-interactive scoping table, and the affected-code
inventory above).
References¶
ADR-021— authoritative decision record, including the ladder-vs-two-axis comparison this document's structure followsADR-011andanti_rollback_tpm_host_witness.md— the anchor/daemon mechanics thehardware_witnessanchor-assurance value reusesTIER1_ROADMAP.md— Milestone 9 activation gate (capability-posture axis only)WRITE_ENDPOINT_RISK_MATRIX.md,ADR-020— the separately-governed WRITE endpoint allow-list processCONFIGURATION.md— current, unmodified environment-variable referencereports-ai/reviews/WITNESS_DAEMON_DEPLOYMENT_CONVERGENCE_REVIEW_2026-08-10.md— independent evidence this document'sread_only+hardware_witnessexample state is grounded in