agent-threat-rules 4.1.5 → 4.2.1

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.
Files changed (248) hide show
  1. package/CHANGELOG.md +751 -1
  2. package/README.md +356 -125
  3. package/dist/adapters/openshell-filter.d.ts +10 -3
  4. package/dist/adapters/openshell-filter.d.ts.map +1 -1
  5. package/dist/adapters/openshell-filter.js +10 -3
  6. package/dist/adapters/openshell-filter.js.map +1 -1
  7. package/dist/adapters/stdio-adapter.js +3 -3
  8. package/dist/adapters/stdio-adapter.js.map +1 -1
  9. package/dist/cli/mcp-tools-document.d.ts +40 -0
  10. package/dist/cli/mcp-tools-document.d.ts.map +1 -0
  11. package/dist/cli/mcp-tools-document.js +95 -0
  12. package/dist/cli/mcp-tools-document.js.map +1 -0
  13. package/dist/cli/scan-handler.d.ts +8 -0
  14. package/dist/cli/scan-handler.d.ts.map +1 -1
  15. package/dist/cli/scan-handler.js +126 -34
  16. package/dist/cli/scan-handler.js.map +1 -1
  17. package/dist/cli.d.ts.map +1 -1
  18. package/dist/cli.js +80 -43
  19. package/dist/cli.js.map +1 -1
  20. package/dist/converters/sarif.d.ts +14 -2
  21. package/dist/converters/sarif.d.ts.map +1 -1
  22. package/dist/converters/sarif.js +7 -9
  23. package/dist/converters/sarif.js.map +1 -1
  24. package/dist/corpus-event.d.ts +47 -5
  25. package/dist/corpus-event.d.ts.map +1 -1
  26. package/dist/corpus-event.js +68 -29
  27. package/dist/corpus-event.js.map +1 -1
  28. package/dist/embedding/model-loader.d.ts +2 -0
  29. package/dist/embedding/model-loader.d.ts.map +1 -1
  30. package/dist/embedding/model-loader.js +3 -1
  31. package/dist/embedding/model-loader.js.map +1 -1
  32. package/dist/enforcement.d.ts +8 -0
  33. package/dist/enforcement.d.ts.map +1 -1
  34. package/dist/enforcement.js +10 -0
  35. package/dist/enforcement.js.map +1 -1
  36. package/dist/engine.d.ts +150 -6
  37. package/dist/engine.d.ts.map +1 -1
  38. package/dist/engine.js +337 -76
  39. package/dist/engine.js.map +1 -1
  40. package/dist/eval/embedding-provenance.d.ts +40 -0
  41. package/dist/eval/embedding-provenance.d.ts.map +1 -0
  42. package/dist/eval/embedding-provenance.js +71 -0
  43. package/dist/eval/embedding-provenance.js.map +1 -0
  44. package/dist/eval/eval-harness.d.ts +34 -0
  45. package/dist/eval/eval-harness.d.ts.map +1 -1
  46. package/dist/eval/eval-harness.js +49 -2
  47. package/dist/eval/eval-harness.js.map +1 -1
  48. package/dist/eval/hackaprompt-corpus.d.ts +12 -0
  49. package/dist/eval/hackaprompt-corpus.d.ts.map +1 -1
  50. package/dist/eval/hackaprompt-corpus.js +15 -1
  51. package/dist/eval/hackaprompt-corpus.js.map +1 -1
  52. package/dist/eval/hackaprompt-provenance.d.ts +52 -0
  53. package/dist/eval/hackaprompt-provenance.d.ts.map +1 -0
  54. package/dist/eval/hackaprompt-provenance.js +108 -0
  55. package/dist/eval/hackaprompt-provenance.js.map +1 -0
  56. package/dist/eval/run-hackaprompt-benchmark.d.ts +18 -8
  57. package/dist/eval/run-hackaprompt-benchmark.d.ts.map +1 -1
  58. package/dist/eval/run-hackaprompt-benchmark.js +154 -64
  59. package/dist/eval/run-hackaprompt-benchmark.js.map +1 -1
  60. package/dist/eval/run-pint-benchmark.js +11 -1
  61. package/dist/eval/run-pint-benchmark.js.map +1 -1
  62. package/dist/eval/skill-benchmark.d.ts +26 -6
  63. package/dist/eval/skill-benchmark.d.ts.map +1 -1
  64. package/dist/eval/skill-benchmark.js +49 -16
  65. package/dist/eval/skill-benchmark.js.map +1 -1
  66. package/dist/flywheel.d.ts +4 -0
  67. package/dist/flywheel.d.ts.map +1 -1
  68. package/dist/flywheel.js +12 -4
  69. package/dist/flywheel.js.map +1 -1
  70. package/dist/hook-handler.d.ts +11 -1
  71. package/dist/hook-handler.d.ts.map +1 -1
  72. package/dist/hook-handler.js +84 -16
  73. package/dist/hook-handler.js.map +1 -1
  74. package/dist/hook-tool-output.d.ts +47 -0
  75. package/dist/hook-tool-output.d.ts.map +1 -0
  76. package/dist/hook-tool-output.js +110 -0
  77. package/dist/hook-tool-output.js.map +1 -0
  78. package/dist/index.d.ts +5 -3
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +3 -2
  81. package/dist/index.js.map +1 -1
  82. package/dist/layer-integration.d.ts +4 -2
  83. package/dist/layer-integration.d.ts.map +1 -1
  84. package/dist/layer-integration.js +6 -2
  85. package/dist/layer-integration.js.map +1 -1
  86. package/dist/mcp-server.d.ts +8 -0
  87. package/dist/mcp-server.d.ts.map +1 -1
  88. package/dist/mcp-server.js +1 -0
  89. package/dist/mcp-server.js.map +1 -1
  90. package/dist/mcp-tools/scan.d.ts.map +1 -1
  91. package/dist/mcp-tools/scan.js +9 -1
  92. package/dist/mcp-tools/scan.js.map +1 -1
  93. package/dist/measurement/enforce-lane.d.ts +66 -0
  94. package/dist/measurement/enforce-lane.d.ts.map +1 -0
  95. package/dist/measurement/enforce-lane.js +89 -0
  96. package/dist/measurement/enforce-lane.js.map +1 -0
  97. package/dist/measurement/from-eval-harness.d.ts +16 -3
  98. package/dist/measurement/from-eval-harness.d.ts.map +1 -1
  99. package/dist/measurement/from-eval-harness.js +15 -5
  100. package/dist/measurement/from-eval-harness.js.map +1 -1
  101. package/dist/measurement/in-sample-ablation.d.ts +29 -0
  102. package/dist/measurement/in-sample-ablation.d.ts.map +1 -0
  103. package/dist/measurement/in-sample-ablation.js +45 -0
  104. package/dist/measurement/in-sample-ablation.js.map +1 -0
  105. package/dist/measurement/schema.d.ts +38 -1
  106. package/dist/measurement/schema.d.ts.map +1 -1
  107. package/dist/measurement/schema.js +43 -1
  108. package/dist/measurement/schema.js.map +1 -1
  109. package/dist/quality/action-eligibility.d.ts +4 -2
  110. package/dist/quality/action-eligibility.d.ts.map +1 -1
  111. package/dist/quality/action-eligibility.js.map +1 -1
  112. package/dist/rule-confidence.d.ts +31 -0
  113. package/dist/rule-confidence.d.ts.map +1 -0
  114. package/dist/rule-confidence.js +49 -0
  115. package/dist/rule-confidence.js.map +1 -0
  116. package/dist/rule-scaffolder.d.ts.map +1 -1
  117. package/dist/rule-scaffolder.js +43 -4
  118. package/dist/rule-scaffolder.js.map +1 -1
  119. package/dist/semantic-evaluator.d.ts.map +1 -1
  120. package/dist/semantic-evaluator.js +15 -13
  121. package/dist/semantic-evaluator.js.map +1 -1
  122. package/dist/shadow-evaluator.d.ts +7 -2
  123. package/dist/shadow-evaluator.d.ts.map +1 -1
  124. package/dist/shadow-evaluator.js +29 -25
  125. package/dist/shadow-evaluator.js.map +1 -1
  126. package/dist/testcase-event.d.ts +82 -1
  127. package/dist/testcase-event.d.ts.map +1 -1
  128. package/dist/testcase-event.js +135 -16
  129. package/dist/testcase-event.js.map +1 -1
  130. package/dist/tier0-invariant.d.ts.map +1 -1
  131. package/dist/tier0-invariant.js +1 -0
  132. package/dist/tier0-invariant.js.map +1 -1
  133. package/dist/tier1-blacklist.d.ts.map +1 -1
  134. package/dist/tier1-blacklist.js +1 -0
  135. package/dist/tier1-blacklist.js.map +1 -1
  136. package/dist/tool-definition-scope.d.ts +45 -0
  137. package/dist/tool-definition-scope.d.ts.map +1 -0
  138. package/dist/tool-definition-scope.js +68 -0
  139. package/dist/tool-definition-scope.js.map +1 -0
  140. package/dist/types.d.ts +42 -2
  141. package/dist/types.d.ts.map +1 -1
  142. package/dist/verdict.d.ts +46 -2
  143. package/dist/verdict.d.ts.map +1 -1
  144. package/dist/verdict.js +99 -14
  145. package/dist/verdict.js.map +1 -1
  146. package/package.json +2 -1
  147. package/rules/agent-manipulation/ATR-2026-00030-cross-agent-attack.yaml +2 -2
  148. package/rules/agent-manipulation/ATR-2026-00139-casual-authority-redirect.yaml +1 -1
  149. package/rules/agent-manipulation/ATR-2026-00328-named-mode-persona-override.yaml +1 -1
  150. package/rules/agent-manipulation/ATR-2026-00330-yoja-allcaps-settings-block-persona.yaml +1 -1
  151. package/rules/agent-manipulation/ATR-2026-00418-weknora-mcp-config-rce.yaml +5 -5
  152. package/rules/agent-manipulation/ATR-2026-00432-superagi-output-handler-eval-rce.yaml +1 -1
  153. package/rules/agent-manipulation/ATR-2026-00440-semantic-kernel-vector-store-eval-rce.yaml +3 -3
  154. package/rules/context-exfiltration/ATR-2026-00426-nl-output-injection-credential-leak.yaml +1 -1
  155. package/rules/context-exfiltration/ATR-2026-00501-data-exfiltration-via-markdown-image-and-link-url-injection.yaml +16 -2
  156. package/rules/context-exfiltration/ATR-2026-00505-system-prompt-extraction-instruction-dump-request.yaml +6 -4
  157. package/rules/context-exfiltration/ATR-2026-00702-ipi-credential-exfil-via-agent-action.yaml +1 -1
  158. package/rules/context-exfiltration/ATR-2026-00707-ipi-file-exfil-with-destroy-or-tamper.yaml +1 -1
  159. package/rules/context-exfiltration/ATR-2026-00851-indirect-pi-financial-payment-exfil.yaml +1 -1
  160. package/rules/context-exfiltration/ATR-2026-01457-sysprompt-completion-clone-attack.yaml +1 -1
  161. package/rules/context-exfiltration/ATR-2026-01462-sysprompt-override-pseudo-admin.yaml +1 -1
  162. package/rules/context-exfiltration/ATR-2026-01463-imend-imsystem-prompt-injection-format.yaml +1 -1
  163. package/rules/context-exfiltration/ATR-2026-01606-ssrf-internal-network-scan.yaml +1 -1
  164. package/rules/context-exfiltration/ATR-2026-02847-policy-justified-exfil-to-email.yaml +1 -1
  165. package/rules/context-exfiltration/ATR-2026-02855-cross-email-exfil-relay.yaml +79 -0
  166. package/rules/context-exfiltration/ATR-2026-02861-exfil-send-email-with-body-directive.yaml +83 -0
  167. package/rules/context-exfiltration/ATR-2026-02866-idna-label-separator-in-fetch-url-host.yaml +104 -0
  168. package/rules/excessive-autonomy/ATR-2026-00500-ssrf-via-agent-url-fetch-instruction.yaml +12 -2
  169. package/rules/excessive-autonomy/ATR-2026-00710-ipi-physical-access-grant.yaml +1 -1
  170. package/rules/model-abuse/ATR-2026-01897-host-sabotage.yaml +2 -2
  171. package/rules/model-security/ATR-2026-00433-modelcache-torch-load-deserialization-rce.yaml +1 -1
  172. package/rules/privilege-escalation/ATR-2026-00040-privilege-escalation.yaml +1 -1
  173. package/rules/privilege-escalation/ATR-2026-00436-enclave-vm-sandbox-escape-rce.yaml +1 -1
  174. package/rules/privilege-escalation/ATR-2026-01601-sql-injection-destructive-ddl.yaml +3 -3
  175. package/rules/privilege-escalation/ATR-2026-01612-debug-mode-privilege-escalation.yaml +1 -1
  176. package/rules/privilege-escalation/ATR-2026-02867-identifier-typed-tool-argument-dot-dot-path-segment.yaml +106 -0
  177. package/rules/prompt-injection/ATR-2026-00004-system-prompt-override.yaml +2 -2
  178. package/rules/prompt-injection/ATR-2026-00080-encoding-evasion.yaml +1 -1
  179. package/rules/prompt-injection/ATR-2026-00086-visual-spoofing.yaml +1 -1
  180. package/rules/prompt-injection/ATR-2026-00203-context-pollution-skill-description.yaml +1 -1
  181. package/rules/prompt-injection/ATR-2026-00228-structured-jailbreak.yaml +2 -2
  182. package/rules/prompt-injection/ATR-2026-00233-structured-jailbreak.yaml +2 -2
  183. package/rules/prompt-injection/ATR-2026-00256-base-n-encoding-jailbreak.yaml +1 -1
  184. package/rules/prompt-injection/ATR-2026-00264-latent-injection-translation.yaml +3 -3
  185. package/rules/prompt-injection/ATR-2026-00265-latent-injection-rag-document.yaml +1 -1
  186. package/rules/prompt-injection/ATR-2026-00282-perez-prompt-injection-hijack.yaml +4 -4
  187. package/rules/prompt-injection/ATR-2026-00321-hyphenated-system-instruction-injection.yaml +1 -1
  188. package/rules/prompt-injection/ATR-2026-00394-backspace-deletion-character-injection.yaml +2 -2
  189. package/rules/prompt-injection/ATR-2026-00446-variable-assignment-payload-injection.yaml +3 -3
  190. package/rules/prompt-injection/ATR-2026-00476-garak-web-injection-javascript-alert-1.yaml +3 -3
  191. package/rules/prompt-injection/ATR-2026-00481-garak-dan-mixedunassigned.yaml +1 -1
  192. package/rules/prompt-injection/ATR-2026-00489-garak-encoding-javascript-alert-1.yaml +3 -3
  193. package/rules/prompt-injection/ATR-2026-00503-fake-error-state-takeover-unrestricted-replacement-bot.yaml +4 -2
  194. package/rules/prompt-injection/ATR-2026-00508-escape-delimiter-wrapped-goal-hijacking-in-user-input-prompt.yaml +3 -3
  195. package/rules/prompt-injection/ATR-2026-00512-rules-file-backdoor-injection.yaml +1 -1
  196. package/rules/prompt-injection/ATR-2026-01005-many-shot-repetition-override.yaml +2 -2
  197. package/rules/prompt-injection/ATR-2026-01006-payload-split-string-concat-injection.yaml +2 -2
  198. package/rules/prompt-injection/ATR-2026-01015-evasion-refusal-suppression.yaml +1 -1
  199. package/rules/prompt-injection/ATR-2026-01016-evasion-response-priming-forced-opener.yaml +1 -1
  200. package/rules/prompt-injection/ATR-2026-01026-evasion-unicode-glitch-token-anomaly.yaml +2 -2
  201. package/rules/prompt-injection/ATR-2026-01304-indirect-injection-carrier-important-override.yaml +1 -1
  202. package/rules/prompt-injection/ATR-2026-01759-dos-new-important-instructions-injection-in-document.yaml +1 -1
  203. package/rules/prompt-injection/ATR-2026-01865-llmail-fake-email-boundary-marker.yaml +1 -1
  204. package/rules/prompt-injection/ATR-2026-02010-emoji-only-payload.yaml +2 -2
  205. package/rules/prompt-injection/ATR-2026-02815-injected-halt-order-canned-reply-dos.yaml +80 -0
  206. package/rules/prompt-injection/ATR-2026-02820-tool-result-orders-agent-to-hide-its-own-action.yaml +106 -0
  207. package/rules/prompt-injection/ATR-2026-02858-context-suppression-directive.yaml +73 -0
  208. package/rules/prompt-injection/ATR-2026-02859-tool-output-agent-role-reassignment.yaml +71 -0
  209. package/rules/prompt-injection/ATR-2026-02860-forged-instruction-boundary-marker.yaml +70 -0
  210. package/rules/prompt-injection/ATR-2026-02862-fake-chat-template-turn-injection.yaml +79 -0
  211. package/rules/skill-compromise/ATR-2026-00060-skill-impersonation.yaml +1 -1
  212. package/rules/skill-compromise/ATR-2026-00063-skill-chain-attack.yaml +2 -2
  213. package/rules/skill-compromise/ATR-2026-00064-over-permissioned-skill.yaml +1 -1
  214. package/rules/skill-compromise/ATR-2026-00066-parameter-injection.yaml +2 -2
  215. package/rules/skill-compromise/ATR-2026-00149-skill-exfil-compound.yaml +6 -6
  216. package/rules/skill-compromise/ATR-2026-00223-reverse-shell-dropper.yaml +2 -2
  217. package/rules/skill-compromise/ATR-2026-01755-backdoor-pot-linguistic-trigger-phrase.yaml +1 -1
  218. package/rules/skill-compromise/ATR-2026-01756-backdoor-pot-symbol-emoticon-trigger.yaml +1 -1
  219. package/rules/skill-compromise/ATR-2026-02261-code-credential-exfil-chain.yaml +1 -1
  220. package/rules/tool-poisoning/ATR-2026-00012-unauthorized-tool-call.yaml +23 -23
  221. package/rules/tool-poisoning/ATR-2026-00013-tool-ssrf.yaml +16 -5
  222. package/rules/tool-poisoning/ATR-2026-00161-important-tag-cross-tool-shadowing.yaml +1 -1
  223. package/rules/tool-poisoning/ATR-2026-00277-echo-template-command-injection.yaml +2 -2
  224. package/rules/tool-poisoning/ATR-2026-00494-garak-exploitation-mixedunassigned.yaml +4 -4
  225. package/rules/tool-poisoning/ATR-2026-00521-shell-command-injection-agent-tool-context.yaml +1 -1
  226. package/rules/tool-poisoning/ATR-2026-00581-mcp-tool-rug-pull-post-approval-redefinition.yaml +1 -1
  227. package/rules/tool-poisoning/ATR-2026-01303-tool-schema-enumeration-social-engineering.yaml +1 -1
  228. package/rules/tool-poisoning/ATR-2026-01307-mcp-dns-rebinding-attack.yaml +2 -2
  229. package/rules/tool-poisoning/ATR-2026-01775-semantic-mcp-tool-manifest-poisoning.yaml +1 -1
  230. package/rules/tool-poisoning/ATR-2026-01932-shadow-undeclared-mcp-server-registration.yaml +1 -1
  231. package/rules/tool-poisoning/ATR-2026-02025-mcp-full-schema-poisoning-nondescription-field.yaml +1 -1
  232. package/rules/tool-poisoning/ATR-2026-02817-untrusted-content-dictates-tool-arguments.yaml +83 -0
  233. package/rules/tool-poisoning/ATR-2026-02864-non-canonical-ipv4-host-in-url-tool-argument.yaml +102 -0
  234. package/rules/tool-poisoning/ATR-2026-02865-read-only-utility-write-flag-in-command-argument.yaml +157 -0
  235. package/rules/tool-poisoning/ATR-2026-02868-ssrf-ipv6-transition-address-embedded-private-ipv4.yaml +107 -0
  236. package/rules/tool-poisoning/ATR-2026-02870-invisible-control-before-directive.yaml +94 -0
  237. package/spec/README.md +6 -3
  238. package/spec/atr-event-v1.0.md +13 -8
  239. package/spec/atr-method-v1.1.md +2 -2
  240. package/spec/category-registry/gemara/README.md +102 -0
  241. package/spec/category-registry/gemara/ai-agent-capability-catalog.yaml +157 -0
  242. package/spec/category-registry/gemara/atr-attack-vector-catalog.yaml +130 -0
  243. package/spec/category-registry/gemara/atr-detection-control-catalog.yaml +327 -0
  244. package/spec/category-registry/gemara/atr-threat-catalog.yaml +246 -0
  245. package/spec/category-registry/gemara/src/editorial.yaml +743 -0
  246. package/spec/mappings/README.md +1 -1
  247. package/spec/mappings/atr-to-nist-csf-2.0.md +5 -4
  248. package/spec/schema/event.schema.json +1 -1
