@intutic/sync-daemon 2.9.1 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/disconnect/harnesses.d.ts.map +1 -1
  2. package/dist/disconnect/harnesses.js +3 -2
  3. package/dist/disconnect/harnesses.js.map +1 -1
  4. package/dist/harness/antigravityCliHooks.js +3 -3
  5. package/dist/harness/antigravityCliHooks.js.map +1 -1
  6. package/dist/harness/antigravityHooks.d.ts +8 -0
  7. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  8. package/dist/harness/antigravityHooks.js +27 -5
  9. package/dist/harness/antigravityHooks.js.map +1 -1
  10. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  11. package/dist/harness/claudeCodeHooks.js +58 -16
  12. package/dist/harness/claudeCodeHooks.js.map +1 -1
  13. package/dist/harness/clineHooks.d.ts.map +1 -1
  14. package/dist/harness/clineHooks.js +4 -2
  15. package/dist/harness/clineHooks.js.map +1 -1
  16. package/dist/harness/codexHooks.d.ts.map +1 -1
  17. package/dist/harness/codexHooks.js +4 -2
  18. package/dist/harness/codexHooks.js.map +1 -1
  19. package/dist/harness/cursorHooks.d.ts.map +1 -1
  20. package/dist/harness/cursorHooks.js +8 -3
  21. package/dist/harness/cursorHooks.js.map +1 -1
  22. package/dist/harness/gateBody.d.ts +177 -4
  23. package/dist/harness/gateBody.d.ts.map +1 -1
  24. package/dist/harness/gateBody.js +1658 -26
  25. package/dist/harness/gateBody.js.map +1 -1
  26. package/dist/harness/githubCopilotHooks.js +3 -3
  27. package/dist/harness/githubCopilotHooks.js.map +1 -1
  28. package/dist/harness/gooseHooks.d.ts.map +1 -1
  29. package/dist/harness/gooseHooks.js +8 -1
  30. package/dist/harness/gooseHooks.js.map +1 -1
  31. package/dist/harness/grokHooks.js +2 -2
  32. package/dist/harness/hermesHooks.js +1 -1
  33. package/dist/harness/hermesHooks.js.map +1 -1
  34. package/dist/harness/museHooks.d.ts.map +1 -1
  35. package/dist/harness/museHooks.js +4 -2
  36. package/dist/harness/museHooks.js.map +1 -1
  37. package/dist/harness/n8nHooks.js +1 -1
  38. package/dist/harness/openCodeHooks.d.ts.map +1 -1
  39. package/dist/harness/openCodeHooks.js +32 -8
  40. package/dist/harness/openCodeHooks.js.map +1 -1
  41. package/dist/harness/openclawHooks.d.ts.map +1 -1
  42. package/dist/harness/openclawHooks.js +19 -8
  43. package/dist/harness/openclawHooks.js.map +1 -1
  44. package/dist/harness/piHooks.js +7 -7
  45. package/dist/harness/windsurfHooks.js +2 -2
  46. package/dist/index.d.ts +9 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +8 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/lib/approvedBypasses.d.ts +17 -4
  51. package/dist/lib/approvedBypasses.d.ts.map +1 -1
  52. package/dist/lib/approvedBypasses.js +17 -4
  53. package/dist/lib/approvedBypasses.js.map +1 -1
  54. package/dist/lib/approvedPlans.d.ts +73 -0
  55. package/dist/lib/approvedPlans.d.ts.map +1 -0
  56. package/dist/lib/approvedPlans.js +124 -0
  57. package/dist/lib/approvedPlans.js.map +1 -0
  58. package/dist/lib/decideLogs.d.ts +46 -0
  59. package/dist/lib/decideLogs.d.ts.map +1 -0
  60. package/dist/lib/decideLogs.js +160 -0
  61. package/dist/lib/decideLogs.js.map +1 -0
  62. package/dist/lib/policySnapshot.d.ts +143 -3
  63. package/dist/lib/policySnapshot.d.ts.map +1 -1
  64. package/dist/lib/policySnapshot.js +480 -14
  65. package/dist/lib/policySnapshot.js.map +1 -1
  66. package/dist/lib/refusalDetailPy.d.ts +11 -0
  67. package/dist/lib/refusalDetailPy.d.ts.map +1 -0
  68. package/dist/lib/refusalDetailPy.js +109 -0
  69. package/dist/lib/refusalDetailPy.js.map +1 -0
  70. package/dist/lib/ruleRecordPy.d.ts +11 -0
  71. package/dist/lib/ruleRecordPy.d.ts.map +1 -0
  72. package/dist/lib/ruleRecordPy.js +277 -0
  73. package/dist/lib/ruleRecordPy.js.map +1 -0
  74. package/dist/lib/scopeMatchPy.d.ts +11 -0
  75. package/dist/lib/scopeMatchPy.d.ts.map +1 -0
  76. package/dist/lib/scopeMatchPy.js +278 -0
  77. package/dist/lib/scopeMatchPy.js.map +1 -0
  78. package/dist/lib/testOutcomePy.d.ts +11 -0
  79. package/dist/lib/testOutcomePy.d.ts.map +1 -0
  80. package/dist/lib/testOutcomePy.js +371 -0
  81. package/dist/lib/testOutcomePy.js.map +1 -0
  82. package/dist/skillWriter.d.ts +1 -1
  83. package/dist/skillWriter.d.ts.map +1 -1
  84. package/dist/skillWriter.js +7 -0
  85. package/dist/skillWriter.js.map +1 -1
  86. package/dist/syncCycle.d.ts +15 -2
  87. package/dist/syncCycle.d.ts.map +1 -1
  88. package/dist/syncCycle.js +15 -3
  89. package/dist/syncCycle.js.map +1 -1
  90. package/package.json +5 -5
