@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.
Files changed (150) hide show
  1. package/README.md +6 -14
  2. package/dist/agentReporter.d.ts +11 -1
  3. package/dist/agentReporter.d.ts.map +1 -1
  4. package/dist/agentReporter.js +9 -2
  5. package/dist/agentReporter.js.map +1 -1
  6. package/dist/configWriter.d.ts +2 -1
  7. package/dist/configWriter.d.ts.map +1 -1
  8. package/dist/configWriter.js +4 -4
  9. package/dist/configWriter.js.map +1 -1
  10. package/dist/disconnect/originals.d.ts.map +1 -1
  11. package/dist/disconnect/originals.js +5 -12
  12. package/dist/disconnect/originals.js.map +1 -1
  13. package/dist/disconnect/plan.d.ts.map +1 -1
  14. package/dist/disconnect/plan.js +3 -14
  15. package/dist/disconnect/plan.js.map +1 -1
  16. package/dist/harness/aiderConfigMerger.d.ts.map +1 -1
  17. package/dist/harness/aiderConfigMerger.js +4 -9
  18. package/dist/harness/aiderConfigMerger.js.map +1 -1
  19. package/dist/harness/antigravityCliHooks.d.ts.map +1 -1
  20. package/dist/harness/antigravityCliHooks.js +3 -7
  21. package/dist/harness/antigravityCliHooks.js.map +1 -1
  22. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  23. package/dist/harness/antigravityHooks.js +3 -7
  24. package/dist/harness/antigravityHooks.js.map +1 -1
  25. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  26. package/dist/harness/claudeCodeHooks.js +15 -4
  27. package/dist/harness/claudeCodeHooks.js.map +1 -1
  28. package/dist/harness/clineHooks.d.ts.map +1 -1
  29. package/dist/harness/clineHooks.js +4 -5
  30. package/dist/harness/clineHooks.js.map +1 -1
  31. package/dist/harness/codexConfigMerger.d.ts.map +1 -1
  32. package/dist/harness/codexConfigMerger.js +2 -3
  33. package/dist/harness/codexConfigMerger.js.map +1 -1
  34. package/dist/harness/codexHooks.d.ts.map +1 -1
  35. package/dist/harness/codexHooks.js +3 -7
  36. package/dist/harness/codexHooks.js.map +1 -1
  37. package/dist/harness/continueConfigMerger.d.ts.map +1 -1
  38. package/dist/harness/continueConfigMerger.js +2 -3
  39. package/dist/harness/continueConfigMerger.js.map +1 -1
  40. package/dist/harness/cursorHooks.d.ts.map +1 -1
  41. package/dist/harness/cursorHooks.js +12 -8
  42. package/dist/harness/cursorHooks.js.map +1 -1
  43. package/dist/harness/dshHooks.d.ts.map +1 -1
  44. package/dist/harness/dshHooks.js +7 -18
  45. package/dist/harness/dshHooks.js.map +1 -1
  46. package/dist/harness/gateBody.d.ts +39 -2
  47. package/dist/harness/gateBody.d.ts.map +1 -1
  48. package/dist/harness/gateBody.js +465 -70
  49. package/dist/harness/gateBody.js.map +1 -1
  50. package/dist/harness/gateSightings.d.ts.map +1 -1
  51. package/dist/harness/gateSightings.js +2 -3
  52. package/dist/harness/gateSightings.js.map +1 -1
  53. package/dist/harness/githubCopilotHooks.d.ts.map +1 -1
  54. package/dist/harness/githubCopilotHooks.js +2 -4
  55. package/dist/harness/githubCopilotHooks.js.map +1 -1
  56. package/dist/harness/gooseHooks.d.ts +13 -5
  57. package/dist/harness/gooseHooks.d.ts.map +1 -1
  58. package/dist/harness/gooseHooks.js +39 -25
  59. package/dist/harness/gooseHooks.js.map +1 -1
  60. package/dist/harness/grokHooks.d.ts.map +1 -1
  61. package/dist/harness/grokHooks.js +4 -10
  62. package/dist/harness/grokHooks.js.map +1 -1
  63. package/dist/harness/hermesHooks.d.ts.map +1 -1
  64. package/dist/harness/hermesHooks.js +5 -13
  65. package/dist/harness/hermesHooks.js.map +1 -1
  66. package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
  67. package/dist/harness/mcpAutoWrite.js +9 -10
  68. package/dist/harness/mcpAutoWrite.js.map +1 -1
  69. package/dist/harness/museHooks.d.ts.map +1 -1
  70. package/dist/harness/museHooks.js +4 -10
  71. package/dist/harness/museHooks.js.map +1 -1
  72. package/dist/harness/n8nHooks.d.ts +2 -1
  73. package/dist/harness/n8nHooks.d.ts.map +1 -1
  74. package/dist/harness/n8nHooks.js +52 -22
  75. package/dist/harness/n8nHooks.js.map +1 -1
  76. package/dist/harness/openWebuiHooks.d.ts.map +1 -1
  77. package/dist/harness/openWebuiHooks.js +7 -7
  78. package/dist/harness/openWebuiHooks.js.map +1 -1
  79. package/dist/harness/openclawHooks.d.ts.map +1 -1
  80. package/dist/harness/openclawHooks.js +5 -4
  81. package/dist/harness/openclawHooks.js.map +1 -1
  82. package/dist/harness/openhandsHooks.d.ts.map +1 -1
  83. package/dist/harness/openhandsHooks.js +4 -10
  84. package/dist/harness/openhandsHooks.js.map +1 -1
  85. package/dist/harness/piHooks.d.ts +3 -1
  86. package/dist/harness/piHooks.d.ts.map +1 -1
  87. package/dist/harness/piHooks.js +5 -4
  88. package/dist/harness/piHooks.js.map +1 -1
  89. package/dist/harness/protectedPaths.d.ts +3 -1
  90. package/dist/harness/protectedPaths.d.ts.map +1 -1
  91. package/dist/harness/protectedPaths.js +56 -2
  92. package/dist/harness/protectedPaths.js.map +1 -1
  93. package/dist/harness/rulesSection.d.ts.map +1 -1
  94. package/dist/harness/rulesSection.js +3 -7
  95. package/dist/harness/rulesSection.js.map +1 -1
  96. package/dist/harness/windsurfHooks.d.ts.map +1 -1
  97. package/dist/harness/windsurfHooks.js +3 -7
  98. package/dist/harness/windsurfHooks.js.map +1 -1
  99. package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -1
  100. package/dist/harness/windsurfJetBrainsProxy.js +1 -5
  101. package/dist/harness/windsurfJetBrainsProxy.js.map +1 -1
  102. package/dist/inventory.js +1 -1
  103. package/dist/inventory.js.map +1 -1
  104. package/dist/lib/approvedBypasses.d.ts.map +1 -1
  105. package/dist/lib/approvedBypasses.js +2 -4
  106. package/dist/lib/approvedBypasses.js.map +1 -1
  107. package/dist/lib/atomicWrite.d.ts +45 -0
  108. package/dist/lib/atomicWrite.d.ts.map +1 -0
  109. package/dist/lib/atomicWrite.js +104 -0
  110. package/dist/lib/atomicWrite.js.map +1 -0
  111. package/dist/lib/chaosPy.d.ts +11 -0
  112. package/dist/lib/chaosPy.d.ts.map +1 -0
  113. package/dist/lib/chaosPy.js +320 -0
  114. package/dist/lib/chaosPy.js.map +1 -0
  115. package/dist/lib/complianceProbes.d.ts +28 -7
  116. package/dist/lib/complianceProbes.d.ts.map +1 -1
  117. package/dist/lib/complianceProbes.js +47 -14
  118. package/dist/lib/complianceProbes.js.map +1 -1
  119. package/dist/lib/decisionsDigest.d.ts.map +1 -1
  120. package/dist/lib/decisionsDigest.js +2 -5
  121. package/dist/lib/decisionsDigest.js.map +1 -1
  122. package/dist/lib/egressPolicy.d.ts.map +1 -1
  123. package/dist/lib/egressPolicy.js +2 -4
  124. package/dist/lib/egressPolicy.js.map +1 -1
  125. package/dist/lib/policySnapshot.d.ts +2 -2
  126. package/dist/lib/policySnapshot.d.ts.map +1 -1
  127. package/dist/lib/policySnapshot.js +43 -20
  128. package/dist/lib/policySnapshot.js.map +1 -1
  129. package/dist/lib/regexLinearity.d.ts.map +1 -1
  130. package/dist/lib/regexLinearity.js +2 -0
  131. package/dist/lib/regexLinearity.js.map +1 -1
  132. package/dist/lib/runtimeEnv.d.ts.map +1 -1
  133. package/dist/lib/runtimeEnv.js +3 -4
  134. package/dist/lib/runtimeEnv.js.map +1 -1
  135. package/dist/skillWriter.d.ts +2 -2
  136. package/dist/skillWriter.d.ts.map +1 -1
  137. package/dist/skillWriter.js +10 -6
  138. package/dist/skillWriter.js.map +1 -1
  139. package/dist/trajectoryMonitor.d.ts +22 -6
  140. package/dist/trajectoryMonitor.d.ts.map +1 -1
  141. package/dist/trajectoryMonitor.js +40 -9
  142. package/dist/trajectoryMonitor.js.map +1 -1
  143. package/dist/watcher/settingsGuard.d.ts.map +1 -1
  144. package/dist/watcher/settingsGuard.js +23 -2
  145. package/dist/watcher/settingsGuard.js.map +1 -1
  146. package/dist/wsClient.d.ts +0 -1
  147. package/dist/wsClient.d.ts.map +1 -1
  148. package/dist/wsClient.js +0 -6
  149. package/dist/wsClient.js.map +1 -1
  150. package/package.json +22 -15