package/dist/engine.js CHANGED
@@ -18,8 +18,10 @@ import { loadRulesFromDirectory, loadRuleFile } from './loader.js';
18
18
  import { evaluateTraceRule } from './trace-evaluator.js';
19
19
  import { evaluateSemanticRule } from './semantic-evaluator.js';
20
20
  import { laneAllows, requiresConfirm } from './quality/rule-contract.js';
21
+ import { appliesToToolDefinition } from './tool-definition-scope.js';
21
22
  import { laneFromConfig } from './enforcement.js';
22
- import { computeVerdict } from './verdict.js';
23
+ import { computeVerdict, isModelDecided, modelDecisionsFromConfig } from './verdict.js';
24
+ import { ruleConfidence } from './rule-confidence.js';
23
25
  import { SemanticModule } from './modules/semantic.js';
24
26
  import { resolveSkillId, runFingerprintLayer, shouldRunSemanticLayer, createSemanticModuleFromConfig, runSemanticLayer, } from './layer-integration.js';
25
27
  import { buildBlacklistMatch, resolveSkillId as resolveBlacklistSkillId } from './tier1-blacklist.js';
@@ -104,14 +106,103 @@ export function decodeBase64Blocks(content) {
104
106
  return decoded;
105
107
  }
106
108
  /** Map agent event types to ATR source types */
