@blamejs/exceptd-skills 0.16.3 → 0.16.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -669,6 +669,7 @@
669
669
  "developer_workflow_can_tolerate_approval_friction == true"
670
670
  ],
671
671
  "priority": 1,
672
+ "for_signals": ["auto-approve-on-high-impact-tool","rubber-stamp-approval-pattern","unprompted-tool-chain"],
672
673
  "compensating_controls": [
673
674
  "approval_decision_recorded_in_audit_log",
674
675
  "approval_pattern_baseline_monitored"
@@ -683,6 +684,7 @@
683
684
  "model_supports_trust_zone_pattern == true"
684
685
  ],
685
686
  "priority": 2,
687
+ "for_signals": ["instruction-coercion-in-tool-response","unprompted-tool-chain"],
686
688
  "compensating_controls": [
687
689
  "trust_zone_test_battery_in_ci",
688
690
  "system_prompt_versioned_in_source_control"
@@ -697,6 +699,7 @@
697
699
  "agent_egress_traffic_routable_through_inspection == true"
698
700
  ],
699
701
  "priority": 2,
702
+ "for_signals": ["agent-egress-to-non-allowlisted-destination","credential-shadow-in-tool-args"],
700
703
  "compensating_controls": [
701
704
  "inspection_rules_recorded_in_iac",
702
705
  "false_positive_baseline_established"
@@ -711,6 +714,7 @@
711
714
  "alternative_content_sources_available_for_removed_externals == true"
712
715
  ],
713
716
  "priority": 2,
717
+ "for_signals": ["rag-source-from-untrusted-origin","instruction-coercion-in-tool-response"],
714
718
  "compensating_controls": [
715
719
  "rag_source_inventory_recorded",
716
720
  "integrity_control_evidence_per_source"
@@ -876,6 +876,7 @@
876
876
  "auto_update_available == true"
877
877
  ],
878
878
  "priority": 1,
879
+ "for_signals": ["vulnerable-windsurf-version"],
879
880
  "compensating_controls": [],
880
881
  "estimated_time_hours": 0.5
881
882
  },
@@ -886,6 +887,7 @@
886
887
  "operator_authorized_to_modify_mcp_config == true"
887
888
  ],
888
889
  "priority": 2,
890
+ "for_signals": ["unsigned-mcp-manifest","mcp-typosquat-candidate"],
889
891
  "compensating_controls": [
890
892
  "mcp_egress_network_allowlist",
891
893
  "mcp_runs_as_non_root_enforced"
@@ -899,6 +901,7 @@
899
901
  "all_authorized_mcp_servers_have_published_signatures == true"
900
902
  ],
901
903
  "priority": 3,
904
+ "for_signals": ["mcp-allowlist-missing","mcp-command-provenance-gap"],
902
905
  "compensating_controls": [
903
906
  "pre-commit hook validates mcp config against signed allowlist",
904
907
  "CI gate rejects PRs that introduce unsigned mcp entries"
@@ -912,6 +915,7 @@
912
915
  "package_lockfile_exists == true OR sigstore_rekor_entry_available == true"
913
916
  ],
914
917
  "priority": 4,
918
+ "for_signals": ["mcp-version-without-integrity"],
915
919
  "compensating_controls": [
916
920
  "scheduled re-pin job on every authorized MCP package release"
917
921
  ],
@@ -855,6 +855,7 @@
855
855
  "operator_assigns_register_owner == true"
856
856
  ],
857
857
  "priority": 1,
858
+ "for_signals": ["no-cryptographic-asset-register","register-incomplete-per-asset-fields","long-retention-classical-only-asset"],
858
859
  "compensating_controls": [
859
860
  "interim_high_value_asset_prioritisation",
860
861
  "vendor_engagement_for_unattested_dependencies"
@@ -869,6 +870,7 @@
869
870
  "operator_legal_team_engaged == true"
870
871
  ],
871
872
  "priority": 2,
873
+ "for_signals": ["vendor-no-pqc-commitment","policy-without-vendor-sla"],
872
874
  "compensating_controls": [
873
875
  "alternative_vendor_shortlist_per_dependency",
874
876
  "data_minimisation_for_vendor_handled_data"
@@ -883,6 +885,7 @@
883
885
  "hybrid_configured_asset_inventory_present == true"
884
886
  ],
885
887
  "priority": 2,