@@ -55,11 +55,13 @@
55
55
  * @module
56
56
  */
57
57
  import * as fs from 'node:fs/promises';
58
+ import { existsSync } from 'node:fs';
58
59
  import * as path from 'node:path';
59
60
  import * as os from 'node:os';
60
61
  import { createHash } from 'node:crypto';
61
62
  import { createLogger } from '@intutic/logger';
62
- import { encodeInterventionModeRecord, encodeMcpAllowlistRecord, isOpaqueInterventionMode, encodeMcpRegistryRecord, encodeSsoGroupRecord, evaluateSsoGroupClearance, isUnrestrictedMcpRegistry, parseMcpRegistryRecord, parseSsoGroupPolicy, } from '@intutic/shared-types';
63
+ import { DECIDE_FALLBACK_PREFIX, DECIDE_SOP_TITLE_MAX_CHARS, TOOL_TOKEN_RE, decideFallbackId, decideFallbackReason, decideRuleHash, decideRuleRef, decideRuleText, encodeDecideRecord, SNAPSHOT_FORMAT_RECORD_TAG, parseDecideLine, parseDecideLines, parseDecidePin, scrubReason, splitFrontMatter, parseFrontMatterEnforcing, encodeInterventionModeRecord, encodeMcpAllowlistRecord, isOpaqueInterventionMode, encodeMcpRegistryRecord, encodeSsoGroupRecord, evaluateSsoGroupClearance, isUnrestrictedMcpRegistry, parseMcpRegistryRecord, parseSsoGroupPolicy, } from '@intutic/shared-types';
64
+ import { RULE_SNAPSHOT_FORMAT, compileSopGateRecords, encodeGateRuleRecord, fromServedGateRecord, gateRuleFallback, laneHoldPatterns as laneHoldPatternSpecs, laneHoldRuleId, laneHoldTokens, toServedGateRecord, tokenLane, } from '@intutic/shared-types';
63
65
  import { toRulesLine, GATE_VERSION, RULES_COLUMNS } from '../harness/gateBody.js';
64
66
  import { writeFileAtomic } from './atomicWrite.js';
65
67
  import { DESTRUCTIVE_COMMAND_PATTERNS, SKILL_CONTENT_PATTERNS, SKILL_SURFACE_PATTERNS, assertPortableEre, } from '../harness/protectedPaths.js';
@@ -368,6 +370,261 @@ function parsePrincipal(value) {
368
370
  ssoGroups: Array.isArray(p.ssoGroups) ? p.ssoGroups.filter((g) => typeof g === 'string') : [],
369
371
  };
370
372
  }
