@intutic/sync-daemon 2.9.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/disconnect/harnesses.d.ts.map +1 -1
  2. package/dist/disconnect/harnesses.js +4 -3
  3. package/dist/disconnect/harnesses.js.map +1 -1
  4. package/dist/disconnect/plan.d.ts +6 -1
  5. package/dist/disconnect/plan.d.ts.map +1 -1
  6. package/dist/disconnect/plan.js +4 -2
  7. package/dist/disconnect/plan.js.map +1 -1
  8. package/dist/harness/antigravityCliHooks.js +3 -3
  9. package/dist/harness/antigravityCliHooks.js.map +1 -1
  10. package/dist/harness/antigravityHooks.d.ts +8 -0
  11. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  12. package/dist/harness/antigravityHooks.js +27 -5
  13. package/dist/harness/antigravityHooks.js.map +1 -1
  14. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  15. package/dist/harness/claudeCodeHooks.js +58 -16
  16. package/dist/harness/claudeCodeHooks.js.map +1 -1
  17. package/dist/harness/clineHooks.d.ts.map +1 -1
  18. package/dist/harness/clineHooks.js +4 -2
  19. package/dist/harness/clineHooks.js.map +1 -1
  20. package/dist/harness/codexHooks.d.ts.map +1 -1
  21. package/dist/harness/codexHooks.js +4 -2
  22. package/dist/harness/codexHooks.js.map +1 -1
  23. package/dist/harness/cursorHooks.d.ts.map +1 -1
  24. package/dist/harness/cursorHooks.js +8 -3
  25. package/dist/harness/cursorHooks.js.map +1 -1
  26. package/dist/harness/gateBody.d.ts +177 -4
  27. package/dist/harness/gateBody.d.ts.map +1 -1
  28. package/dist/harness/gateBody.js +1658 -26
  29. package/dist/harness/gateBody.js.map +1 -1
  30. package/dist/harness/githubCopilotHooks.js +3 -3
  31. package/dist/harness/githubCopilotHooks.js.map +1 -1
  32. package/dist/harness/gooseHooks.d.ts.map +1 -1
  33. package/dist/harness/gooseHooks.js +8 -1
  34. package/dist/harness/gooseHooks.js.map +1 -1
  35. package/dist/harness/grokHooks.js +2 -2
  36. package/dist/harness/hermesHooks.js +1 -1
  37. package/dist/harness/hermesHooks.js.map +1 -1
  38. package/dist/harness/museHooks.d.ts.map +1 -1
  39. package/dist/harness/museHooks.js +4 -2
  40. package/dist/harness/museHooks.js.map +1 -1
  41. package/dist/harness/n8nHooks.js +1 -1
  42. package/dist/harness/openCodeHooks.d.ts.map +1 -1
  43. package/dist/harness/openCodeHooks.js +32 -8
  44. package/dist/harness/openCodeHooks.js.map +1 -1
  45. package/dist/harness/openclawHooks.d.ts.map +1 -1
  46. package/dist/harness/openclawHooks.js +19 -8
  47. package/dist/harness/openclawHooks.js.map +1 -1
  48. package/dist/harness/piHooks.js +7 -7
  49. package/dist/harness/windsurfHooks.js +2 -2
  50. package/dist/index.d.ts +9 -3
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +8 -2
  53. package/dist/index.js.map +1 -1
  54. package/dist/lib/approvedBypasses.d.ts +17 -4
  55. package/dist/lib/approvedBypasses.d.ts.map +1 -1
  56. package/dist/lib/approvedBypasses.js +17 -4
  57. package/dist/lib/approvedBypasses.js.map +1 -1
  58. package/dist/lib/approvedPlans.d.ts +73 -0
  59. package/dist/lib/approvedPlans.d.ts.map +1 -0
  60. package/dist/lib/approvedPlans.js +124 -0
  61. package/dist/lib/approvedPlans.js.map +1 -0
  62. package/dist/lib/decideLogs.d.ts +46 -0
  63. package/dist/lib/decideLogs.d.ts.map +1 -0
  64. package/dist/lib/decideLogs.js +160 -0
  65. package/dist/lib/decideLogs.js.map +1 -0
  66. package/dist/lib/policySnapshot.d.ts +143 -3
  67. package/dist/lib/policySnapshot.d.ts.map +1 -1
  68. package/dist/lib/policySnapshot.js +480 -14
  69. package/dist/lib/policySnapshot.js.map +1 -1
  70. package/dist/lib/refusalDetailPy.d.ts +11 -0
  71. package/dist/lib/refusalDetailPy.d.ts.map +1 -0
  72. package/dist/lib/refusalDetailPy.js +109 -0
  73. package/dist/lib/refusalDetailPy.js.map +1 -0
  74. package/dist/lib/ruleRecordPy.d.ts +11 -0
  75. package/dist/lib/ruleRecordPy.d.ts.map +1 -0
  76. package/dist/lib/ruleRecordPy.js +277 -0
  77. package/dist/lib/ruleRecordPy.js.map +1 -0
  78. package/dist/lib/scopeMatchPy.d.ts +11 -0
  79. package/dist/lib/scopeMatchPy.d.ts.map +1 -0
  80. package/dist/lib/scopeMatchPy.js +278 -0
  81. package/dist/lib/scopeMatchPy.js.map +1 -0
  82. package/dist/lib/sopFiles.d.ts.map +1 -1
  83. package/dist/lib/sopFiles.js +5 -3
  84. package/dist/lib/sopFiles.js.map +1 -1
  85. package/dist/lib/testOutcomePy.d.ts +11 -0
  86. package/dist/lib/testOutcomePy.d.ts.map +1 -0
  87. package/dist/lib/testOutcomePy.js +371 -0
  88. package/dist/lib/testOutcomePy.js.map +1 -0
  89. package/dist/skillWriter.d.ts +1 -1
  90. package/dist/skillWriter.d.ts.map +1 -1
  91. package/dist/skillWriter.js +7 -0
  92. package/dist/skillWriter.js.map +1 -1
  93. package/dist/syncCycle.d.ts +15 -2
  94. package/dist/syncCycle.d.ts.map +1 -1
  95. package/dist/syncCycle.js +15 -3
  96. package/dist/syncCycle.js.map +1 -1
  97. package/package.json +5 -5
@@ -50,11 +50,16 @@
50
50
  */