888
+ "for_signals": ["no-downgrade-detection"],
886
889
  "compensating_controls": [
887
890
  "alerting_on_downgrade_threshold_per_service",
888
891
  "weekly_downgrade_report_to_security_team"
@@ -897,6 +900,7 @@
897
900
  "key_migration_window_within_jurisdiction_deadline == true"
898
901
  ],
899
902
  "priority": 3,
903
+ "for_signals": ["hsm-firmware-no-pqc"],
900
904
  "compensating_controls": [
901
905
  "network_segmentation_for_classical_only_hsm",
902
906
  "shortened_retention_for_keys_pending_migration"
@@ -910,6 +914,7 @@
910
914
  "operator_jurisdictional_scope_documented == true"
911
915
  ],
912
916
  "priority": 4,
917
+ "for_signals": ["regulator-deadline-missing-or-stale"],
913
918
  "compensating_controls": [
914
919
  "automated_regulator_publication_monitoring",
915
920
  "quarterly_deadline_review_with_legal"
@@ -923,6 +928,7 @@
923
928
  "firmware_update_channel_exists == true OR end_of_life_communicable_to_customers == true"
924
929
  ],
925
930
  "priority": 4,
931
+ "for_signals": ["embedded-tls-stack-classical-only"],
926
932
  "compensating_controls": [
927
933
  "pqc_gateway_for_classical_only_devices",
928
934
  "customer_notification_of_hndl_exposure"
@@ -947,6 +947,7 @@
947
947
  "forensic_acquisition_tooling_available == true"
948
948
  ],
949
949
  "priority": 1,
950
+ "for_signals": ["mass-file-extension-change-event","shadow-copy-deletion-no-iac-ticket","encrypted-file-extension-growth-rate","cobaltstrike-beacon-signature","large-outbound-transfer-pre-encryption","bloodhound-class-ad-recon","ad-admin-count-modification-event"],
950
951
  "compensating_controls": [
951
952
  "out-of-band-comms-on-signal-or-wickr",
952
953
  "incident-commander-named",
@@ -685,6 +685,7 @@
685
685
  "ops_authorization_for_chmod == true"
686
686
  ],
687
687
  "priority": 1,
688
+ "for_signals": ["non-baseline-suid"],
688
689
  "compensating_controls": [
689
690
  "suid-baseline-tracked",
690
691
  "edr-alerts-on-suid-creation"
@@ -699,6 +700,7 @@
699
700
  "automation_account_owner_authorization == true"
700
701
  ],
701
702
  "priority": 1,
703
+ "for_signals": ["sudoers-nopasswd-wildcard"],
702
704
  "compensating_controls": [
703
705
  "sudoers-change-recorded",
704
706
  "automation-account-inventory-updated"
@@ -712,6 +714,7 @@
712
714
  "deterministic_implant_indicator_fired == true"
713
715
  ],
714
716
  "priority": 1,
717
+ "for_signals": ["duplicate-uid-zero","orphan-privileged-process","cron-or-timer-outside-policy"],
715
718
  "compensating_controls": [
716
719
  "host-isolated-from-network",
717
720
  "memory-snapshot-captured-pre-removal"
@@ -726,6 +729,7 @@
726
729
  "service_owner_identified == true"
727
730
  ],
728
731
  "priority": 2,
732
+ "for_signals": ["listening-socket-unknown-bind"],
729
733
  "compensating_controls": [
730
734
  "host-firewall-rule-added",
731
735
  "service-inventory-updated"
@@ -1261,6 +1261,7 @@
1261
1261
  "operator_authorized_for_package_upgrade == true"
1262
1262
  ],
1263
1263
  "priority": 1,
1264
+ "for_signals": ["package-matches-catalogued-cve","kev-listed-match","windsurf-vulnerable-version"],
1264
1265
  "compensating_controls": [
1265
1266
  "restart_affected_services_post_upgrade",
1266
1267
  "regression_test_post_upgrade"
@@ -1274,6 +1275,7 @@
1274
1275
  "ci_pipeline_modifiable == true"
1275
1276
  ],
1276
1277
  "priority": 2,
1278
+ "for_signals": ["lockfile-no-integrity"],
1277
1279
  "compensating_controls": [
1278
1280
  "lockfile_review_in_pr_template"
1279
1281
  ],
@@ -1298,6 +1300,7 @@
1298
1300
  "sbom_tooling_supports_transitive == true"
