Skip to content

fix(security): stop four gates re-seeding the doc drift they caused - #85

Merged
wshallwshall merged 3 commits into
mainfrom
claude/doc-drift-code-defects
Jul 30, 2026
Merged

fix(security): stop four gates re-seeding the doc drift they caused#85
wshallwshall merged 3 commits into
mainfrom
claude/doc-drift-code-defects

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Why

The documentation pass in #83 corrected five operator docs against the posture the engine actually enforces. Four of those corrections were treating symptoms. The code itself named a settings key that does not exist, described its own gate against the wrong axis, silently discounted two allow-lists, and validated alert routing everywhere except the state an operator is most likely to be in.

Left alone, each re-seeds the drift the moment someone reads the source — two of the doc defects in #83 were provably copied from the stale comments fixed here.

The four fixes

1. DICOM peer-control refusal named a key that is silently discarded

transports/dicom.py told the operator to set [inbound].source_ip_allowlist. InboundSettings has no such field (bind_host / ack_after / stream_inflight_budget_bytes only) and _Section is extra="ignore" — so an operator following the engine's own error message writes a key into messagefoundry.toml that is accepted and dropped, leaving a non-loopback C-STORE SCP with no peer-IP gate while believing it has one.

Aggravated by the construction gate counting controls: a spoofable calling_ae_allowlist plus the discarded key passes the fail-closed check.

The message now names the working surface — an inbound(...) keyword, the only one for a DICOM SCP since DICOM() is not authorable in connections.toml — and explicitly distinguishes it from the connections.toml [[inbound]] key that is real, so a site running MLLP alongside DICOM cannot read it as license to delete a working allowlist. It leads with mTLS and warns in-band that an AE Title is caller-asserted. Same spelling corrected in the module docstring, the gate comment, config/wiring.py (×2), config/settings.py, docs/SECURITY.md (×3) and docs/ASVS-L2-PHASE0-CHANGES.md.

2. Open-egress gate counted six of eight allow-lists — fixed in the fail-closed direction only

egress_open omitted allowed_smtp and allowed_direct, both of which are enforced downstream by _allowlist_for (wiring_runner.py:5979-5982, :6434, :6449). A mail-only or Direct-only PHI instance could enumerate every destination it uses and still be refused as "UNRESTRICTED" — while the gate's own docstring already claimed that declaring a list was sufficient.

Deliberately implemented in the narrow form: the two lists count only when [security].block_unlisted_outbound was left unset, which is exactly the state the deny-by-default flip (__main__.py) turns ON. Measured on --env prod:

config before after
allowed_smtp only (flip unset) exit 2 starts, deny-by-default flipped ON
allowed_direct only (flip unset) exit 2 starts, deny-by-default flipped ON
block_unlisted_outbound = false + allowed_smtp only exit 2 exit 2 (unchanged)
all other combinations unchanged

No shipped refusal stops firing. The instance that explicitly opted out of deny-by-default still cannot satisfy the gate on smtp/direct alone, because there the other six transports stay allow-any. The refusal now names that override when it is the reason a declared allowlist did not satisfy the gate.

3. Two gate comments described the deployment tier, not the enforcement dial

__main__.py said "refuse (production) / warn (non-production)" over branches that read enforcing — which is enforce by default on dev and staging as much as prod, so all three refuse. Comment-only, no behaviour change. These are the comments two BLOCKING doc defects in #83 were copied from.

4. Alert rules were validated everywhere except where it matters

notifier_from_settings returned on zero transports before cross-validating rules, so a [[alerts.rules]] block routing to a transport the instance does not configure was accepted and silently never applied — while the identical rule alongside one transport was a hard ValueError. The fail-loud guarantee held everywhere except the state an operator is in while first setting alerts up.

Validation now runs first. Verified matrix:

case before after
zero transports + rule naming webhook silently None raises, naming the keys to add
zero transports + rule naming no transport None None + warning
zero transports + no rules None None (unchanged)
webhook configured + rule naming webhook sink sink (unchanged)
webhook configured + rule naming email raises raises (unchanged)

Also gated at authoring time: alert add cross-checks the rule being added, so the VS Code "New Alert" command can no longer persist a config that only fails at the next boot. Scoped to the rule being added (not every rule in the file) so a file already containing a bad rule can still be repaired with alert remove rather than being wedged shut.

⚠️ BREAKING CHANGE — for release notes

An instance that starts today with an inert [[alerts.rules]] block naming an unconfigured transport will now refuse to start until that transport is configured.

Scope: rules present AND ≥1 rule or escalation tier sets a non-empty transports AND webhook_url unset AND not all of email_smtp_host + email_from + email_to set.

The mitigating fact, which I think decides it: in that state the rule has never routed a single alert. This removes no working behaviour — it converts a permanent silent no-op into a one-line-of-config startup refusal whose message names the exact keys. Rules that name no transport keep starting, so the ordinary "write rules first, wire the transport later" flow is unaffected.

Filed, not fixed

BACKLOG #252 — the DICOM gate counts a spoofable AE-title list as a sufficient peer control. An AE Title is a caller-asserted string with no cryptographic binding, so a non-loopback SCP can pass a "fail-closed peer controls" check while being reachable by anyone who guesses one AE Title. Demoting it is an ADR 0025 §9 + docs/SECURITY.md contract change and a breaking change for anyone relying on it, so it needs its own decision rather than riding in on a message correction. Number allocated via scripts/coord/alloc.ps1.