373
+ /**
374
+ * The `decideRules` of a resolve response, or of a snapshot's JSON. Every
375
+ * object is kept, with its wrongly-typed fields defaulted: a rule dropped
376
+ * here would be a rule no gate refuses for. Invalid pins are dropped, which
377
+ * can only stamp a rule shadow.
378
+ */
379
+ function parseServedDecideRules(value) {
380
+ if (!Array.isArray(value))
381
+ return [];
382
+ const text = (v) => (typeof v === 'string' ? v : '');
383
+ return value
384
+ .filter((r) => typeof r === 'object' && r !== null && !Array.isArray(r))
385
+ .map((r) => ({
386
+ sopId: text(r.sopId),
387
+ sopTitle: text(r.sopTitle),
388
+ ruleText: text(r.ruleText),
389
+ ir: r.ir,
390
+ ...(r.rollout === 'shadow' || r.rollout === 'enforce' ? { rollout: r.rollout } : {}),
391
+ ...(typeof r.ruleHash === 'string' ? { ruleHash: r.ruleHash } : {}),
392
+ pins: Array.isArray(r.pins) ? r.pins.filter((p) => parseDecidePin(p) !== null) : [],
393
+ }));
394
+ }
395
+ /**
396
+ * The `gateRecords` of a resolve response, or of a snapshot's JSON, in the
397
+ * served shape (`ServedGateRecord`, `@intutic/shared-types`). A record is
398
+ * kept when it has that shape; one that then does not decode is not written
399
+ * as an `@rule` line, and its fallback line refuses in its place
400
+ * ({@link compileGateRecords}). A record without a `sopId` takes its ref's.
401
+ */
402
+ export function parseServedGateRecords(value) {
403
+ if (!Array.isArray(value))
404
+ return [];
405
+ const out = [];
406
+ for (const r of value) {
407
+ if (typeof r !== 'object' || r === null)
408
+ continue;
409
+ const x = r;
410
+ if ((x.kind === 'requires_outcome' || x.kind === 'scope_paths' || x.kind === 'plan_approval') &&
411
+ (x.rollout === 'shadow' || x.rollout === 'enforce') &&
412
+ (x.fallbackId === null || typeof x.fallbackId === 'string') &&
413
+ typeof x.payload === 'object' && x.payload !== null && !Array.isArray(x.payload)) {
414
+ const payload = x.payload;
415
+ const ref = typeof payload.ref === 'string' ? payload.ref : '';
416
+ const sopId = typeof x.sopId === 'string' ? x.sopId : ref.slice(0, Math.max(0, ref.lastIndexOf('#')));
417
+ out.push({ sopId, kind: x.kind, rollout: x.rollout, fallbackId: x.fallbackId, payload });
418
+ }
419
+ }
420
+ return out;
421
+ }
422
+ /**
423
+ * The `holdRules` of a resolve response: only hold ids this batch defines
424
+ * (`sop.lane.*`, `revert.*`), on the `phrase` or `action` subject, at `hold`
425
+ * or `shadow`. Anything else is dropped with a log line: a served rule is
426
+ * never a way to put another kind of rule on the blocking path.
427
+ */
428
+ function parseServedHoldRules(value) {
429
+ if (!Array.isArray(value))
430
+ return [];
431
+ return value.filter((r) => {
432
+ if (typeof r !== 'object' || r === null)
433
+ return false;
434
+ const x = r;
435
+ const ok = typeof x.id === 'string' && /^(sop\.lane\.(irreversible|reversible_wide)\.|revert\.)action:[a-z_]+$/.test(x.id) &&
436
+ typeof x.source === 'string' && x.source !== '' && !/[\t\n\r]/.test(x.source) &&
437
+ (x.subject === 'phrase' || x.subject === 'action') &&
438
+ (x.severity === 'hold' || x.severity === 'shadow') &&
439
+ typeof x.reason === 'string';
440
+ if (!ok)
441
+ log.warn({ action: 'served_hold_rule_rejected', id: x.id }, 'A served hold rule is not one a gate takes — not shipped');
442
+ return ok;
443
+ });
444
+ }
445
+ function parseServedRevertHolds(value) {
446
+ if (!Array.isArray(value))
447
+ return [];
448
+ const rate = (v) => (typeof v === 'number' && isFinite(v) ? v : null);
449
+ return value
450
+ .filter((r) => typeof r === 'object' && r !== null && typeof r.subject === 'string')
451
+ .map((r) => ({
452
+ subject: r.subject,
453
+ revertRate: rate(r.revertRate),
454
+ rejectionRate: rate(r.rejectionRate),
455
+ }));
456
+ }
457
+ const sha256Hex = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
458
+ function jsonOrNull(text) {
459
+ try {
460
+ return JSON.parse(text);
461
+ }
462
+ catch {
463
+ return null;
464
+ }
465
+ }
466
+ /** A reason, scrubbed for the `.rules` projection and at most `max` characters. */
467
+ function boundedText(text, max) {
468
+ return Array.from(scrubReason(text)).slice(0, max).join('');
469
+ }
470
+ /** A fallback line refusing `tools`, or every tool when none could be read. */
471
+ function decideFallbackPattern(id, tools, severity, reason) {
472
+ const names = [...new Set(tools)].sort().map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
473
+ const source = names.length > 0 ? ` (${names.join('|')}) ` : ' [^ ]+ ';
474
+ assertPortableEre(source, id);
475
+ return {
476
+ id,
477
+ source,
478
+ subject: 'tool',
479
+ severity: severity,
480
+ reason: boundedText(reason, 300),
481
+ rationale: 'A decide rule a gate that cannot evaluate it refuses: it covers these tools.',
482
+ matches: [],
483
+ notMatches: [],
484
+ };
485
+ }
486
+ /**
487
+ * Why a readable rule is stamped as it is: its own
488
+ * rollout, then `intutic decide disable` on this machine, then the policy
489
+ * source's shadow stamp (the server's kill switch, a SHADOW guardrail, or an
490
+ * SOP in `mode: shadow`), then SILENT_LOG, then pins. Only a
491
+ * rule that clears all of them is stamped `enforce`.
492
+ */
493
+ function stampDecideRule(rollout, served, silentLog, pins, localDisable) {
494
+ if (rollout !== 'enforce')
495
+ return { rollout: 'shadow', stampedBy: 'rule' };
496
+ if (localDisable)
497
+ return { rollout: 'shadow', stampedBy: 'local-disable' };
498
+ if (served === 'shadow')
499
+ return { rollout: 'shadow', stampedBy: 'server-shadow' };
500
+ if (silentLog)
501
+ return { rollout: 'shadow', stampedBy: 'silent-log' };
502
+ if (pins.length === 0)
503
+ return { rollout: 'shadow', stampedBy: 'no-pins' };
504
+ return { rollout: 'enforce', stampedBy: 'rule' };
505
+ }
506
+ /**
507
+ * Compiles the policy's decide rules, in order. A readable `on: tool_call`
508
+ * rule becomes a record, and a fallback line when it is stamped `enforce`;
509
+ * an `on: response` rule is the gateway's and is not written. A line that
510
+ * does not read (or reuses an earlier line's id in its SOP) gets a fallback
511
+ * line and no record, so every gate refuses the tools it names, or every
512
+ * tool when it names none it can read; `unreadable.<n>` (its place among the
513
+ * SOP's decide lines) keeps that id apart from any rule's.
514
+ */
515
+ function compileDecideRules(policy) {
516
+ const served = policy.decideRules ?? [];
517
+ const silentLog = isSilentLogMode(policy);
518
+ const localDisable = served.length > 0 && decideLocallyDisabled();
519
+ // Each SOP's lines parsed together, so an id used twice is caught as the
520
+ // proxy catches it.
521
+ const bySop = new Map();
522
+ served.forEach((r, i) => bySop.set(r.sopId, [...(bySop.get(r.sopId) ?? []), i]));
523
+ const decls = new Map();
524
+ for (const indices of bySop.values()) {
525
+ const parsed = parseDecideLines(indices.map((i) => {
526
+ const ir = served[i].ir;
527
+ return typeof ir === 'string' ? ir : (JSON.stringify(ir) ?? '');
528
+ }));
529
+ indices.forEach((i, k) => decls.set(i, { decl: parsed[k], line: k + 1 }));
530
+ }
531
+ const out = { records: [], fallbacks: [] };
532
+ served.forEach((r, i) => {
533
+ const { decl, line } = decls.get(i);
534
+ const sopTitle = boundedText(r.sopTitle, DECIDE_SOP_TITLE_MAX_CHARS);
535
+ if (decl.kind === 'unreadable') {
536
+ log.warn({ action: 'decide_rule_unreadable', sopId: r.sopId, ruleId: decl.id, error: decl.error }, 'A decide rule does not read; the snapshot refuses the tools it names until it is fixed');
537
+ const raw = typeof r.ir === 'string' ? jsonOrNull(r.ir) : r.ir;
538
+ const named = raw !== null && typeof raw === 'object' && Array.isArray(raw.tools) ? raw.tools : [];
539
+ const tools = named.filter((t) => typeof t === 'string' && TOOL_TOKEN_RE.test(t));
540
+ out.fallbacks.push(decideFallbackPattern(`${DECIDE_FALLBACK_PREFIX}unreadable.${line}@${sha256Hex(r.sopId).slice(0, 12)}`, tools, silentLog ? 'shadow' : 'block', `Decide rule ${decl.id ?? `on decide line ${line}`} (${sopTitle}) does not read, so the calls it covers are refused: ${decl.error}`));
541
+ return;
542
+ }
543
+ const rule = decl.rule;
544
+ if (rule.on !== 'tool_call')
545
+ return;
546
+ const ruleHash = decideRuleHash(rule, sha256Hex);
547
+ const pins = r.ruleHash !== undefined && r.ruleHash !== ruleHash ? [] : r.pins;
548
+ const stamp = stampDecideRule(rule.rollout, r.rollout, silentLog, pins, localDisable);
549
+ const fallbackId = stamp.rollout === 'enforce' ? decideFallbackId(rule.id, r.sopId, sha256Hex) : null;
550
+ out.records.push({
551
+ v: 1,
552
+ ref: decideRuleRef(r.sopId, rule.id),
553
+ ruleHash,
554
+ fallbackId,
555
+ rollout: stamp.rollout,
556
+ stampedBy: stamp.stampedBy,
557
+ ir: rule,
558
+ sopTitle,
559
+ ruleText: decideRuleText(r.ruleText),
560
+ pins,
561
+ });
562
+ if (fallbackId)
563
+ out.fallbacks.push(decideFallbackPattern(fallbackId, rule.tools ?? [], 'block', decideFallbackReason(rule.id, sopTitle)));
564
+ });
565
+ return out;
566
+ }
567
+ /**
568
+ * The marker `intutic decide disable` leaves in `~/.intutic/decide/`: while
569
+ * it exists, every decide rule this machine writes into a snapshot is
570
+ * stamped `local-disable`, so it runs as shadow and gets no fallback line.
571
+ * A line that does not read still refuses: that refusal is not the engine's.
572
+ */
573
+ export const DECIDE_DISABLED_FILE = 'disabled';
574
+ /** Whether `intutic decide disable` is in force on this machine. Read at each write, from the home directory then. */
575
+ export function decideLocallyDisabled() {
576
+ return existsSync(path.join(os.homedir(), '.intutic', 'decide', DECIDE_DISABLED_FILE));
577
+ }
578
+ /** Where an `intutic decide eval` pass leaves the pins it earned: `evals/<ruleHash>/<digest>.json`, each a pin. */
579
+ export const DEFAULT_DECIDE_DIR = path.join(os.homedir(), '.intutic', 'decide');
580
+ /**
581
+ * The `decide:` lines of the workspace's local SOPs, for a snapshot written
582
+ * with no control plane. Each line's text is kept as written, so the writer
583
+ * reads it exactly as the proxy does, unreadable lines included. A readable
584
+ * rule's pins are the ones `decideDir/evals/<ruleHash>/` holds; a file that is
585
+ * not a pin is ignored. An SOP whose front matter says `mode: shadow` has its
586
+ * rules stamped shadow, as the control plane's resolve stamps them
587
+ * (`listServedDecideRules`): the record says `server-shadow`, and no fallback
588
+ * line is written.
589
+ */
590
+ export async function localDecideRules(entries, opts = {}) {
591
+ const dir = opts.decideDir ?? DEFAULT_DECIDE_DIR;
592
+ const out = [];
593
+ for (const entry of entries) {
594
+ const { front, body } = splitFrontMatter(entry.content);
595
+ const lines = front
596
+ .split('\n')
597
+ .map((l) => l.trim())
598
+ .filter((l) => l.startsWith('decide:'))
599
+ .map((l) => l.slice('decide:'.length));
600
+ if (lines.length === 0)
601
+ continue;
602
+ const heading = body.split('\n').find((l) => l.startsWith('# '));
603
+ const sopTitle = heading ? heading.slice(2) : entry.title;
604
+ const sopShadow = parseFrontMatterEnforcing(front).mode === 'shadow';
605
+ for (const line of lines) {
606
+ const decl = parseDecideLine(line);
607
+ const pins = decl.kind === 'rule' ? await readLocalPins(path.join(dir, 'evals', decideRuleHash(decl.rule, sha256Hex))) : [];
608
+ out.push({ sopId: entry.sopId, sopTitle, ruleText: body, ir: line, ...(sopShadow ? { rollout: 'shadow' } : {}), pins });
609
+ }
610
+ }
611
+ return out;
612
+ }
613
+ async function readLocalPins(dir) {
614
+ const names = await fs.readdir(dir).catch(() => []);
615
+ const pins = [];
616
+ for (const name of names.filter((n) => n.endsWith('.json')).sort()) {
617
+ try {
618
+ const pin = parseDecidePin(JSON.parse(await fs.readFile(path.join(dir, name), 'utf-8')));
619
+ if (pin)
620
+ pins.push(pin);
621
+ }
622
+ catch {
623
+ // Not a pin: the rule runs as shadow without it, which refuses nothing new.
624
+ }
625
+ }
626
+ return pins;
627
+ }
371
628
  /** Fetches resolved policy for the workspace. Returns null on any failure —
372
629
  * the caller keeps the previous snapshot rather than replacing it with nothing. */