107
- const EVENT_TYPE_TO_SOURCE = {
109
+ export const EVENT_TYPE_TO_SOURCE = Object.freeze({
108
110
  llm_input: 'llm_io',
109
111
  llm_output: 'llm_io',
110
112
  tool_call: 'tool_call',
111
113
  tool_response: 'mcp_exchange',
112
114
  agent_behavior: 'agent_behavior',
113
115
  multi_agent_message: 'multi_agent_comm',
114
- };
116
+ });
117
+ /**
118
+ * agent_source types that name what a rule reads rather than a stream of
119
+ * events of their own (spec/atr-schema.yaml), mapped to the source type whose
120
+ * events carry that data: a context window reaches the model as LLM input or
121
+ * as tool output, so those rules run where llm_io rules run; a skill is
122
+ * installed, granted permissions and chained through tool calls, so those
123
+ * rules run where tool_call rules run. memory_access is left out for now
124
+ * (WITHHELD_SOURCE_TYPES).
125
+ *
126
+ * No event type maps to them in EVENT_TYPE_TO_SOURCE, so the source filter used
127
+ * to skip them on every typed event: on the tool_call and tool_response events
128
+ * src/hook-handler.ts builds, such a rule never ran, while `atr test` mapped its
129
+ * type to a base type and passed its cases. Only an event with no source type
130
+ * (mcp_exchange) or a SKILL.md scan reached it. `atr test` now leaves the type
131
+ * alone: what it runs is what the engine admits.
132
+ */
133
+ export const RUNTIME_SOURCE_TYPE = Object.freeze({
134
+ context_window: 'llm_io',
135
+ skill_lifecycle: 'tool_call',
136
+ skill_permission: 'tool_call',
137
+ skill_chain: 'tool_call',
138
+ });
139
+ /**
140
+ * Extended agent_source types left out of RUNTIME_SOURCE_TYPE for now, each
141
+ * with the reason. A rule of such a type still runs only on an event with no
142
+ * source type and in a SKILL.md scan. `atr test` reports its cases as
143
+ * unevaluable with this reason instead of passing them on an event the engine
144
+ * skips the rule on.
145
+ *
146
+ * memory_access reads what context_window reads, an agent's memory as it
147
+ * reaches the model, and would map to llm_io. Its rules (ATR-2026-00075 and
148
+ * -01155 today) were written for memory writes, and 00075 also matches standing
149
+ * instructions in an ordinary chat turn ("From now on, always answer in
150
+ * French."). On llm_input events the adapters that block by default would
151
+ * refuse those messages: the Mastra ATRProcessor aborts on high and critical
152
+ * in the hunt lane, and the OpenShell filter denies at high. Map it once those
153
+ * rules match memory writes only, measured on chat-turn data.
154
+ */
155
+ export const WITHHELD_SOURCE_TYPES = Object.freeze({
156
+ memory_access: 'its rules also match standing instructions in ordinary chat turns, which adapters that block by default would refuse',
157
+ });
158
+ /**
159
+ * Whether a rule of this agent_source.type runs on an event of this source
160
+ * type: the rule's source type (after RUNTIME_SOURCE_TYPE) is the event's, or
161
+ * one of two documented exceptions holds. An event type with no source type
162
+ * (mcp_exchange) admits every rule, whatever its agent_source holds; on a typed
163
+ * event a type that is not a string is admitted by nothing.
164
+ * sourceAdmits() adds the trace exception.
165
+ */
166
+ export function sourceTypeAdmits(ruleSourceType, eventSourceType) {
167
+ if (!eventSourceType)
168
+ return true;
169
+ if (typeof ruleSourceType !== 'string')
170
+ return false;
171
+ const source = Object.hasOwn(RUNTIME_SOURCE_TYPE, ruleSourceType)
172
+ ? RUNTIME_SOURCE_TYPE[ruleSourceType]
173
+ : ruleSourceType;
174
+ if (source === eventSourceType)
175
+ return true;
176
+ // mcp_exchange rules also match tool_call events.
177
+ if (source === 'mcp_exchange' && eventSourceType === 'tool_call')
178
+ return true;
179
+ // Indirect prompt injection: llm_io (prompt-injection) rules must also run on
180
+ // tool_response events. A poisoned tool / MCP / RAG output is the primary
181
+ // indirect-injection channel — the payload never appears in a direct
182
+ // llm_input, it rides in on tool output the model then reads. tool_response
183
+ // maps to source type 'mcp_exchange', so without this the entire llm_io
184
+ // family was silently skipped on every tool response. (Field resolution
185
+ // routes event.content into user_input/agent_output for tool_response events
186
+ // — see resolveField.)
187
+ return source === 'llm_io' && eventSourceType === 'mcp_exchange';
188
+ }
189
+ /**
190
+ * Whether the source-type filter admits a rule on an event: sourceTypeAdmits(),
191
+ * or a trace-method rule on an event that carries a trace. One definition for
192
+ * evaluateRaw() and evaluateAsync().
193
+ */
194
+ function sourceAdmits(rule, eventSourceType, event) {
195
+ // agent_source?. : the loader requires only id, title and detection, and a
196
+ // rule without agent_source must not abort the whole evaluation.
197
+ if (sourceTypeAdmits(rule.agent_source?.type, eventSourceType))
198
+ return true;
199
+ // Trace-method rules declare agent_source.type: agent_trace, which maps to no
200
+ // event source type and would always be filtered out. Evaluate them whenever
201
+ // the event actually carries a trace payload; evaluateRule still returns null
202
+ // if the trace primitives don't fire, and ordinary (non-trace) events are
203
+ // unaffected because event.trace is undefined.
204
+ return rule.detection?.method === 'trace' && event.trace !== undefined;
205
+ }
115
206
  /** Map agent event types to default field names */
