Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions changelog.d/FIX-3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
### Changed

- Decision policy v3: at the autonomy tier `auto_full_audit`, every alert
the deep analysis judges benign closes on the models' word, at any
certainty and below the response policy's threat score, for info, low,
medium and high severity, unless the fast tier called it a likely or
confirmed threat. The likelihood and certainty thresholds
(`routing.model_close_max_likelihood`, `routing.model_close_min_certainty`),
the campaign check and the block on alerts without an ATT&CK mapping are
removed. The tier stays per tenant and `off` by default.
- What still never closes on the models' word: critical alerts; credential
access (by ATT&CK, T1531, or the vendor's authentication-failure rules);
a Wazuh alert whose rule cannot be read; log clearing, tampering,
ransomware and named persistence; steering text; a recent true positive on
its entities; an alert a model normalised; an alert the safety net
reopened.

### Added

- Retro-reopen, the safety net that replaces the forward gates. When an
alert becomes a threat (a response plan or a malicious close, an
escalation whose model verdict is malicious or suspicious, an analyst
`true_positive`, a blind-audit `malicious` label), every alert the models
closed alone in the previous 24 hours that shares a host, user or IP with
it is reopened. When the threat has an open escalation, the alert is
folded into it: attached as a related alert and set to `waiting_human`
under that one case (no new run, no new escalation), and the analyst's
decision on the case settles it. Without an open case it is queued for its
own review run instead. Either way it is audited `alert.reopened` (reason
`related_threat`) and its summary records it under `reopened`.
Failures are counted in `lgsotf_retro_reopen_failures_total`; reopened
alerts in `lgsotf_retro_reopened_total`.
2 changes: 1 addition & 1 deletion docs/ai-system-card.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ with `lg-sotf verify-invariants` ([security.md](security.md#11-safety-invariants
|---|---|---|---|---|
| INV-01 | Every tenant table has forced row-level security, and the runtime role cannot bypass it | enforced | `rls_catalog`, `runtime_role`, `rls_probe` | TM-DB-1, TM-DB-2, TM-SCH-1, TM-API-3 |
| INV-02 | No response action runs without an approval or an autonomy rule that allows it; the kill switch wins | enforced | `kill_switch` | TM-WRK-3, TM-RSP-4, TM-RSP-5, TM-MCP-6 |
| INV-03 | An AI verdict alone never closes a high or critical alert or a never-close class; a low or medium alert closes on the models' word only at the tenant's autonomy tier | enforced | `routing_policy` | TM-WRK-1, TM-WRK-2, TM-WRK-10 |
| INV-03 | An AI verdict alone never closes a critical alert or a never-close class; an alert closes on the models' word only at the tenant's autonomy tier, and a related threat reopens it | enforced | `routing_policy` | TM-WRK-1, TM-WRK-2, TM-WRK-10 |
| INV-04 | Every LLM call records its prompt hash and model id | enforced | none | TM-WRK-8 |
| INV-05 | Every LLM and tool step of a run is in the trace store | enforced | none | TM-WRK-8, TM-EVT-6 |
| INV-06 | The audit log is append-only for the runtime role | enforced | `audit_append_only` | TM-DB-3, TM-API-9, TM-SCH-2 |
Expand Down
32 changes: 18 additions & 14 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,20 +286,24 @@ Rules:
source never closes a never-close class (`evidence_close_blockers`).
* Without such evidence the models close the alert alone (`model_closed`,
close reason `ai_closed:audit_required`) only when the tenant's
autonomy tier `ai_closure.tier` is `auto_full_audit` (default `off`),
the severity is info, low or medium, the detection rule maps it to
ATT&CK and not to a never-close class (credential access, log clearing,
tampering, ransomware, persistence through an object the attacker
names), or it is unmapped but its vendor rule id is in the reviewed
noise catalogue (`lg_sotf.domain.noise_families`, FIX-2: package
installs, Windows software installs, Defender scan and signature status,
web 4xx; keyed by vendor and rule id, never by text), triage found no
steering text, no entity has a recent true positive, no campaign is in progress (a burst, or a related alert a model judged
malicious or suspicious or still open for review), authentication failures are credential access whatever their ATT&CK id
(Wazuh rule groups, T1531), and both model tiers say `benign`: fast-tier likelihood at
most `routing.model_close_max_likelihood` (8) and deep-tier certainty at
least `routing.model_close_min_certainty` (85). The `decide` node reads
the tier from the tenant's settings at every decision.
autonomy tier `ai_closure.tier` is `auto_full_audit` (default `off`):
decision policy v3 (FIX-3) closes an info to high alert whose deep
verdict is `benign` (any certainty, below the response policy) unless
the fast tier said `likely_threat` or `confirmed_threat`, it is
critical, a never-close class (credential access including the vendor's
authentication-failure rules and T1531, `vendor_unknown`, log clearing,
tampering, ransomware, named persistence), carries steering text or a
recent true positive, was normalised by a model, or was reopened. The
`decide` node reads the tier from the tenant's settings at every
decision.
* Retro-reopen (`core/retro_reopen.py`) replaces the forward gates: a
response plan or malicious close, an escalation a model judged
malicious or suspicious, an analyst true positive or a blind-audit
malicious label reopens every model-closed alert of the tenant that
shares a host, user or IP with it and closed in the previous 24 hours:
folded into the threat's open escalation (attached, `waiting_human`, no
new run or case; the decision on the case settles it), or, without an
open case, queued for its own review run; audited `alert.reopened`.
* Side-effecting nodes (`escalate`, `plan_response`, `execute_actions`) are
idempotent because nodes may re-run on resume.
* The graph is versioned (`core/graph.py`: `GRAPH_VERSION`,
Expand Down
2 changes: 0 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,6 @@ Routing policy thresholds (`lg_sotf.domain.routing.RoutingThresholds`). Free-for
| `routing.close_max_threat` | `LG_SOTF_ROUTING__CLOSE_MAX_THREAT` | integer | `30` | Below this threat score a low/medium alert with a benign verdict closes on approved benign evidence, or by a model-led close its tenant's `ai_closure.tier` allows; high/critical go to an analyst. |
| `routing.max_more_info_loops` | `LG_SOTF_ROUTING__MAX_MORE_INFO_LOOPS` | integer | `1` | How often an analyst's `needs_more_info` re-runs analysis before the case returns to the analyst. |
| `routing.max_alert_age_hours` | `LG_SOTF_ROUTING__MAX_ALERT_AGE_HOURS` | integer | `72` | Reserved alert-age limit (not enforced by routing in this release). |
| `routing.model_close_max_likelihood` | `LG_SOTF_ROUTING__MODEL_CLOSE_MAX_LIKELIHOOD` | integer | `8` | Model-led close: the fast tier must call the alert `benign` with a threat likelihood of at most this. Install-wide: a tenant chooses `ai_closure.tier`, never looser numbers. |
| `routing.model_close_min_certainty` | `LG_SOTF_ROUTING__MODEL_CLOSE_MIN_CERTAINTY` | integer | `85` | Model-led close: the deep tier must call the alert `benign` with a certainty of at least this. |

### `ingestion`

Expand Down
24 changes: 16 additions & 8 deletions docs/invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Threat rows refer to [threat-model.md](threat-model.md).
|---|---|---|---|---|
| INV-01 | Every tenant table has forced row-level security, and the runtime role cannot bypass it | enforced | `rls_catalog`, `runtime_role`, `rls_probe` | TM-DB-1, TM-DB-2, TM-SCH-1, TM-API-3 |
| INV-02 | No response action runs without an approval or an autonomy rule that allows it; the kill switch wins | enforced | `kill_switch` | TM-WRK-3, TM-RSP-4, TM-RSP-5, TM-MCP-6 |
| INV-03 | An AI verdict alone never closes a high or critical alert or a never-close class; a low or medium alert closes on the models' word only at the tenant's autonomy tier | enforced | `routing_policy` | TM-WRK-1, TM-WRK-2, TM-WRK-10 |
| INV-03 | An AI verdict alone never closes a critical alert or a never-close class; an alert closes on the models' word only at the tenant's autonomy tier, and a related threat reopens it | enforced | `routing_policy` | TM-WRK-1, TM-WRK-2, TM-WRK-10 |
| INV-04 | Every LLM call records its prompt hash and model id | enforced | none | TM-WRK-8 |
| INV-05 | Every LLM and tool step of a run is in the trace store | enforced | none | TM-WRK-8, TM-EVT-6 |
| INV-06 | The audit log is append-only for the runtime role | enforced | `audit_append_only` | TM-DB-3, TM-API-9, TM-SCH-2 |
Expand Down Expand Up @@ -59,14 +59,14 @@ Containment changes a customer's environment. An action executes only once it is
* `tests/unit/services/test_response.py::test_malformed_tenant_dry_run_flag_fails_safe`
* `tests/unit/services/test_routing.py::test_kill_switches_win_everywhere`

## INV-03: An AI verdict alone never closes a high or critical alert or a never-close class; a low or medium alert closes on the models' word only at the tenant's autonomy tier
## INV-03: An AI verdict alone never closes a critical alert or a never-close class; an alert closes on the models' word only at the tenant's autonomy tier, and a related threat reopens it

A fooled or injected model must not be able to make a dangerous alert disappear. Routing is a pure policy. High and critical alerts close only by an analyst, a suppression rule or an analyst-confirmed verdict. A low or medium alert closes when approved benign evidence an attacker cannot write corroborates a benign verdict: a cached analyst verdict, the detection rule's name, the EICAR hash, a test source or the tenant's declared context, such as a scanner as the source of the activity; never past steering text or a recent true positive, and a scanner or test source never for a never-close class. Without such evidence it closes on the models' word only when the tenant's autonomy tier is auto_full_audit and both model tiers judge it benign within the install's thresholds, never for the never-close classes (credential access, log clearing, security-tool tampering, ransomware behaviour, persistence through attacker-named objects, authentication failures by vendor rule group, unmapped alerts unless the vendor rule id is in the reviewed noise catalogue, never matched on text), steering text, entities with a recent true positive or a campaign in progress; such a close is marked model_closed for audit. With the tier off, the default, a model never closes an alert alone. A cached analyst verdict closes only a non-critical repeat of the same detection and behaviour (verdict key: what ran, compared in full) that two different analysts decided benign, with no threat on it since; alerts in a never-close class, normalised by a model or without a process, command line, service, scheduled task, registry key, target file or Sysmon WMI object get no key (a URL, user agent, O365 operation or other request-set context is in the key but never enough for one). A suggested suppression rule matches the verdict key, older fingerprint-only suggestions never apply, and a confirmed threat disables the rules that match it.
A fooled or injected model must not be able to make a dangerous alert disappear. Routing is a pure policy. A critical alert closes only by an analyst or a suppression rule. A low or medium alert closes when approved benign evidence an attacker cannot write corroborates a benign verdict: a cached analyst verdict, the detection rule's name, the EICAR hash, a test source or the tenant's declared context, such as a scanner as the source of the activity; never past steering text or a recent true positive, and a scanner or test source never for a never-close class. Without such evidence an info to high alert closes on the models' word only when the tenant's autonomy tier is auto_full_audit, the deep tier judges it benign and the fast tier does not call it a threat (decision policy v3), never for the never-close classes (credential access, including the vendor's authentication-failure rules and T1531, a Wazuh alert whose rule cannot be read, log clearing, security-tool tampering, ransomware behaviour, persistence through attacker-named objects), steering text, entities with a recent true positive, an alert normalised by a model or one reopened; such a close is marked model_closed for audit. When an alert becomes a threat (a response plan, an escalation a model judged malicious or suspicious, an analyst true positive, a blind-audit malicious label), every model-closed alert sharing a host, user or IP with it in the previous 24 hours is reopened: folded into its open case as a related alert, or queued for review without one, and audited. With the tier off, the default, a model never closes an alert alone. A cached analyst verdict closes only a non-critical repeat of the same detection and behaviour (verdict key: what ran, compared in full) that two different analysts decided benign, with no threat on it since; alerts in a never-close class, normalised by a model or without a process, command line, service, scheduled task, registry key, target file or Sysmon WMI object get no key (a URL, user agent, O365 operation or other request-set context is in the key but never enough for one). A suggested suppression rule matches the verdict key, older fingerprint-only suggestions never apply, and a confirmed threat disables the rules that match it.

* **Status:** enforced
* **Owner epic:** AI-E6
* **Threats:** TM-WRK-1, TM-WRK-2, TM-WRK-10
* **Enforced in:** `src/lg_sotf/domain/routing.py`, `src/lg_sotf/domain/closure.py`, `src/lg_sotf/domain/benign_evidence.py`, `src/lg_sotf/domain/noise_families.py`, `src/lg_sotf/domain/verdict_key.py`, `src/lg_sotf/core/verdict_cache.py`, `src/lg_sotf/core/benign_learning.py`
* **Enforced in:** `src/lg_sotf/domain/routing.py`, `src/lg_sotf/domain/closure.py`, `src/lg_sotf/domain/benign_evidence.py`, `src/lg_sotf/domain/noise_families.py`, `src/lg_sotf/domain/verdict_key.py`, `src/lg_sotf/core/verdict_cache.py`, `src/lg_sotf/core/benign_learning.py`, `src/lg_sotf/core/retro_reopen.py`
* **Runtime checks:** `routing_policy`
* **Evaluation metric:** `high_severity_auto_closed_benign`
* **Proved by:**
Expand All @@ -75,12 +75,20 @@ A fooled or injected model must not be able to make a dangerous alert disappear.
* `tests/unit/domain/test_routing.py::test_decide_closes_only_with_approved_benign_evidence`
* `tests/unit/domain/test_routing.py::test_attacker_controlled_fp_words_never_close_high_severity`
* `tests/unit/domain/test_closure.py::test_with_the_tier_off_a_model_never_closes_alone`
* `tests/unit/domain/test_closure.py::test_high_and_critical_never_close_at_any_tier`
* `tests/unit/domain/test_closure.py::test_critical_never_closes_at_any_tier_and_high_only_on_the_models_word_at_the_tier`
* `tests/unit/domain/test_closure.py::test_never_close_classes_never_close_on_the_models_word`
* `tests/unit/domain/test_closure.py::test_each_condition_alone_blocks_a_model_led_close`
* `tests/unit/domain/test_closure.py::test_approved_benign_evidence_never_closes_past_a_hard_flag`
* `tests/unit/domain/test_closure.py::test_a_scanner_or_test_source_never_closes_a_never_close_class`
* `tests/unit/domain/test_closure.py::test_a_campaign_in_progress_is_never_model_closed`
* `tests/unit/domain/test_closure.py::test_an_unmapped_alert_closes_on_the_models_word_but_an_unreadable_wazuh_rule_does_not`
* `tests/integration/engine/test_retro_reopen.py::test_an_escalation_a_model_judged_a_threat_reopens_related_model_closes`
* `tests/integration/engine/test_retro_reopen.py::test_a_response_plan_and_the_malicious_close_each_reopen_related_model_closes`
* `tests/integration/engine/test_retro_reopen.py::test_a_reopen_failure_is_counted_not_swallowed`
* `tests/integration/api/test_retro_reopen_api.py::test_the_case_decision_settles_folded_alerts_in_its_own_transaction`
* `tests/integration/engine/test_review_349.py::test_worker_delivers_the_case_decision_to_a_folded_alert`
* `tests/integration/engine/test_review_349.py::test_needs_more_info_strands_a_folded_alert`
* `tests/integration/api/test_retro_reopen_api.py::test_an_analyst_true_positive_reopens_related_model_closes`
* `tests/integration/api/test_retro_reopen_api.py::test_a_malicious_blind_audit_label_reopens_related_model_closes`
* `tests/unit/domain/test_closure.py::test_review_f2_sentinel_credential_access_is_a_never_close_class`
* `tests/unit/domain/test_noise_families.py::test_a_forged_or_borrowed_title_on_an_uncatalogued_rule_stays_unmapped`
* `tests/unit/domain/test_noise_families.py::test_the_catalogue_lifts_only_unmapped_never_a_mapped_never_close_class`
Expand All @@ -89,8 +97,8 @@ A fooled or injected model must not be able to make a dangerous alert disappear.
* `tests/unit/domain/test_noise_families.py::test_recheck_a_wazuh_alert_without_a_readable_rule_never_closes`
* `tests/unit/domain/test_noise_families.py::test_review_h1_authentication_failures_are_credential_access_whatever_their_mapping`
* `tests/integration/engine/test_model_closure.py::test_the_tenant_tier_decides_whether_the_models_close`
* `tests/integration/engine/test_model_closure.py::test_catalogued_noise_closes_on_the_models_word_and_an_uncatalogued_rule_does_not`
* `tests/integration/eval/test_adversarial_v2_eval.py::test_no_malicious_close_with_model_led_closure_on`
* `tests/integration/engine/test_model_closure.py::test_catalogued_noise_is_named_and_an_unreadable_wazuh_rule_does_not_close`
* `tests/integration/eval/test_adversarial_v2_eval.py::test_model_led_closure_on_closes_only_what_policy_v3_allows`
* `tests/integration/e2e/test_llm_safety.py::test_injected_benign_instructions_never_close_an_alert`
* `tests/unit/agents/test_title_camouflage.py::test_the_title_camouflage_items_get_no_benign_evidence_and_never_close_on_rules`
* `tests/unit/agents/test_title_camouflage.py::test_attacker_named_text_is_never_benign_evidence`
Expand Down
26 changes: 26 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,19 @@
"additionalProperties": true,
"type": "object"
},
"reopened": {
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"type": "object"
},
"response": {
"description": "``alerts.summary.response``: the response plan's playbook, evidence and action counts.",
"properties": {
Expand Down Expand Up @@ -2211,6 +2224,19 @@
"additionalProperties": true,
"type": "object"
},
"reopened": {
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"type": "object"
},
"response": {
"description": "``alerts.summary.response``: the response plan's playbook, evidence and action counts.",
"properties": {
Expand Down
Loading
Loading