51
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, 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';
53
+ import { ARGUMENTS_SIZE_LIMIT, CHAOS_GATE_JS_SOURCE, COMMAND_SIZE_LIMIT, DECIDE_BUDGET_MARGIN_MS, DECIDE_FALLBACK_PREFIX, DECIDE_GATE_JS_SOURCE, DECIDE_MIN_CALL_MS, DECIDE_RECORD_TAG, decideBudgetMs, decideLogRedactionSources, GATE_DEADLINE_MARGIN_MS, HOOK_TIMEOUT_SECONDS, SNAPSHOT_FORMAT, SNAPSHOT_FORMAT_RECORD_TAG, 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
54
  import { CHAOS_PY_SOURCE } from '../lib/chaosPy.js';
55
55
  import { MCP_REGISTRY_PY_SOURCE } from '../lib/mcpRegistryPy.js';
56
56
  import { PHRASES_PY_SOURCE } from '../lib/phrasesPy.js';
57
57
  import { SEQUENCE_PY_SOURCE } from '../lib/sequencePy.js';
58
+ import { REFUSAL_DETAIL_JS_SOURCE, RULE_RECORD_JS_SOURCE, RULE_RECORD_TAG, RULE_SNAPSHOT_FORMAT, SCOPE_MATCH_JS_SOURCE, TEST_OUTCOME_JS_SOURCE, LANE_PHRASES, } from '@intutic/shared-types';
59
+ import { TEST_OUTCOME_PY_SOURCE } from '../lib/testOutcomePy.js';
60
+ import { SCOPE_MATCH_PY_SOURCE } from '../lib/scopeMatchPy.js';
61
+ import { RULE_RECORD_PY_SOURCE } from '../lib/ruleRecordPy.js';
62
+ import { REFUSAL_DETAIL_PY_SOURCE } from '../lib/refusalDetailPy.js';
58
63
  /**
59
64
  * Bumped when the emitted evaluator changes shape.
60
65
  *
@@ -251,8 +256,132 @@ import { SEQUENCE_PY_SOURCE } from '../lib/sequencePy.js';
251
256
  * audit line keeps the full reason. A TRANSPARENT or SILENT_LOG snapshot has no
252
257
  * such line and is unchanged. A v20 gate skips the line, as it skips any
253
258
  * two-column one, and names the rule.
259
+ *
260
+ * v22: decide rules in the snapshot (snapshot format 2). A snapshot carrying
261
+ * one starts with an `@format` data line (tag and `2`) and holds an
262
+ * `@decide` record per tool-call rule (five columns: tag, fallback line id or
263
+ * `-`, stamped rollout, comma-joined tools, base64 of the rule's record;
264
+ * `decideRecord.ts` in `@intutic/shared-types`), inside the digest. Each rule
265
+ * stamped `enforce` also has a fallback line, an ordinary `block` rule on its
266
+ * tools with an id starting `decide-fallback:`, placed right after the
267
+ * SSO-group rules. The JS gates decode each record, rule and hash checked
268
+ * (`decide.ts`, emitted as source), and carry the ones that decode as
269
+ * `decideRules`; the bash gates keep the records whole for the CLI. Every
270
+ * family evaluates decide rules ({@link GATE_EVALUATES_DECIDE}), so it skips
271
+ * a fallback line, but only when a decoded record names it (the bash gates:
272
+ * an `@decide` record of five columns); a record that does not decode leaves
273
+ * its fallback line in force. The decide tier runs after every deterministic
274
+ * tier allowed the call, and only then (a deterministic refusal asks the
275
+ * engine nothing): the JS gates ask the self-hosted engine in process
276
+ * (`intuticGateWithDecide`, an async wrapper around the synchronous tiers),
277
+ * the bash gates and the n8n gate through `intutic decide check --stdin`,
278
+ * each within its harness's budget (`decideBudgetMs` in gateLimits.ts). A
279
+ * rule refuses with `DECIDE_REFUSED`; one that cannot decide asks a person
280
+ * through the harness's {@link AskContract}, or refuses with
281
+ * `DECIDE_ESCALATED` where there is none; an engine (or CLI) that cannot
282
+ * answer refuses an enforcing rule with `GOVERNANCE_UNAVAILABLE` and lets a
283
+ * shadow rule's call on. Each evaluated call is written to the decide log
284
+ * (`~/.intutic/logs/decide-<date>.jsonl`, `decideLog.ts`): the JS gates write
285
+ * it in process, with the record, redaction and drift stats the CLI writes
286
+ * for the bash and n8n gates; a write that fails changes nothing. The
287
+ * synchronous `intuticGate`, which cannot ask
288
+ * the engine, refuses any call an enforcing decide rule names. An unverified
289
+ * snapshot drops the records with the rest of the dynamic tier. A gate
290
+ * reading a format above 2 reports `snapshot_format_newer` and enforces what
291
+ * it reads. A v21 gate skips the records, as it skips any line under six
292
+ * columns, and refuses each fallback line's tools.
293
+ *
294
+ * v23: rule records, post-tool outcomes and the tier order. A
295
+ * snapshot carrying any `@rule` record (five columns: tag, fallback line id
296
+ * or `-`, kind, stamped rollout, base64 of the canonical payload;
297
+ * `gateRuleRecord.ts` in `@intutic/shared-types`) starts with `@format 3`. Three kinds: `requires_outcome`
298
+ * (`requires_before: action:run_tests[ok] -> X`), `scope_paths` (under
299
+ * `scope_paths_mode: enforce`) and `plan_approval` (`required | strict`). Each
300
+ * enforcing record has a `<kind>-fallback:` line, an ordinary `block` rule
301
+ * placed after the SSO-group rules and the decide fallbacks, which a gate
302
+ * skips only when a record that decodes names it and its family evaluates
303
+ * that kind ({@link GATE_EVALUATES_RULE_RECORDS}); the JS gates decode
304
+ * in process, the bash gates in the extractor's Python. A post-tool
305
+ * invocation (`--event post`, or the writer's own post handler) records a
306
+ * test run's outcome in `~/.intutic/hooks/state/outcomes/<sha256(harness NUL
307
+ * session)[:32]>.json` and always allows: the fail-closed prelude exits 0
308
+ * in post mode. Only the harnesses whose post-tool payload was captured
309
+ * report outcomes ({@link POST_TOOL_OBSERVABLE}); on the others a qualified
310
+ * rule refuses with the "not observable" reason. An `[ok]` outcome goes
311
+ * stale on any later file change in the session. Refusals carry
312
+ * `PREREQUISITE_UNMET`, `OUT_OF_SCOPE`, `PLAN_NOT_APPROVED` and `OFF_PLAN`.
313
+ * The tiers run in one order in every family: deterministic refusals (the
314
+ * floor, the snapshot's block rules, `requires_outcome`, the MCP registry
315
+ * and allowlist), then holds, then trajectory (`scope_paths`,
316
+ * `plan_approval`), then decide; a decide allow never releases a hold. The
317
+ * classifier knows `action:run_tests`, `action:http_post`, `action:migrate`,
318
+ * `action:prod_write`, `action:destroy`, `action:payment` and the
319
+ * `action:force_push` compound. A v22 gate skips the records, refuses each
320
+ * fallback line's calls, and holds lane and revert holds through their
321
+ * `phrase` rules.
322
+ */
323
+ export const GATE_VERSION = 23;
324
+ /**
325
+ * Whether each family of rendered gate evaluates each `@rule` kind. A family
326
+ * that does not keeps the kind's fallback lines in force, so it refuses what
327
+ * the rule covers. The n8n workflow gate has no session, no tool results and
328
+ * no file targets, so it keeps every fallback line.
329
+ */
330
+ export const GATE_EVALUATES_RULE_RECORDS = {
331
+ js: { requires_outcome: true, scope_paths: true, plan_approval: true },
332
+ bash: { requires_outcome: true, scope_paths: true, plan_approval: true },
333
+ n8n: { requires_outcome: false, scope_paths: false, plan_approval: false },
334
+ };
335
+ /**
336
+ * Whether a harness reports tool results a gate can read a test outcome
337
+ * from: only where the post-tool payload was captured from
338
+ * the harness itself and carries an exit code or a structured error flag.
339
+ * The fixtures are `__tests__/fixtures/post-tool/<harness>.json`.
340
+ *
341
+ * - `claude-code`: `PostToolUse` (Bash `tool_response.stdout`/`stderr`, no
342
+ * exit code) and `PostToolUseFailure` (`error`, first line `Exit code N`);
343
+ * captured from Claude Code 2.1.233.
344
+ * - `gemini-cli` (the bash gate the antigravity writer installs): `AfterTool`
345
+ * (`tool_response.llmContent` carrying `Exit Code: N` when non-zero);
346
+ * captured from Gemini CLI 0.36.0.
347
+ *
348
+ * Every other harness is `false`: no payload was captured, so a qualified
349
+ * `requires_before` there refuses with the "not observable" reason.
350
+ */
351
+ export const POST_TOOL_OBSERVABLE = {
352
+ 'claude-code': true,
353
+ 'gemini-cli': true,
354
+ cursor: false,
355
+ windsurf: false,
356
+ codex: false,
357
+ 'muse-code': false,
358
+ 'github-copilot': false,
359
+ cline: false,
360
+ grok: false,
361
+ antigravity: false,
362
+ goose: false,
363
+ openhands: false,
364
+ hermes: false,
365
+ openclaw: false,
366
+ pi: false,
367
+ opencode: false,
368
+ };
369
+ /**
370
+ * The approved-plans cache the daemon writes (§4.3, `approvedPlans.ts`), in
371
+ * the same directory as the policy snapshot. Its `#expires` header is three
372
+ * sync intervals after it was written; a gate reads a cache that is missing,
373
+ * past it, or fails its digest or workspace check as "no plan approved".
374
+ */
375
+ export const APPROVED_PLANS_FILE = 'approved-plans.jsonl';
376
+ /**
377
+ * Whether each family of rendered gate evaluates the decide rules a snapshot
378
+ * carries: the JS gates, the bash gates and the n8n workflow gate. A family
379
+ * that does not keeps every decide rule's fallback line in force, so it
380
+ * refuses what the rule covers exactly as a gate that reads no `@decide`
381
+ * records does; it never skips a fallback line it cannot stand in for. Set a
382
+ * family only where its decide tier runs.
254
383
  */
255
- export const GATE_VERSION = 21;
384
+ export const GATE_EVALUATES_DECIDE = { js: true, bash: true, n8n: true };
256
385
  /**
257
386
  * The refusal codes a hook gate's JSON decision carries, as `code` beside the
258
387
  * deciding `ruleId` (null when no rule decided), for the harnesses that read
@@ -278,7 +407,17 @@ export const GATE_VERSION = 21;
278
407
  * (`gateDeadlineMs` in `@intutic/shared-types`);
279
408
  * - `UNREADABLE_CALL`: the payload held no tool call the gate could read;
280
409
  * - `GATE_CRASHED`: the gate failed while deciding, or a chaos run's
281
- * `INTUTIC_CHAOS_PLAN` is set but unreadable.
410
+ * `INTUTIC_CHAOS_PLAN` is set but unreadable;
411
+ * - `DECIDE_REFUSED`, `DECIDE_ESCALATED`, `GOVERNANCE_UNAVAILABLE`: a decide
412
+ * rule refused, could not be decided where the harness cannot ask, or its
413
+ * engine was unavailable while it enforces.
414
+ * - `PREREQUISITE_UNMET`: a `requires_before: action:run_tests[ok] -> X` rule
415
+ * is not met (no run, a failed or unreadable one, a stale one, no session,
416
+ * or a harness whose results cannot be observed);
417
+ * - `OUT_OF_SCOPE`: a file change outside `scope_paths` under
418
+ * `scope_paths_mode: enforce`;
419
+ * - `PLAN_NOT_APPROVED`, `OFF_PLAN`: `plan_approval:` and no approved plan
420
+ * for the session, or (`strict`) a tool that is not one of its steps.
282
421
  *
283
422
  * A fault a chaos run injects into an allowed call is not a refusal by
284
423
  * governance: its JSON decision carries `code: null` and `ruleId: null`, or
@@ -289,6 +428,10 @@ export const HOOK_REFUSAL_CODES = [
289
428
  'SNAPSHOT',
290
429
  'SSO_GROUP',
291
430
  'HELD',
431
+ 'PREREQUISITE_UNMET',
432
+ 'OUT_OF_SCOPE',
433
+ 'OFF_PLAN',
434
+ 'PLAN_NOT_APPROVED',
292
435
  'SERVER_BLOCKED',
293
436
  'SERVER_HELD',
294
437
  'SERVER_NOT_APPROVED',
@@ -299,6 +442,9 @@ export const HOOK_REFUSAL_CODES = [
299
442
  'GATE_DEADLINE',
300
443
  'UNREADABLE_CALL',
301
444
  'GATE_CRASHED',
445
+ 'DECIDE_REFUSED',
446
+ 'DECIDE_ESCALATED',
447
+ 'GOVERNANCE_UNAVAILABLE',
302
448
  ];
303
449
  /**
304
450
  * The coarse command → action-token classification the hold tier keys on:
@@ -316,7 +462,29 @@ export const ACTION_NEEDLES = [
316
462
  ['action:publish', ['npm publish', 'pnpm publish', 'yarn publish', 'cargo publish', 'twine upload', 'poetry publish', 'gem push', 'docker manifest push']],
317
463
  ['action:release', ['gh release create', 'git tag', 'npm version', 'cargo release', 'goreleaser release', 'semantic-release']],
318
464
  ['action:db_write', ['insert into', 'update ', 'delete from', 'drop table', 'drop database', 'drop schema', 'truncate ', 'alter table']],
465
+ ['action:run_tests', ['npm test', 'npm run test', 'pnpm test', 'pnpm run test', 'yarn test', 'cargo test', 'go test', 'pytest', 'python -m pytest', 'vitest', 'jest', 'mocha', 'rspec', 'phpunit', 'make test', 'gradle test', 'mvn test', 'dotnet test', 'bazel test', 'tox']],
466
+ ['action:http_post', ['curl -x post', 'curl --request post', 'curl -d ', 'curl --data', 'wget --post', 'http post']],
467
+ ['action:migrate', ['prisma migrate deploy', 'prisma db push', 'alembic upgrade', 'alembic downgrade', 'rails db:migrate', 'rake db:migrate', 'manage.py migrate', 'flyway migrate', 'liquibase update', 'knex migrate:latest', 'sequelize db:migrate']],
468
+ ['action:prod_write', ['--prod', 'rails_env=production', 'node_env=production', '--context prod', '--namespace production', '-n production', '--environment production', '--stage prod']],
469
+ ['action:destroy', ['terraform destroy', 'kubectl delete', 'helm uninstall', 'helm delete', 'drop table', 'drop database', 'drop schema', 'truncate ', 'aws s3 rb', 'aws s3 rm']],
470
+ ['action:payment', ['api.stripe.com/v1/charges', 'api.stripe.com/v1/payment_intents', 'api.stripe.com/v1/transfers', 'api.stripe.com/v1/payouts', 'api.stripe.com/v1/refunds', 'stripe charges create', 'stripe payment_intents create', 'stripe transfers create', 'stripe payouts create', 'api-m.paypal.com/v2/payments', 'api.paypal.com/v1/payments']],
319
471
  ];
472
+ /**
473
+ * `action:force_push` is a compound, not a needle list (§5.1): the `git push`
474
+ * phrase AND a whole-word ` --force ` or ` -f ` in the space-padded,
475
+ * whitespace-collapsed, lower-cased command. ` --force-with-lease` does not
476
+ * match.
477
+ */
478
+ export const FORCE_PUSH_TOKEN = 'action:force_push';
479
+ export const FORCE_PUSH_FLAGS = [' --force ', ' -f '];
480
+ /**
481
+ * `action:destroy` also covers a recursive, forced `rm` whose target is
482
+ * outside the working tree (absolute, under `~`, or containing `..`; Q3).
483
+ * No phrase can say "outside the tree", so both classifiers run the
484
+ * predicate `removesOutsideTree` of `@intutic/gate` (`actions.ts`) after the
485
+ * needles; `rm -rf build/` stays unclassified.
486
+ */
487
+ export const DESTROY_TOKEN = 'action:destroy';
320
488
  /**
321
489
  * The (lower-cased) tool names whose `command` the classifier reads: every
322
490
  * harness's shell tool. The same list as the proxy's `SHELL_TOOLS` in
@@ -343,10 +511,283 @@ export const REVIEW_REQUESTS_LOG = `.intutic/events/${REVIEW_REQUESTS_BASENAME}`
343
511
  /** Bumped when a hold record's shape changes; the control plane drops any
344
512
  * other version at ingest. */
345
513
  export const REVIEW_REQUEST_VERSION = 1;
514
+ /**
515
+ * The rule-record tiers of a bash gate, in Python over the shared
516
+ * ports ({@link TEST_OUTCOME_PY_SOURCE}, {@link SCOPE_MATCH_PY_SOURCE},
517
+ * {@link RULE_RECORD_PY_SOURCE}, {@link REFUSAL_DETAIL_PY_SOURCE}): the
518
+ * fallback lines a decoded record names, the `requires_outcome` check with
519
+ * its state bookkeeping, the trajectory check (`scope_paths`,
520
+ * `plan_approval`), and the post-tool recorder. A decoded record is flat:
521
+ * its payload fields beside `kind`, `fallbackId` and `rollout`. Each check
522
+ * returns "" to continue, or verdict, code, rule id, reason and the
523
+ * retry-scope line (§6.2; empty when the refusal names no paths or tools)
524
+ * joined by tabs, the verdict `block` or `shadow`.
525
+ */
526
+ const GATE_RULE_TIERS_PY = String.raw `
527
+
528
+ def _intutic_records(records_text, kind):
529
+ out = []
530
+ for line in str(records_text or "").split("\n"):
531
+ r = decode_gate_rule_record(line) if line else None
532
+ if r is not None and r["kind"] == kind:
533
+ out.append(r)
534
+ return out
535
+
536
+
537
+ def intutic_rule_skip(records_text, kinds_csv):
538
+ # The fallback line ids a decoded, enforcing record names, one per line,
539
+ # for the kinds this gate evaluates. Any other fallback line stays.
540
+ kinds = [k for k in str(kinds_csv).split(",") if k]
541
+ out = []
542
+ for line in str(records_text or "").split("\n"):
543
+ r = decode_gate_rule_record(line) if line else None
544
+ if r is not None and r["fallbackId"] and r["kind"] in kinds:
545
+ out.append(r["fallbackId"])
546
+ return "\n".join(out)
547
+
548
+
549
+ def _intutic_now_ms():
550
+ import time
551
+ return int(time.time() * 1000)
552
+
553
+
554
+ def _intutic_state_path(harness, session):
555
+ import hashlib, os
556
+ base = os.environ.get("INTUTIC_OUTCOME_STATE_DIR") or os.path.join(
557
+ os.path.expanduser("~"), ".intutic", "hooks", "state", "outcomes")
558
+ return os.path.join(base, outcome_state_file_name(str(harness), str(session),
559
+ lambda t: hashlib.sha256(t.encode("utf-8")).hexdigest()))
560
+
561
+
562
+ def _intutic_state_read(path):
563
+ # (exists, state): a missing file is an empty state; one that does not
564
+ # read is None, which leaves every qualified rule unmet.
565
+ import os
566
+ if not os.path.exists(path):
567
+ return False, empty_outcome_state()
568
+ try:
569
+ with open(path, "r", encoding="utf-8") as fh:
570
+ return True, parse_outcome_state(fh.read())
571
+ except Exception:
572
+ return True, None
573
+
574
+
575
+ def _intutic_state_write(path, state):
576
+ import json, os
577
+ try:
578
+ os.makedirs(os.path.dirname(path), exist_ok=True)
579
+ tmp = path + ".tmp." + str(os.getpid())
580
+ with open(tmp, "w", encoding="utf-8") as fh:
581
+ json.dump(state, fh, separators=(",", ":"))
582
+ os.replace(tmp, path)
583
+ except Exception:
584
+ pass
585
+
586
+
587
+ def _intutic_verdict(r, code, rid, reason, scope):
588
+ return "\t".join(["block" if r["rollout"] == "enforce" else "shadow", code, rid, " ".join(reason.split()),
589
+ retry_scope_line("TRANSPARENT", scope) or ""])
590
+
591
+
592
+ def intutic_outcome_check(harness, observable, tool, actions, session, records_text, tool_input_json):
593
+ import json
594
+ try:
595
+ ti = json.loads(tool_input_json or "{}")
596
+ except ValueError:
597
+ ti = {}
598
+ recs = _intutic_records(records_text, "requires_outcome")
599
+ path = _intutic_state_path(harness, session) if session else None
600
+ exists, state = _intutic_state_read(path) if path else (False, empty_outcome_state())
601
+ shadow = ""
602
+ for r in recs:
603
+ then = r["then"]
604
+ if (" " + then + " ") not in str(actions) and then.lower() != str(tool).lower():
605
+ continue
606
+ why = None
607
+ if observable != "1":
608
+ case = "not_observable"
609
+ elif not session:
610
+ case = "no_session"
611
+ else:
612
+ case, why = prerequisite_case(state, r["first"], r["adjacent"], _intutic_now_ms())
613
+ if case == "met":
614
+ continue
615
+ rid = "requires_before." + r["first"] + "[ok]" + ("~>" if r["adjacent"] else "->") + then
616
+ line = _intutic_verdict(r, "PREREQUISITE_UNMET", rid, prerequisite_unmet_reason(r["first"], then, case, why, harness),
617
+ {"tools": [then], "step": r["first"]})
618
+ if r["rollout"] == "enforce":
619
+ return line
620
+ shadow = shadow or line
621
+ # Bookkeeping, on a call this tier lets through: no outcome is adjacent
622
+ # to what comes next any more, and a call that changes files (a mutation
623
+ # tool, or a shell write such as sed -i or git checkout) makes a pass
624
+ # before it stale (Q1).
625
+ if path and exists and state is not None:
626
+ _intutic_state_write(path, note_completed_call(state, is_file_write_call(tool, ti)))
627
+ return shadow
628
+
629
+
630
+ def intutic_post_record(harness, payload_text):
631
+ # A post-tool payload: a shell call that ran tests records its outcome;
632
+ # a file change marks an earlier [ok] stale. Never refuses.
633
+ import json
634
+ try:
635
+ d = json.loads(payload_text or "{}")
636
+ except ValueError:
637
+ return ""
638
+ if not isinstance(d, dict):
639
+ return ""
640
+ session = str(d.get("session_id") or d.get("sessionId") or "")
641
+ if not session:
642
+ return ""
643
+ tool = str(d.get("tool_name") or d.get("toolName") or "")
644
+ ti = d.get("tool_input") if isinstance(d.get("tool_input"), dict) else {}
645
+ command = ""
646
+ for k in ("command", "cmd", "script", "shell_command"):
647
+ if isinstance(ti.get(k), str) and ti.get(k):
648
+ command = ti[k]
649
+ break
650
+ actions = intutic_actions(tool, command)
651
+ path = _intutic_state_path(harness, session)
652
+ exists, state = _intutic_state_read(path)
653
+ if " action:run_tests " in actions:
654
+ resp = d.get("tool_response")
655
+ output = ""
656
+ if isinstance(resp, dict):
657
+ v = resp.get("llmContent")
658
+ output = v if isinstance(v, str) else json.dumps(v)
659
+ # Gemini CLI prefixes the command's output with "Output: ".
660
+ if output.startswith("Output: "):
661
+ output = output[len("Output: "):]
662
+ elif isinstance(resp, str):
663
+ output = resp
664
+ decision = decide_test_outcome({"exitCode": extract_exit_code(output), "output": output})
665
+ _intutic_state_write(path, record_test_outcome(state if state is not None else empty_outcome_state(),
666
+ "action:run_tests", decision, _intutic_now_ms()))
667
+ elif exists and state is not None and is_file_write_call(tool, ti):
668
+ _intutic_state_write(path, note_completed_call(state, True))
669
+ return ""
670
+
671
+
672
+ def _intutic_real(path):
673
+ import os
674
+ return os.path.realpath(path)
675
+
676
+
677
+ def _intutic_repo_root(cwd):
678
+ import os
679
+ d = _intutic_real(cwd or os.getcwd())
680
+ while True:
681
+ if os.path.exists(os.path.join(d, ".git")):
682
+ return d
683
+ parent = os.path.dirname(d)
684
+ if parent == d:
685
+ return _intutic_real(cwd or os.getcwd())
686
+ d = parent
687
+
688
+
689
+ def _intutic_approved_plan(session, workspace_id):
690
+ import hashlib, json, os, datetime
691
+ path = os.environ.get("INTUTIC_APPROVED_PLANS") or os.path.join(
692
+ os.path.expanduser("~"), ".intutic", "hooks", "${APPROVED_PLANS_FILE}")
693
+ try:
694
+ with open(path, "r", encoding="utf-8") as fh:
695
+ lines = fh.read().split("\n")
696
+ except Exception:
697
+ return None
698
+ digest = ws = expires = ""
699
+ body = []
700
+ for line in lines:
701
+ if line.startswith("#digest "):
702
+ digest = line[8:].strip()
703
+ elif line.startswith("#workspace "):
704
+ ws = line[11:].strip()
705
+ elif line.startswith("#expires "):
706
+ expires = line[9:].strip()
707
+ elif line and not line.startswith("#"):
708
+ body.append(line)
709
+ if hashlib.sha256("\n".join(body).encode("utf-8")).hexdigest()[:32] != digest:
710
+ return None
711
+ if ws and workspace_id and ws != workspace_id:
712
+ return None
713
+ try:
714
+ exp = datetime.datetime.fromisoformat(expires.replace("Z", "+00:00"))
715
+ except Exception:
716
+ return None
717
+ if datetime.datetime.now(datetime.timezone.utc) >= exp:
718
+ return None
719
+ tools = None
720
+ for line in body:
721
+ try:
722
+ e = json.loads(line)
723
+ except ValueError:
724
+ continue
725
+ if not isinstance(e, dict) or e.get("sessionId") != session or e.get("state") not in ("APPROVED", "EXECUTING"):
726
+ continue
727
+ tools = (tools or []) + [str(t).lower() for t in (e.get("tools") or []) if isinstance(t, str)]
728
+ return None if tools is None else {"tools": tools}
729
+
730
+
731
+ def intutic_trajectory_check(harness, tool, session, records_text, tool_input_json, payload_cwd, workspace_id):
732
+ import json, os
733
+ try:
734
+ ti = json.loads(tool_input_json or "{}")
735
+ except ValueError:
736
+ ti = {}
737
+ shadow = ""
738
+ scopes = _intutic_records(records_text, "scope_paths")
739
+ targets = [t["target"] for t in change_targets(tool, ti)] if scopes else []
740
+ if targets:
741
+ cwd = payload_cwd or os.getcwd()
742
+ root = _intutic_repo_root(cwd)
743
+ rels = []
744
+ for t in targets:
745
+ absolute = _intutic_real(os.path.join(_intutic_real(cwd), t))
746
+ rel = os.path.relpath(absolute, root)
747
+ rels.append((t, None if rel == ".." or rel.startswith(".." + os.sep) or os.path.isabs(rel) else rel.replace(os.sep, "/")))
748
+ for rollout in ("enforce", "shadow"):
749
+ group = [r for r in scopes if r["rollout"] == rollout]
750
+ if not group:
751
+ continue
752
+ union = [s for r in group for s in r["scopePaths"]]
753
+ bad = [t for t, rel in rels if rel is None or not is_within_scope(rel, union)]
754
+ if bad:
755
+ r = group[0]
756
+ line = _intutic_verdict(r, "OUT_OF_SCOPE", "scope_paths." + r["sopTitle"],
757
+ ", ".join(bad) + " is outside scope_paths (" + ", ".join(union) + ")", {"paths": bad})
758
+ if rollout == "enforce":
759
+ return line
760
+ shadow = shadow or line
761
+ plans = _intutic_records(records_text, "plan_approval")
762
+ if plans and not is_plan_exempt_tool(str(tool)):
763
+ for rollout in ("enforce", "shadow"):
764
+ group = [r for r in plans if r["rollout"] == rollout]
765
+ if not group:
766
+ continue
767
+ strict = [r for r in group if r["mode"] == "strict"]
768
+ r = strict[0] if strict else group[0]
769
+ level = r["mode"]
770
+ rid = "plan_approval." + r["sopTitle"]
771
+ line = ""
772
+ if not session:
773
+ line = _intutic_verdict(r, "PLAN_NOT_APPROVED", rid, "plan_approval: " + level + " - " + harness + " sent no session id, so no approved plan can be found for this call", {"tools": [str(tool)]})
774
+ else:
775
+ plan = _intutic_approved_plan(session, workspace_id)
776
+ if plan is None:
777
+ line = _intutic_verdict(r, "PLAN_NOT_APPROVED", rid, "plan_approval: " + level + " - this session has no APPROVED or EXECUTING plan; get its plan approved, then retry", {"tools": [str(tool)]})
778
+ elif level == "strict" and str(tool).lower() not in plan["tools"]:
779
+ line = _intutic_verdict(r, "OFF_PLAN", rid, "plan_approval: strict - " + str(tool) + " is not a step of the approved plan", {"tools": [str(tool)]})
780
+ if line and rollout == "enforce":
781
+ return line
782
+ shadow = shadow or line
783
+ return shadow
784
+ `;
346
785
  /**
347
786
  * The Python the bash gates run, defined once per gate script: the phrase
348
787
  * matcher ({@link PHRASES_PY_SOURCE}), the MCP registry decision
349
- * ({@link MCP_REGISTRY_PY_SOURCE}) and four entry points over them.
788
+ * ({@link MCP_REGISTRY_PY_SOURCE}), the shared rule-record ports (test
789
+ * outcomes, scope match, the `@rule` decoder and refusal detail, emitted
790
+ * from `intutic_clawde/gate/`), and entry points over them.
350
791
  *
351
792
  * - `intutic_actions(tool, command)` — the hold classifier, called by
352
793
  * {@link SHELL_EXTRACT}'s Python, which already reads the raw tool input:
@@ -368,8 +809,56 @@ export const GATE_PY_LIB = `${PHRASES_PY_SOURCE}
368
809
 
369
810
  ${MCP_REGISTRY_PY_SOURCE}
370
811
 
812
+ ${TEST_OUTCOME_PY_SOURCE}
813
+
814
+ ${SCOPE_MATCH_PY_SOURCE}
815
+
816
+ # The lane phrases rule_record.py names (lanes.ts LANE_PHRASES).
817
+ LANE_PHRASES = ${JSON.stringify(LANE_PHRASES)}
818
+
819
+ ${RULE_RECORD_PY_SOURCE}
820
+
821
+ ${REFUSAL_DETAIL_PY_SOURCE}
822
+
371
823
  _INTUTIC_ACTION_NEEDLES = ${JSON.stringify(ACTION_NEEDLES)}
372
824
  _INTUTIC_ACTION_TOOLS = ${JSON.stringify(ACTION_TOOL_NAMES)}
825
+ _INTUTIC_FORCE_FLAGS = ${JSON.stringify(FORCE_PUSH_FLAGS)}
826
+
827
+ _INTUTIC_RM_SEGMENT = re.compile(r"[;&|()\\n]")
828
+ _INTUTIC_RM_GAP = re.compile(r"[ \\t\\r\\f\\v]+")
829
+
830
+
831
+ def _intutic_removes_outside_tree(args):
832
+ # removes_outside_tree (intutic_clawde actions.py, gate-js actions.ts):
833
+ # rm -rf / -fr / -r -f / --recursive --force with a target that is
834
+ # absolute, under ~, or contains "..". rm -rf build/ is not one.
835
+ for segment in _INTUTIC_RM_SEGMENT.split(args):
836
+ words = [w for w in _INTUTIC_RM_GAP.split(segment) if w != ""]
837
+ at = next((i for i, w in enumerate(words) if w == "rm" or w.endswith("/rm")), -1)
838
+ if at == -1:
839
+ continue
840
+ recursive = force = False
841
+ flags = True
842
+ targets = []
843
+ for w in words[at + 1:]:
844
+ if flags and w == "--":
845
+ flags = False
846
+ elif flags and w.startswith("--"):
847
+ if w == "--recursive":
848
+ recursive = True
849
+ if w == "--force":
850
+ force = True
851
+ elif flags and w.startswith("-") and len(w) > 1:
852
+ lower = w.lower()
853
+ if "r" in lower:
854
+ recursive = True
855
+ if "f" in lower:
856
+ force = True
857
+ else:
858
+ targets.append(w.strip("\\"'"))
859
+ if recursive and force and any(t.startswith("/") or t.startswith("~") or ".." in t for t in targets):
860
+ return True
861
+ return False
373
862
 
374
863
 
375
864
  def intutic_actions(tool, command):
@@ -380,8 +869,17 @@ def intutic_actions(tool, command):
380
869
  for action, needles in _INTUTIC_ACTION_NEEDLES:
381
870
  if any(has_phrase(words, n) for n in needles):
382
871
  out += action + " "
872
+ # A recursive, forced remove outside the working tree is destroy too
873
+ # (Q3); no phrase can say "outside the tree", so it is a predicate.
874
+ if " ${DESTROY_TOKEN} " not in out and _intutic_removes_outside_tree(str(command or "")):
875
+ out += "${DESTROY_TOKEN} "
876
+ # The force-push compound: the git push phrase and a whole-word flag.
877
+ if has_phrase(words, "git push"):
878
+ padded = " " + " ".join(str(command or "").lower().split()) + " "
879
+ if any(f in padded for f in _INTUTIC_FORCE_FLAGS):
880
+ out += "${FORCE_PUSH_TOKEN} "
383
881
  return out
384
-
882
+ ${GATE_RULE_TIERS_PY}
385
883
 
386
884
  def intutic_phrase_rule(source, command):
387
885
  words = phrase_text(command)
@@ -435,6 +933,63 @@ def intutic_chaos_call(tool, decision, rule_id, destructive, policy_hit, key="")
435
933
  if fault is None:
436
934
  return ""
437
935
  return fault["kind"] + "\\t" + fault["reason"]
936
+
937
+
938
+ _INTUTIC_DECIDE_CODES = {
939
+ "allow": (None,),
940
+ "refuse": ("DECIDE_REFUSED", "DECIDE_ESCALATED", "GOVERNANCE_UNAVAILABLE"),
941
+ "escalate": ("DECIDE_ESCALATED",),
942
+ }
943
+
944
+
945
+ def intutic_decide_check(harness, tool, budget_ms, session_id, enforce, tool_input_json):
946
+ # The decide tier of a bash gate: \`intutic decide check --stdin\` on a call
947
+ # an @decide record names. Returns outcome, code, rule id and reason,
948
+ # joined by the unit separator (an empty field where there is none). A
949
+ # missing CLI, a non-zero exit, a timeout or an answer that does not read
950
+ # is unavailable: refused with GOVERNANCE_UNAVAILABLE when a matched
951
+ # record is stamped enforce (enforce "1"), allowed, with the reason, when
952
+ # every one is shadow. The CLI spends the budget, its own start included.
953
+ import json, shutil, subprocess
954
+ sep = chr(31)
955
+
956
+ def clean(text):
957
+ return " ".join(str(text).split())
958
+
959
+ def unavailable(why):
960
+ if enforce != "1":
961
+ return sep.join(["allow", "", "", clean(why)])
962
+ return sep.join(["refuse", "GOVERNANCE_UNAVAILABLE", "", clean("GOVERNANCE_UNAVAILABLE — " + why)])
963
+
964
+ try:
965
+ tool_input = json.loads(tool_input_json or "{}")
966
+ except ValueError:
967
+ tool_input = {}
968
+ exe = shutil.which("intutic")
969
+ if not exe:
970
+ return unavailable("the decide rules on " + tool + " need the intutic CLI, which is not on PATH")
971
+ payload = {"v": 1, "harness": harness, "tool": tool, "toolInput": tool_input, "cwd": os.getcwd(),
972
+ "sessionId": session_id, "budgetMs": int(budget_ms), "lastUserTurn": None}
973
+ try:
974
+ done = subprocess.run([exe, "decide", "check", "--stdin"], input=json.dumps(payload), capture_output=True,
975
+ text=True, timeout=int(budget_ms) / 1000.0)
976
+ except subprocess.TimeoutExpired:
977
+ return unavailable("intutic decide check did not answer within " + str(budget_ms) + " ms")
978
+ except OSError as err:
979
+ return unavailable("intutic decide check could not run (" + type(err).__name__ + ")")
980
+ if done.returncode != 0:
981
+ return unavailable("intutic decide check exited " + str(done.returncode))
982
+ try:
983
+ out = json.loads(done.stdout)
984
+ except ValueError:
985
+ out = None
986
+ rule_id = out.get("ruleId") if isinstance(out, dict) else None
987
+ if (not isinstance(out, dict) or out.get("v") != 1 or out.get("outcome") not in _INTUTIC_DECIDE_CODES
988
+ or out.get("code") not in _INTUTIC_DECIDE_CODES[out.get("outcome")]
989
+ or not (rule_id is None or (isinstance(rule_id, str) and rule_id.startswith("decide.")))
990
+ or not isinstance(out.get("reason", ""), str)):
991
+ return unavailable("intutic decide check gave an answer this gate does not read")
992
+ return sep.join([out["outcome"], out.get("code") or "", clean(rule_id or ""), clean(out.get("reason") or "")])
438
993
  `;
439
994
  /**
440
995
  * How old a snapshot may be before a gate reports it as stale.
@@ -445,6 +1000,18 @@ def intutic_chaos_call(tool, decision, rule_id, destructive, policy_hit, key="")
445
1000
  * this fires on "the daemon has been dead for a week", not on "it is Sunday".
446
1001
  */
447
1002
  export const SNAPSHOT_STALE_AFTER_DAYS = 7;
1003
+ /**
1004
+ * The newest snapshot format a v23 gate reads: 3, the `@rule` records' (LLD
1005
+ * #75). The shared `SNAPSHOT_FORMAT` names the decide records' 2 until the
1006
+ * shared modules move it.
1007
+ */
1008
+ export const GATE_READS_FORMAT = Math.max(SNAPSHOT_FORMAT, RULE_SNAPSHOT_FORMAT);
1009
+ /**
1010
+ * What a gate reports, once per call, on a snapshot in a format newer than it
1011
+ * reads (`snapshot_format_newer`); `{n}` is the snapshot's format. It still
1012
+ * enforces every line it reads, and a fallback line of a rule it cannot read.
1013
+ */
1014
+ export const FORMAT_NEWER_MESSAGE = `Policy snapshot format {n} is newer than this gate reads (${GATE_READS_FORMAT}) — records it does not know are skipped and their fallback lines enforced`;
448
1015
  /** Single-quotes a string for shell, safely. */
449
1016
  function shq(s) {
450
1017
  return "'" + s.replace(/'/g, `'\\''`) + "'";
@@ -573,6 +1140,12 @@ export function emitShellFailClosed(harness) {
573
1140
  # "hook errored" and run the tool anyway, so a crash is converted into a block.
574
1141
  # EXIT rather than ERR: ERR is unreliable across shells for set -u deaths, and
575
1142
  # it fires on benign non-zero intermediates; the final status cannot lie.
1143
+ # A post-tool invocation (--event post, gate body v23) records a tool's result
1144
+ # and never refuses: the tool has already run, and some harnesses read a
1145
+ # post-tool exit 2 as text to feed back to the model. So in post mode every
1146
+ # exit, a crash and the deadline included, is 0.
1147
+ INTUTIC_POST_MODE=0
1148
+ if [ "\${1:-}" = "--event" ] && [ "\${2:-}" = "post" ]; then INTUTIC_POST_MODE=1; fi
576
1149
  intutic_fail_closed() {
577
1150
  _intutic_exit_rc=$?
578
1151
  # The trap must not itself die on an unset variable or a failed command —
@@ -580,6 +1153,7 @@ intutic_fail_closed() {
580
1153
  set +eu
581
1154
  trap - EXIT
582
1155
  [ -n "\${INTUTIC_WATCHDOG_PID:-}" ] && kill "$INTUTIC_WATCHDOG_PID" 2>/dev/null
1156
+ if [ "\${INTUTIC_POST_MODE:-0}" = "1" ]; then exit 0; fi
583
1157
  # A chaos run's record of a call the gate refused, held or crashed on (the
584
1158
  # gate body records an allowed one where it ends). It cannot change the
585
1159
  # exit status: everything it prints is discarded.
@@ -606,6 +1180,7 @@ trap intutic_fail_closed EXIT
606
1180
  # linear; a workspace's own WHERE pattern need not be.
607
1181
  intutic_deadline() {
608
1182
  trap - USR1
1183
+ if [ "\${INTUTIC_POST_MODE:-0}" = "1" ]; then exit 0; fi
609
1184
  echo "[Intutic Governance] BLOCKED: GATE_DEADLINE — the gate did not finish evaluating this call within ${deadlineS} s, and refuses it rather than let the hook timeout allow it." >&2
610
1185
  if command -v log_event >/dev/null 2>&1; then
611
1186
  log_event "tool_blocked" "\${TOOL:-unknown}" "GATE_DEADLINE — the gate did not finish within ${deadlineS} s" || true
@@ -639,6 +1214,46 @@ export function emitJsActionClassifier() {
639
1214
  ${PHRASES_JS_SOURCE}
640
1215
  const INTUTIC_ACTION_NEEDLES = ${JSON.stringify(ACTION_NEEDLES)};
641
1216
  const INTUTIC_ACTION_TOOLS = ${JSON.stringify(ACTION_TOOL_NAMES)};
1217
+ const INTUTIC_FORCE_FLAGS = ${JSON.stringify(FORCE_PUSH_FLAGS)};
1218
+
1219
+ /**
1220
+ * removesOutsideTree (@intutic/gate actions.ts): rm -rf / -fr / -r -f /
1221
+ * --recursive --force with a target that is absolute, under ~, or contains
1222
+ * "..". Segments split at ; & | ( ) and newlines; linear in the command.
1223
+ */
1224
+ function intuticRemovesOutsideTree(args) {
1225
+ var segments = args.split(/[;&|()\\n]/);
1226
+ for (var s = 0; s < segments.length; s++) {
1227
+ var words = segments[s].split(/[ \\t\\r\\f\\v]+/).filter(function (w) { return w !== ''; });
1228
+ var at = -1;
1229
+ for (var i = 0; i < words.length; i++) {
1230
+ if (words[i] === 'rm' || words[i].slice(-3) === '/rm') { at = i; break; }
1231
+ }
1232
+ if (at === -1) continue;
1233
+ var recursive = false, force = false, flags = true, targets = [];
1234
+ for (var j = at + 1; j < words.length; j++) {
1235
+ var w = words[j];
1236
+ if (flags && w === '--') flags = false;
1237
+ else if (flags && w.indexOf('--') === 0) {
1238
+ if (w === '--recursive') recursive = true;
1239
+ if (w === '--force') force = true;
1240
+ } else if (flags && w.charAt(0) === '-' && w.length > 1) {
1241
+ var lower = w.toLowerCase();
1242
+ if (lower.indexOf('r') !== -1) recursive = true;
1243
+ if (lower.indexOf('f') !== -1) force = true;
1244
+ } else {
1245
+ var a = 0, b = w.length;
1246
+ while (a < b && (w.charAt(a) === '"' || w.charAt(a) === "'")) a++;
1247
+ while (b > a && (w.charAt(b - 1) === '"' || w.charAt(b - 1) === "'")) b--;
1248
+ targets.push(w.slice(a, b));
1249
+ }
1250
+ }
1251
+ for (var t = 0; t < targets.length; t++) {
1252
+ if (recursive && force && (targets[t].charAt(0) === '/' || targets[t].charAt(0) === '~' || targets[t].indexOf('..') !== -1)) return true;
1253
+ }
1254
+ }
1255
+ return false;
1256
+ }
642
1257
 
643
1258
  /** Space-padded action tokens for a shell command (" action:deploy "), or " ". */
644
1259
  function intuticActions(toolName, command) {
@@ -650,6 +1265,15 @@ function intuticActions(toolName, command) {
650
1265
  if (hasPhrase(words, needles[j])) { out += INTUTIC_ACTION_NEEDLES[i][0] + ' '; break; }
651
1266
  }
652
1267
  }
1268
+ // A recursive, forced remove outside the working tree is destroy too (Q3).
1269
+ if (out.indexOf(' ${DESTROY_TOKEN} ') === -1 && intuticRemovesOutsideTree(String(command == null ? '' : command))) out += '${DESTROY_TOKEN} ';
1270
+ // The force-push compound: the git push phrase and a whole-word flag.
1271
+ if (hasPhrase(words, 'git push')) {
1272
+ var padded = ' ' + String(command == null ? '' : command).toLowerCase().split(/\\s+/).filter(Boolean).join(' ') + ' ';
1273
+ for (var f = 0; f < INTUTIC_FORCE_FLAGS.length; f++) {
1274
+ if (padded.indexOf(INTUTIC_FORCE_FLAGS[f]) !== -1) { out += '${FORCE_PUSH_TOKEN} '; break; }
1275
+ }
1276
+ }
653
1277
  return out;
654
1278
  }
655
1279
 
@@ -803,9 +1427,173 @@ const SCREEN_SUBJECTS = [
803
1427
  ['content', 'TOOL_INPUT_JSON'],
804
1428
  ['action', 'INTUTIC_ACTIONS'],
805
1429
  ];
1430
+ /**
1431
+ * The bash gate's handling of decide fallback lines. A gate that evaluates
1432
+ * decide rules (through the CLI) skips the fallback line an `@decide` record
1433
+ * names; one that does not keeps every fallback line, which refuses.
1434
+ */
1435
+ function shellDecideFallbackSkip(evaluatesDecide) {
1436
+ if (!evaluatesDecide) {
1437
+ return ` # A fallback line stands in for a decide rule a gate cannot evaluate. This
1438
+ # gate evaluates none, so every fallback line stays in force and refuses
1439
+ # its rule's tools, as in a gate that reads no @decide records.
1440
+
1441
+ `;
1442
+ }
1443
+ return ` # A fallback line stands in for a decide rule a gate cannot evaluate. This
1444
+ # gate hands a decide rule to the CLI, which decodes its record and refuses
1445
+ # the call when it does not decode, so it skips the fallback line that an
1446
+ # @decide record of five columns names in its second. Any other line with
1447
+ # the prefix is enforced.
1448
+ if [ \${#INTUTIC_DECIDE_RECORDS[@]} -gt 0 ]; then
1449
+ _intutic_skip=$'\\n'
1450
+ for _rec in "\${INTUTIC_DECIDE_RECORDS[@]}"; do
1451
+ _intutic_tabs="\${_rec//[^$'\\t']/}"
1452
+ _intutic_fb="\${_rec#*$'\\t'}"
1453
+ _intutic_fb="\${_intutic_fb%%$'\\t'*}"
1454
+ if [ \${#_intutic_tabs} -eq 4 ] && [ -n "\${_rec##*$'\\t'}" ]; then
1455
+ case "$_intutic_fb" in '${DECIDE_FALLBACK_PREFIX}'*) _intutic_skip+="$_intutic_fb"$'\\n' ;; esac
1456
+ fi
1457
+ done
1458
+ _intutic_kept=()
1459
+ if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
1460
+ for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
1461
+ case "$_intutic_skip" in *$'\\n'"\${_rec%%$'\\t'*}"$'\\n'*) continue ;; esac
1462
+ _intutic_kept+=("$_rec")
1463
+ done
1464
+ fi
1465
+ INTUTIC_DYNAMIC=()
1466
+ if [ \${#_intutic_kept[@]} -gt 0 ]; then INTUTIC_DYNAMIC=("\${_intutic_kept[@]}"); fi
1467
+ fi
1468
+
1469
+ `;
1470
+ }
1471
+ /**
1472
+ * The bash gate's handling of `<kind>-fallback:` lines (gate body v23): the
1473
+ * extractor's Python decodes the `@rule` records and names the fallback
1474
+ * lines of the decoded, enforcing ones whose kind this gate evaluates; those
1475
+ * are dropped. Any other fallback line is enforced.
1476
+ */
1477
+ function shellRuleFallbackSkip(kindsCsv) {
1478
+ return ` # A <kind>-fallback: line stands in for a rule record a gate cannot
1479
+ # evaluate. This gate evaluates ${kindsCsv || 'none'}, so it drops the fallback line a
1480
+ # decoded record of those kinds names. A python3 that cannot say keeps them all.
1481
+ if [ \${#INTUTIC_RULE_RECORDS[@]} -gt 0 ] && [ -n ${shq(kindsCsv)} ]; then
1482
+ _intutic_rskip="$(python3 -c 'import os, sys
1483
+ lib = {}
1484
+ exec(os.environ.get("INTUTIC_PY_LIB", ""), lib)
1485
+ sys.stdout.write(lib["intutic_rule_skip"](sys.stdin.read(), sys.argv[1]))' ${shq(kindsCsv)} <<< "$(printf '%s\\n' "\${INTUTIC_RULE_RECORDS[@]}")" 2>/dev/null || true)"
1486
+ if [ -n "$_intutic_rskip" ] && [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
1487
+ _intutic_rskip=$'\\n'"$_intutic_rskip"$'\\n'
1488
+ _intutic_kept=()
1489
+ for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
1490
+ case "$_intutic_rskip" in *$'\\n'"\${_rec%%$'\\t'*}"$'\\n'*) continue ;; esac
1491
+ _intutic_kept+=("$_rec")
1492
+ done
1493
+ INTUTIC_DYNAMIC=()
1494
+ if [ \${#_intutic_kept[@]} -gt 0 ]; then INTUTIC_DYNAMIC=("\${_intutic_kept[@]}"); fi
1495
+ fi
1496
+ fi
1497
+
1498
+ `;
1499
+ }
1500
+ /**
1501
+ * The bash gate's decide tier, after every deterministic tier allowed the
1502
+ * call: a call whose tool an `@decide` record names (column 4) goes to
1503
+ * `intutic decide check --stdin` through `intutic_decide_check` in
1504
+ * {@link GATE_PY_LIB}, within the harness's budget (decideBudgetMs in gateLimits.ts), capped
1505
+ * by what the deadline has left. The CLI decodes the records, so a record that
1506
+ * does not decode refuses there.
1507
+ */
1508
+ function shellDecideTier(opts) {
1509
+ const log = opts.logFn ?? 'log_event';
1510
+ const ask = opts.ask ?? 'none';
1511
+ if (ask !== 'none' && ask !== 'hermes-approve')
1512
+ throw new Error(`gateBody: the ${ask} ask contract is not one a bash gate speaks`);
1513
+ const askBlock = ask === 'hermes-approve'
1514
+ ? ` if [ "$_intutic_dec_out" = "escalate" ]; then
1515
+ # Hermes' human approval: {"action":"approve"}. Never {"decision":"approve"},
1516
+ # which Hermes reads as an allow.
1517
+ intutic_shown "$_intutic_dec_full"
1518
+ echo "[Intutic Governance] ASK: \${_intutic_shown}" >&2
1519
+ ${log} "tool_flagged" "\${TOOL:-}" "DECIDE_ESCALATED — asked a person: $_intutic_dec_full" || true
1520
+ _intutic_chaos_rule="$_intutic_dec_rid"; _intutic_chaos_hit=1
1521
+ intutic_chaos_decide hold >/dev/null 2>&1 || true
1522
+ _intutic_chaos_done=1
1523
+ printf '{"action":"approve","message":"%s"}\\n' "$(intutic_json_escape "$_intutic_shown")"
1524
+ exit 0
1525
+ fi
1526
+ `
1527
+ : ` # ${opts.harness} cannot ask a person, so an escalation refuses (DECIDE_ESCALATED).
1528
+ `;
1529
+ return `# ── Decide tier (gate body v22) ──────────────────────────────────────────────
1530
+ # Only after every tier above allowed the call, and only for a tool an @decide
1531
+ # record names in its fourth column: intutic decide check --stdin asks the
1532
+ # self-hosted engine, within ${decideBudgetMs('bash', opts.harness)} ms less what the deadline has left.
1533
+ # A CLI that is missing, fails, times out or answers what this gate cannot
1534
+ # read is unavailable, which refuses unless every record on the tool is
1535
+ # stamped shadow.
1536
+ _intutic_decide_match=0
1537
+ _intutic_decide_enforce=0
1538
+ if [ -z "\${1:-}" ] && [ \${#INTUTIC_DECIDE_RECORDS[@]} -gt 0 ]; then
1539
+ for _rec in "\${INTUTIC_DECIDE_RECORDS[@]}"; do
1540
+ IFS=$'\\t' read -r _ _d_fb _d_roll _d_tools _d_rest <<< "$_rec"
1541
+ case ",\${_d_tools}," in
1542
+ *",\${TOOL:-},"*)
1543
+ _intutic_decide_match=1
1544
+ [ "$_d_roll" = "enforce" ] && _intutic_decide_enforce=1
1545
+ ;;
1546
+ esac
1547
+ done
1548
+ fi
1549
+ if [ "$_intutic_decide_match" = "1" ]; then
1550
+ _intutic_decide_budget=$(( ${gateDeadlineMs(opts.harness)} - (SECONDS + 1) * 1000 - ${DECIDE_BUDGET_MARGIN_MS} ))
1551
+ [ "$_intutic_decide_budget" -gt ${decideBudgetMs('bash', opts.harness)} ] && _intutic_decide_budget=${decideBudgetMs('bash', opts.harness)}
1552
+ if [ "$_intutic_decide_budget" -lt ${DECIDE_MIN_CALL_MS} ]; then
1553
+ if [ "$_intutic_decide_enforce" = "1" ]; then
1554
+ _intutic_dec="refuse"$'\\x1f'"GOVERNANCE_UNAVAILABLE"$'\\x1f'$'\\x1f'"GOVERNANCE_UNAVAILABLE — no time was left for the decide rules on \${TOOL:-} within the gate deadline"
1555
+ else
1556
+ _intutic_dec="allow"$'\\x1f'$'\\x1f'$'\\x1f'"no time was left for the decide rules on \${TOOL:-} within the gate deadline"
1557
+ fi
1558
+ else
1559
+ _intutic_dec="$(python3 -c 'import os, sys
1560
+ lib = {}
1561
+ exec(os.environ.get("INTUTIC_PY_LIB", ""), lib)
1562
+ sys.stdout.write(lib["intutic_decide_check"](*sys.argv[1:6], sys.stdin.read()))' ${shq(opts.harness)} "\${TOOL:-}" "$_intutic_decide_budget" "\${SESSION_ID:-}" "$_intutic_decide_enforce" <<< "\${TOOL_INPUT_JSON:-}" 2>/dev/null || true)"
1563
+ if [ -z "$_intutic_dec" ]; then
1564
+ # python3 could not say (it is required, and checked, before any rule
1565
+ # runs): unavailable, as a CLI that did not answer is.
1566
+ if [ "$_intutic_decide_enforce" = "1" ]; then
1567
+ _intutic_dec="refuse"$'\\x1f'"GOVERNANCE_UNAVAILABLE"$'\\x1f'$'\\x1f'"GOVERNANCE_UNAVAILABLE — the decide check could not run"
1568
+ else
1569
+ _intutic_dec="allow"$'\\x1f'$'\\x1f'$'\\x1f'"the decide check could not run"
1570
+ fi
1571
+ fi
1572
+ fi
1573
+ IFS=$'\\x1f' read -r _intutic_dec_out _intutic_dec_code _intutic_dec_rid _intutic_dec_reason <<< "$_intutic_dec"
1574
+ if [ "$_intutic_dec_out" = "allow" ]; then
1575
+ # A shadow rule's engine that could not answer: recorded, and the call goes on.
1576
+ if [ -n "$_intutic_dec_reason" ]; then
1577
+ ${log} "tool_would_block" "\${TOOL:-}" "GOVERNANCE_UNAVAILABLE — $_intutic_dec_reason" || true
1578
+ fi
1579
+ else
1580
+ _intutic_dec_full="$_intutic_dec_reason"
1581
+ [ -n "$_intutic_dec_rid" ] && _intutic_dec_full="$_intutic_dec_full [$_intutic_dec_rid]"
1582
+ ${askBlock} _intutic_chaos_rule="$_intutic_dec_rid"; _intutic_chaos_hit=1
1583
+ intutic_shown "$_intutic_dec_full"
1584
+ echo "[Intutic Governance] BLOCKED: \${_intutic_shown} (refusal code \${_intutic_dec_code:-DECIDE_ESCALATED})" >&2
1585
+ ${log} "tool_blocked" "\${TOOL:-}" "$_intutic_dec_full"
1586
+ exit 2
1587
+ fi
1588
+ fi
1589
+ `;
1590
+ }
806
1591
  export function emitShellGate(opts) {
807
1592
  const log = opts.logFn ?? 'log_event';
808
1593
  const floor = staticFloorPatterns();
1594
+ const observable = POST_TOOL_OBSERVABLE[opts.harness] ? '1' : '0';
1595
+ const ruleKinds = Object.keys(GATE_EVALUATES_RULE_RECORDS.bash).filter((k) => GATE_EVALUATES_RULE_RECORDS.bash[k]).join(',');
1596
+ const pyCall = (fn, args) => `python3 -c 'import os, sys\nlib = {}\nexec(os.environ.get("INTUTIC_PY_LIB", ""), lib)\nsys.stdout.write(lib["${fn}"](${args}))'`;
809
1597
  return `
810
1598
  # ── Intutic gate body v${GATE_VERSION} — harness: ${opts.harness} ────────────
811
1599
  # Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
@@ -814,6 +1602,18 @@ export function emitShellGate(opts) {
814
1602
  # were previously passed in and dropped on the floor: opts.harness was declared
815
1603
  # on the option type, supplied by all thirteen writers, and read nowhere.
816
1604
 
1605
+ # ── Post-tool outcome (gate body v23) ────────────────────────────────────────
1606
+ # A post-tool invocation (--event post) records a test run's outcome for this
1607
+ # session, or marks an earlier passing run stale after a file change, and
1608
+ # always allows: the call has already run. Only where ${opts.harness}'s post-tool
1609
+ # payload was captured and carries an exit code (POST_TOOL_OBSERVABLE: ${observable}).
1610
+ if [ "\${INTUTIC_POST_MODE:-0}" = "1" ]; then
1611
+ if [ "${observable}" = "1" ]; then
1612
+ ${pyCall('intutic_post_record', 'sys.argv[1], sys.stdin.read()')} ${shq(opts.harness)} <<< "\${INPUT:-}" >/dev/null 2>&1 || true
1613
+ fi
1614
+ exit 0
1615
+ fi
1616
+
817
1617
  # ── Agent fault injection (intutic chaos run) ────────────────────────────────
818
1618
  # Only when INTUTIC_CHAOS_PLAN is set: a chaos experiment's runner sets it, with
819
1619
  # INTUTIC_CHAOS_EVENTS, in the sandbox the agent runs in, and this gate inherits
@@ -932,6 +1732,14 @@ INTUTIC_MCP_REGISTRY=""
932
1732
  # text, never allows a call.
933
1733
  INTUTIC_OPAQUE=0
934
1734
  INTUTIC_OPAQUE_MESSAGE=${shq(OPAQUE_REFUSAL_MESSAGE)}
1735
+ # The snapshot format (the @format record; 1 without one), and the decide
1736
+ # rules' @decide records, kept whole for the decide tier: this gate decodes
1737
+ # none of them, the CLI does.
1738
+ INTUTIC_SNAPSHOT_FORMAT=1
1739
+ INTUTIC_DECIDE_RECORDS=()
1740
+ # The @rule records (gate body v23), kept whole: the extractor's Python decodes
1741
+ # them for the rule-record tiers below.
1742
+ INTUTIC_RULE_RECORDS=()
935
1743
  if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
936
1744
  INTUTIC_SNAPSHOT_STATE="ok"
937
1745
  while IFS= read -r _line || [ -n "$_line" ]; do
@@ -950,12 +1758,17 @@ if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
950
1758
  ;;
951
1759
  '${MCP_REGISTRY_RECORD_TAG}'$'\\t'*) INTUTIC_MCP_REGISTRY="\${_line#*$'\\t'}" ;;
952
1760
  '${INTERVENTION_MODE_RECORD_TAG}'$'\\t''OPAQUE') INTUTIC_OPAQUE=1 ;;
1761
+ '${SNAPSHOT_FORMAT_RECORD_TAG}'$'\\t'*)
1762
+ case "\${_line#*$'\\t'}" in ''|*[!0-9]*) : ;; *) INTUTIC_SNAPSHOT_FORMAT="\${_line#*$'\\t'}" ;; esac
1763
+ ;;
1764
+ '${DECIDE_RECORD_TAG}'$'\\t'*) INTUTIC_DECIDE_RECORDS+=("$_line") ;;
1765
+ '${RULE_RECORD_TAG}'$'\\t'*) INTUTIC_RULE_RECORDS+=("$_line") ;;
953
1766
  '#'*|'') : ;;
954
1767
  *) INTUTIC_DYNAMIC+=("$_line") ;;
955
1768
  esac
956
1769
  done < "$INTUTIC_SNAPSHOT_RULES"
957
1770
 
958
- # Integrity, in the order that fails cheapest first.
1771
+ ${shellDecideFallbackSkip(GATE_EVALUATES_DECIDE.bash)}${shellRuleFallbackSkip(ruleKinds)}${SHELL_SHADOWED_PHRASE_SKIP} # Integrity, in the order that fails cheapest first.
959
1772
  #
960
1773
  # All three of these were parsed and then ignored. A digest nobody recomputes
961
1774
  # is not integrity, it is a comment; and a workspace id nobody compares means a
@@ -1001,6 +1814,9 @@ if [ -f "$INTUTIC_SNAPSHOT_RULES" ]; then
1001
1814
  # never set: the snapshot admits no MCP server (the refusal below).
1002
1815
  INTUTIC_MCP_REGISTRY=""
1003
1816
  INTUTIC_MCP_ALLOWLIST=0
1817
+ # The decide and rule records go with the rest of the dynamic tier.
1818
+ INTUTIC_DECIDE_RECORDS=()
1819
+ INTUTIC_RULE_RECORDS=()
1004
1820
  fi
1005
1821
  fi
1006
1822
 
@@ -1025,6 +1841,9 @@ case "$INTUTIC_SNAPSHOT_STATE" in
1025
1841
  empty) ${log} "snapshot_empty" "\${TOOL:-}" "Policy snapshot contains no rules — the compile produced nothing" || true ;;
1026
1842
  stale) ${log} "snapshot_stale" "\${TOOL:-}" "Policy snapshot is \${_intutic_age_days} days old and still enforced" || true ;;
1027
1843
  esac
1844
+ if [ "$INTUTIC_SNAPSHOT_FORMAT" -gt ${GATE_READS_FORMAT} ]; then
1845
+ ${log} "snapshot_format_newer" "\${TOOL:-}" "${FORMAT_NEWER_MESSAGE.replace('{n}', '$INTUTIC_SNAPSHOT_FORMAT')}" || true
1846
+ fi
1028
1847
 
1029
1848
  # Normalise once; every pattern is written against this shape. The extractor
1030
1849
  # hands each field over with its whitespace already collapsed to single spaces
@@ -1246,6 +2065,13 @@ intutic_apply() {
1246
2065
  destructive.*) return 0 ;;
1247
2066
  esac
1248
2067
  fi
2068
+ # The tier order (gate body v23): every refusal first, the holds after the
2069
+ # rule records and the MCP checks. A hold is only ever decided on a call
2070
+ # nothing deterministic refused.
2071
+ case "\${_intutic_pass:-all}" in
2072
+ det) [ "$rsev" = "hold" ] && return 0 ;;
2073
+ hold) [ "$rsev" = "hold" ] || return 0 ;;
2074
+ esac
1249
2075
  intutic_rule_fires "$rec"