116
207
  const EVENT_TYPE_TO_FIELD = {
117
208
  llm_input: 'user_input',
@@ -121,9 +212,28 @@ const EVENT_TYPE_TO_FIELD = {
121
212
  agent_behavior: 'metric',
122
213
  multi_agent_message: 'agent_message',
123
214
  };
215
+ /**
216
+ * A semantic rule's pattern fallback decided this match: the rule's own regex,
217
+ * not its judge, so it counts as a deterministic decision.
218
+ */
219
+ function asSemanticFallback(match) {
220
+ return match === null ? null : { ...match, decidedBy: 'semantic-fallback' };
221
+ }
124
222
  export class ATREngine {
125
223
  config;
126
224
  rules = [];
225
+ /**
226
+ * id -> the FIRST loaded rule carrying that id, i.e. exactly what
227
+ * `this.rules.find(r => r.id === id)` returns.
228
+ *
229
+ * The per-condition paths below used to run that find() on every regex
230
+ * condition they evaluated: a linear walk over every loaded rule, repeated
231
+ * thousands of times per event. Measured 2026-10-15 on the benign-corpus FP
232
+ * scan (scripts/gate-promotion-fp.ts, 829 rules, 13,848 samples), that walk
233
+ * was ~11% of all CPU and replacing it was a 1.18x speed-up on its own, with
234
+ * the matched-rule set identical for every sample.
235
+ */
236
+ ruleById = new Map();
127
237
  compiledPatterns = new Map();
128
238
  semanticModuleInstance;
129
239
  /**
@@ -132,6 +242,7 @@ export class ATREngine {
132
242
  * lifetime.
133
243
  */
134
244
  lane;
245
+ modelDecisions;
135
246
  /**
136
247
  * Find bundled rules directory shipped with the npm package.
137
248
  * Checks: ../rules (from dist/), ./rules (repo root)
@@ -156,6 +267,7 @@ export class ATREngine {
156
267
  // Config only — never the environment. An embedder's detection breadth must
157
268
  // not depend on a variable someone exported in a shell profile.
158
269
  this.lane = laneFromConfig(config.lane);
270
+ this.modelDecisions = modelDecisionsFromConfig(config.modelDecisions);
159
271
  // Initialize Layer 3 semantic module if config provided
160
272
  if (config.semanticModule) {
161
273
  const moduleConfig = createSemanticModuleFromConfig(config.semanticModule);
@@ -170,6 +282,7 @@ export class ATREngine {
170
282
  */
171
283
  async loadRules() {
172
284
  this.rules = [];
285
+ this.ruleById.clear();
173
286
  this.compiledPatterns.clear();
174
287
  if (this.config.rules) {
175
288
  this.rules.push(...this.config.rules);
@@ -185,12 +298,19 @@ export class ATREngine {
185
298
  // Directory may not exist yet
186
299
  }
187
300
  }
301
+ for (const rule of this.rules)
302
+ this.indexRule(rule);
188
303
  // Pre-compile regex patterns for performance
189
304
  for (const rule of this.rules) {
190
305
  this.compilePatterns(rule);
191
306
  }
192
307
  return this.rules.length;
193
308
  }
309
+ /** First rule wins, so the index answers what Array.find() would. */
310
+ indexRule(rule) {
311
+ if (!this.ruleById.has(rule.id))
312
+ this.ruleById.set(rule.id, rule);
313
+ }
194
314
  /**
195
315
  * Lane gate: does this rule's maturity qualify for the configured detection
196
316
  * lane? 'enforce' = stable only; 'alert' = stable+test; 'hunt'/unset = all.
@@ -201,16 +321,65 @@ export class ATREngine {
201
321
  // missing/odd maturity) lives in the rule-quality contract.
202
322
  return laneAllows(rule.maturity, this.lane);
203
323
  }
324
+ /**
325
+ * Whether this engine evaluates `rule` at all. A draft or deprecated status, a
326
+ * maturity outside the lane, or (on an engine constructed with
327
+ * `toolDefinitions: true`) a rule written for a tool being called, stops a rule
328
+ * before any of its conditions run,
329
+ * so nothing about it — its false positives included — can be observed through
330
+ * this engine. evaluateRaw() and evaluateAsync() both ask this, and a
331
+ * measurement harness must ask it too before recording a number for a rule:
332
+ * a rule that is never evaluated scores 0 by construction, and that 0 is not
333
+ * a measurement.
334
+ */
335
+ evaluates(rule) {
336
+ if (rule.status === 'deprecated' || rule.status === 'draft')
337
+ return false;
338
+ // An engine scanning tool definitions does not run the rules written for a tool being called.
339
+ if (this.config.toolDefinitions === true && !appliesToToolDefinition(rule))
340
+ return false;
341
+ return this.passesLane(rule);
342
+ }
343
+ /**
344
+ * On an engine constructed with `toolDefinitions: true`: how many loaded rules it evaluates, and how
345
+ * many it would evaluate (status and lane allow them) but leaves out because they were written for
346
+ * a tool being called. Null on any other engine. Lets a report say "not run" instead of reading as
347
+ * "ran and found nothing".
348
+ */
349
+ getToolDefinitionScope() {
350
+ if (this.config.toolDefinitions !== true)
351
+ return null;
352
+ let rulesEvaluated = 0;
353
+ let rulesSkippedByScope = 0;
354
+ for (const rule of this.rules) {
355
+ if (rule.status === 'deprecated' || rule.status === 'draft' || !this.passesLane(rule))
356
+ continue;
357
+ if (appliesToToolDefinition(rule))
358
+ rulesEvaluated += 1;
359
+ else
360
+ rulesSkippedByScope += 1;
361
+ }
362
+ return { rulesEvaluated, rulesSkippedByScope };
363
+ }
204
364
  /** Active detection lane (config, else 'hunt'). Diagnostics/tests. */
205
365
  getLane() {
206
366
  return this.lane;
207
367
  }
368
+ /**
369
+ * What a model-decided match may do (config, else 'advisory'). A caller that
370
+ * runs evaluateAsync() and computes the verdict itself passes this on:
371
+ * computeVerdict(matches, { modelDecisions: engine.getModelDecisions() }).
372
+ */
373
+ getModelDecisions() {
374
+ return this.modelDecisions;
375
+ }
208
376
  /**
209
377
  * Load a single rule file and add it to the engine.
210
378
  */
211
379
  addRuleFile(filePath) {
212
380
  const rule = loadRuleFile(filePath);
213
381
  this.rules.push(rule);
382
+ this.indexRule(rule);
214
383
  this.compilePatterns(rule);
215
384
  }
216
385
  /**
@@ -218,6 +387,7 @@ export class ATREngine {
218
387
  */
219
388
  addRule(rule) {
220
389
  this.rules.push(rule);
390
+ this.indexRule(rule);
221
391
  this.compilePatterns(rule);
222
392
  }
223
393
  /**
@@ -274,36 +444,21 @@ export class ATREngine {
274
444
  // Tier 2: Pattern matching (existing regex rules)
275
445
  const isSkillContext = event.scanContext === 'skill';
276
446
  for (const rule of this.rules) {
277
- // Skip deprecated and draft rules
278
- if (rule.status === 'deprecated' || rule.status === 'draft')
279
- continue;
280
- // Lane gate: keep non-stable maturities out of enforce/alert lanes
281
- if (!this.passesLane(rule))
447
+ // Skip deprecated and draft rules, and maturities outside the lane
448
+ // (non-stable rules stay out of the enforce/alert lanes).
449
+ if (!this.evaluates(rule))
282
450
  continue;
283
451
  // Source type filtering: skip rules that don't apply to this event type
284
- // When scanContext is 'skill', skip source-type filtering — all rules fire
285
- if (!isSkillContext && eventSourceType && rule.agent_source.type !== eventSourceType) {
286
- // Allow mcp_exchange rules to also match tool_call events
287
- const mcpOverTool = rule.agent_source.type === 'mcp_exchange' && eventSourceType === 'tool_call';
288
- // Indirect prompt injection: llm_io (prompt-injection) rules must also
289
- // run on tool_response events. A poisoned tool / MCP / RAG output is the
290
- // primary indirect-injection channel — the payload never appears in a
291
- // direct llm_input, it rides in on tool output the model then reads.
292
- // tool_response maps to source type 'mcp_exchange', so without this the
293
- // entire llm_io family was silently skipped on every tool response.
294
- // (Field resolution routes event.content into user_input/agent_output
295
- // for tool_response events — see getFieldValue.)
296
- const llmIoOverToolResponse = rule.agent_source.type === 'llm_io' && eventSourceType === 'mcp_exchange';
297
- // Trace-method rules declare agent_source.type: agent_trace, which maps
298
- // to no event source type and would always be filtered out. Evaluate
299
- // them whenever the event actually carries a trace payload; evaluateRule
300
- // still returns null if the trace primitives don't fire, and ordinary
301
- // (non-trace) events are unaffected because event.trace is undefined.
302
- const traceWithPayload = rule.detection?.method === 'trace' && event.trace !== undefined;
303
- if (!mcpOverTool && !llmIoOverToolResponse && !traceWithPayload) {
304
- continue;
305
- }
306
- }
452
+ // (sourceAdmits). When scanContext is 'skill', skip it — all rules fire.
453
+ if (!isSkillContext && !sourceAdmits(rule, eventSourceType, event))
454
+ continue;
455
+ // Whether the skill compound gate below applies to this rule. Hoisted so
456
+ // the pre-check and the gate itself read one expression.
457
+ const skillGated = isSkillContext && rule.tags?.scan_target !== 'skill' && rule.tags?.scan_target !== 'both';
458
+ // The gate's verdict is already known for most rules — see
459
+ // skillGateAlwaysRejects(). Skip the evaluation, not the verdict.
460
+ if (skillGated && skillGateAlwaysRejects(rule))
461
+ continue;
307
462
  const matchResult = this.evaluateRule(rule, event);
308
463
  if (matchResult) {
309
464
  // Skill context compound gating: rules not designed for SKILL.md must
@@ -333,15 +488,17 @@ export class ATREngine {
333
488
  // shapes has NOT measured those 645 rules on it) is named by
334
489
  // skillPathCoverage() in scripts/lib/corpus-event.ts and pinned by
335
490
  // tests/skill-path-coverage.test.ts.
336
- if (isSkillContext && rule.tags.scan_target !== 'skill' && rule.tags.scan_target !== 'both') {
337
- const totalConds = Number(rule.detection?.conditions?.length ?? 1);
338
- const minRequired = Math.max(2, Math.ceil(totalConds * 0.3));
491
+ if (skillGated) {
492
+ const minRequired = skillGateMinRequired(rule);
339
493
  if ((matchResult.matchedConditions?.length ?? 0) < minRequired) {
340
494
  continue;
341
495
  }
342
496
  }
343
497
  matches.push(matchResult);
344
- allMatchedPatterns.push(...matchResult.matchedPatterns);
498
+ // A judge's category and evidence are not patterns a rule matched; they
499
+ // must not feed pattern_frequency and other cross-event conditions.
500
+ if (!isModelDecided(matchResult))
501
+ allMatchedPatterns.push(...matchResult.matchedPatterns);
345
502
  }
346
503
  }
347
504
  // Record event in session tracker (always, for cross-event sequence detection)
@@ -430,36 +587,31 @@ export class ATREngine {
430
587
  // Tier 2: Pattern matching + async semantic rules
431
588
  const isSkillContext = event.scanContext === 'skill';
432
589
  for (const rule of this.rules) {
433
- if (rule.status === 'deprecated' || rule.status === 'draft')
590
+ if (!this.evaluates(rule))
434
591
  continue;
435
- if (!this.passesLane(rule))
592
+ // Same source-type filter as evaluateRaw() (sourceAdmits).
593
+ if (!isSkillContext && !sourceAdmits(rule, eventSourceType, event))
594
+ continue;
595
+ // Same hoisted predicate and pre-check as evaluateRaw().
596
+ const skillGated = isSkillContext && rule.tags?.scan_target !== 'skill' && rule.tags?.scan_target !== 'both';
597
+ if (skillGated && skillGateAlwaysRejects(rule))
436
598
  continue;
437
- if (!isSkillContext && eventSourceType && rule.agent_source.type !== eventSourceType) {
438
- const mcpOverTool = rule.agent_source.type === 'mcp_exchange' && eventSourceType === 'tool_call';
439
- // Indirect prompt injection: llm_io rules also run on tool_response
440
- // (source type mcp_exchange) — poisoned tool/MCP/RAG output. See the
441
- // matching note in evaluateRaw().
442
- const llmIoOverToolResponse = rule.agent_source.type === 'llm_io' && eventSourceType === 'mcp_exchange';
443
- // Trace-method rules are source-agnostic — evaluate when a trace is present.
444
- const traceWithPayload = rule.detection?.method === 'trace' && event.trace !== undefined;
445
- if (!mcpOverTool && !llmIoOverToolResponse && !traceWithPayload) {
446
- continue;
447
- }
448
- }
449
599
  const matchResult = await this.evaluateRuleAsync(rule, event, this.config.semanticJudge);
450
600
  if (matchResult) {
451
601
  // Skill compound gate — see the long note in evaluateRaw(). For
452
602
  // `condition: any` rules this rejects unconditionally, which is why the
453
603
  // SKILL.md path effectively runs only scan_target: skill|both rules.
454
- if (isSkillContext && rule.tags.scan_target !== 'skill' && rule.tags.scan_target !== 'both') {
455
- const totalConds = Number(rule.detection?.conditions?.length ?? 1);
456
- const minRequired = Math.max(2, Math.ceil(totalConds * 0.3));
604
+ if (skillGated) {
605
+ const minRequired = skillGateMinRequired(rule);
457
606
  if ((matchResult.matchedConditions?.length ?? 0) < minRequired) {
458
607
  continue;
459
608
  }
460
609
  }
461
610
  matches.push(matchResult);
462
- allMatchedPatterns.push(...matchResult.matchedPatterns);
611
+ // A judge's category and evidence are not patterns a rule matched; they
612
+ // must not feed pattern_frequency and other cross-event conditions.
613
+ if (!isModelDecided(matchResult))
614
+ allMatchedPatterns.push(...matchResult.matchedPatterns);
463
615
  }
464
616
  }
465
617
  // Record event in session tracker (always, for cross-event sequence detection)
@@ -529,14 +681,14 @@ export class ATREngine {
529
681
  const result = evaluateTraceRule(rule, event.trace);
530
682
  if (!result.matched)
531
683
  return null;
532
- const baseConfidence = rule.tags.confidence === 'high' ? 0.9 : rule.tags.confidence === 'medium' ? 0.7 : 0.5;
533
684
  return {
534
685
  rule,
535
686
  matchedConditions: result.matchedPrimitives.map((p) => `trace.${p}`),
536
687
  matchedPatterns: result.violations,
537
- confidence: baseConfidence,
688
+ confidence: ruleConfidence(rule.tags.confidence, 0),
538
689
  timestamp: new Date().toISOString(),
539
690
  scan_context: 'native',
691
+ decidedBy: 'trace',
540
692
  };
541
693
  }
542
694
  // method=semantic — async path is not used in the sync evaluate() API.
@@ -546,7 +698,7 @@ export class ATREngine {
546
698
  // If the rule has fallback_method=pattern, evaluate pattern conditions
547
699
  // synchronously. Otherwise skip because the judge path is async-only.
548
700
  if (detection.semantic?.fallback_method === 'pattern') {
549
- return this.evaluatePatternRule(rule, event);
701
+ return asSemanticFallback(this.evaluatePatternRule(rule, event));
550
702
  }
551
703
  return null;
552
704
  }
@@ -587,10 +739,20 @@ export class ATREngine {
587
739
  const result = await evaluateSemanticRule(rule, event.content, { judge: effectiveJudge });
588
740
  if (!result.matched) {
589
741
  if (result.reason?.includes('fallback_pattern')) {
590
- return this.evaluatePatternRule(rule, event);
742
+ return asSemanticFallback(this.evaluatePatternRule(rule, event));
591
743
  }
592
744
  return null;
593
745
  }
746
+ // The judge matched (atr-method-v1.1 §6.4: a match iff its confidence clears
747
+ // the threshold). When the rule's own pattern fallback matches the same
748
+ // input, the measured regex stands behind the decision: report it as the
749
+ // fallback's match, which is enforced. Otherwise only the judge does, and
750
+ // under modelDecisions: 'advisory' its match is reported, not enforced.
751
+ if (this.modelDecisions === 'advisory' && rule.detection.semantic?.fallback_method === 'pattern') {
752
+ const corroborated = asSemanticFallback(this.evaluatePatternRule(rule, event));
753
+ if (corroborated)
754
+ return corroborated;
755
+ }
594
756
  const matchedPatterns = [
595
757
  ...(result.category ? [result.category] : []),
596
758
  ...(result.evidence ? [result.evidence] : []),
@@ -602,6 +764,7 @@ export class ATREngine {
602
764
  confidence: result.confidence ?? 0.5,
603
765
  timestamp: new Date().toISOString(),
604
766
  scan_context: 'native',
767
+ decidedBy: 'semantic-judge',
605
768
  };
606
769
  }
607
770
  // For non-semantic methods, fall back to sync path.
@@ -642,6 +805,7 @@ export class ATREngine {
642
805
  confidence: 1.0, // exact match
643
806
  timestamp: new Date().toISOString(),
644
807
  scan_context: 'native',
808
+ decidedBy: 'signature',
645
809
  };
646
810
  }
647
811
  /**
@@ -671,9 +835,8 @@ export class ATREngine {
671
835
  : matchedConditionIndices.length === conditions.length;
672
836
  if (!matched)
673
837
  return null;
674
- const baseConfidence = rule.tags.confidence === 'high' ? 0.9 : rule.tags.confidence === 'medium' ? 0.7 : 0.5;
675
838
  const matchRatio = matchedConditionIndices.length / Math.max(conditions.length, 1);
676
- const confidence = Math.min(baseConfidence + matchRatio * 0.1, 1.0);
839
+ const confidence = ruleConfidence(rule.tags.confidence, matchRatio);
677
840
  return {
678
841
  rule,
679
842
  matchedConditions: matchedConditionIndices.map(String),
@@ -681,6 +844,7 @@ export class ATREngine {
681
844
  confidence,
682
845
  timestamp: new Date().toISOString(),
683
846
  scan_context: 'native',
847
+ decidedBy: 'pattern',
684
848
  };
685
849
  }
686
850
  /**
@@ -726,7 +890,7 @@ export class ATREngine {
726
890
  // NL-style rules set tags.suppress_in_code_blocks: true so that matches
727
891
  // landing inside ```...``` fenced blocks (e.g. pentest example payloads)
728
892
  // do not fire. Built lazily so non-opt-in rules pay no cost.
729
- const ruleForSuppress = this.rules.find(r => r.id === ruleId);
893
+ const ruleForSuppress = this.ruleById.get(ruleId);
730
894
  const suppressInCodeBlocks = ruleForSuppress?.tags?.suppress_in_code_blocks === true;
731
895
  const codeRanges = suppressInCodeBlocks ? buildCodeBlockRanges(fieldValue) : [];
732
896
  // Try pre-compiled pattern first
@@ -746,9 +910,7 @@ export class ATREngine {
746
910
  return false;
747
911
  }
748
912
  // Fallback: compile on the fly (ReDoS-gated — see safeCompile)
749
- const normalized = normalizeRegex(value);
750
- const rFlags = needsUnicodeFlag(normalized) ? 'iu' : 'i';
751
- const regex = safeCompile(normalized, rFlags);
913
+ const regex = compileRegexCondition(value);
752
914
  if (regex && testNormalisedThenRaw(regex, fieldValue, rawFieldValue)) {
753
915
  if (suppressInCodeBlocks && codeRanges.length > 0 && isInsideCodeBlock(fieldValue, regex, codeRanges)) {
754
916
  return false;
@@ -801,9 +963,8 @@ export class ATREngine {
801
963
  const finalResult = this.evaluateExpression(conditionExpr, conditionResults);
802
964
  if (!finalResult)
803
965
  return null;
804
- const baseConfidence = rule.tags.confidence === 'high' ? 0.9 : rule.tags.confidence === 'medium' ? 0.7 : 0.5;
805
966
  const matchRatio = matchedConditionNames.length / Math.max(Object.keys(conditions).length, 1);
806
- const confidence = Math.min(baseConfidence + matchRatio * 0.1, 1.0);
967
+ const confidence = ruleConfidence(rule.tags.confidence, matchRatio);
807
968
  return {
808
969
  rule,
809
970
  matchedConditions: matchedConditionNames,
@@ -811,6 +972,7 @@ export class ATREngine {
811
972
  confidence,
812
973
  timestamp: new Date().toISOString(),
813
974
  scan_context: 'native',
975
+ decidedBy: 'pattern',
814
976
  };
815
977
  }
816
978
  /**
@@ -851,7 +1013,7 @@ export class ATREngine {
851
1013
  // skills)
852
1014
  // - Other rules → suppressed (their patterns FP on code examples)
853
1015
  const isSkillCtx = event.scanContext === 'skill';
854
- const matchedRule = this.rules.find(r => r.id === ruleId);
1016
+ const matchedRule = this.ruleById.get(ruleId);
855
1017
  const isSkillRule = matchedRule?.tags?.scan_target === 'skill';
856
1018
  const explicitSuppress = matchedRule?.tags?.suppress_in_code_blocks === true;
857
1019
  const suppressInCodeBlocks = explicitSuppress
@@ -919,7 +1081,7 @@ export class ATREngine {
919
1081
  * because attackers deliberately hide payloads in code blocks.
920
1082
  */
921
1083
  shouldSuppressInCodeBlocks(ruleId) {
922
- const rule = this.rules.find(r => r.id === ruleId);
1084
+ const rule = this.ruleById.get(ruleId);
923
1085
  if (!rule)
924
1086
  return false;
925
1087
  const category = rule.tags?.category ?? '';
@@ -1250,9 +1412,7 @@ export class ATREngine {
1250
1412
  for (let i = 0; i < conditions.length; i++) {
1251
1413
  const cond = conditions[i];
1252
1414
  if (cond['operator'] === 'regex' && typeof cond['value'] === 'string') {
1253
- const pattern = normalizeRegex(cond['value']);
1254
- const flags = needsUnicodeFlag(pattern) ? 'iu' : 'i';
1255
- const compiledRe = safeCompile(pattern, flags, rule.id);
1415
+ const compiledRe = compileRegexCondition(cond['value'], rule.id);
1256
1416
  if (compiledRe)
1257
1417
  ruleMap.set(String(i), [compiledRe]);
1258
1418
  }
@@ -1339,7 +1499,8 @@ export class ATREngine {
1339
1499
  if (hasConfirmMatch) {
1340
1500
  const sim = embResult?.value ?? 0;
1341
1501
  // Note: confirmThreshold 0 disables the gate (every similarity is >= 0).
1342
- if (sim < (this.config.confirmThreshold ?? 0.6)) {
1502
+ // Written so that a similarity that is not a number drops the rule too.
1503
+ if (!(sim >= (this.config.confirmThreshold ?? 0.6))) {
1343
1504
  matches = matches.filter((m) => !requiresConfirm(m.rule));
1344
1505
  layersUsed.push('embedding-confirm');
1345
1506
  }
@@ -1347,7 +1508,8 @@ export class ATREngine {
1347
1508
  // Additive Tier 2.5 signal: a high-similarity input surfaced as an embedding-only
1348
1509
  // match. EXCLUDED from the enforce lane — an embedding-only signal must never
1349
1510
  // produce a block there (only deterministic stable rules can block in enforce).
1350
- // Available in alert/hunt as an advisory signal.
1511
+ // In alert/hunt it is a model's decision (decidedBy: 'embedding'), which
1512
+ // computeVerdict reports without enforcing unless modelDecisions is 'enforce'.
1351
1513
  if (lane !== 'enforce' && embResult && embResult.value >= 0.65) {
1352
1514
  const severity = embResult.value >= 0.95 ? 'critical'
1353
1515
  : embResult.value >= 0.88 ? 'high'
@@ -1375,6 +1537,7 @@ export class ATREngine {
1375
1537
  confidence: embResult.value,
1376
1538
  timestamp: new Date().toISOString(),
1377
1539
  scan_context: 'native',
1540
+ decidedBy: 'embedding',
1378
1541
  };
1379
1542
  matches = [...matches, syntheticMatch];
1380
1543
  }
@@ -1398,7 +1561,7 @@ export class ATREngine {
1398
1561
  matches = merged;
1399
1562
  }
1400
1563
  }
1401
- const verdict = computeVerdict(matches);
1564
+ const verdict = computeVerdict(matches, { modelDecisions: this.modelDecisions });
1402
1565
  let actionResults = Object.freeze([]);
1403
1566
  if (executor && verdict.actions.length > 0) {
1404
1567
  const context = Object.freeze({
@@ -1546,15 +1709,97 @@ export class ATREngine {
1546
1709
  };
1547
1710
  }
1548
1711
  }
1712
+ /**
1713
+ * The skill compound gate's threshold: how many conditions a rule that is not
1714
+ * `scan_target: skill|both` must report as matched on the SKILL.md path.
1715
+ * evaluateRaw(), evaluateAsync() and skillGateAlwaysRejects() all read it from
1716
+ * here, so the gate and the pre-check in front of it cannot drift apart.
1717
+ */
1718
+ function skillGateMinRequired(rule) {
1719
+ const totalConds = Number(rule.detection?.conditions?.length ?? 1);
1720
+ return Math.max(2, Math.ceil(totalConds * 0.3));
1721
+ }
1722
+ /**
1723
+ * The most conditions a match of this rule can report. An array-format pattern
1724
+ * rule with `condition: any|or` reports at most one, because
1725
+ * evaluateArrayConditions() breaks on the first matching condition. Every other
1726
+ * shape is treated as unbounded, so it is never skipped.
1727
+ */
1728
+ function maxReportedConditions(rule) {
1729
+ const { detection } = rule;
1730
+ if ((detection.method ?? 'pattern') !== 'pattern')
1731
+ return Number.POSITIVE_INFINITY;
1732
+ if (!Array.isArray(detection.conditions))
1733
+ return Number.POSITIVE_INFINITY;
1734
+ return detection.condition === 'any' || detection.condition === 'or'
1735
+ ? 1
1736
+ : Number.POSITIVE_INFINITY;
1737
+ }
1738
+ /**
1739
+ * True when the skill compound gate is certain to reject this rule's match
1740
+ * whatever the input, so evaluating the rule on the SKILL.md path is wasted work:
1741
+ * the most conditions it can report is below the gate's threshold.
1742
+ *
1743
+ * Deliberately the narrowest provable case, and deliberately NOT
1744
+ * skillPathBlocker() from src/corpus-event.ts: that predicate exists to be
1745
+ * CHECKED against this engine (tests/skill-path-coverage.test.ts), and an engine
1746
+ * that consulted it would turn those tests into the predicate checking itself.
1747
+ * If the any-mode short-circuit in evaluateArrayConditions() is ever removed,
1748
+ * maxReportedConditions() has to change with it — the test "evaluateArrayConditions
1749
+ * still short-circuits" in tests/skill-path-coverage.test.ts fails first.
1750
+ *
1751
+ * Measured 2026-10-15 on the benign-corpus FP scan: 645 of 829 rules qualify,
1752
+ * and skipping them took the skill pass from 16.8% to ~3% of all CPU with the
1753
+ * matched-rule set identical for every one of 13,848 samples.
1754
+ */
1755
+ function skillGateAlwaysRejects(rule) {
1756
+ return maxReportedConditions(rule) < skillGateMinRequired(rule);
1757
+ }
1549
1758
  function escapeRegex(str) {
1550
1759
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1551
1760
  }
1761
+ /** A leading inline flag group as rule authors write it: (?i), (?is), (?im), ... */
1762
+ const LEADING_INLINE_FLAGS = /^\(\?([imsx]+)\)/;
1552
1763
  /**
1553
1764
  * Strip inline flags like (?i) from regex patterns.
1554
1765
  * JavaScript RegExp uses flags as a constructor parameter, not inline.
1555
1766
  */
1556
1767
  function normalizeRegex(pattern) {
1557
- return pattern.replace(/^\(\?[imsx]+\)/, '');
1768
+ return pattern.replace(LEADING_INLINE_FLAGS, '');
1769
+ }
1770
+ /**
1771
+ * The engine's single definition of how an array-format `operator: regex`
1772
+ * condition is compiled (SPEC §6.4). Both compile sites in ATREngine reach it
1773
+ * through compileRegexCondition() (compilePatterns() and the on-the-fly fallback
1774
+ * in evaluateArrayCondition()), so does ShadowEvaluator, and
1775
+ * scripts/gate-case-sensitivity.ts imports it, because it has to see each pattern
1776
+ * exactly as the engine does. That gate used to keep its own copy of this logic,
1777
+ * and the copy went stale when needsUnicodeFlag() started setting `u` for astral
1778
+ * literals. Other scripts still re-derive it by hand and could import it too.
1779
+ *
1780
+ * Two consequences rule authors cannot see from the pattern:
1781
+ * - `i` is applied whether or not the author wrote (?i), so letter case never
1782
+ * distinguishes anything: [A-Z] also matches a-z.
1783
+ * - A stripped (?s), (?m) or (?x) is NOT applied.
1784
+ */
1785
+ export function regexConditionSpec(value) {
1786
+ const inline = LEADING_INLINE_FLAGS.exec(value);
1787
+ const source = normalizeRegex(value);
1788
+ return { source, inlineFlags: inline?.[1] ?? '', flags: needsUnicodeFlag(source) ? 'iu' : 'i' };
1789
+ }
1790
+ /**
1791
+ * Compile an array-format `operator: regex` value the way the engine does: the
1792
+ * leading flag group handled by regexConditionSpec(), then the ReDoS gate in
1793
+ * safeCompile(). Returns null for a pattern the gate rejects or that does not
1794
+ * compile; such a condition never matches. Exported for code outside
1795
+ * ATREngine, such as ShadowEvaluator and RuleScaffolder, that must compile a
1796
+ * condition exactly as the engine does.
1797
+ *
1798
+ * @internal Not re-exported from the package entry point.
1799
+ */
1800
+ export function compileRegexCondition(value, ruleId) {
1801
+ const spec = regexConditionSpec(value);
1802
+ return safeCompile(spec.source, spec.flags, ruleId);
1558
1803
  }
1559
1804
  /**
1560
1805
  * Normalize Unicode text to NFC form and strip zero-width characters.
@@ -1564,11 +1809,24 @@ function normalizeRegex(pattern) {
1564
1809
  * (\uFF21\uFF22\uFF23 vs ABC) remain distinct so rules that explicitly target full-width
1565
1810
  * evasion can still match. For aggressive normalization use
1566
1811
  * `normalizeUnicodeAggressive()`.
1812
+ *
1813
+ * Hot path, memoised the same way as foldConfusables() below: every condition
1814
+ * normalises the field it reads, and consecutive conditions almost always read
1815
+ * the same string, so a single-entry memo turns per-condition work into
1816
+ * per-distinct-input work. Measured 2026-10-15 on the benign-corpus FP scan,
1817
+ * the repeated NFKC pass plus its zero-width strip were ~9% of all CPU.
1567
1818
  */
1819
+ let _normKey = '';
1820
+ let _normVal = '';
1568
1821
  export function normalizeUnicode(text) {
1569
- return text
1822
+ if (text === _normKey)
1823
+ return _normVal;
1824
+ const out = text
1570
1825
  .normalize('NFKC')
1571
1826
  .replace(/[\u200B\u200C\u200D\uFEFF\u2060\u180E\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '');
1827
+ _normKey = text;
1828
+ _normVal = out;
1829
+ return out;
1572
1830
  }
1573
1831
  /**
1574
1832
  * Map of high-confidence homoglyphs (Cyrillic / Greek / other scripts whose
@@ -1758,8 +2016,11 @@ const MAX_EVAL_LENGTH = 100_000;
1758
2016
  /**
1759
2017
  * Safely test a regex pattern against input with length limits.
1760
2018
  * Returns false if input exceeds MAX_EVAL_LENGTH to prevent ReDoS.
2019
+ * Exported so ShadowEvaluator and RuleScaffolder apply the same limit as the engine.
2020
+ *
2021
+ * @internal Not re-exported from the package entry point.
1761
2022
  */
1762
- function safeRegexTest(regex, input) {
2023
+ export function safeRegexTest(regex, input) {
1763
2024
  if (input.length > MAX_EVAL_LENGTH)
1764
2025
  return false;
1765
2026
  return regex.test(input);