@@ -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 = 18;
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
- try { console.error('[Intutic Guardrail] HELD: ' + reason + ' ' + intuticHoldHint(holdId)); } catch (e) {}
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
- echo "[Intutic Guardrail] HELD: \${rreason} [\${rid}] \${hint}" >&2
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
- intutic_apply() {
988
- local rec="$1"
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
- echo "[Intutic Governance] BLOCKED: \${reason}" >&2
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
- echo "[Intutic Governance] BLOCKED: \${_intutic_reg_reason} [\${_intutic_reg_rid}]" >&2
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
- echo "[Intutic Governance] BLOCKED: $_intutic_mcp_reason" >&2
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
- const fields = `code: ${code}, ruleId: ${ruleId}${extra ? `, ${extra}` : ''}`;
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 (` process.stdout.write(JSON.stringify({ decision: 'deny', reason: reason + ' (refusal code ' + ${code} + ')' }) + '\\n');\n` +
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
- return require('vm').runInThisContext('__intuticGateRules()', { timeout: _deadlineMs });
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
- var words = null;
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
- // 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
- }
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 reason = rule.reason + ' [' + rule.id + ']';
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, reason); } catch (e) {}
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 reason = _reg.reason + ' [' + _reg.ruleId + ']';
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, reason); } catch (e) {}
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
- ${refuseWith('_mcp.code', '_mcp.ruleId')}
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
- _state = {"digest": "", "workspace": ""}
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
- try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
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: ' + reason);
2865
+ throw new Error('[Intutic Governance] BLOCKED: ' + shown);
2471
2866
  }
2472
2867
  }
2473
2868
  }