1299
1301
  ],
1300
1302
  "priority": 4,
1303
+ "for_signals": ["transitive-deps-incomplete-sbom"],
1301
1304
  "compensating_controls": [
1302
1305
  "sbom_completeness_gate_in_ci"
1303
1306
  ],
@@ -1310,6 +1313,7 @@
1310
1313
  "security_team_capacity_for_vex == true"
1311
1314
  ],
1312
1315
  "priority": 5,
1316
+ "for_signals": ["matched-cve-without-vex"],
1313
1317
  "compensating_controls": [
1314
1318
  "vex_template_in_security_playbook"
1315
1319
  ],
@@ -1323,6 +1327,7 @@
1323
1327
  "ci_or_pre-commit_modifiable == true"
1324
1328
  ],
1325
1329
  "priority": 6,
1330
+ "for_signals": ["ai-code-no-provenance"],
1326
1331
  "compensating_controls": [
1327
1332
  "pr_review_for_ai_emitted_code",
1328
1333
  "ai_code_review_checklist"
@@ -1336,6 +1341,7 @@
1336
1341
  "ml_loader_modifiable == true OR ml_inference_pipeline_owned == true"
1337
1342
  ],
1338
1343
  "priority": 7,
1344
+ "for_signals": ["model-weight-unsigned-and-executable-format"],
1339
1345
  "compensating_controls": [
1340
1346
  "model_inventory_review",
1341
1347
  "non-safetensors_models_quarantined"
@@ -754,6 +754,7 @@
754
754
  "rotation_ownership_identified == true"
755
755
  ],
756
756
  "priority": 1,
757
+ "for_signals": ["aws-access-key-id","aws-secret-access-key","gcp-service-account-json","github-personal-access-token","github-fine-grained-pat","slack-bot-or-user-token","stripe-secret-key","jwt-token-with-secret-context","ssh-private-key-block","openai-api-key","anthropic-api-key"],
757
758
  "compensating_controls": [
758
759
  "session-revocation",
759
760
  "cloudtrail-or-audit-log-review-for-misuse-window"
@@ -767,6 +768,7 @@
767
768
  "file_owner_is_current_user OR has_sudo == true"
768
769
  ],
769
770
  "priority": 2,
771
+ "for_signals": ["world-writable-env-file","ssh-key-bad-perms"],
770
772
  "compensating_controls": [
771
773
  "perm-change-recorded"
772
774
  ],
@@ -908,6 +908,7 @@
908
908
  "rotation_runbook_documented == true OR ad_hoc_rotation_acceptable_within_4h == true"
909
909
  ],
910
910
  "priority": 1,
911
+ "for_signals": ["credential-store-touched-during-window","long-lived-token-in-compromised-ci-log","outbound-exfil-during-window"],
911
912
  "compensating_controls": [
912
913
  "rotation_recorded_in_secret_store_audit_log",
913
914
  "synthetic_test_validates_new_credential",
@@ -923,6 +924,7 @@
923
924
  "host_isolation_authority == true"
924
925
  ],
925
926
  "priority": 1,
927
+ "for_signals": ["compromised-install-on-host"],
926
928
  "compensating_controls": [
927
929
  "host_isolation_during_audit",
928
930
  "fresh_workstation_provisioning_for_affected_developers"
@@ -937,6 +939,7 @@
937
939
  "operator_can_re_authenticate_ai_assistant == true"
938
940
  ],
939
941
  "priority": 1,
942
+ "for_signals": ["ai-assistant-config-mutated"],
940
943
  "compensating_controls": [
941
944
  "ai_assistant_baseline_attestation",
942
945
  "weekly_ai_assistant_config_drift_scan"
@@ -951,6 +954,7 @@
951
954
  "operator_can_file_advisories == true"
952
955
  ],
953
956
  "priority": 2,
957
+ "for_signals": ["operator-published-package-republish"],
954
958
  "compensating_controls": [
955
959
  "advisory_filed_within_24h",
956
960
  "yank_or_deprecate_within_4h",
@@ -966,6 +970,7 @@
966
970
  "slsa_l3_pipeline_operational == true"
967
971
  ],
968
972
  "priority": 3,
973
+ "for_signals": ["no-provenance-revocation-filed"],
969
974
  "compensating_controls": [
970
975
  "provenance_revocation_filed",
971
976
  "clean_release_provenance_attested"
@@ -979,6 +984,7 @@
979
984
  "ir_plan_ownership_attested == true"
980
985
  ],
