@meetless/mla 0.1.4

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 (202) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +81 -0
  3. package/dist/build-info.json +9 -0
  4. package/dist/bundles/ask-core.js +396 -0
  5. package/dist/bundles/mcp.js +16592 -0
  6. package/dist/bundles/trace-core.js +263 -0
  7. package/dist/cli.js +828 -0
  8. package/dist/commands/activate.js +781 -0
  9. package/dist/commands/adoption.js +130 -0
  10. package/dist/commands/ask.js +290 -0
  11. package/dist/commands/context.js +114 -0
  12. package/dist/commands/debug.js +313 -0
  13. package/dist/commands/doctor.js +1021 -0
  14. package/dist/commands/enrich.js +427 -0
  15. package/dist/commands/evidence.js +229 -0
  16. package/dist/commands/flush.js +184 -0
  17. package/dist/commands/graph.js +104 -0
  18. package/dist/commands/init.js +272 -0
  19. package/dist/commands/internal-active-review.js +322 -0
  20. package/dist/commands/internal-auto-index.js +188 -0
  21. package/dist/commands/internal-capture-decisions.js +320 -0
  22. package/dist/commands/internal-evidence-correlate.js +239 -0
  23. package/dist/commands/internal-evidence-hooks.js +240 -0
  24. package/dist/commands/internal-evidence-inject.js +231 -0
  25. package/dist/commands/internal-finalize.js +221 -0
  26. package/dist/commands/internal-pretool-observe.js +225 -0
  27. package/dist/commands/internal-refresh.js +136 -0
  28. package/dist/commands/internal-session-nudge.js +120 -0
  29. package/dist/commands/internal-steer-sync.js +117 -0
  30. package/dist/commands/internal-turn-recap.js +140 -0
  31. package/dist/commands/kb.js +375 -0
  32. package/dist/commands/kb_add.js +681 -0
  33. package/dist/commands/kb_forget.js +283 -0
  34. package/dist/commands/kb_move.js +45 -0
  35. package/dist/commands/kb_pending.js +410 -0
  36. package/dist/commands/kb_personal.js +149 -0
  37. package/dist/commands/kb_promote.js +188 -0
  38. package/dist/commands/kb_purge.js +168 -0
  39. package/dist/commands/kb_reingest.js +335 -0
  40. package/dist/commands/kb_retime.js +170 -0
  41. package/dist/commands/kb_review.js +391 -0
  42. package/dist/commands/kb_revision.js +179 -0
  43. package/dist/commands/kb_show.js +385 -0
  44. package/dist/commands/label.js +226 -0
  45. package/dist/commands/login.js +295 -0
  46. package/dist/commands/logout.js +108 -0
  47. package/dist/commands/mcp-supervisor.js +93 -0
  48. package/dist/commands/mcp.js +227 -0
  49. package/dist/commands/queue-prune.js +98 -0
  50. package/dist/commands/review.js +358 -0
  51. package/dist/commands/rewire.js +124 -0
  52. package/dist/commands/rules.js +728 -0
  53. package/dist/commands/scan-context.js +67 -0
  54. package/dist/commands/session.js +347 -0
  55. package/dist/commands/stats.js +479 -0
  56. package/dist/commands/status.js +61 -0
  57. package/dist/commands/summary.js +250 -0
  58. package/dist/commands/turn.js +114 -0
  59. package/dist/commands/uninstall.js +222 -0
  60. package/dist/commands/whoami.js +102 -0
  61. package/dist/commands/workspace.js +130 -0
  62. package/dist/hooks-template/ce0-post-tool-use.sh +34 -0
  63. package/dist/hooks-template/ce0-session-start.sh +49 -0
  64. package/dist/hooks-template/ce0-stop.sh +29 -0
  65. package/dist/hooks-template/ce0-user-prompt-submit.sh +38 -0
  66. package/dist/hooks-template/common.sh +934 -0
  67. package/dist/hooks-template/event-batch-filter.jq +67 -0
  68. package/dist/hooks-template/flush.sh +503 -0
  69. package/dist/hooks-template/post-tool-use.sh +423 -0
  70. package/dist/hooks-template/pre-tool-use.sh +69 -0
  71. package/dist/hooks-template/session-start.sh +140 -0
  72. package/dist/hooks-template/stop.sh +308 -0
  73. package/dist/hooks-template/user-prompt-submit.sh +1162 -0
  74. package/dist/lib/activation.js +79 -0
  75. package/dist/lib/active-conflict-cache.js +141 -0
  76. package/dist/lib/active-memory.js +59 -0
  77. package/dist/lib/active-review-runner.js +26 -0
  78. package/dist/lib/agent-decision/index.js +25 -0
  79. package/dist/lib/agent-decision/keys.js +49 -0
  80. package/dist/lib/agent-decision/normalize-claude.js +183 -0
  81. package/dist/lib/agent-decision/types.js +21 -0
  82. package/dist/lib/agent-decision/validate.js +216 -0
  83. package/dist/lib/analytics/capture.js +96 -0
  84. package/dist/lib/analytics/command-event.js +267 -0
  85. package/dist/lib/analytics/consent.js +58 -0
  86. package/dist/lib/analytics/coverage-gap.js +96 -0
  87. package/dist/lib/analytics/envelope.js +236 -0
  88. package/dist/lib/analytics/event-id.js +86 -0
  89. package/dist/lib/analytics/evidence.js +150 -0
  90. package/dist/lib/analytics/followthrough.js +194 -0
  91. package/dist/lib/analytics/forwarder.js +109 -0
  92. package/dist/lib/analytics/logs.js +78 -0
  93. package/dist/lib/analytics/metrics.js +78 -0
  94. package/dist/lib/analytics/recorder.js +92 -0
  95. package/dist/lib/analytics/review-analytics.js +75 -0
  96. package/dist/lib/analytics/sequence.js +77 -0
  97. package/dist/lib/analytics/store.js +131 -0
  98. package/dist/lib/analytics/turn-recap.js +279 -0
  99. package/dist/lib/artifact_id.js +108 -0
  100. package/dist/lib/auth-breaker.js +161 -0
  101. package/dist/lib/auto-index.js +112 -0
  102. package/dist/lib/classifier.js +88 -0
  103. package/dist/lib/config.js +298 -0
  104. package/dist/lib/conflict-advisory.js +64 -0
  105. package/dist/lib/debug-bundle.js +520 -0
  106. package/dist/lib/enrichment/ingest.js +301 -0
  107. package/dist/lib/enrichment/plan.js +253 -0
  108. package/dist/lib/enrichment/protocol.js +359 -0
  109. package/dist/lib/enrichment/scout-brief.js +176 -0
  110. package/dist/lib/failure-telemetry.js +444 -0
  111. package/dist/lib/git.js +200 -0
  112. package/dist/lib/governance-cache.js +77 -0
  113. package/dist/lib/governed-path-cache.js +76 -0
  114. package/dist/lib/http.js +677 -0
  115. package/dist/lib/identity-envelope.js +23 -0
  116. package/dist/lib/kb-candidate.js +65 -0
  117. package/dist/lib/kb_acl.js +98 -0
  118. package/dist/lib/login.js +353 -0
  119. package/dist/lib/mcp-fetchers.js +130 -0
  120. package/dist/lib/mcp-restart.js +47 -0
  121. package/dist/lib/observability.js +805 -0
  122. package/dist/lib/open-url.js +33 -0
  123. package/dist/lib/orphan-guard.js +70 -0
  124. package/dist/lib/packaged.js +21 -0
  125. package/dist/lib/reconcile-sessions.js +171 -0
  126. package/dist/lib/redactor.js +89 -0
  127. package/dist/lib/relationship-candidate-query.js +27 -0
  128. package/dist/lib/render.js +611 -0
  129. package/dist/lib/rules/applicability.js +64 -0
  130. package/dist/lib/rules/attest-code-rule-version.js +47 -0
  131. package/dist/lib/rules/attest-notes-location.js +217 -0
  132. package/dist/lib/rules/attest-rule-version.js +69 -0
  133. package/dist/lib/rules/canonical-json.js +97 -0
  134. package/dist/lib/rules/ce0-emit.js +64 -0
  135. package/dist/lib/rules/ce0-evidence.js +281 -0
  136. package/dist/lib/rules/ce0-recall-sample.js +82 -0
  137. package/dist/lib/rules/ce0-rule.js +55 -0
  138. package/dist/lib/rules/ce0-sampling-bucket.js +15 -0
  139. package/dist/lib/rules/ce0-store.js +683 -0
  140. package/dist/lib/rules/ce0-telemetry-project.js +93 -0
  141. package/dist/lib/rules/ce0-telemetry.js +158 -0
  142. package/dist/lib/rules/code-rule-registry.js +17 -0
  143. package/dist/lib/rules/command-match.js +185 -0
  144. package/dist/lib/rules/consult-evidence-binding.js +27 -0
  145. package/dist/lib/rules/consultation-capture-adapter.js +193 -0
  146. package/dist/lib/rules/content-match.js +56 -0
  147. package/dist/lib/rules/deny-admission.js +99 -0
  148. package/dist/lib/rules/durable-observation.js +190 -0
  149. package/dist/lib/rules/enforce-notes-version.js +421 -0
  150. package/dist/lib/rules/evaluation-input-hash.js +126 -0
  151. package/dist/lib/rules/evaluator.js +108 -0
  152. package/dist/lib/rules/inert-rule-families.js +51 -0
  153. package/dist/lib/rules/input-authority-resolver.js +241 -0
  154. package/dist/lib/rules/interception-schema.js +170 -0
  155. package/dist/lib/rules/interception-store.js +267 -0
  156. package/dist/lib/rules/live-input-authority.js +66 -0
  157. package/dist/lib/rules/local-matcher.js +108 -0
  158. package/dist/lib/rules/local-observe.js +79 -0
  159. package/dist/lib/rules/local-rule-version-repo.js +214 -0
  160. package/dist/lib/rules/memory-requirement.js +109 -0
  161. package/dist/lib/rules/notes-observe.js +39 -0
  162. package/dist/lib/rules/notes-path.js +261 -0
  163. package/dist/lib/rules/notes-rule.js +75 -0
  164. package/dist/lib/rules/observe-adapter.js +114 -0
  165. package/dist/lib/rules/observed-rule-hash.js +119 -0
  166. package/dist/lib/rules/prompt-submit-adapter.js +132 -0
  167. package/dist/lib/rules/requirement-subject.js +240 -0
  168. package/dist/lib/rules/rule-activity.js +67 -0
  169. package/dist/lib/rules/rule-version-hash.js +151 -0
  170. package/dist/lib/rules/runtime-scope.js +55 -0
  171. package/dist/lib/rules/stop-adapter.js +116 -0
  172. package/dist/lib/rules/stop-response-snapshot.js +174 -0
  173. package/dist/lib/rules/types.js +10 -0
  174. package/dist/lib/rules/ulid.js +46 -0
  175. package/dist/lib/rules/version-evaluation.js +156 -0
  176. package/dist/lib/scanner/agent-memory.js +99 -0
  177. package/dist/lib/scanner/bootstrap-summary.js +87 -0
  178. package/dist/lib/scanner/cache.js +59 -0
  179. package/dist/lib/scanner/frontmatter.js +42 -0
  180. package/dist/lib/scanner/parse-directives.js +69 -0
  181. package/dist/lib/scanner/parse-structured.js +72 -0
  182. package/dist/lib/scanner/render.js +73 -0
  183. package/dist/lib/scanner/scan.js +132 -0
  184. package/dist/lib/scanner/score.js +38 -0
  185. package/dist/lib/scanner/scout-mission.js +126 -0
  186. package/dist/lib/scanner/types.js +7 -0
  187. package/dist/lib/session-scope.js +195 -0
  188. package/dist/lib/spool.js +355 -0
  189. package/dist/lib/staleness.js +100 -0
  190. package/dist/lib/steer-cache.js +87 -0
  191. package/dist/lib/tagged-reference.js +20 -0
  192. package/dist/lib/temporal.js +109 -0
  193. package/dist/lib/turn-recap-emit.js +67 -0
  194. package/dist/lib/unwire.js +253 -0
  195. package/dist/lib/update-check.js +469 -0
  196. package/dist/lib/update-notifier.js +217 -0
  197. package/dist/lib/upgrade-apply.js +643 -0
  198. package/dist/lib/wire.js +1087 -0
  199. package/dist/lib/workspace.js +96 -0
  200. package/dist/lib/zip.js +154 -0
  201. package/dist/pretool-entry.js +37 -0
  202. package/package.json +75 -0