1250
2076
  [ "$_intutic_fires" = "1" ] || return 0
1251
2077
  if [ "$rsev" = "shadow" ]; then
@@ -1350,12 +2176,51 @@ for _rec in "\${INTUTIC_FLOOR[@]}"; do
1350
2176
  intutic_apply "$_rec"
1351
2177
  done
1352
2178
 
2179
+ _intutic_pass=det
1353
2180
  if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
1354
2181
  for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
1355
2182
  intutic_apply "$_rec"
1356
2183
  done
1357
2184
  fi
1358
2185
 
2186
+ # ── Rule records (gate body v23) ─────────────────────────────────────────────
2187
+ # A rule record's verdict, as the Python check returns it: verdict, code, rule
2188
+ # id and reason, tab-separated; empty to continue. shadow records the finding;
2189
+ # block refuses.
2190
+ intutic_rule_verdict() {
2191
+ [ -n "$1" ] || return 0
2192
+ local verdict code rid reason retry
2193
+ IFS=$'\t' read -r verdict code rid reason retry <<< "$1"
2194
+ if [ "$verdict" = "shadow" ]; then
2195
+ intutic_chaos_note "$rid"
2196
+ ${log} "tool_would_block" "\${TOOL:-}" "\${reason} [\${rid}]" || true
2197
+ return 0
2198
+ fi
2199
+ _intutic_chaos_rule="$rid"; _intutic_chaos_hit=1
2200
+ intutic_shown "\${reason} [\${rid}]"
2201
+ echo "[Intutic Governance] BLOCKED: \${_intutic_shown} (refusal code \${code})" >&2
2202
+ # Where to retry: the paths or tools the refusal names. An
2203
+ # OPAQUE workspace drops it, as it drops the rule.
2204
+ if [ -n "$retry" ] && [ "$INTUTIC_OPAQUE" != "1" ]; then echo "$retry" >&2; fi
2205
+ ${log} "tool_blocked" "\${TOOL:-}" "\${reason} [\${rid}]"
2206
+ exit 2
2207
+ }
2208
+ _intutic_rr_text=""
2209
+ if [ \${#INTUTIC_RULE_RECORDS[@]} -gt 0 ]; then
2210
+ _intutic_rr_text="$(printf '%s\n' "\${INTUTIC_RULE_RECORDS[@]}")"
2211
+ fi
2212
+ # requires_outcome: a call whose action token (or tool) a qualified
2213
+ # requires_before gates is refused unless this session's last test run passed,
2214
+ # and no file changed since (PREREQUISITE_UNMET). The check also keeps the
2215
+ # session's state: a file change marks a passing run stale. A check that
2216
+ # cannot run refuses.
2217
+ case "$_intutic_rr_text" in
2218
+ *$'\t'requires_outcome$'\t'*)
2219
+ _intutic_rr="$(${pyCall('intutic_outcome_check', '*(sys.argv[1:7] + [sys.stdin.read()])')} ${shq(opts.harness)} ${observable} "\${TOOL:-}" "\${INTUTIC_ACTIONS:- }" "\${SESSION_ID:-}" "$_intutic_rr_text" <<< "\${TOOL_INPUT_JSON:-}" 2>/dev/null || printf 'block\tPREREQUISITE_UNMET\trequires_before\tthe requires_before check could not run')"
2220
+ intutic_rule_verdict "$_intutic_rr"
2221
+ ;;
2222
+ esac
2223
+
1359
2224
  # ── Unverified snapshot (gate body v17) ──────────────────────────────────────
1360
2225
  # A snapshot that failed its digest or workspace check admits no MCP server:
1361
2226
  # neither record in it can be vouched for, and a deleted one looks like one
@@ -1445,6 +2310,27 @@ case "\${TOOL:-}" in
1445
2310
  ;;
1446
2311
  esac
1447
2312
 
2313
+ # ── Holds (gate body v8; after every refusal since v23) ──────────────────────
2314
+ _intutic_pass=hold
2315
+ if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
2316
+ for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
2317
+ intutic_apply "$_rec"
2318
+ done
2319
+ fi
2320
+
2321
+ # ── Trajectory: scope_paths and plan_approval (gate body v23) ────────────────
2322
+ # A file change outside the union of scope_paths (OUT_OF_SCOPE), and a call
2323
+ # before the session's plan is approved (PLAN_NOT_APPROVED) or, under strict,
2324
+ # off its steps (OFF_PLAN). After the holds, before the decide tier. A check
2325
+ # that cannot run refuses.
2326
+ case "$_intutic_rr_text" in
2327
+ *$'\t'scope_paths$'\t'*|*$'\t'plan_approval$'\t'*)
2328
+ _intutic_tr="$(${pyCall('intutic_trajectory_check', '*(sys.argv[1:4] + [sys.argv[4], sys.stdin.read(), sys.argv[5], sys.argv[6]])')} ${shq(opts.harness)} "\${TOOL:-}" "\${SESSION_ID:-}" "$_intutic_rr_text" "$PWD" "\${INTUTIC_WORKSPACE_ID:-}" <<< "\${TOOL_INPUT_JSON:-}" 2>/dev/null || printf 'block\tOUT_OF_SCOPE\tscope_paths\tthe scope and plan check could not run')"
2329
+ intutic_rule_verdict "$_intutic_tr"
2330
+ ;;
2331
+ esac
2332
+
2333
+ ${shellDecideTier(opts)}
1448
2334
  # ── Agent fault injection: the call is allowed ───────────────────────────────