981
986
  "priority": 4,
987
+ "for_signals": ["ir-plan-missing-supply-chain-recovery"],
982
988
  "compensating_controls": [
983
989
  "ir_team_training_recorded",
984
990
  "feed_ingestion_attested"
@@ -653,6 +653,7 @@
653
653
  "downstream_consumers_can_be_updated_in_rotation_window == true"
654
654
  ],
655
655
  "priority": 1,
656
+ "for_signals": ["leaked-incoming-webhook-url","webhook-secret-shared-across-apps","long-lived-callback-token-in-ci-log"],
656
657
  "compensating_controls": [
657
658
  "rotation_recorded_in_secret_store_audit_log",
658
659
  "synthetic_event_validates_new_secret"
@@ -667,6 +668,7 @@
667
668
  "deploy_window_within_72h == true"
668
669
  ],
669
670
  "priority": 2,
671
+ "for_signals": ["missing-webhook-signature-validation","missing-webhook-replay-window","missing-state-parameter"],
670
672
  "compensating_controls": [
671
673
  "behaviour_test_added_to_ci",
672
674
  "deploy_recorded_in_change_management"
@@ -681,6 +683,7 @@
681
683
  "downstream_app_can_tolerate_temporary_callback_outage == true"
682
684
  ],
683
685
  "priority": 3,
686
+ "for_signals": ["wildcard-redirect-uri"],
684
687
  "compensating_controls": [
685
688
  "redirect_uri_allowlist_recorded_in_secret_store",
686
689
  "callback_handler_logs_rejected_origins"
@@ -1421,6 +1421,19 @@ function vexFilterFromDoc(doc) {
1421
1421
  return out;
1422
1422
  }
1423
1423
 
1424
+ // Cap a summary line at `max` chars on a word boundary, appending an ellipsis
1425
+ // so a truncated line is visibly marked rather than cut mid-token. A raw
1426
+ // .slice() left blocked-preflight summaries ending mid-word ("...and conne")
1427
+ // with no signal that anything was dropped (the full text remains in the JSON
1428
+ // envelope's reason/remediation fields).
1429
+ function capSummary(s, max = 240) {
1430
+ if (typeof s !== 'string' || s.length <= max) return s;
1431
+ const slice = s.slice(0, max - 1);
1432
+ const lastSpace = slice.lastIndexOf(' ');
1433
+ const base = lastSpace > max - 40 ? slice.slice(0, lastSpace) : slice;
1434
+ return base.replace(/[\s—.,;:-]+$/, '') + '…';
1435
+ }
1436
+
1424
1437
  // --- phase 6: validate ---
1425
1438
 
1426
1439
  function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
@@ -1433,8 +1446,16 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1433
1446
  // Pick the highest-priority remediation_path whose preconditions are all
1434
1447
  // either satisfied by agentSignals or marked unverified=allow.
1435
1448
  const paths = (v.remediation_paths || []).slice().sort((a, b) => a.priority - b.priority);
1436
- let selected = null;
1449
+ // Indicators that actually fired this run — used to prefer a remediation
1450
+ // that addresses the finding (via its for_signals linkage) over the bare
1451
+ // priority-1 default when no path's preconditions are verified.
1452
+ const firedSignalIds = new Set(
1453
+ (analyzeResult._detect_indicators || []).filter(i => i.verdict === 'hit').map(i => i.id)
1454
+ );
1455
+ const addressesFired = (p) =>
1456
+ Array.isArray(p.for_signals) && p.for_signals.some(s => firedSignalIds.has(s));
1437
1457
  const considered = [];
1458
+ const satisfiedIds = new Set();
1438
1459
  for (const p of paths) {
1439
1460
  const pcResult = (p.preconditions || []).map(expr => ({
1440
1461
  expr,
@@ -1442,12 +1463,24 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1442
1463
  submitted: agentSignals[expressionKey(expr)] !== undefined
1443
1464
  }));
1444
1465
  const allSatisfied = pcResult.every(x => x.satisfied);
1445
- considered.push({ id: p.id, priority: p.priority, all_satisfied: allSatisfied, preconditions: pcResult });
1446
- if (allSatisfied && !selected) selected = p;
1447
- }
1448
- // Always at least propose the highest-priority path even if preconditions
1449
- // weren't verified the agent can surface that to the operator.
1450
- if (!selected && paths.length) selected = paths[0];
1466
+ if (allSatisfied) satisfiedIds.add(p.id);
1467
+ considered.push({ id: p.id, priority: p.priority, all_satisfied: allSatisfied, addresses_fired_signal: addressesFired(p), preconditions: pcResult });
1468
+ }
1469
+ // Precedence (paths is priority-sorted, so `find` returns the highest-priority
1470
+ // match). Relevance to a fired indicator outranks a satisfied-but-unrelated
1471
+ // path: recommending a ready-to-apply remediation that does NOT address the
1472
+ // finding (just because its preconditions happen to hold) was the original
1473
+ // defect — for_signals must beat it.
1474
+ // 1. addresses a fired indicator AND preconditions satisfied (relevant + ready)
1475
+ // 2. addresses a fired indicator (relevant; operator must meet its preconditions)
1476
+ // 3. preconditions satisfied (no fired-signal-linked path — actionable fallback)
1477
+ // 4. priority-1 (nothing else — at least propose the top path)
1478
+ const selected =
1479
+ paths.find(p => addressesFired(p) && satisfiedIds.has(p.id))
1480
+ || paths.find(addressesFired)
1481
+ || paths.find(p => satisfiedIds.has(p.id))
1482
+ || paths[0]
1483
+ || null;
1451
1484
 
