@intutic/sync-daemon 2.1.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 +24 -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 +45 -25
- package/dist/configReader.d.ts.map +1 -1
- package/dist/configReader.js +70 -46
- package/dist/configReader.js.map +1 -1
- package/dist/configWriter.d.ts +32 -45
- package/dist/configWriter.d.ts.map +1 -1
- package/dist/configWriter.js +52 -686
- package/dist/configWriter.js.map +1 -1
- package/dist/disconnect/harnesses.d.ts +29 -0
- package/dist/disconnect/harnesses.d.ts.map +1 -0
- package/dist/disconnect/harnesses.js +805 -0
- package/dist/disconnect/harnesses.js.map +1 -0
- package/dist/disconnect/index.d.ts +25 -0
- package/dist/disconnect/index.d.ts.map +1 -0
- package/dist/disconnect/index.js +92 -0
- package/dist/disconnect/index.js.map +1 -0
- package/dist/disconnect/mcp.d.ts +81 -0
- package/dist/disconnect/mcp.d.ts.map +1 -0
- package/dist/disconnect/mcp.js +354 -0
- package/dist/disconnect/mcp.js.map +1 -0
- package/dist/disconnect/originals.d.ts +88 -0
- package/dist/disconnect/originals.d.ts.map +1 -0
- package/dist/disconnect/originals.js +257 -0
- package/dist/disconnect/originals.js.map +1 -0
- package/dist/disconnect/plan.d.ts +175 -0
- package/dist/disconnect/plan.d.ts.map +1 -0
- package/dist/disconnect/plan.js +580 -0
- package/dist/disconnect/plan.js.map +1 -0
- package/dist/disconnect/recognise.d.ts +43 -0
- package/dist/disconnect/recognise.d.ts.map +1 -0
- package/dist/disconnect/recognise.js +108 -0
- package/dist/disconnect/recognise.js.map +1 -0
- package/dist/disconnect/tomlLines.d.ts +39 -0
- package/dist/disconnect/tomlLines.d.ts.map +1 -0
- package/dist/disconnect/tomlLines.js +105 -0
- package/dist/disconnect/tomlLines.js.map +1 -0
- package/dist/harness/aiderConfigMerger.d.ts +29 -8
- package/dist/harness/aiderConfigMerger.d.ts.map +1 -1
- package/dist/harness/aiderConfigMerger.js +63 -12
- 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 +38 -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 -6
- package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
- package/dist/harness/claudeCodeHooks.js +115 -28
- package/dist/harness/claudeCodeHooks.js.map +1 -1
- package/dist/harness/claudeProjectApproval.d.ts +53 -0
- package/dist/harness/claudeProjectApproval.d.ts.map +1 -0
- package/dist/harness/claudeProjectApproval.js +159 -0
- package/dist/harness/claudeProjectApproval.js.map +1 -0
- package/dist/harness/clineHooks.d.ts +0 -1
- package/dist/harness/clineHooks.d.ts.map +1 -1
- package/dist/harness/clineHooks.js +13 -4
- package/dist/harness/clineHooks.js.map +1 -1
- package/dist/harness/codexConfigMerger.d.ts.map +1 -1
- package/dist/harness/codexConfigMerger.js +4 -0
- package/dist/harness/codexConfigMerger.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 +15 -4
- package/dist/harness/codexHooks.js.map +1 -1
- package/dist/harness/continueConfigMerger.d.ts.map +1 -1
- package/dist/harness/continueConfigMerger.js +4 -0
- package/dist/harness/continueConfigMerger.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 +12 -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 +12 -5
- package/dist/harness/dshHooks.js.map +1 -1
- 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 +195 -69
- package/dist/harness/gateBody.d.ts.map +1 -1
- package/dist/harness/gateBody.js +847 -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 +11 -10
- 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 +29 -1
- package/dist/harness/gooseHooks.d.ts.map +1 -1
- package/dist/harness/gooseHooks.js +107 -69
- 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 +20 -18
- 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 +55 -19
- package/dist/harness/hermesHooks.js.map +1 -1
- package/dist/harness/holdRedaction.d.ts +4 -1
- package/dist/harness/holdRedaction.d.ts.map +1 -1
- package/dist/harness/holdRedaction.js +32 -8
- package/dist/harness/holdRedaction.js.map +1 -1
- package/dist/harness/jetbrainsXmlConfig.d.ts +20 -0
- package/dist/harness/jetbrainsXmlConfig.d.ts.map +1 -1
- package/dist/harness/jetbrainsXmlConfig.js +39 -18
- package/dist/harness/jetbrainsXmlConfig.js.map +1 -1
- package/dist/harness/mcpAutoWrite.d.ts +62 -25
- package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
- package/dist/harness/mcpAutoWrite.js +406 -95
- 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 +15 -7
- 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 +9 -8
- 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 +11 -3
- package/dist/harness/openWebuiHooks.js.map +1 -1
- package/dist/harness/openclawHooks.d.ts +75 -18
- package/dist/harness/openclawHooks.d.ts.map +1 -1
- package/dist/harness/openclawHooks.js +214 -160
- package/dist/harness/openclawHooks.js.map +1 -1
- package/dist/harness/openhandsHooks.d.ts +37 -11
- package/dist/harness/openhandsHooks.d.ts.map +1 -1
- package/dist/harness/openhandsHooks.js +148 -97
- 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 -163
- 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 +18 -3
- package/dist/harness/windsurfHooks.d.ts.map +1 -1
- package/dist/harness/windsurfHooks.js +41 -8
- package/dist/harness/windsurfHooks.js.map +1 -1
- package/dist/harness/windsurfJetBrainsProxy.d.ts +1 -1
- package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -1
- package/dist/harness/windsurfJetBrainsProxy.js +5 -1
- package/dist/harness/windsurfJetBrainsProxy.js.map +1 -1
- package/dist/index.d.ts +21 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -19
- 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 +35 -17
- package/dist/lib/decisionsDigest.d.ts.map +1 -1
- package/dist/lib/decisionsDigest.js +106 -47
- 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 +63 -9
- package/dist/lib/policySnapshot.d.ts.map +1 -1
- package/dist/lib/policySnapshot.js +269 -45
- 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 +18 -12
- package/dist/sessionReporter.d.ts.map +1 -1
- package/dist/sessionReporter.js +37 -24
- 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 +142 -0
- package/dist/syncCycle.d.ts.map +1 -0
- package/dist/syncCycle.js +327 -0
- package/dist/syncCycle.js.map +1 -0
- package/dist/trajectoryMonitor.d.ts +2 -4
- package/dist/trajectoryMonitor.d.ts.map +1 -1
- package/dist/trajectoryMonitor.js +2 -5
- 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 +36 -7
- package/dist/watcher/settingsGuard.d.ts.map +1 -1
- package/dist/watcher/settingsGuard.js +397 -60
- 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 -30
- package/dist/harness/claudeDesktopHooks.d.ts.map +0 -1
- package/dist/harness/claudeDesktopHooks.js +0 -254
- 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 -247
- 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 -286
- package/dist/harness/rooCodeHooks.js.map +0 -1
- package/dist/hashReporter.d.ts +0 -33
- package/dist/hashReporter.d.ts.map +0 -1
- package/dist/hashReporter.js +0 -69
- package/dist/hashReporter.js.map +0 -1
- package/dist/integrityStore.d.ts +0 -35
- package/dist/integrityStore.d.ts.map +0 -1
- package/dist/integrityStore.js +0 -71
- package/dist/integrityStore.js.map +0 -1
- package/dist/lib/gitWorktrees.d.ts +0 -140
- package/dist/lib/gitWorktrees.d.ts.map +0 -1
- package/dist/lib/gitWorktrees.js +0 -191
- package/dist/lib/gitWorktrees.js.map +0 -1
- package/dist/syncLoop.d.ts +0 -172
- package/dist/syncLoop.d.ts.map +0 -1
- package/dist/syncLoop.js +0 -878
- package/dist/syncLoop.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
|
|
@@ -113,12 +118,157 @@ import { DESTRUCTIVE_COMMAND_PATTERNS, NORMALISE_CONTRACT, staticFloorPatterns,
|
|
|
113
118
|
* unrecognised severity to `flags`, i.e. ALLOWED, which is why the v8 filter
|
|
114
119
|
* maps unknown to block and why the daemon regenerates every gate each cycle —
|
|
115
120
|
* the skew window is one sync interval.
|
|
121
|
+
*
|
|
122
|
+
* v9: SSO-group refusals. The snapshot carries the workspace's
|
|
123
|
+
* `sso_group_policy` and the member it was issued to as an `@sso_groups` data
|
|
124
|
+
* line (two columns, so every rule parser skips it; inside the digest, so an
|
|
125
|
+
* edited group list fails the check), and the policy decided for that member
|
|
126
|
+
* as ordinary `sso_group.*` block rules on the tool subject. The one gate-side
|
|
127
|
+
* change: an invalid snapshot keeps its `sso_group.*` block rules instead of
|
|
128
|
+
* dropping them with the rest of the dynamic tier. A v8 gate reading a v9
|
|
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.
|
|
116
223
|
*/
|
|
117
|
-
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
|
+
];
|
|
118
267
|
/**
|
|
119
268
|
* The coarse command → action-token classification the hold tier keys on:
|
|
120
|
-
* `review_before: action:deploy` holds a shell command that contains
|
|
121
|
-
* 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
|
|
122
272
|
* about shell commands is a gate that blocks real work — and it mirrors the
|
|
123
273
|
* proxy's `actions.rs` in both directions: `hookActionParity.test.ts` reads
|
|
124
274
|
* THIS file as text and fails if either side knows a needle the other does
|
|
@@ -129,10 +279,27 @@ export const ACTION_NEEDLES = [
|
|
|
129
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']],
|
|
130
280
|
['action:publish', ['npm publish', 'pnpm publish', 'yarn publish', 'cargo publish', 'twine upload', 'poetry publish', 'gem push', 'docker manifest push']],
|
|
131
281
|
['action:release', ['gh release create', 'git tag', 'npm version', 'cargo release', 'goreleaser release', 'semantic-release']],
|
|
132
|
-
['action:db_write', ['insert into', 'update ', 'delete from', 'drop table', 'truncate ', 'alter table']],
|
|
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',
|
|
133
302
|
];
|
|
134
|
-
/** The (lower-cased) tool names whose `command` the classifier reads. */
|
|
135
|
-
export const ACTION_TOOL_NAMES = ['bash', 'shell', 'run_command', 'terminal', 'execute'];
|
|
136
303
|
/** Where a gate appends a hold, relative to the workspace root; the daemon's
|
|
137
304
|
* `drainReviewRequests` reads the same file. */
|
|
138
305
|
export const REVIEW_REQUESTS_BASENAME = 'review-requests.jsonl';
|
|
@@ -140,18 +307,68 @@ export const REVIEW_REQUESTS_LOG = `.intutic/events/${REVIEW_REQUESTS_BASENAME}`
|
|
|
140
307
|
/** Bumped when a hold record's shape changes; the control plane drops any
|
|
141
308
|
* other version at ingest. */
|
|
142
309
|
export const REVIEW_REQUEST_VERSION = 1;
|
|
143
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
`;
|
|
155
372
|
/**
|
|
156
373
|
* How old a snapshot may be before a gate reports it as stale.
|
|
157
374
|
*
|
|
@@ -173,15 +390,28 @@ function shq(s) {
|
|
|
173
390
|
* severity by one and the table still "works".
|
|
174
391
|
*/
|
|
175
392
|
function shellGuardTable(name, patterns) {
|
|
176
|
-
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'))}`);
|
|
177
394
|
return `${name}=(\n${rows.join('\n')}\n)`;
|
|
178
395
|
}
|
|
179
|
-
/**
|
|
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
|
+
*/
|
|
180
400
|
function jsGuardTable(name, patterns) {
|
|
181
401
|
const rows = patterns.map((p) => ` { id: ${JSON.stringify(p.id)}, re: new RegExp(${JSON.stringify(p.source)}` +
|
|
182
|
-
`${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)}, ` +
|
|
183
405
|
`subject: ${JSON.stringify(p.subject ?? 'any')}, reason: ${JSON.stringify(p.reason)} },`);
|
|
184
|
-
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];`);
|
|
185
415
|
}
|
|
186
416
|
/**
|
|
187
417
|
* The `.rules` snapshot record layout, defined once so the writer and every
|
|
@@ -226,7 +456,7 @@ export const RULES_COLUMNS = ['id', 'severity', 'flags', 'subject', 'reason', 's
|
|
|
226
456
|
*/
|
|
227
457
|
export function toRulesLine(p) {
|
|
228
458
|
const reason = p.reason.replace(/[\t\n\r]/g, ' ');
|
|
229
|
-
const base = [p.id, p.severity, p
|
|
459
|
+
const base = [p.id, p.severity, ruleFlags(p), p.subject ?? 'any', reason, p.source].join('\t');
|
|
230
460
|
// Appended only when present — see RULES_COLUMNS for why base64, and why a
|
|
231
461
|
// rule without one must serialise byte-identically to the v3 layout.
|
|
232
462
|
return p.argPattern ? base + '\t' + Buffer.from(p.argPattern, 'utf8').toString('base64') : base;
|
|
@@ -263,8 +493,13 @@ export function toRulesLine(p) {
|
|
|
263
493
|
* defined by the writer AFTER this prelude, so the trap probes for it before
|
|
264
494
|
* calling: a crash earlier than the definition still blocks, just without an
|
|
265
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}).
|
|
266
499
|
*/
|
|
267
|
-
export
|
|
500
|
+
export function emitShellFailClosed(harness) {
|
|
501
|
+
const deadlineS = gateDeadlineMs(harness) / 1000;
|
|
502
|
+
return `
|
|
268
503
|
# ── Intutic fail-closed prelude v${GATE_VERSION} ─────────────────────────────
|
|
269
504
|
# Any exit status other than the two deliberate verdicts (0 = allow, 2 = block)
|
|
270
505
|
# means this gate crashed rather than decided. The harness would read exit 1 as
|
|
@@ -277,6 +512,7 @@ intutic_fail_closed() {
|
|
|
277
512
|
# that would replace the exit status it exists to correct.
|
|
278
513
|
set +eu
|
|
279
514
|
trap - EXIT
|
|
515
|
+
[ -n "\${INTUTIC_WATCHDOG_PID:-}" ] && kill "$INTUTIC_WATCHDOG_PID" 2>/dev/null
|
|
280
516
|
if [ "$_intutic_exit_rc" = "0" ] || [ "$_intutic_exit_rc" = "2" ]; then
|
|
281
517
|
exit "$_intutic_exit_rc"
|
|
282
518
|
fi
|
|
@@ -287,12 +523,33 @@ intutic_fail_closed() {
|
|
|
287
523
|
exit 2
|
|
288
524
|
}
|
|
289
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 $! )"
|
|
290
545
|
`;
|
|
546
|
+
}
|
|
291
547
|
/**
|
|
292
548
|
* The bash gate body.
|
|
293
549
|
*
|
|
294
|
-
* Expects in scope: `$TOOL`, `$TARGET
|
|
295
|
-
*
|
|
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
|
|
296
553
|
* uses the exit-code contract.
|
|
297
554
|
*/
|
|
298
555
|
/** Single-quotes a path for bash. */
|
|
@@ -300,31 +557,54 @@ function shellQuote(s) {
|
|
|
300
557
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
301
558
|
}
|
|
302
559
|
/**
|
|
303
|
-
* The
|
|
304
|
-
*
|
|
305
|
-
* `intuticHold` is the one place a hold happens: bypass lookup, hold record,
|
|
306
|
-
* the approve hint and the audit line. It returns `{ bypassed: true }` when an
|
|
307
|
-
* exact, unexpired approved bypass lets the call through and `{ holdId }` when
|
|
308
|
-
* the caller must refuse. Everything is local — no network on the tool path.
|
|
560
|
+
* The JS classifier, `intuticActions(toolName, command)`. Needs
|
|
561
|
+
* `intuticNormalise` in scope. Exported for the classifier's tests.
|
|
309
562
|
*/
|
|
310
|
-
function
|
|
311
|
-
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)};
|
|
312
568
|
const INTUTIC_ACTION_TOOLS = ${JSON.stringify(ACTION_TOOL_NAMES)};
|
|
313
|
-
const INTUTIC_REVIEW_REQUEST_FILE = ${JSON.stringify(reviewRequestFile ?? null)};
|
|
314
569
|
|
|
315
570
|
/** Space-padded action tokens for a shell command (" action:deploy "), or " ". */
|
|
316
571
|
function intuticActions(toolName, command) {
|
|
317
572
|
if (INTUTIC_ACTION_TOOLS.indexOf(String(toolName || '').toLowerCase()) === -1) return ' ';
|
|
318
|
-
var
|
|
573
|
+
var words = phraseText(command), out = ' ';
|
|
319
574
|
for (var i = 0; i < INTUTIC_ACTION_NEEDLES.length; i++) {
|
|
320
575
|
var needles = INTUTIC_ACTION_NEEDLES[i][1];
|
|
321
576
|
for (var j = 0; j < needles.length; j++) {
|
|
322
|
-
if (
|
|
577
|
+
if (hasPhrase(words, needles[j])) { out += INTUTIC_ACTION_NEEDLES[i][0] + ' '; break; }
|
|
323
578
|
}
|
|
324
579
|
}
|
|
325
580
|
return out;
|
|
326
581
|
}
|
|
327
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
|
+
|
|
328
608
|
function intuticReviewRequestFile() {
|
|
329
609
|
var path = require('path');
|
|
330
610
|
if (process.env.INTUTIC_REVIEW_REQUESTS) return process.env.INTUTIC_REVIEW_REQUESTS;
|
|
@@ -420,11 +700,23 @@ function intuticHold(rule, toolName, command, target, toolInput, record, workspa
|
|
|
420
700
|
try { console.error('[Intutic Guardrail] could not record the hold: ' + (e && e.message ? e.message : e)); } catch (e2) {}
|
|
421
701
|
}
|
|
422
702
|
var reason = rule.reason + ' [' + rule.id + ']';
|
|
423
|
-
try { console.error('[Intutic Guardrail] HELD: ' + reason + '
|
|
703
|
+
try { console.error('[Intutic Guardrail] HELD: ' + reason + ' ' + intuticHoldHint(holdId)); } catch (e) {}
|
|
424
704
|
try { record('tool_held', toolName, reason); } catch (e) {}
|
|
425
705
|
return { holdId: holdId };
|
|
426
706
|
}`;
|
|
427
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
|
+
];
|
|
428
720
|
export function emitShellGate(opts) {
|
|
429
721
|
const log = opts.logFn ?? 'log_event';
|
|
430
722
|
const floor = staticFloorPatterns();
|
|
@@ -458,7 +750,14 @@ if [ -z "\${1:-}" ] && [ "\${INTUTIC_EXTRACT_STATE:-malformed}" != "ok" ]; then
|
|
|
458
750
|
exit 2
|
|
459
751
|
fi
|
|
460
752
|
|
|
461
|
-
|
|
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
|
|
462
761
|
|
|
463
762
|
# The static floor: compiled in, always enforced, never sourced from disk.
|
|
464
763
|
# These are the families with years of evidence behind them, so they block from
|
|
@@ -488,12 +787,13 @@ fi
|
|
|
488
787
|
|
|
489
788
|
INTUTIC_SNAPSHOT_WORKSPACE=""
|
|
490
789
|
INTUTIC_SNAPSHOT_GENERATED=""
|
|
491
|
-
#
|
|
492
|
-
#
|
|
493
|
-
|
|
494
|
-
# 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
|
|
495
793
|
INTUTIC_MCP_SEVERITY=""
|
|
496
794
|
INTUTIC_MCP_SERVERS=""
|
|
795
|
+
# The MCP server registry record (base64 JSON, inside the digest).
|
|
796
|
+
INTUTIC_MCP_REGISTRY=""
|
|
497
797
|
if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
|
|
498
798
|
INTUTIC_SNAPSHOT_STATE="ok"
|
|
499
799
|
while IFS= read -r _line || [ -n "$_line" ]; do
|
|
@@ -501,13 +801,16 @@ if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
|
|
|
501
801
|
'#digest '*) INTUTIC_SNAPSHOT_DIGEST="\${_line#\\#digest }" ;;
|
|
502
802
|
'#workspace '*) INTUTIC_SNAPSHOT_WORKSPACE="\${_line#\\#workspace }" ;;
|
|
503
803
|
'#generated '*) INTUTIC_SNAPSHOT_GENERATED="\${_line#\\#generated }" ;;
|
|
504
|
-
'
|
|
505
|
-
#
|
|
506
|
-
# expansion only, no subshell
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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'}"
|
|
510
812
|
;;
|
|
813
|
+
'${MCP_REGISTRY_RECORD_TAG}'$'\\t'*) INTUTIC_MCP_REGISTRY="\${_line#*$'\\t'}" ;;
|
|
511
814
|
'#'*|'') : ;;
|
|
512
815
|
*) INTUTIC_DYNAMIC+=("$_line") ;;
|
|
513
816
|
esac
|
|
@@ -540,13 +843,25 @@ if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
|
|
|
540
843
|
fi
|
|
541
844
|
|
|
542
845
|
# Degrade to the compiled floor. The dynamic tier is additive, so dropping it
|
|
543
|
-
# returns to yesterday's behaviour rather than opening a hole.
|
|
544
|
-
#
|
|
545
|
-
#
|
|
846
|
+
# returns to yesterday's behaviour rather than opening a hole.
|
|
847
|
+
# The SSO-group refusals survive it: they only ever refuse, and dropping
|
|
848
|
+
# them would make editing the member's group list in this file a way to
|
|
849
|
+
# clear a tool the workspace's group policy refuses.
|
|
546
850
|
if [ "$INTUTIC_SNAPSHOT_STATE" = "invalid" ]; then
|
|
851
|
+
_intutic_kept=()
|
|
852
|
+
if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
|
|
853
|
+
for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
|
|
854
|
+
case "$_rec" in
|
|
855
|
+
sso_group.*$'\\t'block$'\\t'*) _intutic_kept+=("$_rec") ;;
|
|
856
|
+
esac
|
|
857
|
+
done
|
|
858
|
+
fi
|
|
547
859
|
INTUTIC_DYNAMIC=()
|
|
548
|
-
|
|
549
|
-
|
|
860
|
+
if [ \${#_intutic_kept[@]} -gt 0 ]; then INTUTIC_DYNAMIC=("\${_intutic_kept[@]}"); fi
|
|
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
|
|
550
865
|
fi
|
|
551
866
|
fi
|
|
552
867
|
|
|
@@ -567,15 +882,19 @@ fi
|
|
|
567
882
|
|
|
568
883
|
case "$INTUTIC_SNAPSHOT_STATE" in
|
|
569
884
|
absent) ${log} "snapshot_absent" "\${TOOL:-}" "No policy snapshot at $INTUTIC_SNAPSHOT_RULES — built-in protections only" || true ;;
|
|
570
|
-
invalid) ${log} "snapshot_invalid" "\${TOOL:-}" "Policy snapshot failed its digest or workspace check — dynamic rules dropped" || true ;;
|
|
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 ;;
|
|
571
886
|
empty) ${log} "snapshot_empty" "\${TOOL:-}" "Policy snapshot contains no rules — the compile produced nothing" || true ;;
|
|
572
887
|
stale) ${log} "snapshot_stale" "\${TOOL:-}" "Policy snapshot is \${_intutic_age_days} days old and still enforced" || true ;;
|
|
573
888
|
esac
|
|
574
889
|
|
|
575
|
-
# Normalise once; every pattern is written against this shape.
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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:-} "
|
|
579
898
|
|
|
580
899
|
# ── Hold tier (gate body v8) ─────────────────────────────────────────────────
|
|
581
900
|
# A \`hold\` rule refuses the call and records it for a human; an exact,
|
|
@@ -587,7 +906,6 @@ if [ -z "$INTUTIC_REVIEW_REQUEST_FILE" ]; then
|
|
|
587
906
|
INTUTIC_REVIEW_REQUEST_FILE=${opts.reviewRequestFile ? shellQuote(opts.reviewRequestFile) : '"$(cd "$(dirname "$0")/.." 2>/dev/null && pwd)/events/review-requests.jsonl"'}
|
|
588
907
|
fi
|
|
589
908
|
INTUTIC_APPROVED_BYPASSES="\${INTUTIC_APPROVED_BYPASSES:-$HOME/.intutic/hooks/approved-bypasses.jsonl}"
|
|
590
|
-
${shellActionClassifier()}
|
|
591
909
|
intutic_sha256() {
|
|
592
910
|
if command -v shasum >/dev/null 2>&1; then shasum -a 256 | cut -c1-64
|
|
593
911
|
elif command -v sha256sum >/dev/null 2>&1; then sha256sum | cut -c1-64
|
|
@@ -632,8 +950,8 @@ intutic_bypass() {
|
|
|
632
950
|
# Returns 0 when an approved bypass lets the call through (the caller
|
|
633
951
|
# continues), 2 when the call is held (the caller refuses).
|
|
634
952
|
intutic_hold() {
|
|
635
|
-
local rid="$1" rreason="$2" tn th entry decided_by hold_id at file targeth
|
|
636
|
-
tn="$
|
|
953
|
+
local rid="$1" rreason="$2" tn th entry decided_by hold_id at file targeth hint
|
|
954
|
+
tn="$INTUTIC_NTOOL"
|
|
637
955
|
th="$( { printf '%s' "$INTUTIC_NCOMMAND"; printf '\\0'; printf '%s' "$INTUTIC_NTARGET"; } | intutic_sha256 )"
|
|
638
956
|
if entry="$(intutic_bypass "$rid" "$tn" "$th")"; then
|
|
639
957
|
decided_by="$(printf '%s' "$entry" | sed -n 's/.*"decidedBy":"\\([^"]*\\)".*/\\1/p')"
|
|
@@ -655,12 +973,13 @@ intutic_hold() {
|
|
|
655
973
|
"$(intutic_json_escape "\${INTUTIC_WORKSPACE_ID:-}")" "$at" "$(intutic_json_escape "$tn")" "$targeth" >> "$file" 2>/dev/null; }; then
|
|
656
974
|
echo "[Intutic Guardrail] could not record the hold at \${file}" >&2
|
|
657
975
|
fi
|
|
658
|
-
|
|
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
|
|
659
980
|
${log} "tool_held" "\${TOOL:-}" "\${rreason} [\${rid}]" || true
|
|
660
981
|
return 2
|
|
661
982
|
}
|
|
662
|
-
# Computed after the classifier is defined; read by the \`action\` subject.
|
|
663
|
-
INTUTIC_ACTIONS="$(intutic_actions "\${TOOL:-}" "\${COMMAND:-}")"
|
|
664
983
|
|
|
665
984
|
# A rule declares which part of the call it matches. \`any\` means command and
|
|
666
985
|
# target — never the tool name, because a pattern like \`\\.claude/settings\\.json\`
|
|
@@ -683,31 +1002,59 @@ intutic_apply() {
|
|
|
683
1002
|
destructive.*) return 0 ;;
|
|
684
1003
|
esac
|
|
685
1004
|
fi
|
|
686
|
-
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.
|
|
687
1009
|
case "$rsubj" in
|
|
688
|
-
tool) _subs=("$INTUTIC_NTOOL") ;;
|
|
689
|
-
command) _subs=("$INTUTIC_NCOMMAND") ;;
|
|
690
|
-
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") ;;
|
|
691
1013
|
# Serialized tool input, un-normalised — same subject the WHERE argPattern
|
|
692
1014
|
# machinery matches. The secrets.* floor rules ride this. Guaranteed
|
|
693
1015
|
# non-empty by the extractor (it defaults to "{}").
|
|
694
|
-
content) _subs=("$TOOL_INPUT_JSON") ;;
|
|
695
|
-
# The space-padded action tokens
|
|
696
|
-
# (" action:deploy "), so a hold on \`action:deploy\`
|
|
697
|
-
|
|
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
|
+
;;
|
|
698
1037
|
# Each field is tested separately rather than concatenated. Joining them
|
|
699
1038
|
# lets a pattern match across the seam — a command ending in "chflags" and
|
|
700
1039
|
# an unrelated target starting with "nouchg" would trip the bypass rule
|
|
701
1040
|
# together while neither does alone.
|
|
702
|
-
*)
|
|
1041
|
+
*)
|
|
1042
|
+
intutic_unscreened command "$_ci" && _subs+=("$INTUTIC_NCOMMAND")
|
|
1043
|
+
intutic_unscreened target "$_ci" && _subs+=("$INTUTIC_NTARGET")
|
|
1044
|
+
;;
|
|
703
1045
|
esac
|
|
1046
|
+
[ \${#_subs[@]} -gt 0 ] || return 0
|
|
704
1047
|
hit=0
|
|
1048
|
+
[ "$rsubj" = "phrase" ] && hit=1
|
|
705
1049
|
for s in "\${_subs[@]}"; do
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
|
711
1058
|
done
|
|
712
1059
|
[ "$hit" = "1" ] || return 0
|
|
713
1060
|
# The argument condition of a WHERE rule. The tool-name half has matched; the
|
|
@@ -773,6 +1120,51 @@ sys.exit(0 if rx.search(sys.argv[2]) else 1)
|
|
|
773
1120
|
exit 2
|
|
774
1121
|
}
|
|
775
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
|
+
|
|
776
1168
|
for _rec in "\${INTUTIC_FLOOR[@]}"; do
|
|
777
1169
|
intutic_apply "$_rec"
|
|
778
1170
|
done
|
|
@@ -783,23 +1175,68 @@ if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
|
|
|
783
1175
|
done
|
|
784
1176
|
fi
|
|
785
1177
|
|
|
786
|
-
# ──
|
|
787
|
-
# A
|
|
788
|
-
#
|
|
789
|
-
#
|
|
790
|
-
#
|
|
791
|
-
#
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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.
|
|
800
1237
|
case "\${TOOL:-}" in
|
|
801
1238
|
mcp__*__*)
|
|
802
|
-
if [
|
|
1239
|
+
if [ "$INTUTIC_MCP_ALLOWLIST" = "1" ]; then
|
|
803
1240
|
_intutic_mcp_server="\${TOOL#mcp__}"
|
|
804
1241
|
_intutic_mcp_server="\${_intutic_mcp_server%%__*}"
|
|
805
1242
|
case ",$INTUTIC_MCP_SERVERS," in
|
|
@@ -836,11 +1273,10 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
836
1273
|
const fs = require('fs'), os = require('os'), path = require('path');
|
|
837
1274
|
const p = process.env.INTUTIC_SNAPSHOT_RULES ||
|
|
838
1275
|
path.join(os.homedir(), '.intutic', 'hooks', 'policy-snapshot.rules');
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
// \`#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.
|
|
842
1278
|
const out = { rules: [], digest: 'none', state: 'absent', workspaceId: '', generatedAt: '', ageDays: 0,
|
|
843
|
-
|
|
1279
|
+
mcpAllowlist: null, mcpRegistry: null };
|
|
844
1280
|
let text;
|
|
845
1281
|
try { text = fs.readFileSync(p, 'utf8'); } catch (e) { return out; }
|
|
846
1282
|
out.state = 'ok';
|
|
@@ -848,28 +1284,45 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
848
1284
|
if (line.startsWith('#digest ')) { out.digest = line.slice(8).trim(); continue; }
|
|
849
1285
|
if (line.startsWith('#workspace ')) { out.workspaceId = line.slice(11).trim(); continue; }
|
|
850
1286
|
if (line.startsWith('#generated ')) { out.generatedAt = line.slice(11).trim(); continue; }
|
|
851
|
-
if (line.startsWith('
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
const sp = rest.indexOf(' ');
|
|
858
|
-
if (sp === -1) { out.mcpSeverity = rest.trim(); out.mcpServers = []; }
|
|
859
|
-
else {
|
|
860
|
-
out.mcpSeverity = rest.slice(0, sp).trim();
|
|
861
|
-
out.mcpServers = rest.slice(sp + 1).trim().split(',').filter(Boolean);
|
|
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) };
|
|
862
1293
|
}
|
|
863
1294
|
continue;
|
|
864
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
|
+
}
|
|
865
1313
|
if (!line || line.startsWith('#')) continue;
|
|
866
1314
|
const f = line.split('\\t');
|
|
867
1315
|
if (f.length < 6 || !f[5]) continue;
|
|
868
|
-
let re;
|
|
1316
|
+
let re, seq = null;
|
|
869
1317
|
// A snapshot rule that will not compile is dropped, not fatal. The floor
|
|
870
1318
|
// above is compiled in and unaffected, so this degrades to "today's
|
|
871
|
-
// behaviour" rather than to "no gate".
|
|
872
|
-
|
|
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; }
|
|
873
1326
|
// Optional seventh column: the WHERE clause, base64 so an arbitrary regex
|
|
874
1327
|
// cannot collide with the tab separator. Absent in v3-format files, which
|
|
875
1328
|
// is exactly "no argument condition". An argPattern that does not decode
|
|
@@ -881,7 +1334,7 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
881
1334
|
try { argRe = new RegExp(Buffer.from(f[6], 'base64').toString('utf8')); }
|
|
882
1335
|
catch (e) { argDowngraded = true; }
|
|
883
1336
|
}
|
|
884
|
-
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],
|
|
885
1338
|
argRe: argRe, argDowngraded: argDowngraded });
|
|
886
1339
|
}
|
|
887
1340
|
|
|
@@ -891,7 +1344,9 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
891
1344
|
try {
|
|
892
1345
|
const body = text.split('\\n').filter(function (l) { return l && l.charAt(0) !== '#'; }).join('\\n');
|
|
893
1346
|
const actual = require('crypto').createHash('sha256').update(body).digest('hex').slice(0, 32);
|
|
894
|
-
|
|
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';
|
|
895
1350
|
} catch (e) { /* no crypto — leave the digest unverified rather than fail the gate */ }
|
|
896
1351
|
|
|
897
1352
|
if (out.state === 'ok' && out.workspaceId && INTUTIC_WORKSPACE_ID && out.workspaceId !== INTUTIC_WORKSPACE_ID) {
|
|
@@ -902,10 +1357,17 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
902
1357
|
// exactly like a healthy quiet workspace.
|
|
903
1358
|
if (out.state === 'ok' && out.rules.length === 0) out.state = 'empty';
|
|
904
1359
|
|
|
905
|
-
// Additive tier, so dropping it returns to yesterday's behaviour. The
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
|
|
1360
|
+
// Additive tier, so dropping it returns to yesterday's behaviour. The
|
|
1361
|
+
// SSO-group refusals are the exception: they only ever refuse, and dropping
|
|
1362
|
+
// them would make editing the member's group list in this file a way to
|
|
1363
|
+
// clear a tool the workspace's group policy refuses.
|
|
1364
|
+
if (out.state === 'invalid') {
|
|
1365
|
+
out.rules = out.rules.filter(function (r) { return r.id.indexOf('sso_group.') === 0 && r.severity === 'block'; });
|
|
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;
|
|
1370
|
+
}
|
|
909
1371
|
|
|
910
1372
|
if (out.state === 'ok' && out.generatedAt) {
|
|
911
1373
|
const t = Date.parse(out.generatedAt);
|
|
@@ -927,17 +1389,31 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
927
1389
|
*/
|
|
928
1390
|
export function emitJsGate(opts) {
|
|
929
1391
|
const floor = staticFloorPatterns();
|
|
930
|
-
const
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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` +
|
|
935
1407
|
` process.exit(0);`
|
|
936
|
-
: opts.contract === '
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
+
};
|
|
941
1417
|
return `
|
|
942
1418
|
// ── Intutic gate body v${GATE_VERSION} — harness: ${opts.harness} ────────────
|
|
943
1419
|
// Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
|
|
@@ -986,9 +1462,28 @@ function intuticGuardEnvelope(ctx, keys, record) {
|
|
|
986
1462
|
'refusing rather than allowing a call the gate cannot read.';
|
|
987
1463
|
try { console.error(reason); } catch (e) {}
|
|
988
1464
|
try { record('tool_blocked', 'unknown', reason); } catch (e) {}
|
|
989
|
-
${
|
|
1465
|
+
${refuseWith("'UNREADABLE_CALL'", 'null')}
|
|
990
1466
|
}
|
|
991
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
|
+
|
|
992
1487
|
/**
|
|
993
1488
|
* Evaluates one tool call. Returns normally to allow; refuses via this harness's
|
|
994
1489
|
* contract otherwise.
|
|
@@ -1005,6 +1500,47 @@ ${refuse}
|
|
|
1005
1500
|
* it itself so no writer can hand it a differently-shaped string.
|
|
1006
1501
|
*/
|
|
1007
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) {
|
|
1008
1544
|
// M3: Cline's \`use_mcp_tool\` envelope, normalized into the
|
|
1009
1545
|
// \`mcp__<server>__<tool>\` shape every other harness's MCP tool name already
|
|
1010
1546
|
// takes — BEFORE any rule fires. Cline's own tool-call schema names the
|
|
@@ -1045,7 +1581,7 @@ function intuticGate(toolName, target, command, record, workspaceId, toolInput,
|
|
|
1045
1581
|
var _msg = snap.state === 'absent'
|
|
1046
1582
|
? 'No policy snapshot — built-in protections only'
|
|
1047
1583
|
: snap.state === 'invalid'
|
|
1048
|
-
? 'Policy snapshot failed its digest or workspace check — dynamic rules dropped'
|
|
1584
|
+
? 'Policy snapshot failed its digest or workspace check — dynamic rules dropped except SSO-group refusals, and every MCP call refused'
|
|
1049
1585
|
: snap.state === 'empty'
|
|
1050
1586
|
? 'Policy snapshot contains no rules — the compile produced nothing'
|
|
1051
1587
|
: 'Policy snapshot is ' + snap.ageDays + ' days old and still enforced';
|
|
@@ -1064,12 +1600,20 @@ function intuticGate(toolName, target, command, record, workspaceId, toolInput,
|
|
|
1064
1600
|
// The space-padded action tokens the command classifies to, for \`action\`
|
|
1065
1601
|
// subject rules (" action:deploy ").
|
|
1066
1602
|
const nActions = intuticActions(toolName, command);
|
|
1603
|
+
var words = null;
|
|
1067
1604
|
const rules = INTUTIC_FLOOR.concat(snap.rules);
|
|
1068
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
|
+
}
|
|
1069
1612
|
// Each field is tested separately rather than concatenated — joining them
|
|
1070
1613
|
// lets a pattern match across the seam between two innocuous values.
|
|
1071
1614
|
const subjects =
|
|
1072
|
-
rule.subject === '
|
|
1615
|
+
rule.subject === 'phrase' ? [nCommand]
|
|
1616
|
+
: rule.subject === 'tool' ? [nTool]
|
|
1073
1617
|
: rule.subject === 'command' ? [nCommand]
|
|
1074
1618
|
: rule.subject === 'target' ? [nTarget]
|
|
1075
1619
|
// The serialized tool input, un-normalised: a content rule (the
|
|
@@ -1080,7 +1624,7 @@ function intuticGate(toolName, target, command, record, workspaceId, toolInput,
|
|
|
1080
1624
|
: rule.subject === 'action' ? [nActions]
|
|
1081
1625
|
: [nCommand, nTarget];
|
|
1082
1626
|
for (const subject of subjects) {
|
|
1083
|
-
if (
|
|
1627
|
+
if (rule.subject !== 'phrase' && !intuticRuleMatches(rule, subject)) continue;
|
|
1084
1628
|
// The argument condition of a WHERE rule: the tool-name half has
|
|
1085
1629
|
// matched, and the rule fires only if the argPattern also matches the
|
|
1086
1630
|
// serialized tool input. A pattern that failed to compile at load time
|
|
@@ -1106,8 +1650,8 @@ function intuticGate(toolName, target, command, record, workspaceId, toolInput,
|
|
|
1106
1650
|
// otherwise the hold is recorded for review and the call refused.
|
|
1107
1651
|
const held = intuticHold(rule, toolName, command, target, toolInput, record, workspaceId, sessionId);
|
|
1108
1652
|
if (held.bypassed) continue;
|
|
1109
|
-
const reason = '[Intutic Governance] HELD: ' + rule.reason + ' [' + rule.id + ']
|
|
1110
|
-
${
|
|
1653
|
+
const reason = '[Intutic Governance] HELD: ' + rule.reason + ' [' + rule.id + '] ' + intuticHoldHint(held.holdId);
|
|
1654
|
+
${refuseWith("'HELD'", 'rule.id', 'holdId: held.holdId')}
|
|
1111
1655
|
}
|
|
1112
1656
|
if (rule.severity === 'warn') {
|
|
1113
1657
|
// Advisory tier — allowed, recorded with the rule id and the command's
|
|
@@ -1129,36 +1673,60 @@ ${refuse}
|
|
|
1129
1673
|
const reason = rule.reason + ' [' + rule.id + ']';
|
|
1130
1674
|
try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
|
|
1131
1675
|
try { record('tool_blocked', toolName, reason); } catch (e) {}
|
|
1132
|
-
${
|
|
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')}
|
|
1133
1692
|
}
|
|
1134
1693
|
}
|
|
1135
1694
|
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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) {
|
|
1145
1716
|
var _mcpRest = toolName.slice('mcp__'.length);
|
|
1146
1717
|
var _mcpSep = _mcpRest.indexOf('__');
|
|
1147
|
-
var
|
|
1148
|
-
if (
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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) {}
|
|
1152
1726
|
} else {
|
|
1153
|
-
try { console.error('[Intutic Governance] BLOCKED: ' +
|
|
1154
|
-
try { record('tool_blocked', toolName,
|
|
1155
|
-
|
|
1156
|
-
// block-scoped to that loop, so without this the two stdout contracts
|
|
1157
|
-
// and the throw contract would hit a ReferenceError here — a refusal
|
|
1158
|
-
// that crashes is still a refusal for exit-code gates, and a
|
|
1159
|
-
// ReferenceError for the others.
|
|
1160
|
-
var reason = _mcpReason;
|
|
1161
|
-
${refuse}
|
|
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')}
|
|
1162
1730
|
}
|
|
1163
1731
|
}
|
|
1164
1732
|
}
|
|
@@ -1167,7 +1735,7 @@ ${refuse}
|
|
|
1167
1735
|
`;
|
|
1168
1736
|
}
|
|
1169
1737
|
/**
|
|
1170
|
-
* Pre-image capture — the storage half of the rollback enforcement rung
|
|
1738
|
+
* Pre-image capture — the storage half of the rollback enforcement rung.
|
|
1171
1739
|
*
|
|
1172
1740
|
* ## Why this exists and why it is here
|
|
1173
1741
|
*
|
|
@@ -1208,7 +1776,7 @@ ${refuse}
|
|
|
1208
1776
|
*/
|
|
1209
1777
|
export function emitPreImageCapture() {
|
|
1210
1778
|
return `
|
|
1211
|
-
// ── Intutic pre-image capture (rollback rung
|
|
1779
|
+
// ── Intutic pre-image capture (rollback rung) ────────────────────────────────
|
|
1212
1780
|
const _INTUTIC_ROLLBACK_DIR = path.join(process.cwd(), '.intutic', 'rollback');
|
|
1213
1781
|
const _INTUTIC_MAX_PREIMAGE_BYTES = 2 * 1024 * 1024;
|
|
1214
1782
|
const _INTUTIC_MAX_PREIMAGE_ENTRIES = 50;
|
|
@@ -1353,20 +1921,22 @@ function _intuticCapturePreImage(toolName, input, ruleId) {
|
|
|
1353
1921
|
* every emitted gate for real.
|
|
1354
1922
|
*
|
|
1355
1923
|
* Refuses through the harness's contract, like everything else: exit 2 means
|
|
1356
|
-
* nothing to cline
|
|
1924
|
+
* nothing to cline, which only reads stdout — a crashed
|
|
1357
1925
|
* stdout-cancel gate must still print its cancel object.
|
|
1358
1926
|
*/
|
|
1359
1927
|
export function emitJsFailClosedPrelude(opts) {
|
|
1360
1928
|
const crashBody = opts.contract === 'stdout-cancel'
|
|
1361
1929
|
? ` try {\n` +
|
|
1362
|
-
` process.stdout.write(JSON.stringify({ cancel: true,
|
|
1363
|
-
` '[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` +
|
|
1364
1933
|
` } catch (e) { /* stdout gone too — nothing left to refuse through */ }\n` +
|
|
1365
1934
|
` process.exit(0);`
|
|
1366
1935
|
: opts.contract === 'stdout-decision-deny'
|
|
1367
1936
|
? ` try {\n` +
|
|
1368
1937
|
` process.stdout.write(JSON.stringify({ decision: 'deny', reason:\n` +
|
|
1369
|
-
` '[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`) +
|
|
1370
1940
|
` } catch (e) { /* stdout gone too — nothing left to refuse through */ }\n` +
|
|
1371
1941
|
` process.exit(0);`
|
|
1372
1942
|
: ` try {\n` +
|
|
@@ -1409,8 +1979,16 @@ if ! command -v python3 >/dev/null 2>&1; then
|
|
|
1409
1979
|
exit 2
|
|
1410
1980
|
fi
|
|
1411
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
|
+
|
|
1412
1990
|
INTUTIC_FIELDS="$(printf '%s' "$INPUT" | python3 -c '
|
|
1413
|
-
import sys, json
|
|
1991
|
+
import os, sys, json
|
|
1414
1992
|
def clean(v):
|
|
1415
1993
|
return " ".join(str(v).split()) if isinstance(v, str) else ""
|
|
1416
1994
|
state = "ok"
|
|
@@ -1440,22 +2018,54 @@ def first(*keys):
|
|
|
1440
2018
|
if isinstance(v, str) and v:
|
|
1441
2019
|
return clean(v)
|
|
1442
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 = " "
|
|
1443
2049
|
# The state line comes FIRST: the lines after it may legitimately be empty,
|
|
1444
2050
|
# and command substitution strips trailing newlines, so the last line is the
|
|
1445
2051
|
# only position an empty value cannot survive in.
|
|
1446
2052
|
print(state)
|
|
2053
|
+
print(too_large)
|
|
1447
2054
|
print(clean(d.get("tool_name", "")))
|
|
1448
2055
|
print(first("path", "file_path", "notebook_path", "filePath"))
|
|
1449
|
-
print(first("command", "cmd", "script", "shell_command"))
|
|
2056
|
+
print("" if too_large else first("command", "cmd", "script", "shell_command"))
|
|
1450
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))
|
|
1451
2061
|
# The FULL tool_input, serialized to the exact shape argPattern rules are
|
|
1452
2062
|
# matched against everywhere: JSON.stringify(tool_input) — compact separators,
|
|
1453
2063
|
# insertion order, non-ASCII intact. The selected fields above are whitespace-
|
|
1454
2064
|
# collapsed for token matching; this one must NOT be, or a WHERE clause that
|
|
1455
2065
|
# spans a key/value boundary matches here and not in the JS gates. json.dumps
|
|
1456
2066
|
# escapes every newline, so it is still exactly one line to read back.
|
|
1457
|
-
print(
|
|
1458
|
-
' 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')"
|
|
1459
2069
|
|
|
1460
2070
|
# Each read is \`|| true\` because command substitution strips trailing newlines:
|
|
1461
2071
|
# a tool call with no command argument yields fewer lines than reads, so a late
|
|
@@ -1463,10 +2073,15 @@ print(json.dumps(i, separators=(",", ":"), ensure_ascii=False))
|
|
|
1463
2073
|
# **exit 1** — which every harness reads as a hook error and lets the call
|
|
1464
2074
|
# through. A guard that fails open on the most ordinary input there is (a Write
|
|
1465
2075
|
# with no shell command) is worse than no guard, because it looks present.
|
|
1466
|
-
{ IFS= read -r INTUTIC_EXTRACT_STATE || true; IFS= read -r TOOL || true; IFS= read -r TARGET || true; IFS= read -r COMMAND || true; IFS= read -r SESSION_ID || true; IFS= read -r TOOL_INPUT_JSON || true; } <<EOF_INTUTIC_FIELDS
|
|
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
|
|
1467
2077
|
$INTUTIC_FIELDS
|
|
1468
2078
|
EOF_INTUTIC_FIELDS
|
|
1469
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:-\"\"}"
|
|
1470
2085
|
# Empty means the extractor itself died (the fallback printf above): treat it
|
|
1471
2086
|
# exactly like a payload the parser rejected. The gate body refuses on any
|
|
1472
2087
|
# value other than "ok" — see the envelope refusal in emitShellGate.
|
|
@@ -1505,7 +2120,7 @@ TOOL_NAME="$TOOL"; TARGET_PATH="$TARGET"
|
|
|
1505
2120
|
export function emitPythonGate() {
|
|
1506
2121
|
const floor = staticFloorPatterns();
|
|
1507
2122
|
const rows = floor.map((p) => ` (${JSON.stringify(p.id)}, ${JSON.stringify(p.source)}, ` +
|
|
1508
|
-
`${p.ignoreCase ? 're.IGNORECASE' : '0'}, ${JSON.stringify(p.reason)}),`);
|
|
2123
|
+
`${p.ignoreCase ? 're.IGNORECASE' : '0'}, ${JSON.stringify(p.reason)}, ${p.sequence ? 'True' : 'False'}),`);
|
|
1509
2124
|
return `
|
|
1510
2125
|
# ── Intutic gate body v${GATE_VERSION} — harness: open-webui ─────────────────
|
|
1511
2126
|
# Prompts are flagged, never blocked, by these. See emitPythonGate().
|
|
@@ -1517,6 +2132,21 @@ ${rows.join('\n')}
|
|
|
1517
2132
|
${NORMALISE_CONTRACT.pySource}
|
|
1518
2133
|
|
|
1519
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
|
+
|
|
1520
2150
|
_state = {"digest": "", "workspace": ""}
|
|
1521
2151
|
|
|
1522
2152
|
|
|
@@ -1570,7 +2200,7 @@ def _intutic_snapshot_rules():
|
|
|
1570
2200
|
# A rule that will not compile is dropped, not fatal.
|
|
1571
2201
|
if _skip_destructive and f[0].startswith("destructive."):
|
|
1572
2202
|
continue
|
|
1573
|
-
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]))
|
|
1574
2204
|
except Exception:
|
|
1575
2205
|
continue
|
|
1576
2206
|
except Exception:
|
|
@@ -1582,7 +2212,8 @@ def _intutic_snapshot_rules():
|
|
|
1582
2212
|
body = "\\n".join(l.rstrip("\\n") for l in fh
|
|
1583
2213
|
if l.strip() and not l.startswith("#"))
|
|
1584
2214
|
actual = hashlib.sha256(body.encode()).hexdigest()[:32]
|
|
1585
|
-
|
|
2215
|
+
# No digest line is unverified too: the daemon always writes one.
|
|
2216
|
+
if actual != _state["digest"]:
|
|
1586
2217
|
return []
|
|
1587
2218
|
except Exception:
|
|
1588
2219
|
pass
|
|
@@ -1595,15 +2226,23 @@ def _intutic_evaluate(text):
|
|
|
1595
2226
|
"""Returns (blocks, flags) for a prompt. Floor rules can only flag."""
|
|
1596
2227
|
subject = _intutic_normalise(text)
|
|
1597
2228
|
blocks, flags, shadowed = [], [], []
|
|
1598
|
-
for rid, src, flags_re, reason in _INTUTIC_FLOOR:
|
|
2229
|
+
for rid, src, flags_re, reason, seq in _INTUTIC_FLOOR:
|
|
1599
2230
|
try:
|
|
1600
|
-
if
|
|
2231
|
+
if _intutic_rule_search(src, subject, flags_re, seq):
|
|
1601
2232
|
flags.append((rid, reason))
|
|
1602
2233
|
except Exception:
|
|
1603
2234
|
continue
|
|
1604
|
-
|
|
2235
|
+
words = None
|
|
2236
|
+
for rid, src, flags_re, reason, severity, rsubj, seq in _intutic_snapshot_rules():
|
|
1605
2237
|
try:
|
|
1606
|
-
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):
|
|
1607
2246
|
continue
|
|
1608
2247
|
except Exception:
|
|
1609
2248
|
continue
|
|
@@ -1674,8 +2313,14 @@ ${jsGuardTable('INTUTIC_FLOOR', floor)}
|
|
|
1674
2313
|
// had already drifted from the in-process one on null handling.
|
|
1675
2314
|
${NORMALISE_CONTRACT.jsSource}
|
|
1676
2315
|
|
|
2316
|
+
// The phrase matcher (@intutic/shared-types phrases.ts), for phrase rules.
|
|
2317
|
+
${PHRASES_JS_SOURCE}
|
|
2318
|
+
|
|
1677
2319
|
${JS_SNAPSHOT_LOADER}
|
|
1678
2320
|
|
|
2321
|
+
// The refusal of an MCP call on an unverified snapshot (mcpRegistryRecord.ts).
|
|
2322
|
+
${MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE}
|
|
2323
|
+
|
|
1679
2324
|
/**
|
|
1680
2325
|
* Evaluates one workflow. Returns normally to allow; throws to abort the
|
|
1681
2326
|
* execution. \`record\` is called as (verdict, toolName, reason), the same
|
|
@@ -1699,7 +2344,7 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
|
|
|
1699
2344
|
var _msg = snap.state === 'absent'
|
|
1700
2345
|
? 'No policy snapshot — built-in protections only'
|
|
1701
2346
|
: snap.state === 'invalid'
|
|
1702
|
-
? 'Policy snapshot failed its digest or workspace check — dynamic rules dropped'
|
|
2347
|
+
? 'Policy snapshot failed its digest or workspace check — dynamic rules dropped except SSO-group refusals, and every MCP call refused'
|
|
1703
2348
|
: snap.state === 'empty'
|
|
1704
2349
|
? 'Policy snapshot contains no rules — the compile produced nothing'
|
|
1705
2350
|
: 'Policy snapshot is ' + snap.ageDays + ' days old and still enforced';
|
|
@@ -1736,6 +2381,18 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
|
|
|
1736
2381
|
if (!node || typeof node !== 'object') continue;
|
|
1737
2382
|
const nodeType = String(node.type || '');
|
|
1738
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
|
+
}
|
|
1739
2396
|
// Compact stringify, insertion order, non-ASCII intact — the same shape
|
|
1740
2397
|
// matchSopRule and every per-tool gate pin for argPattern matching.
|
|
1741
2398
|
var paramsJson = '{}';
|
|
@@ -1768,13 +2425,21 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
|
|
|
1768
2425
|
if (v && typeof v === 'object') { for (const k in v) _walk(v[k]); }
|
|
1769
2426
|
})(node.parameters == null ? {} : node.parameters);
|
|
1770
2427
|
const nLeaves = _leaves.map(intuticNormalise);
|
|
2428
|
+
var leafWords = null;
|
|
1771
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
|
+
}
|
|
1772
2437
|
// A node has no separate command/target — its parameters are both. A
|
|
1773
2438
|
// 'tool' rule matches the node TYPE and nothing else, for the same
|
|
1774
2439
|
// reason the per-tool gates never test a path pattern against "Write".
|
|
1775
|
-
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);
|
|
1776
2441
|
for (const subject of subjects) {
|
|
1777
|
-
if (
|
|
2442
|
+
if (rule.subject !== 'phrase' && !intuticRuleMatches(rule, subject)) continue;
|
|
1778
2443
|
if (rule.argDowngraded) {
|
|
1779
2444
|
try {
|
|
1780
2445
|
record('rule_downgraded', 'n8n:' + nodeName,
|