373
630
  export async function fetchResolvedPolicy(opts) {
@@ -418,6 +675,10 @@ async function requestResolvedPolicy(opts) {
418
675
  ssoGroupPolicy: parseSsoGroupPolicy(rec.ssoGroupPolicy),
419
676
  principal: parsePrincipal(rec.principal),
420
677
  mcpRegistry: parseMcpRegistryRecord(rec.mcpRegistry),
678
+ decideRules: parseServedDecideRules(body.decideRules),
679
+ gateRecords: parseServedGateRecords(body.gateRecords),
680
+ holdRules: parseServedHoldRules(body.holdRules),
681
+ revertHolds: parseServedRevertHolds(body.revertHolds),
421
682
  };
422
683
  }
423
684
  catch (err) {
@@ -479,7 +740,160 @@ function sanitizeMcpServerNames(names) {
479
740
  }
480
741
  /** Builds the rule set a snapshot would carry, without writing it. Exported so
481
742
  * a test can assert the contents rather than re-deriving them. */
482
- export function buildSnapshotRules(policy, localHoldTokens = [], localHoldMode = 'hold') {
743
+ export function buildSnapshotRules(policy, localHoldTokens = [], localHoldMode = 'hold', localSops = []) {
744
+ const rules = compileGateRecords(policy, localSops);
745
+ return snapshotRules(policy, localHoldTokens, localHoldMode, compileDecideRules(policy).fallbacks, {
746
+ ruleFallbacks: rules.fallbacks,
747
+ holds: laneHoldPatterns(policy, localSops, localHoldMode),
748
+ });
749
+ }
750
+ /** One local SOP's front matter, parsed once for its records and its lanes; parse errors are logged. */
751
+ function localSopFrontMatter(entry) {
752
+ const fm = parseFrontMatterEnforcing(splitFrontMatter(entry.content).front);
753
+ for (const error of fm.errors) {
754
+ log.warn({ action: 'sop_rule_error', sopId: entry.sopId, error }, 'A local SOP rule does not read; it is enforced as written, which refuses');
755
+ }
756
+ return fm;
757
+ }
758
+ /** A shared fallback line (`gateRuleFallback`) as a snapshot rule. */
759
+ function fallbackPattern(f) {
760
+ let match = { subject: f.subject, source: f.source, ignoreCase: f.ignoreCase };
761
+ try {
762
+ assertPortableEre(match.subject === 'phrase' ? ' x ' : match.source, f.id);
763
+ }
764
+ catch {
765
+ match = { subject: 'tool', source: ' [^ ]+ ', ignoreCase: false };
766
+ }
767
+ return {
768
+ id: f.id,
769
+ source: match.source,
770
+ subject: match.subject,
771
+ ...(match.ignoreCase ? { ignoreCase: true } : {}),
772
+ severity: 'block',
773
+ reason: boundedText(f.reason, 300),
774
+ rationale: 'A rule record a gate that cannot evaluate it refuses: it covers these calls.',
775
+ matches: [],
776
+ notMatches: [],
777
+ };
778
+ }
779
+ /**
780
+ * The fallback line of a served, enforcing record that does not decode: no
781
+ * `@rule` line is written for it, so nothing stands in for its line, which
782
+ * refuses every call until the record is fixed. A record that cannot be read
783
+ * is no reason to let what it covers through.
784
+ */
785
+ function unreadableRecordFallback(r) {
786
+ if (r.rollout !== 'enforce')
787
+ return null;
788
+ const id = r.fallbackId && /^[a-z_]+-fallback:[1-9][0-9]{0,5}@[0-9a-f]{12}$/.test(r.fallbackId)
789
+ ? r.fallbackId
790
+ : `${r.kind}-fallback:1@${sha256Hex(r.sopId).slice(0, 12)}`;
791
+ const title = boundedText(typeof r.payload.sopTitle === 'string' ? r.payload.sopTitle : r.sopId, 80);
792
+ return {
793
+ id,
794
+ source: ' [^ ]+ ',
795
+ subject: 'tool',
796
+ severity: 'block',
797
+ reason: boundedText(`A ${r.kind} rule of ${title} could not be read, so every call is refused until the SOP is fixed`, 300),
798
+ rationale: 'A rule record a gate that cannot evaluate it refuses: it covers these calls.',
799
+ matches: [],
800
+ notMatches: [],
801
+ };
802
+ }
803
+ /**
804
+ * The policy's `@rule` records, served ones first and then the local SOPs'
805
+ * (`compileSopGateRecords`, `@intutic/shared-types`), and the fallback line
806
+ * of each enforcing one (`gateRuleFallback`). Under SILENT_LOG every record
807
+ * is stamped shadow and has no fallback line, as decide rules are.
808
+ */
809
+ export function compileGateRecords(policy, localSops = []) {
810
+ const silentLog = isSilentLogMode(policy);
811
+ const records = [];
812
+ const fallbacks = [];
813
+ const seen = new Set();
814
+ const addFallback = (f) => {
815
+ if (!f || seen.has(f.id))
816
+ return;
817
+ seen.add(f.id);
818
+ fallbacks.push(f);
819
+ };
820
+ for (const served of parseServedGateRecords(policy.gateRecords ?? [])) {
821
+ const stamped = silentLog ? { ...served, rollout: 'shadow', fallbackId: null } : served;
822
+ const r = fromServedGateRecord(stamped);
823
+ if (r === null) {
824
+ log.warn({ action: 'served_gate_record_unreadable', sopId: served.sopId, kind: served.kind }, 'A served rule record does not decode; its fallback line refuses in its place');
825
+ addFallback(unreadableRecordFallback(stamped));
826
+ continue;
827
+ }
828
+ records.push(r);
829
+ }
830
+ for (const entry of localSops) {
831
+ records.push(...compileSopGateRecords({ sopId: entry.sopId, sopTitle: entry.title, shadow: silentLog }, localSopFrontMatter(entry), sha256Hex).records);
832
+ }
833
+ for (const r of records) {
834
+ const f = gateRuleFallback(r);
835
+ if (f)
836
+ addFallback(fallbackPattern(f));
837
+ }
838
+ return { records, fallbacks };
839
+ }
840
+ /**
841
+ * The hold rules of a local SOP's closed lanes, in the shape
842
+ * resolve serves the workspace's (`laneHoldPatterns`, `@intutic/shared-types`):
843
+ * per token, a `sop.lane.<class>.<token>` rule on the `phrase` subject,
844
+ * which a gate whose classifier predates the token still holds, and for
845
+ * force_push and destroy also one on the `action` subject, under the same
846
+ * id, so one approval releases the call whichever rule held it. Only SOPs
847
+ * with no `roles:` (a gate knows no role). With no control plane (`block`) a
848
+ * lane hold refuses.
849
+ */
850
+ export function localLaneHoldRules(localSops, mode) {
851
+ const sops = localSops.map(localSopFrontMatter).filter((fm) => fm.roles.length === 0);
852
+ const out = [];
853
+ for (const token of laneHoldTokens({ sops })) {
854
+ const laneClass = tokenLane(token);
855
+ const offline = mode === 'block';
856
+ const base = {
857
+ id: laneHoldRuleId(token),
858
+ severity: 'hold',
859
+ reason: offline
860
+ ? `Refused: ${token} is in the closed ${laneClass} lane, and holding it for approval needs a control plane — run it yourself, or open the lane.`
861
+ : `Held for human review: ${token} is in the closed ${laneClass} lane`,
862
+ rationale: 'A SOP closed this reversibility lane: a person signs off each call in it.',
863
+ };
864
+ for (const p of laneHoldPatternSpecs(token))
865
+ out.push({ ...base, source: p.source, subject: p.subject, ...(p.ignoreCase ? { ignoreCase: true } : {}) });
866
+ }
867
+ return out;
868
+ }
869
+ /**
870
+ * The lane and revert hold rules: resolve's (`holdRules`), then the local
871
+ * SOPs' lanes for an id resolve did not serve. Shadow under SILENT_LOG; a
872
+ * local hold refuses (`block`) with no control plane.
873
+ */
874
+ function laneHoldPatterns(policy, localSops, mode) {
875
+ const shadow = isSilentLogMode(policy);
876
+ // Validated here as well as at the fetch: a policy rebuilt from a
877
+ // snapshot's JSON, or built by a caller, takes the same check.
878
+ const served = parseServedHoldRules(policy.holdRules ?? []);
879
+ const ids = new Set(served.map((r) => r.id));
880
+ const local = localLaneHoldRules(localSops, mode).filter((r) => !ids.has(r.id));
881
+ return [
882
+ ...served.map((r) => ({ r, offline: false })),
883
+ ...local.map((r) => ({ r, offline: mode === 'block' })),
884
+ ].map(({ r, offline }) => ({
885
+ id: r.id,
886
+ source: r.source,
887
+ subject: r.subject,
888
+ ...(r.ignoreCase ? { ignoreCase: true } : {}),
889
+ severity: (shadow || r.severity === 'shadow' ? 'shadow' : offline ? 'block' : 'hold'),
890
+ reason: boundedText(r.reason, 300),
891
+ rationale: r.rationale ?? 'A closed reversibility lane or the revert lane holds this action.',
892
+ matches: [],
893
+ notMatches: [],
894
+ }));
895
+ }
896
+ function snapshotRules(policy, localHoldTokens, localHoldMode, decideFallbacks, extra = { ruleFallbacks: [], holds: [] }) {
483
897
  // `shadow` names the advisory GuardPattern severity these rules are demoted
484
898
  // to; the workspace-level trigger is intervention mode SILENT_LOG.
485
899
  const shadow = isSilentLogMode(policy);
@@ -538,8 +952,23 @@ export function buildSnapshotRules(policy, localHoldTokens = [], localHoldMode =
538
952
  }));
539
953
  // SSO-group refusals first. Every gate stops at the first rule that refuses,
540
954
  // and a `hold` rule earlier in the list could otherwise let an approved
541
- // bypass through a call the group policy refuses outright.
542
- return [...ssoGroupPatterns(policy), ...sopRules, ...localHolds, ...destructive, ...skillSurface, ...skillContent];
955
+ // bypass through a call the group policy refuses outright. The decide
956
+ // fallback lines next, ahead of every shadow and warn rule: a 2.9.0 SDK
957
+ // gate returns at the first of those that matches, and would never reach a
958
+ // fallback line after one.
959
+ // The rule records' fallback lines right after the decide ones, for the
960
+ // same reason; the lane and revert holds after the local review holds.
961
+ return [
962
+ ...ssoGroupPatterns(policy),
963
+ ...decideFallbacks,
964
+ ...extra.ruleFallbacks,
965
+ ...sopRules,
966
+ ...localHolds,
967
+ ...extra.holds,
968
+ ...destructive,
969
+ ...skillSurface,
970
+ ...skillContent,
971
+ ];
543
972
  }