1452
1485
  // selected_remediation selection logic:
1453
1486
  // 1. Iterate remediation_paths sorted by priority ASC (lower number =
@@ -1546,11 +1579,14 @@ function computeRegressionNextRun(triggers) {
1546
1579
  const parsed = parseInterval(t.interval, now);
1547
1580
  if (!parsed) continue;
1548
1581
  if (parsed.event) {
1549
- eventTriggers.push({ interval: t.interval, trigger: t.trigger || t.event || null });
1582
+ // Shipped playbooks key the trigger string as `condition`; `trigger` /
1583
+ // `event` are accepted for external/fixture submissions. Reading only
1584
+ // the latter two left regression_event_triggers[].trigger always null.
1585
+ eventTriggers.push({ interval: t.interval, trigger: t.trigger || t.event || t.condition || null });
1550
1586
  continue;
1551
1587
  }
1552
1588
  if (parsed.unparseable) {
1553
- unparseable.push({ interval: parsed.unparseable, trigger: t.trigger || null });
1589
+ unparseable.push({ interval: parsed.unparseable, trigger: t.trigger || t.event || t.condition || null });
1554
1590
  continue;
1555
1591
  }
1556
1592
  if (parsed.date && (!soonest || parsed.date < soonest)) soonest = parsed.date;
@@ -3271,7 +3307,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3271
3307
  // playbooks_run[] by array index. `verdict:"blocked"` +
3272
3308
  // `summary_line` keep the flat result-envelope shape consistent
3273
3309
  // across both branches.
3274
- const summaryLine = `${playbookId}: blocked at preflight (${pre.blocked_by || 'unknown'}) — ${pre.reason || ''}`.slice(0, 240);
3310
+ const summaryLine = capSummary(`${playbookId}: blocked at preflight (${pre.blocked_by || 'unknown'}) — ${pre.reason || ''}`);
3275
3311
  return {
3276
3312
  ok: false,
3277
3313
  playbook_id: playbookId,
@@ -3487,14 +3523,23 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3487
3523
  && phases.detect.classification !== 'not_detected'
3488
3524
  && phases.detect.classification !== 'inconclusive'
3489
3525
  && phases.detect.classification !== 'pending') {
3490
- // Only on a real detection verdict: surface the dominant FIRED
3491
- // indicator's id (the signal that drove the verdict). Gating on the
3492
- // verdict keeps a stray hit on an inconclusive/not-detected run from
3493
- // advertising a finding. Prefer a deterministic or high-confidence hit;
3494
- // fall back to the first hit.
3526
+ // Only on a real detection verdict: surface the FIRED indicator that
3527
+ // best explains the result. Gating on the verdict keeps a stray hit on
3528
+ // an inconclusive/not-detected run from advertising a finding. Prefer the
3529
+ // indicator that actually drove the RWEP score the greatest
3530
+ // weight_applied among fired, weight-bearing rwep_inputs — so the
3531
+ // headline names the finding that produced the number shown beside it.
3532
+ // Fall back to the dominant fired indicator (deterministic/high
3533
+ // confidence), then the first hit, when no weighted signal fired.
3534
+ const breakdown = (phases.analyze.rwep && Array.isArray(phases.analyze.rwep.breakdown))
3535
+ ? phases.analyze.rwep.breakdown : [];
3536
+ const driver = breakdown
3537
+ .filter((b) => b.fired && typeof b.weight_applied === 'number' && b.weight_applied > 0)
3538
+ .sort((a, b) => b.weight_applied - a.weight_applied)[0];
3495
3539
  const hits = phases.detect.indicators.filter((i) => i.verdict === 'hit');
3496
3540
  const dominant = hits.find((i) => i.deterministic === true || i.confidence === 'high') || hits[0];
3497
- if (dominant && dominant.id) topFinding = dominant.id;
3541
+ if (driver && driver.signal_id) topFinding = driver.signal_id;
3542
+ else if (dominant && dominant.id) topFinding = dominant.id;
3498
3543
  }
3499
3544
  // Evidence completeness: indicators-evaluated vs indicators-known
3500
3545
  // distinguishes "ran fully and found nothing" from "couldn't
@@ -3534,6 +3579,13 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3534
3579
  evidence_hash: evidenceHash,
3535
3580
  submission_digest: submissionDigest,
3536
3581
  preflight_issues: pre.issues,
3582
+ // Non-fatal collector notices (e.g. a file skipped for exceeding the
3583
+ // scan size limit) surfaced from the submission so a `collect | run`
3584
+ // consumer can see what the collector could not scan. Advisory only:
3585
+ // never affects verdict, rwep, or evidence_completeness.
3586
+ ...(Array.isArray(agentSubmission.collector_errors) && agentSubmission.collector_errors.length
3587
+ ? { collector_warnings: agentSubmission.collector_errors }
3588
+ : {}),
3537
3589
  // Source provenance for precondition_checks. Shape:
3538
3590
  // { '<pc-id>': 'submission' | 'runOpts' | 'merged', ... }
3539
3591
  precondition_check_source: pcSource,
@@ -521,6 +521,11 @@
521
521
  "examples": [["live_patch_available == true"], ["reboot_window_within_4h == true"], ["compensating_control_deployed == true"]]
522
522
  },
