@intutic/sync-daemon 2.2.0 → 2.3.0

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 (235) hide show
  1. package/README.md +1 -1
  2. package/dist/agentReporter.d.ts +23 -11
  3. package/dist/agentReporter.d.ts.map +1 -1
  4. package/dist/agentReporter.js +20 -13
  5. package/dist/agentReporter.js.map +1 -1
  6. package/dist/configReader.d.ts +1 -3
  7. package/dist/configReader.d.ts.map +1 -1
  8. package/dist/configReader.js +0 -2
  9. package/dist/configReader.js.map +1 -1
  10. package/dist/configWriter.d.ts +11 -12
  11. package/dist/configWriter.d.ts.map +1 -1
  12. package/dist/configWriter.js +35 -58
  13. package/dist/configWriter.js.map +1 -1
  14. package/dist/disconnect/harnesses.d.ts +0 -2
  15. package/dist/disconnect/harnesses.d.ts.map +1 -1
  16. package/dist/disconnect/harnesses.js +157 -80
  17. package/dist/disconnect/harnesses.js.map +1 -1
  18. package/dist/disconnect/index.d.ts.map +1 -1
  19. package/dist/disconnect/index.js +11 -1
  20. package/dist/disconnect/index.js.map +1 -1
  21. package/dist/disconnect/mcp.d.ts +21 -0
  22. package/dist/disconnect/mcp.d.ts.map +1 -1
  23. package/dist/disconnect/mcp.js +71 -0
  24. package/dist/disconnect/mcp.js.map +1 -1
  25. package/dist/disconnect/recognise.d.ts +2 -2
  26. package/dist/disconnect/recognise.d.ts.map +1 -1
  27. package/dist/disconnect/recognise.js +15 -5
  28. package/dist/disconnect/recognise.js.map +1 -1
  29. package/dist/harness/aiderConfigMerger.d.ts +28 -8
  30. package/dist/harness/aiderConfigMerger.d.ts.map +1 -1
  31. package/dist/harness/aiderConfigMerger.js +59 -11
  32. package/dist/harness/aiderConfigMerger.js.map +1 -1
  33. package/dist/harness/antigravityCliHooks.d.ts +61 -0
  34. package/dist/harness/antigravityCliHooks.d.ts.map +1 -0
  35. package/dist/harness/antigravityCliHooks.js +207 -0
  36. package/dist/harness/antigravityCliHooks.js.map +1 -0
  37. package/dist/harness/antigravityHooks.d.ts +12 -2
  38. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  39. package/dist/harness/antigravityHooks.js +36 -16
  40. package/dist/harness/antigravityHooks.js.map +1 -1
  41. package/dist/harness/antigravityProducts.d.ts +40 -0
  42. package/dist/harness/antigravityProducts.d.ts.map +1 -0
  43. package/dist/harness/antigravityProducts.js +109 -0
  44. package/dist/harness/antigravityProducts.js.map +1 -0
  45. package/dist/harness/claudeAgentsMd.d.ts +29 -0
  46. package/dist/harness/claudeAgentsMd.d.ts.map +1 -0
  47. package/dist/harness/claudeAgentsMd.js +80 -0
  48. package/dist/harness/claudeAgentsMd.js.map +1 -0
  49. package/dist/harness/claudeCodeHooks.d.ts +6 -5
  50. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  51. package/dist/harness/claudeCodeHooks.js +98 -24
  52. package/dist/harness/claudeCodeHooks.js.map +1 -1
  53. package/dist/harness/clineHooks.d.ts +0 -1
  54. package/dist/harness/clineHooks.d.ts.map +1 -1
  55. package/dist/harness/clineHooks.js +10 -4
  56. package/dist/harness/clineHooks.js.map +1 -1
  57. package/dist/harness/codexHooks.d.ts +2 -0
  58. package/dist/harness/codexHooks.d.ts.map +1 -1
  59. package/dist/harness/codexHooks.js +12 -4
  60. package/dist/harness/codexHooks.js.map +1 -1
  61. package/dist/harness/cursorHooks.d.ts +5 -2
  62. package/dist/harness/cursorHooks.d.ts.map +1 -1
  63. package/dist/harness/cursorHooks.js +9 -3
  64. package/dist/harness/cursorHooks.js.map +1 -1
  65. package/dist/harness/dshHooks.d.ts +5 -5
  66. package/dist/harness/dshHooks.d.ts.map +1 -1
  67. package/dist/harness/dshHooks.js +5 -5
  68. package/dist/harness/gateArtifacts.d.ts +35 -0
  69. package/dist/harness/gateArtifacts.d.ts.map +1 -0
  70. package/dist/harness/gateArtifacts.js +88 -0
  71. package/dist/harness/gateArtifacts.js.map +1 -0
  72. package/dist/harness/gateBody.d.ts +186 -69
  73. package/dist/harness/gateBody.d.ts.map +1 -1
  74. package/dist/harness/gateBody.js +820 -182
  75. package/dist/harness/gateBody.js.map +1 -1
  76. package/dist/harness/gateSightings.d.ts +47 -0
  77. package/dist/harness/gateSightings.d.ts.map +1 -0
  78. package/dist/harness/gateSightings.js +159 -0
  79. package/dist/harness/gateSightings.js.map +1 -0
  80. package/dist/harness/githubCopilotHooks.d.ts.map +1 -1
  81. package/dist/harness/githubCopilotHooks.js +7 -1
  82. package/dist/harness/githubCopilotHooks.js.map +1 -1
  83. package/dist/harness/gooseHardener.d.ts +0 -1
  84. package/dist/harness/gooseHardener.d.ts.map +1 -1
  85. package/dist/harness/gooseHardener.js +1 -1
  86. package/dist/harness/gooseHardener.js.map +1 -1
  87. package/dist/harness/gooseHooks.d.ts +14 -1
  88. package/dist/harness/gooseHooks.d.ts.map +1 -1
  89. package/dist/harness/gooseHooks.js +55 -29
  90. package/dist/harness/gooseHooks.js.map +1 -1
  91. package/dist/harness/grokHooks.d.ts +2 -2
  92. package/dist/harness/grokHooks.d.ts.map +1 -1
  93. package/dist/harness/grokHooks.js +11 -6
  94. package/dist/harness/grokHooks.js.map +1 -1
  95. package/dist/harness/hermesHooks.d.ts +18 -8
  96. package/dist/harness/hermesHooks.d.ts.map +1 -1
  97. package/dist/harness/hermesHooks.js +48 -17
  98. package/dist/harness/hermesHooks.js.map +1 -1
  99. package/dist/harness/mcpAutoWrite.d.ts +24 -9
  100. package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
  101. package/dist/harness/mcpAutoWrite.js +218 -25
  102. package/dist/harness/mcpAutoWrite.js.map +1 -1
  103. package/dist/harness/museHooks.d.ts +3 -3
  104. package/dist/harness/museHooks.d.ts.map +1 -1
  105. package/dist/harness/museHooks.js +9 -5
  106. package/dist/harness/museHooks.js.map +1 -1
  107. package/dist/harness/n8nHooks.d.ts +0 -1
  108. package/dist/harness/n8nHooks.d.ts.map +1 -1
  109. package/dist/harness/n8nHooks.js +4 -1
  110. package/dist/harness/n8nHooks.js.map +1 -1
  111. package/dist/harness/openCodeHooks.d.ts +2 -2
  112. package/dist/harness/openCodeHooks.d.ts.map +1 -1
  113. package/dist/harness/openCodeHooks.js +7 -3
  114. package/dist/harness/openCodeHooks.js.map +1 -1
  115. package/dist/harness/openWebuiHooks.d.ts +0 -1
  116. package/dist/harness/openWebuiHooks.d.ts.map +1 -1
  117. package/dist/harness/openWebuiHooks.js +5 -1
  118. package/dist/harness/openWebuiHooks.js.map +1 -1
  119. package/dist/harness/openclawHooks.d.ts +70 -18
  120. package/dist/harness/openclawHooks.d.ts.map +1 -1
  121. package/dist/harness/openclawHooks.js +213 -161
  122. package/dist/harness/openclawHooks.js.map +1 -1
  123. package/dist/harness/openhandsHooks.d.ts +24 -10
  124. package/dist/harness/openhandsHooks.d.ts.map +1 -1
  125. package/dist/harness/openhandsHooks.js +49 -48
  126. package/dist/harness/openhandsHooks.js.map +1 -1
  127. package/dist/harness/piHooks.d.ts +52 -17
  128. package/dist/harness/piHooks.d.ts.map +1 -1
  129. package/dist/harness/piHooks.js +177 -166
  130. package/dist/harness/piHooks.js.map +1 -1
  131. package/dist/harness/protectedPaths.d.ts +107 -25
  132. package/dist/harness/protectedPaths.d.ts.map +1 -1
  133. package/dist/harness/protectedPaths.js +342 -48
  134. package/dist/harness/protectedPaths.js.map +1 -1
  135. package/dist/harness/rulesSection.d.ts +62 -0
  136. package/dist/harness/rulesSection.d.ts.map +1 -0
  137. package/dist/harness/rulesSection.js +145 -0
  138. package/dist/harness/rulesSection.js.map +1 -0
  139. package/dist/harness/windsurfHooks.d.ts +0 -1
  140. package/dist/harness/windsurfHooks.d.ts.map +1 -1
  141. package/dist/harness/windsurfHooks.js +4 -1
  142. package/dist/harness/windsurfHooks.js.map +1 -1
  143. package/dist/index.d.ts +12 -8
  144. package/dist/index.d.ts.map +1 -1
  145. package/dist/index.js +11 -9
  146. package/dist/index.js.map +1 -1
  147. package/dist/inventory.d.ts +53 -0
  148. package/dist/inventory.d.ts.map +1 -0
  149. package/dist/inventory.js +133 -0
  150. package/dist/inventory.js.map +1 -0
  151. package/dist/lib/complianceProbes.d.ts +2 -1
  152. package/dist/lib/complianceProbes.d.ts.map +1 -1
  153. package/dist/lib/complianceProbes.js +1 -1
  154. package/dist/lib/complianceProbes.js.map +1 -1
  155. package/dist/lib/decisionsDigest.d.ts +34 -16
  156. package/dist/lib/decisionsDigest.d.ts.map +1 -1
  157. package/dist/lib/decisionsDigest.js +105 -46
  158. package/dist/lib/decisionsDigest.js.map +1 -1
  159. package/dist/lib/egressPolicy.d.ts +2 -2
  160. package/dist/lib/egressPolicy.js +2 -2
  161. package/dist/lib/mcpRegistryPy.d.ts +11 -0
  162. package/dist/lib/mcpRegistryPy.d.ts.map +1 -0
  163. package/dist/lib/mcpRegistryPy.js +173 -0
  164. package/dist/lib/mcpRegistryPy.js.map +1 -0
  165. package/dist/lib/phrasesPy.d.ts +11 -0
  166. package/dist/lib/phrasesPy.d.ts.map +1 -0
  167. package/dist/lib/phrasesPy.js +351 -0
  168. package/dist/lib/phrasesPy.js.map +1 -0
  169. package/dist/lib/policySnapshot.d.ts +46 -10
  170. package/dist/lib/policySnapshot.d.ts.map +1 -1
  171. package/dist/lib/policySnapshot.js +136 -47
  172. package/dist/lib/policySnapshot.js.map +1 -1
  173. package/dist/lib/processPoller.d.ts.map +1 -1
  174. package/dist/lib/processPoller.js +9 -2
  175. package/dist/lib/processPoller.js.map +1 -1
  176. package/dist/lib/regexLinearity.d.ts +43 -0
  177. package/dist/lib/regexLinearity.d.ts.map +1 -0
  178. package/dist/lib/regexLinearity.js +275 -0
  179. package/dist/lib/regexLinearity.js.map +1 -0
  180. package/dist/lib/runtimeEnv.d.ts +1 -3
  181. package/dist/lib/runtimeEnv.d.ts.map +1 -1
  182. package/dist/lib/runtimeEnv.js +0 -2
  183. package/dist/lib/runtimeEnv.js.map +1 -1
  184. package/dist/lib/sequencePy.d.ts +10 -0
  185. package/dist/lib/sequencePy.d.ts.map +1 -0
  186. package/dist/lib/sequencePy.js +102 -0
  187. package/dist/lib/sequencePy.js.map +1 -0
  188. package/dist/sessionReporter.d.ts +17 -11
  189. package/dist/sessionReporter.d.ts.map +1 -1
  190. package/dist/sessionReporter.js +36 -23
  191. package/dist/sessionReporter.js.map +1 -1
  192. package/dist/skillWriter.d.ts +23 -5
  193. package/dist/skillWriter.d.ts.map +1 -1
  194. package/dist/skillWriter.js +159 -16
  195. package/dist/skillWriter.js.map +1 -1
  196. package/dist/syncCycle.d.ts.map +1 -1
  197. package/dist/syncCycle.js +21 -9
  198. package/dist/syncCycle.js.map +1 -1
  199. package/dist/trajectoryMonitor.d.ts +1 -3
  200. package/dist/trajectoryMonitor.d.ts.map +1 -1
  201. package/dist/trajectoryMonitor.js +1 -4
  202. package/dist/trajectoryMonitor.js.map +1 -1
  203. package/dist/watcher/driftWatcher.d.ts +0 -1
  204. package/dist/watcher/driftWatcher.d.ts.map +1 -1
  205. package/dist/watcher/driftWatcher.js +1 -2
  206. package/dist/watcher/driftWatcher.js.map +1 -1
  207. package/dist/watcher/settingsGuard.d.ts +33 -7
  208. package/dist/watcher/settingsGuard.d.ts.map +1 -1
  209. package/dist/watcher/settingsGuard.js +373 -61
  210. package/dist/watcher/settingsGuard.js.map +1 -1
  211. package/dist/watcher/vscodeHookSettings.d.ts +30 -0
  212. package/dist/watcher/vscodeHookSettings.d.ts.map +1 -0
  213. package/dist/watcher/vscodeHookSettings.js +170 -0
  214. package/dist/watcher/vscodeHookSettings.js.map +1 -0
  215. package/dist/wsClient.d.ts +0 -1
  216. package/dist/wsClient.d.ts.map +1 -1
  217. package/dist/wsClient.js +0 -1
  218. package/dist/wsClient.js.map +1 -1
  219. package/package.json +5 -5
  220. package/dist/harness/claudeDesktopHooks.d.ts +0 -33
  221. package/dist/harness/claudeDesktopHooks.d.ts.map +0 -1
  222. package/dist/harness/claudeDesktopHooks.js +0 -256
  223. package/dist/harness/claudeDesktopHooks.js.map +0 -1
  224. package/dist/harness/continueHooks.d.ts +0 -63
  225. package/dist/harness/continueHooks.d.ts.map +0 -1
  226. package/dist/harness/continueHooks.js +0 -250
  227. package/dist/harness/continueHooks.js.map +0 -1
  228. package/dist/harness/gateKind.d.ts +0 -164
  229. package/dist/harness/gateKind.d.ts.map +0 -1
  230. package/dist/harness/gateKind.js +0 -144
  231. package/dist/harness/gateKind.js.map +0 -1
  232. package/dist/harness/rooCodeHooks.d.ts +0 -31
  233. package/dist/harness/rooCodeHooks.d.ts.map +0 -1
  234. package/dist/harness/rooCodeHooks.js +0 -281
  235. package/dist/harness/rooCodeHooks.js.map +0 -1