@@ -0,0 +1,421 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.recordDenyDecision = recordDenyDecision;
4
+ exports.evaluateAndEnforceNotesVersion = evaluateAndEnforceNotesVersion;
5
+ exports.evaluateAndEnforceLiveRules = evaluateAndEnforceLiveRules;
6
+ exports.evaluateEnforceOrObserveNotesRule = evaluateEnforceOrObserveNotesRule;
7
+ const evaluation_input_hash_1 = require("./evaluation-input-hash");
8
+ const interception_store_1 = require("./interception-store");
9
+ const local_rule_version_repo_1 = require("./local-rule-version-repo");
10
+ const durable_observation_1 = require("./durable-observation");
11
+ const notes_path_1 = require("./notes-path");
12
+ const inert_rule_families_1 = require("./inert-rule-families");
13
+ const observe_adapter_1 = require("./observe-adapter");
14
+ const evaluator_1 = require("./evaluator");
15
+ const attest_notes_location_1 = require("./attest-notes-location");
16
+ const ulid_1 = require("./ulid");
17
+ const deny_admission_1 = require("./deny-admission");
18
+ const version_evaluation_1 = require("./version-evaluation");
19
+ const PASS_THROUGH = {};
20
+ /**
21
+ * Build the tool_attempt + version-arm rule_evaluation_record pair for one interception. The
22
+ * evaluation-input-v1 snapshot carries the RUNNING evaluator's supported triple, byte-identical to
23
+ * slice 8's observe writer, so a snapshot-only replay reproduces the verdict. The deny accounting
24
+ * (aggregate decision + emission status) is derived from the effective enforcement through the slice-9
25
+ * kernel so this seam never re-decides what the kernel already owns.
26
+ */
27
+ function buildArmRows(ids, subject, enforcement, ctx) {
28
+ const evaluationInput = {
29
+ toolName: subject.toolName,
30
+ target: subject.target,
31
+ forbiddenRootRelativePath: subject.payload.compliance.config.forbiddenRootRelativePath,
32
+ evaluatorContractVersion: durable_observation_1.EVALUATOR_CONTRACT_VERSION,
33
+ matcherSchemaVersion: durable_observation_1.MATCHER_SCHEMA_VERSION,
34
+ pathCanonicalizerVersion: durable_observation_1.PATH_CANONICALIZER_VERSION,
35
+ };
36
+ const accounting = (0, deny_admission_1.planDenyAccounting)(enforcement.effectiveEnforcement);
37
+ const attempt = {
38
+ attemptId: ids.attemptId,
39
+ runtimeScopeId: ctx.runtimeScopeId,
40
+ sessionId: ctx.sessionId,
41
+ toolName: subject.toolName,
42
+ evaluationInputSnapshot: (0, evaluation_input_hash_1.serializeEvaluationInput)(evaluationInput),
43
+ evaluationInputHash: (0, evaluation_input_hash_1.evaluationInputHash)(evaluationInput),
44
+ aggregateDecision: accounting.aggregateDecision,
45
+ denyEmissionStatus: accounting.denyEmissionStatus,
46
+ inputAuthorityConfigHash: enforcement.inputAuthorityConfigHash,
47
+ createdAt: ctx.createdAt,
48
+ };
49
+ const evaluation = {
50
+ evaluationId: ids.evaluationId,
51
+ attemptId: ids.attemptId,
52
+ runtimeScopeId: ctx.runtimeScopeId,
53
+ result: enforcement.result,
54
+ eligibleEnforcement: enforcement.eligibleEnforcement,
55
+ effectiveEnforcement: enforcement.effectiveEnforcement,
56
+ verdictReasonCode: enforcement.verdictReasonCode,
57
+ gateReasonCode: enforcement.gateReasonCode,
58
+ evaluatorContractVersion: durable_observation_1.EVALUATOR_CONTRACT_VERSION,
59
+ ruleVersionId: subject.version.versionId,
60
+ canonicalPayloadHash: subject.version.canonicalPayloadHash,
61
+ createdAt: ctx.createdAt,
62
+ };
63
+ return { attempt, evaluation };
64
+ }
65
+ /** Write a version-arm row pair atomically (a single BEGIN IMMEDIATE), so an interception is never
66
+ * half-recorded. */
67
+ function writeArm(store, attempt, evaluation) {
68
+ store.db
69
+ .transaction(() => {
70
+ (0, interception_store_1.insertToolAttempt)(store, attempt);
71
+ (0, local_rule_version_repo_1.insertVersionEvaluationRecord)(store, evaluation);
72
+ })
73
+ .immediate();
74
+ }
75
+ /**
76
+ * Persist a would-be deny that a gate (or a generation churn) lowered to effective NONE, and report
77
+ * the row ids. The eligible enforcement stays DENY (the rule WOULD have denied) but the effective
78
+ * enforcement is NONE with the single primary gate reason RULE_ENFORCEMENT_UNAVAILABLE, the deny
79
+ * status is NOT_APPLICABLE, and the input-authority config hash resolved at this deny is recorded for
80
+ * the audit and for `mla doctor`. This is the honest record behind a fail-open.
81
+ */
82
+ function recordEnforcementUnavailable(store, subject, verdict, inputAuthorityConfigHash, ctx) {
83
+ const ids = { attemptId: (0, ulid_1.ulid)(ctx.now, ctx.rand), evaluationId: (0, ulid_1.ulid)(ctx.now, ctx.rand) };
84
+ const { attempt, evaluation } = buildArmRows(ids, subject, {
85
+ result: verdict.result,
86
+ verdictReasonCode: verdict.verdictReasonCode,
87
+ eligibleEnforcement: "DENY",
88
+ effectiveEnforcement: "NONE",
89
+ gateReasonCode: "RULE_ENFORCEMENT_UNAVAILABLE",
90
+ inputAuthorityConfigHash,
91
+ }, ctx);
92
+ writeArm(store, attempt, evaluation);
93
+ return ids;
94
+ }
95
+ /**
96
+ * The linearized deny commit (P0.52). Mints the two ULIDs and builds the DENY / DECISION_RECORDED row
97
+ * pair OUTSIDE any write transaction, then opens a single BEGIN IMMEDIATE and RE-READS the LIVE version
98
+ * for the deny's (scope, rule). It persists ONLY when that is still the exact generation the deny was
99
+ * evaluated against; if a concurrent attest superseded it in the window, the deny is inadmissible, the
100
+ * transaction writes nothing, and it fails open with GENERATION_CHURN.
101
+ *
102
+ * This is the minimal safe floor of the contract: the spec permits one retry, but we treat the first
103
+ * observed churn as inadmissible (no retry loop), which is strictly more conservative. `beforeCommit`
104
+ * is the test seam that lands a supersede in the window; it runs BEFORE the IMMEDIATE acquires the
105
+ * write lock, exactly as a real concurrent attest that commits ahead of our deny-commit would.
106
+ */
107
+ function recordDenyDecision(store, subject, ctx, opts = {}) {
108
+ const attemptId = (0, ulid_1.ulid)(ctx.now, ctx.rand);
109
+ const evaluationId = (0, ulid_1.ulid)(ctx.now, ctx.rand);
110
+ const { attempt, evaluation } = buildArmRows({ attemptId, evaluationId }, subject, {
111
+ result: subject.result,
112
+ verdictReasonCode: subject.verdictReasonCode,
113
+ eligibleEnforcement: "DENY",
114
+ effectiveEnforcement: "DENY",
115
+ gateReasonCode: null,
116
+ inputAuthorityConfigHash: subject.inputAuthorityConfigHash,
117
+ }, ctx);
118
+ // The concurrent attest, if any, commits ahead of our write lock (it cannot land once BEGIN IMMEDIATE
119
+ // holds the lock, so the realistic race is "it committed in the window before us").
120
+ opts.beforeCommit?.();
121
+ let churned = false;
122
+ store.db
123
+ .transaction(() => {
124
+ const live = (0, local_rule_version_repo_1.getLiveLocalRuleVersion)(store, subject.version.runtimeScopeId, subject.version.ruleId);
125
+ if (!live || live.versionId !== subject.version.versionId) {
126
+ churned = true;
127
+ return;
128
+ }
129
+ (0, interception_store_1.insertToolAttempt)(store, attempt);
130
+ (0, local_rule_version_repo_1.insertVersionEvaluationRecord)(store, evaluation);
131
+ })
132
+ .immediate();
133
+ if (churned) {
134
+ return { committed: false, cause: "GENERATION_CHURN" };
135
+ }
136
+ return { committed: true, attemptId, evaluationId };
137
+ }
138
+ /** Describe the blocked target for the deny reason; a non-relative target degrades to a generic phrase
139
+ * rather than leaking an unclassified path shape. */
140
+ function describeTarget(target) {
141
+ return target.kind === "RUNTIME_RELATIVE" ? target.path : "the requested file";
142
+ }
143
+ /**
144
+ * Build the human-facing deny reason for any PROHIBIT forbidden-root rule. It is grounded in the
145
+ * attested prose (`payload.text`, the human-attested directive) plus the concrete blocked target and
146
+ * the configured forbidden root, and it names the violated rule id so the block is self-explaining in
147
+ * the Claude Code surface. No rule-specific copy is hard-coded here: the attested prose carries the
148
+ * steer (where the file SHOULD go), so the one reason builder serves the whole family, not just the
149
+ * notes pilot.
150
+ */
151
+ function buildDenyReason(ruleId, payload, target) {
152
+ const where = describeTarget(target);
153
+ const forbidden = payload.compliance.config.forbiddenRootRelativePath;
154
+ return (`Blocked by Meetless rule ${ruleId}. Writing ${where} under the forbidden ` +
155
+ `"${forbidden}/" root is prohibited. ${payload.text}`);
156
+ }
157
+ /**
158
+ * The enforced version-backed PreToolUse seam for the notes-location pilot. See the module header for
159
+ * the full pipeline. Returns the hook response (pass-through, or the one admitted deny) plus the
160
+ * durable outcome on the side channel. Skip semantics match slice 8 exactly: malformed payload or
161
+ * absent session id is INFRA, no LIVE version is NO_LIVE_VERSION, a non-Write/Edit tool or a glob
162
+ * non-match is NOT_APPLICABLE, and none of those persist a row.
163
+ */
164
+ async function evaluateAndEnforceNotesVersion(store, input) {
165
+ const parsed = (0, observe_adapter_1.parsePreToolUseInput)(input.rawStdin);
166
+ if (!parsed) {
167
+ return { response: PASS_THROUGH, outcome: { kind: "INFRA", diagnostic: "malformed hook input" } };
168
+ }
169
+ if (parsed.session_id === undefined) {
170
+ return { response: PASS_THROUGH, outcome: { kind: "INFRA", diagnostic: "missing session_id" } };
171
+ }
172
+ const ruleId = input.ruleId ?? attest_notes_location_1.NOTES_LOCATION_RULE_ID;
173
+ const version = (0, local_rule_version_repo_1.getLiveLocalRuleVersion)(store, input.runtimeScopeId, ruleId);
174
+ if (!version) {
175
+ return { response: PASS_THROUGH, outcome: { kind: "NO_LIVE_VERSION" } };
176
+ }
177
+ const payload = JSON.parse(version.rulePayload);
178
+ const call = { toolName: parsed.tool_name, toolInput: parsed.tool_input };
179
+ if ((0, evaluator_1.selectRule)(call, payload.applicability) === "NOT_APPLICABLE" || payload.applicability.mode !== "action") {
180
+ return { response: PASS_THROUGH, outcome: { kind: "NOT_APPLICABLE" } };
181
+ }
182
+ // The selector proved the tool is in the version's {Write, Edit} list and the matcher field holds a
183
+ // matching string, so this narrowing is sound (mirrors slice 8).
184
+ const toolName = parsed.tool_name;
185
+ const rawFilePath = parsed.tool_input[payload.applicability.matcher.field];
186
+ const classify = input.classifyRuntime ?? notes_path_1.classifyRuntimeTarget;
187
+ const target = await classify(rawFilePath, input.runtimeProjectRoot);
188
+ const verdict = (0, version_evaluation_1.versionBackedVerdict)(payload, target);
189
+ const eligible = (0, deny_admission_1.projectEligibleEnforcement)(verdict.result, payload.enforcementCeiling);
190
+ const ctx = {
191
+ runtimeScopeId: input.runtimeScopeId,
192
+ sessionId: parsed.session_id,
193
+ createdAt: input.createdAt,
194
+ now: input.now,
195
+ rand: input.rand,
196
+ };
197
+ const subject = { toolName, target, version, payload };
198
+ // Not a would-be deny (COMPLIANT, UNKNOWN, or a non-DENY ceiling): OBSERVE and pass through, WITHOUT
199
+ // resolving input authority. This is byte-identical to slice 8's writer.
200
+ if (eligible !== "DENY") {
201
+ const persisted = (0, version_evaluation_1.recordVersionEvaluation)(store, { toolName, target, version }, ctx);
202
+ return {
203
+ response: PASS_THROUGH,
204
+ outcome: {
205
+ kind: "OBSERVED",
206
+ attemptId: persisted.attemptId,
207
+ evaluationId: persisted.evaluationId,
208
+ result: persisted.result,
209
+ verdictReasonCode: persisted.verdictReasonCode,
210
+ ruleVersionId: persisted.ruleVersionId,
211
+ canonicalPayloadHash: persisted.canonicalPayloadHash,
212
+ },
213
+ };
214
+ }
215
+ // A would-be deny. Re-resolve the input authority (P0.58) and the attested path root (P0.63) at THIS
216
+ // deny, then run the pure admission kernel (slice 9).
217
+ const inputAuthority = input.resolveInputAuthority();
218
+ const pathRoot = (0, deny_admission_1.resolveAttestedPathRoot)({
219
+ configuredRelativeForbiddenPath: payload.compliance.config.forbiddenRootRelativePath,
220
+ activeRuntimeProjectRoot: input.runtimeProjectRoot,
221
+ });
222
+ const admission = (0, deny_admission_1.admitEnforcement)({ eligibleEnforcement: eligible, inputAuthority, pathRoot });
223
+ if (admission.effectiveEnforcement !== "DENY") {
224
+ // A gate lowered the deny to NONE: record the honest NONE arm and fail open. The cause distinguishes
225
+ // a non-sole input authority (P0.58) from an unresolved path root (P0.63); both are recorded as the
226
+ // single gate reason RULE_ENFORCEMENT_UNAVAILABLE.
227
+ const ids = recordEnforcementUnavailable(store, subject, verdict, inputAuthority.configHash, ctx);
228
+ const cause = inputAuthority.kind !== "MLA_SOLE_AUTHORITY" ? "INPUT_AUTHORITY" : "PATH_ROOT";
229
+ return {
230
+ response: PASS_THROUGH,
231
+ outcome: { kind: "ENFORCEMENT_UNAVAILABLE", cause, attemptId: ids.attemptId, evaluationId: ids.evaluationId },
232
+ };
233
+ }
234
+ // Admitted DENY. Linearize the durable commit against the LIVE generation (P0.52).
235
+ const deny = recordDenyDecision(store, {
236
+ toolName,
237
+ target,
238
+ version,
239
+ payload,
240
+ result: verdict.result,
241
+ verdictReasonCode: verdict.verdictReasonCode,
242
+ inputAuthorityConfigHash: inputAuthority.configHash,
243
+ }, ctx, { beforeCommit: input.beforeDenyCommit });
244
+ if (!deny.committed) {
245
+ // The LIVE generation churned in the deny window: inadmissible. Record the NONE arm and fail open.
246
+ const ids = recordEnforcementUnavailable(store, subject, verdict, inputAuthority.configHash, ctx);
247
+ return {
248
+ response: PASS_THROUGH,
249
+ outcome: { kind: "ENFORCEMENT_UNAVAILABLE", cause: "GENERATION_CHURN", attemptId: ids.attemptId, evaluationId: ids.evaluationId },
250
+ };
251
+ }
252
+ // The deny is committed durably at DECISION_RECORDED. Produce the emission, then advance the row to
253
+ // RESPONSE_EMITTED (R1-4). The DECISION_RECORDED commit is durable before this point, so a crash in
254
+ // the post-commit window leaves an honest DECISION_RECORDED, never NO_DECISION; the actual stdout
255
+ // write of this response is the runtime caller's, immediately after the seam returns.
256
+ const response = {
257
+ permissionDecision: "deny",
258
+ reason: buildDenyReason(ruleId, payload, target),
259
+ };
260
+ (0, interception_store_1.advanceDenyEmissionToResponseEmitted)(store, deny.attemptId);
261
+ return {
262
+ response,
263
+ outcome: {
264
+ kind: "DENIED",
265
+ attemptId: deny.attemptId,
266
+ evaluationId: deny.evaluationId,
267
+ ruleVersionId: version.versionId,
268
+ canonicalPayloadHash: version.canonicalPayloadHash,
269
+ inputAuthorityConfigHash: inputAuthority.configHash,
270
+ },
271
+ };
272
+ }
273
+ /**
274
+ * The PROHIBIT forbidden-root family: the one rule shape R1's evaluator and deny-admission kernel can
275
+ * enforce today, and (proposal §2.0) the shape that is conflict-free BY CONSTRUCTION. A rule is in the
276
+ * family when it PROHIBITs (never an effect that could effectively REQUIRE an action, which is the
277
+ * precondition for a conflict), is action-scoped (ambient rules are prompt-time grounding, not action
278
+ * gates), and carries a non-empty forbidden root for the path evaluator to face. Everything else is the
279
+ * R4 frontier the dispatch refuses to reason about.
280
+ */
281
+ function isProhibitForbiddenRootFamily(payload) {
282
+ return (payload.effect === "PROHIBIT" &&
283
+ payload.applicability.mode === "action" &&
284
+ typeof payload.compliance?.config?.forbiddenRootRelativePath === "string" &&
285
+ payload.compliance.config.forbiddenRootRelativePath.length > 0);
286
+ }
287
+ /**
288
+ * The rule-driven enforce dispatch (R4, conflict mechanization P0.13). Faces ONE tool attempt against
289
+ * EVERY LIVE rule in the scope, not just the notes pilot, by delegating per rule to the proven
290
+ * single-rule seam ({@link evaluateAndEnforceNotesVersion}) with that rule's id. Each delegated face
291
+ * writes its own attempt+eval arm, so the 1-attempt:N-evaluations schema is realized as N attempts that
292
+ * each record their own rule's verdict (the deliberately conservative reuse of the armed-deny machinery
293
+ * over a risky single-attempt linearization refactor).
294
+ *
295
+ * Two invariants make this safe:
296
+ *
297
+ * 1. R4 conflict-safety guard (three-class partition, P0.13). We can prove the absence of conflicts in
298
+ * two cases, and only those. (a) WITHIN the PROHIBIT forbidden-root family (§2.0: a conflict needs an
299
+ * effect that effectively REQUIRES an action, which PROHIBIT never does, so any number of PROHIBIT
300
+ * forbidden-root rules are mutually compatible) the rule is enforceable and we face it. (b) A
301
+ * provably INERT rule (one whose response ceiling is RECORD_ONLY, per {@link isInertNonEnforcingRule})
302
+ * imposes no effect at all on the attempt; no effect cannot be incompatible with a PROHIBIT deny, so
303
+ * it is non-conflicting by construction and we SKIP it. The moment a LIVE rule is NEITHER (an effect
304
+ * that could require an action, an unrecognized schema, a payload that will not parse) we cannot rule
305
+ * out a conflict, so we fail OPEN for the WHOLE attempt (R4_UNSUPPORTED_RULE_KIND) rather than enforce
306
+ * a deny we cannot reason about. The inert-skip is what lets a CE0 consult-evidence rule coexist in
307
+ * the same scope as the deny pilot without disarming it; the fail-open boundary for the genuinely
308
+ * unrecognized is unchanged.
309
+ *
310
+ * 2. Deterministic single block. Rules are faced in ruleId order (the repo returns them ordered by
311
+ * rule_id). The dispatch STOPS at the first deny: the family is conflict-free, so the lowest-ruleId
312
+ * deny is a deterministic, sufficient single block (the action is blocked once; the remaining rules
313
+ * need not run). Rules faced before the winner have already recorded their honest OBSERVE arms.
314
+ *
315
+ * Returns one of two distinct observe-fallback triggers when no enforceable rule is armed: NO_LIVE_VERSION
316
+ * when the scope is literally empty, or ONLY_INERT_RULES when live rules exist but every one is provably
317
+ * inert (RECORD_ONLY). Both are kept distinct from NOT_APPLICABLE (enforceable rules exist but none selected
318
+ * this call), and from each other so the audit never claims "no live version" when an inert rule is live.
319
+ */
320
+ async function evaluateAndEnforceLiveRules(store, input) {
321
+ const liveVersions = (0, local_rule_version_repo_1.listLiveLocalRuleVersions)(store, input.runtimeScopeId);
322
+ if (liveVersions.length === 0) {
323
+ return { response: PASS_THROUGH, outcome: { kind: "NO_LIVE_VERSION" } };
324
+ }
325
+ // Invariant 1: partition the LIVE rules into THREE classes (generalized-R4, P0.13). (a) An ENFORCEABLE
326
+ // family rule (PROHIBIT forbidden-root) is collected to be faced. (b) A provably INERT rule (one whose
327
+ // response ceiling is RECORD_ONLY, so it imposes no effect on the attempt and CANNOT conflict with a
328
+ // PROHIBIT deny) is SKIPPED: its presence neither enforces nor poisons the attempt, which is exactly
329
+ // what lets a CE0 consult-evidence rule coexist with the live deny pilot instead of disarming it. (c)
330
+ // Anything else is the R4 frontier we cannot reason about, so we fail OPEN for the whole attempt. A
331
+ // payload that will not even parse is, a fortiori, one we cannot reason about: fail open.
332
+ const enforceable = [];
333
+ for (const version of liveVersions) {
334
+ let payload = null;
335
+ try {
336
+ payload = JSON.parse(version.rulePayload);
337
+ }
338
+ catch {
339
+ payload = null;
340
+ }
341
+ if (payload && isProhibitForbiddenRootFamily(payload)) {
342
+ enforceable.push(version);
343
+ continue;
344
+ }
345
+ if ((0, inert_rule_families_1.isInertNonEnforcingRule)(payload)) {
346
+ continue;
347
+ }
348
+ return {
349
+ response: PASS_THROUGH,
350
+ outcome: { kind: "R4_UNSUPPORTED_RULE_KIND", ruleId: version.ruleId },
351
+ };
352
+ }
353
+ // Only inert rules were live: no ENFORCEABLE armed rule exists, so the enforce path has nothing to
354
+ // enforce and hands off to the R0 observe substrate exactly as an empty scope would. ONLY_INERT_RULES (not
355
+ // NO_LIVE_VERSION) records that the scope was NOT empty: a live inert rule existed but imposed no effect.
356
+ // The composed seam folds it into the observe fallback identically; the distinct tag keeps the audit honest.
357
+ if (enforceable.length === 0) {
358
+ return { response: PASS_THROUGH, outcome: { kind: "ONLY_INERT_RULES" } };
359
+ }
360
+ // Invariant 2: face each in-family rule in ruleId order; stop at the first deny.
361
+ let firstNonDeny = null;
362
+ for (const version of enforceable) {
363
+ const perRule = await evaluateAndEnforceNotesVersion(store, { ...input, ruleId: version.ruleId });
364
+ switch (perRule.outcome.kind) {
365
+ case "DENIED":
366
+ return perRule;
367
+ case "INFRA":
368
+ // Input-level (malformed hook payload / missing session): identical for every rule, so the
369
+ // first rule's diagnosis is the attempt's diagnosis.
370
+ return perRule;
371
+ case "OBSERVED":
372
+ case "ENFORCEMENT_UNAVAILABLE":
373
+ // Applicable but did not (could not) deny. Remember the first such outcome so the aggregate
374
+ // reports an applicable-but-not-denied attempt rather than a spurious NOT_APPLICABLE.
375
+ if (!firstNonDeny)
376
+ firstNonDeny = perRule;
377
+ break;
378
+ case "NOT_APPLICABLE":
379
+ case "NO_LIVE_VERSION":
380
+ // NOT_APPLICABLE: this rule's matcher did not select the call. NO_LIVE_VERSION: the version was
381
+ // revoked between the list and the face (a benign race). Both are skips for this rule.
382
+ break;
383
+ case "R4_UNSUPPORTED_RULE_KIND":
384
+ // Unreachable (the guard above already rejected any out-of-family rule); fail open defensively.
385
+ return perRule;
386
+ }
387
+ }
388
+ // No rule denied. Surface the first applicable rule's outcome if any; otherwise no matcher selected
389
+ // this call and the attempt is NOT_APPLICABLE.
390
+ return firstNonDeny ?? { response: PASS_THROUGH, outcome: { kind: "NOT_APPLICABLE" } };
391
+ }
392
+ /**
393
+ * The composed PreToolUse seam the live hook calls (proposal §3.6: observe is the always-on R0
394
+ * substrate, enforce layers on the human-attested LIVE version). It ENFORCES against the LIVE attested
395
+ * version when one exists; otherwise (NO_LIVE_VERSION) it records the R0 observed substrate so a rule
396
+ * that has never been attested still leaves an attestable observed snapshot. This closes the bootstrap
397
+ * gap: enforce-only writes nothing when unarmed, so an empty store could never produce the snapshot an
398
+ * operator attests from. Observe never grants, so the fallback's response stays pass-through; the deny
399
+ * path is reached only through the enforce seam against an attested version, never through observe.
400
+ */
401
+ async function evaluateEnforceOrObserveNotesRule(store, input) {
402
+ const enforced = await evaluateAndEnforceLiveRules(store, input);
403
+ // Both NO_LIVE_VERSION (empty scope) and ONLY_INERT_RULES (live rules exist but all are inert) mean no
404
+ // enforceable version is armed, so both hand off to the R0 observe substrate. The two tags are kept
405
+ // distinct in the dispatch outcome for audit honesty, but the fallback decision is identical here.
406
+ const k = enforced.outcome.kind;
407
+ if (k !== "NO_LIVE_VERSION" && k !== "ONLY_INERT_RULES") {
408
+ return enforced;
409
+ }
410
+ const observed = await (0, durable_observation_1.observeAndRecordNotesRule)(store, {
411
+ rawStdin: input.rawStdin,
412
+ directives: input.directives,
413
+ runtimeProjectRoot: input.runtimeProjectRoot,
414
+ runtimeScopeId: input.runtimeScopeId,
415
+ createdAt: input.createdAt,
416
+ now: input.now,
417
+ rand: input.rand,
418
+ classifyRuntime: input.classifyRuntime,
419
+ });
420
+ return { response: observed.response, outcome: observed.outcome };
421
+ }
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EvaluationInputHashError = exports.CANONICALIZATION_FAILED = exports.EVALUATION_INPUT_HASH_DOMAIN = void 0;
4
+ exports.buildEvaluationInputPayload = buildEvaluationInputPayload;
5
+ exports.serializeEvaluationInput = serializeEvaluationInput;
6
+ exports.evaluationInputHash = evaluationInputHash;
7
+ const crypto_1 = require("crypto");
8
+ const canonical_json_1 = require("./canonical-json");
9
+ /**
10
+ * The `evaluation-input-v1` canonical hash domain (proposal §10.1 step 2, decision 4).
11
+ *
12
+ * It computes the content identity of the ACTION-SIDE replay basis: the
13
+ * post-canonicalization compliance-replay input the R0 build persists as
14
+ * tool_attempt.evaluation_input_snapshot. This is NOT a rule hash; it is the exact input the
15
+ * four-state evaluator judged, so a later replay can recompute the verdict from the stored
16
+ * snapshot alone, with no re-read of CLAUDE.md and no second filesystem probe. The digest is
17
+ *
18
+ * SHA-256( domainTag || 0x00 || JCS(payload) ) (lowercase hex)
19
+ *
20
+ * where JCS is the repo's existing RFC 8785 canonicalizer (canonical-json.ts) and the domain
21
+ * tag + single 0x00 separator (decision 6) guarantee this digest can NEVER collide with the
22
+ * observed snapshot (`observed-rule-v1`), an attested version (`rule-version-v1`), or any other
23
+ * hashed artifact, even when two normalized bodies are byte-identical. JCS escapes control
24
+ * characters, so the only raw 0x00 byte in the hash input is the separator; the boundary
25
+ * between tag and payload is unambiguous.
26
+ *
27
+ * LOCKED SHAPE (proposal §10.1 step 2): exactly these JSON names, and `target` is the
28
+ * discriminated three-arm union. An in-scope target is a runtime-root-RELATIVE `path` (never an
29
+ * absolute home path); an out-of-scope target carries no path; an uncanonicalizable target is
30
+ * `UNKNOWN` with the single locked `reasonCode: "CANONICALIZATION_FAILED"`. The payload
31
+ * deliberately EXCLUDES file contents, tool output, and any unrestricted absolute home path. No
32
+ * unknown fields (fail-closed); no floats (float-free by construction).
33
+ *
34
+ * Universal-NFC caveat (honest). Unlike `observed-rule-v1`, this payload has NO prose field:
35
+ * `toolName` is an enum, `path` and `forbiddenRootRelativePath` are filesystem-derived, and the
36
+ * three version tags are opaque. The proposal's per-field rule says filesystem-derived and opaque
37
+ * values stay byte-for-byte; the vendored JCS primitive applies NFC to EVERY string. For the
38
+ * all-ASCII notes-location pilot every field is NFC-stable, so universal NFC is byte-identical to
39
+ * the per-field rule and the golden vectors are contract-correct. When a future evaluation input
40
+ * can carry a non-NFC `path` (for instance a target path with combining marks), this domain must
41
+ * switch to a per-field-NFC encoder so those bytes are preserved verbatim. That boundary is
42
+ * recorded in the ledger.
43
+ */
44
+ exports.EVALUATION_INPUT_HASH_DOMAIN = "evaluation-input-v1";
45
+ /** The locked reasonCode for an uncanonicalizable target. */
46
+ exports.CANONICALIZATION_FAILED = "CANONICALIZATION_FAILED";
47
+ /** Thrown when an input carries a field or value outside the evaluation-input-v1 schema. */
48
+ class EvaluationInputHashError extends Error {
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = "EvaluationInputHashError";
52
+ }
53
+ }
54
+ exports.EvaluationInputHashError = EvaluationInputHashError;
55
+ // The closed key sets for each object in the payload schema. Unknown fields are an error
56
+ // (decision 6), so a forward-incompatible producer cannot silently mint a hash a consumer
57
+ // would compute differently.
58
+ const TOP_LEVEL_KEYS = new Set([
59
+ "toolName",
60
+ "target",
61
+ "forbiddenRootRelativePath",
62
+ "evaluatorContractVersion",
63
+ "matcherSchemaVersion",
64
+ "pathCanonicalizerVersion",
65
+ ]);
66
+ const TARGET_RUNTIME_RELATIVE_KEYS = new Set(["kind", "path"]);
67
+ const TARGET_OUTSIDE_KEYS = new Set(["kind"]);
68
+ const TARGET_UNKNOWN_KEYS = new Set(["kind", "reasonCode"]);
69
+ function rejectUnknownKeys(obj, allowed, context) {
70
+ for (const key of Object.keys(obj)) {
71
+ if (!allowed.has(key)) {
72
+ throw new EvaluationInputHashError(`unknown field '${key}' in ${context} is outside the ${exports.EVALUATION_INPUT_HASH_DOMAIN} schema`);
73
+ }
74
+ }
75
+ }
76
+ function buildTargetPayload(target) {
77
+ switch (target.kind) {
78
+ case "RUNTIME_RELATIVE":
79
+ rejectUnknownKeys(target, TARGET_RUNTIME_RELATIVE_KEYS, "target(RUNTIME_RELATIVE)");
80
+ return { kind: "RUNTIME_RELATIVE", path: target.path };
81
+ case "OUTSIDE_RUNTIME_SCOPE":
82
+ rejectUnknownKeys(target, TARGET_OUTSIDE_KEYS, "target(OUTSIDE_RUNTIME_SCOPE)");
83
+ return { kind: "OUTSIDE_RUNTIME_SCOPE" };
84
+ case "UNKNOWN":
85
+ rejectUnknownKeys(target, TARGET_UNKNOWN_KEYS, "target(UNKNOWN)");
86
+ if (target.reasonCode !== exports.CANONICALIZATION_FAILED) {
87
+ throw new EvaluationInputHashError(`target(UNKNOWN).reasonCode must be '${exports.CANONICALIZATION_FAILED}', got '${String(target.reasonCode)}'`);
88
+ }
89
+ return { kind: "UNKNOWN", reasonCode: exports.CANONICALIZATION_FAILED };
90
+ default: {
91
+ const unexpected = target;
92
+ throw new EvaluationInputHashError(`unknown target kind '${String(unexpected.kind)}' is outside the ${exports.EVALUATION_INPUT_HASH_DOMAIN} schema`);
93
+ }
94
+ }
95
+ }
96
+ /**
97
+ * Build the closed canonical payload for an EvaluationInputV1: the exact object that gets
98
+ * canonicalized and hashed. Rejects unknown fields and an unknown target arm, and locks the
99
+ * UNKNOWN reasonCode. Field NFC is applied by the JCS encoder on the way out; see the
100
+ * universal-NFC caveat in the file header.
101
+ */
102
+ function buildEvaluationInputPayload(input) {
103
+ rejectUnknownKeys(input, TOP_LEVEL_KEYS, "evaluation input");
104
+ return {
105
+ toolName: input.toolName,
106
+ target: buildTargetPayload(input.target),
107
+ forbiddenRootRelativePath: input.forbiddenRootRelativePath,
108
+ evaluatorContractVersion: input.evaluatorContractVersion,
109
+ matcherSchemaVersion: input.matcherSchemaVersion,
110
+ pathCanonicalizerVersion: input.pathCanonicalizerVersion,
111
+ };
112
+ }
113
+ /** The exact RFC 8785 canonical JSON string that is hashed (UTF-8). Exposed for golden
114
+ * vectors and debugging; the digest is over these bytes prefixed by the domain. */
115
+ function serializeEvaluationInput(input) {
116
+ return (0, canonical_json_1.canonicalize)(buildEvaluationInputPayload(input));
117
+ }
118
+ /** The evaluation-input-v1 content hash: SHA-256(domainTag || 0x00 || JCS(payload)), lowercase hex. */
119
+ function evaluationInputHash(input) {
120
+ const jcs = serializeEvaluationInput(input);
121
+ const h = (0, crypto_1.createHash)("sha256");
122
+ h.update(exports.EVALUATION_INPUT_HASH_DOMAIN, "utf8");
123
+ h.update(Buffer.from([0x00]));
124
+ h.update(jcs, "utf8");
125
+ return h.digest("hex");
126
+ }
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.selectRule = selectRule;
4
+ exports.verdictForForbiddenRoot = verdictForForbiddenRoot;
5
+ exports.verdictForForbiddenContent = verdictForForbiddenContent;
6
+ exports.verdictForForbiddenCommand = verdictForForbiddenCommand;
7
+ exports.isEnforcementEligible = isEnforcementEligible;
8
+ /**
9
+ * Minimal R0 glob: an optional literal prefix (before the first "*") and an
10
+ * optional literal suffix (after the last "*"). Covers "*.md", "** /*.md",
11
+ * "foo*", "*bar", and "*". Anything between the first and last "*" is ignored,
12
+ * which is sufficient for the configured "*.md" matcher and keeps the surface
13
+ * tiny on purpose.
14
+ */
15
+ function matchesGlob(value, glob) {
16
+ const firstStar = glob.indexOf("*");
17
+ if (firstStar === -1) {
18
+ return value === glob;
19
+ }
20
+ const lastStar = glob.lastIndexOf("*");
21
+ const prefix = glob.slice(0, firstStar);
22
+ const suffix = glob.slice(lastStar + 1);
23
+ return (value.length >= prefix.length + suffix.length &&
24
+ value.startsWith(prefix) &&
25
+ value.endsWith(suffix));
26
+ }
27
+ /**
28
+ * Pure selection. Ambient rules are prompt-time grounding, not action gates:
29
+ * they never produce a per-call verdict, so at an action point they are
30
+ * NOT_APPLICABLE. An action rule applies when the tool is in its list and,
31
+ * if the matcher carries a glob, the named field holds a string matching it.
32
+ */
33
+ function selectRule(call, applicability) {
34
+ if (applicability.mode === "ambient") {
35
+ return "NOT_APPLICABLE";
36
+ }
37
+ if (!applicability.tools.includes(call.toolName)) {
38
+ return "NOT_APPLICABLE";
39
+ }
40
+ const { matcher } = applicability;
41
+ if (matcher.glob !== undefined) {
42
+ const value = call.toolInput[matcher.field];
43
+ if (typeof value !== "string" || !matchesGlob(value, matcher.glob)) {
44
+ return "NOT_APPLICABLE";
45
+ }
46
+ }
47
+ return "APPLIES";
48
+ }
49
+ /**
50
+ * Pure verdict for a PROHIBIT forbidden-root rule. Maps the path classification
51
+ * (or the "UNSUPPORTED" sentinel from an evaluator that cannot handle the input)
52
+ * to a four-state verdict. The only enforcement-eligible outcome is VIOLATION;
53
+ * every uncertainty degrades to UNKNOWN.
54
+ */
55
+ function verdictForForbiddenRoot(classification) {
56
+ switch (classification) {
57
+ case "UNDER_FORBIDDEN_ROOT":
58
+ return { result: "VIOLATION", reasonCode: "FORBIDDEN_PATH_MATCH" };
59
+ case "OUTSIDE_FORBIDDEN_ROOT":
60
+ return { result: "COMPLIANT", reasonCode: "COMPLIANT_OUTSIDE_FORBIDDEN_ROOT" };
61
+ case "INDETERMINATE":
62
+ return { result: "UNKNOWN", reasonCode: "CANONICALIZATION_FAILED" };
63
+ case "UNSUPPORTED":
64
+ return { result: "UNKNOWN", reasonCode: "EVALUATOR_UNSUPPORTED" };
65
+ }
66
+ }
67
+ /**
68
+ * Pure verdict for a PROHIBIT forbidden-content rule (the em-dash-ban class).
69
+ * Unlike the path/command matchers, a content field is fully observable, so a
70
+ * "no needle" result is a genuine COMPLIANT rather than an UNKNOWN: we hold the
71
+ * entire payload, so absence of the forbidden bytes is proven, not merely
72
+ * unobserved. INDETERMINATE (non-string field or empty needle set) degrades to
73
+ * UNKNOWN, which never asks or denies.
74
+ */
75
+ function verdictForForbiddenContent(classification) {
76
+ switch (classification) {
77
+ case "CONTAINS_FORBIDDEN":
78
+ return { result: "VIOLATION", reasonCode: "FORBIDDEN_CONTENT_MATCH" };
79
+ case "NO_FORBIDDEN":
80
+ return { result: "COMPLIANT", reasonCode: "COMPLIANT_NO_FORBIDDEN_CONTENT" };
81
+ case "INDETERMINATE":
82
+ return { result: "UNKNOWN", reasonCode: "CONTENT_INDETERMINATE" };
83
+ }
84
+ }
85
+ /**
86
+ * Pure verdict for a PROHIBIT forbidden-command rule (the git/prisma class). The
87
+ * inverse of the content verdict: because a shell string is opaque, there is NO
88
+ * COMPLIANT outcome. Only a positive literal token-run match is a verdict
89
+ * (VIOLATION); both NO_MATCH and INDETERMINATE degrade to UNKNOWN, since a
90
+ * non-match cannot prove the command will not perform the operation (an alias, a
91
+ * wrapper script, eval, or $VAR expansion could). The distinct UNKNOWN reason
92
+ * codes keep "tokenized, found nothing" observably separate from "could not
93
+ * evaluate".
94
+ */
95
+ function verdictForForbiddenCommand(classification) {
96
+ switch (classification) {
97
+ case "MATCHES_FORBIDDEN":
98
+ return { result: "VIOLATION", reasonCode: "FORBIDDEN_COMMAND_MATCH" };
99
+ case "NO_MATCH":
100
+ return { result: "UNKNOWN", reasonCode: "COMMAND_NO_MATCH_OPAQUE" };
101
+ case "INDETERMINATE":
102
+ return { result: "UNKNOWN", reasonCode: "COMMAND_INDETERMINATE" };
103
+ }
104
+ }
105
+ /** Only VIOLATION is potentially enforcement-eligible. */
106
+ function isEnforcementEligible(result) {
107
+ return result === "VIOLATION";
108
+ }