523
523
  "priority": { "type": "integer", "minimum": 1, "description": "Lower number is preferred. 1 is the recommended path when its preconditions hold." },
524
+ "for_signals": {
525
+ "type": "array",
526
+ "items": { "type": "string" },
527
+ "description": "Optional. detect.indicators ids this remediation addresses. When no path's preconditions are satisfied, selected_remediation prefers the highest-priority path whose for_signals includes a fired indicator, so the recommendation matches the finding instead of always defaulting to priority-1. Omit (or leave empty) for generic/fallback paths."
528
+ },
524
529
  "compensating_controls": { "type": "array", "items": { "type": "string" } },
525
530
  "estimated_time_hours": { "type": "number" }
526
531
  }
@@ -419,6 +419,24 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
419
419
  }
420
420
  }
421
421
 
422
+ // validate.remediation_paths[].for_signals[] must reference real indicator
423
+ // ids. A dangling ref silently never matches, so selected_remediation falls
424
+ // back to priority-1 without surfacing the intended finding-specific link —
425
+ // exactly the kind of "looks wired, does nothing" drift this gate exists to
426
+ // catch. Warning severity (promoted to a hard error under --strict, matching
427
+ // the false_positive_profile precedent above).
428
+ const validatePhase = phases.validate || {};
429
+ for (const [i, rp] of (validatePhase.remediation_paths || []).entries()) {
430
+ if (!rp || typeof rp !== 'object' || !Array.isArray(rp.for_signals)) continue;
431
+ for (const sig of rp.for_signals) {
432
+ if (!indIds.has(sig)) {
433
+ warn(
434
+ `phases.validate.remediation_paths[${i}] (${rp.id || 'unknown'}).for_signals: unresolved "${sig}" — no matching phases.detect.indicators[].id`,
435
+ );
436
+ }
437
+ }
438
+ }
439
+
422
440
  // rwep_threshold ordering. Hard error — a misordered threshold actively
423
441
  // breaks the scoring path.
424
442
  const rwep = direct.rwep_threshold || {};