@intutic/sync-daemon 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -14
- package/dist/agentReporter.d.ts +11 -1
- package/dist/agentReporter.d.ts.map +1 -1
- package/dist/agentReporter.js +9 -2
- package/dist/agentReporter.js.map +1 -1
- package/dist/configWriter.d.ts +2 -1
- package/dist/configWriter.d.ts.map +1 -1
- package/dist/configWriter.js +4 -4
- package/dist/configWriter.js.map +1 -1
- package/dist/disconnect/originals.d.ts.map +1 -1
- package/dist/disconnect/originals.js +5 -12
- package/dist/disconnect/originals.js.map +1 -1
- package/dist/disconnect/plan.d.ts.map +1 -1
- package/dist/disconnect/plan.js +3 -14
- package/dist/disconnect/plan.js.map +1 -1
- package/dist/harness/aiderConfigMerger.d.ts.map +1 -1
- package/dist/harness/aiderConfigMerger.js +4 -9
- package/dist/harness/aiderConfigMerger.js.map +1 -1
- package/dist/harness/antigravityCliHooks.d.ts.map +1 -1
- package/dist/harness/antigravityCliHooks.js +3 -7
- package/dist/harness/antigravityCliHooks.js.map +1 -1
- package/dist/harness/antigravityHooks.d.ts.map +1 -1
- package/dist/harness/antigravityHooks.js +3 -7
- package/dist/harness/antigravityHooks.js.map +1 -1
- package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
- package/dist/harness/claudeCodeHooks.js +15 -4
- package/dist/harness/claudeCodeHooks.js.map +1 -1
- package/dist/harness/clineHooks.d.ts.map +1 -1
- package/dist/harness/clineHooks.js +4 -5
- package/dist/harness/clineHooks.js.map +1 -1
- package/dist/harness/codexConfigMerger.d.ts.map +1 -1
- package/dist/harness/codexConfigMerger.js +2 -3
- package/dist/harness/codexConfigMerger.js.map +1 -1
- package/dist/harness/codexHooks.d.ts.map +1 -1
- package/dist/harness/codexHooks.js +3 -7
- package/dist/harness/codexHooks.js.map +1 -1
- package/dist/harness/continueConfigMerger.d.ts.map +1 -1
- package/dist/harness/continueConfigMerger.js +2 -3
- package/dist/harness/continueConfigMerger.js.map +1 -1
- package/dist/harness/cursorHooks.d.ts.map +1 -1
- package/dist/harness/cursorHooks.js +12 -8
- package/dist/harness/cursorHooks.js.map +1 -1
- package/dist/harness/dshHooks.d.ts.map +1 -1
- package/dist/harness/dshHooks.js +7 -18
- package/dist/harness/dshHooks.js.map +1 -1
- package/dist/harness/gateBody.d.ts +39 -2
- package/dist/harness/gateBody.d.ts.map +1 -1
- package/dist/harness/gateBody.js +465 -70
- package/dist/harness/gateBody.js.map +1 -1
- package/dist/harness/gateSightings.d.ts.map +1 -1
- package/dist/harness/gateSightings.js +2 -3
- package/dist/harness/gateSightings.js.map +1 -1
- package/dist/harness/githubCopilotHooks.d.ts.map +1 -1
- package/dist/harness/githubCopilotHooks.js +2 -4
- package/dist/harness/githubCopilotHooks.js.map +1 -1
- package/dist/harness/gooseHooks.d.ts +13 -5
- package/dist/harness/gooseHooks.d.ts.map +1 -1
- package/dist/harness/gooseHooks.js +39 -25
- package/dist/harness/gooseHooks.js.map +1 -1
- package/dist/harness/grokHooks.d.ts.map +1 -1
- package/dist/harness/grokHooks.js +4 -10
- package/dist/harness/grokHooks.js.map +1 -1
- package/dist/harness/hermesHooks.d.ts.map +1 -1
- package/dist/harness/hermesHooks.js +5 -13
- package/dist/harness/hermesHooks.js.map +1 -1
- package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
- package/dist/harness/mcpAutoWrite.js +9 -10
- package/dist/harness/mcpAutoWrite.js.map +1 -1
- package/dist/harness/museHooks.d.ts.map +1 -1
- package/dist/harness/museHooks.js +4 -10
- package/dist/harness/museHooks.js.map +1 -1
- package/dist/harness/n8nHooks.d.ts +2 -1
- package/dist/harness/n8nHooks.d.ts.map +1 -1
- package/dist/harness/n8nHooks.js +52 -22
- package/dist/harness/n8nHooks.js.map +1 -1
- package/dist/harness/openWebuiHooks.d.ts.map +1 -1
- package/dist/harness/openWebuiHooks.js +7 -7
- package/dist/harness/openWebuiHooks.js.map +1 -1
- package/dist/harness/openclawHooks.d.ts.map +1 -1
- package/dist/harness/openclawHooks.js +5 -4
- package/dist/harness/openclawHooks.js.map +1 -1
- package/dist/harness/openhandsHooks.d.ts.map +1 -1
- package/dist/harness/openhandsHooks.js +4 -10
- package/dist/harness/openhandsHooks.js.map +1 -1
- package/dist/harness/piHooks.d.ts +3 -1
- package/dist/harness/piHooks.d.ts.map +1 -1
- package/dist/harness/piHooks.js +5 -4
- package/dist/harness/piHooks.js.map +1 -1
- package/dist/harness/protectedPaths.d.ts +3 -1
- package/dist/harness/protectedPaths.d.ts.map +1 -1
- package/dist/harness/protectedPaths.js +56 -2
- package/dist/harness/protectedPaths.js.map +1 -1
- package/dist/harness/rulesSection.d.ts.map +1 -1
- package/dist/harness/rulesSection.js +3 -7
- package/dist/harness/rulesSection.js.map +1 -1
- package/dist/harness/windsurfHooks.d.ts.map +1 -1
- package/dist/harness/windsurfHooks.js +3 -7
- package/dist/harness/windsurfHooks.js.map +1 -1
- package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -1
- package/dist/harness/windsurfJetBrainsProxy.js +1 -5
- package/dist/harness/windsurfJetBrainsProxy.js.map +1 -1
- package/dist/inventory.js +1 -1
- package/dist/inventory.js.map +1 -1
- package/dist/lib/approvedBypasses.d.ts.map +1 -1
- package/dist/lib/approvedBypasses.js +2 -4
- package/dist/lib/approvedBypasses.js.map +1 -1
- package/dist/lib/atomicWrite.d.ts +45 -0
- package/dist/lib/atomicWrite.d.ts.map +1 -0
- package/dist/lib/atomicWrite.js +104 -0
- package/dist/lib/atomicWrite.js.map +1 -0
- package/dist/lib/chaosPy.d.ts +11 -0
- package/dist/lib/chaosPy.d.ts.map +1 -0
- package/dist/lib/chaosPy.js +320 -0
- package/dist/lib/chaosPy.js.map +1 -0
- package/dist/lib/complianceProbes.d.ts +28 -7
- package/dist/lib/complianceProbes.d.ts.map +1 -1
- package/dist/lib/complianceProbes.js +47 -14
- package/dist/lib/complianceProbes.js.map +1 -1
- package/dist/lib/decisionsDigest.d.ts.map +1 -1
- package/dist/lib/decisionsDigest.js +2 -5
- package/dist/lib/decisionsDigest.js.map +1 -1
- package/dist/lib/egressPolicy.d.ts.map +1 -1
- package/dist/lib/egressPolicy.js +2 -4
- package/dist/lib/egressPolicy.js.map +1 -1
- package/dist/lib/policySnapshot.d.ts +2 -2
- package/dist/lib/policySnapshot.d.ts.map +1 -1
- package/dist/lib/policySnapshot.js +43 -20
- package/dist/lib/policySnapshot.js.map +1 -1
- package/dist/lib/regexLinearity.d.ts.map +1 -1
- package/dist/lib/regexLinearity.js +2 -0
- package/dist/lib/regexLinearity.js.map +1 -1
- package/dist/lib/runtimeEnv.d.ts.map +1 -1
- package/dist/lib/runtimeEnv.js +3 -4
- package/dist/lib/runtimeEnv.js.map +1 -1
- package/dist/skillWriter.d.ts +2 -2
- package/dist/skillWriter.d.ts.map +1 -1
- package/dist/skillWriter.js +10 -6
- package/dist/skillWriter.js.map +1 -1
- package/dist/trajectoryMonitor.d.ts +22 -6
- package/dist/trajectoryMonitor.d.ts.map +1 -1
- package/dist/trajectoryMonitor.js +40 -9
- package/dist/trajectoryMonitor.js.map +1 -1
- package/dist/watcher/settingsGuard.d.ts.map +1 -1
- package/dist/watcher/settingsGuard.js +23 -2
- package/dist/watcher/settingsGuard.js.map +1 -1
- package/dist/wsClient.d.ts +0 -1
- package/dist/wsClient.d.ts.map +1 -1
- package/dist/wsClient.js +0 -6
- package/dist/wsClient.js.map +1 -1
- package/package.json +22 -15
package/dist/harness/gateBody.js
CHANGED
|
@@ -48,9 +48,10 @@
|
|
|
48
48
|
*
|
|
49
49
|
* @module
|
|
50
50
|
*/
|
|
51
|
-
import { HOLD_APPROVAL_HINT_TEMPLATE, HOLD_ID_PLACEHOLDER } from '@intutic/shared-types';
|
|
51
|
+
import { HOLD_APPROVAL_HINT_TEMPLATE, HOLD_ID_PLACEHOLDER, INTERVENTION_MODE_RECORD_TAG, OPAQUE_REFUSAL_MESSAGE, } from '@intutic/shared-types';
|
|
52
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';
|
|
53
|
+
import { ARGUMENTS_SIZE_LIMIT, CHAOS_GATE_JS_SOURCE, 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 { CHAOS_PY_SOURCE } from '../lib/chaosPy.js';
|
|
54
55
|
import { MCP_REGISTRY_PY_SOURCE } from '../lib/mcpRegistryPy.js';
|
|
55
56
|
import { PHRASES_PY_SOURCE } from '../lib/phrasesPy.js';
|
|
56
57
|
import { SEQUENCE_PY_SOURCE } from '../lib/sequencePy.js';
|
|
@@ -220,8 +221,38 @@ import { SEQUENCE_PY_SOURCE } from '../lib/sequencePy.js';
|
|
|
220
221
|
* a call at the size limit had forked a hundred `grep`s and spent seconds of
|
|
221
222
|
* a loaded machine before any rule could match. The `.rules` format is
|
|
222
223
|
* unchanged.
|
|
224
|
+
*
|
|
225
|
+
* v19: agent fault injection. When a chaos run's runner sets
|
|
226
|
+
* `INTUTIC_CHAOS_PLAN` and `INTUTIC_CHAOS_EVENTS` in the agent's sandbox, the
|
|
227
|
+
* JS and bash gates report every call (`plan_loaded`, `gate_decision`,
|
|
228
|
+
* `tool_call`) to the events file and inject the plan's `tool.error` and
|
|
229
|
+
* `permission.deny_or_delay` faults into calls they allowed
|
|
230
|
+
* (`@intutic/shared-types` chaosGate.ts; `chaos.py` in the bash gates). A
|
|
231
|
+
* plan that is set but unreadable refuses the call. Without the variables
|
|
232
|
+
* nothing changes; the `.rules` format is unchanged.
|
|
233
|
+
*
|
|
234
|
+
* v20: a chaos run's permission delay no longer waits inside the gate. A
|
|
235
|
+
* v19 gate slept for `delay_s` in `delay` and `hold_then_deny` modes, cut
|
|
236
|
+
* short just under the hook timeout. A v20 gate holds the call the way an
|
|
237
|
+
* approval hold does: refused as held (`HELD` in a JSON decision), and so is
|
|
238
|
+
* the identical retry (the same tool, command and target, the key an
|
|
239
|
+
* approval hold uses) until `delay_s` has passed since the first refusal;
|
|
240
|
+
* then the retry is allowed, or denied with the plan's canary. The hold's
|
|
241
|
+
* state is a file beside the run's other counters (chaosGate.ts). Without a
|
|
242
|
+
* chaos plan nothing changes; the `.rules` format is unchanged.
|
|
243
|
+
*
|
|
244
|
+
* v21: the OPAQUE intervention mode. The snapshot of an OPAQUE workspace
|
|
245
|
+
* carries an `@intervention_mode` data line (tag and `OPAQUE`, two columns,
|
|
246
|
+
* inside the digest; `encodeInterventionModeRecord` in `@intutic/shared-types`),
|
|
247
|
+
* and a gate reading one refuses on workspace policy with
|
|
248
|
+
* `OPAQUE_REFUSAL_MESSAGE` in place of the rule's reason and id: what the agent
|
|
249
|
+
* reads on stderr, in a JSON decision's reason (whose `ruleId` is null) and in
|
|
250
|
+
* a thrown error. The code, exit status and contract are unchanged, and the
|
|
251
|
+
* audit line keeps the full reason. A TRANSPARENT or SILENT_LOG snapshot has no
|
|
252
|
+
* such line and is unchanged. A v20 gate skips the line, as it skips any
|
|
253
|
+
* two-column one, and names the rule.
|
|
223
254
|
*/
|
|
224
|
-
export const GATE_VERSION =
|
|
255
|
+
export const GATE_VERSION = 21;
|
|
225
256
|
/**
|
|
226
257
|
* The refusal codes a hook gate's JSON decision carries, as `code` beside the
|
|
227
258
|
* deciding `ruleId` (null when no rule decided), for the harnesses that read
|
|
@@ -246,7 +277,12 @@ export const GATE_VERSION = 18;
|
|
|
246
277
|
* - `GATE_DEADLINE`: the gate did not decide within its harness's deadline
|
|
247
278
|
* (`gateDeadlineMs` in `@intutic/shared-types`);
|
|
248
279
|
* - `UNREADABLE_CALL`: the payload held no tool call the gate could read;
|
|
249
|
-
* - `GATE_CRASHED`: the gate failed while deciding
|
|
280
|
+
* - `GATE_CRASHED`: the gate failed while deciding, or a chaos run's
|
|
281
|
+
* `INTUTIC_CHAOS_PLAN` is set but unreadable.
|
|
282
|
+
*
|
|
283
|
+
* A fault a chaos run injects into an allowed call is not a refusal by
|
|
284
|
+
* governance: its JSON decision carries `code: null` and `ruleId: null`, or
|
|
285
|
+
* `code: 'HELD'` and `ruleId: null` when a permission fault holds the call.
|
|
250
286
|
*/
|
|
251
287
|
export const HOOK_REFUSAL_CODES = [
|
|
252
288
|
'BUILT_IN_RULE',
|
|
@@ -321,6 +357,8 @@ export const REVIEW_REQUEST_VERSION = 1;
|
|
|
321
357
|
* decision on an MCP call.
|
|
322
358
|
* - `intutic_mcp_unverified(tool)` — the refusal of an MCP call on a
|
|
323
359
|
* snapshot that failed its integrity check.
|
|
360
|
+
* - `intutic_chaos_begin()` and `intutic_chaos_call(...)` — a chaos run's
|
|
361
|
+
* plan check and per-call record and fault ({@link CHAOS_PY_SOURCE}).
|
|
324
362
|
*
|
|
325
363
|
* The classifier used to be `grep -E` with a gap regex over the normalised
|
|
326
364
|
* command, and bash's own pattern substitution on it took a minute on a long
|
|
@@ -368,6 +406,35 @@ def intutic_mcp_unverified(tool):
|
|
|
368
406
|
# is not an MCP call.
|
|
369
407
|
call = split_mcp_tool_name(tool)
|
|
370
408
|
return "\\t".join(unverified_refusal(call[0])) if call else ""
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
${CHAOS_PY_SOURCE}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def intutic_chaos_begin():
|
|
415
|
+
# Why the chaos run's plan cannot be used, or "" when it can.
|
|
416
|
+
plan, error = chaos_decode(os.environ.get("INTUTIC_CHAOS_PLAN", ""))
|
|
417
|
+
if error:
|
|
418
|
+
return error
|
|
419
|
+
if not os.environ.get("INTUTIC_CHAOS_EVENTS"):
|
|
420
|
+
return "INTUTIC_CHAOS_PLAN is set but INTUTIC_CHAOS_EVENTS is not, so nothing the run does could be reported"
|
|
421
|
+
return ""
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def intutic_chaos_call(tool, decision, rule_id, destructive, policy_hit, key=""):
|
|
425
|
+
# Records one call as the gate decided it, before any fault; returns the
|
|
426
|
+
# fault to apply to an allowed call as "kind<TAB>reason" (kind deny or
|
|
427
|
+
# hold), or "". key is the call's digest, which a held call's retry shares.
|
|
428
|
+
plan, error = chaos_decode(os.environ.get("INTUTIC_CHAOS_PLAN", ""))
|
|
429
|
+
events = os.environ.get("INTUTIC_CHAOS_EVENTS", "")
|
|
430
|
+
if error or not events:
|
|
431
|
+
return ""
|
|
432
|
+
call = {"tool": tool, "decision": decision, "ruleId": rule_id or None,
|
|
433
|
+
"destructive": destructive == "1", "policyHit": policy_hit == "1", "key": key}
|
|
434
|
+
fault = chaos_call(plan, events, call, chaos_now())
|
|
435
|
+
if fault is None:
|
|
436
|
+
return ""
|
|
437
|
+
return fault["kind"] + "\\t" + fault["reason"]
|
|
371
438
|
`;
|
|
372
439
|
/**
|
|
373
440
|
* How old a snapshot may be before a gate reports it as stale.
|
|
@@ -513,6 +580,12 @@ intutic_fail_closed() {
|
|
|
513
580
|
set +eu
|
|
514
581
|
trap - EXIT
|
|
515
582
|
[ -n "\${INTUTIC_WATCHDOG_PID:-}" ] && kill "$INTUTIC_WATCHDOG_PID" 2>/dev/null
|
|
583
|
+
# A chaos run's record of a call the gate refused, held or crashed on (the
|
|
584
|
+
# gate body records an allowed one where it ends). It cannot change the
|
|
585
|
+
# exit status: everything it prints is discarded.
|
|
586
|
+
if [ "$_intutic_exit_rc" != "0" ] && [ "\${INTUTIC_CHAOS_ACTIVE:-0}" = "1" ] && command -v intutic_chaos_decide >/dev/null 2>&1; then
|
|
587
|
+
intutic_chaos_decide "\${_intutic_chaos_decision:-deny}" >/dev/null 2>&1
|
|
588
|
+
fi
|
|
516
589
|
if [ "$_intutic_exit_rc" = "0" ] || [ "$_intutic_exit_rc" = "2" ]; then
|
|
517
590
|
exit "$_intutic_exit_rc"
|
|
518
591
|
fi
|
|
@@ -587,6 +660,16 @@ function intuticPhraseRule(source, words) {
|
|
|
587
660
|
return false;
|
|
588
661
|
}`;
|
|
589
662
|
}
|
|
663
|
+
/**
|
|
664
|
+
* What an OPAQUE workspace's agent reads of a refusal on workspace policy,
|
|
665
|
+
* emitted once into every JS-family gate: `intuticShown(snap, reason)` is the
|
|
666
|
+
* reason, or the generic sentence when the snapshot carries the
|
|
667
|
+
* `@intervention_mode` record. The audit line always gets the full reason.
|
|
668
|
+
*/
|
|
669
|
+
const JS_OPAQUE_HELPERS = `const INTUTIC_OPAQUE_MESSAGE = ${JSON.stringify(OPAQUE_REFUSAL_MESSAGE)};
|
|
670
|
+
function intuticShown(snap, reason) {
|
|
671
|
+
return snap && snap.opaque ? INTUTIC_OPAQUE_MESSAGE : reason;
|
|
672
|
+
}`;
|
|
590
673
|
/**
|
|
591
674
|
* The hold tier's JS helpers, emitted once into every JS-family gate.
|
|
592
675
|
*
|
|
@@ -598,6 +681,7 @@ function intuticPhraseRule(source, words) {
|
|
|
598
681
|
function jsHoldHelpers(reviewRequestFile) {
|
|
599
682
|
return `${emitJsActionClassifier()}
|
|
600
683
|
const INTUTIC_REVIEW_REQUEST_FILE = ${JSON.stringify(reviewRequestFile ?? null)};
|
|
684
|
+
${JS_OPAQUE_HELPERS}
|
|
601
685
|
|
|
602
686
|
// Who may approve a hold, and when the retry passes: the sentence every gate
|
|
603
687
|
// and the MCP proxy print (holdMessages.ts in shared-types).
|
|
@@ -654,7 +738,7 @@ function intuticApprovedBypass(workspaceId, sopRuleId, toolNameNormalized, targe
|
|
|
654
738
|
return null;
|
|
655
739
|
}
|
|
656
740
|
|
|
657
|
-
function intuticHold(rule, toolName, command, target, toolInput, record, workspaceId, sessionId) {
|
|
741
|
+
function intuticHold(rule, toolName, command, target, toolInput, record, workspaceId, sessionId, opaque) {
|
|
658
742
|
var crypto = require('crypto'), fs = require('fs'), path = require('path');
|
|
659
743
|
// Key material, computed the same way at both ends: the normalised tool
|
|
660
744
|
// name, and sha256 of the normalised command/target pair joined by a NUL so
|
|
@@ -700,7 +784,9 @@ function intuticHold(rule, toolName, command, target, toolInput, record, workspa
|
|
|
700
784
|
try { console.error('[Intutic Guardrail] could not record the hold: ' + (e && e.message ? e.message : e)); } catch (e2) {}
|
|
701
785
|
}
|
|
702
786
|
var reason = rule.reason + ' [' + rule.id + ']';
|
|
703
|
-
|
|
787
|
+
// An OPAQUE workspace's agent reads the generic sentence, not the rule; the
|
|
788
|
+
// hint stays, since it names the hold, not the policy.
|
|
789
|
+
try { console.error('[Intutic Guardrail] HELD: ' + (opaque ? INTUTIC_OPAQUE_MESSAGE : reason) + ' ' + intuticHoldHint(holdId)); } catch (e) {}
|
|
704
790
|
try { record('tool_held', toolName, reason); } catch (e) {}
|
|
705
791
|
return { holdId: holdId };
|
|
706
792
|
}`;
|
|
@@ -728,6 +814,52 @@ export function emitShellGate(opts) {
|
|
|
728
814
|
# were previously passed in and dropped on the floor: opts.harness was declared
|
|
729
815
|
# on the option type, supplied by all thirteen writers, and read nowhere.
|
|
730
816
|
|
|
817
|
+
# ── Agent fault injection (intutic chaos run) ────────────────────────────────
|
|
818
|
+
# Only when INTUTIC_CHAOS_PLAN is set: a chaos experiment's runner sets it, with
|
|
819
|
+
# INTUTIC_CHAOS_EVENTS, in the sandbox the agent runs in, and this gate inherits
|
|
820
|
+
# both. The work is chaos.py in INTUTIC_PY_LIB, the transliteration of
|
|
821
|
+
# chaosGate.ts in @intutic/shared-types, which explains the counter files kept
|
|
822
|
+
# beside the events file. An allowed call is recorded where this body ends,
|
|
823
|
+
# where its fault is applied; a refused, held or crashed one by the exit trap.
|
|
824
|
+
# A fault applies only to an allowed call. Scoped to the bare invocation, like
|
|
825
|
+
# the envelope refusal below.
|
|
826
|
+
INTUTIC_CHAOS_ACTIVE=0
|
|
827
|
+
_intutic_chaos_done=0
|
|
828
|
+
_intutic_chaos_decision=deny
|
|
829
|
+
_intutic_chaos_rule=""
|
|
830
|
+
_intutic_chaos_hit=0
|
|
831
|
+
_intutic_chaos_destructive=0
|
|
832
|
+
# Records this call as decided ($1: allow, deny or hold) and prints the fault
|
|
833
|
+
# to apply to an allowed one, as kind and reason, tab-separated. $2 is the
|
|
834
|
+
# call's digest, which a held call's retry shares.
|
|
835
|
+
intutic_chaos_decide() {
|
|
836
|
+
[ "$INTUTIC_CHAOS_ACTIVE" = "1" ] && [ "$_intutic_chaos_done" = "0" ] || return 0
|
|
837
|
+
python3 -c 'import os, sys
|
|
838
|
+
lib = {}
|
|
839
|
+
exec(os.environ.get("INTUTIC_PY_LIB", ""), lib)
|
|
840
|
+
sys.stdout.write(lib["intutic_chaos_call"](*sys.argv[1:]))' "\${TOOL:-unknown}" "$1" "$_intutic_chaos_rule" "$_intutic_chaos_destructive" "$_intutic_chaos_hit" "\${2:-}" || true
|
|
841
|
+
}
|
|
842
|
+
# A rule fired without deciding the call (shadow, warn): its id, if none yet.
|
|
843
|
+
intutic_chaos_note() {
|
|
844
|
+
[ -n "$_intutic_chaos_rule" ] || _intutic_chaos_rule="$1"
|
|
845
|
+
}
|
|
846
|
+
if [ -z "\${1:-}" ] && [ -n "\${INTUTIC_CHAOS_PLAN:-}" ]; then
|
|
847
|
+
# A plan that is set but unreadable refuses, as an unreadable payload does:
|
|
848
|
+
# an experiment run without the faults it asked for reports a resilience it
|
|
849
|
+
# never measured.
|
|
850
|
+
_intutic_chaos_err="$(python3 -c 'import os, sys
|
|
851
|
+
lib = {}
|
|
852
|
+
exec(os.environ.get("INTUTIC_PY_LIB", ""), lib)
|
|
853
|
+
sys.stdout.write(lib["intutic_chaos_begin"]())' 2>/dev/null || echo "INTUTIC_CHAOS_PLAN could not be read")"
|
|
854
|
+
if [ -n "$_intutic_chaos_err" ]; then
|
|
855
|
+
_intutic_chaos_err="\${_intutic_chaos_err} — refusing the call rather than running the experiment without the faults it asked for"
|
|
856
|
+
echo "[Intutic Governance] BLOCKED: \${_intutic_chaos_err}" >&2
|
|
857
|
+
${log} "tool_blocked" "\${TOOL:-unknown}" "\${_intutic_chaos_err}" || true
|
|
858
|
+
exit 2
|
|
859
|
+
fi
|
|
860
|
+
INTUTIC_CHAOS_ACTIVE=1
|
|
861
|
+
fi
|
|
862
|
+
|
|
731
863
|
# Refuse an unreadable envelope before any rule is consulted.
|
|
732
864
|
#
|
|
733
865
|
# Unparseable stdin used to degrade to empty extracted fields, which match no
|
|
@@ -794,6 +926,12 @@ INTUTIC_MCP_SEVERITY=""
|
|
|
794
926
|
INTUTIC_MCP_SERVERS=""
|
|
795
927
|
# The MCP server registry record (base64 JSON, inside the digest).
|
|
796
928
|
INTUTIC_MCP_REGISTRY=""
|
|
929
|
+
# An OPAQUE workspace (the @intervention_mode record): a refusal on workspace
|
|
930
|
+
# policy tells the agent this sentence instead of the rule, and the audit line
|
|
931
|
+
# keeps the rule. Kept on a snapshot that fails its digest: it only ever hides
|
|
932
|
+
# text, never allows a call.
|
|
933
|
+
INTUTIC_OPAQUE=0
|
|
934
|
+
INTUTIC_OPAQUE_MESSAGE=${shq(OPAQUE_REFUSAL_MESSAGE)}
|
|
797
935
|
if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
|
|
798
936
|
INTUTIC_SNAPSHOT_STATE="ok"
|
|
799
937
|
while IFS= read -r _line || [ -n "$_line" ]; do
|
|
@@ -811,6 +949,7 @@ if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
|
|
|
811
949
|
INTUTIC_MCP_SERVERS="\${_intutic_mcp_rest#*$'\\t'}"
|
|
812
950
|
;;
|
|
813
951
|
'${MCP_REGISTRY_RECORD_TAG}'$'\\t'*) INTUTIC_MCP_REGISTRY="\${_line#*$'\\t'}" ;;
|
|
952
|
+
'${INTERVENTION_MODE_RECORD_TAG}'$'\\t''OPAQUE') INTUTIC_OPAQUE=1 ;;
|
|
814
953
|
'#'*|'') : ;;
|
|
815
954
|
*) INTUTIC_DYNAMIC+=("$_line") ;;
|
|
816
955
|
esac
|
|
@@ -914,6 +1053,12 @@ intutic_sha256() {
|
|
|
914
1053
|
intutic_json_escape() {
|
|
915
1054
|
printf '%s' "$1" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g' | tr -d '\\n\\r\\t'
|
|
916
1055
|
}
|
|
1056
|
+
# What the agent reads of a refusal on workspace policy, in _intutic_shown: the
|
|
1057
|
+
# reason ($1), or in an OPAQUE workspace the generic sentence. The audit line
|
|
1058
|
+
# is always written with the full reason.
|
|
1059
|
+
intutic_shown() {
|
|
1060
|
+
if [ "$INTUTIC_OPAQUE" = "1" ]; then _intutic_shown="$INTUTIC_OPAQUE_MESSAGE"; else _intutic_shown="$1"; fi
|
|
1061
|
+
}
|
|
917
1062
|
# Looks for a valid, unexpired, EXACT-match approved bypass. Prints nothing
|
|
918
1063
|
# and returns 1 on ANY reason not to bypass: missing file, failed digest or
|
|
919
1064
|
# workspace check, no matching line, or an entry whose expiresAt has passed —
|
|
@@ -976,7 +1121,8 @@ intutic_hold() {
|
|
|
976
1121
|
# Who may approve, and when the retry passes (holdMessages.ts in shared-types).
|
|
977
1122
|
hint=${shq(HOLD_APPROVAL_HINT_TEMPLATE)}
|
|
978
1123
|
hint="\${hint//\\{holdId\\}/$hold_id}"
|
|
979
|
-
|
|
1124
|
+
intutic_shown "\${rreason} [\${rid}]"
|
|
1125
|
+
echo "[Intutic Guardrail] HELD: \${_intutic_shown} \${hint}" >&2
|
|
980
1126
|
${log} "tool_held" "\${TOOL:-}" "\${rreason} [\${rid}]" || true
|
|
981
1127
|
return 2
|
|
982
1128
|
}
|
|
@@ -984,24 +1130,20 @@ intutic_hold() {
|
|
|
984
1130
|
# A rule declares which part of the call it matches. \`any\` means command and
|
|
985
1131
|
# target — never the tool name, because a pattern like \`\\.claude/settings\\.json\`
|
|
986
1132
|
# would otherwise be tested against "Write" and quietly match nothing.
|
|
987
|
-
|
|
988
|
-
|
|
1133
|
+
#
|
|
1134
|
+
# Whether a rule fires on this call sets _intutic_fires to 1 or 0; the function
|
|
1135
|
+
# always returns 0, so it runs under set -e like the rest of the gate. With $2
|
|
1136
|
+
# "quiet" it records nothing: a chaos run's destructive check probes rules
|
|
1137
|
+
# without deciding by them.
|
|
1138
|
+
intutic_rule_fires() {
|
|
1139
|
+
local rec="$1" quiet="\${2:-}"
|
|
989
1140
|
local rid rsev rflags rsubj rreason rsrc rargb64 s hit
|
|
1141
|
+
_intutic_fires=0
|
|
990
1142
|
# Seven variables over a six- or seven-field record: a v3 line leaves rargb64
|
|
991
1143
|
# empty, which is exactly "no argument condition". The base64 column cannot
|
|
992
1144
|
# contain a tab, so the last variable never absorbs a separator.
|
|
993
1145
|
IFS=$'\\t' read -r rid rsev rflags rsubj rreason rsrc rargb64 <<< "$rec"
|
|
994
1146
|
[ -z "$rsrc" ] && return 0
|
|
995
|
-
# The escape hatch skips the DESTRUCTIVE family only. It is not a way to turn
|
|
996
|
-
# off a workspace's own BLOCK: SOP rules — those are what the customer asked
|
|
997
|
-
# for, and one environment variable disarming them would make the switch the
|
|
998
|
-
# first thing an agent sets. Keyed on the rule-id family, which the snapshot
|
|
999
|
-
# writer assigns: destructive.* versus sop.*
|
|
1000
|
-
if [ "$INTUTIC_GUARDS_DISABLED" = "1" ]; then
|
|
1001
|
-
case "$rid" in
|
|
1002
|
-
destructive.*) return 0 ;;
|
|
1003
|
-
esac
|
|
1004
|
-
fi
|
|
1005
1147
|
local _subs=() _ci=
|
|
1006
1148
|
case "$rflags" in *i*) _ci=i ;; esac
|
|
1007
1149
|
# A subject the screen below cleared for this case is left out: no pattern
|
|
@@ -1082,18 +1224,41 @@ sys.exit(0 if rx.search(sys.argv[2]) else 1)
|
|
|
1082
1224
|
' "$rargb64" "\${TOOL_INPUT_JSON:-}" 2>/dev/null || _intutic_arg_rc=$?
|
|
1083
1225
|
if [ "$_intutic_arg_rc" = "1" ]; then
|
|
1084
1226
|
return 0
|
|
1085
|
-
elif [ "$_intutic_arg_rc" != "0" ]; then
|
|
1227
|
+
elif [ "$_intutic_arg_rc" != "0" ] && [ -z "$quiet" ]; then
|
|
1086
1228
|
${log} "rule_downgraded" "\${TOOL:-}" "argPattern for \${rid} did not compile in python re — rule enforced name-only" || true
|
|
1087
1229
|
fi
|
|
1088
1230
|
fi
|
|
1231
|
+
_intutic_fires=1
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
intutic_apply() {
|
|
1235
|
+
local rec="$1"
|
|
1236
|
+
local rid rsev rflags rsubj rreason rsrc rargb64
|
|
1237
|
+
IFS=$'\\t' read -r rid rsev rflags rsubj rreason rsrc rargb64 <<< "$rec"
|
|
1238
|
+
[ -z "$rsrc" ] && return 0
|
|
1239
|
+
# The escape hatch skips the DESTRUCTIVE family only. It is not a way to turn
|
|
1240
|
+
# off a workspace's own BLOCK: SOP rules — those are what the customer asked
|
|
1241
|
+
# for, and one environment variable disarming them would make the switch the
|
|
1242
|
+
# first thing an agent sets. Keyed on the rule-id family, which the snapshot
|
|
1243
|
+
# writer assigns: destructive.* versus sop.*
|
|
1244
|
+
if [ "$INTUTIC_GUARDS_DISABLED" = "1" ]; then
|
|
1245
|
+
case "$rid" in
|
|
1246
|
+
destructive.*) return 0 ;;
|
|
1247
|
+
esac
|
|
1248
|
+
fi
|
|
1249
|
+
intutic_rule_fires "$rec"
|
|
1250
|
+
[ "$_intutic_fires" = "1" ] || return 0
|
|
1089
1251
|
if [ "$rsev" = "shadow" ]; then
|
|
1252
|
+
intutic_chaos_note "$rid"
|
|
1090
1253
|
${log} "tool_would_block" "\${TOOL:-}" "\${rreason} [\${rid}]" || true
|
|
1091
1254
|
return 0
|
|
1092
1255
|
fi
|
|
1093
1256
|
if [ "$rsev" = "hold" ]; then
|
|
1094
1257
|
# Not a permanent denial: the action is legitimate, someone asked to see
|
|
1095
1258
|
# it first. A bypass they approved lets this exact call through once.
|
|
1259
|
+
_intutic_chaos_rule="$rid"; _intutic_chaos_hit=1
|
|
1096
1260
|
if intutic_hold "$rid" "$rreason"; then return 0; fi
|
|
1261
|
+
_intutic_chaos_decision=hold
|
|
1097
1262
|
exit 2
|
|
1098
1263
|
fi
|
|
1099
1264
|
if [ "$rsev" = "warn" ]; then
|
|
@@ -1109,13 +1274,16 @@ sys.exit(0 if rx.search(sys.argv[2]) else 1)
|
|
|
1109
1274
|
# telemetry.
|
|
1110
1275
|
_intutic_verb="\${INTUTIC_NCOMMAND# }"
|
|
1111
1276
|
_intutic_verb="\${_intutic_verb%% *}"
|
|
1277
|
+
intutic_chaos_note "$rid"
|
|
1112
1278
|
${log} "tool_flagged" "\${TOOL:-}" "\${rreason} [\${rid}] verb=\${_intutic_verb}" || true
|
|
1113
1279
|
return 0
|
|
1114
1280
|
fi
|
|
1115
1281
|
# The rule's own reason, not a generic one: hookEvents.resolveSeverity reads
|
|
1116
1282
|
# this text for "governance-protected" to file the incident CRITICAL.
|
|
1117
1283
|
local reason="\${rreason} [\${rid}]"
|
|
1118
|
-
|
|
1284
|
+
_intutic_chaos_rule="$rid"; _intutic_chaos_hit=1
|
|
1285
|
+
intutic_shown "$reason"
|
|
1286
|
+
echo "[Intutic Governance] BLOCKED: \${_intutic_shown}" >&2
|
|
1119
1287
|
${log} "tool_blocked" "\${TOOL:-}" "\${reason}"
|
|
1120
1288
|
exit 2
|
|
1121
1289
|
}
|
|
@@ -1165,6 +1333,19 @@ intutic_unscreened() {
|
|
|
1165
1333
|
[ "\${!v:-}" != "1" ]
|
|
1166
1334
|
}
|
|
1167
1335
|
|
|
1336
|
+
# A chaos run's no_destructive_attempt probe: whether a destructive.* rule
|
|
1337
|
+
# matches this call, whether or not it decides it, the escape hatch included.
|
|
1338
|
+
if [ "$INTUTIC_CHAOS_ACTIVE" = "1" ] && [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
|
|
1339
|
+
for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
|
|
1340
|
+
case "$_rec" in
|
|
1341
|
+
destructive.*)
|
|
1342
|
+
intutic_rule_fires "$_rec" quiet
|
|
1343
|
+
if [ "$_intutic_fires" = "1" ]; then _intutic_chaos_destructive=1; break; fi
|
|
1344
|
+
;;
|
|
1345
|
+
esac
|
|
1346
|
+
done
|
|
1347
|
+
fi
|
|
1348
|
+
|
|
1168
1349
|
for _rec in "\${INTUTIC_FLOOR[@]}"; do
|
|
1169
1350
|
intutic_apply "$_rec"
|
|
1170
1351
|
done
|
|
@@ -1196,6 +1377,7 @@ sys.stdout.write(lib["intutic_mcp_unverified"](sys.argv[1]))' "\${TOOL:-}" 2>/de
|
|
|
1196
1377
|
_intutic_unv_rid="policy_snapshot"
|
|
1197
1378
|
_intutic_unv_reason="POLICY_SNAPSHOT_UNVERIFIED — the policy snapshot failed its integrity check and admits no MCP server"
|
|
1198
1379
|
fi
|
|
1380
|
+
_intutic_chaos_rule="$_intutic_unv_rid"; _intutic_chaos_hit=1
|
|
1199
1381
|
echo "[Intutic Governance] BLOCKED: \${_intutic_unv_reason} [\${_intutic_unv_rid}]" >&2
|
|
1200
1382
|
${log} "tool_blocked" "\${TOOL:-}" "\${_intutic_unv_reason} [\${_intutic_unv_rid}]"
|
|
1201
1383
|
exit 2
|
|
@@ -1219,7 +1401,9 @@ exec(os.environ.get("INTUTIC_PY_LIB", ""), lib)
|
|
|
1219
1401
|
sys.stdout.write(lib["intutic_mcp_registry"](sys.argv[1], sys.argv[2]))' "$INTUTIC_MCP_REGISTRY" "\${TOOL:-}" 2>/dev/null || true)"
|
|
1220
1402
|
if [ -n "$_intutic_reg" ]; then
|
|
1221
1403
|
IFS=$'\\t' read -r _ _intutic_reg_rid _intutic_reg_reason <<< "$_intutic_reg"
|
|
1222
|
-
|
|
1404
|
+
_intutic_chaos_rule="$_intutic_reg_rid"; _intutic_chaos_hit=1
|
|
1405
|
+
intutic_shown "\${_intutic_reg_reason} [\${_intutic_reg_rid}]"
|
|
1406
|
+
echo "[Intutic Governance] BLOCKED: \${_intutic_shown}" >&2
|
|
1223
1407
|
${log} "tool_blocked" "\${TOOL:-}" "\${_intutic_reg_reason} [\${_intutic_reg_rid}]"
|
|
1224
1408
|
exit 2
|
|
1225
1409
|
fi
|
|
@@ -1246,9 +1430,12 @@ case "\${TOOL:-}" in
|
|
|
1246
1430
|
*)
|
|
1247
1431
|
_intutic_mcp_reason="MCP server \\"$_intutic_mcp_server\\" is not on the MCP server allowlist for this workspace [mcp_allowlist]"
|
|
1248
1432
|
if [ "$INTUTIC_MCP_SEVERITY" = "shadow" ]; then
|
|
1433
|
+
intutic_chaos_note mcp_allowlist
|
|
1249
1434
|
${log} "tool_would_block" "\${TOOL:-}" "$_intutic_mcp_reason" || true
|
|
1250
1435
|
else
|
|
1251
|
-
|
|
1436
|
+
_intutic_chaos_rule=mcp_allowlist; _intutic_chaos_hit=1
|
|
1437
|
+
intutic_shown "$_intutic_mcp_reason"
|
|
1438
|
+
echo "[Intutic Governance] BLOCKED: \${_intutic_shown}" >&2
|
|
1252
1439
|
${log} "tool_blocked" "\${TOOL:-}" "$_intutic_mcp_reason"
|
|
1253
1440
|
exit 2
|
|
1254
1441
|
fi
|
|
@@ -1257,6 +1444,27 @@ case "\${TOOL:-}" in
|
|
|
1257
1444
|
fi
|
|
1258
1445
|
;;
|
|
1259
1446
|
esac
|
|
1447
|
+
|
|
1448
|
+
# ── Agent fault injection: the call is allowed ───────────────────────────────
|
|
1449
|
+
# Recorded as allowed, then the fault that is due, if any. A permission delay
|
|
1450
|
+
# never waits here: the call is held, and its identical retry (the same key an
|
|
1451
|
+
# approval hold uses) passes, or is denied, once the delay has passed.
|
|
1452
|
+
if [ "$INTUTIC_CHAOS_ACTIVE" = "1" ]; then
|
|
1453
|
+
_intutic_chaos_key="$( { printf '%s' "$INTUTIC_NTOOL"; printf '\\0'; printf '%s' "$INTUTIC_NCOMMAND"; printf '\\0'; printf '%s' "$INTUTIC_NTARGET"; } | intutic_sha256 )"
|
|
1454
|
+
_intutic_fault="$(intutic_chaos_decide allow "$_intutic_chaos_key")"
|
|
1455
|
+
_intutic_chaos_done=1
|
|
1456
|
+
if [ -n "$_intutic_fault" ]; then
|
|
1457
|
+
_intutic_fault_kind="\${_intutic_fault%%$'\\t'*}"
|
|
1458
|
+
_intutic_fault_reason="\${_intutic_fault#*$'\\t'}"
|
|
1459
|
+
# The fault's own text, carrying the plan's canary: what the agent reads.
|
|
1460
|
+
if [ "$_intutic_fault_kind" = "hold" ]; then
|
|
1461
|
+
echo "[Intutic Governance] HELD: \${_intutic_fault_reason}" >&2
|
|
1462
|
+
else
|
|
1463
|
+
echo "$_intutic_fault_reason" >&2
|
|
1464
|
+
fi
|
|
1465
|
+
exit 2
|
|
1466
|
+
fi
|
|
1467
|
+
fi
|
|
1260
1468
|
# ── end Intutic gate body ────────────────────────────────────────────────────
|
|
1261
1469
|
`;
|
|
1262
1470
|
}
|
|
@@ -1275,8 +1483,10 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
1275
1483
|
path.join(os.homedir(), '.intutic', 'hooks', 'policy-snapshot.rules');
|
|
1276
1484
|
// mcpAllowlist and mcpRegistry stay null without their records: the
|
|
1277
1485
|
// workspace set no allowlist and made no registry decision.
|
|
1486
|
+
// opaque: an OPAQUE workspace (the @intervention_mode record). Kept on a
|
|
1487
|
+
// snapshot that fails its digest: it only ever hides text, never allows.
|
|
1278
1488
|
const out = { rules: [], digest: 'none', state: 'absent', workspaceId: '', generatedAt: '', ageDays: 0,
|
|
1279
|
-
mcpAllowlist: null, mcpRegistry: null };
|
|
1489
|
+
mcpAllowlist: null, mcpRegistry: null, opaque: false };
|
|
1280
1490
|
let text;
|
|
1281
1491
|
try { text = fs.readFileSync(p, 'utf8'); } catch (e) { return out; }
|
|
1282
1492
|
out.state = 'ok';
|
|
@@ -1284,6 +1494,7 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
1284
1494
|
if (line.startsWith('#digest ')) { out.digest = line.slice(8).trim(); continue; }
|
|
1285
1495
|
if (line.startsWith('#workspace ')) { out.workspaceId = line.slice(11).trim(); continue; }
|
|
1286
1496
|
if (line.startsWith('#generated ')) { out.generatedAt = line.slice(11).trim(); continue; }
|
|
1497
|
+
if (line === '${INTERVENTION_MODE_RECORD_TAG}\\tOPAQUE') { out.opaque = true; continue; }
|
|
1287
1498
|
if (line.startsWith('${MCP_ALLOWLIST_RECORD_TAG}\\t')) {
|
|
1288
1499
|
// The MCP server allowlist: tag, severity and comma-joined servers, a
|
|
1289
1500
|
// data line inside the digest. Any severity but shadow refuses.
|
|
@@ -1379,6 +1590,92 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
|
|
|
1379
1590
|
}
|
|
1380
1591
|
return out;
|
|
1381
1592
|
}`;
|
|
1593
|
+
/**
|
|
1594
|
+
* A chaos run's side of the JS gates, defined once: the plan, this call's
|
|
1595
|
+
* record, and the fault to apply to an allowed call.
|
|
1596
|
+
*
|
|
1597
|
+
* `intuticChaosBegin` starts a call's record in `intuticGate`; rules that
|
|
1598
|
+
* fire without deciding note themselves (`intuticChaosNote`); every refusal
|
|
1599
|
+
* and the allow at the end report the decision (`intuticChaosDecide`), which
|
|
1600
|
+
* returns the fault due. Nothing here throws: a fault the gate cannot inject
|
|
1601
|
+
* or report is said on stderr, and the call is decided as governance decided
|
|
1602
|
+
* it.
|
|
1603
|
+
*/
|
|
1604
|
+
const JS_CHAOS_HELPERS = `// ── Agent fault injection (intutic chaos run) ───────────────────────────────
|
|
1605
|
+
// Only when INTUTIC_CHAOS_PLAN is set: a chaos experiment's runner sets it,
|
|
1606
|
+
// with INTUTIC_CHAOS_EVENTS, in the sandbox the agent runs in, and this gate
|
|
1607
|
+
// inherits both. Emitted from @intutic/shared-types chaosGate.ts, which
|
|
1608
|
+
// explains the counter files kept beside the events file. A fault applies only
|
|
1609
|
+
// to a call this gate allowed: it never turns a refusal or a hold into an allow.
|
|
1610
|
+
${CHAOS_GATE_JS_SOURCE}
|
|
1611
|
+
|
|
1612
|
+
var _intuticChaosState = null;
|
|
1613
|
+
var _intuticChaosCall = null;
|
|
1614
|
+
|
|
1615
|
+
/** The plan, read once per process: { plan, events, error }. */
|
|
1616
|
+
function intuticChaos() {
|
|
1617
|
+
if (_intuticChaosState) return _intuticChaosState;
|
|
1618
|
+
var raw = process.env.INTUTIC_CHAOS_PLAN || '', events = process.env.INTUTIC_CHAOS_EVENTS || '';
|
|
1619
|
+
var s = { plan: null, events: events, error: null };
|
|
1620
|
+
if (raw) {
|
|
1621
|
+
var d = chaosGateDecode(raw);
|
|
1622
|
+
if (d.error) s.error = d.error;
|
|
1623
|
+
else if (!events) s.error = 'INTUTIC_CHAOS_PLAN is set but INTUTIC_CHAOS_EVENTS is not, so nothing the run does could be reported';
|
|
1624
|
+
else s.plan = d.plan;
|
|
1625
|
+
}
|
|
1626
|
+
_intuticChaosState = s;
|
|
1627
|
+
return s;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/** Starts this call's record, unless the last gate run said more of the same call follows. */
|
|
1631
|
+
function intuticChaosBegin(toolName) {
|
|
1632
|
+
if (_intuticChaosCall && _intuticChaosCall.more) { _intuticChaosCall.more = false; return; }
|
|
1633
|
+
_intuticChaosCall = { tool: String(toolName == null ? '' : toolName), ruleId: null, destructive: false, policyHit: false, more: false };
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/** A rule fired on this call without deciding it (shadow, warn, an approved bypass). */
|
|
1637
|
+
function intuticChaosNote(ruleId, policyHit) {
|
|
1638
|
+
var c = _intuticChaosCall;
|
|
1639
|
+
if (!c) return;
|
|
1640
|
+
if (ruleId && !c.ruleId) c.ruleId = ruleId;
|
|
1641
|
+
if (policyHit) c.policyHit = true;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Reports this call as the gate decided it, before any fault (allow, deny or
|
|
1646
|
+
* hold), and returns the fault to apply to an allowed call, or null. key is
|
|
1647
|
+
* the call's digest (intuticChaosKey), which a held call's retry shares.
|
|
1648
|
+
*/
|
|
1649
|
+
function intuticChaosDecide(toolName, decision, ruleId, policyHit, key) {
|
|
1650
|
+
var c = _intuticChaosCall;
|
|
1651
|
+
_intuticChaosCall = null;
|
|
1652
|
+
try {
|
|
1653
|
+
var s = intuticChaos();
|
|
1654
|
+
if (!s.plan) return null;
|
|
1655
|
+
return chaosGateCall(require('fs'), s.plan, s.events, {
|
|
1656
|
+
tool: c ? c.tool : String(toolName == null ? '' : toolName),
|
|
1657
|
+
decision: decision,
|
|
1658
|
+
ruleId: ruleId || (c ? c.ruleId : null),
|
|
1659
|
+
destructive: !!(c && c.destructive),
|
|
1660
|
+
policyHit: !!policyHit || !!(c && c.policyHit),
|
|
1661
|
+
key: key || '',
|
|
1662
|
+
}, new Date().toISOString());
|
|
1663
|
+
} catch (e) {
|
|
1664
|
+
try { console.error('[Intutic Governance] chaos: ' + String((e && e.message) || e)); } catch (e2) {}
|
|
1665
|
+
return null;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* A call's digest, as an approval hold keys it: sha256 of the normalised tool,
|
|
1671
|
+
* command and target, NUL-separated. A held call's identical retry has the
|
|
1672
|
+
* same one.
|
|
1673
|
+
*/
|
|
1674
|
+
function intuticChaosKey(toolName, command, target) {
|
|
1675
|
+
return require('crypto').createHash('sha256')
|
|
1676
|
+
.update(intuticNormalise(toolName) + '\\u0000' + intuticNormalise(command) + '\\u0000' + intuticNormalise(target))
|
|
1677
|
+
.digest('hex');
|
|
1678
|
+
}`;
|
|
1382
1679
|
/**
|
|
1383
1680
|
* The JavaScript gate body.
|
|
1384
1681
|
*
|
|
@@ -1395,13 +1692,19 @@ export function emitJsGate(opts) {
|
|
|
1395
1692
|
* `code` and `ruleId` are JavaScript expressions: a JSON decision carries
|
|
1396
1693
|
* them ({@link HOOK_REFUSAL_CODES}), and `extra` adds fields such as `holdId`.
|
|
1397
1694
|
*/
|
|
1398
|
-
const refuseWith = (code, ruleId, extra = '') => {
|
|
1399
|
-
|
|
1695
|
+
const refuseWith = (code, ruleId, extra = '', tool = 'toolName', shownRuleId = ruleId) => {
|
|
1696
|
+
// The decision names shownRuleId: the deciding rule, or null where an
|
|
1697
|
+
// OPAQUE workspace hides it. A chaos run's record keeps the rule.
|
|
1698
|
+
const fields = `code: ${code}, ruleId: ${shownRuleId}${extra ? `, ${extra}` : ''}`;
|
|
1699
|
+
// A chaos run's record of the refusal, first: every contract below ends
|
|
1700
|
+
// the call. A refusal a rule decided is a policy hit.
|
|
1701
|
+
const chaos = ` intuticChaosDecide(${tool}, '${code === "'HELD'" ? 'hold' : 'deny'}', ${ruleId}, ${ruleId} != null);\n`;
|
|
1400
1702
|
if (opts.contract === 'stdout-decision-deny' && opts.documentedFieldsOnly) {
|
|
1401
|
-
return (
|
|
1703
|
+
return (chaos +
|
|
1704
|
+
` process.stdout.write(JSON.stringify({ decision: 'deny', reason: reason + ' (refusal code ' + ${code} + ')' }) + '\\n');\n` +
|
|
1402
1705
|
` process.exit(0);`);
|
|
1403
1706
|
}
|
|
1404
|
-
return opts.contract === 'stdout-cancel'
|
|
1707
|
+
return chaos + (opts.contract === 'stdout-cancel'
|
|
1405
1708
|
// Cline shows a cancel's `errorMessage`; it has no `reason` field.
|
|
1406
1709
|
? ` process.stdout.write(JSON.stringify({ cancel: true, errorMessage: reason, ${fields} }) + '\\n');\n` +
|
|
1407
1710
|
` process.exit(0);`
|
|
@@ -1412,8 +1715,27 @@ export function emitJsGate(opts) {
|
|
|
1412
1715
|
// The envelope guard's reason already carries the prefix; a rule's
|
|
1413
1716
|
// reason does not. One message shape either way.
|
|
1414
1717
|
? ` throw new Error(String(reason).indexOf('[Intutic Governance]') === 0 ? String(reason) : '[Intutic Governance] BLOCKED: ' + reason);`
|
|
1415
|
-
: ` process.exit(2)
|
|
1718
|
+
: ` process.exit(2);`);
|
|
1416
1719
|
};
|
|
1720
|
+
/**
|
|
1721
|
+
* A fault a chaos run injects into an allowed call, for a `reason` in scope:
|
|
1722
|
+
* the fault's own text with the plan's canary, through this harness's
|
|
1723
|
+
* contract. Not a refusal by governance, so a JSON decision carries
|
|
1724
|
+
* `code: null`, or `HELD` for a call a permission fault holds (whose
|
|
1725
|
+
* reason already starts `[Intutic Governance] HELD:`, as a hold's does);
|
|
1726
|
+
* the in-process contracts prefix a refusal's text so the host's wrapper
|
|
1727
|
+
* passes it on as it is rather than as a gate fault.
|
|
1728
|
+
*/
|
|
1729
|
+
const refuseFault = (code) => ` try { console.error(reason); } catch (e) {}\n` +
|
|
1730
|
+
(opts.contract === 'stdout-cancel'
|
|
1731
|
+
? ` process.stdout.write(JSON.stringify({ cancel: true, errorMessage: reason, code: ${code}, ruleId: null }) + '\\n');\n` +
|
|
1732
|
+
` process.exit(0);`
|
|
1733
|
+
: opts.contract === 'stdout-decision-deny'
|
|
1734
|
+
? ` process.stdout.write(JSON.stringify(${opts.documentedFieldsOnly ? `{ decision: 'deny', reason: reason${code === 'null' ? '' : ` + ' (refusal code HELD)'`} }` : `{ decision: 'deny', reason: reason, code: ${code}, ruleId: null }`}) + '\\n');\n` +
|
|
1735
|
+
` process.exit(0);`
|
|
1736
|
+
: opts.contract === 'throw'
|
|
1737
|
+
? ` throw new Error(${code === 'null' ? `'[Intutic Governance] ' + reason` : 'reason'});`
|
|
1738
|
+
: ` process.exit(2);`);
|
|
1417
1739
|
return `
|
|
1418
1740
|
// ── Intutic gate body v${GATE_VERSION} — harness: ${opts.harness} ────────────
|
|
1419
1741
|
// Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
|
|
@@ -1432,6 +1754,8 @@ ${JS_SNAPSHOT_LOADER}
|
|
|
1432
1754
|
|
|
1433
1755
|
${jsHoldHelpers(opts.reviewRequestFile)}
|
|
1434
1756
|
|
|
1757
|
+
${JS_CHAOS_HELPERS}
|
|
1758
|
+
|
|
1435
1759
|
/**
|
|
1436
1760
|
* Refuses a parsed payload that carries no recognisable tool-call envelope.
|
|
1437
1761
|
*
|
|
@@ -1462,7 +1786,7 @@ function intuticGuardEnvelope(ctx, keys, record) {
|
|
|
1462
1786
|
'refusing rather than allowing a call the gate cannot read.';
|
|
1463
1787
|
try { console.error(reason); } catch (e) {}
|
|
1464
1788
|
try { record('tool_blocked', 'unknown', reason); } catch (e) {}
|
|
1465
|
-
${refuseWith("'UNREADABLE_CALL'", 'null')}
|
|
1789
|
+
${refuseWith("'UNREADABLE_CALL'", 'null', '', "'unknown'")}
|
|
1466
1790
|
}
|
|
1467
1791
|
|
|
1468
1792
|
/**
|
|
@@ -1499,7 +1823,18 @@ ${MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE}
|
|
|
1499
1823
|
* matchSopRule and the intutic-clawde gate port pin — and the gate serializes
|
|
1500
1824
|
* it itself so no writer can hand it a differently-shaped string.
|
|
1501
1825
|
*/
|
|
1502
|
-
function intuticGate(toolName, target, command, record, workspaceId, toolInput, sessionId) {
|
|
1826
|
+
function intuticGate(toolName, target, command, record, workspaceId, toolInput, sessionId, more) {
|
|
1827
|
+
// A chaos run's record of this call (see intuticChaosDecide). A plan that is
|
|
1828
|
+
// set but unreadable refuses, as an unreadable payload does: an experiment
|
|
1829
|
+
// run without the faults it asked for reports a resilience it never measured.
|
|
1830
|
+
intuticChaosBegin(toolName);
|
|
1831
|
+
var _chaos = intuticChaos();
|
|
1832
|
+
if (_chaos.error) {
|
|
1833
|
+
var reason = _chaos.error + ' — refusing the call rather than running the experiment without the faults it asked for';
|
|
1834
|
+
try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
|
|
1835
|
+
try { record('tool_blocked', toolName, reason); } catch (e) {}
|
|
1836
|
+
${refuseWith("'GATE_CRASHED'", 'null')}
|
|
1837
|
+
}
|
|
1503
1838
|
// Too large to evaluate inside the hook timeout (gateLimits.ts in
|
|
1504
1839
|
// @intutic/shared-types): refused before any rule reads the call.
|
|
1505
1840
|
var _commandBytes = Buffer.byteLength(String(command == null ? '' : command), 'utf8');
|
|
@@ -1530,7 +1865,7 @@ ${opts.contract === 'throw'
|
|
|
1530
1865
|
return _intuticGateRules(toolName, target, command, record, workspaceId, toolInput, sessionId);
|
|
1531
1866
|
};
|
|
1532
1867
|
try {
|
|
1533
|
-
|
|
1868
|
+
require('vm').runInThisContext('__intuticGateRules()', { timeout: _deadlineMs });
|
|
1534
1869
|
} catch (err) {
|
|
1535
1870
|
if (!err || err.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw err;
|
|
1536
1871
|
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';
|
|
@@ -1538,6 +1873,53 @@ ${opts.contract === 'throw'
|
|
|
1538
1873
|
try { record('tool_blocked', toolName, reason); } catch (e) {}
|
|
1539
1874
|
${refuseWith("'GATE_DEADLINE'", 'null')}
|
|
1540
1875
|
}
|
|
1876
|
+
|
|
1877
|
+
// The call is allowed. A writer that runs this once per target of one call
|
|
1878
|
+
// (OpenClaw's apply_patch) passes more for all but the last, so the
|
|
1879
|
+
// call is reported, and faulted, once.
|
|
1880
|
+
if (more) {
|
|
1881
|
+
if (_intuticChaosCall) _intuticChaosCall.more = true;
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
var _fault = intuticChaosDecide(toolName, 'allow', null, false, intuticChaos().plan ? intuticChaosKey(toolName, command, target) : '');
|
|
1885
|
+
if (!_fault) return;
|
|
1886
|
+
// A permission delay never waits here: the call is held, and its identical
|
|
1887
|
+
// retry passes, or is denied, once the delay has passed.
|
|
1888
|
+
if (_fault.kind === 'hold') {
|
|
1889
|
+
var reason = '[Intutic Governance] HELD: ' + _fault.reason;
|
|
1890
|
+
${refuseFault("'HELD'")}
|
|
1891
|
+
}
|
|
1892
|
+
var reason = _fault.reason;
|
|
1893
|
+
${refuseFault('null')}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* The subjects of this call a rule matches, before its argument condition:
|
|
1898
|
+
* for a phrase rule, the command when its phrases are in it. The context c
|
|
1899
|
+
* holds the call's subject strings, and the phrase matcher's words once
|
|
1900
|
+
* computed.
|
|
1901
|
+
*/
|
|
1902
|
+
function intuticRuleSubjects(rule, c) {
|
|
1903
|
+
// A phrase rule's source is |-separated phrases matched as words against
|
|
1904
|
+
// the raw command — never as a regex, which backtracks on crafted input.
|
|
1905
|
+
if (rule.subject === 'phrase') {
|
|
1906
|
+
if (c.words === null) c.words = phraseText(c.command);
|
|
1907
|
+
return intuticPhraseRule(rule.re.source, c.words) ? [c.nCommand] : [];
|
|
1908
|
+
}
|
|
1909
|
+
// Each field is tested separately rather than concatenated — joining them
|
|
1910
|
+
// lets a pattern match across the seam between two innocuous values.
|
|
1911
|
+
const subjects =
|
|
1912
|
+
rule.subject === 'tool' ? [c.nTool]
|
|
1913
|
+
: rule.subject === 'command' ? [c.nCommand]
|
|
1914
|
+
: rule.subject === 'target' ? [c.nTarget]
|
|
1915
|
+
// The serialized tool input, un-normalised: a content rule (the
|
|
1916
|
+
// secrets.* floor) matches the bytes a Write would put on disk. The
|
|
1917
|
+
// same string the WHERE argPattern machinery tests, so the two content
|
|
1918
|
+
// subjects cannot drift.
|
|
1919
|
+
: rule.subject === 'content' ? [c.toolInputJson]
|
|
1920
|
+
: rule.subject === 'action' ? [c.nActions]
|
|
1921
|
+
: [c.nCommand, c.nTarget];
|
|
1922
|
+
return subjects.filter(function (subject) { return intuticRuleMatches(rule, subject); });
|
|
1541
1923
|
}
|
|
1542
1924
|
|
|
1543
1925
|
function _intuticGateRules(toolName, target, command, record, workspaceId, toolInput, sessionId) {
|
|
@@ -1587,6 +1969,11 @@ function _intuticGateRules(toolName, target, command, record, workspaceId, toolI
|
|
|
1587
1969
|
: 'Policy snapshot is ' + snap.ageDays + ' days old and still enforced';
|
|
1588
1970
|
try { record('snapshot_' + snap.state, toolName, _msg); } catch (e) {}
|
|
1589
1971
|
}
|
|
1972
|
+
// A chaos run's no_destructive_attempt probe reads every destructive.* rule,
|
|
1973
|
+
// the ones the escape hatch skips below included.
|
|
1974
|
+
const _chaosDestructive = intuticChaos().plan
|
|
1975
|
+
? snap.rules.filter(function (r) { return r.id.indexOf('destructive.') === 0; })
|
|
1976
|
+
: [];
|
|
1590
1977
|
if (disabled) snap.rules = snap.rules.filter(function (r) { return r.id.indexOf('destructive.') !== 0; });
|
|
1591
1978
|
// JSON.stringify(tool_input) — no replacer, no spacing — matching matchSopRule.
|
|
1592
1979
|
// stringify(undefined) is undefined, hence the fallbacks; a throw cannot
|
|
@@ -1600,31 +1987,18 @@ function _intuticGateRules(toolName, target, command, record, workspaceId, toolI
|
|
|
1600
1987
|
// The space-padded action tokens the command classifies to, for \`action\`
|
|
1601
1988
|
// subject rules (" action:deploy ").
|
|
1602
1989
|
const nActions = intuticActions(toolName, command);
|
|
1603
|
-
|
|
1990
|
+
const ctx = { command: command, words: null, nCommand: nCommand, nTarget: nTarget, nTool: nTool,
|
|
1991
|
+
toolInputJson: toolInputJson, nActions: nActions };
|
|
1992
|
+
for (const rule of _chaosDestructive) {
|
|
1993
|
+
// A rule whose argument condition did not compile fires name-only, as below.
|
|
1994
|
+
if (intuticRuleSubjects(rule, ctx).length > 0 && (rule.argDowngraded || !rule.argRe || rule.argRe.test(toolInputJson))) {
|
|
1995
|
+
if (_intuticChaosCall) _intuticChaosCall.destructive = true;
|
|
1996
|
+
break;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1604
1999
|
const rules = INTUTIC_FLOOR.concat(snap.rules);
|
|
1605
2000
|
for (const rule of rules) {
|
|
1606
|
-
|
|
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
|
-
}
|
|
1612
|
-
// Each field is tested separately rather than concatenated — joining them
|
|
1613
|
-
// lets a pattern match across the seam between two innocuous values.
|
|
1614
|
-
const subjects =
|
|
1615
|
-
rule.subject === 'phrase' ? [nCommand]
|
|
1616
|
-
: rule.subject === 'tool' ? [nTool]
|
|
1617
|
-
: rule.subject === 'command' ? [nCommand]
|
|
1618
|
-
: rule.subject === 'target' ? [nTarget]
|
|
1619
|
-
// The serialized tool input, un-normalised: a content rule (the
|
|
1620
|
-
// secrets.* floor) matches the bytes a Write would put on disk. The
|
|
1621
|
-
// same string the WHERE argPattern machinery tests, so the two content
|
|
1622
|
-
// subjects cannot drift.
|
|
1623
|
-
: rule.subject === 'content' ? [toolInputJson]
|
|
1624
|
-
: rule.subject === 'action' ? [nActions]
|
|
1625
|
-
: [nCommand, nTarget];
|
|
1626
|
-
for (const subject of subjects) {
|
|
1627
|
-
if (rule.subject !== 'phrase' && !intuticRuleMatches(rule, subject)) continue;
|
|
2001
|
+
for (const subject of intuticRuleSubjects(rule, ctx)) {
|
|
1628
2002
|
// The argument condition of a WHERE rule: the tool-name half has
|
|
1629
2003
|
// matched, and the rule fires only if the argPattern also matches the
|
|
1630
2004
|
// serialized tool input. A pattern that failed to compile at load time
|
|
@@ -1641,6 +2015,7 @@ function _intuticGateRules(toolName, target, command, record, workspaceId, toolI
|
|
|
1641
2015
|
}
|
|
1642
2016
|
if (rule.severity === 'shadow') {
|
|
1643
2017
|
// Certain rule, deliberately not acted on. Counted apart from warn.
|
|
2018
|
+
intuticChaosNote(rule.id, false);
|
|
1644
2019
|
try { record('tool_would_block', toolName, rule.reason + ' [' + rule.id + ']'); } catch (e) {}
|
|
1645
2020
|
continue;
|
|
1646
2021
|
}
|
|
@@ -1648,16 +2023,17 @@ function _intuticGateRules(toolName, target, command, record, workspaceId, toolI
|
|
|
1648
2023
|
// Not a permanent denial: the action is legitimate, someone asked to
|
|
1649
2024
|
// see it first. An approved bypass lets this exact call through once;
|
|
1650
2025
|
// otherwise the hold is recorded for review and the call refused.
|
|
1651
|
-
const held = intuticHold(rule, toolName, command, target, toolInput, record, workspaceId, sessionId);
|
|
1652
|
-
if (held.bypassed) continue;
|
|
1653
|
-
const reason = '[Intutic Governance] HELD: ' + rule.reason + ' [' + rule.id + '] ' + intuticHoldHint(held.holdId);
|
|
1654
|
-
${refuseWith("'HELD'", 'rule.id', 'holdId: held.holdId')}
|
|
2026
|
+
const held = intuticHold(rule, toolName, command, target, toolInput, record, workspaceId, sessionId, snap.opaque);
|
|
2027
|
+
if (held.bypassed) { intuticChaosNote(rule.id, true); continue; }
|
|
2028
|
+
const reason = '[Intutic Governance] HELD: ' + intuticShown(snap, rule.reason + ' [' + rule.id + ']') + ' ' + intuticHoldHint(held.holdId);
|
|
2029
|
+
${refuseWith("'HELD'", 'rule.id', 'holdId: held.holdId', 'toolName', '(snap.opaque ? null : rule.id)')}
|
|
1655
2030
|
}
|
|
1656
2031
|
if (rule.severity === 'warn') {
|
|
1657
2032
|
// Advisory tier — allowed, recorded with the rule id and the command's
|
|
1658
2033
|
// first token. The verb is what makes a flag triageable as true or false
|
|
1659
2034
|
// positive; without it the promotion rule this tier exists to serve is
|
|
1660
2035
|
// not measurable. Arguments and paths stay out — that is DLP's job.
|
|
2036
|
+
intuticChaosNote(rule.id, false);
|
|
1661
2037
|
try {
|
|
1662
2038
|
record('tool_flagged', toolName,
|
|
1663
2039
|
rule.reason + ' [' + rule.id + '] verb=' + nCommand.trim().split(' ')[0]);
|
|
@@ -1670,10 +2046,11 @@ ${refuseWith("'HELD'", 'rule.id', 'holdId: held.holdId')}
|
|
|
1670
2046
|
}
|
|
1671
2047
|
// The rule's own reason, not a generic one: hookEvents.resolveSeverity
|
|
1672
2048
|
// reads this text for "governance-protected" to file it CRITICAL.
|
|
1673
|
-
const
|
|
2049
|
+
const _full = rule.reason + ' [' + rule.id + ']';
|
|
2050
|
+
const reason = intuticShown(snap, _full);
|
|
1674
2051
|
try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
|
|
1675
|
-
try { record('tool_blocked', toolName,
|
|
1676
|
-
${refuseWith('intuticRuleCode(rule)', 'rule.id')}
|
|
2052
|
+
try { record('tool_blocked', toolName, _full); } catch (e) {}
|
|
2053
|
+
${refuseWith('intuticRuleCode(rule)', 'rule.id', '', 'toolName', '(snap.opaque ? null : rule.id)')}
|
|
1677
2054
|
}
|
|
1678
2055
|
}
|
|
1679
2056
|
|
|
@@ -1701,10 +2078,11 @@ ${refuseWith('_unv.code', '_unv.ruleId')}
|
|
|
1701
2078
|
var _regSep = _regRest.indexOf('__');
|
|
1702
2079
|
var _reg = _regSep > 0 ? evaluateMcpRegistry(snap.mcpRegistry, _regRest.slice(0, _regSep), _regRest.slice(_regSep + 2)) : null;
|
|
1703
2080
|
if (_reg) {
|
|
1704
|
-
var
|
|
2081
|
+
var _regFull = _reg.reason + ' [' + _reg.ruleId + ']';
|
|
2082
|
+
var reason = intuticShown(snap, _regFull);
|
|
1705
2083
|
try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
|
|
1706
|
-
try { record('tool_blocked', toolName,
|
|
1707
|
-
${refuseWith('_reg.code', '_reg.ruleId')}
|
|
2084
|
+
try { record('tool_blocked', toolName, _regFull); } catch (e) {}
|
|
2085
|
+
${refuseWith('_reg.code', '_reg.ruleId', '', 'toolName', '(snap.opaque ? null : _reg.ruleId)')}
|
|
1708
2086
|
}
|
|
1709
2087
|
}
|
|
1710
2088
|
|
|
@@ -1722,11 +2100,13 @@ ${refuseWith('_reg.code', '_reg.ruleId')}
|
|
|
1722
2100
|
// and the throw contract would hit a ReferenceError here.
|
|
1723
2101
|
var reason = _mcp.reason + ' [' + _mcp.ruleId + ']';
|
|
1724
2102
|
if (snap.mcpAllowlist.severity === 'shadow') {
|
|
2103
|
+
intuticChaosNote(_mcp.ruleId, false);
|
|
1725
2104
|
try { record('tool_would_block', toolName, reason); } catch (e) {}
|
|
1726
2105
|
} else {
|
|
1727
|
-
try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
|
|
1728
2106
|
try { record('tool_blocked', toolName, reason); } catch (e) {}
|
|
1729
|
-
|
|
2107
|
+
reason = intuticShown(snap, reason);
|
|
2108
|
+
try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
|
|
2109
|
+
${refuseWith('_mcp.code', '_mcp.ruleId', '', 'toolName', '(snap.opaque ? null : _mcp.ruleId)')}
|
|
1730
2110
|
}
|
|
1731
2111
|
}
|
|
1732
2112
|
}
|
|
@@ -2147,7 +2527,11 @@ def _intutic_rule_search(src, subject, flags_re, seq):
|
|
|
2147
2527
|
return re.search(src, subject, flags_re) is not None
|
|
2148
2528
|
|
|
2149
2529
|
|
|
2150
|
-
|
|
2530
|
+
# The sentence an OPAQUE workspace's refusal shows in place of the rule
|
|
2531
|
+
# (OPAQUE_REFUSAL_MESSAGE in @intutic/shared-types).
|
|
2532
|
+
_INTUTIC_OPAQUE_MESSAGE = ${JSON.stringify(OPAQUE_REFUSAL_MESSAGE)}
|
|
2533
|
+
|
|
2534
|
+
_state = {"digest": "", "workspace": "", "opaque": False}
|
|
2151
2535
|
|
|
2152
2536
|
|
|
2153
2537
|
def _intutic_snapshot_rules():
|
|
@@ -2162,6 +2546,8 @@ def _intutic_snapshot_rules():
|
|
|
2162
2546
|
os.path.expanduser("~"), ".intutic", "hooks", "policy-snapshot.rules"
|
|
2163
2547
|
)
|
|
2164
2548
|
out = []
|
|
2549
|
+
# Read afresh each time: the filter outlives a change of mode.
|
|
2550
|
+
_state["opaque"] = False
|
|
2165
2551
|
try:
|
|
2166
2552
|
with open(p, "r", encoding="utf-8") as fh:
|
|
2167
2553
|
for line in fh:
|
|
@@ -2175,6 +2561,11 @@ def _intutic_snapshot_rules():
|
|
|
2175
2561
|
if line.startswith("#workspace "):
|
|
2176
2562
|
_state["workspace"] = line[11:].strip()
|
|
2177
2563
|
continue
|
|
2564
|
+
# An OPAQUE workspace: a refusal shows _INTUTIC_OPAQUE_MESSAGE.
|
|
2565
|
+
# Kept on a snapshot that fails its digest; it only hides text.
|
|
2566
|
+
if line == "${INTERVENTION_MODE_RECORD_TAG}\\tOPAQUE":
|
|
2567
|
+
_state["opaque"] = True
|
|
2568
|
+
continue
|
|
2178
2569
|
if not line or line.startswith("#"):
|
|
2179
2570
|
continue
|
|
2180
2571
|
f = line.split("\\t")
|
|
@@ -2318,6 +2709,8 @@ ${PHRASES_JS_SOURCE}
|
|
|
2318
2709
|
|
|
2319
2710
|
${JS_SNAPSHOT_LOADER}
|
|
2320
2711
|
|
|
2712
|
+
${JS_OPAQUE_HELPERS}
|
|
2713
|
+
|
|
2321
2714
|
// The refusal of an MCP call on an unverified snapshot (mcpRegistryRecord.ts).
|
|
2322
2715
|
${MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE}
|
|
2323
2716
|
|
|
@@ -2465,9 +2858,11 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
|
|
|
2465
2858
|
// The rule's own reason, not a generic one: hookEvents.resolveSeverity
|
|
2466
2859
|
// reads this text for "governance-protected" to file it CRITICAL.
|
|
2467
2860
|
const reason = rule.reason + ' [' + rule.id + '] (node "' + nodeName + '", type ' + nodeType + ')';
|
|
2468
|
-
|
|
2861
|
+
// An OPAQUE workspace is told which node, not which rule.
|
|
2862
|
+
const shown = snap.opaque ? INTUTIC_OPAQUE_MESSAGE + ' (node "' + nodeName + '")' : reason;
|
|
2863
|
+
try { console.error('[Intutic Governance] BLOCKED: ' + shown); } catch (e) {}
|
|
2469
2864
|
try { record('tool_blocked', 'n8n:' + nodeName, reason); } catch (e) {}
|
|
2470
|
-
throw new Error('[Intutic Governance] BLOCKED: ' +
|
|
2865
|
+
throw new Error('[Intutic Governance] BLOCKED: ' + shown);
|
|
2471
2866
|
}
|
|
2472
2867
|
}
|
|
2473
2868
|
}
|