@@ -48,7 +48,12 @@
48
48
  *
49
49
  * @module
50
50
  */
51
- import { DESTRUCTIVE_COMMAND_PATTERNS, NORMALISE_CONTRACT, staticFloorPatterns, } from './protectedPaths.js';
51
+ import { HOLD_APPROVAL_HINT_TEMPLATE, HOLD_ID_PLACEHOLDER } from '@intutic/shared-types';
52
+ import { DESTRUCTIVE_COMMAND_PATTERNS, NORMALISE_CONTRACT, ruleFlags, staticFloorPatterns, } from './protectedPaths.js';
53
+ import { ARGUMENTS_SIZE_LIMIT, COMMAND_SIZE_LIMIT, gateDeadlineMs, MCP_ALLOWLIST_JS_SOURCE, MCP_ALLOWLIST_RECORD_TAG, MCP_REGISTRY_JS_SOURCE, MCP_REGISTRY_RECORD_TAG, MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE, PHRASES_JS_SOURCE, SEQUENCE_JS_SOURCE, } from '@intutic/shared-types';
54
+ import { MCP_REGISTRY_PY_SOURCE } from '../lib/mcpRegistryPy.js';
55
+ import { PHRASES_PY_SOURCE } from '../lib/phrasesPy.js';
56
+ import { SEQUENCE_PY_SOURCE } from '../lib/sequencePy.js';
52
57
  /**
53
58
  * Bumped when the emitted evaluator changes shape.
54
59
  *
@@ -69,7 +74,7 @@ import { DESTRUCTIVE_COMMAND_PATTERNS, NORMALISE_CONTRACT, staticFloorPatterns,
69
74
  *
70
75
  * v5: the residual fail-open edges were closed. Every bash gate now converts
71
76
  * an accidental crash (exit != 0 and != 2 under `set -euo pipefail`) into
72
- * exit 2 via {@link SHELL_FAIL_CLOSED}; every bash gate refuses a stdin
77
+ * exit 2 via {@link emitShellFailClosed}; every bash gate refuses a stdin
73
78
  * payload from which neither a tool name nor a tool_input could be extracted
74
79
  * (the envelope posture githubCopilotHooks established, now uniform); every
75
80
  * JS gate installs `uncaughtException`/`unhandledRejection` handlers as its
@@ -98,7 +103,7 @@ import { DESTRUCTIVE_COMMAND_PATTERNS, NORMALISE_CONTRACT, staticFloorPatterns,
98
103
  * picks the fail-open reading of that ambiguity rather than blocking every
99
104
  * MCP call the first time a v6 gate meets an old snapshot).
100
105
  *
101
- * v8 (TD-474 item 4): the `hold` severity and the `action` subject. A hold
106
+ * v8: the `hold` severity and the `action` subject. A hold
102
107
  * rule — a `require_approval` SOP rule the control plane resolves, or a local
103
108
  * `review_before:` token the daemon compiles as `sop.local.review_before.*` —
104
109
  * refuses the call through the harness's own contract, appends the v1 hold
@@ -122,12 +127,148 @@ import { DESTRUCTIVE_COMMAND_PATTERNS, NORMALISE_CONTRACT, staticFloorPatterns,
122
127
  * change: an invalid snapshot keeps its `sso_group.*` block rules instead of
123
128
  * dropping them with the rest of the dynamic tier. A v8 gate reading a v9
124
129
  * snapshot enforces the same rules and drops them on an invalid one.
130
+ *
131
+ * v10: the hold classifier matches each action needle's words with a gap
132
+ * regex (`SQL_GAP_ERE`, replaced in v12) between them, over the normalised command, instead of
133
+ * as a plain substring of the raw one. `DROP/**\/TABLE`, `DROP -- why` +
134
+ * newline + `TABLE`, an escaped `\n`, a line continuation and (in the JS
135
+ * gates, which read the raw command) a tab or a doubled space all dodged a
136
+ * `review_before: action:db_write` or `action:deploy` hold. Only the emitted
137
+ * classifier changed; the `.rules` format did not, so v9 and v10 gates read
138
+ * each other's snapshots — a v9 gate just holds fewer spellings.
139
+ *
140
+ * v11: the classifier reads every harness's shell tool ({@link
141
+ * ACTION_TOOL_NAMES}, now the proxy's `SHELL_TOOLS`, Gemini CLI's
142
+ * `run_shell_command` included) with the v10 matching. A v10 gate does not
143
+ * classify a command run through those extra tool names. The `.rules` format
144
+ * is unchanged.
145
+ *
146
+ * v12: no regex runs on a command to find words that may be split apart. The
147
+ * hold classifier and `phrase`-subject rules (`destructive.sql_drop`) use the
148
+ * linear phrase matcher (`@intutic/shared-types` phrases.ts, emitted as
149
+ * JavaScript; `phrases.py`, emitted as Python and run by the bash gates'
150
+ * extractor), over the raw command. The v10 regex took seconds on a few
151
+ * hundred kilobytes of `git -- git -- …` in the JS gates, and bash's pattern
152
+ * substitution a minute on a long run of backslashes. A v11 gate reading a v12
153
+ * snapshot reads a `phrase` rule's source — the phrases joined by `|` — as a
154
+ * regex over the command, which still matches their plain spellings.
155
+ *
156
+ * v13: no gate rule runs a backtracking regex on a call, and no gate runs
157
+ * past its own deadline. Rules shaped `A.*B` carry the `s` flag in the
158
+ * `.rules` flags column (`is` with `i`) and run as sequences of steps
159
+ * (`@intutic/shared-types` sequence.ts, `sequence.py`); `grep -E` still runs
160
+ * them as written. A call whose command is over `COMMAND_SIZE_LIMIT` bytes or
161
+ * whose arguments are over `ARGUMENTS_SIZE_LIMIT` is refused as
162
+ * `COMMAND_TOO_LARGE`, and a gate still deciding at its deadline refuses
163
+ * with `GATE_DEADLINE`, ahead of the harnesses that read a
164
+ * hook timeout as an allow. A v12 gate compares the flags column with `i`, so
165
+ * it runs a sequence rule as the regex it also is, case-sensitively.
166
+ *
167
+ * v14: the MCP server registry and refusal codes. The snapshot carries the
168
+ * workspace's registry decisions as an `@mcp_registry` data line (two
169
+ * columns, inside the digest), and the JS and bash gates refuse an
170
+ * `mcp__<server>__<tool>` call to a blocked or held server, to a server the
171
+ * workspace has not approved under `mcpDefaultPolicy: deny`, or to a disabled
172
+ * tool, with the MCP proxy's codes, rule ids and reasons
173
+ * (`evaluateMcpRegistry` in `@intutic/shared-types`). An invalid snapshot
174
+ * keeps the record but not its approvals, as it keeps the SSO-group refusals
175
+ * but not the member's groups. A JSON decision (Cline, Grok Build,
176
+ * Antigravity) now carries `code` and `ruleId` ({@link HOOK_REFUSAL_CODES}).
177
+ * The `.rules` rule format is unchanged; a v13 gate skips the record line as
178
+ * it skips `@sso_groups`, and enforces no registry.
179
+ *
180
+ * v15: the in-process gates (the `'throw'` contract: OpenCode's plugin, Pi's
181
+ * extension, OpenClaw's plugin) run their rules under the same deadline,
182
+ * measured from the start of the call rather than of the process, which
183
+ * started long before. Pi and OpenCode await a tool-call handler with no time
184
+ * limit, and OpenClaw's own hook timeout cannot interrupt synchronous code,
185
+ * so a v14 in-process gate on a slow ` WHERE ` pattern held the agent.
186
+ *
187
+ * v16: the MCP server allowlist moved inside the digest. It was a
188
+ * `#mcpservers` header, which the digest does not cover, so adding a server
189
+ * to it by hand widened the allowlist and left the snapshot valid. It is now
190
+ * an `@mcp_allowlist` data line (tag, severity, comma-joined servers; three
191
+ * columns, so every rule parser skips it), and a snapshot that fails its
192
+ * digest keeps the allowlist but admits no server and refuses at `block`,
193
+ * as it keeps the registry's refusals but not its approvals. The bash gates'
194
+ * registry decision is `intutic_clawde/gate/mcp_registry.py`, emitted
195
+ * verbatim. A v15 gate reading a v16 snapshot enforces no allowlist; the
196
+ * daemon regenerates every gate each sync cycle.
197
+ *
198
+ * v17: an unverified snapshot admits no MCP server. A snapshot whose digest
199
+ * is broken or missing, or that names another workspace, used to keep the
200
+ * registry's refusals and an allowlist that admitted nothing; but a deleted
201
+ * record looks exactly like one never set, and an edit to the registry's
202
+ * blocked list or default policy still widened it. Every gate now refuses
203
+ * every MCP call on such a snapshot as `POLICY_SNAPSHOT_UNVERIFIED`
204
+ * (`mcpSnapshotUnverifiedRefusal` in `@intutic/shared-types`), at `block`
205
+ * in an observe-only workspace too, and the sync daemon restores the last
206
+ * snapshot it verified. The JS and Python readers also read a snapshot with
207
+ * no `#digest` line as unverified, as the bash gates already did. And the
208
+ * compiled floor refuses an edit to a VS Code settings file that sets
209
+ * `chat.useHooks` or `chat.hookFilesLocations` (`HOOK_SETTING_PATTERNS`),
210
+ * which can switch off the GitHub Copilot gate. The `.rules` format is
211
+ * unchanged.
212
+ *
213
+ * v18: each gate's deadline is its own harness's: a second under the hook
214
+ * timeout that harness applies (`HOOK_GATE_TIMEOUTS` and `gateDeadlineMs` in
215
+ * `@intutic/shared-types`), at most 9 s. One 4 s deadline for every gate,
216
+ * sized for Grok Build's 5 s default (connect now writes 10 s), refused legitimate calls on a busy machine in
217
+ * harnesses that wait 10 s or more. The bash gates also screen each subject
218
+ * with one `grep` holding every rule's pattern before testing rule by rule,
219
+ * and take the whitespace-collapsed fields from the extractor as they are:
220
+ * a call at the size limit had forked a hundred `grep`s and spent seconds of
221
+ * a loaded machine before any rule could match. The `.rules` format is
222
+ * unchanged.
125
223
  */
126
- export const GATE_VERSION = 9;
224
+ export const GATE_VERSION = 18;
225
+ /**
226
+ * The refusal codes a hook gate's JSON decision carries, as `code` beside the
227
+ * deciding `ruleId` (null when no rule decided), for the harnesses that read
228
+ * a decision from stdout rather than an exit code: Cline (`cancel`), Grok
229
+ * Build and Antigravity (`decision: deny`). The exit-code harnesses read the
230
+ * same refusal as text on stderr, the rule id in brackets. Held to
231
+ * `packages/shared-types/fixtures/refusal-codes.json` (`hook`) by a test.
232
+ *
233
+ * - `BUILT_IN_RULE`: a rule compiled into the gate (a governance bypass, a
234
+ * write to a protected path or a skill directory, a secret in content);
235
+ * - `SNAPSHOT`: a block rule from the policy snapshot (an SOP, the
236
+ * destructive-command tier, a skill-content rule);
237
+ * - `SSO_GROUP`: an `sso_group.*` rule, compiled from the workspace's SSO
238
+ * group policy for this member;
239
+ * - `HELD`: a hold rule, with `holdId`;
240
+ * - `SERVER_BLOCKED`, `SERVER_HELD`, `SERVER_NOT_APPROVED`, `TOOL_DISABLED`:
241
+ * the MCP server registry;
242
+ * - `SERVER_NOT_ALLOWED`: the workspace's `mcpAllowedServers` list;
243
+ * - `POLICY_SNAPSHOT_UNVERIFIED`: an MCP call on a snapshot that failed its
244
+ * integrity check, which admits no MCP server;
245
+ * - `COMMAND_TOO_LARGE`: the call is over the size a gate evaluates;
246
+ * - `GATE_DEADLINE`: the gate did not decide within its harness's deadline
247
+ * (`gateDeadlineMs` in `@intutic/shared-types`);
248
+ * - `UNREADABLE_CALL`: the payload held no tool call the gate could read;
249
+ * - `GATE_CRASHED`: the gate failed while deciding.
250
+ */
251
+ export const HOOK_REFUSAL_CODES = [
252
+ 'BUILT_IN_RULE',
253
+ 'SNAPSHOT',
254
+ 'SSO_GROUP',
255
+ 'HELD',
256
+ 'SERVER_BLOCKED',
257
+ 'SERVER_HELD',
258
+ 'SERVER_NOT_APPROVED',
259
+ 'TOOL_DISABLED',
260
+ 'SERVER_NOT_ALLOWED',
261
+ 'POLICY_SNAPSHOT_UNVERIFIED',
262
+ 'COMMAND_TOO_LARGE',
263
+ 'GATE_DEADLINE',
264
+ 'UNREADABLE_CALL',
265
+ 'GATE_CRASHED',
266
+ ];
127
267
  /**
128
268
  * The coarse command → action-token classification the hold tier keys on:
129
- * `review_before: action:deploy` holds a shell command that contains any of
130
- * the deploy needles. Deliberately minimal — a gate that tries to be clever
269
+ * `review_before: action:deploy` holds a shell command that contains one of
270
+ * the deploy needles, whatever separates its words (the phrase matcher,
271
+ * `@intutic/shared-types` phrases.ts). Deliberately minimal — a gate that tries to be clever
131
272
  * about shell commands is a gate that blocks real work — and it mirrors the
132
273
  * proxy's `actions.rs` in both directions: `hookActionParity.test.ts` reads
133
274
  * THIS file as text and fails if either side knows a needle the other does
@@ -138,10 +279,27 @@ export const ACTION_NEEDLES = [
138
279
  ['action:deploy', ['git push', 'kubectl apply', 'kubectl rollout', 'helm upgrade', 'helm install', 'terraform apply', 'docker push', 'serverless deploy', 'fly deploy', 'vercel deploy', 'gcloud run deploy', 'aws deploy', 'aws s3 sync', 'eb deploy']],
139
280
  ['action:publish', ['npm publish', 'pnpm publish', 'yarn publish', 'cargo publish', 'twine upload', 'poetry publish', 'gem push', 'docker manifest push']],
140
281
  ['action:release', ['gh release create', 'git tag', 'npm version', 'cargo release', 'goreleaser release', 'semantic-release']],
141
- ['action:db_write', ['insert into', 'update ', 'delete from', 'drop table', 'truncate ', 'alter table']],
282
+ ['action:db_write', ['insert into', 'update ', 'delete from', 'drop table', 'drop database', 'drop schema', 'truncate ', 'alter table']],
283
+ ];
284
+ /**
285
+ * The (lower-cased) tool names whose `command` the classifier reads: every
286
+ * harness's shell tool. The same list as the proxy's `SHELL_TOOLS` in
287
+ * `actions.rs` (`hookActionParity.test.ts` holds them equal), so a hold the
288
+ * proxy would classify is one the gate can hold before the call runs.
289
+ * `run_shell_command` is Gemini CLI's, `run_command` Antigravity's and
290
+ * `execute_command` Cline's.
291
+ */
292
+ export const ACTION_TOOL_NAMES = [
293
+ 'bash',
294
+ 'shell',
295
+ 'run_command',
296
+ 'runcommand',
297
+ 'execute_command',
298
+ 'run_shell_command',
299
+ 'terminal',
300
+ 'execute',
301
+ 'exec',
142
302
  ];
