@intutic/sync-daemon 1.10.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +2 -2
  2. package/dist/agentReporter.d.ts +205 -0
  3. package/dist/agentReporter.d.ts.map +1 -0
  4. package/dist/agentReporter.js +394 -0
  5. package/dist/agentReporter.js.map +1 -0
  6. package/dist/configReader.d.ts +39 -2
  7. package/dist/configReader.d.ts.map +1 -1
  8. package/dist/configReader.js +56 -2
  9. package/dist/configReader.js.map +1 -1
  10. package/dist/configWriter.d.ts +51 -1
  11. package/dist/configWriter.d.ts.map +1 -1
  12. package/dist/configWriter.js +490 -63
  13. package/dist/configWriter.js.map +1 -1
  14. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  15. package/dist/harness/antigravityHooks.js +33 -55
  16. package/dist/harness/antigravityHooks.js.map +1 -1
  17. package/dist/harness/claudeCodeHooks.d.ts +47 -13
  18. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  19. package/dist/harness/claudeCodeHooks.js +316 -54
  20. package/dist/harness/claudeCodeHooks.js.map +1 -1
  21. package/dist/harness/claudeDesktopHooks.d.ts.map +1 -1
  22. package/dist/harness/claudeDesktopHooks.js +35 -48
  23. package/dist/harness/claudeDesktopHooks.js.map +1 -1
  24. package/dist/harness/clineHooks.d.ts.map +1 -1
  25. package/dist/harness/clineHooks.js +42 -34
  26. package/dist/harness/clineHooks.js.map +1 -1
  27. package/dist/harness/codexHooks.d.ts +36 -0
  28. package/dist/harness/codexHooks.d.ts.map +1 -0
  29. package/dist/harness/codexHooks.js +227 -0
  30. package/dist/harness/codexHooks.js.map +1 -0
  31. package/dist/harness/continueHooks.d.ts +45 -5
  32. package/dist/harness/continueHooks.d.ts.map +1 -1
  33. package/dist/harness/continueHooks.js +225 -5
  34. package/dist/harness/continueHooks.js.map +1 -1
  35. package/dist/harness/cursorHooks.d.ts +31 -0
  36. package/dist/harness/cursorHooks.d.ts.map +1 -1
  37. package/dist/harness/cursorHooks.js +78 -49
  38. package/dist/harness/cursorHooks.js.map +1 -1
  39. package/dist/harness/dshHooks.d.ts +198 -0
  40. package/dist/harness/dshHooks.d.ts.map +1 -0
  41. package/dist/harness/dshHooks.js +569 -0
  42. package/dist/harness/dshHooks.js.map +1 -0
  43. package/dist/harness/gateBody.d.ts +422 -0
  44. package/dist/harness/gateBody.d.ts.map +1 -0
  45. package/dist/harness/gateBody.js +1816 -0
  46. package/dist/harness/gateBody.js.map +1 -0
  47. package/dist/harness/gateKind.d.ts +164 -0
  48. package/dist/harness/gateKind.d.ts.map +1 -0
  49. package/dist/harness/gateKind.js +143 -0
  50. package/dist/harness/gateKind.js.map +1 -0
  51. package/dist/harness/githubCopilotHooks.d.ts +54 -0
  52. package/dist/harness/githubCopilotHooks.d.ts.map +1 -0
  53. package/dist/harness/githubCopilotHooks.js +218 -0
  54. package/dist/harness/githubCopilotHooks.js.map +1 -0
  55. package/dist/harness/gooseHooks.d.ts.map +1 -1
  56. package/dist/harness/gooseHooks.js +67 -37
  57. package/dist/harness/gooseHooks.js.map +1 -1
  58. package/dist/harness/grokHooks.d.ts +77 -0
  59. package/dist/harness/grokHooks.d.ts.map +1 -0
  60. package/dist/harness/grokHooks.js +350 -0
  61. package/dist/harness/grokHooks.js.map +1 -0
  62. package/dist/harness/hermesHooks.d.ts.map +1 -1
  63. package/dist/harness/hermesHooks.js +26 -55
  64. package/dist/harness/hermesHooks.js.map +1 -1
  65. package/dist/harness/holdRedaction.d.ts +69 -0
  66. package/dist/harness/holdRedaction.d.ts.map +1 -0
  67. package/dist/harness/holdRedaction.js +211 -0
  68. package/dist/harness/holdRedaction.js.map +1 -0
  69. package/dist/harness/jetbrainsXmlConfig.d.ts +51 -0
  70. package/dist/harness/jetbrainsXmlConfig.d.ts.map +1 -0
  71. package/dist/harness/jetbrainsXmlConfig.js +151 -0
  72. package/dist/harness/jetbrainsXmlConfig.js.map +1 -0
  73. package/dist/harness/mcpAutoWrite.d.ts +100 -3
  74. package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
  75. package/dist/harness/mcpAutoWrite.js +712 -90
  76. package/dist/harness/mcpAutoWrite.js.map +1 -1
  77. package/dist/harness/museHooks.d.ts +53 -0
  78. package/dist/harness/museHooks.d.ts.map +1 -0
  79. package/dist/harness/museHooks.js +273 -0
  80. package/dist/harness/museHooks.js.map +1 -0
  81. package/dist/harness/n8nHooks.d.ts +25 -9
  82. package/dist/harness/n8nHooks.d.ts.map +1 -1
  83. package/dist/harness/n8nHooks.js +174 -9
  84. package/dist/harness/n8nHooks.js.map +1 -1
  85. package/dist/harness/openCodeHooks.d.ts +93 -0
  86. package/dist/harness/openCodeHooks.d.ts.map +1 -0
  87. package/dist/harness/openCodeHooks.js +248 -0
  88. package/dist/harness/openCodeHooks.js.map +1 -0
  89. package/dist/harness/openWebuiHooks.d.ts.map +1 -1
  90. package/dist/harness/openWebuiHooks.js +56 -11
  91. package/dist/harness/openWebuiHooks.js.map +1 -1
  92. package/dist/harness/openclawHooks.d.ts.map +1 -1
  93. package/dist/harness/openclawHooks.js +30 -54
  94. package/dist/harness/openclawHooks.js.map +1 -1
  95. package/dist/harness/openhandsHooks.d.ts.map +1 -1
  96. package/dist/harness/openhandsHooks.js +35 -41
  97. package/dist/harness/openhandsHooks.js.map +1 -1
  98. package/dist/harness/piHooks.d.ts.map +1 -1
  99. package/dist/harness/piHooks.js +31 -58
  100. package/dist/harness/piHooks.js.map +1 -1
  101. package/dist/harness/protectedPaths.d.ts +353 -0
  102. package/dist/harness/protectedPaths.d.ts.map +1 -0
  103. package/dist/harness/protectedPaths.js +805 -0
  104. package/dist/harness/protectedPaths.js.map +1 -0
  105. package/dist/harness/rooCodeHooks.d.ts.map +1 -1
  106. package/dist/harness/rooCodeHooks.js +34 -48
  107. package/dist/harness/rooCodeHooks.js.map +1 -1
  108. package/dist/harness/windsurfHooks.d.ts +72 -0
  109. package/dist/harness/windsurfHooks.d.ts.map +1 -1
  110. package/dist/harness/windsurfHooks.js +172 -58
  111. package/dist/harness/windsurfHooks.js.map +1 -1
  112. package/dist/harness/windsurfJetBrainsProxy.d.ts +78 -0
  113. package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -0
  114. package/dist/harness/windsurfJetBrainsProxy.js +159 -0
  115. package/dist/harness/windsurfJetBrainsProxy.js.map +1 -0
  116. package/dist/hashReporter.js +1 -1
  117. package/dist/hashReporter.js.map +1 -1
  118. package/dist/index.d.ts +12 -6
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +14 -6
  121. package/dist/index.js.map +1 -1
  122. package/dist/integrityStore.d.ts +0 -8
  123. package/dist/integrityStore.d.ts.map +1 -1
  124. package/dist/integrityStore.js +6 -29
  125. package/dist/integrityStore.js.map +1 -1
  126. package/dist/lib/approvedBypasses.d.ts +76 -0
  127. package/dist/lib/approvedBypasses.d.ts.map +1 -0
  128. package/dist/lib/approvedBypasses.js +140 -0
  129. package/dist/lib/approvedBypasses.js.map +1 -0
  130. package/dist/lib/complianceProbes.d.ts.map +1 -1
  131. package/dist/lib/complianceProbes.js +5 -0
  132. package/dist/lib/complianceProbes.js.map +1 -1
  133. package/dist/lib/decisionsDigest.d.ts +99 -0
  134. package/dist/lib/decisionsDigest.d.ts.map +1 -0
  135. package/dist/lib/decisionsDigest.js +199 -0
  136. package/dist/lib/decisionsDigest.js.map +1 -0
  137. package/dist/lib/egressPolicy.d.ts +69 -0
  138. package/dist/lib/egressPolicy.d.ts.map +1 -0
  139. package/dist/lib/egressPolicy.js +126 -0
  140. package/dist/lib/egressPolicy.js.map +1 -0
  141. package/dist/lib/gitWorktrees.d.ts +140 -0
  142. package/dist/lib/gitWorktrees.d.ts.map +1 -0
  143. package/dist/lib/gitWorktrees.js +191 -0
  144. package/dist/lib/gitWorktrees.js.map +1 -0
  145. package/dist/lib/policySnapshot.d.ts +224 -0
  146. package/dist/lib/policySnapshot.d.ts.map +1 -0
  147. package/dist/lib/policySnapshot.js +637 -0
  148. package/dist/lib/policySnapshot.js.map +1 -0
  149. package/dist/lib/processPoller.d.ts +44 -0
  150. package/dist/lib/processPoller.d.ts.map +1 -1
  151. package/dist/lib/processPoller.js +176 -1
  152. package/dist/lib/processPoller.js.map +1 -1
  153. package/dist/lib/runtimeEnv.d.ts +8 -0
  154. package/dist/lib/runtimeEnv.d.ts.map +1 -1
  155. package/dist/lib/runtimeEnv.js +3 -0
  156. package/dist/lib/runtimeEnv.js.map +1 -1
  157. package/dist/localTrajectoryHeuristics.d.ts +51 -0
  158. package/dist/localTrajectoryHeuristics.d.ts.map +1 -0
  159. package/dist/localTrajectoryHeuristics.js +90 -0
  160. package/dist/localTrajectoryHeuristics.js.map +1 -0
  161. package/dist/sessionReporter.d.ts +70 -0
  162. package/dist/sessionReporter.d.ts.map +1 -0
  163. package/dist/sessionReporter.js +140 -0
  164. package/dist/sessionReporter.js.map +1 -0
  165. package/dist/skillWriter.d.ts +1 -1
  166. package/dist/skillWriter.d.ts.map +1 -1
  167. package/dist/skillWriter.js +5 -2
  168. package/dist/skillWriter.js.map +1 -1
  169. package/dist/syncLoop.d.ts +105 -3
  170. package/dist/syncLoop.d.ts.map +1 -1
  171. package/dist/syncLoop.js +397 -65
  172. package/dist/syncLoop.js.map +1 -1
  173. package/dist/trajectoryMonitor.d.ts +2 -0
  174. package/dist/trajectoryMonitor.d.ts.map +1 -1
  175. package/dist/trajectoryMonitor.js +57 -0
  176. package/dist/trajectoryMonitor.js.map +1 -1
  177. package/dist/watcher/driftWatcher.d.ts.map +1 -1
  178. package/dist/watcher/driftWatcher.js +12 -3
  179. package/dist/watcher/driftWatcher.js.map +1 -1
  180. package/dist/watcher/settingsGuard.d.ts +23 -0
  181. package/dist/watcher/settingsGuard.d.ts.map +1 -1
  182. package/dist/watcher/settingsGuard.js +202 -17
  183. package/dist/watcher/settingsGuard.js.map +1 -1
  184. package/package.json +37 -6
  185. package/dist/brainIndexer.d.ts +0 -51
  186. package/dist/brainIndexer.d.ts.map +0 -1
  187. package/dist/brainIndexer.js +0 -148
  188. package/dist/brainIndexer.js.map +0 -1
  189. package/dist/harness/cursorHooksJson.d.ts +0 -29
  190. package/dist/harness/cursorHooksJson.d.ts.map +0 -1
  191. package/dist/harness/cursorHooksJson.js +0 -130
  192. package/dist/harness/cursorHooksJson.js.map +0 -1
  193. package/dist/harness/gooseHooksWriter.d.ts +0 -27
  194. package/dist/harness/gooseHooksWriter.d.ts.map +0 -1
  195. package/dist/harness/gooseHooksWriter.js +0 -147
  196. package/dist/harness/gooseHooksWriter.js.map +0 -1
  197. package/dist/harness/openhandsHooksWriter.d.ts +0 -23
  198. package/dist/harness/openhandsHooksWriter.d.ts.map +0 -1
  199. package/dist/harness/openhandsHooksWriter.js +0 -146
  200. package/dist/harness/openhandsHooksWriter.js.map +0 -1