544
973
  /**
545
974
  * The workspace's SSO-group policy, decided for the member this snapshot is
@@ -655,11 +1084,25 @@ function localHoldPattern(token, shadow, mode) {
655
1084
  * path. Both carry the same digest, so a test — and the gate — can tell that the
656
1085
  * projection really came from the JSON.
657
1086
  */
658
- export async function writePolicySnapshot(policy, snapshotDir = DEFAULT_SNAPSHOT_DIR, localHoldTokens = [], localHolds = 'hold') {
659
- return oneAtATime(() => writeSnapshotFiles(policy, snapshotDir, localHoldTokens, { localHolds }));
1087
+ export async function writePolicySnapshot(policy, snapshotDir = DEFAULT_SNAPSHOT_DIR, localHoldTokens = [], localHolds = 'hold', localSops = []) {
1088
+ return oneAtATime(() => writeSnapshotFiles(policy, snapshotDir, localHoldTokens, { localHolds, localSops }));
660
1089
  }
661
1090
  async function writeSnapshotFiles(policy, snapshotDir, localHoldTokens, opts = {}) {
662
- const rules = buildSnapshotRules(policy, localHoldTokens, opts.localHolds);
1091
+ const decide = compileDecideRules(policy);
1092
+ const gateRules = compileGateRecords(policy, opts.localSops ?? []);
1093
+ const rules = snapshotRules(policy, localHoldTokens, opts.localHolds ?? 'hold', decide.fallbacks, {
1094
+ ruleFallbacks: gateRules.fallbacks,
1095
+ holds: laneHoldPatterns(policy, opts.localSops ?? [], opts.localHolds ?? 'hold'),
1096
+ });
1097
+ // Format 2 once any decide line is written: an `@decide` record, or a
1098
+ // fallback line. Without one the snapshot stays byte-identical to format 1.
1099
+ // Format 3 once any `@rule` record is; a snapshot with
1100
+ // decide lines and no rule record stays format 2, byte for byte. The
1101
+ // record is written here rather than by `encodeSnapshotFormatRecord`, whose
1102
+ // format moves with the newest one.
1103
+ const decideFormat = decide.records.length > 0 || decide.fallbacks.length > 0;
1104
+ const ruleFormat = gateRules.records.length > 0;
1105
+ const format = ruleFormat ? RULE_SNAPSHOT_FORMAT : decideFormat ? 2 : 1;
663
1106
  // One timestamp shared by both artifacts. Two `new Date()` calls would put
664
1107
  // different values in the JSON and the .rules, so a reader comparing them
665
1108
  // would see drift that is not there.
@@ -694,18 +1137,25 @@ async function writeSnapshotFiles(policy, snapshotDir, localHoldTokens, opts = {
694
1137
  // existed. Unlike SILENT_LOG it changes no rule's severity: what is refused
695
1138
  // is unchanged, only what the agent is told.
696
1139
  const opaque = isOpaqueInterventionMode(policy.interventionMode);
1140
+ // Each decide rule's record rides inside the digest too, in SOP order: an
1141
+ // edited record fails the check, and an invalid snapshot drops the records
1142
+ // with the fallback lines, as it drops the rest of the dynamic tier.
697
1143
  const lines = [
1144
+ ...(format > 1 ? [`${SNAPSHOT_FORMAT_RECORD_TAG}\t${format}`] : []),
698
1145
  ...(opaque ? [encodeInterventionModeRecord()] : []),
699
1146
  ...(ssoGroups ? [encodeSsoGroupRecord(ssoGroups)] : []),
700
1147
  ...(mcpRegistry ? [encodeMcpRegistryRecord(mcpRegistry)] : []),
701
1148
  ...(mcpServers.length > 0 ? [encodeMcpAllowlistRecord({ severity: mcpSeverity, servers: mcpServers })] : []),
1149
+ ...decide.records.map(encodeDecideRecord),
1150
+ // Each rule record rides inside the digest too, after the decide records.
1151
+ ...gateRules.records.map(encodeGateRuleRecord),
702
1152
  ...rules.map(toRulesLine),
703
1153
  ];
704
1154
  const digest = createHash('sha256').update(lines.join('\n')).digest('hex').slice(0, 32);
705
1155
  await fs.mkdir(snapshotDir, { recursive: true });
706
1156
  const json = JSON.stringify({
707
1157
  _comment: 'Intutic policy snapshot — auto-generated. DO NOT EDIT.',
708
- version: 1,
1158
+ version: format,
709
1159
  /**
710
1160
  * Which emitted evaluator these rules were compiled for.
711
1161
  *
@@ -734,9 +1184,18 @@ async function writeSnapshotFiles(policy, snapshotDir, localHoldTokens, opts = {
734
1184
  ...(ssoGroups ? { ssoGroups } : {}),
735
1185
  // The same record as the `.rules` file's `@mcp_registry` line.
736
1186
  ...(mcpRegistry ? { mcpRegistry } : {}),
737
- // With `sopRules`, `interventionMode`, `mcpAllowedServers` and
738
- // `mcpRegistry`, enough to rebuild this snapshot without the control
739
- // plane (`forgetSnapshotMember`).
1187
+ // The decide rules as resolve served them (or the local SOPs held
1188
+ // them), which the `@decide` records and fallback lines were compiled
1189
+ // from: readable, and enough to compile them again.
1190
+ ...(policy.decideRules && policy.decideRules.length > 0 ? { decideRules: policy.decideRules } : {}),
1191
+ // The rule records as written (served and local), and the lane and
1192
+ // revert holds resolve served: enough to compile them again.
1193
+ ...(gateRules.records.length > 0 ? { gateRecords: gateRules.records.map((r) => toServedGateRecord(r, r.ref.slice(0, r.ref.lastIndexOf('#')))) } : {}),
1194
+ ...(policy.holdRules && policy.holdRules.length > 0 ? { holdRules: policy.holdRules } : {}),
1195
+ ...(policy.revertHolds && policy.revertHolds.length > 0 ? { revertHolds: policy.revertHolds } : {}),
1196
+ // With `sopRules`, `interventionMode`, `mcpAllowedServers`,
1197
+ // `mcpRegistry` and `decideRules`, enough to rebuild this snapshot
1198
+ // without the control plane (`forgetSnapshotMember`).
740
1199
  sqlDropStrictBlock: policy.sqlDropStrictBlock,
741
1200
  /**
742
1201
  * The resolve response verbatim, alongside the gate projection below.
@@ -834,13 +1293,15 @@ export function localResolvedPolicy() {
834
1293
  };
835
1294
  }
836
1295
  /**
837
- * Writes the snapshot of {@link localResolvedPolicy}, marked `source: local`.
1296
+ * Writes the snapshot of {@link localResolvedPolicy}, marked `source: local`,
1297
+ * with the local SOPs' decide rules ({@link localDecideRules}) when given.
838
1298
  * The local holds refuse (`block`) rather than hold: with no control plane
839
1299
  * nothing could approve them. Never fetches.
840
1300
  */
841
1301
  export async function writeLocalPolicySnapshot(opts = {}) {
842
1302
  const dir = opts.snapshotDir ?? DEFAULT_SNAPSHOT_DIR;
843
- return oneAtATime(() => writeSnapshotFiles(localResolvedPolicy(), dir, opts.localHoldTokens ?? [], { localHolds: 'block', source: 'local' }));
1303
+ const policy = { ...localResolvedPolicy(), ...(opts.decideRules ? { decideRules: opts.decideRules } : {}) };
1304
+ return oneAtATime(() => writeSnapshotFiles(policy, dir, opts.localHoldTokens ?? [], { localHolds: 'block', source: 'local', localSops: opts.localSops ?? [] }));
844
1305
  }
845
1306
  /**
846
1307
  * Puts the last verified snapshot back when the live one differs from it.
@@ -903,7 +1364,7 @@ export async function refreshPolicySnapshot(opts) {
903
1364
  if (!result)
904
1365
  return null;
905
1366
  try {
906
- return await writePolicySnapshot(result, dir, opts.localHoldTokens ?? []);
1367
+ return await writePolicySnapshot(result, dir, opts.localHoldTokens ?? [], 'hold', opts.localSops ?? []);
907
1368
  }
908
1369
  catch (err) {
909
1370
  log.warn({ action: 'policy_snapshot_write_failed', err }, 'Could not write policy snapshot');
@@ -945,6 +1406,11 @@ async function forgetSnapshotMember(dir, localHoldTokens) {
945
1406
  ssoGroupPolicy,
946
1407
  principal: null,
947
1408
  mcpRegistry: parseMcpRegistryRecord(doc.mcpRegistry),
1409
+ decideRules: parseServedDecideRules(doc.decideRules),
1410
+ // The records as the last write compiled them, local ones included.
1411
+ gateRecords: parseServedGateRecords(doc.gateRecords),
1412
+ holdRules: parseServedHoldRules(doc.holdRules),
1413
+ revertHolds: parseServedRevertHolds(doc.revertHolds),
948
1414
  }, dir, localHoldTokens);
949
1415
  log.warn({ action: 'policy_snapshot_member_forgotten', workspaceId: doc.workspaceId }, "The control plane refused this machine's key; the snapshot now treats the member's SSO groups as unknown");
950
1416
  }