1449
2335
  # Recorded as allowed, then the fault that is due, if any. A permission delay
1450
2336
  # never waits here: the call is held, and its identical retry (the same key an
@@ -1468,16 +2354,130 @@ fi
1468
2354
  # ── end Intutic gate body ────────────────────────────────────────────────────
1469
2355
  `;
1470
2356
  }
2357
+ /**
2358
+ * The decide modules every JS-family gate embeds beside
2359
+ * {@link jsSnapshotLoader}: the snapshot's decoders and, for the decide
2360
+ * tier, the engine client and the protections around it (`DECIDE_GATE_JS_SOURCE`
2361
+ * in `@intutic/shared-types`), each module in a scope of its own.
2362
+ */
2363
+ const JS_DECIDE_LIB = `// Decide rules, emitted from decide.ts, decideRecord.ts, decideHostGuard.ts,
2364
+ // decideEngine.ts and decideEvaluate.ts in @intutic/shared-types: the @format
2365
+ // record, each @decide record checked against the rule it carries, and the
2366
+ // engine client and protections the decide tier runs.
2367
+ const intuticDecideLib = (${DECIDE_GATE_JS_SOURCE})(function (text) {
2368
+ return require('crypto').createHash('sha256').update(text, 'utf8').digest('hex');
2369
+ });`;
2370
+ /** A JS gate that evaluates decide rules skips each fallback line a decoded record names. */
2371
+ const JS_DECIDE_FALLBACK_SKIP = ` // A fallback line stands in for a decide rule a gate cannot evaluate. This
2372
+ // gate reads the rule from its record, so it skips the line, but only when
2373
+ // that record decoded: any other line with the prefix is enforced.
2374
+ if (out.decideRules.length > 0) {
2375
+ const skip = out.decideRules.map(function (r) { return r.fallbackId; });
2376
+ out.rules = out.rules.filter(function (r) {
2377
+ return r.id.indexOf('${DECIDE_FALLBACK_PREFIX}') !== 0 || skip.indexOf(r.id) === -1;
2378
+ });
2379
+ }
2380
+ `;
2381
+ /** A JS gate that does not evaluate decide rules keeps every fallback line. */
2382
+ const JS_DECIDE_FALLBACK_KEPT = ` // A fallback line stands in for a decide rule a gate cannot evaluate. This
2383
+ // gate evaluates none, so every fallback line stays in force and refuses
2384
+ // its rule's tools, as in a gate that reads no @decide records.
2385
+ `;
2386
+ /**
2387
+ * A hold token's rules share one id (`laneHoldPatterns`, `@intutic/shared-types`):
2388
+ * a `phrase` rule, which a gate whose classifier predates the token holds
2389
+ * by, and for force_push and destroy an `action` rule. A v23 gate classifies
2390
+ * every token, so it skips a `phrase` rule that shares its id with an
2391
+ * `action` rule and holds by the action rule alone: the partial phrase
2392
+ * `git push --force` also matches `--force-with-lease`, which is no force
2393
+ * push. One id means one approval releases the call at every gate, old or
2394
+ * new.
2395
+ */
2396
+ const JS_SHADOWED_PHRASE_SKIP = ` // A phrase rule that shares its id with an action rule is the partial form a
2397
+ // gate older than v23 holds by; this one holds by the action rule.
2398
+ {
2399
+ const actionIds = out.rules.filter(function (r) { return r.subject === 'action'; }).map(function (r) { return r.id; });
2400
+ if (actionIds.length > 0) out.rules = out.rules.filter(function (r) { return r.subject !== 'phrase' || actionIds.indexOf(r.id) === -1; });
2401
+ }
2402
+ `;
2403
+ /** {@link JS_SHADOWED_PHRASE_SKIP} in a bash gate. Fields are cut by expansion: an empty flags column must not shift the subject. */
2404
+ const SHELL_SHADOWED_PHRASE_SKIP = ` # A phrase rule that shares its id with an action rule is the partial form a
2405
+ # gate older than v23 holds by; this one holds by the action rule.
2406
+ if [ \${#INTUTIC_DYNAMIC[@]} -gt 0 ]; then
2407
+ _intutic_aids=$'\\n'
2408
+ for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
2409
+ _r="\${_rec#*$'\\t'}"; _r="\${_r#*$'\\t'}"; _r="\${_r#*$'\\t'}"
2410
+ if [ "\${_r%%$'\\t'*}" = "action" ]; then _intutic_aids+="\${_rec%%$'\\t'*}"$'\\n'; fi
2411
+ done
2412
+ if [ "$_intutic_aids" != $'\\n' ]; then
2413
+ _intutic_kept=()
2414
+ for _rec in "\${INTUTIC_DYNAMIC[@]}"; do
2415
+ _r="\${_rec#*$'\\t'}"; _r="\${_r#*$'\\t'}"; _r="\${_r#*$'\\t'}"
2416
+ if [ "\${_r%%$'\\t'*}" = "phrase" ]; then
2417
+ case "$_intutic_aids" in *$'\\n'"\${_rec%%$'\\t'*}"$'\\n'*) continue ;; esac
2418
+ fi
2419
+ _intutic_kept+=("$_rec")
2420
+ done
2421
+ INTUTIC_DYNAMIC=()
2422
+ if [ \${#_intutic_kept[@]} -gt 0 ]; then INTUTIC_DYNAMIC=("\${_intutic_kept[@]}"); fi
2423
+ fi
2424
+ fi
2425
+
2426
+ `;
2427
+ /** A JS gate that evaluates rule records skips each fallback line a decoded, enforcing record of those kinds names. */
2428
+ function jsRuleFallbackSkip(kinds) {
2429
+ return ` // A <kind>-fallback: line stands in for a rule record a gate cannot
2430
+ // evaluate. This gate evaluates ${kinds.join(', ')}, so it skips the line a
2431
+ // decoded record of those kinds names; any other fallback line is enforced.
2432
+ if (out.gateRecords.length > 0) {
2433
+ const kinds = ${JSON.stringify(kinds)};
2434
+ const rskip = out.gateRecords.filter(function (r) { return r.fallbackId && kinds.indexOf(r.kind) !== -1; })
2435
+ .map(function (r) { return r.fallbackId; });
2436
+ out.rules = out.rules.filter(function (r) { return rskip.indexOf(r.id) === -1; });
2437
+ }
2438
+ `;
2439
+ }
2440
+ /**
2441
+ * The shared rule-record ports from `@intutic/shared-types`, in a scope of
2442
+ * their own: the test outcome decision and its state
2443
+ * ({@link TEST_OUTCOME_JS_SOURCE}), the scope match
2444
+ * ({@link SCOPE_MATCH_JS_SOURCE}), the `@rule` decoder
2445
+ * ({@link RULE_RECORD_JS_SOURCE}) and the refusal detail
2446
+ * ({@link REFUSAL_DETAIL_JS_SOURCE}). The scope match calls the phrase
2447
+ * matcher (`PHRASES_JS_SOURCE`), which the classifier emits at the top level
2448
+ * of every JS gate; it is read at call time, after both are defined.
2449
+ */
2450
+ const JS_RULE_LIB = `// Rule records, test outcomes, scope matching and refusal detail, emitted
2451
+ // from @intutic/shared-types (testOutcome.ts, scopeMatch.ts, gateRuleRecord.ts,
2452
+ // refusalDetail.ts).
2453
+ const intuticRuleLib = (function () {
2454
+ ${TEST_OUTCOME_JS_SOURCE}
2455
+ ${SCOPE_MATCH_JS_SOURCE}
2456
+ ${RULE_RECORD_JS_SOURCE}
2457
+ ${REFUSAL_DETAIL_JS_SOURCE}
2458
+ return { decode: decodeGateRuleRecord, exitCode: extractExitCode, outcome: decideTestOutcome,
2459
+ emptyState: emptyOutcomeState, parseState: parseOutcomeState, recordOutcome: recordTestOutcome,
2460
+ noteCall: noteCompletedCall, prerequisite: prerequisiteCase, unmetReason: prerequisiteUnmetReason,
2461
+ withinScope: isWithinScope, changeTargets: changeTargets, isFileWrite: isFileWriteCall,
2462
+ planExempt: isPlanExemptTool, detail: refusalDetailForMode, retryLine: retryScopeLine };
2463
+ })();`;
1471
2464
  /**
1472
2465
  * The snapshot loader every JS-family gate embeds, defined once.
1473
2466
  *
1474
2467
  * Extracted from {@link emitJsGate}'s template when the n8n workflow gate
1475
2468
  * arrived: two emitters needing the same loader is exactly the situation that
1476
- * produced the four defects this module's header describes, so the loader is a
1477
- * constant both interpolate rather than a block one of them copies. The
1478
- * emitted bytes for the per-tool gates are unchanged.
2469
+ * produced the four defects this module's header describes, so the loader is
2470
+ * one function both interpolate rather than a block one of them copies. It
2471
+ * needs {@link JS_DECIDE_LIB} in scope.
2472
+ *
2473
+ * Its decide state: `format` (1 without an @format record), and
2474
+ * `decideRules`, the records that decoded. Only a gate that evaluates decide
2475
+ * rules (`evaluatesDecide`, {@link GATE_EVALUATES_DECIDE}) drops a fallback
2476
+ * line, and only when a decoded record names it; any other gate keeps every
2477
+ * fallback line in force.
1479
2478
  */
1480
- const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
2479
+ function jsSnapshotLoader(evaluatesDecide, ruleKinds = [], skipShadowedPhrases = false) {
2480
+ return `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
1481
2481
  const fs = require('fs'), os = require('os'), path = require('path');
1482
2482
  const p = process.env.INTUTIC_SNAPSHOT_RULES ||
1483
2483
  path.join(os.homedir(), '.intutic', 'hooks', 'policy-snapshot.rules');
@@ -1486,7 +2486,7 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
1486
2486
  // opaque: an OPAQUE workspace (the @intervention_mode record). Kept on a
1487
2487
  // snapshot that fails its digest: it only ever hides text, never allows.
1488
2488
  const out = { rules: [], digest: 'none', state: 'absent', workspaceId: '', generatedAt: '', ageDays: 0,
1489
- mcpAllowlist: null, mcpRegistry: null, opaque: false };
2489
+ mcpAllowlist: null, mcpRegistry: null, opaque: false, format: 1, decideRules: [], gateRecords: [] };
1490
2490
  let text;
1491
2491
  try { text = fs.readFileSync(p, 'utf8'); } catch (e) { return out; }
1492
2492
  out.state = 'ok';
@@ -1495,6 +2495,28 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
1495
2495
  if (line.startsWith('#workspace ')) { out.workspaceId = line.slice(11).trim(); continue; }
1496
2496
  if (line.startsWith('#generated ')) { out.generatedAt = line.slice(11).trim(); continue; }
1497
2497
  if (line === '${INTERVENTION_MODE_RECORD_TAG}\\tOPAQUE') { out.opaque = true; continue; }
2498
+ if (line.startsWith('${SNAPSHOT_FORMAT_RECORD_TAG}\\t')) {
2499
+ const v = intuticDecideLib.format(line);
2500
+ if (v !== null) out.format = v;
2501
+ continue;
2502
+ }
2503
+ if (line.startsWith('${DECIDE_RECORD_TAG}\\t')) {
2504
+ // A decide rule's record. One that does not decode is skipped, and the
2505
+ // rule's fallback line below then stays in force and refuses.
2506
+ const rec = intuticDecideLib.record(line);
2507
+ if (rec) out.decideRules.push(rec);
2508
+ continue;
2509
+ }
2510
+ if (line.startsWith('${RULE_RECORD_TAG}\\t')) {
2511
+ ${ruleKinds.length > 0
2512
+ ? ` // A rule record (gate body v23). One that does not decode is skipped, and
2513
+ // its fallback line below stays in force and refuses.
2514
+ const rr = intuticRuleLib.decode(line);
2515
+ if (rr) out.gateRecords.push(rr);`
2516
+ : ` // A rule record (gate body v23). This gate evaluates no kind, so each
2517
+ // record's fallback line stays in force and refuses.`}
2518
+ continue;
2519
+ }
1498
2520
  if (line.startsWith('${MCP_ALLOWLIST_RECORD_TAG}\\t')) {
1499
2521
  // The MCP server allowlist: tag, severity and comma-joined servers, a
1500
2522
  // data line inside the digest. Any severity but shadow refuses.
@@ -1524,6 +2546,7 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
1524
2546
  if (!line || line.startsWith('#')) continue;
1525
2547
  const f = line.split('\\t');
1526
2548
  if (f.length < 6 || !f[5]) continue;
2549
+
1527
2550
  let re, seq = null;
1528
2551
  // A snapshot rule that will not compile is dropped, not fatal. The floor
1529
2552
  // above is compiled in and unaffected, so this degrades to "today's
@@ -1545,10 +2568,13 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
1545
2568
  try { argRe = new RegExp(Buffer.from(f[6], 'base64').toString('utf8')); }
1546
2569
  catch (e) { argDowngraded = true; }
1547
2570
  }
1548
- out.rules.push({ id: f[0], severity: f[1], subject: f[3] || 'any', re: re, seq: seq, reason: f[4],
2571
+ // src: the source as written. A phrase rule reads it, never re.source,
2572
+ // which escapes a slash ('api.stripe.com/v1/charges' read back as
2573
+ // 'api.stripe.com\\/v1\\/charges' matched nothing).
2574
+ out.rules.push({ id: f[0], severity: f[1], subject: f[3] || 'any', re: re, seq: seq, reason: f[4], src: f[5],
1549
2575
  argRe: argRe, argDowngraded: argDowngraded });
1550
2576
  }
1551
-
2577
+ ${evaluatesDecide ? JS_DECIDE_FALLBACK_SKIP : JS_DECIDE_FALLBACK_KEPT}${ruleKinds.length > 0 ? jsRuleFallbackSkip(ruleKinds) : ''}${skipShadowedPhrases ? JS_SHADOWED_PHRASE_SKIP : ''}
1552
2578
  // Integrity. A digest nobody recomputes is a comment, and a workspace id
1553
2579
  // nobody compares means a snapshot from workspace A dropped onto workspace B's
1554
2580
  // machine is enforced verbatim, with B's events attributing A's rules to B.
@@ -1578,6 +2604,9 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
1578
2604
  // never set: the gate admits no MCP server on this snapshot at all.
1579
2605
  out.mcpRegistry = null;
1580
2606
  out.mcpAllowlist = null;
2607
+ // The decide and rule records go with the rest of the dynamic tier.
2608
+ out.decideRules = [];
2609
+ out.gateRecords = [];
1581
2610
  }
1582
2611
 
1583
2612
  if (out.state === 'ok' && out.generatedAt) {
@@ -1590,6 +2619,7 @@ const JS_SNAPSHOT_LOADER = `function intuticLoadSnapshot(INTUTIC_WORKSPACE_ID) {
1590
2619
  }
1591
2620
  return out;
1592
2621
  }`;
2622
+ }
1593
2623
  /**
1594
2624
  * A chaos run's side of the JS gates, defined once: the plan, this call's
1595
2625
  * record, and the fault to apply to an allowed call.
@@ -1676,6 +2706,244 @@ function intuticChaosKey(toolName, command, target) {
1676
2706
  .update(intuticNormalise(toolName) + '\\u0000' + intuticNormalise(command) + '\\u0000' + intuticNormalise(target))
1677
2707
  .digest('hex');
1678
2708
  }`;
2709
+ /**
2710
+ * The rule-record tiers of a JS gate, emitted once per gate: the
2711
+ * `requires_outcome` check and its state, the trajectory check
2712
+ * (`scope_paths`, `plan_approval`) and the post-tool recorder
2713
+ * (`intuticRecordOutcome`). Needs {@link JS_RULE_LIB} and the classifier in
2714
+ * scope.
2715
+ */
2716
+ function jsRuleTiers(harness) {
2717
+ return `// ── Rule records (gate body v23) ─────────────────────────────────────────────
2718
+ // The @rule records' tiers: requires_outcome among the deterministic refusals,
2719
+ // scope_paths and plan_approval after the holds (the trajectory tier), and
2720
+ // the post-tool recorder a writer calls with a tool's result. A record
2721
+ // stamped shadow records its finding and refuses nothing.
2722
+ const INTUTIC_HARNESS = ${JSON.stringify(harness)};
2723
+ // Whether this harness's post-tool payload was captured and carries an exit
2724
+ // code or an error flag (POST_TOOL_OBSERVABLE in gateBody.ts).
2725
+ const INTUTIC_POST_OBSERVABLE = ${POST_TOOL_OBSERVABLE[harness] ? 'true' : 'false'};
2726
+ // The working directory of the call, when the writer knows it (cwd in the
2727
+ // payload); the process's otherwise.
2728
+ var _intuticCallCwd = null;
2729
+
2730
+ /** ~/.intutic/hooks/state/outcomes/<sha256(harness NUL session)[:32]>.json */
2731
+ function intuticOutcomeStatePath(sessionId) {
2732
+ var path = require('path'), os = require('os');
2733
+ var base = process.env.INTUTIC_OUTCOME_STATE_DIR || path.join(os.homedir(), '.intutic', 'hooks', 'state', 'outcomes');
2734
+ var h = require('crypto').createHash('sha256').update(INTUTIC_HARNESS + '\\u0000' + String(sessionId), 'utf8').digest('hex').slice(0, 32);
2735
+ return path.join(base, h + '.json');
2736
+ }
2737
+
2738
+ /** { exists, state }: a missing file is an empty state; one that does not read is null, which leaves every rule unmet. */
2739
+ function intuticOutcomeStateRead(file) {
2740
+ var fs = require('fs');
2741
+ if (!fs.existsSync(file)) return { exists: false, state: intuticRuleLib.emptyState() };
2742
+ try { return { exists: true, state: intuticRuleLib.parseState(fs.readFileSync(file, 'utf8')) }; }
2743
+ catch (e) { return { exists: true, state: null }; }
2744
+ }
2745
+
2746
+ /** Written atomically; a write that fails changes no decision. */
2747
+ function intuticOutcomeStateWrite(file, state) {
2748
+ try {
2749
+ var fs = require('fs'), path = require('path');
2750
+ fs.mkdirSync(path.dirname(file), { recursive: true });
2751
+ var tmp = file + '.tmp.' + process.pid;
2752
+ fs.writeFileSync(tmp, JSON.stringify(state));
2753
+ fs.renameSync(tmp, file);
2754
+ } catch (e) {}
2755
+ }
2756
+
2757
+ /** Why a qualified prerequisite is not met (the shared reason), or null when it is. */
2758
+ function intuticOutcomeUnmet(r, sessionId, state) {
2759
+ var c, why = null;
2760
+ if (!INTUTIC_POST_OBSERVABLE) c = 'not_observable';
2761
+ else if (!sessionId) c = 'no_session';
2762
+ else { var got = intuticRuleLib.prerequisite(state, r.first, r.adjacent, Date.now()); c = got.case; why = got.why; }
2763
+ return c === 'met' ? null : intuticRuleLib.unmetReason(r.first, r.then, c, why, INTUTIC_HARNESS);
2764
+ }
2765
+
2766
+ /** A rule record's finding; scope is where the refusal applies (§6.2). */
2767
+ function intuticRuleVerdict(r, code, ruleId, reason, scope) {
2768
+ return { verdict: r.rollout === 'enforce' ? 'block' : 'shadow', code: code, ruleId: ruleId, reason: reason, scope: scope };
2769
+ }
2770
+
2771
+ /**
2772
+ * The requires_outcome tier: a call whose action token (or tool name) a
2773
+ * qualified requires_before gates, unless this session's last test run
2774
+ * passed and nothing changed since. Then the session's bookkeeping: a call
2775
+ * that changes files (a mutation tool, or a shell write such as sed -i or
2776
+ * git checkout: isFileWriteCall, Q1) marks a passing run stale, and every
2777
+ * call ends adjacency for ~> rules.
2778
+ */
2779
+ function intuticOutcomeTier(snap, toolName, nActions, toolInput, sessionId) {
2780
+ var recs = snap.gateRecords.filter(function (r) { return r.kind === 'requires_outcome'; });
2781
+ var file = sessionId ? intuticOutcomeStatePath(sessionId) : null;
2782
+ var read = file ? intuticOutcomeStateRead(file) : { exists: false, state: intuticRuleLib.emptyState() };
2783
+ var shadow = null;
2784
+ for (var i = 0; i < recs.length; i++) {
2785
+ var r = recs[i];
2786
+ if (nActions.indexOf(' ' + r.then + ' ') === -1 && String(r.then).toLowerCase() !== String(toolName).toLowerCase()) continue;
2787
+ var why = intuticOutcomeUnmet(r, sessionId, read.state);
2788
+ if (why === null) continue;
2789
+ var v = intuticRuleVerdict(r, 'PREREQUISITE_UNMET', 'requires_before.' + r.first + '[ok]' + (r.adjacent ? '~>' : '->') + r.then,
2790
+ why, { tools: [r.then], step: r.first });
2791
+ if (v.verdict === 'block') return v;
2792
+ if (!shadow) shadow = v;
2793
+ }
2794
+ if (file && read.exists && read.state) {
2795
+ intuticOutcomeStateWrite(file, intuticRuleLib.noteCall(read.state, intuticRuleLib.isFileWrite(toolName, toolInput)));
2796
+ }
2797
+ return shadow;
2798
+ }
2799
+
2800
+ /**
2801
+ * Records a tool's result, from the writer's post-tool handler: a shell call
2802
+ * that ran tests records its outcome for the session; a call that changed
2803
+ * files marks an earlier passing run stale. result: { exitCode?, isError?,
2804
+ * output? }. Never throws and never refuses.
2805
+ */
2806
+ function intuticRecordOutcome(sessionId, toolName, toolInput, result) {
2807
+ if (!INTUTIC_POST_OBSERVABLE || !sessionId) return;
2808
+ try {
2809
+ var input = toolInput && typeof toolInput === 'object' ? toolInput : {};
2810
+ var command = String(input.command || input.cmd || input.script || input.shell_command || '');
2811
+ var actions = intuticActions(toolName, command);
2812
+ var file = intuticOutcomeStatePath(sessionId);
2813
+ var read = intuticOutcomeStateRead(file);
2814
+ if (actions.indexOf(' action:run_tests ') !== -1) {
2815
+ var output = String(result && result.output != null ? result.output : '');
2816
+ var exitCode = result && typeof result.exitCode === 'number' ? result.exitCode : intuticRuleLib.exitCode(output);
2817
+ var d = intuticRuleLib.outcome({ exitCode: exitCode, isError: result ? result.isError : null, output: output });
2818
+ intuticOutcomeStateWrite(file, intuticRuleLib.recordOutcome(read.state || intuticRuleLib.emptyState(), 'action:run_tests', d, Date.now()));
2819
+ } else if (read.exists && read.state && intuticRuleLib.isFileWrite(toolName, input)) {
2820
+ intuticOutcomeStateWrite(file, intuticRuleLib.noteCall(read.state, true));
2821
+ }
2822
+ } catch (e) {}
2823
+ }
2824
+
2825
+ /** The real path of p, resolving its deepest existing ancestor when p does not exist yet. */
2826
+ function intuticRealPath(p) {
2827
+ var fs = require('fs'), path = require('path');
2828
+ var abs = path.resolve(p);
2829
+ try { return fs.realpathSync(abs); } catch (e) {}
2830
+ var rest = [], dir = abs;
2831
+ for (;;) {
2832
+ var parent = path.dirname(dir);
2833
+ rest.unshift(path.basename(dir));
2834
+ if (parent === dir) return abs;
2835
+ dir = parent;
2836
+ try { return path.join.apply(path, [fs.realpathSync(dir)].concat(rest)); } catch (e) {}
2837
+ }
2838
+ }
2839
+
2840
+ /** The workspace root of a call: its cwd walked up to the nearest .git, else the cwd. */
2841
+ function intuticRepoRoot(cwd) {
2842
+ var fs = require('fs'), path = require('path');
2843
+ var d = intuticRealPath(cwd);
2844
+ for (;;) {
2845
+ if (fs.existsSync(path.join(d, '.git'))) return d;
2846
+ var parent = path.dirname(d);
2847
+ if (parent === d) return intuticRealPath(cwd);
2848
+ d = parent;
2849
+ }
2850
+ }
2851
+
2852
+ /**
2853
+ * The session's approved plan from the daemon's cache, { tools }, or null:
2854
+ * missing, past its #expires, failing its digest or workspace check, or no
2855
+ * APPROVED or EXECUTING plan for the session.
2856
+ */
2857
+ function intuticApprovedPlan(sessionId, workspaceId) {
2858
+ var fs = require('fs'), os = require('os'), path = require('path');
2859
+ var file = process.env.INTUTIC_APPROVED_PLANS || path.join(os.homedir(), '.intutic', 'hooks', ${JSON.stringify(APPROVED_PLANS_FILE)});
2860
+ var raw;
2861
+ try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { return null; }
2862
+ var digest = '', ws = '', expires = '', body = [];
2863
+ raw.split('\\n').forEach(function (line) {
2864
+ if (line.indexOf('#digest ') === 0) digest = line.slice(8).trim();
2865
+ else if (line.indexOf('#workspace ') === 0) ws = line.slice(11).trim();
2866
+ else if (line.indexOf('#expires ') === 0) expires = line.slice(9).trim();
2867
+ else if (line && line.charAt(0) !== '#') body.push(line);
2868
+ });
2869
+ if (require('crypto').createHash('sha256').update(body.join('\\n')).digest('hex').slice(0, 32) !== digest) return null;
2870
+ if (ws && workspaceId && ws !== workspaceId) return null;
2871
+ var exp = Date.parse(expires);
2872
+ if (!exp || isNaN(exp) || Date.now() >= exp) return null;
2873
+ var tools = null;
2874
+ for (var i = 0; i < body.length; i++) {
2875
+ var e;
2876
+ try { e = JSON.parse(body[i]); } catch (x) { continue; }
2877
+ if (!e || e.sessionId !== sessionId || (e.state !== 'APPROVED' && e.state !== 'EXECUTING')) continue;
2878
+ tools = (tools || []).concat((Array.isArray(e.tools) ? e.tools : [])
2879
+ .filter(function (t) { return typeof t === 'string'; }).map(function (t) { return t.toLowerCase(); }));
2880
+ }
2881
+ return tools === null ? null : { tools: tools };
2882
+ }
2883
+
2884
+ /**
2885
+ * The trajectory tier: a file change outside the union of scope_paths
2886
+ * (OUT_OF_SCOPE), and under plan_approval a call before the session's plan is
2887
+ * approved (PLAN_NOT_APPROVED) or, strict, off its steps (OFF_PLAN).
2888
+ */
2889
+ function intuticTrajectoryTier(snap, toolName, toolInput, sessionId, workspaceId) {
2890
+ var path = require('path');
2891
+ var shadow = null;
2892
+ var scopes = snap.gateRecords.filter(function (r) { return r.kind === 'scope_paths'; });
2893
+ var targets = scopes.length > 0 ? intuticRuleLib.changeTargets(toolName, toolInput).map(function (t) { return t.target; }) : [];
2894
+ if (targets.length > 0) {
2895
+ var cwd = _intuticCallCwd || process.cwd();
2896
+ var root = intuticRepoRoot(cwd);
2897
+ var rels = targets.map(function (t) {
2898
+ var rel = path.relative(root, intuticRealPath(path.resolve(intuticRealPath(cwd), t)));
2899
+ var outside = rel === '..' || rel.indexOf('..' + path.sep) === 0 || path.isAbsolute(rel);
2900
+ return { t: t, rel: outside ? null : rel.split(path.sep).join('/') };
2901
+ });
2902
+ var rollouts = ['enforce', 'shadow'];
2903
+ for (var k = 0; k < rollouts.length; k++) {
2904
+ var group = scopes.filter(function (r) { return r.rollout === rollouts[k]; });
2905
+ if (group.length === 0) continue;
2906
+ var union = [];
2907
+ group.forEach(function (r) { union = union.concat(r.scopePaths); });
2908
+ var bad = rels.filter(function (x) { return x.rel === null || !intuticRuleLib.withinScope(x.rel, union); })
2909
+ .map(function (x) { return x.t; });
2910
+ if (bad.length === 0) continue;
2911
+ var v = intuticRuleVerdict(group[0], 'OUT_OF_SCOPE', 'scope_paths.' + group[0].sopTitle,
2912
+ bad.join(', ') + ' is outside scope_paths (' + union.join(', ') + ')', { paths: bad });
2913
+ if (v.verdict === 'block') return v;
2914
+ if (!shadow) shadow = v;
2915
+ }
2916
+ }
2917
+ var plans = snap.gateRecords.filter(function (r) { return r.kind === 'plan_approval'; });
2918
+ if (plans.length > 0 && !intuticRuleLib.planExempt(String(toolName))) {
2919
+ var prolls = ['enforce', 'shadow'];
2920
+ for (var j = 0; j < prolls.length; j++) {
2921
+ var pg = plans.filter(function (r) { return r.rollout === prolls[j]; });
2922
+ if (pg.length === 0) continue;
2923
+ var strict = pg.filter(function (r) { return r.mode === 'strict'; });
2924
+ var r = strict.length > 0 ? strict[0] : pg[0];
2925
+ var level = r.mode;
2926
+ var rid = 'plan_approval.' + r.sopTitle;
2927
+ var pscope = { tools: [String(toolName)] };
2928
+ var pv = null;
2929
+ if (!sessionId) {
2930
+ pv = intuticRuleVerdict(r, 'PLAN_NOT_APPROVED', rid, 'plan_approval: ' + level + ' - ' + INTUTIC_HARNESS + ' sent no session id, so no approved plan can be found for this call', pscope);
2931
+ } else {
2932
+ var plan = intuticApprovedPlan(sessionId, workspaceId);
2933
+ if (plan === null) {
2934
+ pv = intuticRuleVerdict(r, 'PLAN_NOT_APPROVED', rid, 'plan_approval: ' + level + ' - this session has no APPROVED or EXECUTING plan; get its plan approved, then retry', pscope);
2935
+ } else if (level === 'strict' && plan.tools.indexOf(String(toolName).toLowerCase()) === -1) {
2936
+ pv = intuticRuleVerdict(r, 'OFF_PLAN', rid, 'plan_approval: strict - ' + String(toolName) + ' is not a step of the approved plan', pscope);
2937
+ }
2938
+ }
2939
+ if (pv && pv.verdict === 'block') return pv;
2940
+ if (pv && !shadow) shadow = pv;
2941
+ }
2942
+ }
2943
+ return shadow;
2944
+ }
2945
+ `;
2946
+ }
1679
2947
  /**
1680
2948
  * The JavaScript gate body.
1681
2949
  *
@@ -1687,6 +2955,38 @@ function intuticChaosKey(toolName, command, target) {
1687
2955
  export function emitJsGate(opts) {
1688
2956
  const floor = staticFloorPatterns();
1689
2957
  const deadlineMs = gateDeadlineMs(opts.harness);
2958
+ // The most the decide tier spends on a call (decideBudgetMs in gateLimits.ts).
2959
+ const decideBudget = decideBudgetMs('js', opts.harness);
2960
+ const ask = opts.ask ?? 'none';
2961
+ /**
2962
+ * Asks a person through this harness's {@link AskContract}, for a `reason`
2963
+ * in scope. Exactly the documented fields: a harness that reads a decision
2964
+ * object may reject one with fields it does not know.
2965
+ */
2966
+ const askWith = () => {
2967
+ switch (ask) {
2968
+ case 'claude-pretooluse':
2969
+ return (` process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason } }) + '\\n');\n` +
2970
+ ` process.exit(0);`);
2971
+ case 'copilot-decision':
2972
+ // The Copilot CLI reads the decision at the top level, VS Code under
2973
+ // hookSpecificOutput.
2974
+ return (` process.stdout.write(JSON.stringify({ permissionDecision: 'ask', permissionDecisionReason: reason,\n` +
2975
+ ` hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: reason } }) + '\\n');\n` +
2976
+ ` process.exit(0);`);
2977
+ case 'cursor-permission':
2978
+ return (` process.stdout.write(JSON.stringify({ permission: 'ask', user_message: reason, agent_message: reason }) + '\\n');\n` +
2979
+ ` process.exit(0);`);
2980
+ case 'antigravity-force-ask':
2981
+ return ` process.stdout.write(JSON.stringify({ decision: 'force_ask', reason: reason }) + '\\n');\n process.exit(0);`;
2982
+ case 'throw-ask':
2983
+ return (` var _ask = new Error('[Intutic Governance] ASK: ' + reason);\n` +
2984
+ ` _ask.intuticAsk = { reason: reason, ruleId: snap.opaque ? null : dec.ruleId };\n` +
2985
+ ` throw _ask;`);
2986
+ default:
2987
+ throw new Error(`gateBody: the ${ask} ask contract is not one a JavaScript gate speaks`);
2988
+ }
2989
+ };
1690
2990
  /**
1691
2991
  * The refusal, through this harness's contract, for a `reason` in scope.
1692
2992
  * `code` and `ruleId` are JavaScript expressions: a JSON decision carries
@@ -1736,6 +3036,9 @@ export function emitJsGate(opts) {
1736
3036
  : opts.contract === 'throw'
1737
3037
  ? ` throw new Error(${code === 'null' ? `'[Intutic Governance] ' + reason` : 'reason'});`
1738
3038
  : ` process.exit(2);`);
3039
+ /** An exit-code harness's retry-scope line (§6.2), after the reason; none in OPAQUE. */
3040
+ const retryScopeStderr = ` const _retry = intuticRuleLib.retryLine(_mode, v.scope);\n` +
3041
+ ` if (_retry) { try { console.error(_retry); } catch (e) {} }\n`;
1739
3042
  return `
1740
3043
  // ── Intutic gate body v${GATE_VERSION} — harness: ${opts.harness} ────────────
1741
3044
  // Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
@@ -1750,10 +3053,16 @@ ${jsGuardTable('INTUTIC_FLOOR', floor)}
1750
3053
  // had already drifted from the in-process one on null handling.
1751
3054
  ${NORMALISE_CONTRACT.jsSource}
1752
3055
 
1753
- ${JS_SNAPSHOT_LOADER}
3056
+ ${JS_DECIDE_LIB}
3057
+
3058
+ ${JS_RULE_LIB}
3059
+
3060
+ ${jsSnapshotLoader(GATE_EVALUATES_DECIDE.js, Object.keys(GATE_EVALUATES_RULE_RECORDS.js).filter((k) => GATE_EVALUATES_RULE_RECORDS.js[k]), true)}
1754
3061
 
1755
3062
  ${jsHoldHelpers(opts.reviewRequestFile)}
1756
3063
 
3064
+ ${jsRuleTiers(opts.harness)}
3065
+
1757
3066
  ${JS_CHAOS_HELPERS}
1758
3067
 
1759
3068
  /**
@@ -1809,8 +3118,10 @@ ${MCP_ALLOWLIST_JS_SOURCE}
1809
3118
  ${MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE}
1810
3119
 
1811
3120
  /**
1812
- * Evaluates one tool call. Returns normally to allow; refuses via this harness's
1813
- * contract otherwise.
3121
+ * Evaluates one tool call's deterministic tiers: returns the call's tool name
3122
+ * (as the rules read it) and the snapshot to allow; refuses via this
3123
+ * harness's contract otherwise. {@link intuticGateWithDecide} and
3124
+ * {@link intuticGate} run it first.
1814
3125
  *
1815
3126
  * Takes its inputs as parameters rather than closing over the writer's locals:
1816
3127
  * the eight JS writers name and shape those differently, and a gate that
@@ -1823,7 +3134,7 @@ ${MCP_SNAPSHOT_UNVERIFIED_JS_SOURCE}
1823
3134
  * matchSopRule and the intutic-clawde gate port pin — and the gate serializes
1824
3135
  * it itself so no writer can hand it a differently-shaped string.
1825
3136
  */
1826
- function intuticGate(toolName, target, command, record, workspaceId, toolInput, sessionId, more) {
3137
+ function intuticGateDeterministic(toolName, target, command, record, workspaceId, toolInput, sessionId) {
1827
3138
  // A chaos run's record of this call (see intuticChaosDecide). A plan that is
1828
3139
  // set but unreadable refuses, as an unreadable payload does: an experiment
1829
3140
  // run without the faults it asked for reports a resilience it never measured.
@@ -1865,7 +3176,7 @@ ${opts.contract === 'throw'
1865
3176
  return _intuticGateRules(toolName, target, command, record, workspaceId, toolInput, sessionId);
1866
3177
  };
1867
3178
  try {
1868
- require('vm').runInThisContext('__intuticGateRules()', { timeout: _deadlineMs });
3179
+ return require('vm').runInThisContext('__intuticGateRules()', { timeout: _deadlineMs });
1869
3180
  } catch (err) {
1870
3181
  if (!err || err.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw err;
1871
3182
  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';
@@ -1873,10 +3184,14 @@ ${opts.contract === 'throw'
1873
3184
  try { record('tool_blocked', toolName, reason); } catch (e) {}
1874
3185
  ${refuseWith("'GATE_DEADLINE'", 'null')}
1875
3186
  }
3187
+ }
1876
3188
 
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.
3189
+ /**
3190
+ * Every tier allowed the call. A writer that runs the gate once per target of
3191
+ * one call (OpenClaw's apply_patch) passes more for all but the last, so the
3192
+ * call is reported, and faulted, once.
3193
+ */
3194
+ function intuticGateAllowed(toolName, target, command, more) {
1880
3195
  if (more) {
1881
3196
  if (_intuticChaosCall) _intuticChaosCall.more = true;
1882
3197
  return;
@@ -1893,6 +3208,164 @@ ${refuseFault("'HELD'")}
1893
3208
  ${refuseFault('null')}
1894
3209
  }
1895
3210
 
3211
+ // ── Decide tier (gate body v22) ──────────────────────────────────────────────
3212
+ // After every deterministic tier allowed the call, and only then: the
3213
+ // snapshot's decide rules on this tool, asked of the self-hosted engine
3214
+ // (~/.intutic/decide/engine.json, INTUTIC_DECIDE_* in the environment) within
3215
+ // ${decideBudget} ms, less what the deadline has left. A rule refuses, asks a person
3216
+ // or allows; an engine that cannot answer refuses an enforcing rule
3217
+ // (GOVERNANCE_UNAVAILABLE) and lets a shadow rule's call on. A rule whose
3218
+ // estimate (the engine's per-call p95 \`intutic doctor\` stored) does not fit
3219
+ // the time left is not asked: an enforcing one escalates.
3220
+
3221
+ /**
3222
+ * The engine, its URL, the canary cache and the stored p95, read for each call
3223
+ * that needs them, and the resolved configuration (cfg) for the decide log.
3224
+ */
3225
+ function intuticDecideDeps() {
3226
+ const fs = require('fs'), os = require('os'), path = require('path');
3227
+ const dir = path.join(os.homedir(), '.intutic', 'decide');
3228
+ var engine, url = '', q95 = null, parallel = 1, resolved = null;
3229
+ try {
3230
+ var file = null;
3231
+ try { file = JSON.parse(fs.readFileSync(path.join(dir, 'engine.json'), 'utf8')); } catch (e) {
3232
+ if (!e || e.code !== 'ENOENT') throw new intuticDecideLib.EngineError('not_configured', 'engine.json does not read: ' + String((e && e.message) || e));
3233
+ }
3234
+ q95 = intuticDecideLib.storedQ95(file);
3235
+ var cfg = intuticDecideLib.resolveConfig(file, process.env);
3236
+ resolved = cfg;
3237
+ url = cfg.url;
3238
+ parallel = cfg.parallel;
3239
+ engine = intuticDecideLib.createEngine(cfg);
3240
+ } catch (e) {
3241
+ engine = e instanceof intuticDecideLib.EngineError ? e : new intuticDecideLib.EngineError('not_configured', String((e && e.message) || e));
3242
+ }
3243
+ return { engine: engine, engineUrl: url, canary: intuticDecideLib.canaryStore(path.join(dir, 'canary.json'), fs),
3244
+ sha256Hex: function (t) { return require('crypto').createHash('sha256').update(t, 'utf8').digest('hex'); },
3245
+ q95Ms: q95, parallel: parallel, cfg: resolved };
3246
+ }
3247
+
3248
+ // The patterns the decide log's arguments excerpt is redacted with
3249
+ // (decideLogRedactionSources in @intutic/shared-types).
3250
+ const INTUTIC_DECIDE_REDACT = ${JSON.stringify(decideLogRedactionSources())};
3251
+
3252
+ /**
3253
+ * Appends this call's decide record to ~/.intutic/logs/decide-<date>.jsonl and
3254
+ * updates each rule's drift stats, as \`intutic decide check\` does for the
3255
+ * shell gates. Never throws: a log that cannot be written never changes the
3256
+ * decision.
3257
+ */
3258
+ function intuticDecideLog(ev, rules, call, deps) {
3259
+ try {
3260
+ const fs = require('fs'), os = require('os'), crypto = require('crypto');
3261
+ intuticDecideLib.writeLog(ev, rules, { atMs: Date.now(), harness: ${JSON.stringify(opts.harness)},
3262
+ workspaceId: call.workspaceId || '', sessionId: call.sessionId || '', tool: call.tool, toolInput: call.toolInput,
3263
+ cwd: call.cwd, budgetMs: call.budgetMs, latencyMs: Date.now() - call.startedMs,
3264
+ engine: intuticDecideLib.logEngine(deps.cfg, ev), redact: INTUTIC_DECIDE_REDACT },
3265
+ intuticDecideLib.logDeps(fs, os.homedir(), function (t) { return crypto.createHash('sha256').update(t, 'utf8').digest('hex'); }));
3266
+ } catch (e) {}
3267
+ }
3268
+
3269
+ /**
3270
+ * The decide tier's decision on a call the deterministic tiers allowed:
3271
+ * { outcome, code, ruleId, via, reason }, or null when no decide rule names
3272
+ * the tool. Records each shadow rule's finding. Never throws: a tier that
3273
+ * cannot finish is unavailable, which refuses unless every rule on the tool
3274
+ * is stamped shadow.
3275
+ */
3276
+ async function intuticDecideTier(toolName, toolInput, snap, record, startedMs, opts, workspaceId, sessionId) {
3277
+ const rules = snap.decideRules.filter(function (r) { return r.ir.on === 'tool_call' && (r.ir.tools || []).indexOf(toolName) !== -1; });
3278
+ if (rules.length === 0) return null;
3279
+ ${opts.contract === 'throw'
3280
+ ? ` var _elapsed = Date.now() - startedMs;`
3281
+ : ` var _elapsed = Math.round(process.uptime() * 1000);`}
3282
+ var _budget = Math.min(${decideBudget}, ${deadlineMs} - _elapsed - ${DECIDE_BUDGET_MARGIN_MS});
3283
+ if (_budget < ${DECIDE_MIN_CALL_MS}) {
3284
+ return intuticDecideLib.unavailable(rules, toolName, 'no time was left for the decide rules on ' + toolName + ' within the gate deadline');
3285
+ }
3286
+ var _timer = null, ev = null, _asked = Date.now();
3287
+ var _call = { tool: toolName, toolInput: toolInput == null ? {} : toolInput, cwd: (opts && opts.cwd) || process.cwd(),
3288
+ budgetMs: _budget, startedMs: _asked, workspaceId: workspaceId, sessionId: sessionId };
3289
+ var _deps = null;
3290
+ try {
3291
+ _deps = intuticDecideDeps();
3292
+ ev = await Promise.race([
3293
+ intuticDecideLib.evaluate({ deterministic: 'allow', tool: toolName, toolInput: _call.toolInput,
3294
+ cwd: _call.cwd, lastUserTurn: (opts && opts.lastUserTurn) || null, rules: rules,
3295
+ deadlineMs: _asked + _budget }, _deps),
3296
+ new Promise(function (resolve) { _timer = setTimeout(function () { resolve(null); }, _budget + ${DECIDE_BUDGET_MARGIN_MS / 2}); }),
3297
+ ]);
3298
+ } catch (e) {
3299
+ ev = null;
3300
+ } finally {
3301
+ if (_timer) clearTimeout(_timer);
3302
+ }
3303
+ if (!ev) return intuticDecideLib.unavailable(rules, toolName, 'the decide rules on ' + toolName + ' were not evaluated within ' + _budget + ' ms');
3304
+ intuticDecideLog(ev, rules, _call, _deps);
3305
+ for (const f of intuticDecideLib.shadowFindings(ev, rules)) {
3306
+ try { record('tool_would_block', toolName, f.reason + ' [' + f.ruleId + ']'); } catch (e) {}
3307
+ }
3308
+ return intuticDecideLib.decision(ev, rules);
3309
+ }
3310
+
3311
+ /**
3312
+ * Acts on a decide decision that is not an allow: asks a person through this
3313
+ * harness's ask where it has one and the call can ask (\`canAsk\`), and
3314
+ * otherwise refuses, an escalation with DECIDE_ESCALATED.
3315
+ */
3316
+ function intuticDecideAct(dec, toolName, snap, record, canAsk) {
3317
+ var _full = dec.reason + (dec.ruleId ? ' [' + dec.ruleId + ']' : '');
3318
+ ${ask === 'none'
3319
+ ? ` // ${opts.harness} cannot ask a person on this path, so an escalation refuses.`
3320
+ : ` if (dec.outcome === 'escalate' && canAsk) {
3321
+ var reason = intuticShown(snap, _full);
3322
+ try { console.error('[Intutic Governance] ASK: ' + reason); } catch (e) {}
3323
+ try { record('tool_flagged', toolName, 'DECIDE_ESCALATED — asked a person: ' + _full); } catch (e) {}
3324
+ intuticChaosDecide(toolName, 'hold', dec.ruleId, dec.ruleId != null);
3325
+ ${askWith()}
3326
+ }`}
3327
+ var reason = intuticShown(snap, _full);
3328
+ try { console.error('[Intutic Governance] BLOCKED: ' + reason); } catch (e) {}
3329
+ try { record('tool_blocked', toolName, _full); } catch (e) {}
3330
+ ${refuseWith('dec.code', 'dec.ruleId', '', 'toolName', '(snap.opaque ? null : dec.ruleId)')}
3331
+ }
3332
+
3333
+ /**
3334
+ * Evaluates one tool call: the deterministic tiers, then the decide tier.
3335
+ * Resolves to allow; refuses, or asks, via this harness's contracts.
3336
+ * \`opts\`: \`more\` (as {@link intuticGateAllowed}), \`canAsk\` (false where
3337
+ * this call's hook cannot ask), \`cwd\`, \`lastUserTurn\`, and \`before\`, the
3338
+ * writer's own deterministic checks, run after the shared ones and before
3339
+ * any engine call.
3340
+ */
3341
+ async function intuticGateWithDecide(toolName, target, command, record, workspaceId, toolInput, sessionId, opts) {
3342
+ opts = opts || {};
3343
+ var _started = Date.now();
3344
+ _intuticCallCwd = opts.cwd || null;
3345
+ var _d = intuticGateDeterministic(toolName, target, command, record, workspaceId, toolInput, sessionId);
3346
+ if (typeof opts.before === 'function') opts.before();
3347
+ var _dec = await intuticDecideTier(_d.toolName, toolInput, _d.snap, record, _started, opts, workspaceId, sessionId);
3348
+ if (_dec && _dec.outcome !== 'allow') intuticDecideAct(_dec, _d.toolName, _d.snap, record, opts.canAsk !== false);
3349
+ intuticGateAllowed(toolName, target, command, opts.more);
3350
+ }
3351
+
3352
+ /**
3353
+ * The deterministic tiers alone, synchronously. It cannot ask the decide
3354
+ * engine, so a call a decide rule names is refused here, unless every such
3355
+ * rule is stamped shadow: a caller that has not moved to
3356
+ * {@link intuticGateWithDecide} never opens what the rule's skipped fallback
3357
+ * line would have refused. \`more\` as {@link intuticGateAllowed}.
3358
+ */
3359
+ function intuticGate(toolName, target, command, record, workspaceId, toolInput, sessionId, more) {
3360
+ _intuticCallCwd = null;
3361
+ var _d = intuticGateDeterministic(toolName, target, command, record, workspaceId, toolInput, sessionId);
3362
+ if (!more) {
3363
+ var _dec = intuticDecideLib.unavailable(_d.snap.decideRules, _d.toolName, 'this gate entry cannot evaluate the decide rules on ' + _d.toolName);
3364
+ if (_dec.outcome !== 'allow') intuticDecideAct(_dec, _d.toolName, _d.snap, record, false);
3365
+ }
3366
+ intuticGateAllowed(toolName, target, command, more);
3367
+ }
3368
+
1896
3369
  /**
1897
3370
  * The subjects of this call a rule matches, before its argument condition:
1898
3371
  * for a phrase rule, the command when its phrases are in it. The context c
@@ -1904,7 +3377,7 @@ function intuticRuleSubjects(rule, c) {
1904
3377
  // the raw command — never as a regex, which backtracks on crafted input.
1905
3378
  if (rule.subject === 'phrase') {
1906
3379
  if (c.words === null) c.words = phraseText(c.command);
1907
- return intuticPhraseRule(rule.re.source, c.words) ? [c.nCommand] : [];
3380
+ return intuticPhraseRule(rule.src != null ? rule.src : rule.re.source, c.words) ? [c.nCommand] : [];
1908
3381
  }
1909
3382
  // Each field is tested separately rather than concatenated — joining them
1910
3383
  // lets a pattern match across the seam between two innocuous values.
@@ -1969,6 +3442,9 @@ function _intuticGateRules(toolName, target, command, record, workspaceId, toolI
1969
3442
  : 'Policy snapshot is ' + snap.ageDays + ' days old and still enforced';
1970
3443
  try { record('snapshot_' + snap.state, toolName, _msg); } catch (e) {}
1971
3444
  }
3445
+ if (snap.format > ${GATE_READS_FORMAT}) {
3446
+ try { record('snapshot_format_newer', toolName, ${JSON.stringify(FORMAT_NEWER_MESSAGE)}.replace('{n}', String(snap.format))); } catch (e) {}
3447
+ }
1972
3448
  // A chaos run's no_destructive_attempt probe reads every destructive.* rule,
1973
3449
  // the ones the escape hatch skips below included.
1974
3450
  const _chaosDestructive = intuticChaos().plan
@@ -1997,7 +3473,31 @@ function _intuticGateRules(toolName, target, command, record, workspaceId, toolI
1997
3473
  }
1998
3474
  }
1999
3475
  const rules = INTUTIC_FLOOR.concat(snap.rules);
3476
+ // A rule record's verdict (gate body v23): shadow records the finding,
3477
+ // block refuses through this harness's contract.
3478
+ const _intuticRuleAct = function (v) {
3479
+ if (!v) return;
3480
+ if (v.verdict === 'shadow') {
3481
+ intuticChaosNote(v.ruleId, false);
3482
+ try { record('tool_would_block', toolName, v.reason + ' [' + v.ruleId + ']'); } catch (e) {}
3483
+ return;
3484
+ }
3485
+ const _full = v.reason + ' [' + v.ruleId + ']';
3486
+ const reason = intuticShown(snap, _full);
3487
+ // Where the refusal applies: bounded, and dropped in an
3488
+ // OPAQUE workspace, as the rule is.
3489
+ const _mode = snap.opaque ? 'OPAQUE' : 'TRANSPARENT';
3490
+ const _detail = intuticRuleLib.detail(_mode, v.scope, null);
3491
+ try { console.error('[Intutic Governance] BLOCKED: ' + reason + ' (refusal code ' + v.code + ')'); } catch (e) {}
3492
+ ${opts.contract === 'exit2' ? retryScopeStderr : ''} try { record('tool_blocked', toolName, _full); } catch (e) {}
3493
+ ${refuseWith('v.code', 'v.ruleId', 'scope: _detail.scope, evidence: _detail.evidence', 'toolName', '(snap.opaque ? null : v.ruleId)')}
3494
+ };
3495
+ // The tier order (gate body v23): every refusal first ('det'), the holds
3496
+ // after the rule records and the MCP checks ('hold'). A hold is only ever
3497
+ // decided on a call nothing deterministic refused.
3498
+ const _intuticApplyRules = function (pass) {
2000
3499
  for (const rule of rules) {
3500
+ if (pass === 'det' ? rule.severity === 'hold' : rule.severity !== 'hold') continue;
2001
3501
  for (const subject of intuticRuleSubjects(rule, ctx)) {
2002
3502
  // The argument condition of a WHERE rule: the tool-name half has
2003
3503
  // matched, and the rule fires only if the argPattern also matches the
@@ -2041,7 +3541,7 @@ ${refuseWith("'HELD'", 'rule.id', 'holdId: held.holdId', 'toolName', '(snap.opaq
2041
3541
  // The rollback rung's storage half: this call was flagged and is about
2042
3542
  // to proceed, which is the only instant the prior bytes still exist.
2043
3543
  // No-op unless the workspace opted in.
2044
- try { _intuticCapturePreImage(toolName, toolInput, rule.id); } catch (e) {}
3544
+ try { _intuticCapturePreImage(toolName, toolInput, rule.id, nActions); } catch (e) {}
2045
3545
  continue;
2046
3546
  }
2047
3547
  // The rule's own reason, not a generic one: hookEvents.resolveSeverity
@@ -2053,6 +3553,10 @@ ${refuseWith("'HELD'", 'rule.id', 'holdId: held.holdId', 'toolName', '(snap.opaq
2053
3553
  ${refuseWith('intuticRuleCode(rule)', 'rule.id', '', 'toolName', '(snap.opaque ? null : rule.id)')}
2054
3554
  }
2055
3555
  }
3556
+ };
3557
+ _intuticApplyRules('det');
3558
+ // requires_outcome (gate body v23), among the deterministic refusals.
3559
+ _intuticRuleAct(intuticOutcomeTier(snap, toolName, nActions, toolInput, sessionId));
2056
3560
 
2057
3561
  // An unverified snapshot admits no MCP server (gate body v17): neither
2058
3562
  // record in it can be vouched for, and a deleted one looks like one never
@@ -2110,6 +3614,12 @@ ${refuseWith('_mcp.code', '_mcp.ruleId', '', 'toolName', '(snap.opaque ? null :
2110
3614
  }
2111
3615
  }
2112
3616
  }
3617
+ // The holds, after every refusal; then the trajectory tier (gate body v23).
3618
+ _intuticApplyRules('hold');
3619
+ _intuticRuleAct(intuticTrajectoryTier(snap, toolName, toolInput, sessionId, workspaceId || ''));
3620
+ // Allowed by every deterministic tier: the decide tier reads the tool name
3621
+ // as the rules did, and the snapshot's decide rules.
3622
+ return { toolName: toolName, snap: snap };
2113
3623
  }
2114
3624
  // ── end Intutic gate body ────────────────────────────────────────────────────
2115
3625
  `;
@@ -2187,7 +3697,7 @@ function _intuticTargetPath(input) {
2187
3697
  * convenience layered on top of it, and inverting that would make the feature
2188
3698
  * a new way to break the user's workflow.
2189
3699
  */