143
- /** The (lower-cased) tool names whose `command` the classifier reads. */
144
- export const ACTION_TOOL_NAMES = ['bash', 'shell', 'run_command', 'terminal', 'execute'];
145
303
  /** Where a gate appends a hold, relative to the workspace root; the daemon's
146
304
  * `drainReviewRequests` reads the same file. */
147
305
  export const REVIEW_REQUESTS_BASENAME = 'review-requests.jsonl';
@@ -149,18 +307,68 @@ export const REVIEW_REQUESTS_LOG = `.intutic/events/${REVIEW_REQUESTS_BASENAME}`
149
307
  /** Bumped when a hold record's shape changes; the control plane drops any
150
308
  * other version at ingest. */
151
309
  export const REVIEW_REQUEST_VERSION = 1;
152
- /** The bash classifier: echoes a space-padded token string for `$TOOL`/`$COMMAND`. */
153
- function shellActionClassifier() {
154
- const cases = ACTION_NEEDLES.map(([action, needles]) => ` case "$_c" in ${needles.map((n) => `*"${n}"*`).join('|')}) out="\${out}${action} " ;; esac`).join('\n');
155
- return `intutic_actions() {
156
- local _t _c out=" "
157
- _t="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
158
- case "$_t" in ${[...ACTION_TOOL_NAMES].join('|')}) ;; *) printf ' '; return 0 ;; esac
159
- _c="$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')"
160
- ${cases}
161
- printf '%s' "$out"
162
- }`;
163
- }
310
+ /**
311
+ * The Python the bash gates run, defined once per gate script: the phrase
312
+ * matcher ({@link PHRASES_PY_SOURCE}), the MCP registry decision
313
+ * ({@link MCP_REGISTRY_PY_SOURCE}) and four entry points over them.
314
+ *
315
+ * - `intutic_actions(tool, command)` — the hold classifier, called by
316
+ * {@link SHELL_EXTRACT}'s Python, which already reads the raw tool input:
317
+ * space-padded action tokens (" action:deploy ") or " ".
318
+ * - `intutic_phrase_rule(source, command)` — a `phrase`-subject rule: its
319
+ * source is `|`-separated phrases, each matched as words with boundaries.
320
+ * - `intutic_mcp_registry(record_b64, tool)` — the `@mcp_registry` record's
321
+ * decision on an MCP call.
322
+ * - `intutic_mcp_unverified(tool)` — the refusal of an MCP call on a
323
+ * snapshot that failed its integrity check.
324
+ *
325
+ * The classifier used to be `grep -E` with a gap regex over the normalised
326
+ * command, and bash's own pattern substitution on it took a minute on a long
327
+ * run of backslashes. Python is already required by every bash gate.
328
+ */
329
+ export const GATE_PY_LIB = `${PHRASES_PY_SOURCE}
330
+
331
+ ${MCP_REGISTRY_PY_SOURCE}
332
+
333
+ _INTUTIC_ACTION_NEEDLES = ${JSON.stringify(ACTION_NEEDLES)}
334
+ _INTUTIC_ACTION_TOOLS = ${JSON.stringify(ACTION_TOOL_NAMES)}
335
+
336
+
337
+ def intutic_actions(tool, command):
338
+ if str(tool or "").lower() not in _INTUTIC_ACTION_TOOLS:
339
+ return " "
340
+ words = phrase_text(command)
341
+ out = " "
342
+ for action, needles in _INTUTIC_ACTION_NEEDLES:
343
+ if any(has_phrase(words, n) for n in needles):
344
+ out += action + " "
345
+ return out
346
+
347
+
348
+ def intutic_phrase_rule(source, command):
349
+ words = phrase_text(command)
350
+ return any(has_phrase(words, p, True) for p in source.split("|"))
351
+
352
+
353
+ def intutic_mcp_registry(record_b64, tool):
354
+ # The registry decision for one mcp__<server>__<tool> call, from the
355
+ # snapshot's @mcp_registry record: "code<TAB>ruleId<TAB>reason", or "" to
356
+ # let the call continue.
357
+ call = split_mcp_tool_name(tool)
358
+ registry = decode_registry_record(MCP_REGISTRY_RECORD_TAG + "\\t" + record_b64)
359
+ if call is None or registry is None:
360
+ return ""
361
+ refusal = evaluate_registry(registry, call[0], call[1])
362
+ return "\\t".join(refusal) if refusal else ""
363
+
364
+
365
+ def intutic_mcp_unverified(tool):
366
+ # The refusal of an mcp__<server>__<tool> call on a snapshot that failed
367
+ # its integrity check, as "code<TAB>ruleId<TAB>reason"; "" for a call that
368
+ # is not an MCP call.
369
+ call = split_mcp_tool_name(tool)
370
+ return "\\t".join(unverified_refusal(call[0])) if call else ""
371
+ `;
164
372
  /**
165
373
  * How old a snapshot may be before a gate reports it as stale.
166
374
  *
@@ -182,15 +390,28 @@ function shq(s) {
182
390
  * severity by one and the table still "works".
183
391
  */
184
392
  function shellGuardTable(name, patterns) {
185
- const rows = patterns.map((p) => ` ${shq([p.id, p.severity, p.ignoreCase ? 'i' : '-', p.subject ?? 'any', p.reason, p.source].join('\t'))}`);
393
+ const rows = patterns.map((p) => ` ${shq([p.id, p.severity, ruleFlags(p), p.subject ?? 'any', p.reason, p.source].join('\t'))}`);
186
394
  return `${name}=(\n${rows.join('\n')}\n)`;
187
395
  }
188
- /** Emits a guard table as a JS array literal. */
396
+ /**
397
+ * Emits a guard table as a JS array literal, preceded by the sequence-rule
398
+ * matcher its `seq` entries are compiled with (sequence.ts, emitted as source).
399
+ */
189
400
  function jsGuardTable(name, patterns) {
190
401
  const rows = patterns.map((p) => ` { id: ${JSON.stringify(p.id)}, re: new RegExp(${JSON.stringify(p.source)}` +
191
- `${p.ignoreCase ? ", 'i'" : ''}), severity: ${JSON.stringify(p.severity)}, ` +
402
+ `${p.ignoreCase ? ", 'i'" : ''}), ` +
403
+ `seq: ${p.sequence ? `compileSequence(${JSON.stringify(p.source)}, ${!!p.ignoreCase})` : 'null'}, ` +
404
+ `severity: ${JSON.stringify(p.severity)}, ` +
192
405
  `subject: ${JSON.stringify(p.subject ?? 'any')}, reason: ${JSON.stringify(p.reason)} },`);
193
- return `const ${name} = [\n${rows.join('\n')}\n];`;
406
+ return (`// Sequence rules (flags s): the regex's steps, each searched for once from\n` +
407
+ `// where the previous one ended, so a backtracking engine runs them in\n` +
408
+ `// linear time. Emitted from @intutic/shared-types sequence.ts.\n` +
409
+ `${SEQUENCE_JS_SOURCE}\n` +
410
+ `/** Whether a rule (floor or snapshot) matches one subject string. */\n` +
411
+ `function intuticRuleMatches(rule, subject) {\n` +
412
+ ` return rule.seq ? sequenceMatch(rule.seq, subject) : rule.re.test(subject);\n` +
413
+ `}\n\n` +
414
+ `const ${name} = [\n${rows.join('\n')}\n];`);
194
415
  }
195
416
  /**
196
417
  * The `.rules` snapshot record layout, defined once so the writer and every
@@ -235,7 +456,7 @@ export const RULES_COLUMNS = ['id', 'severity', 'flags', 'subject', 'reason', 's
235
456
  */
236
457
  export function toRulesLine(p) {
237
458
  const reason = p.reason.replace(/[\t\n\r]/g, ' ');
238
- const base = [p.id, p.severity, p.ignoreCase ? 'i' : '-', p.subject ?? 'any', reason, p.source].join('\t');
459
+ const base = [p.id, p.severity, ruleFlags(p), p.subject ?? 'any', reason, p.source].join('\t');
239
460
  // Appended only when present — see RULES_COLUMNS for why base64, and why a
240
461
  // rule without one must serialise byte-identically to the v3 layout.
241
462
  return p.argPattern ? base + '\t' + Buffer.from(p.argPattern, 'utf8').toString('base64') : base;
@@ -272,8 +493,13 @@ export function toRulesLine(p) {
272
493
  * defined by the writer AFTER this prelude, so the trap probes for it before
273
494
  * calling: a crash earlier than the definition still blocks, just without an
274
495
  * audit line (stderr carries the reason either way).
496
+ *
497
+ * It also starts the gate's deadline, which is the harness's own
498
+ * ({@link gateDeadlineMs}).
275
499
  */
276
- export const SHELL_FAIL_CLOSED = `
500
+ export function emitShellFailClosed(harness) {
501
+ const deadlineS = gateDeadlineMs(harness) / 1000;
502
+ return `
277
503
  # ── Intutic fail-closed prelude v${GATE_VERSION} ─────────────────────────────
278
504
  # Any exit status other than the two deliberate verdicts (0 = allow, 2 = block)
279
505
  # means this gate crashed rather than decided. The harness would read exit 1 as
@@ -286,6 +512,7 @@ intutic_fail_closed() {
286
512
  # that would replace the exit status it exists to correct.
287
513
  set +eu
288
514
  trap - EXIT
515
+ [ -n "\${INTUTIC_WATCHDOG_PID:-}" ] && kill "$INTUTIC_WATCHDOG_PID" 2>/dev/null
289
516
  if [ "$_intutic_exit_rc" = "0" ] || [ "$_intutic_exit_rc" = "2" ]; then
290
517
  exit "$_intutic_exit_rc"
291
518
  fi
@@ -296,12 +523,33 @@ intutic_fail_closed() {
296
523
  exit 2
297
524
  }
298
525
  trap intutic_fail_closed EXIT
526
+
527
+ # ── Internal deadline ────────────────────────────────────────────────────────
528
+ # Most harnesses run a call whose hook outlives their timeout as if the hook had
529
+ # allowed it. So this gate refuses at ${deadlineS} s from its own start, inside the
530
+ # hook timeout ${harness} applies (gateLimits.ts): a watchdog, detached so it holds none of
531
+ # the harness's pipes, stops the gate's children (a grep or python3 still
532
+ # matching) and signals it; the trap below refuses. Every built-in rule is
533
+ # linear; a workspace's own WHERE pattern need not be.
534
+ intutic_deadline() {
535
+ trap - USR1
536
+ echo "[Intutic Governance] BLOCKED: GATE_DEADLINE — the gate did not finish evaluating this call within ${deadlineS} s, and refuses it rather than let the hook timeout allow it." >&2
537
+ if command -v log_event >/dev/null 2>&1; then
538
+ log_event "tool_blocked" "\${TOOL:-unknown}" "GATE_DEADLINE — the gate did not finish within ${deadlineS} s" || true
539
+ fi
540
+ exit 2
541
+ }
542
+ trap intutic_deadline USR1
543
+ INTUTIC_GATE_PID=$$
544
+ INTUTIC_WATCHDOG_PID="$( { ( sleep ${deadlineS}; pkill -P "$INTUTIC_GATE_PID" 2>/dev/null; kill -USR1 "$INTUTIC_GATE_PID" 2>/dev/null ) </dev/null >/dev/null 2>&1 & } ; echo $! )"
299
545
  `;
546
+ }
300
547
  /**
301
548
  * The bash gate body.
302
549
  *
303
- * Expects in scope: `$TOOL`, `$TARGET`, `$COMMAND`, and the log function named
304
- * by {@link ShellGateOptions.logFn}. Refuses with `exit 2` — every bash harness
550
+ * Expects in scope: `$TOOL`, `$TARGET` and `$COMMAND` as {@link SHELL_EXTRACT}
551
+ * leaves them, whitespace collapsed to single spaces, and the log function
552
+ * named by {@link ShellGateOptions.logFn}. Refuses with `exit 2` — every bash harness
305
553
  * uses the exit-code contract.
306
554
  */
307
555
  /** Single-quotes a path for bash. */
@@ -309,31 +557,54 @@ function shellQuote(s) {
309
557
  return `'${s.replace(/'/g, `'\\''`)}'`;
310
558
  }
311
559
  /**
312
- * The hold tier's JS helpers, emitted once into every JS-family gate.
313
- *
314
- * `intuticHold` is the one place a hold happens: bypass lookup, hold record,
315
- * the approve hint and the audit line. It returns `{ bypassed: true }` when an
316
- * exact, unexpired approved bypass lets the call through and `{ holdId }` when
317
- * the caller must refuse. Everything is local — no network on the tool path.
560
+ * The JS classifier, `intuticActions(toolName, command)`. Needs
561
+ * `intuticNormalise` in scope. Exported for the classifier's tests.
318
562
  */
