Skip to content

escalation: enforcement-posture Phase A — plugin defaults + honest Tier-2 labels (#75) - #95

Merged
gterdem merged 3 commits into
mainfrom
issue-75-enforcement-posture-phase-a
Jul 17, 2026
Merged

gterdem merged 3 commits into
mainfrom
issue-75-enforcement-posture-phase-a

Conversation

@gterdem

@gterdem gterdem commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Summary

Implements ADR-0067 D6 + Amendment 1 (both Accepted) — Phase A of the enforcement-posture axis.

  • SDK metadata.py: SourceMetadata.enforcement: Literal["observe", "enforce", "detect_only"] | None = None — additive, defaulted; every existing plugin stays byte-compatible. PLUGIN_CONTRACT.md gains the v1.5 changelog entry.
  • core escalation/posture.py (new): resolve_posture_map(instance_keys, plugin_defaults, instance_overrides=None) resolves (instance override OR plugin default) into a per-instance posture map, shipped at full Phase-B signature width now — instance_overrides is in the interface and always empty in this phase (Phase B / Contract: enforcement posture Phase B — per-instance override + azure_waf per-policy posture #44 supplies it; unit-tested that an override wins when present). qualified_tier2_disposition(postures, n_block_drop) is the D6 + Amendment 1 label table.
  • core escalation/decider.py: decide() gains an additive posture_map parameter (default None → today's behaviour, zero shipped-label movement). A qualified Tier-2 verdict's generic block_status_unknown narrows to not_blocked_passive (observe), detected_no_action (detect_only), or not_blocked_enforcing (enforce + zero BLOCK/DROP from the actor — Amendment 1 A1.1). Undeclared posture, enforce with a BLOCK/DROP present, or postures that differ across the actor's contributing instances all keep block_status_unknown.
  • Pipeline wiring: Pipeline takes an optional posture_defaults map; analyze_ip resolves the actor's own contributing (source_type, source_id) instances against it and passes the result to decide(). _pipeline_factory.py derives posture_defaults from the loaded plugin registry; run.py/serve.py now pass the registry through (previously unused for this purpose).
  • In-tree plugins (zero core edits): suricata/syslog/linux_auth → observe; clamav → detect_only; aws_network_firewall → enforce. azure_waf deliberately declares nothing (per-policy posture — Phase B, Contract: enforcement posture Phase B — per-instance override + azure_waf per-policy posture #44).
  • Frontend escalationCopy.ts: adds the three posture-derived disposition rows (POSTURE_COPY), kept out of the fixed 4-row TIER_COPY legend — same pattern as OBSERVED_COPY (they are Tier-2 variants, never a new tier). dispositionLabel/tierGroupLabel/dispositionColor resolve them.
  • docs/escalation-and-triage-model.md: new §2.2 for the posture axis + the interim Suricata inline-IPS mis-label note the ADR requires.

Safety property (pinned by tests across all posture values × tally shapes): no posture value can ever change a tier or produce block_status="blocked" — blocked derives solely from an actor's own BLOCK/DROP tallies.

Acceptance criteria checklist

  • SourceMetadata.enforcement additive/defaulted; PLUGIN_CONTRACT.md changelog entry — metadata.py, PLUGIN_CONTRACT.md v1.5.
  • Resolver ships at full Phase-B signature width; override wins when supplied (unit-tested, always absent in production this phase) — posture.py::resolve_posture_map + TestResolvePostureMap.
  • Four-way disposition table with matching RULE-tagged justifications — posture.py::qualified_tier2_disposition + decider.py::_tier2_verdict; justification builder unchanged (already engine/rule-text only, already true under every posture).
  • EscalationDispositionLiteral (SDK + TS) grows additively; escalationCopy.ts gains the rows — models.py, escalationCopy.ts.
  • In-tree plugins declare defaults, zero core edits — suricata/syslog/clamav/aws_nfw/linux_auth plugin.py.
  • Must-NOT: no Settings UI / instance_loader key / per-instance override in this phase — none added.
  • Must-NOT: no posture value changes tier or produces block_status="blocked" — TestSafetyProperty (parametrized across all 4 posture values × 3 tally shapes).
  • Must-NOT: azure_waf declares nothing — test_azure_waf.py::test_metadata_enforcement_is_undeclared.
  • Must-NOT: enforce + BLOCK/DROP present, and undeclared/mixed, keep block_status_unknown — covered in TestQualifiedTier2DispositionTable / TestDeciderPostureIntegration.
  • Golden untouched — expected_scores.json sha256 unchanged: fe4787643955c920e934e3789c79f741cd8c8cde6b2adbc6540b66ff3743f31f (matches the pinned value).
  • Concrete shipped case pinned — TestClamAVConcreteCase + TestPipelineWiring (end-to-end through Pipeline.analyze_ip, not just the decider in isolation).
  • Suricata IPS mis-label interim note — docs/escalation-and-triage-model.md §2.2 (no dedicated Suricata setup doc exists in-tree to duplicate it into — verified by search).

Notes for review

  • Implemented per ADR-0067 Amendment 1 (Accepted 2026-07-16, commit c3151ab), which supersedes the original issue's 3-way label table with a 4-way one (enforce + zero blocks gets its own honest label instead of staying "unknown"). The issue's own architect-advisory comment flagged this; the amendment is now accepted so this PR implements the amended (final) criteria.
  • escalation/decider.py is 507 lines (~1% over the ~500 target) after the additive docstring/parameter growth — kept as one cohesive module (the tiered action model + its justification builders are one concern) rather than fragmenting; trimmed the added docstring to minimize the overage.
  • Pipeline wiring (Pipeline.posture_defaults, _pipeline_factory.py, run.py/serve.py passing registry through) isn't in the issue's literal module-sketch file list, but is required for the concrete ClamAV/etc. shipped-case behavior to actually reach production (ADR-0067 D6: "supplied by the pipeline") rather than being decider-only-testable.

Gates

  • Tree: /home/galip/projects/firewatch/.claude/worktrees/agent-a5a816cef834da34e, branch issue-75-enforcement-posture-phase-a, HEAD b16df26 (merge of origin/main @ a4e6045).
  • bash scripts/gates-backend.sh → ruff clean, pyright 0 errors, pytest 4449 passed / 1 skipped (fast profile).
  • tests/golden/fixtures/expected_scores.json sha256 fe4787643955c920e934e3789c79f741cd8c8cde6b2adbc6540b66ff3743f31f — unchanged.
  • Frontend: npm run typecheck / npm run lint / npm test all clean (177 files, 4316 tests).

Closes #75

gterdem added 3 commits July 17, 2026 00:28
…er-2 labels (#75)

Implements ADR-0067 D6 + Amendment 1 (accepted): SourceMetadata gains an
additive `enforcement` field ("observe" | "enforce" | "detect_only" | None);
core resolves (instance override OR plugin default) into a per-instance
posture map via a new escalation/posture.py module, shipped at full Phase-B
signature width now (instance_overrides always empty in this phase — Phase B
/ #44 adds only the instance-loader key, no resolver/decider interface change).

The decider narrows the generic "block status unknown" qualified-Tier-2 label
to an honest, posture-specific one when an actor's contributing instances
declare a single, uniform posture: observe -> not_blocked_passive, detect_only
-> detected_no_action, enforce (with zero BLOCK/DROP from the actor) ->
not_blocked_enforcing (Amendment 1 A1.1). Undeclared posture, enforce with a
BLOCK/DROP present, or mixed postures across contributing instances all keep
block_status_unknown.

Safety property (pinned by tests across all posture values x tally shapes): no
posture value can change a tier or produce block_status="blocked" - blocked
derives only from per-event BLOCK/DROP tallies.

In-tree plugins declare defaults (zero core edits): suricata/syslog/linux_auth
-> observe, clamav -> detect_only, aws_network_firewall -> enforce. azure_waf
declares nothing (per-policy posture, Phase B). Pipeline wires each loaded
plugin's declared default through to the decider (CLI run/serve pass the
plugin registry to the pipeline factory).

Frontend escalationCopy.ts gains the three posture-derived disposition rows,
kept out of the fixed 4-row TIER_COPY legend (same pattern as OBSERVED_COPY).

docs/escalation-and-triage-model.md gains a 2.2 section for the posture axis
and the interim Suricata inline-IPS mis-label note the ADR requires.
@gterdem
gterdem merged commit 58cb82a into main Jul 17, 2026
3 checks passed
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.

Contract: enforcement posture Phase A — plugin-declared defaults + honest Tier-2 labels

1 participant