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