319
- function jsHoldHelpers(reviewRequestFile) {
320
- return `const INTUTIC_ACTION_NEEDLES = ${JSON.stringify(ACTION_NEEDLES)};
563
+ export function emitJsActionClassifier() {
564
+ return `// The phrase matcher, emitted from @intutic/shared-types phrases.ts. No
565
+ // regex runs on a command to find words that may be split apart.
566
+ ${PHRASES_JS_SOURCE}
567
+ const INTUTIC_ACTION_NEEDLES = ${JSON.stringify(ACTION_NEEDLES)};
321
568
  const INTUTIC_ACTION_TOOLS = ${JSON.stringify(ACTION_TOOL_NAMES)};
322
- const INTUTIC_REVIEW_REQUEST_FILE = ${JSON.stringify(reviewRequestFile ?? null)};
323
569
 
324
570
  /** Space-padded action tokens for a shell command (" action:deploy "), or " ". */
325
571
  function intuticActions(toolName, command) {
326
572
  if (INTUTIC_ACTION_TOOLS.indexOf(String(toolName || '').toLowerCase()) === -1) return ' ';
327
- var c = String(command || '').toLowerCase(), out = ' ';
573
+ var words = phraseText(command), out = ' ';
328
574
  for (var i = 0; i < INTUTIC_ACTION_NEEDLES.length; i++) {
329
575
  var needles = INTUTIC_ACTION_NEEDLES[i][1];
330
576
  for (var j = 0; j < needles.length; j++) {
331
- if (c.indexOf(needles[j]) !== -1) { out += INTUTIC_ACTION_NEEDLES[i][0] + ' '; break; }
577
+ if (hasPhrase(words, needles[j])) { out += INTUTIC_ACTION_NEEDLES[i][0] + ' '; break; }
332
578
  }
333
579
  }
334
580
  return out;
335
581
  }
336
582
 
583
+ /** A \`phrase\`-subject rule: its source is |-separated phrases matched as words. */
584
+ function intuticPhraseRule(source, words) {
585
+ var phrases = String(source).split('|');
586
+ for (var i = 0; i < phrases.length; i++) if (hasPhrase(words, phrases[i], true)) return true;
587
+ return false;
588
+ }`;
589
+ }
590
+ /**
591
+ * The hold tier's JS helpers, emitted once into every JS-family gate.
592
+ *
593
+ * `intuticHold` is the one place a hold happens: bypass lookup, hold record,
594
+ * the approve hint and the audit line. It returns `{ bypassed: true }` when an
595
+ * exact, unexpired approved bypass lets the call through and `{ holdId }` when
596
+ * the caller must refuse. Everything is local — no network on the tool path.
597
+ */
598
+ function jsHoldHelpers(reviewRequestFile) {
599
+ return `${emitJsActionClassifier()}
600
+ const INTUTIC_REVIEW_REQUEST_FILE = ${JSON.stringify(reviewRequestFile ?? null)};
601
+
602
+ // Who may approve a hold, and when the retry passes: the sentence every gate
603
+ // and the MCP proxy print (holdMessages.ts in shared-types).
604
+ function intuticHoldHint(holdId) {
605
+ return ${JSON.stringify(HOLD_APPROVAL_HINT_TEMPLATE)}.split(${JSON.stringify(HOLD_ID_PLACEHOLDER)}).join(holdId);
606
+ }
607
+
337
608
  function intuticReviewRequestFile() {
338
609
  var path = require('path');
339
610
  if (process.env.INTUTIC_REVIEW_REQUESTS) return process.env.INTUTIC_REVIEW_REQUESTS;
@@ -429,11 +700,23 @@ function intuticHold(rule, toolName, command, target, toolInput, record, workspa
429
700
  try { console.error('[Intutic Guardrail] could not record the hold: ' + (e && e.message ? e.message : e)); } catch (e2) {}
430
701
  }
431
702
  var reason = rule.reason + ' [' + rule.id + ']';
432
- try { console.error('[Intutic Guardrail] HELD: ' + reason + ' Approve with: intutic decision approve ' + holdId + ' (or: intutic decision reject ' + holdId + ')'); } catch (e) {}
703
+ try { console.error('[Intutic Guardrail] HELD: ' + reason + ' ' + intuticHoldHint(holdId)); } catch (e) {}
433
704
  try { record('tool_held', toolName, reason); } catch (e) {}
434
705
  return { holdId: holdId };
435
706
  }`;
436
707
  }
708
+ /**
709
+ * The subjects the bash gate screens ({@link emitShellGate}), each with the
710
+ * variable holding its text. Every subject a rule can test with grep is here;
711
+ * `phrase` rules run in Python and are not screened.
712
+ */
713
+ const SCREEN_SUBJECTS = [
714
+ ['tool', 'INTUTIC_NTOOL'],
715
+ ['command', 'INTUTIC_NCOMMAND'],
716
+ ['target', 'INTUTIC_NTARGET'],
717
+ ['content', 'TOOL_INPUT_JSON'],
718
+ ['action', 'INTUTIC_ACTIONS'],
719
+ ];
437
720
  export function emitShellGate(opts) {
438
721
  const log = opts.logFn ?? 'log_event';
439
722
  const floor = staticFloorPatterns();
@@ -467,7 +750,14 @@ if [ -z "\${1:-}" ] && [ "\${INTUTIC_EXTRACT_STATE:-malformed}" != "ok" ]; then
467
750
  exit 2
468
751
  fi
469
752
 
470
- ${NORMALISE_CONTRACT.shell}
753
+ # Refuse a call too large to evaluate inside the hook timeout, before any rule
754
+ # reads it (the extractor measured it; see gateLimits.ts). Scoped like the
755
+ # envelope refusal above: the post/stop invocations carry no call to gate.
756
+ if [ -z "\${1:-}" ] && [ -n "\${INTUTIC_TOO_LARGE:-}" ]; then
757
+ echo "[Intutic Governance] BLOCKED: COMMAND_TOO_LARGE — \${INTUTIC_TOO_LARGE}" >&2
758
+ ${log} "tool_blocked" "\${TOOL:-unknown}" "COMMAND_TOO_LARGE — \${INTUTIC_TOO_LARGE}" || true
759
+ exit 2
760
+ fi
471
761
 
472
762
  # The static floor: compiled in, always enforced, never sourced from disk.
473
763
  # These are the families with years of evidence behind them, so they block from
@@ -497,12 +787,13 @@ fi
497
787
 
498
788
  INTUTIC_SNAPSHOT_WORKSPACE=""
499
789
  INTUTIC_SNAPSHOT_GENERATED=""
500
- # M3: the per-server MCP allowlist header. Empty means "not configured" —
501
- # indistinguishable from "header absent", deliberately: writePolicySnapshot
502
- # omits the line entirely rather than shipping it with an empty list, so
503
- # empty-list-means-unrestricted holds all the way to the gate.
790
+ # The per-server MCP allowlist (the @mcp_allowlist record, inside the digest).
791
+ # No record means the workspace set no list, and every server is allowed.
792
+ INTUTIC_MCP_ALLOWLIST=0
504
793
  INTUTIC_MCP_SEVERITY=""
505
794
  INTUTIC_MCP_SERVERS=""
795
+ # The MCP server registry record (base64 JSON, inside the digest).
796
+ INTUTIC_MCP_REGISTRY=""
506
797
  if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
507
798
  INTUTIC_SNAPSHOT_STATE="ok"
508
799
  while IFS= read -r _line || [ -n "$_line" ]; do
@@ -510,13 +801,16 @@ if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
510
801
  '#digest '*) INTUTIC_SNAPSHOT_DIGEST="\${_line#\\#digest }" ;;
511
802
  '#workspace '*) INTUTIC_SNAPSHOT_WORKSPACE="\${_line#\\#workspace }" ;;
512
803
  '#generated '*) INTUTIC_SNAPSHOT_GENERATED="\${_line#\\#generated }" ;;
513
- '#mcpservers '*)
514
- # \`#mcpservers <severity> <comma-joined-server-names>\` — parameter
515
- # expansion only, no subshell, matching the other header lines here.
516
- _intutic_mcp_rest="\${_line#\\#mcpservers }"
517
- INTUTIC_MCP_SEVERITY="\${_intutic_mcp_rest%% *}"
518
- INTUTIC_MCP_SERVERS="\${_intutic_mcp_rest#* }"
804
+ '${MCP_ALLOWLIST_RECORD_TAG}'$'\\t'*)
805
+ # Tag, severity and comma-joined servers, tab-separated — parameter
806
+ # expansion only, no subshell. The writer drops a server name holding
807
+ # whitespace or a comma, so neither separator can appear in one.
808
+ INTUTIC_MCP_ALLOWLIST=1
809
+ _intutic_mcp_rest="\${_line#*$'\\t'}"
810
+ INTUTIC_MCP_SEVERITY="\${_intutic_mcp_rest%%$'\\t'*}"
811
+ INTUTIC_MCP_SERVERS="\${_intutic_mcp_rest#*$'\\t'}"
519
812
  ;;
813
+ '${MCP_REGISTRY_RECORD_TAG}'$'\\t'*) INTUTIC_MCP_REGISTRY="\${_line#*$'\\t'}" ;;
520
814
  '#'*|'') : ;;
521
815
  *) INTUTIC_DYNAMIC+=("$_line") ;;
522
816
  esac
@@ -549,9 +843,7 @@ if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
549
843
  fi
550
844
 
551
845
  # Degrade to the compiled floor. The dynamic tier is additive, so dropping it
552
- # returns to yesterday's behaviour rather than opening a hole. The MCP
553
- # allowlist ships through the same file and degrades the same way — an
554
- # invalid snapshot must not leave a stale allowlist enforcing.
846
+ # returns to yesterday's behaviour rather than opening a hole.
555
847
  # The SSO-group refusals survive it: they only ever refuse, and dropping
556
848
  # them would make editing the member's group list in this file a way to
557
849
  # clear a tool the workspace's group policy refuses.
@@ -566,8 +858,10 @@ if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
566
858
  fi
567
859
  INTUTIC_DYNAMIC=()
568
860
  if [ \${#_intutic_kept[@]} -gt 0 ]; then INTUTIC_DYNAMIC=("\${_intutic_kept[@]}"); fi
569
- INTUTIC_MCP_SEVERITY=""
570
- INTUTIC_MCP_SERVERS=""
861
+ # Neither MCP record can be vouched for, and a deleted one looks like one
862
+ # never set: the snapshot admits no MCP server (the refusal below).
863
+ INTUTIC_MCP_REGISTRY=""
864
+ INTUTIC_MCP_ALLOWLIST=0
571
865
  fi
572
866
  fi
573
867
 
@@ -588,15 +882,19 @@ fi
588
882
 
589
883
  case "$INTUTIC_SNAPSHOT_STATE" in
590
884
  absent) ${log} "snapshot_absent" "\${TOOL:-}" "No policy snapshot at $INTUTIC_SNAPSHOT_RULES — built-in protections only" || true ;;
591
- invalid) ${log} "snapshot_invalid" "\${TOOL:-}" "Policy snapshot failed its digest or workspace check — dynamic rules dropped except SSO-group refusals" || true ;;
885
+ invalid) ${log} "snapshot_invalid" "\${TOOL:-}" "Policy snapshot failed its digest or workspace check — dynamic rules dropped except SSO-group refusals, and every MCP call refused" || true ;;
592
886
  empty) ${log} "snapshot_empty" "\${TOOL:-}" "Policy snapshot contains no rules — the compile produced nothing" || true ;;
593
887
  stale) ${log} "snapshot_stale" "\${TOOL:-}" "Policy snapshot is \${_intutic_age_days} days old and still enforced" || true ;;
594
888
  esac
595
889
 
596
- # Normalise once; every pattern is written against this shape.
597
- INTUTIC_NCOMMAND="$(intutic_normalise "\${COMMAND:-}")"
598
- INTUTIC_NTARGET="$(intutic_normalise "\${TARGET:-}")"
599
- INTUTIC_NTOOL="$(intutic_normalise "\${TOOL:-}")"
890
+ # Normalise once; every pattern is written against this shape. The extractor
891
+ # hands each field over with its whitespace already collapsed to single spaces
892
+ # by Python, Unicode whitespace included, which bash cannot match; padding is
893
+ # all that is left to do. Bash substitutions over a 256 KiB command to find no
894
+ # whitespace left took most of a second on a loaded machine.
895
+ INTUTIC_NCOMMAND=" \${COMMAND:-} "
896
+ INTUTIC_NTARGET=" \${TARGET:-} "
897
+ INTUTIC_NTOOL=" \${TOOL:-} "
600
898
 
601
899
  # ── Hold tier (gate body v8) ─────────────────────────────────────────────────
602
900
  # A \`hold\` rule refuses the call and records it for a human; an exact,
@@ -608,7 +906,6 @@ if [ -z "$INTUTIC_REVIEW_REQUEST_FILE" ]; then
608
906
  INTUTIC_REVIEW_REQUEST_FILE=${opts.reviewRequestFile ? shellQuote(opts.reviewRequestFile) : '"$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)/events/review-requests.jsonl"'}
609
907
  fi
610
908
  INTUTIC_APPROVED_BYPASSES="\${INTUTIC_APPROVED_BYPASSES:-$HOME/.intutic/hooks/approved-bypasses.jsonl}"
611
- ${shellActionClassifier()}
612
909
  intutic_sha256() {
613
910
  if command -v shasum >/dev/null 2>&1; then shasum -a 256 | cut -c1-64
614
911
  elif command -v sha256sum >/dev/null 2>&1; then sha256sum | cut -c1-64
@@ -653,8 +950,8 @@ intutic_bypass() {
653
950
  # Returns 0 when an approved bypass lets the call through (the caller
654
951
  # continues), 2 when the call is held (the caller refuses).
655
952
  intutic_hold() {
656
- local rid="$1" rreason="$2" tn th entry decided_by hold_id at file targeth
657
- tn="$(intutic_normalise "\${TOOL:-}")"
953
+ local rid="$1" rreason="$2" tn th entry decided_by hold_id at file targeth hint
954
+ tn="$INTUTIC_NTOOL"
658
955
  th="$( { printf '%s' "$INTUTIC_NCOMMAND"; printf '\\0'; printf '%s' "$INTUTIC_NTARGET"; } | intutic_sha256 )"
659
956
  if entry="$(intutic_bypass "$rid" "$tn" "$th")"; then
660
957
  decided_by="$(printf '%s' "$entry" | sed -n 's/.*"decidedBy":"\\([^"]*\\)".*/\\1/p')"
@@ -676,12 +973,13 @@ intutic_hold() {
676
973
  "$(intutic_json_escape "\${INTUTIC_WORKSPACE_ID:-}")" "$at" "$(intutic_json_escape "$tn")" "$targeth" >> "$file" 2>/dev/null; }; then
677
974
  echo "[Intutic Guardrail] could not record the hold at \${file}" >&2
678
975
  fi
679
- echo "[Intutic Guardrail] HELD: \${rreason} [\${rid}] Approve with: intutic decision approve \${hold_id} (or: intutic decision reject \${hold_id})" >&2
976
+ # Who may approve, and when the retry passes (holdMessages.ts in shared-types).
977
+ hint=${shq(HOLD_APPROVAL_HINT_TEMPLATE)}
978
+ hint="\${hint//\\{holdId\\}/$hold_id}"
979
+ echo "[Intutic Guardrail] HELD: \${rreason} [\${rid}] \${hint}" >&2
680
980
  ${log} "tool_held" "\${TOOL:-}" "\${rreason} [\${rid}]" || true
681
981
  return 2
682
982
  }
683
- # Computed after the classifier is defined; read by the \`action\` subject.
684
- INTUTIC_ACTIONS="$(intutic_actions "\${TOOL:-}" "\${COMMAND:-}")"
685
983
 
686
984
  # A rule declares which part of the call it matches. \`any\` means command and
687
985
  # target — never the tool name, because a pattern like \`\\.claude/settings\\.json\`
@@ -704,31 +1002,59 @@ intutic_apply() {
704
1002
  destructive.*) return 0 ;;
705
1003
  esac
706
1004
  fi
707
- local _subs=()
1005
+ local _subs=() _ci=
1006
+ case "$rflags" in *i*) _ci=i ;; esac
1007
+ # A subject the screen below cleared for this case is left out: no pattern
1008
+ # matched it, this rule's included.
708
1009
  case "$rsubj" in
709
- tool) _subs=("$INTUTIC_NTOOL") ;;
710
- command) _subs=("$INTUTIC_NCOMMAND") ;;
711
- target) _subs=("$INTUTIC_NTARGET") ;;
1010
+ tool) intutic_unscreened tool "$_ci" && _subs=("$INTUTIC_NTOOL") ;;
1011
+ command) intutic_unscreened command "$_ci" && _subs=("$INTUTIC_NCOMMAND") ;;
1012
+ target) intutic_unscreened target "$_ci" && _subs=("$INTUTIC_NTARGET") ;;
712
1013
  # Serialized tool input, un-normalised — same subject the WHERE argPattern
713
1014
  # machinery matches. The secrets.* floor rules ride this. Guaranteed
714
1015
  # non-empty by the extractor (it defaults to "{}").
715
- content) _subs=("$TOOL_INPUT_JSON") ;;
716
- # The space-padded action tokens intutic_actions derived from the command
717
- # (" action:deploy "), so a hold on \`action:deploy\` matches whole tokens.
718
- action) _subs=("$INTUTIC_ACTIONS") ;;
1016
+ content) intutic_unscreened content "$_ci" && _subs=("$TOOL_INPUT_JSON") ;;
1017
+ # The space-padded action tokens the extractor's classifier derived from
1018
+ # the raw command (" action:deploy "), so a hold on \`action:deploy\`
1019
+ # matches whole tokens.
1020
+ action) intutic_unscreened action "$_ci" && _subs=("$INTUTIC_ACTIONS") ;;
1021
+ # The source is |-separated phrases matched as words against the raw
1022
+ # command by the phrase matcher in Python, not a regex: grep is linear,
1023
+ # but the gap between the words was not expressible without one that
1024
+ # backtracks in the other gates. One python3 run, only for these rules.
1025
+ # The command goes in on stdin: as an argument, a long one would exceed
1026
+ # the kernel's per-argument limit and the rule would silently not run.
1027
+ phrase)
1028
+ if python3 -c 'import os, sys, json
1029
+ lib = {}
1030
+ exec(os.environ.get("INTUTIC_PY_LIB", ""), lib)
1031
+ sys.exit(0 if lib["intutic_phrase_rule"](sys.argv[1], json.load(sys.stdin)) else 1)' "$rsrc" <<< "$INTUTIC_RAW_COMMAND_JSON" 2>/dev/null; then
1032
+ _subs=("$INTUTIC_NCOMMAND")
1033
+ else
1034
+ return 0
1035
+ fi
1036
+ ;;
719
1037
  # Each field is tested separately rather than concatenated. Joining them
720
1038
  # lets a pattern match across the seam — a command ending in "chflags" and
721
1039
  # an unrelated target starting with "nouchg" would trip the bypass rule
722
1040
  # together while neither does alone.
723
- *) _subs=("$INTUTIC_NCOMMAND" "$INTUTIC_NTARGET") ;;
1041
+ *)
1042
+ intutic_unscreened command "$_ci" && _subs+=("$INTUTIC_NCOMMAND")
1043
+ intutic_unscreened target "$_ci" && _subs+=("$INTUTIC_NTARGET")
1044
+ ;;
724
1045
  esac
1046
+ [ \${#_subs[@]} -gt 0 ] || return 0
725
1047
  hit=0
1048
+ [ "$rsubj" = "phrase" ] && hit=1
726
1049
  for s in "\${_subs[@]}"; do
727
- if [ "$rflags" = "i" ]; then
728
- printf '%s' "$s" | grep -qiE -- "$rsrc" && hit=1
729
- else
730
- printf '%s' "$s" | grep -qE -- "$rsrc" && hit=1
731
- fi
1050
+ [ "$rsubj" = "phrase" ] && break
1051
+ # Flags: i = case-insensitive; s (a sequence rule) changes nothing here —
1052
+ # grep matches with an automaton, linear in the text, so the regex runs
1053
+ # as written.
1054
+ case "$rflags" in
1055
+ *i*) printf '%s' "$s" | grep -qiE -- "$rsrc" && hit=1 ;;
1056
+ *) printf '%s' "$s" | grep -qE -- "$rsrc" && hit=1 ;;
1057
+ esac
732
1058
  done
733
1059
  [ "$hit" = "1" ] || return 0
734
1060
  # The argument condition of a WHERE rule. The tool-name half has matched; the
@@ -794,6 +1120,51 @@ sys.exit(0 if rx.search(sys.argv[2]) else 1)
794
1120
  exit 2
795
1121
  }
796
1122
 
1123
+ # ── Screening ────────────────────────────────────────────────────────────────
1124
+ # Rule by rule, the gate forks a grep per rule and subject: about a hundred on
1125
+ # every call, which on a loaded machine took seconds before any rule could
1126
+ # match. So each subject is first screened by one grep holding every pattern
1127
+ # that tests it, the case-insensitive ones apart. A subject none of them
1128
+ # matches is left out of every rule that tests it, since no one of them could
1129
+ # match it either. A screen that matches, or one grep cannot run (exit 2),
1130
+ # clears nothing, and the rules decide as before: the screen saves work and
1131
+ # never changes a verdict. grep is still the engine, so the screen is linear too.
1132
+ ${SCREEN_SUBJECTS.map(([key]) => `_intutic_screen_${key}=(); _intutic_screen_${key}i=(); _intutic_clear_${key}=; _intutic_clear_${key}i=`).join('\n')}
1133
+ intutic_screen_add() {
1134
+ case "$1$2" in
1135
+ ${SCREEN_SUBJECTS.flatMap(([key]) => [` ${key}) _intutic_screen_${key}+=(-e "$3") ;;`, ` ${key}i) _intutic_screen_${key}i+=(-e "$3") ;;`]).join('\n')}
1136
+ esac
1137
+ }
1138
+ for _rec in "\${INTUTIC_FLOOR[@]}" \${INTUTIC_DYNAMIC[@]+"\${INTUTIC_DYNAMIC[@]}"}; do
1139
+ IFS=$'\t' read -r _r_id _r_sev _r_flags _r_subj _r_reason _r_src _r_arg <<< "$_rec"
1140
+ [ -n "$_r_src" ] || continue
1141
+ _r_ci=
1142
+ case "$_r_flags" in *i*) _r_ci=i ;; esac
1143
+ case "$_r_subj" in
1144
+ tool|command|target|content|action) intutic_screen_add "$_r_subj" "$_r_ci" "$_r_src" ;;
1145
+ phrase) : ;;
1146
+ *) intutic_screen_add command "$_r_ci" "$_r_src"; intutic_screen_add target "$_r_ci" "$_r_src" ;;
1147
+ esac
1148
+ done
1149
+ # $1 subject, $2 "i" or empty, $3 its text, then the patterns as -e pairs.
1150
+ intutic_screen() {
1151
+ local key="$1" ci="$2" text="$3" rc=0
1152
+ shift 3
1153
+ if [ "$ci" = "i" ]; then printf '%s' "$text" | grep -qiE "$@" || rc=$?
1154
+ else printf '%s' "$text" | grep -qE "$@" || rc=$?
1155
+ fi
1156
+ [ "$rc" != "1" ] || printf -v "_intutic_clear_$key$ci" '%s' 1
1157
+ }
1158
+ ${SCREEN_SUBJECTS.flatMap(([key, text]) => [
1159
+ `[ \${#_intutic_screen_${key}[@]} -eq 0 ] || intutic_screen ${key} '' "$${text}" "\${_intutic_screen_${key}[@]}"`,
1160
+ `[ \${#_intutic_screen_${key}i[@]} -eq 0 ] || intutic_screen ${key} i "$${text}" "\${_intutic_screen_${key}i[@]}"`,
1161
+ ]).join('\n')}
1162
+ # Whether rules testing subject $1, case $2, still need to: not when screened clear.
1163
+ intutic_unscreened() {
1164
+ local v="_intutic_clear_$1$2"
1165
+ [ "\${!v:-}" != "1" ]
1166
+ }
1167
+
797
1168
  for _rec in "\${INTUTIC_FLOOR[@]}"; do