@@ -0,0 +1,1816 @@
1
+ /**
2
+ * The gate evaluator, emitted once per language instead of written thirteen
3
+ * times.
4
+ *
5
+ * # Why this file exists
6
+ *
7
+ * Every harness writer used to hand-author its own copy of the decision logic
8
+ * inside a TypeScript template literal. Four production defects came directly
9
+ * out of that arrangement, and each one silently removed enforcement while every
10
+ * test stayed green:
11
+ *
12
+ * - `claudeCodeHooks` emitted `...UNIVERSAL_PROTECTED_PATHS,` as *literal source
13
+ * text* into a script that never imported it. Node threw `ReferenceError`
14
+ * before the gate decided anything — and a Claude Code PreToolUse hook exiting
15
+ * 1 is a hook *error*, not a block, so the call proceeded.
16
+ * - `INTUITIC_` for `INTUTIC_`: the gate died on an unbound variable under
17
+ * `set -u`, exiting 1 rather than 2 — again, not a block.
18
+ * - Five bash writers wrote `\"` where `\\"` was needed. Bash ate the quotes,
19
+ * the audit line stopped being parseable JSON, and `drainHookEvents` drops
20
+ * malformed lines and then *wipes the log* on a successful drain. The record
21
+ * of a block was destroyed rather than delayed.
22
+ * - `gooseHooks`/`openhandsHooks` never extracted a command at all, so neither
23
+ * had a shell-bypass guard — for their entire life.
24
+ *
25
+ * The common cause is not carelessness. It is that shell and JavaScript embedded
26
+ * in a template literal have three levels of escaping and no compiler checking
27
+ * any of them. Emitting from one place does not make the escaping easier; it
28
+ * makes there be one of it, with one set of tests.
29
+ *
30
+ * # The contract with a writer
31
+ *
32
+ * # One editing hazard, since it bit four times
33
+ *
34
+ * A backtick inside a comment in an emitted body ends the TypeScript template
35
+ * literal it lives in, and the rest of the gate becomes TypeScript. `tsc` does
36
+ * catch it — but as a syntax error dozens of lines away, which reads as
37
+ * unrelated damage. Use plain quotes in comments inside the emitted strings.
38
+ *
39
+ * A lint for this was tried and removed: a line-based scan cannot cheaply tell
40
+ * a comment inside a template literal from one outside it, and it produced 26
41
+ * false positives on this file's own prose. The compiler is the right detector;
42
+ * this note is the right documentation.
43
+ *
44
+ * A writer supplies the harness-specific parts — how to read the tool call, how
45
+ * to record an event, how to refuse — and interpolates the bodies below. See
46
+ * {@link ShellGateOptions} and {@link JsGateOptions} for exactly what must be in
47
+ * scope.
48
+ *
49
+ * @module
50
+ */
51
+ import { DESTRUCTIVE_COMMAND_PATTERNS, NORMALISE_CONTRACT, staticFloorPatterns, } from './protectedPaths.js';
52
+ /**
53
+ * Bumped when the emitted evaluator changes shape.
54
+ *
55
+ * Stamped into the header of every emitted gate and into the policy snapshot, so
56
+ * the version a machine is actually running can be read off the artifact rather
57
+ * than inferred. The previous docstring claimed it "rides on every audit line",
58
+ * which was simply untrue — nothing emitted it anywhere, and a constant whose
59
+ * comment describes behaviour it does not have is worse than no constant.
60
+ *
61
+ * v4: the `.rules` projection grew an `argPatternB64` column and every emitted
62
+ * evaluator learned to condition a matched rule on the serialized tool input
63
+ * (` WHERE ` SOP rules). Nothing *triggers* on this number: the daemon
64
+ * regenerates every gate unconditionally each sync cycle, the snapshot writer
65
+ * stamps it into `policy-snapshot.json` for support conversations, and
66
+ * `gateLivenessService` records it off events when a gate reports one. A v3
67
+ * gate reading a v4 snapshot and a v4 gate reading a v3 snapshot both degrade
68
+ * to v3 behaviour — see the encoding note on {@link RULES_COLUMNS}.
69
+ *
70
+ * v5: the residual fail-open edges were closed. Every bash gate now converts
71
+ * 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
73
+ * payload from which neither a tool name nor a tool_input could be extracted
74
+ * (the envelope posture githubCopilotHooks established, now uniform); every
75
+ * JS gate installs `uncaughtException`/`unhandledRejection` handlers as its
76
+ * first statements ({@link emitJsFailClosedPrelude}) so a crash outside the
77
+ * stdin handler refuses through the harness's own contract instead of exiting
78
+ * 1 (which every exit-code harness reads as "hook errored — allow"); and the
79
+ * malformed-envelope refusal is shared across the JS writers via
80
+ * `intuticGuardEnvelope`. The `.rules` format is unchanged — v4 and v5 gates
81
+ * read each other's snapshots byte-identically.
82
+ *
83
+ * v6 (M3): `#mcpservers` header parsing + `mcp__<server>__*` allowlist
84
+ * refusal + Cline `use_mcp_tool` envelope normalization; rule-line format
85
+ * itself is unchanged. This IS a `.rules` shape change, unlike v5 (which only
86
+ * closed fail-open edges) — a new header line means:
87
+ *
88
+ * - a v5 gate reading a v6 snapshot ignores the unknown `#mcpservers` header
89
+ * (every existing header-parsing loop already falls through unrecognised
90
+ * `#`-prefixed lines to the comment case) and degrades to v5 behaviour: no
91
+ * allowlist enforcement, but no crash and no false-block either;
92
+ * - a v6 gate reading a v5 snapshot (no `#mcpservers` header present) also
93
+ * behaves as unrestricted — the empty-list default — because "header
94
+ * absent" and "header present with an empty list" are deliberately the same
95
+ * observable state (`writePolicySnapshot` omits the line rather than
96
+ * emitting it empty, precisely so a v6 gate cannot tell "no snapshot
97
+ * configured a list yet" from "this workspace wants zero servers", and
98
+ * picks the fail-open reading of that ambiguity rather than blocking every
99
+ * MCP call the first time a v6 gate meets an old snapshot).
100
+ *
101
+ * v8 (TD-474 item 4): the `hold` severity and the `action` subject. A hold
102
+ * rule — a `require_approval` SOP rule the control plane resolves, or a local
103
+ * `review_before:` token the daemon compiles as `sop.local.review_before.*` —
104
+ * refuses the call through the harness's own contract, appends the v1 hold
105
+ * record `drainReviewRequests` already reads, prints the `intutic decision
106
+ * approve <holdId>` hint, and lets the call through instead when the
107
+ * daemon's approved-bypass cache holds an exact, unexpired match for it. One
108
+ * mechanism in the shared bodies; the Claude Code writer's bespoke copy is
109
+ * gone. The `.rules` line format is unchanged — `hold` is just a new value in
110
+ * the severity column — so a v7 JS or bash gate reading a v8 snapshot falls
111
+ * through its severity switch to BLOCK (fail closed, no hold record, no
112
+ * bypass). The v7 Python filter is the one exception: its mapping sent every
113
+ * unrecognised severity to `flags`, i.e. ALLOWED, which is why the v8 filter
114
+ * maps unknown to block and why the daemon regenerates every gate each cycle —
115
+ * the skew window is one sync interval.
116
+ */
117
+ export const GATE_VERSION = 8;
118
+ /**
119
+ * The coarse command → action-token classification the hold tier keys on:
120
+ * `review_before: action:deploy` holds a shell command that contains any of
121
+ * the deploy needles. Deliberately minimal — a gate that tries to be clever
122
+ * about shell commands is a gate that blocks real work — and it mirrors the
123
+ * proxy's `actions.rs` in both directions: `hookActionParity.test.ts` reads
124
+ * THIS file as text and fails if either side knows a needle the other does
125
+ * not. Keep each entry on one line in this exact `['action:x', [...]]` shape;
126
+ * that is what the test's regex finds.
127
+ */
128
+ export const ACTION_NEEDLES = [
129
+ ['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']],
130
+ ['action:publish', ['npm publish', 'pnpm publish', 'yarn publish', 'cargo publish', 'twine upload', 'poetry publish', 'gem push', 'docker manifest push']],
131
+ ['action:release', ['gh release create', 'git tag', 'npm version', 'cargo release', 'goreleaser release', 'semantic-release']],
132
+ ['action:db_write', ['insert into', 'update ', 'delete from', 'drop table', 'truncate ', 'alter table']],
133
+ ];
134
+ /** The (lower-cased) tool names whose `command` the classifier reads. */
135
+ export const ACTION_TOOL_NAMES = ['bash', 'shell', 'run_command', 'terminal', 'execute'];
136
+ /** Where a gate appends a hold, relative to the workspace root; the daemon's
137
+ * `drainReviewRequests` reads the same file. */
138
+ export const REVIEW_REQUESTS_BASENAME = 'review-requests.jsonl';
139
+ export const REVIEW_REQUESTS_LOG = `.intutic/events/${REVIEW_REQUESTS_BASENAME}`;
140
+ /** Bumped when a hold record's shape changes; the control plane drops any
141
+ * other version at ingest. */
142
+ export const REVIEW_REQUEST_VERSION = 1;
143
+ /** The bash classifier: echoes a space-padded token string for `$TOOL`/`$COMMAND`. */
144
+ function shellActionClassifier() {
145
+ const cases = ACTION_NEEDLES.map(([action, needles]) => ` case "$_c" in ${needles.map((n) => `*"${n}"*`).join('|')}) out="\${out}${action} " ;; esac`).join('\n');
146
+ return `intutic_actions() {
147
+ local _t _c out=" "
148
+ _t="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
149
+ case "$_t" in ${[...ACTION_TOOL_NAMES].join('|')}) ;; *) printf ' '; return 0 ;; esac
150
+ _c="$(printf '%s' "$2" | tr '[:upper:]' '[:lower:]')"
151
+ ${cases}
152
+ printf '%s' "$out"
153
+ }`;
154
+ }
155
+ /**
156
+ * How old a snapshot may be before a gate reports it as stale.
157
+ *
158
+ * Alerting only — a stale snapshot is still enforced, deliberately. If rules
159
+ * expired into permissiveness, "stop the daemon and wait" would be a supported
160
+ * way to disarm governance. Seven days is far longer than the sync interval, so
161
+ * this fires on "the daemon has been dead for a week", not on "it is Sunday".
162
+ */
163
+ export const SNAPSHOT_STALE_AFTER_DAYS = 7;
164
+ /** Single-quotes a string for shell, safely. */
165
+ function shq(s) {
166
+ return "'" + s.replace(/'/g, `'\\''`) + "'";
167
+ }
168
+ /**
169
+ * Emits a guard table as a bash array of tab-separated records.
170
+ *
171
+ * Tab-separated rather than parallel arrays so that a rule cannot half-exist:
172
+ * with four arrays kept in step by index, dropping one entry shifts every
173
+ * severity by one and the table still "works".
174
+ */
175
+ function shellGuardTable(name, patterns) {
176
+ const rows = patterns.map((p) => ` ${shq([p.id, p.severity, p.ignoreCase ? 'i' : '-', p.subject ?? 'any', p.reason, p.source].join('\t'))}`);
177
+ return `${name}=(\n${rows.join('\n')}\n)`;
178
+ }
179
+ /** Emits a guard table as a JS array literal. */
180
+ function jsGuardTable(name, patterns) {
181
+ const rows = patterns.map((p) => ` { id: ${JSON.stringify(p.id)}, re: new RegExp(${JSON.stringify(p.source)}` +
182
+ `${p.ignoreCase ? ", 'i'" : ''}), severity: ${JSON.stringify(p.severity)}, ` +
183
+ `subject: ${JSON.stringify(p.subject ?? 'any')}, reason: ${JSON.stringify(p.reason)} },`);
184
+ return `const ${name} = [\n${rows.join('\n')}\n];`;
185
+ }
186
+ /**
187
+ * The `.rules` snapshot record layout, defined once so the writer and every
188
+ * reader cannot disagree about column order.
189
+ *
190
+ * `source` may contain any punctuation EXCEPT a tab — `assertPortableEre`
191
+ * rejects literal tabs precisely because this projection is tab-separated —
192
+ * so it is safe mid-record.
193
+ *
194
+ * `argPatternB64` is the ` WHERE ` clause of a SOP rule, **base64-encoded**
195
+ * (standard alphabet, UTF-8 source), and the encoding is not a stylistic
196
+ * choice. An argPattern is an arbitrary JS regex: unlike `source` it is not
197
+ * run through `assertPortableEre`, so it may legally contain tabs, and any
198
+ * escaping convention would be a second dialect every one of the four readers
199
+ * (bash, JS, the Open WebUI filter, and the LangGraph port) had to get
200
+ * byte-identically right. Base64's alphabet ([A-Za-z0-9+/=]) cannot collide
201
+ * with the separator, full stop.
202
+ *
203
+ * The column is OMITTED (not emitted empty) when a rule has no argPattern, so
204
+ * every rule that exists today serialises byte-identically to the v3 layout.
205
+ * Compatibility in both directions:
206
+ * - a v4 gate reading a v3 file sees six fields and treats every rule as
207
+ * name-only — exactly v3 behaviour;
208
+ * - a v3 gate reading a v4 file: the JS and Python readers index `f[5]` and
209
+ * ignore the extra field; the bash reader's final `read` variable absorbs
210
+ * `source<TAB>base64`, so that one rule matches nothing rather than
211
+ * crashing the gate. That last degradation (a WHERE rule inert under a
212
+ * stale bash gate for one sync cycle, until the daemon — which writes both
213
+ * the snapshot and the gates — regenerates them) is the cost of never
214
+ * shifting the six columns every deployed reader already indexes.
215
+ */
216
+ export const RULES_COLUMNS = ['id', 'severity', 'flags', 'subject', 'reason', 'source', 'argPatternB64'];
217
+ /**
218
+ * Serialises one rule into a `.rules` line.
219
+ *
220
+ * The reason travels with the rule rather than being generated at block time.
221
+ * `hookEvents.resolveSeverity` classifies an incident by looking for the literal
222
+ * substrings "governance-protected" and "bypass pattern" in the reason text — so
223
+ * a gate that emits a generic message files a governance-config tamper as MEDIUM
224
+ * instead of CRITICAL, and whatever routes on CRITICAL never sees it. That
225
+ * coupling is asserted in `harnessProtectedPaths.test.ts`.
226
+ */
227
+ export function toRulesLine(p) {
228
+ const reason = p.reason.replace(/[\t\n\r]/g, ' ');
229
+ const base = [p.id, p.severity, p.ignoreCase ? 'i' : '-', p.subject ?? 'any', reason, p.source].join('\t');
230
+ // Appended only when present — see RULES_COLUMNS for why base64, and why a
231
+ // rule without one must serialise byte-identically to the v3 layout.
232
+ return p.argPattern ? base + '\t' + Buffer.from(p.argPattern, 'utf8').toString('base64') : base;
233
+ }
234
+ /**
235
+ * The bash fail-closed prelude. Interpolated by every bash writer immediately
236
+ * after `set -euo pipefail`, before anything that can fail has run.
237
+ *
238
+ * The edge it closes: under `set -euo pipefail` any script bug — an unbound
239
+ * variable, a `read` that hits EOF outside its guard, a corrupted snapshot
240
+ * tripping an unguarded command — ends the script with the failing command's
241
+ * status, usually 1. Every harness that runs these gates treats a non-2
242
+ * non-zero exit as "the hook errored" and ALLOWS the call, so an accidental
243
+ * crash was an allow that looked like enforcement.
244
+ *
245
+ * An EXIT trap that inspects the final status, deliberately not `trap ... ERR`:
246
+ *
247
+ * - ERR does not fire on `set -u` unbound-variable deaths or on every `set -e`
248
+ * exit path, and its firing rules differ between bash versions and whatever
249
+ * /bin/sh a harness substitutes. EXIT always runs, in every POSIX shell.
250
+ * - An ERR trap also fires on every benign non-zero intermediate — a grep
251
+ * no-match inside a condition, an arithmetic result of 0 — and turning those
252
+ * into blocks would make the gate refuse every call, which is exactly as
253
+ * broken as allowing every call. Inspecting only the FINAL exit status
254
+ * sidesteps that class entirely: `set -e` semantics are untouched, and a
255
+ * status the script deliberately produced (0 = allow, 2 = block, including
256
+ * the python3-missing refusal in SHELL_EXTRACT) passes through unchanged.
257
+ * Under `set -e` a script bug can never reach the final `exit 0`, so
258
+ * "status not in {0, 2}" is exactly "the gate crashed".
259
+ *
260
+ * The trap body runs under the script's own `set -eu`, so it disarms both
261
+ * first — a trap that itself dies on an unset variable would exit with that
262
+ * failure's status and reopen the hole it exists to close. `log_event` is
263
+ * defined by the writer AFTER this prelude, so the trap probes for it before
264
+ * calling: a crash earlier than the definition still blocks, just without an
265
+ * audit line (stderr carries the reason either way).
266
+ */
267
+ export const SHELL_FAIL_CLOSED = `
268
+ # ── Intutic fail-closed prelude v${GATE_VERSION} ─────────────────────────────
269
+ # Any exit status other than the two deliberate verdicts (0 = allow, 2 = block)
270
+ # means this gate crashed rather than decided. The harness would read exit 1 as
271
+ # "hook errored" and run the tool anyway, so a crash is converted into a block.
272
+ # EXIT rather than ERR: ERR is unreliable across shells for set -u deaths, and
273
+ # it fires on benign non-zero intermediates; the final status cannot lie.
274
+ intutic_fail_closed() {
275
+ _intutic_exit_rc=$?
276
+ # The trap must not itself die on an unset variable or a failed command —
277
+ # that would replace the exit status it exists to correct.
278
+ set +eu
279
+ trap - EXIT
280
+ if [ "$_intutic_exit_rc" = "0" ] || [ "$_intutic_exit_rc" = "2" ]; then
281
+ exit "$_intutic_exit_rc"
282
+ fi
283
+ echo "[Intutic Governance] BLOCKED: gate crashed (exit \${_intutic_exit_rc}) — failing closed rather than allowing an unevaluated call." >&2
284
+ if command -v log_event >/dev/null 2>&1; then
285
+ log_event "tool_blocked" "\${TOOL:-unknown}" "gate crashed (exit \${_intutic_exit_rc}) — failing closed" || true
286
+ fi
287
+ exit 2
288
+ }
289
+ trap intutic_fail_closed EXIT
290
+ `;
291
+ /**
292
+ * The bash gate body.
293
+ *
294
+ * Expects in scope: `$TOOL`, `$TARGET`, `$COMMAND`, and the log function named
295
+ * by {@link ShellGateOptions.logFn}. Refuses with `exit 2` — every bash harness
296
+ * uses the exit-code contract.
297
+ */
298
+ /** Single-quotes a path for bash. */
299
+ function shellQuote(s) {
300
+ return `'${s.replace(/'/g, `'\\''`)}'`;
301
+ }
302
+ /**
303
+ * The hold tier's JS helpers, emitted once into every JS-family gate.
304
+ *
305
+ * `intuticHold` is the one place a hold happens: bypass lookup, hold record,
306
+ * the approve hint and the audit line. It returns `{ bypassed: true }` when an
307
+ * exact, unexpired approved bypass lets the call through and `{ holdId }` when
308
+ * the caller must refuse. Everything is local — no network on the tool path.
309
+ */
310
+ function jsHoldHelpers(reviewRequestFile) {
311
+ return `const INTUTIC_ACTION_NEEDLES = ${JSON.stringify(ACTION_NEEDLES)};
312
+ const INTUTIC_ACTION_TOOLS = ${JSON.stringify(ACTION_TOOL_NAMES)};
313
+ const INTUTIC_REVIEW_REQUEST_FILE = ${JSON.stringify(reviewRequestFile ?? null)};
314
+
315
+ /** Space-padded action tokens for a shell command (" action:deploy "), or " ". */
316
+ function intuticActions(toolName, command) {
317
+ if (INTUTIC_ACTION_TOOLS.indexOf(String(toolName || '').toLowerCase()) === -1) return ' ';
318
+ var c = String(command || '').toLowerCase(), out = ' ';
319
+ for (var i = 0; i < INTUTIC_ACTION_NEEDLES.length; i++) {
320
+ var needles = INTUTIC_ACTION_NEEDLES[i][1];
321
+ for (var j = 0; j < needles.length; j++) {
322
+ if (c.indexOf(needles[j]) !== -1) { out += INTUTIC_ACTION_NEEDLES[i][0] + ' '; break; }
323
+ }
324
+ }
325
+ return out;
326
+ }
327
+
328
+ function intuticReviewRequestFile() {
329
+ var path = require('path');
330
+ if (process.env.INTUTIC_REVIEW_REQUESTS) return process.env.INTUTIC_REVIEW_REQUESTS;
331
+ if (INTUTIC_REVIEW_REQUEST_FILE) return INTUTIC_REVIEW_REQUEST_FILE;
332
+ var self = typeof __filename === 'string' ? __filename : (process.argv[1] || '.');
333
+ return path.resolve(path.dirname(self), '..', 'events', '${REVIEW_REQUESTS_BASENAME}');
334
+ }
335
+
336
+ /**
337
+ * Looks for a valid, unexpired, EXACT-match approved bypass in the cache the
338
+ * daemon polled from GET /api/v1/decisions/approved-bypasses. Returns null on
339
+ * ANY reason not to bypass — missing file, failed digest or workspace check,
340
+ * no matching line, an entry whose expiresAt has passed — and every null
341
+ * falls through to the hold: this can only make enforcement MORE permissive
342
+ * for the one call it matches exactly, never less for any other.
343
+ */
344
+ function intuticApprovedBypass(workspaceId, sopRuleId, toolNameNormalized, targetHash) {
345
+ var fs = require('fs'), os = require('os'), path = require('path');
346
+ var file = process.env.INTUTIC_APPROVED_BYPASSES ||
347
+ path.join(os.homedir(), '.intutic', 'hooks', 'approved-bypasses.jsonl');
348
+ var raw;
349
+ try { raw = fs.readFileSync(file, 'utf-8'); } catch (e) { return null; }
350
+ var lines = raw.split('\\n');
351
+ var digest = '', ws = '', body = [];
352
+ for (var i = 0; i < lines.length; i++) {
353
+ var line = lines[i];
354
+ if (line.indexOf('#digest ') === 0) { digest = line.slice(8).trim(); continue; }
355
+ if (line.indexOf('#workspace ') === 0) { ws = line.slice(11).trim(); continue; }
356
+ if (!line || line.charAt(0) === '#') continue;
357
+ body.push(line);
358
+ }
359
+ try {
360
+ var actual = require('crypto').createHash('sha256').update(body.join('\\n')).digest('hex').slice(0, 32);
361
+ if (actual !== digest) return null;
362
+ } catch (e) { return null; }
363
+ if (ws && ws !== workspaceId) return null;
364
+ var now = Date.now();
365
+ for (var k = 0; k < body.length; k++) {
366
+ var entry;
367
+ try { entry = JSON.parse(body[k]); } catch (e) { continue; }
368
+ if (!entry || entry.workspaceId !== workspaceId || entry.sopRuleId !== sopRuleId) continue;
369
+ if (entry.toolNameNormalized !== toolNameNormalized || entry.targetHash !== targetHash) continue;
370
+ var exp = Date.parse(entry.expiresAt);
371
+ if (!exp || isNaN(exp) || now >= exp) continue;
372
+ return entry;
373
+ }
374
+ return null;
375
+ }
376
+
377
+ function intuticHold(rule, toolName, command, target, toolInput, record, workspaceId, sessionId) {
378
+ var crypto = require('crypto'), fs = require('fs'), path = require('path');
379
+ // Key material, computed the same way at both ends: the normalised tool
380
+ // name, and sha256 of the normalised command/target pair joined by a NUL so
381
+ // command="a" target="b" cannot collide with command="ab" target="".
382
+ var toolNameNormalized = intuticNormalise(toolName);
383
+ var targetHash = crypto.createHash('sha256')
384
+ .update(intuticNormalise(command) + '\\u0000' + intuticNormalise(target)).digest('hex');
385
+ var bypass = intuticApprovedBypass(workspaceId || '', rule.id, toolNameNormalized, targetHash);
386
+ if (bypass) {
387
+ // Let it through — LOUDLY. A bypass nobody can see used is no better than
388
+ // the observe-only gap it replaces.
389
+ var bypassReason = 'Approved bypass for ' + rule.id + ' — approved by ' + bypass.decidedBy + ' on hold ' + bypass.holdId;
390
+ try { console.error('[Intutic Guardrail] BYPASSED: ' + bypassReason); } catch (e) {}
391
+ try { record('hold_approved_bypass_used', toolName, bypassReason); } catch (e) {}
392
+ return { bypassed: true };
393
+ }
394
+ var holdId = 'hold_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);
395
+ try {
396
+ var file = intuticReviewRequestFile();
397
+ fs.mkdirSync(path.dirname(file), { recursive: true });
398
+ var rec = {
399
+ v: ${REVIEW_REQUEST_VERSION},
400
+ holdId: holdId,
401
+ reason: rule.id,
402
+ tool: toolName,
403
+ sessionId: sessionId || '',
404
+ workspaceId: workspaceId || '',
405
+ at: new Date().toISOString(),
406
+ toolNameNormalized: toolNameNormalized,
407
+ targetHash: targetHash,
408
+ };
409
+ // The redacted context snapshot, when this writer emitted the redactor
410
+ // (Claude Code does). Redacted here rather than in the daemon: plaintext
411
+ // that reaches .intutic/events/ is plaintext that ends up in a bug report.
412
+ if (typeof __intuticSnapshot === 'function') {
413
+ rec.context = __intuticSnapshot({ tool: toolName, toolInput: toolInput, cwd: process.cwd(), reason: rule.id });
414
+ }
415
+ // Appended, never overwritten: holds cluster.
416
+ fs.appendFileSync(file, JSON.stringify(rec) + '\\n');
417
+ } catch (e) {
418
+ // The hold still refuses even if the record cannot be written — the
419
+ // contract below is what stops the tool. But say so.
420
+ try { console.error('[Intutic Guardrail] could not record the hold: ' + (e && e.message ? e.message : e)); } catch (e2) {}
421
+ }
422
+ var reason = rule.reason + ' [' + rule.id + ']';
423
+ try { console.error('[Intutic Guardrail] HELD: ' + reason + ' Approve with: intutic decision approve ' + holdId + ' (or: intutic decision reject ' + holdId + ')'); } catch (e) {}
424
+ try { record('tool_held', toolName, reason); } catch (e) {}
425
+ return { holdId: holdId };
426
+ }`;
427
+ }
428
+ export function emitShellGate(opts) {
429
+ const log = opts.logFn ?? 'log_event';
430
+ const floor = staticFloorPatterns();
431
+ return `
432
+ # ── Intutic gate body v${GATE_VERSION} — harness: ${opts.harness} ────────────
433
+ # Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
434
+ #
435
+ # The harness name and version are stamped so this file identifies itself. Both
436
+ # were previously passed in and dropped on the floor: opts.harness was declared
437
+ # on the option type, supplied by all thirteen writers, and read nowhere.
438
+
439
+ # Refuse an unreadable envelope before any rule is consulted.
440
+ #
441
+ # Unparseable stdin used to degrade to empty extracted fields, which match no
442
+ # rule — an ALLOW. Per each harness's contract this hook receives only
443
+ # PreToolUse events, so a payload from which neither a tool name nor a
444
+ # tool_input could be extracted is anomalous by definition: there is no such
445
+ # thing as a legitimate PreToolUse event with no tool call in it. Refusing is
446
+ # the posture githubCopilotHooks established for its Preview envelope, applied
447
+ # uniformly. INTUTIC_EXTRACT_STATE is set by SHELL_EXTRACT; the refusal lives
448
+ # here because log_event is not defined until after extraction runs.
449
+ #
450
+ # Scoped to the bare invocation: goose and openhands register post/stop
451
+ # invocations of this same script WITH arguments, and those events carry no
452
+ # tool call legitimately — refusing them would fail a hook that has nothing to
453
+ # gate. Every PreToolUse registration invokes the script with no arguments.
454
+ if [ -z "\${1:-}" ] && [ "\${INTUTIC_EXTRACT_STATE:-malformed}" != "ok" ]; then
455
+ _intutic_env_reason="unrecognised PreToolUse payload — neither a tool name nor a tool_input could be extracted from stdin; refusing rather than allowing a call the gate cannot read"
456
+ echo "[Intutic Governance] BLOCKED: \${_intutic_env_reason}" >&2
457
+ ${log} "tool_blocked" "unknown" "\${_intutic_env_reason}" || true
458
+ exit 2
459
+ fi
460
+
461
+ ${NORMALISE_CONTRACT.shell}
462
+
463
+ # The static floor: compiled in, always enforced, never sourced from disk.
464
+ # These are the families with years of evidence behind them, so they block from
465
+ # the first run — before any sync has happened.
466
+ ${shellGuardTable('INTUTIC_FLOOR', floor)}
467
+
468
+ # The dynamic tier, read from the policy snapshot. Additive only: it can turn an
469
+ # allow into a block, never a block into an allow. See policySnapshot.ts.
470
+ INTUTIC_SNAPSHOT_RULES="\${INTUTIC_SNAPSHOT_RULES:-$HOME/.intutic/hooks/policy-snapshot.rules}"
471
+ INTUTIC_SNAPSHOT_DIGEST="none"
472
+ INTUTIC_SNAPSHOT_STATE="absent"
473
+ INTUTIC_DYNAMIC=()
474
+
475
+ # A supported escape hatch, and the reason it exists is not politeness.
476
+ #
477
+ # The fastest workaround available to a developer wrongly blocked is
478
+ # \`chflags nouchg\` on the hook — the exact bypass the floor exists to stop. If
479
+ # the only way out is one we treat as an attack, we have manufactured our own
480
+ # adversary. So: an escape we can see, that disables the **dynamic tier only**.
481
+ # The floor is never disabled by it. Every invocation while it is set emits an
482
+ # event, so a machine running ungoverned is visible rather than quiet.
483
+ INTUTIC_GUARDS_DISABLED=0
484
+ if [ "\${INTUTIC_GUARD_DISABLE:-}" = "1" ]; then
485
+ INTUTIC_GUARDS_DISABLED=1
486
+ ${log} "guards_disabled" "\${TOOL:-}" "INTUTIC_GUARD_DISABLE=1 — policy-snapshot rules skipped; built-in protections still active" || true
487
+ fi
488
+
489
+ INTUTIC_SNAPSHOT_WORKSPACE=""
490
+ INTUTIC_SNAPSHOT_GENERATED=""
491
+ # M3: the per-server MCP allowlist header. Empty means "not configured" —
492
+ # indistinguishable from "header absent", deliberately: writePolicySnapshot
493
+ # omits the line entirely rather than shipping it with an empty list, so
494
+ # empty-list-means-unrestricted holds all the way to the gate.
495
+ INTUTIC_MCP_SEVERITY=""
496
+ INTUTIC_MCP_SERVERS=""
497
+ if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
498
+ INTUTIC_SNAPSHOT_STATE="ok"
499
+ while IFS= read -r _line || [ -n "$_line" ]; do
500
+ case "$_line" in
501
+ '#digest '*) INTUTIC_SNAPSHOT_DIGEST="\${_line#\\#digest }" ;;
502
+ '#workspace '*) INTUTIC_SNAPSHOT_WORKSPACE="\${_line#\\#workspace }" ;;
503
+ '#generated '*) INTUTIC_SNAPSHOT_GENERATED="\${_line#\\#generated }" ;;
504
+ '#mcpservers '*)
505
+ # \`#mcpservers <severity> <comma-joined-server-names>\` — parameter
506
+ # expansion only, no subshell, matching the other header lines here.
507
+ _intutic_mcp_rest="\${_line#\\#mcpservers }"
508
+ INTUTIC_MCP_SEVERITY="\${_intutic_mcp_rest%% *}"
509
+ INTUTIC_MCP_SERVERS="\${_intutic_mcp_rest#* }"
510
+ ;;
511
+ '#'*|'') : ;;
512
+ *) INTUTIC_DYNAMIC+=("$_line") ;;
513
+ esac
514
+ done < "$INTUTIC_SNAPSHOT_RULES"
515
+
516
+ # Integrity, in the order that fails cheapest first.
517
+ #
518
+ # All three of these were parsed and then ignored. A digest nobody recomputes
519
+ # is not integrity, it is a comment; and a workspace id nobody compares means a
520
+ # snapshot from workspace A dropped onto workspace B's machine is enforced
521
+ # verbatim, with B's audit events attributing A's rules to B.
522
+ _intutic_body="$(grep -v '^#' "$INTUTIC_SNAPSHOT_RULES" | grep -v '^$' || true)"
523
+ _intutic_actual=""
524
+ if command -v shasum >/dev/null 2>&1; then
525
+ _intutic_actual="$(printf '%s' "$_intutic_body" | shasum -a 256 | cut -c1-32)"
526
+ elif command -v sha256sum >/dev/null 2>&1; then
527
+ _intutic_actual="$(printf '%s' "$_intutic_body" | sha256sum | cut -c1-32)"
528
+ fi
529
+
530
+ if [ -n "$_intutic_actual" ] && [ "$_intutic_actual" != "$INTUTIC_SNAPSHOT_DIGEST" ]; then
531
+ INTUTIC_SNAPSHOT_STATE="invalid"
532
+ elif [ -n "$INTUTIC_SNAPSHOT_WORKSPACE" ] && [ -n "\${INTUTIC_WORKSPACE_ID:-}" ] \
533
+ && [ "$INTUTIC_SNAPSHOT_WORKSPACE" != "$INTUTIC_WORKSPACE_ID" ]; then
534
+ INTUTIC_SNAPSHOT_STATE="invalid"
535
+ elif [ \${#INTUTIC_DYNAMIC[@]} -eq 0 ]; then
536
+ # Distinct from "absent". The writer always ships the destructive tier, so a
537
+ # rule-less snapshot means the compile produced nothing — a real fault that
538
+ # otherwise looks exactly like a healthy quiet workspace.
539
+ INTUTIC_SNAPSHOT_STATE="empty"
540
+ fi
541
+
542
+ # Degrade to the compiled floor. The dynamic tier is additive, so dropping it
543
+ # returns to yesterday's behaviour rather than opening a hole. The MCP
544
+ # allowlist ships through the same file and degrades the same way — an
545
+ # invalid snapshot must not leave a stale allowlist enforcing.
546
+ if [ "$INTUTIC_SNAPSHOT_STATE" = "invalid" ]; then
547
+ INTUTIC_DYNAMIC=()
548
+ INTUTIC_MCP_SEVERITY=""
549
+ INTUTIC_MCP_SERVERS=""
550
+ fi
551
+ fi
552
+
553
+ # Staleness governs alerting, not enforcement: rules stay in force. If they
554
+ # expired into permissiveness, "kill the daemon and wait" would be a supported
555
+ # way to disarm governance.
556
+ if [ "$INTUTIC_SNAPSHOT_STATE" = "ok" ] && [ -n "$INTUTIC_SNAPSHOT_GENERATED" ]; then
557
+ _intutic_age_days="$(python3 -c 'import sys,datetime
558
+ try:
559
+ t=datetime.datetime.fromisoformat(sys.argv[1].replace("Z","+00:00"))
560
+ print(int((datetime.datetime.now(datetime.timezone.utc)-t).total_seconds()//86400))
561
+ except Exception:
562
+ print(0)' "$INTUTIC_SNAPSHOT_GENERATED" 2>/dev/null || echo 0)"
563
+ if [ "$_intutic_age_days" -gt ${SNAPSHOT_STALE_AFTER_DAYS} ] 2>/dev/null; then
564
+ INTUTIC_SNAPSHOT_STATE="stale"
565
+ fi
566
+ fi
567
+
568
+ case "$INTUTIC_SNAPSHOT_STATE" in
569
+ absent) ${log} "snapshot_absent" "\${TOOL:-}" "No policy snapshot at $INTUTIC_SNAPSHOT_RULES — built-in protections only" || true ;;
570
+ invalid) ${log} "snapshot_invalid" "\${TOOL:-}" "Policy snapshot failed its digest or workspace check — dynamic rules dropped" || true ;;
571
+ empty) ${log} "snapshot_empty" "\${TOOL:-}" "Policy snapshot contains no rules — the compile produced nothing" || true ;;
572
+ stale) ${log} "snapshot_stale" "\${TOOL:-}" "Policy snapshot is \${_intutic_age_days} days old and still enforced" || true ;;
573
+ esac
574
+
575
+ # Normalise once; every pattern is written against this shape.
576
+ INTUTIC_NCOMMAND="$(intutic_normalise "\${COMMAND:-}")"
577
+ INTUTIC_NTARGET="$(intutic_normalise "\${TARGET:-}")"
578
+ INTUTIC_NTOOL="$(intutic_normalise "\${TOOL:-}")"
579
+
580
+ # ── Hold tier (gate body v8) ─────────────────────────────────────────────────
581
+ # A \`hold\` rule refuses the call and records it for a human; an exact,
582
+ # unexpired approved bypass the daemon cached lets the same call through once.
583
+ # All local — a file read, a file write and an exit code; no network on the
584
+ # tool path.
585
+ INTUTIC_REVIEW_REQUEST_FILE="\${INTUTIC_REVIEW_REQUESTS:-}"
586
+ if [ -z "$INTUTIC_REVIEW_REQUEST_FILE" ]; then
587
+ INTUTIC_REVIEW_REQUEST_FILE=${opts.reviewRequestFile ? shellQuote(opts.reviewRequestFile) : '"$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)/events/review-requests.jsonl"'}
588
+ fi
589
+ INTUTIC_APPROVED_BYPASSES="\${INTUTIC_APPROVED_BYPASSES:-$HOME/.intutic/hooks/approved-bypasses.jsonl}"
590
+ ${shellActionClassifier()}
591
+ intutic_sha256() {
592
+ if command -v shasum >/dev/null 2>&1; then shasum -a 256 | cut -c1-64
593
+ elif command -v sha256sum >/dev/null 2>&1; then sha256sum | cut -c1-64
594
+ else printf ''; fi
595
+ }
596
+ intutic_json_escape() {
597
+ printf '%s' "$1" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g' | tr -d '\\n\\r\\t'
598
+ }
599
+ # Looks for a valid, unexpired, EXACT-match approved bypass. Prints nothing
600
+ # and returns 1 on ANY reason not to bypass: missing file, failed digest or
601
+ # workspace check, no matching line, or an entry whose expiresAt has passed —
602
+ # every one of which falls through to the hold. Key material is computed the
603
+ # same way the JS gate and the control plane compute it: the normalised tool
604
+ # name, and sha256(normalised command, NUL, normalised target).
605
+ intutic_bypass() {
606
+ local rid="$1" tn="$2" th="$3" hdr_digest hdr_ws body actual now line exp
607
+ [ -n "$th" ] || return 1
608
+ [ -r "$INTUTIC_APPROVED_BYPASSES" ] || return 1
609
+ hdr_digest="$(grep -m1 '^#digest ' "$INTUTIC_APPROVED_BYPASSES" | cut -d' ' -f2 || true)"
610
+ hdr_ws="$(grep -m1 '^#workspace ' "$INTUTIC_APPROVED_BYPASSES" | cut -d' ' -f2 || true)"
611
+ body="$(grep -v '^#' "$INTUTIC_APPROVED_BYPASSES" | grep -v '^$' || true)"
612
+ [ -n "$body" ] || return 1
613
+ actual="$(printf '%s' "$body" | intutic_sha256 | cut -c1-32)"
614
+ [ -n "$actual" ] && [ "$actual" = "$hdr_digest" ] || return 1
615
+ if [ -n "$hdr_ws" ] && [ "$hdr_ws" != "\${INTUTIC_WORKSPACE_ID:-}" ]; then return 1; fi
616
+ now="$(date -u +%Y-%m-%dT%H:%M:%S)"
617
+ while IFS= read -r line; do
618
+ case "$line" in *'"workspaceId":"'"\${INTUTIC_WORKSPACE_ID:-}"'"'*) ;; *) continue ;; esac
619
+ case "$line" in *'"sopRuleId":"'"$rid"'"'*) ;; *) continue ;; esac
620
+ case "$line" in *'"toolNameNormalized":"'"$tn"'"'*) ;; *) continue ;; esac
621
+ case "$line" in *'"targetHash":"'"$th"'"'*) ;; *) continue ;; esac
622
+ exp="$(printf '%s' "$line" | sed -n 's/.*"expiresAt":"\\([^"]*\\)".*/\\1/p')"
623
+ [ -n "$exp" ] || continue
624
+ # ISO-8601 UTC timestamps compare lexically; a past expiry fails closed
625
+ # toward the hold, however fresh the file is.
626
+ if [[ "$exp" > "$now" ]]; then
627
+ printf '%s' "$line"; return 0
628
+ fi
629
+ done <<< "$body"
630
+ return 1
631
+ }
632
+ # Returns 0 when an approved bypass lets the call through (the caller
633
+ # continues), 2 when the call is held (the caller refuses).
634
+ intutic_hold() {
635
+ local rid="$1" rreason="$2" tn th entry decided_by hold_id at file targeth
636
+ tn="$(intutic_normalise "\${TOOL:-}")"
637
+ th="$( { printf '%s' "$INTUTIC_NCOMMAND"; printf '\\0'; printf '%s' "$INTUTIC_NTARGET"; } | intutic_sha256 )"
638
+ if entry="$(intutic_bypass "$rid" "$tn" "$th")"; then
639
+ decided_by="$(printf '%s' "$entry" | sed -n 's/.*"decidedBy":"\\([^"]*\\)".*/\\1/p')"
640
+ hold_id="$(printf '%s' "$entry" | sed -n 's/.*"holdId":"\\([^"]*\\)".*/\\1/p')"
641
+ echo "[Intutic Guardrail] BYPASSED: Approved bypass for \${rid} — approved by \${decided_by} on hold \${hold_id}" >&2
642
+ ${log} "hold_approved_bypass_used" "\${TOOL:-}" "Approved bypass for \${rid} — approved by \${decided_by} on hold \${hold_id}" || true
643
+ return 0
644
+ fi
645
+ hold_id="hold_$(printf '%x' "$(date +%s)")_$(printf '%04x%04x' "$RANDOM" "$RANDOM")"
646
+ at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
647
+ targeth=""
648
+ [ -n "$th" ] && targeth="$(printf ',"targetHash":"%s"' "$th")"
649
+ file="$INTUTIC_REVIEW_REQUEST_FILE"
650
+ # The hold still blocks even if the record cannot be written — the exit code
651
+ # is what stops the tool. But say so: a hold nobody can review is the worst
652
+ # of both outcomes.
653
+ if ! { mkdir -p "$(dirname "$file")" 2>/dev/null && printf '{"v":${REVIEW_REQUEST_VERSION},"holdId":"%s","reason":"%s","tool":"%s","sessionId":"%s","workspaceId":"%s","at":"%s","toolNameNormalized":"%s"%s}\\n' \\
654
+ "$hold_id" "$(intutic_json_escape "$rid")" "$(intutic_json_escape "\${TOOL:-}")" "$(intutic_json_escape "\${SESSION_ID:-}")" \\
655
+ "$(intutic_json_escape "\${INTUTIC_WORKSPACE_ID:-}")" "$at" "$(intutic_json_escape "$tn")" "$targeth" >> "$file" 2>/dev/null; }; then
656
+ echo "[Intutic Guardrail] could not record the hold at \${file}" >&2
657
+ fi
658
+ echo "[Intutic Guardrail] HELD: \${rreason} [\${rid}] Approve with: intutic decision approve \${hold_id} (or: intutic decision reject \${hold_id})" >&2
659
+ ${log} "tool_held" "\${TOOL:-}" "\${rreason} [\${rid}]" || true
660
+ return 2
661
+ }
662
+ # Computed after the classifier is defined; read by the \`action\` subject.
663
+ INTUTIC_ACTIONS="$(intutic_actions "\${TOOL:-}" "\${COMMAND:-}")"
664
+
665
+ # A rule declares which part of the call it matches. \`any\` means command and
666
+ # target — never the tool name, because a pattern like \`\\.claude/settings\\.json\`
667
+ # would otherwise be tested against "Write" and quietly match nothing.
668
+ intutic_apply() {
669
+ local rec="$1"
670
+ local rid rsev rflags rsubj rreason rsrc rargb64 s hit
671
+ # Seven variables over a six- or seven-field record: a v3 line leaves rargb64
672
+ # empty, which is exactly "no argument condition". The base64 column cannot
673
+ # contain a tab, so the last variable never absorbs a separator.
674
+ IFS=$'\\t' read -r rid rsev rflags rsubj rreason rsrc rargb64 <<< "$rec"
675
+ [ -z "$rsrc" ] && return 0
676
+ # The escape hatch skips the DESTRUCTIVE family only. It is not a way to turn
677
+ # off a workspace's own BLOCK: SOP rules — those are what the customer asked
678
+ # for, and one environment variable disarming them would make the switch the
679
+ # first thing an agent sets. Keyed on the rule-id family, which the snapshot
680
+ # writer assigns: destructive.* versus sop.*
681
+ if [ "$INTUTIC_GUARDS_DISABLED" = "1" ]; then
682
+ case "$rid" in
683
+ destructive.*) return 0 ;;
684
+ esac
685
+ fi
686
+ local _subs=()
687
+ case "$rsubj" in
688
+ tool) _subs=("$INTUTIC_NTOOL") ;;
689
+ command) _subs=("$INTUTIC_NCOMMAND") ;;
690
+ target) _subs=("$INTUTIC_NTARGET") ;;
691
+ # Serialized tool input, un-normalised — same subject the WHERE argPattern
692
+ # machinery matches. The secrets.* floor rules ride this. Guaranteed
693
+ # non-empty by the extractor (it defaults to "{}").
694
+ content) _subs=("$TOOL_INPUT_JSON") ;;
695
+ # The space-padded action tokens intutic_actions derived from the command
696
+ # (" action:deploy "), so a hold on \`action:deploy\` matches whole tokens.
697
+ action) _subs=("$INTUTIC_ACTIONS") ;;
698
+ # Each field is tested separately rather than concatenated. Joining them
699
+ # lets a pattern match across the seam — a command ending in "chflags" and
700
+ # an unrelated target starting with "nouchg" would trip the bypass rule
701
+ # together while neither does alone.
702
+ *) _subs=("$INTUTIC_NCOMMAND" "$INTUTIC_NTARGET") ;;
703
+ esac
704
+ hit=0
705
+ for s in "\${_subs[@]}"; do
706
+ if [ "$rflags" = "i" ]; then
707
+ printf '%s' "$s" | grep -qiE -- "$rsrc" && hit=1
708
+ else
709
+ printf '%s' "$s" | grep -qE -- "$rsrc" && hit=1
710
+ fi
711
+ done
712
+ [ "$hit" = "1" ] || return 0
713
+ # The argument condition of a WHERE rule. The tool-name half has matched; the
714
+ # rule fires only if the argPattern also matches the serialized tool input —
715
+ # the same json.dumps(tool_input, separators=(",",":"), ensure_ascii=False)
716
+ # shape matchSopRule and the intutic-clawde gate port pin, produced once by
717
+ # the extractor above. Evaluated by python3 re, not grep: these patterns are
718
+ # authored as JS regexes and routinely use lookahead, which no grep has.
719
+ # Fail-safe direction: a pattern python cannot compile (exit 2) downgrades
720
+ # THIS rule to name-only — today's behaviour, plus a rule_downgraded event —
721
+ # rather than silently dropping the rule, which would fail open.
722
+ if [ -n "$rargb64" ]; then
723
+ _intutic_arg_rc=0
724
+ python3 -c '
725
+ import sys, base64, re
726
+ try:
727
+ pat = base64.b64decode(sys.argv[1], validate=True).decode("utf-8")
728
+ except Exception:
729
+ sys.exit(2)
730
+ try:
731
+ rx = re.compile(pat)
732
+ except re.error:
733
+ sys.exit(2)
734
+ sys.exit(0 if rx.search(sys.argv[2]) else 1)
735
+ ' "$rargb64" "\${TOOL_INPUT_JSON:-}" 2>/dev/null || _intutic_arg_rc=$?
736
+ if [ "$_intutic_arg_rc" = "1" ]; then
737
+ return 0
738
+ elif [ "$_intutic_arg_rc" != "0" ]; then
739
+ ${log} "rule_downgraded" "\${TOOL:-}" "argPattern for \${rid} did not compile in python re — rule enforced name-only" || true
740
+ fi
741
+ fi
742
+ if [ "$rsev" = "shadow" ]; then
743
+ ${log} "tool_would_block" "\${TOOL:-}" "\${rreason} [\${rid}]" || true
744
+ return 0
745
+ fi
746
+ if [ "$rsev" = "hold" ]; then
747
+ # Not a permanent denial: the action is legitimate, someone asked to see
748
+ # it first. A bypass they approved lets this exact call through once.
749
+ if intutic_hold "$rid" "$rreason"; then return 0; fi
750
+ exit 2
751
+ fi
752
+ if [ "$rsev" = "warn" ]; then
753
+ # Advisory tier: allowed, and recorded with enough shape to triage.
754
+ #
755
+ # The rule id alone was not enough. The warn tier exists to earn promotion to
756
+ # block from a measured false-positive rate, and you cannot call a flag true
757
+ # or false without seeing something of what was run. So: the first token of
758
+ # the command, and nothing else. That is the verb — curl, git, rm — which is
759
+ # what distinguishes "a real destructive command" from "a false positive on a
760
+ # build script", and it carries no paths, no arguments, no secrets. The full
761
+ # command stays out deliberately; that is what DLP exists to keep out of
762
+ # telemetry.
763
+ _intutic_verb="\${INTUTIC_NCOMMAND# }"
764
+ _intutic_verb="\${_intutic_verb%% *}"
765
+ ${log} "tool_flagged" "\${TOOL:-}" "\${rreason} [\${rid}] verb=\${_intutic_verb}" || true
766
+ return 0
767
+ fi
768
+ # The rule's own reason, not a generic one: hookEvents.resolveSeverity reads
769
+ # this text for "governance-protected" to file the incident CRITICAL.
770
+ local reason="\${rreason} [\${rid}]"
771
+ echo "[Intutic Governance] BLOCKED: \${reason}" >&2
772
+ ${log} "tool_blocked" "\${TOOL:-}" "\${reason}"
773
+ exit 2
774
+ }
775
+
776
+ for _rec in "\${INTUTIC_FLOOR[@]}"; do
777
+ intutic_apply "$_rec"
778
+ done
779
+
780
+ if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
781
+ for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
782
+ intutic_apply "$_rec"
783
+ done
784
+ fi
785
+
786
+ # ── M3: MCP per-server allowlist backstop ────────────────────────────────────
787
+ # A DEDICATED header field, not a synthetic GuardPattern rule — see
788
+ # policySnapshot.ts's module doc and protectedPaths.ts's assertPortableEre:
789
+ # expressing "allow only these servers" as a single regex needs negative
790
+ # lookahead, which neither \`grep -E\` (POSIX ERE) nor this file's own
791
+ # portable-ERE discipline support. So this is a plain string-membership test
792
+ # over the parsed \${#mcpservers} header, independent of intutic_apply/the
793
+ # GuardPattern tables entirely.
794
+ #
795
+ # Only fires when a \`mcp__<server>__<tool>\`-shaped tool name was actually
796
+ # called AND the header was present (INTUTIC_MCP_SERVERS non-empty — absent
797
+ # header means unrestricted, see the header-parsing block above). Refuses
798
+ # through the SAME exit-2 + \${log} idiom every other block in this file uses,
799
+ # not a parallel mechanism.
800
+ case "\${TOOL:-}" in
801
+ mcp__*__*)
802
+ if [ -n "$INTUTIC_MCP_SERVERS" ]; then
803
+ _intutic_mcp_server="\${TOOL#mcp__}"
804
+ _intutic_mcp_server="\${_intutic_mcp_server%%__*}"
805
+ case ",$INTUTIC_MCP_SERVERS," in
806
+ *",$_intutic_mcp_server,"*)
807
+ : # allowed — the server is in the workspace's list
808
+ ;;
809
+ *)
810
+ _intutic_mcp_reason="MCP server \\"$_intutic_mcp_server\\" is not on the MCP server allowlist for this workspace [mcp_allowlist]"
811
+ if [ "$INTUTIC_MCP_SEVERITY" = "shadow" ]; then
812
+ ${log} "tool_would_block" "\${TOOL:-}" "$_intutic_mcp_reason" || true
813
+ else
814
+ echo "[Intutic Governance] BLOCKED: $_intutic_mcp_reason" >&2
815
+ ${log} "tool_blocked" "\${TOOL:-}" "$_intutic_mcp_reason"
816
+ exit 2
817
+ fi
818
+ ;;
819
+ esac
820
+ fi
821
+ ;;
822
+ esac
823
+ # ── end Intutic gate body ────────────────────────────────────────────────────
824
+ `;
825
+ }
826
+ /**
827
+ * The snapshot loader every JS-family gate embeds, defined once.
828
+ *
829
+ * Extracted from {@link emitJsGate}'s template when the n8n workflow gate
830
+ * arrived: two emitters needing the same loader is exactly the situation that
831
+ * produced the four defects this module's header describes, so the loader is a
832
+ * constant both interpolate rather than a block one of them copies. The
833
+ * emitted bytes for the per-tool gates are unchanged.
834
+ */
835
+ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
836
+ const fs = require('fs'), os = require('os'), path = require('path');
837
+ const p = process.env.INTUTIC_SNAPSHOT_RULES ||
838
+ path.join(os.homedir(), '.intutic', 'hooks', 'policy-snapshot.rules');
839
+ // M3: mcpServers/mcpSeverity default to unrestricted (empty list) — the
840
+ // same "header absent means unrestricted" reading writePolicySnapshot's
841
+ // \`#mcpservers\` header is built on.
842
+ const out = { rules: [], digest: 'none', state: 'absent', workspaceId: '', generatedAt: '', ageDays: 0,
843
+ mcpServers: [], mcpSeverity: 'block' };
844
+ let text;
845
+ try { text = fs.readFileSync(p, 'utf8'); } catch (e) { return out; }
846
+ out.state = 'ok';
847
+ for (const line of text.split('\\n')) {
848
+ if (line.startsWith('#digest ')) { out.digest = line.slice(8).trim(); continue; }
849
+ if (line.startsWith('#workspace ')) { out.workspaceId = line.slice(11).trim(); continue; }
850
+ if (line.startsWith('#generated ')) { out.generatedAt = line.slice(11).trim(); continue; }
851
+ if (line.startsWith('#mcpservers ')) {
852
+ // \`#mcpservers <severity> <comma-joined-server-names>\` — same header a
853
+ // v5 gate would silently ignore via the generic '#'-prefix skip below,
854
+ // which is exactly the graceful-degradation contract this format change
855
+ // relies on.
856
+ const rest = line.slice('#mcpservers '.length);
857
+ const sp = rest.indexOf(' ');
858
+ if (sp === -1) { out.mcpSeverity = rest.trim(); out.mcpServers = []; }
859
+ else {
860
+ out.mcpSeverity = rest.slice(0, sp).trim();
861
+ out.mcpServers = rest.slice(sp + 1).trim().split(',').filter(Boolean);
862
+ }
863
+ continue;
864
+ }
865
+ if (!line || line.startsWith('#')) continue;
866
+ const f = line.split('\\t');
867
+ if (f.length < 6 || !f[5]) continue;
868
+ let re;
869
+ // A snapshot rule that will not compile is dropped, not fatal. The floor
870
+ // above is compiled in and unaffected, so this degrades to "today's
871
+ // behaviour" rather than to "no gate".
872
+ try { re = new RegExp(f[5], f[2] === 'i' ? 'i' : ''); } catch (e) { continue; }
873
+ // Optional seventh column: the WHERE clause, base64 so an arbitrary regex
874
+ // cannot collide with the tab separator. Absent in v3-format files, which
875
+ // is exactly "no argument condition". An argPattern that does not decode
876
+ // or compile downgrades THIS rule to name-only (argDowngraded) instead of
877
+ // dropping it — dropping a block rule because its narrowing clause is
878
+ // broken would fail open.
879
+ let argRe = null, argDowngraded = false;
880
+ if (f.length > 6 && f[6]) {
881
+ try { argRe = new RegExp(Buffer.from(f[6], 'base64').toString('utf8')); }
882
+ catch (e) { argDowngraded = true; }
883
+ }
884
+ out.rules.push({ id: f[0], severity: f[1], subject: f[3] || 'any', re: re, reason: f[4],
885
+ argRe: argRe, argDowngraded: argDowngraded });
886
+ }
887
+
888
+ // Integrity. A digest nobody recomputes is a comment, and a workspace id
889
+ // nobody compares means a snapshot from workspace A dropped onto workspace B's
890
+ // machine is enforced verbatim, with B's events attributing A's rules to B.
891
+ try {
892
+ const body = text.split('\\n').filter(function (l) { return l && l.charAt(0) !== '#'; }).join('\\n');
893
+ const actual = require('crypto').createHash('sha256').update(body).digest('hex').slice(0, 32);
894
+ if (out.digest !== 'none' && actual !== out.digest) out.state = 'invalid';
895
+ } catch (e) { /* no crypto — leave the digest unverified rather than fail the gate */ }
896
+
897
+ if (out.state === 'ok' && out.workspaceId && INTUTIC_WORKSPACE_ID && out.workspaceId !== INTUTIC_WORKSPACE_ID) {
898
+ out.state = 'invalid';
899
+ }
900
+ // Distinct from absent: the writer always ships the destructive tier, so no
901
+ // rules means the compile produced nothing — a fault that otherwise looks
902
+ // exactly like a healthy quiet workspace.
903
+ if (out.state === 'ok' && out.rules.length === 0) out.state = 'empty';
904
+
905
+ // Additive tier, so dropping it returns to yesterday's behaviour. The MCP
906
+ // allowlist ships through the same file and degrades the same way — an
907
+ // invalid snapshot must not leave a stale allowlist enforcing.
908
+ if (out.state === 'invalid') { out.rules = []; out.mcpServers = []; }
909
+
910
+ if (out.state === 'ok' && out.generatedAt) {
911
+ const t = Date.parse(out.generatedAt);
912
+ if (!isNaN(t)) {
913
+ out.ageDays = Math.floor((Date.now() - t) / 86400000);
914
+ // Still enforced — staleness governs alerting, not enforcement.
915
+ if (out.ageDays > ${SNAPSHOT_STALE_AFTER_DAYS}) out.state = 'stale';
916
+ }
917
+ }
918
+ return out;
919
+ }`;
920
+ /**
921
+ * The JavaScript gate body.
922
+ *
923
+ * Emits a self-contained `intuticGate()` that the writer calls. Refuses through
924
+ * whichever mechanism {@link JsGateOptions.contract} names, so a harness that
925
+ * reads stdout rather than the exit code cannot be given the wrong one by
926
+ * accident — it is a parameter, not a copy-paste.
927
+ */
928
+ export function emitJsGate(opts) {
929
+ const floor = staticFloorPatterns();
930
+ const refuse = opts.contract === 'stdout-cancel'
931
+ ? ` process.stdout.write(JSON.stringify({ cancel: true, reason: reason }) + '\\n');\n` +
932
+ ` process.exit(0);`
933
+ : opts.contract === 'stdout-decision-deny'
934
+ ? ` process.stdout.write(JSON.stringify({ decision: 'deny', reason: reason }) + '\\n');\n` +
935
+ ` process.exit(0);`
936
+ : opts.contract === 'throw'
937
+ // The envelope guard's reason already carries the prefix; a rule's
938
+ // reason does not. One message shape either way.
939
+ ? ` throw new Error(String(reason).indexOf('[Intutic Governance]') === 0 ? String(reason) : '[Intutic Governance] BLOCKED: ' + reason);`
940
+ : ` process.exit(2);`;
941
+ return `
942
+ // ── Intutic gate body v${GATE_VERSION} — harness: ${opts.harness} ────────────
943
+ // Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
944
+ //
945
+ // The harness name and version are stamped so this file identifies itself. Both
946
+ // were previously passed in and dropped on the floor: opts.harness was declared
947
+ // on the option type, supplied by all thirteen writers, and read nowhere.
948
+
949
+ ${jsGuardTable('INTUTIC_FLOOR', floor)}
950
+
951
+ // Emitted from NORMALISE_CONTRACT, not retyped. The previous hand-written copy
952
+ // had already drifted from the in-process one on null handling.
953
+ ${NORMALISE_CONTRACT.jsSource}
954
+
955
+ ${JS_SNAPSHOT_LOADER}
956
+
957
+ ${jsHoldHelpers(opts.reviewRequestFile)}
958
+
959
+ /**
960
+ * Refuses a parsed payload that carries no recognisable tool-call envelope.
961
+ *
962
+ * The shared gate extracts by known field names, and an envelope it does not
963
+ * recognise extracts to EMPTY strings — which match no rule, i.e. the gate
964
+ * degrades to ALLOW. These hooks receive only PreToolUse events per each
965
+ * harness's contract, so a payload from which neither a tool name nor a
966
+ * tool_input can be extracted is anomalous by definition: there is no
967
+ * legitimate PreToolUse event with no tool call in it. Refusing is the posture
968
+ * githubCopilotHooks established for its Preview envelope, now uniform.
969
+ *
970
+ * \`keys\` names the envelope fields THIS writer's extractor actually reads —
971
+ * cursor and windsurf accept flat payloads (fields at the top level, no
972
+ * tool_input wrapper), so their key lists are wider than the canonical four.
973
+ * A guard stricter than the extractor would refuse traffic the gate can
974
+ * evaluate perfectly well, and a false positive here teaches operators to
975
+ * disable the hook.
976
+ */
977
+ function intuticGuardEnvelope(ctx, keys, record) {
978
+ if (ctx && typeof ctx === 'object') {
979
+ for (var _gi = 0; _gi < keys.length; _gi++) {
980
+ if (ctx[keys[_gi]] !== undefined) return;
981
+ }
982
+ }
983
+ const reason = '[Intutic Governance] BLOCKED: unrecognised PreToolUse payload — ' +
984
+ 'neither a tool name nor a tool_input could be extracted. This hook receives only ' +
985
+ 'PreToolUse events, so a payload with no tool call in it is anomalous by definition; ' +
986
+ 'refusing rather than allowing a call the gate cannot read.';
987
+ try { console.error(reason); } catch (e) {}
988
+ try { record('tool_blocked', 'unknown', reason); } catch (e) {}
989
+ ${refuse}
990
+ }
991
+
992
+ /**
993
+ * Evaluates one tool call. Returns normally to allow; refuses via this harness's
994
+ * contract otherwise.
995
+ *
996
+ * Takes its inputs as parameters rather than closing over the writer's locals:
997
+ * the eight JS writers name and shape those differently, and a gate that
998
+ * silently reads the wrong variable is the failure this file exists to prevent.
999
+ * \`record\` is called as (verdict, toolName, reason) — writers whose own logger
1000
+ * takes an env argument pass a lambda.
1001
+ *
1002
+ * \`toolInput\` is the raw tool_input OBJECT (not a string): WHERE rules match
1003
+ * their argPattern against JSON.stringify(tool_input) — the compact shape
1004
+ * matchSopRule and the intutic-clawde gate port pin — and the gate serializes
1005
+ * it itself so no writer can hand it a differently-shaped string.
1006
+ */
1007
+ function intuticGate(toolName, target, command, record, workspaceId, toolInput, sessionId) {
1008
+ // M3: Cline's \`use_mcp_tool\` envelope, normalized into the
1009
+ // \`mcp__<server>__<tool>\` shape every other harness's MCP tool name already
1010
+ // takes — BEFORE any rule fires. Cline's own tool-call schema names the
1011
+ // server and the tool as separate arguments (\`server_name\`, \`tool_name\`,
1012
+ // alongside \`arguments\`) rather than composing them into one tool name, so
1013
+ // without this a workspace's own \`mcp__github__.*\`-shaped SOP rule — and
1014
+ // the allowlist check below — would silently never fire on a Cline-shaped
1015
+ // call. Done once, here, rather than per-harness: any JS writer whose
1016
+ // envelope happens to produce \`toolName === 'use_mcp_tool'\` with these
1017
+ // fields benefits, not just Cline's.
1018
+ if (toolName === 'use_mcp_tool' && toolInput && typeof toolInput === 'object') {
1019
+ var _clineServer = toolInput.server_name || toolInput.serverName;
1020
+ var _clineTool = toolInput.tool_name || toolInput.toolName;
1021
+ if (_clineServer && _clineTool) {
1022
+ toolName = 'mcp__' + _clineServer + '__' + _clineTool;
1023
+ }
1024
+ }
1025
+ // See the shell emitter for why this exists: the alternative escape a blocked
1026
+ // developer reaches for is chflags nouchg on the hook itself. Dynamic tier
1027
+ // only — the compiled floor below is never disabled by it — and every
1028
+ // invocation is recorded.
1029
+ // Skips the destructive family only — never the floor, and never a
1030
+ // workspace's own BLOCK: SOP rules. See the shell emitter.
1031
+ const disabled = process.env.INTUTIC_GUARD_DISABLE === '1';
1032
+ if (disabled) {
1033
+ try {
1034
+ record('guards_disabled', toolName,
1035
+ 'INTUTIC_GUARD_DISABLE=1 — policy-snapshot rules skipped; built-in protections still active');
1036
+ } catch (e) {}
1037
+ }
1038
+ const snap = intuticLoadSnapshot(workspaceId || '');
1039
+
1040
+ // Report the snapshot's condition once per invocation. These states were
1041
+ // computed and then never read — an operator could not tell "snapshot missing
1042
+ // on 400 machines" from "snapshot present and healthy", which is the same
1043
+ // blindness the liveness work exists to remove.
1044
+ if (snap.state !== 'ok') {
1045
+ var _msg = snap.state === 'absent'
1046
+ ? 'No policy snapshot — built-in protections only'
1047
+ : snap.state === 'invalid'
1048
+ ? 'Policy snapshot failed its digest or workspace check — dynamic rules dropped'
1049
+ : snap.state === 'empty'
1050
+ ? 'Policy snapshot contains no rules — the compile produced nothing'
1051
+ : 'Policy snapshot is ' + snap.ageDays + ' days old and still enforced';
1052
+ try { record('snapshot_' + snap.state, toolName, _msg); } catch (e) {}
1053
+ }
1054
+ if (disabled) snap.rules = snap.rules.filter(function (r) { return r.id.indexOf('destructive.') !== 0; });
1055
+ // JSON.stringify(tool_input) — no replacer, no spacing — matching matchSopRule.
1056
+ // stringify(undefined) is undefined, hence the fallbacks; a throw cannot
1057
+ // happen on an object that came out of JSON.parse, but a gate must not bet
1058
+ // its exit code on that.
1059
+ var toolInputJson = '{}';
1060
+ try { toolInputJson = JSON.stringify(toolInput == null ? {} : toolInput) || '{}'; } catch (e) {}
1061
+ const nCommand = intuticNormalise(command);
1062
+ const nTarget = intuticNormalise(target);
1063
+ const nTool = intuticNormalise(toolName);
1064
+ // The space-padded action tokens the command classifies to, for \`action\`
1065
+ // subject rules (" action:deploy ").
1066
+ const nActions = intuticActions(toolName, command);
1067
+ const rules = INTUTIC_FLOOR.concat(snap.rules);
1068
+ for (const rule of rules) {
1069
+ // Each field is tested separately rather than concatenated — joining them
1070
+ // lets a pattern match across the seam between two innocuous values.
1071
+ const subjects =
1072
+ rule.subject === 'tool' ? [nTool]
1073
+ : rule.subject === 'command' ? [nCommand]
1074
+ : rule.subject === 'target' ? [nTarget]
1075
+ // The serialized tool input, un-normalised: a content rule (the
1076
+ // secrets.* floor) matches the bytes a Write would put on disk. The
1077
+ // same string the WHERE argPattern machinery tests, so the two content
1078
+ // subjects cannot drift.
1079
+ : rule.subject === 'content' ? [toolInputJson]
1080
+ : rule.subject === 'action' ? [nActions]
1081
+ : [nCommand, nTarget];
1082
+ for (const subject of subjects) {
1083
+ if (!rule.re.test(subject)) continue;
1084
+ // The argument condition of a WHERE rule: the tool-name half has
1085
+ // matched, and the rule fires only if the argPattern also matches the
1086
+ // serialized tool input. A pattern that failed to compile at load time
1087
+ // enforces name-only — today's behaviour — and says so, rather than
1088
+ // silently vanishing (fail open) or crashing the gate (exit 1, which
1089
+ // every harness reads as allow).
1090
+ if (rule.argDowngraded) {
1091
+ try {
1092
+ record('rule_downgraded', toolName,
1093
+ 'argPattern for ' + rule.id + ' did not compile — rule enforced name-only');
1094
+ } catch (e) {}
1095
+ } else if (rule.argRe && !rule.argRe.test(toolInputJson)) {
1096
+ continue;
1097
+ }
1098
+ if (rule.severity === 'shadow') {
1099
+ // Certain rule, deliberately not acted on. Counted apart from warn.
1100
+ try { record('tool_would_block', toolName, rule.reason + ' [' + rule.id + ']'); } catch (e) {}
1101
+ continue;
1102
+ }
1103
+ if (rule.severity === 'hold') {
1104
+ // Not a permanent denial: the action is legitimate, someone asked to
1105
+ // see it first. An approved bypass lets this exact call through once;
1106
+ // otherwise the hold is recorded for review and the call refused.
1107
+ const held = intuticHold(rule, toolName, command, target, toolInput, record, workspaceId, sessionId);
1108
+ if (held.bypassed) continue;
1109
+ const reason = '[Intutic Governance] HELD: ' + rule.reason + ' [' + rule.id + '] — approve with: intutic decision approve ' + held.holdId;
1110
+ ${refuse}
1111
+ }
1112
+ if (rule.severity === 'warn') {
1113
+ // Advisory tier — allowed, recorded with the rule id and the command's
1114
+ // first token. The verb is what makes a flag triageable as true or false
1115
+ // positive; without it the promotion rule this tier exists to serve is
1116
+ // not measurable. Arguments and paths stay out — that is DLP's job.
1117
+ try {
1118
+ record('tool_flagged', toolName,
1119
+ rule.reason + ' [' + rule.id + '] verb=' + nCommand.trim().split(' ')[0]);
1120
+ } catch (e) {}
1121
+ // The rollback rung's storage half: this call was flagged and is about
1122
+ // to proceed, which is the only instant the prior bytes still exist.
1123
+ // No-op unless the workspace opted in.
1124
+ try { _intuticCapturePreImage(toolName, toolInput, rule.id); } catch (e) {}
1125
+ continue;
1126
+ }
1127
+ // The rule's own reason, not a generic one: hookEvents.resolveSeverity
1128
+ // reads this text for "governance-protected" to file it CRITICAL.
1129
+ const reason = rule.reason + ' [' + rule.id + ']';
1130
+ try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
1131
+ try { record('tool_blocked', toolName, reason); } catch (e) {}
1132
+ ${refuse}
1133
+ }
1134
+ }
1135
+
1136
+ // M3: MCP per-server allowlist backstop — see the shell emitter for why
1137
+ // this is a plain membership test over the parsed \`#mcpservers\` header
1138
+ // rather than a synthetic GuardPattern rule (no portable regex can express
1139
+ // "allow only these servers" without negative lookahead). Only fires when
1140
+ // \`toolName\` is actually \`mcp__<server>__<tool>\`-shaped AND the header was
1141
+ // present (\`snap.mcpServers.length > 0\` — an absent header means
1142
+ // unrestricted). Refuses through the SAME \`record\`/\`\${refuse}\` path as
1143
+ // every other block above, not a parallel mechanism.
1144
+ if (snap.mcpServers.length > 0 && toolName.indexOf('mcp__') === 0) {
1145
+ var _mcpRest = toolName.slice('mcp__'.length);
1146
+ var _mcpSep = _mcpRest.indexOf('__');
1147
+ var _mcpServer = _mcpSep >= 0 ? _mcpRest.slice(0, _mcpSep) : '';
1148
+ if (_mcpServer && snap.mcpServers.indexOf(_mcpServer) === -1) {
1149
+ var _mcpReason = 'MCP server "' + _mcpServer + '" is not on the MCP server allowlist for this workspace [mcp_allowlist]';
1150
+ if (snap.mcpSeverity === 'shadow') {
1151
+ try { record('tool_would_block', toolName, _mcpReason); } catch (e) {}
1152
+ } else {
1153
+ try { console.error('[Intutic Governance] BLOCKED: ' + _mcpReason); } catch (e) {}
1154
+ try { record('tool_blocked', toolName, _mcpReason); } catch (e) {}
1155
+ // \`reason\` is what \`\${refuse}\` reads. The rule loop's own \`reason\` is
1156
+ // block-scoped to that loop, so without this the two stdout contracts
1157
+ // and the throw contract would hit a ReferenceError here — a refusal
1158
+ // that crashes is still a refusal for exit-code gates, and a
1159
+ // ReferenceError for the others.
1160
+ var reason = _mcpReason;
1161
+ ${refuse}
1162
+ }
1163
+ }
1164
+ }
1165
+ }
1166
+ // ── end Intutic gate body ────────────────────────────────────────────────────
1167
+ `;
1168
+ }
1169
+ /**
1170
+ * Pre-image capture — the storage half of the rollback enforcement rung (TD-328).
1171
+ *
1172
+ * ## Why this exists and why it is here
1173
+ *
1174
+ * The enforcement ladder jumps from Reask (weak) to Kill (destructive). The
1175
+ * missing rung is **Revert**: undo a flagged action's effect instead of ending
1176
+ * the session over it. Reverting needs a pre-image, and nothing in the system
1177
+ * had one — `ChangeEntry` records `tool`, `op`, `target` and deliberately
1178
+ * **never the content being written**, so a manifest can say *what* changed
1179
+ * and not *to what*.
1180
+ *
1181
+ * It belongs in the PreToolUse gate and nowhere else. The proxy sees tool calls
1182
+ * the model *proposed*, before the harness runs them; only the hook fires in
1183
+ * the instant between "this call was flagged and allowed" and "the file
1184
+ * changed". That instant is the only place the prior bytes still exist.
1185
+ *
1186
+ * ## What is captured, and what is refused
1187
+ *
1188
+ * Only the `warn` tier — a call a guard flagged and let proceed. Blocked calls
1189
+ * changed nothing, so there is nothing to restore; clean calls were never
1190
+ * interesting. That keeps the volume proportional to flags rather than to
1191
+ * edits.
1192
+ *
1193
+ * **Off unless asked for, and that is not this codebase's usual default.**
1194
+ * Every other guard here is on by default because being wrong costs a false
1195
+ * positive. This one stores the *contents* of the user's files on their disk,
1196
+ * so being wrong costs a copy of their work nobody consented to. It follows
1197
+ * the `mirror_sample_rate: 0.0` precedent: a capability with a real external
1198
+ * cost is opted into, not defaulted on. `.intutic/rollback/` is gitignored by
1199
+ * the same writer that creates it.
1200
+ *
1201
+ * Three ceilings, each refusing rather than truncating — a partial pre-image
1202
+ * restores a file to a state it was never in, which is worse than no rollback:
1203
+ * * `MAX_PREIMAGE_BYTES` per file
1204
+ * * `MAX_PREIMAGE_ENTRIES` retained, oldest evicted
1205
+ * * only paths inside the workspace root, resolved — a capture of
1206
+ * `../../.ssh/id_rsa` would be an exfiltration primitive wearing a
1207
+ * governance badge.
1208
+ */
1209
+ export function emitPreImageCapture() {
1210
+ return `
1211
+ // ── Intutic pre-image capture (rollback rung, TD-328) ────────────────────────
1212
+ const _INTUTIC_ROLLBACK_DIR = path.join(process.cwd(), '.intutic', 'rollback');
1213
+ const _INTUTIC_MAX_PREIMAGE_BYTES = 2 * 1024 * 1024;
1214
+ const _INTUTIC_MAX_PREIMAGE_ENTRIES = 50;
1215
+
1216
+ /** Opt-in. Absent file or absent flag means OFF — see the emitter's docstring. */
1217
+ function _intuticRollbackEnabled() {
1218
+ try {
1219
+ const cfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), '.intutic', 'config.json'), 'utf-8'));
1220
+ return cfg && cfg.captureRollbackPreImages === true;
1221
+ } catch (e) { return false; }
1222
+ }
1223
+
1224
+ /** The file path a tool call targets, or null when it targets none. */
1225
+ function _intuticTargetPath(input) {
1226
+ if (!input || typeof input !== 'object') return null;
1227
+ for (const k of ['file_path', 'filePath', 'path', 'notebook_path', 'target_file']) {
1228
+ const v = input[k];
1229
+ if (typeof v === 'string' && v.length > 0) return v;
1230
+ }
1231
+ return null;
1232
+ }
1233
+
1234
+ /**
1235
+ * Copies the CURRENT contents of a flagged call's target aside.
1236
+ *
1237
+ * Silent on every failure: a capture that cannot happen must never turn an
1238
+ * allowed call into a blocked one. The gate's job is governance; this is a
1239
+ * convenience layered on top of it, and inverting that would make the feature
1240
+ * a new way to break the user's workflow.
1241
+ */
1242
+ function _intuticCapturePreImage(toolName, input, ruleId) {
1243
+ try {
1244
+ if (!_intuticRollbackEnabled()) return;
1245
+ const rel = _intuticTargetPath(input);
1246
+ if (!rel) return;
1247
+
1248
+ const root = fs.realpathSync(process.cwd());
1249
+ // BOTH sides resolved, or the check silently disables capture wherever the
1250
+ // workspace path contains a symlink — which on macOS is the default for
1251
+ // /tmp and common for home directories. Comparing a realpath'd root
1252
+ // against an unresolved target compares /private/var/... with /var/...,
1253
+ // so containment fails, the function returns, and nothing is captured
1254
+ // while everything reports success. The file may not exist yet, so the
1255
+ // deepest EXISTING ancestor is what gets resolved.
1256
+ let abs = path.resolve(root, rel);
1257
+ try {
1258
+ abs = fs.realpathSync(abs);
1259
+ } catch (e) {
1260
+ let dir = path.dirname(abs);
1261
+ const base = [path.basename(abs)];
1262
+ for (;;) {
1263
+ try { dir = fs.realpathSync(dir); break; } catch (e2) {
1264
+ const parent = path.dirname(dir);
1265
+ if (parent === dir) break;
1266
+ base.unshift(path.basename(dir));
1267
+ dir = parent;
1268
+ }
1269
+ }
1270
+ abs = path.join(dir, ...base);
1271
+ }
1272
+ // Inside the workspace. Without this a crafted '../../.ssh/id_rsa' would
1273
+ // have the gate copy a secret into a directory the agent can read back —
1274
+ // an exfiltration primitive wearing a governance badge.
1275
+ if (abs !== root && !abs.startsWith(root + path.sep)) return;
1276
+
1277
+ // A file that does not exist yet has no pre-image; restoring it means
1278
+ // deleting it, which the manifest records as a creation.
1279
+ let content = null, existed = false;
1280
+ try {
1281
+ const st = fs.statSync(abs);
1282
+ if (!st.isFile()) return;
1283
+ if (st.size > _INTUTIC_MAX_PREIMAGE_BYTES) return; // refuse, never truncate
1284
+ content = fs.readFileSync(abs);
1285
+ existed = true;
1286
+ } catch (e) { existed = false; }
1287
+
1288
+ fs.mkdirSync(_INTUTIC_ROLLBACK_DIR, { recursive: true });
1289
+ // The directory holds copies of the user's files; it must never be
1290
+ // committed. Written every time because the directory may be new.
1291
+ try {
1292
+ fs.writeFileSync(path.join(_INTUTIC_ROLLBACK_DIR, '.gitignore'), '*\\n', 'utf-8');
1293
+ } catch (e) {}
1294
+
1295
+ const id = crypto.randomBytes(8).toString('hex');
1296
+ if (existed) fs.writeFileSync(path.join(_INTUTIC_ROLLBACK_DIR, id + '.blob'), content);
1297
+
1298
+ const entry = {
1299
+ id: id,
1300
+ capturedAt: new Date().toISOString(),
1301
+ tool: toolName,
1302
+ target: abs,
1303
+ existed: existed,
1304
+ bytes: existed ? content.length : 0,
1305
+ ruleId: ruleId || '',
1306
+ workspaceId: _intuticWsId,
1307
+ };
1308
+ fs.appendFileSync(
1309
+ path.join(_INTUTIC_ROLLBACK_DIR, 'manifest.jsonl'),
1310
+ JSON.stringify(entry) + '\\n',
1311
+ { flag: 'a' }
1312
+ );
1313
+
1314
+ // Evict oldest beyond the ceiling, blob and line together — a manifest
1315
+ // line whose blob was reaped restores nothing while claiming it can.
1316
+ try {
1317
+ const lines = fs.readFileSync(path.join(_INTUTIC_ROLLBACK_DIR, 'manifest.jsonl'), 'utf-8')
1318
+ .split('\\n').filter(Boolean);
1319
+ if (lines.length > _INTUTIC_MAX_PREIMAGE_ENTRIES) {
1320
+ const drop = lines.slice(0, lines.length - _INTUTIC_MAX_PREIMAGE_ENTRIES);
1321
+ for (const l of drop) {
1322
+ try { fs.unlinkSync(path.join(_INTUTIC_ROLLBACK_DIR, JSON.parse(l).id + '.blob')); } catch (e) {}
1323
+ }
1324
+ fs.writeFileSync(
1325
+ path.join(_INTUTIC_ROLLBACK_DIR, 'manifest.jsonl'),
1326
+ lines.slice(lines.length - _INTUTIC_MAX_PREIMAGE_ENTRIES).join('\\n') + '\\n',
1327
+ 'utf-8'
1328
+ );
1329
+ }
1330
+ } catch (e) {}
1331
+ } catch (e) { /* never block on a capture failure */ }
1332
+ }
1333
+ // ── end pre-image capture ────────────────────────────────────────────────────
1334
+ `;
1335
+ }
1336
+ /**
1337
+ * The JS fail-closed prelude — the FIRST statements of every emitted JS gate.
1338
+ *
1339
+ * The edge it closes: each writer's stdin handler wraps its work in a
1340
+ * try/catch that fails closed, but a crash OUTSIDE that handler — a
1341
+ * module-load failure, an unhandled promise rejection from an async path —
1342
+ * ended the process with Node's default exit 1. Every exit-code harness reads
1343
+ * a non-2 non-zero exit as "the hook errored" and ALLOWS the call, and the
1344
+ * stdout-cancel harnesses saw no cancel object, which is also an allow. So a
1345
+ * gate that crashed was indistinguishable from a gate that approved.
1346
+ *
1347
+ * Interpolated by the writer as the first executable code in the script —
1348
+ * before any `require` (after `'use strict'` where the writer has one, since a
1349
+ * directive must lead the file to take effect) — so everything that runs is
1350
+ * covered. The one thing it cannot cover is a SyntaxError in the emitted file
1351
+ * itself: Node rejects the whole module before any statement executes,
1352
+ * handlers included. That residue is pinned by the behaviour tests running
1353
+ * every emitted gate for real.
1354
+ *
1355
+ * Refuses through the harness's contract, like everything else: exit 2 means
1356
+ * nothing to cline or roo-code, whose harnesses only read stdout — a crashed
1357
+ * stdout-cancel gate must still print its cancel object.
1358
+ */
1359
+ export function emitJsFailClosedPrelude(opts) {
1360
+ const crashBody = opts.contract === 'stdout-cancel'
1361
+ ? ` try {\n` +
1362
+ ` process.stdout.write(JSON.stringify({ cancel: true, reason:\n` +
1363
+ ` '[Intutic Governance] gate crashed — failing closed: ' + String((err && err.stack) || err) }) + '\\n');\n` +
1364
+ ` } catch (e) { /* stdout gone too — nothing left to refuse through */ }\n` +
1365
+ ` process.exit(0);`
1366
+ : opts.contract === 'stdout-decision-deny'
1367
+ ? ` try {\n` +
1368
+ ` process.stdout.write(JSON.stringify({ decision: 'deny', reason:\n` +
1369
+ ` '[Intutic Governance] gate crashed — failing closed: ' + String((err && err.stack) || err) }) + '\\n');\n` +
1370
+ ` } catch (e) { /* stdout gone too — nothing left to refuse through */ }\n` +
1371
+ ` process.exit(0);`
1372
+ : ` try {\n` +
1373
+ ` process.stderr.write('[Intutic Governance] BLOCKED: gate crashed — failing closed: ' +\n` +
1374
+ ` String((err && err.stack) || err) + '\\n');\n` +
1375
+ ` } catch (e) { /* stderr gone too — the exit code still blocks */ }\n` +
1376
+ ` process.exit(2);`;
1377
+ return `// ── Intutic fail-closed prelude v${GATE_VERSION} — harness: ${opts.harness} ──
1378
+ // FIRST statements on purpose, before any require: the stdin handler's catch
1379
+ // fails closed, but a crash outside it — a module-load failure, an unhandled
1380
+ // promise rejection — used to exit 1, which every exit-code harness reads as
1381
+ // "hook errored" and ALLOWS, and which prints no cancel object for the
1382
+ // stdout-cancel harnesses, also an allow. Everything that executes after this
1383
+ // line is covered; a SyntaxError in this file itself is the one thing that is
1384
+ // not, because no statement in a file that does not parse ever runs.
1385
+ function _intuticCrash(err) {
1386
+ ${crashBody}
1387
+ }
1388
+ process.on('uncaughtException', _intuticCrash);
1389
+ process.on('unhandledRejection', _intuticCrash);`;
1390
+ }
1391
+ /**
1392
+ * The single `python3` invocation that replaces the three or four each bash gate
1393
+ * used to spawn.
1394
+ *
1395
+ * Process startup dominated the old gates: three `python3` spawns is 75–200ms on
1396
+ * the tool path, more than rule evaluation will ever cost. This does the whole
1397
+ * extraction in one, and collapses whitespace while it is there — which also
1398
+ * makes the three values safe to read back as three lines.
1399
+ *
1400
+ * **Fails closed if there is no parser.** Without one the gate cannot know what
1401
+ * it is being asked to allow, and the old behaviour — empty tool, empty target,
1402
+ * allow — was a silent hole. The message names the fix, because a developer who
1403
+ * hits this needs to act on it rather than file a bug.
1404
+ */
1405
+ export const SHELL_EXTRACT = `
1406
+ if ! command -v python3 >/dev/null 2>&1; then
1407
+ echo "[Intutic Governance] BLOCKED: python3 is required to evaluate tool calls and was not found on PATH." >&2
1408
+ echo "[Intutic Governance] Install python3, or unset the Intutic hooks to run ungoverned." >&2
1409
+ exit 2
1410
+ fi
1411
+
1412
+ INTUTIC_FIELDS="$(printf '%s' "$INPUT" | python3 -c '
1413
+ import sys, json
1414
+ def clean(v):
1415
+ return " ".join(str(v).split()) if isinstance(v, str) else ""
1416
+ state = "ok"
1417
+ try:
1418
+ d = json.load(sys.stdin)
1419
+ except Exception:
1420
+ d = {}
1421
+ state = "malformed"
1422
+ if not isinstance(d, dict):
1423
+ d = {}
1424
+ state = "malformed"
1425
+ # The envelope check githubCopilotHooks established, applied to the bash
1426
+ # family: a payload carrying none of the tool fields in any known spelling is
1427
+ # one the gate cannot evaluate, and extracting empty fields from it would
1428
+ # match no rule — an allow. Reported as a STATE and decided in the gate body,
1429
+ # because log_event is not defined yet at extraction time.
1430
+ if state == "ok" and not any(
1431
+ k in d for k in ("tool_name", "toolName", "tool_input", "toolInput")
1432
+ ):
1433
+ state = "malformed"
1434
+ i = d.get("tool_input") or {}
1435
+ if not isinstance(i, dict):
1436
+ i = {}
1437
+ def first(*keys):
1438
+ for k in keys:
1439
+ v = i.get(k)
1440
+ if isinstance(v, str) and v:
1441
+ return clean(v)
1442
+ return ""
1443
+ # The state line comes FIRST: the lines after it may legitimately be empty,
1444
+ # and command substitution strips trailing newlines, so the last line is the
1445
+ # only position an empty value cannot survive in.
1446
+ print(state)
1447
+ print(clean(d.get("tool_name", "")))
1448
+ print(first("path", "file_path", "notebook_path", "filePath"))
1449
+ print(first("command", "cmd", "script", "shell_command"))
1450
+ print(clean(d.get("session_id", d.get("sessionId", ""))))
1451
+ # The FULL tool_input, serialized to the exact shape argPattern rules are
1452
+ # matched against everywhere: JSON.stringify(tool_input) — compact separators,
1453
+ # insertion order, non-ASCII intact. The selected fields above are whitespace-
1454
+ # collapsed for token matching; this one must NOT be, or a WHERE clause that
1455
+ # spans a key/value boundary matches here and not in the JS gates. json.dumps
1456
+ # escapes every newline, so it is still exactly one line to read back.
1457
+ print(json.dumps(i, separators=(",", ":"), ensure_ascii=False))
1458
+ ' 2>/dev/null || printf '\\n\\n\\n\\n\\n\\n')"
1459
+
1460
+ # Each read is \`|| true\` because command substitution strips trailing newlines:
1461
+ # a tool call with no command argument yields fewer lines than reads, so a late
1462
+ # read hits EOF and returns non-zero. Under \`set -e\` that killed the gate with
1463
+ # **exit 1** — which every harness reads as a hook error and lets the call
1464
+ # through. A guard that fails open on the most ordinary input there is (a Write
1465
+ # with no shell command) is worse than no guard, because it looks present.
1466
+ { 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
1467
+ $INTUTIC_FIELDS
1468
+ EOF_INTUTIC_FIELDS
1469
+ TOOL="\${TOOL:-}"; TARGET="\${TARGET:-}"; COMMAND="\${COMMAND:-}"; SESSION_ID="\${SESSION_ID:-}"
1470
+ # Empty means the extractor itself died (the fallback printf above): treat it
1471
+ # exactly like a payload the parser rejected. The gate body refuses on any
1472
+ # value other than "ok" — see the envelope refusal in emitShellGate.
1473
+ INTUTIC_EXTRACT_STATE="\${INTUTIC_EXTRACT_STATE:-}"
1474
+ [ -n "$INTUTIC_EXTRACT_STATE" ] || INTUTIC_EXTRACT_STATE="malformed"
1475
+ # Empty only when the extractor failed wholesale (malformed stdin). Defaulting
1476
+ # to the empty OBJECT rather than the empty string keeps the argPattern match
1477
+ # well-defined either way.
1478
+ TOOL_INPUT_JSON="\${TOOL_INPUT_JSON:-}"
1479
+ [ -n "$TOOL_INPUT_JSON" ] || TOOL_INPUT_JSON="{}"
1480
+ # Aliases for the names the older writers used. Kept so this extractor is a
1481
+ # drop-in for every bash gate; the canonical names are the unsuffixed ones.
1482
+ TOOL_NAME="$TOOL"; TARGET_PATH="$TARGET"
1483
+ `;
1484
+ /**
1485
+ * The Python gate, for Open WebUI.
1486
+ *
1487
+ * Open WebUI filters see a **prompt**, not a tool call — there is no tool name,
1488
+ * no path argument and no shell command. So this deliberately does less than the
1489
+ * other two emitters, and the difference is the point:
1490
+ *
1491
+ * - It **can** refuse. `inlet()` used to hardcode `"event": "tool_allowed"` and
1492
+ * return the body unconditionally; it was notification-only, and nothing said
1493
+ * so. Raising from `inlet()` is how an Open WebUI filter blocks, and that path
1494
+ * now exists.
1495
+ * - It **does not** block on the compiled floor. A prompt that mentions
1496
+ * `.claude/settings.json` is someone asking about a file, not an agent writing
1497
+ * it, and refusing to discuss a path is the kind of false positive that gets a
1498
+ * governance tool switched off. Those rules flag instead.
1499
+ * - It **does** refuse on a snapshot rule whose severity is `block`, so a
1500
+ * workspace that wants a hard stop here can have one without a release.
1501
+ *
1502
+ * A control that claims more than it delivers is worse than one that claims
1503
+ * less, so `enforces` for this harness in `gateRegistry.ts` says exactly this.
1504
+ */
1505
+ export function emitPythonGate() {
1506
+ const floor = staticFloorPatterns();
1507
+ const rows = floor.map((p) => ` (${JSON.stringify(p.id)}, ${JSON.stringify(p.source)}, ` +
1508
+ `${p.ignoreCase ? 're.IGNORECASE' : '0'}, ${JSON.stringify(p.reason)}),`);
1509
+ return `
1510
+ # ── Intutic gate body v${GATE_VERSION} — harness: open-webui ─────────────────
1511
+ # Prompts are flagged, never blocked, by these. See emitPythonGate().
1512
+ _INTUTIC_FLOOR = [
1513
+ ${rows.join('\n')}
1514
+ ]
1515
+
1516
+
1517
+ ${NORMALISE_CONTRACT.pySource}
1518
+
1519
+
1520
+ _state = {"digest": "", "workspace": ""}
1521
+
1522
+
1523
+ def _intutic_snapshot_rules():
1524
+ """Reads the policy snapshot the sync daemon writes. Absent is normal."""
1525
+ # Dynamic tier only — the floor above is never disabled by this. See the
1526
+ # shell emitter in gateBody.ts for why a supported escape hatch is safer
1527
+ # than the one a blocked developer would otherwise invent.
1528
+ # Destructive family only — never the floor, never the workspace's own
1529
+ # BLOCK: SOP rules. See the shell emitter in gateBody.ts.
1530
+ _skip_destructive = os.environ.get("INTUTIC_GUARD_DISABLE") == "1"
1531
+ p = os.environ.get("INTUTIC_SNAPSHOT_RULES") or os.path.join(
1532
+ os.path.expanduser("~"), ".intutic", "hooks", "policy-snapshot.rules"
1533
+ )
1534
+ out = []
1535
+ try:
1536
+ with open(p, "r", encoding="utf-8") as fh:
1537
+ for line in fh:
1538
+ line = line.rstrip("\\n")
1539
+ # The digest and workspace lines are read, not merely skipped —
1540
+ # an unverified digest is a comment, and an unchecked workspace
1541
+ # id means another workspace's rules enforce here silently.
1542
+ if line.startswith("#digest "):
1543
+ _state["digest"] = line[8:].strip()
1544
+ continue
1545
+ if line.startswith("#workspace "):
1546
+ _state["workspace"] = line[11:].strip()
1547
+ continue
1548
+ if not line or line.startswith("#"):
1549
+ continue
1550
+ f = line.split("\\t")
1551
+ if len(f) < 6 or not f[5]:
1552
+ continue
1553
+ # This filter evaluates a PROMPT, not a tool call, so only
1554
+ # rules whose subject is meaningful for prose may apply.
1555
+ # A tool-subject rule (BLOCK:Bash compiles to subject "tool")
1556
+ # is a pattern over tool NAMES; matched against prompt text it
1557
+ # made Open WebUI refuse any prompt containing the word
1558
+ # "Bash". Skipped — a prompt has no tool name. f[3] was read
1559
+ # by the other three gate families and ignored here, which is
1560
+ # how that shipped.
1561
+ if f[3] == "tool":
1562
+ continue
1563
+ # Likewise a WHERE rule (seventh column, base64 argPattern) is
1564
+ # conditioned on tool ARGUMENTS that a prompt does not have.
1565
+ # Enforcing its tool pattern against prose would apply a rule
1566
+ # its author deliberately narrowed more broadly than written.
1567
+ if len(f) > 6 and f[6]:
1568
+ continue
1569
+ try:
1570
+ # A rule that will not compile is dropped, not fatal.
1571
+ if _skip_destructive and f[0].startswith("destructive."):
1572
+ continue
1573
+ out.append((f[0], f[5], re.IGNORECASE if f[2] == "i" else 0, f[4], f[1]))
1574
+ except Exception:
1575
+ continue
1576
+ except Exception:
1577
+ pass
1578
+
1579
+ # Integrity, same two checks as the shell and JS gates.
1580
+ try:
1581
+ with open(p, "r", encoding="utf-8") as fh:
1582
+ body = "\\n".join(l.rstrip("\\n") for l in fh
1583
+ if l.strip() and not l.startswith("#"))
1584
+ actual = hashlib.sha256(body.encode()).hexdigest()[:32]
1585
+ if _state["digest"] and actual != _state["digest"]:
1586
+ return []
1587
+ except Exception:
1588
+ pass
1589
+ if _state["workspace"] and WORKSPACE_ID and _state["workspace"] != WORKSPACE_ID:
1590
+ return []
1591
+ return out
1592
+
1593
+
1594
+ def _intutic_evaluate(text):
1595
+ """Returns (blocks, flags) for a prompt. Floor rules can only flag."""
1596
+ subject = _intutic_normalise(text)
1597
+ blocks, flags, shadowed = [], [], []
1598
+ for rid, src, flags_re, reason in _INTUTIC_FLOOR:
1599
+ try:
1600
+ if re.search(src, subject, flags_re):
1601
+ flags.append((rid, reason))
1602
+ except Exception:
1603
+ continue
1604
+ for rid, src, flags_re, reason, severity in _intutic_snapshot_rules():
1605
+ try:
1606
+ if not re.search(src, subject, flags_re):
1607
+ continue
1608
+ except Exception:
1609
+ continue
1610
+ if severity == "warn":
1611
+ flags.append((rid, reason))
1612
+ elif severity == "shadow":
1613
+ shadowed.append((rid, reason))
1614
+ else:
1615
+ # \`block\`, \`hold\` and anything this filter does not know. A
1616
+ # prompt filter has no tool call to hold and no reviewer to wait
1617
+ # for, so a hold refuses outright; and an unrecognised severity
1618
+ # must not read as advisory — that was the v7 fail-open edge.
1619
+ blocks.append((rid, reason))
1620
+ return blocks, flags, shadowed
1621
+ # ── end Intutic gate body ────────────────────────────────────────────────────
1622
+ `;
1623
+ }
1624
+ /**
1625
+ * The n8n workflow gate.
1626
+ *
1627
+ * A different unit of evaluation than every other JS gate, and the difference
1628
+ * is the whole design. n8n's only blocking mechanism is deployment-level:
1629
+ * `EXTERNAL_HOOK_FILES=/path/to/hook.js` names a module whose
1630
+ * `workflow.preExecute` functions run once per WORKFLOW execution and receive
1631
+ * the full Workflow object — every node with its parameters — not one tool
1632
+ * call. A hook that throws aborts the execution; that throw is the refusal.
1633
+ *
1634
+ * So the emitted evaluator walks the workflow's nodes and maps each onto the
1635
+ * shape the shared rules were written for:
1636
+ *
1637
+ * - the node's **type** (`n8n-nodes-base.executeCommand`, …) stands in for the
1638
+ * tool name — `subject: 'tool'` rules match it;
1639
+ * - the node's **serialized parameters** (compact `JSON.stringify`, the same
1640
+ * shape `matchSopRule` pins) stand in for the command/target — `command`,
1641
+ * `target` and `any` rules match the normalised serialization, and a WHERE
1642
+ * rule's argPattern matches the raw serialization, exactly as it matches
1643
+ * `JSON.stringify(tool_input)` in the per-tool gates.
1644
+ *
1645
+ * Granularity is honest here: one refusal aborts the whole execution, not one
1646
+ * node, because that is all preExecute can do. The BLOCKED message names the
1647
+ * offending node and rule so the operator knows which node to fix.
1648
+ *
1649
+ * Refuses by THROWING — there is no exit code and no stdout contract in-process.
1650
+ * An internal fault also throws, which n8n treats the same way: fail closed.
1651
+ *
1652
+ * Verified during the v5 fail-open audit and deliberately left alone: the
1653
+ * writer's preExecute wrapper has no try/catch around this gate (see the
1654
+ * comment at the module.exports site in n8nHooks.ts), so a fault inside the
1655
+ * evaluator aborts the execution exactly as a BLOCKED throw does. This is the
1656
+ * one family whose crash posture was already closed; installing process-level
1657
+ * exit handlers here would be actively wrong, since the gate runs inside the
1658
+ * n8n server process, not a per-call subprocess. Pinned by the "internal
1659
+ * fault" case in the n8n block of generatedGateFailClosed.test.ts.
1660
+ */
1661
+ export function emitN8nWorkflowGate() {
1662
+ const floor = staticFloorPatterns();
1663
+ return `
1664
+ // ── Intutic gate body v${GATE_VERSION} — harness: n8n (workflow-level) ───────
1665
+ // Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
1666
+ //
1667
+ // Contract: js-throw. This runs inside the n8n process as a workflow.preExecute
1668
+ // hook (EXTERNAL_HOOK_FILES); throwing is what aborts the execution. See
1669
+ // emitN8nWorkflowGate() for why the unit is a workflow, not a tool call.
1670
+
1671
+ ${jsGuardTable('INTUTIC_FLOOR', floor)}
1672
+
1673
+ // Emitted from NORMALISE_CONTRACT, not retyped. The previous hand-written copy
1674
+ // had already drifted from the in-process one on null handling.
1675
+ ${NORMALISE_CONTRACT.jsSource}
1676
+
1677
+ ${JS_SNAPSHOT_LOADER}
1678
+
1679
+ /**
1680
+ * Evaluates one workflow. Returns normally to allow; throws to abort the
1681
+ * execution. \`record\` is called as (verdict, toolName, reason), the same
1682
+ * contract every JS writer's logger speaks.
1683
+ */
1684
+ function intuticGateWorkflow(workflow, record, workspaceId) {
1685
+ // The escape hatch skips the destructive family only — never the compiled
1686
+ // floor, and never a workspace's own BLOCK: SOP rules. See the shell emitter
1687
+ // in gateBody.ts for why a visible escape is safer than the one a blocked
1688
+ // operator would otherwise invent.
1689
+ const disabled = process.env.INTUTIC_GUARD_DISABLE === '1';
1690
+ const wfName = (workflow && (workflow.name || workflow.id)) ? String(workflow.name || workflow.id) : 'workflow';
1691
+ if (disabled) {
1692
+ try {
1693
+ record('guards_disabled', 'n8n:' + wfName,
1694
+ 'INTUTIC_GUARD_DISABLE=1 — policy-snapshot rules skipped; built-in protections still active');
1695
+ } catch (e) {}
1696
+ }
1697
+ const snap = intuticLoadSnapshot(workspaceId || '');
1698
+ if (snap.state !== 'ok') {
1699
+ var _msg = snap.state === 'absent'
1700
+ ? 'No policy snapshot — built-in protections only'
1701
+ : snap.state === 'invalid'
1702
+ ? 'Policy snapshot failed its digest or workspace check — dynamic rules dropped'
1703
+ : snap.state === 'empty'
1704
+ ? 'Policy snapshot contains no rules — the compile produced nothing'
1705
+ : 'Policy snapshot is ' + snap.ageDays + ' days old and still enforced';
1706
+ try { record('snapshot_' + snap.state, 'n8n:' + wfName, _msg); } catch (e) {}
1707
+ }
1708
+ if (disabled) snap.rules = snap.rules.filter(function (r) { return r.id.indexOf('destructive.') !== 0; });
1709
+ const rules = INTUTIC_FLOOR.concat(snap.rules);
1710
+
1711
+ // BOTH shapes, verified live, and the difference is load-bearing: the docs'
1712
+ // external-hooks example passes a workflow whose \`nodes\` is an ARRAY, but
1713
+ // what \`workflow.preExecute\` actually receives in a running n8n (probed
1714
+ // against the current image with an argument-dumping hook) is a Workflow
1715
+ // class instance whose \`nodes\` is an OBJECT keyed by node name. The first
1716
+ // version of this gate handled only the array, iterated zero nodes on every
1717
+ // real execution, and allowed an offending workflow straight through while
1718
+ // every fixture-driven test — written to the docs' array shape — stayed
1719
+ // green. A shape neither branch recognises falls through to zero nodes and
1720
+ // is REFUSED below rather than allowed.
1721
+ var nodes = [];
1722
+ var nodesRecognised = false;
1723
+ if (workflow && Array.isArray(workflow.nodes)) {
1724
+ nodes = workflow.nodes; nodesRecognised = true;
1725
+ } else if (workflow && workflow.nodes && typeof workflow.nodes === 'object') {
1726
+ nodes = Object.values(workflow.nodes); nodesRecognised = true;
1727
+ }
1728
+ // Nothing to evaluate that we can SEE is nothing: fine. A workflow whose
1729
+ // nodes we cannot read at all is not fine — refusing is the only posture
1730
+ // that fails closed if n8n changes the shape again.
1731
+ if (!nodesRecognised) {
1732
+ try { record('tool_blocked', 'n8n:' + wfName, 'unrecognised workflow.nodes shape — failing closed'); } catch (e) {}
1733
+ throw new Error('[Intutic Governance] BLOCKED: unrecognised workflow shape — the governance gate cannot read the workflow nodes (n8n contract change?). Failing closed.');
1734
+ }
1735
+ for (const node of nodes) {
1736
+ if (!node || typeof node !== 'object') continue;
1737
+ const nodeType = String(node.type || '');
1738
+ const nodeName = String(node.name || nodeType || 'node');
1739
+ // Compact stringify, insertion order, non-ASCII intact — the same shape
1740
+ // matchSopRule and every per-tool gate pin for argPattern matching.
1741
+ var paramsJson = '{}';
1742
+ try { paramsJson = JSON.stringify(node.parameters == null ? {} : node.parameters) || '{}'; } catch (e) {}
1743
+ const nType = intuticNormalise(nodeType);
1744
+ // The padded BASENAME too, and this is load-bearing: writePolicySnapshot
1745
+ // space-pads source patterns (/ (shell|executeCommand) /), and the
1746
+ // per-tool gates match them against space-padded tool names. An n8n node
1747
+ // type is dot-namespaced ("n8n-nodes-base.executeCommand"), so against
1748
+ // the padded full type the pattern's leading space can never sit before
1749
+ // "executeCommand" — a real product snapshot matched ZERO node types in a
1750
+ // live server while the unit tests, whose fixture wrote unpadded
1751
+ // patterns, stayed green. Both candidates are tested: the padded full
1752
+ // type (for unpadded/fixture-style patterns, which match unanchored) and
1753
+ // the padded basename (for the product writer's padded patterns).
1754
+ const nTypeBase = intuticNormalise(nodeType.indexOf('.') >= 0 ? nodeType.slice(nodeType.lastIndexOf('.') + 1) : nodeType);
1755
+ const nParams = intuticNormalise(paramsJson);
1756
+ // Every string LEAF of the parameters, tested individually alongside the
1757
+ // whole serialization. Not redundancy: the shared patterns are written
1758
+ // against a normalised command, whose padding gives them their edges — in
1759
+ // the serialized JSON a command's last character is followed by a quote,
1760
+ // so a pattern like the destructive rm rule (which requires a boundary
1761
+ // after the path) matches the extracted string and NOT the JSON. Testing
1762
+ // each leaf restores exactly the shape the rules were written for, and
1763
+ // each is tested separately for the same anti-seam reason as ever.
1764
+ var _leaves = [];
1765
+ (function _walk(v) {
1766
+ if (typeof v === 'string') { _leaves.push(v); return; }
1767
+ if (Array.isArray(v)) { for (const x of v) _walk(x); return; }
1768
+ if (v && typeof v === 'object') { for (const k in v) _walk(v[k]); }
1769
+ })(node.parameters == null ? {} : node.parameters);
1770
+ const nLeaves = _leaves.map(intuticNormalise);
1771
+ for (const rule of rules) {
1772
+ // A node has no separate command/target — its parameters are both. A
1773
+ // 'tool' rule matches the node TYPE and nothing else, for the same
1774
+ // reason the per-tool gates never test a path pattern against "Write".
1775
+ const subjects = rule.subject === 'tool' ? [nType, nTypeBase] : [nParams].concat(nLeaves);
1776
+ for (const subject of subjects) {
1777
+ if (!rule.re.test(subject)) continue;
1778
+ if (rule.argDowngraded) {
1779
+ try {
1780
+ record('rule_downgraded', 'n8n:' + nodeName,
1781
+ 'argPattern for ' + rule.id + ' did not compile — rule enforced name-only');
1782
+ } catch (e) {}
1783
+ } else if (rule.argRe && !rule.argRe.test(paramsJson)) {
1784
+ continue;
1785
+ }
1786
+ if (rule.severity === 'shadow') {
1787
+ try { record('tool_would_block', 'n8n:' + nodeName, rule.reason + ' [' + rule.id + ']'); } catch (e) {}
1788
+ continue;
1789
+ }
1790
+ if (rule.severity === 'warn') {
1791
+ // Advisory tier — allowed, recorded with the rule id and the node
1792
+ // type. The type is what makes a flag triageable; parameters stay
1793
+ // out of telemetry deliberately — that is DLP's job.
1794
+ try { record('tool_flagged', 'n8n:' + nodeName, rule.reason + ' [' + rule.id + '] node=' + nodeType); } catch (e) {}
1795
+ continue;
1796
+ }
1797
+ // \`hold\` falls through to the refusal below: this gate runs inside
1798
+ // the n8n server, which has no workspace to record a hold in and no
1799
+ // developer at a prompt to approve one, so a hold is a block here.
1800
+ // The rule's own reason, not a generic one: hookEvents.resolveSeverity
1801
+ // reads this text for "governance-protected" to file it CRITICAL.
1802
+ const reason = rule.reason + ' [' + rule.id + '] (node "' + nodeName + '", type ' + nodeType + ')';
1803
+ try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
1804
+ try { record('tool_blocked', 'n8n:' + nodeName, reason); } catch (e) {}
1805
+ throw new Error('[Intutic Governance] BLOCKED: ' + reason);
1806
+ }
1807
+ }
1808
+ }
1809
+ }
1810
+ // ── end Intutic gate body ────────────────────────────────────────────────────
1811
+ `;
1812
+ }
1813
+ /** The destructive tier, for the snapshot writer to ship. Not compiled into any
1814
+ * gate — see the note on {@link DESTRUCTIVE_COMMAND_PATTERNS}. */
1815
+ export { DESTRUCTIVE_COMMAND_PATTERNS };
1816
+ //# sourceMappingURL=gateBody.js.map