escalation: enforcement-posture Phase A — plugin defaults + honest Tier-2 labels (#75) - #95
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements ADR-0067 D6 + Amendment 1 (both Accepted) — Phase A of the enforcement-posture axis.
metadata.py:SourceMetadata.enforcement: Literal["observe", "enforce", "detect_only"] | None = None— additive, defaulted; every existing plugin stays byte-compatible.PLUGIN_CONTRACT.mdgains the v1.5 changelog entry.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_overridesis 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.escalation/decider.py:decide()gains an additiveposture_mapparameter (defaultNone→ today's behaviour, zero shipped-label movement). A qualified Tier-2 verdict's genericblock_status_unknownnarrows tonot_blocked_passive(observe),detected_no_action(detect_only), ornot_blocked_enforcing(enforce+ zero BLOCK/DROP from the actor — Amendment 1 A1.1). Undeclared posture,enforcewith a BLOCK/DROP present, or postures that differ across the actor's contributing instances all keepblock_status_unknown.Pipelinetakes an optionalposture_defaultsmap;analyze_ipresolves the actor's own contributing(source_type, source_id)instances against it and passes the result todecide()._pipeline_factory.pyderivesposture_defaultsfrom the loaded plugin registry;run.py/serve.pynow pass the registry through (previously unused for this purpose).suricata/syslog/linux_auth→observe;clamav→detect_only;aws_network_firewall→enforce.azure_wafdeliberately declares nothing (per-policy posture — Phase B, Contract: enforcement posture Phase B — per-instance override + azure_waf per-policy posture #44).escalationCopy.ts: adds the three posture-derived disposition rows (POSTURE_COPY), kept out of the fixed 4-rowTIER_COPYlegend — same pattern asOBSERVED_COPY(they are Tier-2 variants, never a new tier).dispositionLabel/tierGroupLabel/dispositionColorresolve 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"—blockedderives solely from an actor's own BLOCK/DROP tallies.Acceptance criteria checklist
SourceMetadata.enforcementadditive/defaulted; PLUGIN_CONTRACT.md changelog entry —metadata.py,PLUGIN_CONTRACT.mdv1.5.posture.py::resolve_posture_map+TestResolvePostureMap.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.tsgains the rows —models.py,escalationCopy.ts.plugin.py.block_status="blocked"—TestSafetyProperty(parametrized across all 4 posture values × 3 tally shapes).azure_wafdeclares nothing —test_azure_waf.py::test_metadata_enforcement_is_undeclared.enforce+ BLOCK/DROP present, and undeclared/mixed, keepblock_status_unknown— covered inTestQualifiedTier2DispositionTable/TestDeciderPostureIntegration.expected_scores.jsonsha256 unchanged:fe4787643955c920e934e3789c79f741cd8c8cde6b2adbc6540b66ff3743f31f(matches the pinned value).TestClamAVConcreteCase+TestPipelineWiring(end-to-end throughPipeline.analyze_ip, not just the decider in isolation).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
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.pyis 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.posture_defaults,_pipeline_factory.py,run.py/serve.pypassingregistrythrough) 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
/home/galip/projects/firewatch/.claude/worktrees/agent-a5a816cef834da34e, branchissue-75-enforcement-posture-phase-a, HEADb16df26(merge oforigin/main@a4e6045).bash scripts/gates-backend.sh→ ruff clean, pyright 0 errors, pytest 4449 passed / 1 skipped (fast profile).tests/golden/fixtures/expected_scores.jsonsha256fe4787643955c920e934e3789c79f741cd8c8cde6b2adbc6540b66ff3743f31f— unchanged.npm run typecheck/npm run lint/npm testall clean (177 files, 4316 tests).Closes #75