@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.
- package/dist/disconnect/harnesses.d.ts.map +1 -1
- package/dist/disconnect/harnesses.js +4 -3
- package/dist/disconnect/harnesses.js.map +1 -1
- package/dist/disconnect/plan.d.ts +6 -1
- package/dist/disconnect/plan.d.ts.map +1 -1
- package/dist/disconnect/plan.js +4 -2
- package/dist/disconnect/plan.js.map +1 -1
- package/dist/harness/antigravityCliHooks.js +3 -3
- package/dist/harness/antigravityCliHooks.js.map +1 -1
- package/dist/harness/antigravityHooks.d.ts +8 -0
- package/dist/harness/antigravityHooks.d.ts.map +1 -1
- package/dist/harness/antigravityHooks.js +27 -5
- package/dist/harness/antigravityHooks.js.map +1 -1
- package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
- package/dist/harness/claudeCodeHooks.js +58 -16
- package/dist/harness/claudeCodeHooks.js.map +1 -1
- package/dist/harness/clineHooks.d.ts.map +1 -1
- package/dist/harness/clineHooks.js +4 -2
- package/dist/harness/clineHooks.js.map +1 -1
- package/dist/harness/codexHooks.d.ts.map +1 -1
- package/dist/harness/codexHooks.js +4 -2
- package/dist/harness/codexHooks.js.map +1 -1
- package/dist/harness/cursorHooks.d.ts.map +1 -1
- package/dist/harness/cursorHooks.js +8 -3
- package/dist/harness/cursorHooks.js.map +1 -1
- package/dist/harness/gateBody.d.ts +177 -4
- package/dist/harness/gateBody.d.ts.map +1 -1
- package/dist/harness/gateBody.js +1658 -26
- package/dist/harness/gateBody.js.map +1 -1
- package/dist/harness/githubCopilotHooks.js +3 -3
- package/dist/harness/githubCopilotHooks.js.map +1 -1
- package/dist/harness/gooseHooks.d.ts.map +1 -1
- package/dist/harness/gooseHooks.js +8 -1
- package/dist/harness/gooseHooks.js.map +1 -1
- package/dist/harness/grokHooks.js +2 -2
- package/dist/harness/hermesHooks.js +1 -1
- package/dist/harness/hermesHooks.js.map +1 -1
- package/dist/harness/museHooks.d.ts.map +1 -1
- package/dist/harness/museHooks.js +4 -2
- package/dist/harness/museHooks.js.map +1 -1
- package/dist/harness/n8nHooks.js +1 -1
- package/dist/harness/openCodeHooks.d.ts.map +1 -1
- package/dist/harness/openCodeHooks.js +32 -8
- package/dist/harness/openCodeHooks.js.map +1 -1
- package/dist/harness/openclawHooks.d.ts.map +1 -1
- package/dist/harness/openclawHooks.js +19 -8
- package/dist/harness/openclawHooks.js.map +1 -1
- package/dist/harness/piHooks.js +7 -7
- package/dist/harness/windsurfHooks.js +2 -2
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/approvedBypasses.d.ts +17 -4
- package/dist/lib/approvedBypasses.d.ts.map +1 -1
- package/dist/lib/approvedBypasses.js +17 -4
- package/dist/lib/approvedBypasses.js.map +1 -1
- package/dist/lib/approvedPlans.d.ts +73 -0
- package/dist/lib/approvedPlans.d.ts.map +1 -0
- package/dist/lib/approvedPlans.js +124 -0
- package/dist/lib/approvedPlans.js.map +1 -0
- package/dist/lib/decideLogs.d.ts +46 -0
- package/dist/lib/decideLogs.d.ts.map +1 -0
- package/dist/lib/decideLogs.js +160 -0
- package/dist/lib/decideLogs.js.map +1 -0
- package/dist/lib/policySnapshot.d.ts +143 -3
- package/dist/lib/policySnapshot.d.ts.map +1 -1
- package/dist/lib/policySnapshot.js +480 -14
- package/dist/lib/policySnapshot.js.map +1 -1
- package/dist/lib/refusalDetailPy.d.ts +11 -0
- package/dist/lib/refusalDetailPy.d.ts.map +1 -0
- package/dist/lib/refusalDetailPy.js +109 -0
- package/dist/lib/refusalDetailPy.js.map +1 -0
- package/dist/lib/ruleRecordPy.d.ts +11 -0
- package/dist/lib/ruleRecordPy.d.ts.map +1 -0
- package/dist/lib/ruleRecordPy.js +277 -0
- package/dist/lib/ruleRecordPy.js.map +1 -0
- package/dist/lib/scopeMatchPy.d.ts +11 -0
- package/dist/lib/scopeMatchPy.d.ts.map +1 -0
- package/dist/lib/scopeMatchPy.js +278 -0
- package/dist/lib/scopeMatchPy.js.map +1 -0
- package/dist/lib/sopFiles.d.ts.map +1 -1
- package/dist/lib/sopFiles.js +5 -3
- package/dist/lib/sopFiles.js.map +1 -1
- package/dist/lib/testOutcomePy.d.ts +11 -0
- package/dist/lib/testOutcomePy.d.ts.map +1 -0
- package/dist/lib/testOutcomePy.js +371 -0
- package/dist/lib/testOutcomePy.js.map +1 -0
- package/dist/skillWriter.d.ts +1 -1
- package/dist/skillWriter.d.ts.map +1 -1
- package/dist/skillWriter.js +7 -0
- package/dist/skillWriter.js.map +1 -1
- package/dist/syncCycle.d.ts +15 -2
- package/dist/syncCycle.d.ts.map +1 -1
- package/dist/syncCycle.js +15 -3
- package/dist/syncCycle.js.map +1 -1
- 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
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
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
|
-
|
|
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
|
}
|