798
1169
  intutic_apply "$_rec"
799
1170
  done
@@ -804,23 +1175,68 @@ if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
804
1175
  done
805
1176
  fi
806
1177
 
807
- # ── M3: MCP per-server allowlist backstop ────────────────────────────────────
808
- # A DEDICATED header field, not a synthetic GuardPattern rule — see
809
- # policySnapshot.ts's module doc and protectedPaths.ts's assertPortableEre:
810
- # expressing "allow only these servers" as a single regex needs negative
811
- # lookahead, which neither \`grep -E\` (POSIX ERE) nor this file's own
812
- # portable-ERE discipline support. So this is a plain string-membership test
813
- # over the parsed \${#mcpservers} header, independent of intutic_apply/the
814
- # GuardPattern tables entirely.
815
- #
816
- # Only fires when a \`mcp__<server>__<tool>\`-shaped tool name was actually
817
- # called AND the header was present (INTUTIC_MCP_SERVERS non-empty — absent
818
- # header means unrestricted, see the header-parsing block above). Refuses
819
- # through the SAME exit-2 + \${log} idiom every other block in this file uses,
820
- # not a parallel mechanism.
1178
+ # ── Unverified snapshot (gate body v17) ──────────────────────────────────────
1179
+ # A snapshot that failed its digest or workspace check admits no MCP server:
1180
+ # neither record in it can be vouched for, and a deleted one looks like one
1181
+ # never set. Refused at block, in an observe-only workspace too, with the code,
1182
+ # rule id and reason of mcpSnapshotUnverifiedRefusal (intutic_mcp_unverified
1183
+ # in GATE_PY_LIB).
1184
+ if [ "$INTUTIC_SNAPSHOT_STATE" = "invalid" ]; then
1185
+ case "\${TOOL:-}" in
1186
+ mcp__*__*)
1187
+ _intutic_unv="$(python3 -c 'import os, sys
1188
+ lib = {}
1189
+ exec(os.environ.get("INTUTIC_PY_LIB", ""), lib)
1190
+ sys.stdout.write(lib["intutic_mcp_unverified"](sys.argv[1]))' "\${TOOL:-}" 2>/dev/null || true)"
1191
+ if [ -n "$_intutic_unv" ]; then
1192
+ IFS=$'\\t' read -r _ _intutic_unv_rid _intutic_unv_reason <<< "$_intutic_unv"
1193
+ else
1194
+ # python3 could not say why (it is required, and checked, before any
1195
+ # rule runs): still a refusal, never an allow.
1196
+ _intutic_unv_rid="policy_snapshot"
1197
+ _intutic_unv_reason="POLICY_SNAPSHOT_UNVERIFIED — the policy snapshot failed its integrity check and admits no MCP server"
1198
+ fi
1199
+ echo "[Intutic Governance] BLOCKED: \${_intutic_unv_reason} [\${_intutic_unv_rid}]" >&2
1200
+ ${log} "tool_blocked" "\${TOOL:-}" "\${_intutic_unv_reason} [\${_intutic_unv_rid}]"
1201
+ exit 2
1202
+ ;;
1203
+ esac
1204
+ fi
1205
+
1206
+ # ── MCP server registry backstop (gate body v14) ─────────────────────────────
1207
+ # The workspace's registry decisions from the snapshot's @mcp_registry record,
1208
+ # applied to every mcp__<server>__<tool> call: a blocked or held server, a
1209
+ # server not approved under mcpDefaultPolicy deny, a disabled tool. The same
1210
+ # decision, rule ids and reasons as the MCP proxy, so a server no proxy fronts
1211
+ # is refused as one it fronts would be. Evaluated in Python (intutic_mcp_registry
1212
+ # in GATE_PY_LIB), which reads the record's JSON; only for MCP-shaped calls.
1213
+ if [ -n "$INTUTIC_MCP_REGISTRY" ]; then
1214
+ case "\${TOOL:-}" in
1215
+ mcp__*__*)
1216
+ _intutic_reg="$(python3 -c 'import os, sys
1217
+ lib = {}
1218
+ exec(os.environ.get("INTUTIC_PY_LIB", ""), lib)
1219
+ sys.stdout.write(lib["intutic_mcp_registry"](sys.argv[1], sys.argv[2]))' "$INTUTIC_MCP_REGISTRY" "\${TOOL:-}" 2>/dev/null || true)"
1220
+ if [ -n "$_intutic_reg" ]; then
1221
+ IFS=$'\\t' read -r _ _intutic_reg_rid _intutic_reg_reason <<< "$_intutic_reg"
1222
+ echo "[Intutic Governance] BLOCKED: \${_intutic_reg_reason} [\${_intutic_reg_rid}]" >&2
1223
+ ${log} "tool_blocked" "\${TOOL:-}" "\${_intutic_reg_reason} [\${_intutic_reg_rid}]"
1224
+ exit 2
1225
+ fi
1226
+ ;;
1227
+ esac
1228
+ fi
1229
+
1230
+ # ── MCP per-server allowlist backstop ────────────────────────────────────────
1231
+ # A dedicated record, not a synthetic GuardPattern rule: "allow only these
1232
+ # servers" as one regex needs negative lookahead, which POSIX ERE (grep -E)
1233
+ # does not have. So this is a plain membership test over the @mcp_allowlist
1234
+ # record, with evaluateMcpAllowlist's code, rule id and reason (the shared
1235
+ # vectors hold this copy to it). Only for an mcp__<server>__<tool> call, and
1236
+ # only when the snapshot carries the record.
821
1237
  case "\${TOOL:-}" in
822
1238
  mcp__*__*)
823
- if [ -n "$INTUTIC_MCP_SERVERS" ]; then
1239
+ if [ "$INTUTIC_MCP_ALLOWLIST" = "1" ]; then
824
1240
  _intutic_mcp_server="\${TOOL#mcp__}"
825
1241
  _intutic_mcp_server="\${_intutic_mcp_server%%__*}"
826
1242
  case ",$INTUTIC_MCP_SERVERS," in
@@ -857,11 +1273,10 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
857
1273
  const fs = require('fs'), os = require('os'), path = require('path');
858
1274
  const p = process.env.INTUTIC_SNAPSHOT_RULES ||
859
1275
  path.join(os.homedir(), '.intutic', 'hooks', 'policy-snapshot.rules');
860
- // M3: mcpServers/mcpSeverity default to unrestricted (empty list) — the
861
- // same "header absent means unrestricted" reading writePolicySnapshot's
862
- // \`#mcpservers\` header is built on.
1276
+ // mcpAllowlist and mcpRegistry stay null without their records: the
1277
+ // workspace set no allowlist and made no registry decision.
863
1278
  const out = { rules: [], digest: 'none', state: 'absent', workspaceId: '', generatedAt: '', ageDays: 0,
864
- mcpServers: [], mcpSeverity: 'block' };
1279
+ mcpAllowlist: null, mcpRegistry: null };
865
1280
  let text;
866
1281
  try { text = fs.readFileSync(p, 'utf8'); } catch (e) { return out; }
867
1282
  out.state = 'ok';
@@ -869,28 +1284,45 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
869
1284
  if (line.startsWith('#digest ')) { out.digest = line.slice(8).trim(); continue; }
870
1285
  if (line.startsWith('#workspace ')) { out.workspaceId = line.slice(11).trim(); continue; }
871
1286
  if (line.startsWith('#generated ')) { out.generatedAt = line.slice(11).trim(); continue; }
872
- if (line.startsWith('#mcpservers ')) {
873
- // \`#mcpservers <severity> <comma-joined-server-names>\` — same header a
874
- // v5 gate would silently ignore via the generic '#'-prefix skip below,
875
- // which is exactly the graceful-degradation contract this format change
876
- // relies on.
877
- const rest = line.slice('#mcpservers '.length);
878
- const sp = rest.indexOf(' ');
879
- if (sp === -1) { out.mcpSeverity = rest.trim(); out.mcpServers = []; }
880
- else {
881
- out.mcpSeverity = rest.slice(0, sp).trim();
882
- out.mcpServers = rest.slice(sp + 1).trim().split(',').filter(Boolean);
1287
+ if (line.startsWith('${MCP_ALLOWLIST_RECORD_TAG}\\t')) {
1288
+ // The MCP server allowlist: tag, severity and comma-joined servers, a
1289
+ // data line inside the digest. Any severity but shadow refuses.
1290
+ const a = line.split('\\t');
1291
+ if (a.length === 3) {
1292
+ out.mcpAllowlist = { severity: a[1] === 'shadow' ? 'shadow' : 'block', servers: a[2].split(',').filter(Boolean) };
883
1293
  }
884
1294
  continue;
885
1295
  }
1296
+ if (line.startsWith('${MCP_REGISTRY_RECORD_TAG}\\t')) {
1297
+ // The MCP server registry record: base64 JSON, a data line inside the
1298
+ // digest. Read into the shape evaluateMcpRegistry expects; a damaged
1299
+ // record is no registry.
1300
+ try {
1301
+ const r = JSON.parse(Buffer.from(line.slice(${MCP_REGISTRY_RECORD_TAG.length + 1}), 'base64').toString('utf8'));
1302
+ const strings = function (v) { return Array.isArray(v) ? v.filter(function (x) { return typeof x === 'string'; }) : []; };
1303
+ const disabled = {};
1304
+ if (r.disabledTools && typeof r.disabledTools === 'object') {
1305
+ for (const k of Object.keys(r.disabledTools)) disabled[k] = strings(r.disabledTools[k]);
1306
+ }
1307
+ out.mcpRegistry = { defaultPolicy: r.defaultPolicy === 'deny' ? 'deny' : 'allow',
1308
+ approvedServers: strings(r.approvedServers), blockedServers: strings(r.blockedServers),
1309
+ heldServers: strings(r.heldServers), disabledTools: disabled };
1310
+ } catch (e) { out.mcpRegistry = null; }
1311
+ continue;
1312
+ }
886
1313
  if (!line || line.startsWith('#')) continue;
887
1314
  const f = line.split('\\t');
888
1315
  if (f.length < 6 || !f[5]) continue;
889
- let re;
1316
+ let re, seq = null;
890
1317
  // A snapshot rule that will not compile is dropped, not fatal. The floor
891
1318
  // above is compiled in and unaffected, so this degrades to "today's
892
- // behaviour" rather than to "no gate".
893
- try { re = new RegExp(f[5], f[2] === 'i' ? 'i' : ''); } catch (e) { continue; }
1319
+ // behaviour" rather than to "no gate". Flags: i = case-insensitive,
1320
+ // s = a sequence rule (see intuticRuleMatches).
1321
+ const ic = f[2].indexOf('i') !== -1;
1322
+ try {
1323
+ re = new RegExp(f[5], ic ? 'i' : '');
1324
+ if (f[2].indexOf('s') !== -1) seq = compileSequence(f[5], ic);
1325
+ } catch (e) { continue; }
894
1326
  // Optional seventh column: the WHERE clause, base64 so an arbitrary regex
895
1327
  // cannot collide with the tab separator. Absent in v3-format files, which
896
1328
  // is exactly "no argument condition". An argPattern that does not decode
@@ -902,7 +1334,7 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
902
1334
  try { argRe = new RegExp(Buffer.from(f[6], 'base64').toString('utf8')); }
903
1335
  catch (e) { argDowngraded = true; }
904
1336
  }
905
- out.rules.push({ id: f[0], severity: f[1], subject: f[3] || 'any', re: re, reason: f[4],
1337
+ out.rules.push({ id: f[0], severity: f[1], subject: f[3] || 'any', re: re, seq: seq, reason: f[4],
906
1338
  argRe: argRe, argDowngraded: argDowngraded });
907
1339
  }
908
1340
 
@@ -912,7 +1344,9 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
912
1344
  try {
913
1345
  const body = text.split('\\n').filter(function (l) { return l && l.charAt(0) !== '#'; }).join('\\n');
914
1346
  const actual = require('crypto').createHash('sha256').update(body).digest('hex').slice(0, 32);
915
- if (out.digest !== 'none' && actual !== out.digest) out.state = 'invalid';
1347
+ // A snapshot with no digest line is unverified too: the daemon always
1348
+ // writes one, so its absence means the file was edited.
1349
+ if (actual !== out.digest) out.state = 'invalid';
916
1350
  } catch (e) { /* no crypto — leave the digest unverified rather than fail the gate */ }
917
1351
 
918
1352
  if (out.state === 'ok' && out.workspaceId && INTUTIC_WORKSPACE_ID && out.workspaceId !== INTUTIC_WORKSPACE_ID) {
@@ -923,15 +1357,16 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
923
1357
  // exactly like a healthy quiet workspace.
924
1358
  if (out.state === 'ok' && out.rules.length === 0) out.state = 'empty';
925
1359
 
926
- // Additive tier, so dropping it returns to yesterday's behaviour. The MCP
927
- // allowlist ships through the same file and degrades the same way — an
928
- // invalid snapshot must not leave a stale allowlist enforcing. The
1360
+ // Additive tier, so dropping it returns to yesterday's behaviour. The
929
1361
  // SSO-group refusals are the exception: they only ever refuse, and dropping
930
1362
  // them would make editing the member's group list in this file a way to
931
1363
  // clear a tool the workspace's group policy refuses.
932
1364
  if (out.state === 'invalid') {
933
1365
  out.rules = out.rules.filter(function (r) { return r.id.indexOf('sso_group.') === 0 && r.severity === 'block'; });
934
- out.mcpServers = [];
1366
+ // Neither MCP record can be vouched for, and a deleted one looks like one
1367
+ // never set: the gate admits no MCP server on this snapshot at all.
1368
+ out.mcpRegistry = null;
1369
+ out.mcpAllowlist = null;
935
1370
  }
936
1371
 
937
1372
  if (out.state === 'ok' && out.generatedAt) {
@@ -954,17 +1389,31 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
954
1389
  */
955
1390
  export function emitJsGate(opts) {
956
1391
  const floor = staticFloorPatterns();
957
- const refuse = opts.contract === 'stdout-cancel'
958
- ? ` process.stdout.write(JSON.stringify({ cancel: true, reason: reason }) + '\\n');\n` +
959
- ` process.exit(0);`
960
- : opts.contract === 'stdout-decision-deny'
961
- ? ` process.stdout.write(JSON.stringify({ decision: 'deny', reason: reason }) + '\\n');\n` +
1392
+ const deadlineMs = gateDeadlineMs(opts.harness);
1393
+ /**
1394
+ * The refusal, through this harness's contract, for a `reason` in scope.
1395
+ * `code` and `ruleId` are JavaScript expressions: a JSON decision carries
1396
+ * them ({@link HOOK_REFUSAL_CODES}), and `extra` adds fields such as `holdId`.
1397
+ */
1398
+ const refuseWith = (code, ruleId, extra = '') => {
1399
+ const fields = `code: ${code}, ruleId: ${ruleId}${extra ? `, ${extra}` : ''}`;
1400
+ if (opts.contract === 'stdout-decision-deny' && opts.documentedFieldsOnly) {
1401
+ return (` process.stdout.write(JSON.stringify({ decision: 'deny', reason: reason + ' (refusal code ' + ${code} + ')' }) + '\\n');\n` +
1402
+ ` process.exit(0);`);
1403
+ }
1404
+ return opts.contract === 'stdout-cancel'
1405
+ // Cline shows a cancel's `errorMessage`; it has no `reason` field.
1406
+ ? ` process.stdout.write(JSON.stringify({ cancel: true, errorMessage: reason, ${fields} }) + '\\n');\n` +
962
1407
  ` process.exit(0);`
963
- : opts.contract === 'throw'
964
- // The envelope guard's reason already carries the prefix; a rule's
965
- // reason does not. One message shape either way.
966
- ? ` throw new Error(String(reason).indexOf('[Intutic Governance]') === 0 ? String(reason) : '[Intutic Governance] BLOCKED: ' + reason);`
967
- : ` process.exit(2);`;
1408
+ : opts.contract === 'stdout-decision-deny'
1409
+ ? ` process.stdout.write(JSON.stringify({ decision: 'deny', reason: reason, ${fields} }) + '\\n');\n` +
1410
+ ` process.exit(0);`
1411
+ : opts.contract === 'throw'
1412
+ // The envelope guard's reason already carries the prefix; a rule's
1413
+ // reason does not. One message shape either way.
1414
+ ? ` throw new Error(String(reason).indexOf('[Intutic Governance]') === 0 ? String(reason) : '[Intutic Governance] BLOCKED: ' + reason);`
1415
+ : ` process.exit(2);`;
1416
+ };
968
1417
  return `
969
1418
  // ── Intutic gate body v${GATE_VERSION} — harness: ${opts.harness} ────────────
970
1419
  // Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
@@ -1013,9 +1462,28 @@ function intuticGuardEnvelope(ctx, keys, record) {
1013
1462
  'refusing rather than allowing a call the gate cannot read.';
1014
1463
  try { console.error(reason); } catch (e) {}
1015
1464
  try { record('tool_blocked', 'unknown', reason); } catch (e) {}
1016
- ${refuse}
1465
+ ${refuseWith("'UNREADABLE_CALL'", 'null')}
1017
1466
  }
1018
1467
 
1468
+ /**
1469
+ * The refusal code of a matched block rule: SSO_GROUP for the group policy's
1470
+ * rules, BUILT_IN_RULE for this gate's compiled floor, SNAPSHOT for the rest
1471
+ * of the policy snapshot.
1472
+ */
1473
+ function intuticRuleCode(rule) {
1474
+ if (rule.id.indexOf('sso_group.') === 0) return 'SSO_GROUP';
1475
+ return INTUTIC_FLOOR.indexOf(rule) !== -1 ? 'BUILT_IN_RULE' : 'SNAPSHOT';
1476
+ }
1477
+
1478
+ // The MCP server registry and allowlist decisions, emitted from
1479
+ // @intutic/shared-types mcpRegistryRecord.ts: the registry decision is the
1480
+ // function the MCP proxy runs.
1481
+ ${MCP_REGISTRY_JS_SOURCE}
1482
+
1483
+ ${MCP_ALLOWLIST_JS_SOURCE}
1484
+
1485
+ ${MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE}
1486
+
1019
1487
  /**
1020
1488
  * Evaluates one tool call. Returns normally to allow; refuses via this harness's
1021
1489
  * contract otherwise.
@@ -1032,6 +1500,47 @@ ${refuse}
1032
1500
  * it itself so no writer can hand it a differently-shaped string.
1033
1501
  */
1034
1502
  function intuticGate(toolName, target, command, record, workspaceId, toolInput, sessionId) {
1503
+ // Too large to evaluate inside the hook timeout (gateLimits.ts in
1504
+ // @intutic/shared-types): refused before any rule reads the call.
1505
+ var _commandBytes = Buffer.byteLength(String(command == null ? '' : command), 'utf8');
1506
+ var _argumentBytes = 0;
1507
+ try { _argumentBytes = Buffer.byteLength(JSON.stringify(toolInput == null ? {} : toolInput) || '', 'utf8'); } catch (e) {}
1508
+ if (_commandBytes > ${COMMAND_SIZE_LIMIT} || _argumentBytes > ${ARGUMENTS_SIZE_LIMIT}) {
1509
+ var reason = 'COMMAND_TOO_LARGE — ' + (_commandBytes > ${COMMAND_SIZE_LIMIT}
1510
+ ? 'the command is ' + _commandBytes + ' bytes, over the ${COMMAND_SIZE_LIMIT}-byte limit a gate evaluates; split it into smaller commands'
1511
+ : 'the tool arguments are ' + _argumentBytes + ' bytes, over the ${ARGUMENTS_SIZE_LIMIT}-byte limit a gate evaluates; write the content in smaller parts');
1512
+ try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
1513
+ try { record('tool_blocked', toolName, reason); } catch (e) {}
1514
+ ${refuseWith("'COMMAND_TOO_LARGE'", 'null')}
1515
+ }
1516
+ // The rules run under a deadline that interrupts even a regex mid-match,
1517
+ // inside the hook timeout ${opts.harness} applies (gateLimits.ts in
1518
+ // @intutic/shared-types), which most harnesses read as an allow. A call
1519
+ // still undecided then is refused. Built-in rules are linear; a workspace's
1520
+ // own WHERE pattern need not be.
1521
+ ${opts.contract === 'throw'
1522
+ ? ` // In process, the deadline runs from the start of this call: the process
1523
+ // started long before it. Pi and OpenCode await the hook with no time limit,
1524
+ // and OpenClaw's hook timeout cannot interrupt synchronous code, so without
1525
+ // this a slow WHERE pattern holds the agent.
1526
+ var _deadlineMs = ${deadlineMs};`
1527
+ : ` // A hook process runs one call, so the deadline runs from process start.
1528
+ var _deadlineMs = Math.max(1, ${deadlineMs} - Math.round(process.uptime() * 1000));`}
1529
+ globalThis.__intuticGateRules = function () {
1530
+ return _intuticGateRules(toolName, target, command, record, workspaceId, toolInput, sessionId);
1531
+ };
1532
+ try {
1533
+ return require('vm').runInThisContext('__intuticGateRules()', { timeout: _deadlineMs });
1534
+ } catch (err) {
1535
+ if (!err || err.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw err;
1536
+ var reason = 'GATE_DEADLINE — the gate did not finish evaluating this call within ${deadlineMs / 1000} s, and refuses it rather than let the hook timeout allow it';
1537
+ try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
1538
+ try { record('tool_blocked', toolName, reason); } catch (e) {}
1539
+ ${refuseWith("'GATE_DEADLINE'", 'null')}
1540
+ }
1541
+ }
1542
+
1543
+ function _intuticGateRules(toolName, target, command, record, workspaceId, toolInput, sessionId) {
1035
1544
  // M3: Cline's \`use_mcp_tool\` envelope, normalized into the
1036
1545
  // \`mcp__<server>__<tool>\` shape every other harness's MCP tool name already
1037
1546
  // takes — BEFORE any rule fires. Cline's own tool-call schema names the
@@ -1072,7 +1581,7 @@ function intuticGate(toolName, target, command, record, workspaceId, toolInput,
1072
1581
  var _msg = snap.state === 'absent'
1073
1582
  ? 'No policy snapshot — built-in protections only'
1074
1583
  : snap.state === 'invalid'
1075
- ? 'Policy snapshot failed its digest or workspace check — dynamic rules dropped except SSO-group refusals'
1584
+ ? 'Policy snapshot failed its digest or workspace check — dynamic rules dropped except SSO-group refusals, and every MCP call refused'
1076
1585
  : snap.state === 'empty'
1077
1586
  ? 'Policy snapshot contains no rules — the compile produced nothing'
1078
1587
  : 'Policy snapshot is ' + snap.ageDays + ' days old and still enforced';
@@ -1091,12 +1600,20 @@ function intuticGate(toolName, target, command, record, workspaceId, toolInput,
1091
1600
  // The space-padded action tokens the command classifies to, for \`action\`
1092
1601
  // subject rules (" action:deploy ").
1093
1602
  const nActions = intuticActions(toolName, command);
1603
+ var words = null;
1094
1604
  const rules = INTUTIC_FLOOR.concat(snap.rules);
1095
1605
  for (const rule of rules) {
1606
+ // A phrase rule's source is |-separated phrases matched as words against
1607
+ // the raw command — never as a regex, which backtracks on crafted input.
1608
+ if (rule.subject === 'phrase') {
1609
+ if (words === null) words = phraseText(command);
1610
+ if (!intuticPhraseRule(rule.re.source, words)) continue;
1611
+ }
1096
1612
  // Each field is tested separately rather than concatenated — joining them
1097
1613
  // lets a pattern match across the seam between two innocuous values.
1098
1614
  const subjects =
1099
- rule.subject === 'tool' ? [nTool]
1615
+ rule.subject === 'phrase' ? [nCommand]
1616
+ : rule.subject === 'tool' ? [nTool]
1100
1617
  : rule.subject === 'command' ? [nCommand]
1101
1618
  : rule.subject === 'target' ? [nTarget]
1102
1619
  // The serialized tool input, un-normalised: a content rule (the
@@ -1107,7 +1624,7 @@ function intuticGate(toolName, target, command, record, workspaceId, toolInput,
1107
1624
  : rule.subject === 'action' ? [nActions]
1108
1625
  : [nCommand, nTarget];
1109
1626
  for (const subject of subjects) {
1110
- if (!rule.re.test(subject)) continue;
1627
+ if (rule.subject !== 'phrase' && !intuticRuleMatches(rule, subject)) continue;
1111
1628
  // The argument condition of a WHERE rule: the tool-name half has
1112
1629
  // matched, and the rule fires only if the argPattern also matches the
1113
1630
  // serialized tool input. A pattern that failed to compile at load time
@@ -1133,8 +1650,8 @@ function intuticGate(toolName, target, command, record, workspaceId, toolInput,
1133
1650
  // otherwise the hold is recorded for review and the call refused.
1134
1651
  const held = intuticHold(rule, toolName, command, target, toolInput, record, workspaceId, sessionId);
1135
1652
  if (held.bypassed) continue;
1136
- const reason = '[Intutic Governance] HELD: ' + rule.reason + ' [' + rule.id + '] — approve with: intutic decision approve ' + held.holdId;
1137
- ${refuse}
1653
+ const reason = '[Intutic Governance] HELD: ' + rule.reason + ' [' + rule.id + '] ' + intuticHoldHint(held.holdId);
1654
+ ${refuseWith("'HELD'", 'rule.id', 'holdId: held.holdId')}
1138
1655
  }
1139
1656
  if (rule.severity === 'warn') {
1140
1657
  // Advisory tier — allowed, recorded with the rule id and the command's
@@ -1156,36 +1673,60 @@ ${refuse}
1156
1673
  const reason = rule.reason + ' [' + rule.id + ']';
1157
1674
  try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
1158
1675
  try { record('tool_blocked', toolName, reason); } catch (e) {}
1159
- ${refuse}
1676
+ ${refuseWith('intuticRuleCode(rule)', 'rule.id')}
1677
+ }
1678
+ }
1679
+
1680
+ // An unverified snapshot admits no MCP server (gate body v17): neither
1681
+ // record in it can be vouched for, and a deleted one looks like one never
1682
+ // set. Refused at block, in an observe-only workspace too.
1683
+ if (snap.state === 'invalid' && toolName.indexOf('mcp__') === 0) {
1684
+ var _unvRest = toolName.slice('mcp__'.length);
1685
+ var _unvSep = _unvRest.indexOf('__');
1686
+ if (_unvSep > 0) {
1687
+ var _unv = mcpSnapshotUnverifiedRefusal(_unvRest.slice(0, _unvSep));
1688
+ var reason = _unv.reason + ' [' + _unv.ruleId + ']';
1689
+ try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
1690
+ try { record('tool_blocked', toolName, reason); } catch (e) {}
1691
+ ${refuseWith('_unv.code', '_unv.ruleId')}
1160
1692
  }
1161
1693
  }
1162
1694
 
1163
- // M3: MCP per-server allowlist backstop — see the shell emitter for why
1164
- // this is a plain membership test over the parsed \`#mcpservers\` header
1165
- // rather than a synthetic GuardPattern rule (no portable regex can express
1166
- // "allow only these servers" without negative lookahead). Only fires when
1167
- // \`toolName\` is actually \`mcp__<server>__<tool>\`-shaped AND the header was
1168
- // present (\`snap.mcpServers.length > 0\` — an absent header means
1169
- // unrestricted). Refuses through the SAME \`record\`/\`\${refuse}\` path as
1170
- // every other block above, not a parallel mechanism.
1171
- if (snap.mcpServers.length > 0 && toolName.indexOf('mcp__') === 0) {
1695
+ // MCP server registry backstop (gate body v14): the workspace's registry
1696
+ // decisions from the snapshot's @mcp_registry record, applied to every
1697
+ // mcp__<server>__<tool> call — the servers no MCP proxy fronts included —
1698
+ // with the proxy's own decision, codes, rule ids and reasons.
1699
+ if (snap.mcpRegistry && toolName.indexOf('mcp__') === 0) {
1700
+ var _regRest = toolName.slice('mcp__'.length);
1701
+ var _regSep = _regRest.indexOf('__');
1702
+ var _reg = _regSep > 0 ? evaluateMcpRegistry(snap.mcpRegistry, _regRest.slice(0, _regSep), _regRest.slice(_regSep + 2)) : null;
1703
+ if (_reg) {
1704
+ var reason = _reg.reason + ' [' + _reg.ruleId + ']';
1705
+ try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
1706
+ try { record('tool_blocked', toolName, reason); } catch (e) {}
1707
+ ${refuseWith('_reg.code', '_reg.ruleId')}
1708
+ }
1709
+ }
1710
+
1711
+ // MCP per-server allowlist backstop: a plain membership test over the
1712
+ // snapshot's @mcp_allowlist record (see the shell emitter for why it is
1713
+ // not a GuardPattern rule), only for an mcp__<server>__<tool> call and only
1714
+ // when the snapshot carries the record.
1715
+ if (snap.mcpAllowlist && toolName.indexOf('mcp__') === 0) {
1172
1716
  var _mcpRest = toolName.slice('mcp__'.length);
1173
1717
  var _mcpSep = _mcpRest.indexOf('__');
1174
- var _mcpServer = _mcpSep >= 0 ? _mcpRest.slice(0, _mcpSep) : '';
1175
- if (_mcpServer && snap.mcpServers.indexOf(_mcpServer) === -1) {
1176
- var _mcpReason = 'MCP server "' + _mcpServer + '" is not on the MCP server allowlist for this workspace [mcp_allowlist]';
1177
- if (snap.mcpSeverity === 'shadow') {
1178
- try { record('tool_would_block', toolName, _mcpReason); } catch (e) {}
1718
+ var _mcp = _mcpSep > 0 ? evaluateMcpAllowlist(snap.mcpAllowlist, _mcpRest.slice(0, _mcpSep)) : null;
1719
+ if (_mcp) {
1720
+ // \`reason\` is what the refusal reads. The rule loop's own \`reason\` is
1721
+ // block-scoped to that loop, so without this the two stdout contracts
1722
+ // and the throw contract would hit a ReferenceError here.
1723
+ var reason = _mcp.reason + ' [' + _mcp.ruleId + ']';
1724
+ if (snap.mcpAllowlist.severity === 'shadow') {
1725
+ try { record('tool_would_block', toolName, reason); } catch (e) {}
1179
1726
  } else {
1180
- try { console.error('[Intutic Governance] BLOCKED: ' + _mcpReason); } catch (e) {}
1181
- try { record('tool_blocked', toolName, _mcpReason); } catch (e) {}
1182
- // \`reason\` is what \`\${refuse}\` reads. The rule loop's own \`reason\` is
1183
- // block-scoped to that loop, so without this the two stdout contracts
1184
- // and the throw contract would hit a ReferenceError here — a refusal
1185
- // that crashes is still a refusal for exit-code gates, and a
1186
- // ReferenceError for the others.
1187
- var reason = _mcpReason;
1188
- ${refuse}
1727
+ try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
1728
+ try { record('tool_blocked', toolName, reason); } catch (e) {}
1729
+ ${refuseWith('_mcp.code', '_mcp.ruleId')}
1189
1730
  }
1190
1731
  }
1191
1732
  }
@@ -1194,7 +1735,7 @@ ${refuse}
1194
1735
  `;
1195
1736
  }
1196
1737
  /**
1197
- * Pre-image capture — the storage half of the rollback enforcement rung (TD-328).
1738
+ * Pre-image capture — the storage half of the rollback enforcement rung.
1198
1739
  *
1199
1740
  * ## Why this exists and why it is here
1200
1741
  *
@@ -1235,7 +1776,7 @@ ${refuse}
1235
1776
  */
1236
1777
  export function emitPreImageCapture() {
1237
1778
  return `
1238
- // ── Intutic pre-image capture (rollback rung, TD-328) ────────────────────────
1779
+ // ── Intutic pre-image capture (rollback rung) ────────────────────────────────
1239
1780
  const _INTUTIC_ROLLBACK_DIR = path.join(process.cwd(), '.intutic', 'rollback');
1240
1781
  const _INTUTIC_MAX_PREIMAGE_BYTES = 2 * 1024 * 1024;
1241
1782
  const _INTUTIC_MAX_PREIMAGE_ENTRIES = 50;
@@ -1380,20 +1921,22 @@ function _intuticCapturePreImage(toolName, input, ruleId) {
1380
1921
  * every emitted gate for real.
1381
1922
  *
1382
1923
  * Refuses through the harness's contract, like everything else: exit 2 means
1383
- * nothing to cline or roo-code, whose harnesses only read stdout — a crashed
1924
+ * nothing to cline, which only reads stdout — a crashed
1384
1925
  * stdout-cancel gate must still print its cancel object.
1385
1926
  */
1386
1927
  export function emitJsFailClosedPrelude(opts) {
1387
1928
  const crashBody = opts.contract === 'stdout-cancel'
1388
1929
  ? ` try {\n` +
1389
- ` process.stdout.write(JSON.stringify({ cancel: true, reason:\n` +
1390
- ` '[Intutic Governance] gate crashed — failing closed: ' + String((err && err.stack) || err) }) + '\\n');\n` +
1930
+ ` process.stdout.write(JSON.stringify({ cancel: true, errorMessage:\n` +
1931
+ ` '[Intutic Governance] gate crashed — failing closed: ' + String((err && err.stack) || err),\n` +
1932
+ ` code: 'GATE_CRASHED', ruleId: null }) + '\\n');\n` +
1391
1933
  ` } catch (e) { /* stdout gone too — nothing left to refuse through */ }\n` +
1392
1934
  ` process.exit(0);`
1393
1935
  : opts.contract === 'stdout-decision-deny'
1394
1936
  ? ` try {\n` +
1395
1937
  ` process.stdout.write(JSON.stringify({ decision: 'deny', reason:\n` +
1396
- ` '[Intutic Governance] gate crashed — failing closed: ' + String((err && err.stack) || err) }) + '\\n');\n` +
1938
+ ` '[Intutic Governance] gate crashed — failing closed: ' + String((err && err.stack) || err)` +
1939
+ (opts.documentedFieldsOnly ? ` + ' (refusal code GATE_CRASHED)' }) + '\\n');\n` : `,\n code: 'GATE_CRASHED', ruleId: null }) + '\\n');\n`) +
1397
1940
  ` } catch (e) { /* stdout gone too — nothing left to refuse through */ }\n` +
1398
1941
  ` process.exit(0);`
1399
1942
  : ` try {\n` +
@@ -1436,8 +1979,16 @@ if ! command -v python3 >/dev/null 2>&1; then
1436
1979
  exit 2
1437
1980
  fi
1438
1981
 
1982
+ # The phrase matcher and the hold classifier (GATE_PY_LIB in gateBody.ts),
1983
+ # for the extractor below and for phrase-subject rules. A quoted heredoc, so
1984
+ # the shell expands nothing in it; read stops at end of input and returns 1.
1985
+ IFS= read -r -d '' INTUTIC_PY_LIB <<'INTUTIC_PY_LIB_END' || true
1986
+ ${GATE_PY_LIB}
1987
+ INTUTIC_PY_LIB_END
1988
+ export INTUTIC_PY_LIB
1989
+
1439
1990
  INTUTIC_FIELDS="$(printf '%s' "$INPUT" | python3 -c '
1440
- import sys, json
1991
+ import os, sys, json
1441
1992
  def clean(v):
1442
1993
  return " ".join(str(v).split()) if isinstance(v, str) else ""
1443
1994
  state = "ok"
@@ -1467,22 +2018,54 @@ def first(*keys):
1467
2018
  if isinstance(v, str) and v:
1468
2019
  return clean(v)
1469
2020
  return ""
2021
+ def raw_first(*keys):
2022
+ for k in keys:
2023
+ v = i.get(k)
2024
+ if isinstance(v, str) and v:
2025
+ return v
2026
+ return ""
2027
+ raw_command = raw_first("command", "cmd", "script", "shell_command")
2028
+ tool_input_json = json.dumps(i, separators=(",", ":"), ensure_ascii=False)
2029
+ # Too large to evaluate inside the hook timeout (gateLimits.ts): the gate body
2030
+ # refuses on this line, and the command and arguments are not passed on.
2031
+ command_bytes = len(raw_command.encode("utf-8", "surrogatepass"))
2032
+ argument_bytes = len(tool_input_json.encode("utf-8", "surrogatepass"))
2033
+ too_large = ""
2034
+ if command_bytes > ${COMMAND_SIZE_LIMIT}:
2035
+ too_large = "the command is %d bytes, over the ${COMMAND_SIZE_LIMIT}-byte limit a gate evaluates; split it into smaller commands" % command_bytes
2036
+ elif argument_bytes > ${ARGUMENTS_SIZE_LIMIT}:
2037
+ too_large = "the tool arguments are %d bytes, over the ${ARGUMENTS_SIZE_LIMIT}-byte limit a gate evaluates; write the content in smaller parts" % argument_bytes
2038
+ if too_large:
2039
+ raw_command = ""
2040
+ tool_input_json = "{}"
2041
+ # The hold classifier reads the raw command: a line continuation or a comment
2042
+ # is still in it. The library is the shared phrase matcher, not a regex.
2043
+ lib = {}
2044
+ try:
2045
+ exec(os.environ.get("INTUTIC_PY_LIB", ""), lib)
2046
+ actions = lib["intutic_actions"](d.get("tool_name", ""), raw_command) if not too_large else " "
2047
+ except Exception:
2048
+ actions = " "
1470
2049
  # The state line comes FIRST: the lines after it may legitimately be empty,
1471
2050
  # and command substitution strips trailing newlines, so the last line is the
1472
2051
  # only position an empty value cannot survive in.
1473
2052
  print(state)
2053
+ print(too_large)
1474
2054
  print(clean(d.get("tool_name", "")))
1475
2055
  print(first("path", "file_path", "notebook_path", "filePath"))
1476
- print(first("command", "cmd", "script", "shell_command"))
2056
+ print("" if too_large else first("command", "cmd", "script", "shell_command"))
1477
2057
  print(clean(d.get("session_id", d.get("sessionId", ""))))
2058
+ print(actions)
2059
+ # The raw command as one JSON line, for phrase-subject rules.
2060
+ print(json.dumps(raw_command, ensure_ascii=False))
1478
2061
  # The FULL tool_input, serialized to the exact shape argPattern rules are
1479
2062
  # matched against everywhere: JSON.stringify(tool_input) — compact separators,
1480
2063
  # insertion order, non-ASCII intact. The selected fields above are whitespace-
1481
2064
  # collapsed for token matching; this one must NOT be, or a WHERE clause that
1482
2065
  # spans a key/value boundary matches here and not in the JS gates. json.dumps
1483
2066
  # escapes every newline, so it is still exactly one line to read back.
1484
- print(json.dumps(i, separators=(",", ":"), ensure_ascii=False))
1485
- ' 2>/dev/null || printf '\\n\\n\\n\\n\\n\\n')"
2067
+ print(tool_input_json)
2068
+ ' 2>/dev/null || printf '\\n\\n\\n\\n\\n\\n\\n\\n\\n')"
1486
2069
 
1487
2070
  # Each read is \`|| true\` because command substitution strips trailing newlines:
1488
2071
  # a tool call with no command argument yields fewer lines than reads, so a late
@@ -1490,10 +2073,15 @@ print(json.dumps(i, separators=(",", ":"), ensure_ascii=False))
1490
2073
  # **exit 1** — which every harness reads as a hook error and lets the call
1491
2074
  # through. A guard that fails open on the most ordinary input there is (a Write
1492
2075
  # with no shell command) is worse than no guard, because it looks present.
1493
- { IFS= read -r INTUTIC_EXTRACT_STATE || true; IFS= read -r TOOL || true; IFS= read -r TARGET || true; IFS= read -r COMMAND || true; IFS= read -r SESSION_ID || true; IFS= read -r TOOL_INPUT_JSON || true; } <<EOF_INTUTIC_FIELDS
2076
+ { IFS= read -r INTUTIC_EXTRACT_STATE || true; IFS= read -r INTUTIC_TOO_LARGE || true; IFS= read -r TOOL || true; IFS= read -r TARGET || true; IFS= read -r COMMAND || true; IFS= read -r SESSION_ID || true; IFS= read -r INTUTIC_ACTIONS || true; IFS= read -r INTUTIC_RAW_COMMAND_JSON || true; IFS= read -r TOOL_INPUT_JSON || true; } <<EOF_INTUTIC_FIELDS
1494
2077
  $INTUTIC_FIELDS
1495
2078
  EOF_INTUTIC_FIELDS
1496
2079
  TOOL="\${TOOL:-}"; TARGET="\${TARGET:-}"; COMMAND="\${COMMAND:-}"; SESSION_ID="\${SESSION_ID:-}"
2080
+ # Space-padded action tokens the extractor's classifier found, or " ".
2081
+ INTUTIC_ACTIONS="\${INTUTIC_ACTIONS:- }"
2082
+ # Why the call is too large to evaluate, or empty (gateLimits.ts).
2083
+ INTUTIC_TOO_LARGE="\${INTUTIC_TOO_LARGE:-}"
2084
+ INTUTIC_RAW_COMMAND_JSON="\${INTUTIC_RAW_COMMAND_JSON:-\"\"}"
1497
2085
  # Empty means the extractor itself died (the fallback printf above): treat it
1498
2086
  # exactly like a payload the parser rejected. The gate body refuses on any
1499
2087
  # value other than "ok" — see the envelope refusal in emitShellGate.
@@ -1532,7 +2120,7 @@ TOOL_NAME="$TOOL"; TARGET_PATH="$TARGET"
1532
2120
  export function emitPythonGate() {
1533
2121
  const floor = staticFloorPatterns();
1534
2122
  const rows = floor.map((p) => ` (${JSON.stringify(p.id)}, ${JSON.stringify(p.source)}, ` +
1535
- `${p.ignoreCase ? 're.IGNORECASE' : '0'}, ${JSON.stringify(p.reason)}),`);
2123
+ `${p.ignoreCase ? 're.IGNORECASE' : '0'}, ${JSON.stringify(p.reason)}, ${p.sequence ? 'True' : 'False'}),`);
1536
2124
  return `
1537
2125
  # ── Intutic gate body v${GATE_VERSION} — harness: open-webui ─────────────────
1538
2126
  # Prompts are flagged, never blocked, by these. See emitPythonGate().
@@ -1544,6 +2132,21 @@ ${rows.join('\n')}
1544
2132
  ${NORMALISE_CONTRACT.pySource}
1545
2133
 
1546
2134
 
2135
+ # The phrase matcher (intutic_clawde/gate/phrases.py), for phrase rules.
2136
+ ${PHRASES_PY_SOURCE}
2137
+
2138
+
2139
+ # The sequence-rule matcher (intutic_clawde/gate/sequence.py), for rules whose
2140
+ # flags carry "s": each step searched for once, so re's backtracking stays linear.
2141
+ ${SEQUENCE_PY_SOURCE}
2142
+
2143
+
2144
+ def _intutic_rule_search(src, subject, flags_re, seq):
2145
+ if seq:
2146
+ return sequence_match(compile_sequence(src, flags_re), subject)
2147
+ return re.search(src, subject, flags_re) is not None
2148
+
2149
+
1547
2150
  _state = {"digest": "", "workspace": ""}
1548
2151
 
1549
2152
 
@@ -1597,7 +2200,7 @@ def _intutic_snapshot_rules():
1597
2200
  # A rule that will not compile is dropped, not fatal.
1598
2201
  if _skip_destructive and f[0].startswith("destructive."):
1599
2202
  continue
1600
- out.append((f[0], f[5], re.IGNORECASE if f[2] == "i" else 0, f[4], f[1]))
2203
+ out.append((f[0], f[5], re.IGNORECASE if "i" in f[2] else 0, f[4], f[1], f[3], "s" in f[2]))
1601
2204
  except Exception:
1602
2205
  continue
1603
2206
  except Exception:
@@ -1609,7 +2212,8 @@ def _intutic_snapshot_rules():
1609
2212
  body = "\\n".join(l.rstrip("\\n") for l in fh
1610
2213
  if l.strip() and not l.startswith("#"))
1611
2214
  actual = hashlib.sha256(body.encode()).hexdigest()[:32]
1612
- if _state["digest"] and actual != _state["digest"]:
2215
+ # No digest line is unverified too: the daemon always writes one.
2216
+ if actual != _state["digest"]:
1613
2217
  return []
1614
2218
  except Exception:
1615
2219
  pass
@@ -1622,15 +2226,23 @@ def _intutic_evaluate(text):
1622
2226
  """Returns (blocks, flags) for a prompt. Floor rules can only flag."""
1623
2227
  subject = _intutic_normalise(text)
1624
2228
  blocks, flags, shadowed = [], [], []
1625
- for rid, src, flags_re, reason in _INTUTIC_FLOOR:
2229
+ for rid, src, flags_re, reason, seq in _INTUTIC_FLOOR:
1626
2230
  try:
1627
- if re.search(src, subject, flags_re):
2231
+ if _intutic_rule_search(src, subject, flags_re, seq):
1628
2232
  flags.append((rid, reason))
1629
2233
  except Exception:
1630
2234
  continue
1631
- for rid, src, flags_re, reason, severity in _intutic_snapshot_rules():
2235
+ words = None
2236
+ for rid, src, flags_re, reason, severity, rsubj, seq in _intutic_snapshot_rules():
1632
2237
  try:
1633
- if not re.search(src, subject, flags_re):
2238
+ if rsubj == "phrase":
2239
+ # |-separated phrases matched as words (phrases.py), never as
2240
+ # a regex, which backtracks on crafted text.
2241
+ if words is None:
2242
+ words = phrase_text(text)
2243
+ if not any(has_phrase(words, p, True) for p in src.split("|")):
2244
+ continue
2245
+ elif not _intutic_rule_search(src, subject, flags_re, seq):
1634
2246
  continue
1635
2247
  except Exception:
1636
2248
  continue
@@ -1701,8 +2313,14 @@ ${jsGuardTable('INTUTIC_FLOOR', floor)}
1701
2313
  // had already drifted from the in-process one on null handling.
1702
2314
  ${NORMALISE_CONTRACT.jsSource}
1703
2315
 
2316
+ // The phrase matcher (@intutic/shared-types phrases.ts), for phrase rules.
2317
+ ${PHRASES_JS_SOURCE}
2318
+
1704
2319
  ${JS_SNAPSHOT_LOADER}
1705
2320
 
2321
+ // The refusal of an MCP call on an unverified snapshot (mcpRegistryRecord.ts).
2322
+ ${MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE}
2323
+
1706
2324
  /**
1707
2325
  * Evaluates one workflow. Returns normally to allow; throws to abort the
1708
2326
  * execution. \`record\` is called as (verdict, toolName, reason), the same
@@ -1726,7 +2344,7 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
1726
2344
  var _msg = snap.state === 'absent'
1727
2345
  ? 'No policy snapshot — built-in protections only'
1728
2346
  : snap.state === 'invalid'
1729
- ? 'Policy snapshot failed its digest or workspace check — dynamic rules dropped except SSO-group refusals'
2347
+ ? 'Policy snapshot failed its digest or workspace check — dynamic rules dropped except SSO-group refusals, and every MCP call refused'
1730
2348
  : snap.state === 'empty'
1731
2349
  ? 'Policy snapshot contains no rules — the compile produced nothing'
1732
2350
  : 'Policy snapshot is ' + snap.ageDays + ' days old and still enforced';
@@ -1763,6 +2381,18 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
1763
2381
  if (!node || typeof node !== 'object') continue;
1764
2382
  const nodeType = String(node.type || '');
1765
2383
  const nodeName = String(node.name || nodeType || 'node');
2384
+ // An MCP client node (n8n's MCP Client and MCP Client Tool, and the
2385
+ // community package's) calls tools on an MCP server. A snapshot that
2386
+ // failed its integrity check admits no MCP server (gate body v17), so a
2387
+ // workflow that would call one is refused, observe-only or not. The node
2388
+ // stands in for the server: n8n names it, the URL it reaches does not.
2389
+ if (snap.state === 'invalid' && /^mcpclient/i.test(nodeType.slice(nodeType.lastIndexOf('.') + 1))) {
2390
+ const _unv = mcpSnapshotUnverifiedRefusal(nodeName);
2391
+ const reason = _unv.reason + ' [' + _unv.ruleId + '] (node "' + nodeName + '", type ' + nodeType + ')';
2392
+ try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
2393
+ try { record('tool_blocked', 'n8n:' + nodeName, reason); } catch (e) {}
2394
+ throw new Error('[Intutic Governance] BLOCKED: ' + reason);
2395
+ }
1766
2396
  // Compact stringify, insertion order, non-ASCII intact — the same shape
1767
2397
  // matchSopRule and every per-tool gate pin for argPattern matching.
1768
2398
  var paramsJson = '{}';
@@ -1795,13 +2425,21 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
1795
2425
  if (v && typeof v === 'object') { for (const k in v) _walk(v[k]); }
1796
2426
  })(node.parameters == null ? {} : node.parameters);
1797
2427
  const nLeaves = _leaves.map(intuticNormalise);
2428
+ var leafWords = null;
1798
2429
  for (const rule of rules) {
2430
+ // A phrase rule's source is |-separated phrases matched as words in each
2431
+ // parameter string — never as a regex, which backtracks on crafted input.
2432
+ if (rule.subject === 'phrase') {
2433
+ if (leafWords === null) leafWords = _leaves.map(phraseText);
2434
+ var _phrases = rule.re.source.split('|');
2435
+ if (!leafWords.some(function (w) { return _phrases.some(function (p) { return hasPhrase(w, p, true); }); })) continue;
2436
+ }
1799
2437
  // A node has no separate command/target — its parameters are both. A
1800
2438
  // 'tool' rule matches the node TYPE and nothing else, for the same
1801
2439
  // reason the per-tool gates never test a path pattern against "Write".
1802
- const subjects = rule.subject === 'tool' ? [nType, nTypeBase] : [nParams].concat(nLeaves);
2440
+ const subjects = rule.subject === 'phrase' ? [nParams] : rule.subject === 'tool' ? [nType, nTypeBase] : [nParams].concat(nLeaves);
1803
2441
  for (const subject of subjects) {
1804
- if (!rule.re.test(subject)) continue;
2442
+ if (rule.subject !== 'phrase' && !intuticRuleMatches(rule, subject)) continue;
1805
2443
  if (rule.argDowngraded) {
1806
2444
  try {
1807
2445
  record('rule_downgraded', 'n8n:' + nodeName,