2190
- function _intuticCapturePreImage(toolName, input, ruleId) {
3700
+ function _intuticCapturePreImage(toolName, input, ruleId, actions) {
2191
3701
  try {
2192
3702
  if (!_intuticRollbackEnabled()) return;
2193
3703
  const rel = _intuticTargetPath(input);
@@ -2253,6 +3763,11 @@ function _intuticCapturePreImage(toolName, input, ruleId) {
2253
3763
  ruleId: ruleId || '',
2254
3764
  workspaceId: _intuticWsId,
2255
3765
  };
3766
+ // The action tokens the gate classified this call to:
3767
+ // intutic rollback sends them on tool_reverted, so a revert counts
3768
+ // against the action and not only the tool.
3769
+ var _acts = String(actions || '').split(' ').filter(function (t) { return t.indexOf('action:') === 0; });
3770
+ if (_acts.length > 0) entry.actions = _acts;
2256
3771
  fs.appendFileSync(
2257
3772
  path.join(_INTUTIC_ROLLBACK_DIR, 'manifest.jsonl'),
2258
3773
  JSON.stringify(entry) + '\\n',
@@ -2332,7 +3847,11 @@ export function emitJsFailClosedPrelude(opts) {
2332
3847
  // stdout-cancel harnesses, also an allow. Everything that executes after this
2333
3848
  // line is covered; a SyntaxError in this file itself is the one thing that is
2334
3849
  // not, because no statement in a file that does not parse ever runs.
3850
+ // A post-tool invocation (--event post, gate body v23) records a tool's result
3851
+ // and never refuses: the tool has already run. Every exit is 0 there.
3852
+ var _intuticPostMode = process.argv[2] === '--event' && process.argv[3] === 'post';
2335
3853
  function _intuticCrash(err) {
3854
+ if (_intuticPostMode) process.exit(0);
2336
3855
  ${crashBody}
2337
3856
  }
2338
3857
  process.on('uncaughtException', _intuticCrash);
@@ -2354,6 +3873,8 @@ process.on('unhandledRejection', _intuticCrash);`;
2354
3873
  */
2355
3874
  export const SHELL_EXTRACT = `
2356
3875
  if ! command -v python3 >/dev/null 2>&1; then
3876
+ # A post-tool invocation records nothing without python3, and never refuses.
3877
+ if [ "\${INTUTIC_POST_MODE:-0}" = "1" ]; then exit 0; fi
2357
3878
  echo "[Intutic Governance] BLOCKED: python3 is required to evaluate tool calls and was not found on PATH." >&2
2358
3879
  echo "[Intutic Governance] Install python3, or unset the Intutic hooks to run ungoverned." >&2
2359
3880
  exit 2
@@ -2690,6 +4211,12 @@ def _intutic_evaluate(text):
2690
4211
  */
2691
4212
  export function emitN8nWorkflowGate() {
2692
4213
  const floor = staticFloorPatterns();
4214
+ // The decide tier's budget and deadline (decideBudgetMs in gateLimits.ts): the host waits
4215
+ // without a limit, so the deadline is a hook's under a 10 s timeout.
4216
+ const budget = decideBudgetMs('js', null);
4217
+ const deadline = HOOK_TIMEOUT_SECONDS * 1000 - GATE_DEADLINE_MARGIN_MS;
4218
+ const margin = DECIDE_BUDGET_MARGIN_MS;
4219
+ const minCall = DECIDE_MIN_CALL_MS;
2693
4220
  return `
2694
4221
  // ── Intutic gate body v${GATE_VERSION} — harness: n8n (workflow-level) ───────
2695
4222
  // Generated by harness/gateBody.ts. Do not edit here; every harness regenerates.
@@ -2707,7 +4234,9 @@ ${NORMALISE_CONTRACT.jsSource}
2707
4234
  // The phrase matcher (@intutic/shared-types phrases.ts), for phrase rules.
2708
4235
  ${PHRASES_JS_SOURCE}
2709
4236
 
2710
- ${JS_SNAPSHOT_LOADER}
4237
+ ${JS_DECIDE_LIB}
4238
+
4239
+ ${jsSnapshotLoader(GATE_EVALUATES_DECIDE.n8n)}
2711
4240
 
2712
4241
  ${JS_OPAQUE_HELPERS}
2713
4242
 
@@ -2743,6 +4272,9 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
2743
4272
  : 'Policy snapshot is ' + snap.ageDays + ' days old and still enforced';
2744
4273
  try { record('snapshot_' + snap.state, 'n8n:' + wfName, _msg); } catch (e) {}
2745
4274
  }
4275
+ if (snap.format > ${GATE_READS_FORMAT}) {
4276
+ try { record('snapshot_format_newer', 'n8n:' + wfName, ${JSON.stringify(FORMAT_NEWER_MESSAGE)}.replace('{n}', String(snap.format))); } catch (e) {}
4277
+ }
2746
4278
  if (disabled) snap.rules = snap.rules.filter(function (r) { return r.id.indexOf('destructive.') !== 0; });
2747
4279
  const rules = INTUTIC_FLOOR.concat(snap.rules);
2748
4280
 
@@ -2824,7 +4356,7 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
2824
4356
  // parameter string — never as a regex, which backtracks on crafted input.
2825
4357
  if (rule.subject === 'phrase') {
2826
4358
  if (leafWords === null) leafWords = _leaves.map(phraseText);
2827
- var _phrases = rule.re.source.split('|');
4359
+ var _phrases = (rule.src != null ? rule.src : rule.re.source).split('|');
2828
4360
  if (!leafWords.some(function (w) { return _phrases.some(function (p) { return hasPhrase(w, p, true); }); })) continue;
2829
4361
  }
2830
4362
  // A node has no separate command/target — its parameters are both. A
@@ -2866,6 +4398,106 @@ function intuticGateWorkflow(workflow, record, workspaceId) {
2866
4398
  }
2867
4399
  }
2868
4400
  }
4401
+ // Allowed by every deterministic tier: the decide tier reads the nodes and
4402
+ // the snapshot's decide rules.
4403
+ return { snap: snap, nodes: nodes };
4404
+ }
4405
+
4406
+ // ── Decide tier (gate body v22) ──────────────────────────────────────────────
4407
+ // After every deterministic tier allowed the workflow, and only then: each
4408
+ // node whose type (or its basename, as the tool rules read it) a decide rule
4409
+ // names goes to \`intutic decide check --stdin\`, which asks the self-hosted
4410
+ // engine within ${budget} ms less what the deadline has left. n8n cannot ask
4411
+ // a person, so an escalation refuses (DECIDE_ESCALATED). A CLI that is
4412
+ // missing, fails, times out or answers what this gate cannot read is
4413
+ // unavailable, which refuses unless every rule on the node is stamped shadow.
4414
+
4415
+ const INTUTIC_DECIDE_CODES = { allow: [null], refuse: ['DECIDE_REFUSED', 'DECIDE_ESCALATED', 'GOVERNANCE_UNAVAILABLE'], escalate: ['DECIDE_ESCALATED'] };
4416
+
4417
+ /** Unavailable: a refusal unless every rule on the tool is stamped shadow, whose allow keeps the reason to record. */
4418
+ function intuticDecideUnavailable(rules, tool, why) {
4419
+ const d = intuticDecideLib.unavailable(rules, tool, why);
4420
+ if (d.outcome === 'allow') d.reason = why;
4421
+ return d;
4422
+ }
4423
+
4424
+ /** One node's decide check through the CLI: a decision { outcome, code, ruleId, reason }. Never throws. */
4425
+ function intuticDecideCheck(tool, toolInput, budgetMs, rules) {
4426
+ const unavailable = function (why) { return intuticDecideUnavailable(rules, tool, why); };
4427
+ return new Promise(function (resolve) {
4428
+ var child, out = '', done = false, timer = null;
4429
+ const finish = function (d) {
4430
+ if (done) return;
4431
+ done = true;
4432
+ if (timer) clearTimeout(timer);
4433
+ resolve(d);
4434
+ };
4435
+ try {
4436
+ child = require('child_process').spawn('intutic', ['decide', 'check', '--stdin'], { stdio: ['pipe', 'pipe', 'ignore'] });
4437
+ } catch (e) {
4438
+ return finish(unavailable('intutic decide check could not run (' + String((e && e.code) || e) + ')'));
4439
+ }
4440
+ timer = setTimeout(function () {
4441
+ try { child.kill('SIGKILL'); } catch (e) {}
4442
+ finish(unavailable('intutic decide check did not answer within ' + budgetMs + ' ms'));
4443
+ }, budgetMs);
4444
+ child.on('error', function (e) {
4445
+ finish(unavailable(e && e.code === 'ENOENT'
4446
+ ? 'the decide rules on ' + tool + ' need the intutic CLI, which is not on PATH'
4447
+ : 'intutic decide check could not run (' + String((e && e.code) || e) + ')'));
4448
+ });
4449
+ child.stdout.on('data', function (c) { out += c; });
4450
+ child.on('close', function (code) {
4451
+ if (code !== 0) return finish(unavailable('intutic decide check exited ' + code));
4452
+ var r = null;
4453
+ try { r = JSON.parse(out); } catch (e) {}
4454
+ const ok = r && typeof r === 'object' && r.v === 1 && Object.prototype.hasOwnProperty.call(INTUTIC_DECIDE_CODES, r.outcome) &&
4455
+ INTUTIC_DECIDE_CODES[r.outcome].indexOf(r.code === undefined ? null : r.code) !== -1 &&
4456
+ (r.ruleId === null || r.ruleId === undefined || (typeof r.ruleId === 'string' && r.ruleId.indexOf('decide.') === 0)) &&
4457
+ (r.reason === undefined || typeof r.reason === 'string');
4458
+ if (!ok) return finish(unavailable('intutic decide check gave an answer this gate does not read'));
4459
+ finish({ outcome: r.outcome, code: r.code || null, ruleId: r.ruleId || null, reason: String(r.reason || '') });
4460
+ });
4461
+ child.stdin.on('error', function () {});
4462
+ child.stdin.end(JSON.stringify({ v: 1, harness: 'n8n', tool: tool, toolInput: toolInput, cwd: process.cwd(), sessionId: '',
4463
+ budgetMs: budgetMs, lastUserTurn: null }));
4464
+ });
4465
+ }
4466
+
4467
+ /**
4468
+ * Evaluates one workflow: the deterministic tiers, then each node a decide
4469
+ * rule names. Resolves to allow; throws to abort the execution.
4470
+ */
4471
+ async function intuticGateWorkflowWithDecide(workflow, record, workspaceId) {
4472
+ const started = Date.now();
4473
+ const r = intuticGateWorkflow(workflow, record, workspaceId);
4474
+ if (r.snap.decideRules.length === 0) return;
4475
+ for (const node of r.nodes) {
4476
+ if (!node || typeof node !== 'object') continue;
4477
+ const nodeType = String(node.type || '');
4478
+ const nodeName = String(node.name || nodeType || 'node');
4479
+ const base = nodeType.indexOf('.') >= 0 ? nodeType.slice(nodeType.lastIndexOf('.') + 1) : nodeType;
4480
+ var tool = null;
4481
+ for (const name of [nodeType, base]) {
4482
+ if (!tool && r.snap.decideRules.some(function (d) { return d.ir.on === 'tool_call' && (d.ir.tools || []).indexOf(name) !== -1; })) tool = name;
4483
+ }
4484
+ if (!tool) continue;
4485
+ const rules = r.snap.decideRules.filter(function (d) { return d.ir.on === 'tool_call' && (d.ir.tools || []).indexOf(tool) !== -1; });
4486
+ const budget = Math.min(${budget}, ${deadline} - (Date.now() - started) - ${margin});
4487
+ const dec = budget < ${minCall}
4488
+ ? intuticDecideUnavailable(rules, tool, 'no time was left for the decide rules on ' + tool + ' within the gate deadline')
4489
+ : await intuticDecideCheck(tool, node.parameters == null ? {} : node.parameters, budget, rules);
4490
+ if (dec.outcome === 'allow') {
4491
+ // A shadow rule whose engine could not answer: recorded, and the workflow goes on.
4492
+ if (dec.reason) { try { record('tool_would_block', 'n8n:' + nodeName, 'GOVERNANCE_UNAVAILABLE — ' + dec.reason); } catch (e) {} }
4493
+ continue;
4494
+ }
4495
+ const reason = dec.reason + (dec.ruleId ? ' [' + dec.ruleId + ']' : '') + ' (node "' + nodeName + '", type ' + nodeType + ')';
4496
+ const shown = r.snap.opaque ? INTUTIC_OPAQUE_MESSAGE + ' (node "' + nodeName + '")' : reason;
4497
+ try { console.error('[Intutic Governance] BLOCKED: ' + shown + ' (refusal code ' + (dec.code || 'DECIDE_ESCALATED') + ')'); } catch (e) {}
4498
+ try { record('tool_blocked', 'n8n:' + nodeName, reason); } catch (e) {}
4499
+ throw new Error('[Intutic Governance] BLOCKED: ' + shown);
4500
+ }
2869
4501
  }
2870
4502
  // ── end Intutic gate body ────────────────────────────────────────────────────
2871
4503
  `;