Verification

  • Unfiltered pytest: 9500 passed, 817 skipped, 0 failed (14:39). No -k filter — a narrow filter is exactly what let a guard go red in CI previously. The +5 vs the prior baseline is exactly accounted for: 4 new tests here + the backlog test docs(backlog): items 232-239 declared no status, turning main red #82 repaired.
  • Every behaviour claim above was produced by executing the shipped predicate in the venv, not by reading it.
  • New regression tests pin: the egress narrow form including the loosening that deliberately did not happen, the alert authoring refusal, and the no-transport rule still being allowed.
  • One existing fixture corrected (test_alerts_edit.py): it round-tripped an escalate tier routing to email against a from-scratch TOML with no transport. Seeded with a real email transport so it keeps testing field round-tripping rather than becoming an accidental refusal test.
  • ruff check / ruff format clean (0.15.22, matching constraints.lock).
  • Integrated with origin/main (5d93cfbc, includes quality: re-runnable Steps-view coverage scan (BACKLOG #239) #81).

Review notes

🤖 Generated with Claude Code

wshallwshall and others added 3 commits July 30, 2026 16:52
The documentation pass (#83) corrected five operator docs against the enforced
posture. Four of those corrections were treating symptoms: the code itself named
a settings key that does not exist, described its own gate against the wrong
axis, silently discounted two allow-lists, and validated alert routing only in
the state an operator is least likely to be in. Left alone, each re-seeds the
drift the moment someone reads the source.

1. DICOM peer-control refusal named a key that is silently discarded
   transports/dicom.py told the operator to set `[inbound].source_ip_allowlist`.
   InboundSettings has no such field and section models ignore unknown keys, so
   an operator following the engine's OWN error message writes a key into
   messagefoundry.toml that is accepted and dropped — leaving a non-loopback SCP
   with no peer-IP gate while believing it has one. Aggravated by the gate
   COUNTING controls, so a spoofable AE-title list plus the discarded key passes.
   The message now names the working surface (an `inbound(...)` keyword, the ONLY
   one for a DICOM SCP since DICOM() is not authorable in connections.toml),
   disambiguates it from the connections.toml `[[inbound]]` key that IS real, and
   leads with mTLS. Same spelling corrected in the module docstring, the gate
   comment, wiring.py, docs/SECURITY.md and docs/ASVS-L2-PHASE0-CHANGES.md.

2. Open-egress gate counted six of eight allow-lists
   `egress_open` omitted allowed_smtp and allowed_direct, both of which ARE
   enforced downstream by _allowlist_for. A mail-only or Direct-only PHI instance
   could enumerate every destination it uses and still be refused as
   "UNRESTRICTED", with nothing naming the two lists that did not count — and the
   gate's own docstring already claimed otherwise.
   Fixed in the FAIL-CLOSED direction only: the two lists count only when
   [security].block_unlisted_outbound was left UNSET, which is exactly the state
   the deny-by-default flip turns ON. Measured, prod PHI: allowed_smtp-only and
   allowed_direct-only go exit 2 -> start (deny-by-default flipped ON); the
   explicit `block_unlisted_outbound = false` + smtp/direct-only case STILL
   refuses. No shipped refusal stops firing. The refusal now names that override
   when it is the reason a declared allowlist did not satisfy the gate.

3. Two gate comments described the deployment tier, not the enforcement dial
   __main__.py said "refuse (production) / warn (non-production)" over branches
   that read `enforcing` — which is `enforce` by default on dev and staging as
   much as prod, so all three REFUSE. These comments are what two BLOCKING doc
   defects were copied from. Comment-only, no behaviour change.

4. Alert rules were validated everywhere except where it matters
   notifier_from_settings returned on zero transports BEFORE cross-validating
   rules, so a [[alerts.rules]] block routing to a transport the instance does
   not configure was accepted and silently never applied — while the identical
   rule alongside one transport was a hard ValueError. Validation now runs first.
   A rule naming no transport is unaffected (still starts, now with a warning when
   rules exist that cannot page anyone).
   BREAKING: an instance that starts today with an inert rules block naming a
   transport will now refuse until the transport is configured. In that state the
   rule has never routed a single alert, so this removes no working behaviour — it
   converts a permanent silent no-op into a startup refusal that names the keys.
   Also gated at AUTHORING time: `alert add` cross-checks the rule being added, so
   the editor can no longer write a config that only fails at the next boot.
   Scoped to the added rule so a file already containing a bad rule can still be
   repaired with `alert remove` rather than being wedged shut.

Filed, not fixed: BACKLOG #252 — the DICOM gate counting a spoofable AE-title
list as a sufficient peer control. That is an ADR 0025 §9 + SECURITY.md contract
change and deserves its own decision, not a rider on a message correction.

Verified with the unfiltered suite (a narrow -k filter is what let a guard go red
in CI last time). New regression tests pin the egress narrow form (including the
loosening that deliberately did NOT happen), the alert authoring refusal, and the
no-transport rule still being allowed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…o claude/doc-drift-code-defects

# Conflicts:
#	docs/BACKLOG.md
@wshallwshall
wshallwshall merged commit fc43eb6 into main Jul 30, 2026
33 checks passed
@wshallwshall
wshallwshall deleted the claude/doc-drift-code-defects branch July 30, 2026 23:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant