rulesync 16.22.1 → 16.23.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.
@@ -6699,6 +6699,8 @@ const QwencodePermissionsOverrideSchema = zod_mini.z.looseObject({
6699
6699
  *
6700
6700
  * @example
6701
6701
  * { "sandbox": { "bash": "enforce", "network": false }, "agent": { "plan_mode_read_only_commands": ["gh pr diff"] } }
6702
+ * @example
6703
+ * { "allowDynamicBash": true, "rawAllow": ["Bash=pnpm test"] }
6702
6704
  */
6703
6705
  const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
6704
6706
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
@@ -6706,7 +6708,8 @@ const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
6706
6708
  agent: zod_mini.z.optional(zod_mini.z.looseObject({})),
6707
6709
  rawAllow: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
6708
6710
  rawAsk: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
6709
- rawDeny: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
6711
+ rawDeny: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
6712
+ allowDynamicBash: zod_mini.z.optional(zod_mini.z.boolean())
6710
6713
  });
6711
6714
  /**
6712
6715
  * Tool-scoped override block for Factory Droid. Factory Droid's `settings.json`
@@ -7350,6 +7353,32 @@ const ZedPermissionsOverrideSchema = zod_mini.z.looseObject({
7350
7353
  })))
7351
7354
  });
7352
7355
  /**
7356
+ * Tool-scoped override block for Devin Local. `sandbox` is the sibling
7357
+ * top-level `config.json` block that governs the sandbox Devin runs commands
7358
+ * in: `allowed_domains` / `denied_domains` (proxy domain patterns, deny beating
7359
+ * allow), `network_mode` (`full`, the upstream default, allows every HTTP
7360
+ * method; `limited` only GET/HEAD/OPTIONS) and `excluded` (`allow` / `ask` /
7361
+ * `deny` lists of `Exec(...)` matchers deciding which commands run *outside* the
7362
+ * sandbox — `deny` pins them inside it). It constrains how
7363
+ * a permitted command runs rather than which commands are permitted, so it has
7364
+ * no canonical category and is authored here.
7365
+ *
7366
+ * Upstream lists `sandbox` as a **User Config Only** key, so it is emitted at
7367
+ * global scope only; at project scope it is dropped with a warning rather than
7368
+ * written into a file Devin would ignore.
7369
+ *
7370
+ * @example
7371
+ * { "sandbox": { "allowed_domains": ["github.com"], "network_mode": "limited" } }
7372
+ * @example
7373
+ * { "sandbox": { "excluded": { "allow": ["Exec(git status *)"], "deny": ["Exec(git tag *)"] } } }
7374
+ * @see https://docs.devin.ai/cli/sandbox
7375
+ * @see https://docs.devin.ai/cli/reference/configuration/config-file
7376
+ */
7377
+ const DevinPermissionsOverrideSchema = zod_mini.z.looseObject({
7378
+ permission: zod_mini.z.optional(ToolScopedPermissionSchema),
7379
+ sandbox: zod_mini.z.optional(zod_mini.z.looseObject({}))
7380
+ });
7381
+ /**
7353
7382
  * Permissions configuration.
7354
7383
  * Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
7355
7384
  * Values are pattern-to-action mappings for that tool category.
@@ -7399,10 +7428,10 @@ const PermissionsConfigSchema = zod_mini.z.looseObject({
7399
7428
  kiro: zod_mini.z.optional(KiroPermissionsOverrideSchema),
7400
7429
  codexcli: zod_mini.z.optional(CodexcliPermissionsOverrideSchema),
7401
7430
  zed: zod_mini.z.optional(ZedPermissionsOverrideSchema),
7431
+ devin: zod_mini.z.optional(DevinPermissionsOverrideSchema),
7402
7432
  "antigravity-ide": zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7403
7433
  copilot: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7404
7434
  copilotcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7405
- devin: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7406
7435
  goose: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7407
7436
  grokcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7408
7437
  "kimi-code": zod_mini.z.optional(KimiCodePermissionsOverrideSchema),
@@ -12628,7 +12657,7 @@ const SHARED_CONFIG_OWNERSHIP = {
12628
12657
  },
12629
12658
  permissions: {
12630
12659
  kind: "replace-owned-keys",
12631
- ownedKeys: ["permissions"]
12660
+ ownedKeys: ["permissions", "sandbox"]
12632
12661
  }
12633
12662
  }
12634
12663
  },
@@ -36349,6 +36378,180 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
36349
36378
  return { permission };
36350
36379
  }
36351
36380
  //#endregion
36381
+ //#region src/features/permissions/sandbox-trust.ts
36382
+ /** A key whose quiet value is an explicit `false`. */
36383
+ const isNotFalse = (value) => value !== false;
36384
+ /** A key whose quiet value is an explicit `true`. */
36385
+ const isNotTrue = (value) => value !== true;
36386
+ /** A list-valued key whose quiet value is the empty list. */
36387
+ const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
36388
+ /** The map-valued counterpart of {@link isNonEmptyList}. */
36389
+ const isNonEmptyMap = (value) => !isRecord$1(value) || Object.keys(value).length > 0;
36390
+ /**
36391
+ * What {@link readSandboxPath} returns when a container on the way to the leaf
36392
+ * is present but is not an object, so the leaf cannot be read at all. It is not
36393
+ * `undefined`, because the two mean opposite things to a caller: `undefined` is
36394
+ * "this path is not being written", while this is "something is being written
36395
+ * here and its shape hides what". The same fail-safe rule the predicates follow
36396
+ * applies to the walk — silence must mean "this cannot loosen anything", not
36397
+ * "this is not the shape the table expected".
36398
+ */
36399
+ const UNREADABLE_SANDBOX_PATH = Symbol("unreadable-sandbox-path");
36400
+ /**
36401
+ * Reads `sandbox` at `path`. Returns `undefined` when a segment is absent, and
36402
+ * {@link UNREADABLE_SANDBOX_PATH} when one is present but is not an object.
36403
+ * Shared by everything that addresses a `sandbox` path so a nested path added to
36404
+ * one of the tables is actually traversed rather than silently skipped, and so a
36405
+ * hostile shape (an array, a string, `null`) is reported rather than throwing.
36406
+ */
36407
+ function readSandboxPath({ sandbox, path }) {
36408
+ let cursor = sandbox;
36409
+ for (const segment of path) {
36410
+ if (cursor === void 0) return void 0;
36411
+ if (!isRecord$1(cursor)) return UNREADABLE_SANDBOX_PATH;
36412
+ cursor = cursor[segment];
36413
+ }
36414
+ return cursor;
36415
+ }
36416
+ /**
36417
+ * Every path in `paths` whose value in `sandbox` loosens the policy. Nothing is
36418
+ * removed — the values are written, just not silently. Call it on the block this
36419
+ * generate authored, after any scope filter has run: a value the file already
36420
+ * held is the user's own, not something rulesync opened, and a path a filter
36421
+ * dropped is not being written at all.
36422
+ */
36423
+ function collectTrustAffectingSandboxPaths({ sandbox, paths }) {
36424
+ const entries = [];
36425
+ const reportedContainers = /* @__PURE__ */ new Set();
36426
+ for (const { path, reason, widens } of paths) {
36427
+ const value = readSandboxPath({
36428
+ sandbox,
36429
+ path
36430
+ });
36431
+ if (value === void 0) continue;
36432
+ if (value === UNREADABLE_SANDBOX_PATH) {
36433
+ const label = findUnreadableContainer({
36434
+ sandbox,
36435
+ path
36436
+ });
36437
+ if (label === void 0 || reportedContainers.has(label)) continue;
36438
+ reportedContainers.add(label);
36439
+ entries.push({
36440
+ label,
36441
+ reason: UNREADABLE_CONTAINER_REASON
36442
+ });
36443
+ continue;
36444
+ }
36445
+ if (!widens(value)) continue;
36446
+ entries.push({
36447
+ label: `sandbox.${path.join(".")}`,
36448
+ reason
36449
+ });
36450
+ }
36451
+ return entries;
36452
+ }
36453
+ /** The reason printed for a container that hides the settings underneath it. */
36454
+ const UNREADABLE_CONTAINER_REASON = "is not the object it has to be, so nothing under it can be checked for what it opens";
36455
+ /**
36456
+ * The prefix of `path` that {@link readSandboxPath} could not walk past, as a
36457
+ * label. `undefined` when the walk was not blocked at all. Callers that report
36458
+ * an unreadable path name the container rather than the leaf, because the leaf
36459
+ * is not what the file actually holds.
36460
+ */
36461
+ function findUnreadableContainer({ sandbox, path }) {
36462
+ let cursor = sandbox;
36463
+ const walked = [];
36464
+ for (const segment of path) {
36465
+ if (cursor === void 0) return void 0;
36466
+ if (!isRecord$1(cursor)) return walked.length === 0 ? "sandbox" : `sandbox.${walked.join(".")}`;
36467
+ walked.push(segment);
36468
+ cursor = cursor[segment];
36469
+ }
36470
+ }
36471
+ /**
36472
+ * The reason printed for a value the file held in a shape that cannot be read,
36473
+ * which this generate is about to replace. `shape` names what the tool documents
36474
+ * there, so the message says which expectation the file's value missed.
36475
+ */
36476
+ const replacedUnreadableReason = ({ shape, toolLabel }) => `replaces a value already in the file that is not the ${shape} ${toolLabel} documents, so what it restricted cannot be read`;
36477
+ /**
36478
+ * The restrictions this generate would weaken, compared between the `sandbox`
36479
+ * already in the file and the one about to replace it. A `before` that is
36480
+ * present but not a list is reported outright: a shape the tool may still honor
36481
+ * is not something to go quiet about just because it cannot be diffed. Shared by
36482
+ * every tool whose override replaces a restricting list whole rather than
36483
+ * merging into it — Claude Code needs no equivalent, because it merges its lists
36484
+ * across settings scopes, so a file can only ever add to them.
36485
+ */
36486
+ function collectRestrictionLosingSandboxEntries({ existing, merged, paths, toolLabel }) {
36487
+ const entries = [];
36488
+ const reportedContainers = /* @__PURE__ */ new Set();
36489
+ for (const { path, reason, loosens } of paths) {
36490
+ const before = readSandboxPath({
36491
+ sandbox: existing,
36492
+ path
36493
+ });
36494
+ if (before === void 0) continue;
36495
+ const [rootKey] = path;
36496
+ if (rootKey !== void 0 && existing[rootKey] === merged[rootKey]) continue;
36497
+ const after = readSandboxPath({
36498
+ sandbox: merged,
36499
+ path
36500
+ });
36501
+ const label = `sandbox.${path.join(".")}`;
36502
+ if (before === UNREADABLE_SANDBOX_PATH) {
36503
+ const container = findUnreadableContainer({
36504
+ sandbox: existing,
36505
+ path
36506
+ });
36507
+ if (container === void 0 || reportedContainers.has(container)) continue;
36508
+ reportedContainers.add(container);
36509
+ entries.push({
36510
+ label: container,
36511
+ reason: replacedUnreadableReason({
36512
+ shape: "object",
36513
+ toolLabel
36514
+ })
36515
+ });
36516
+ continue;
36517
+ }
36518
+ if (!Array.isArray(before)) {
36519
+ entries.push({
36520
+ label,
36521
+ reason: replacedUnreadableReason({
36522
+ shape: "list",
36523
+ toolLabel
36524
+ })
36525
+ });
36526
+ continue;
36527
+ }
36528
+ if (before.length === 0) continue;
36529
+ if (!loosens({
36530
+ before,
36531
+ after: Array.isArray(after) ? after : []
36532
+ })) continue;
36533
+ entries.push({
36534
+ label,
36535
+ reason
36536
+ });
36537
+ }
36538
+ return entries;
36539
+ }
36540
+ /**
36541
+ * The one warning that names every trust-affecting setting this generate wrote
36542
+ * to `relativeFilePath`. Emitted once per file: the individual reasons are what
36543
+ * matter, but the "review this as you would a hook" framing only needs saying
36544
+ * once, and repeating it per key buries the reasons in boilerplate. `noun` lets
36545
+ * a tool whose entries are not all additions call them something more accurate
36546
+ * than "setting".
36547
+ */
36548
+ function warnOnTrustAffectingEntries({ toolLabel, noun = "setting", entries, relativeFilePath, logger }) {
36549
+ if (entries.length === 0) return;
36550
+ const one = entries.length === 1;
36551
+ const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
36552
+ logger?.warn(`${toolLabel} permissions: writing ${entries.length} trust-affecting ${noun}${one ? "" : "s"} to ${relativeFilePath}; review ${one ? "it" : "them"} as you would a hook, especially if this permissions file came from 'rulesync fetch'. ${details}.`);
36553
+ }
36554
+ //#endregion
36352
36555
  //#region src/features/permissions/claudecode-permissions.ts
36353
36556
  /**
36354
36557
  * Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
@@ -36397,19 +36600,6 @@ function parseClaudePermissionEntry(entry) {
36397
36600
  };
36398
36601
  }
36399
36602
  /**
36400
- * Claude Code's file permission checks match only `Edit(path)` and `Read(path)`
36401
- * rules. A `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted
36402
- * but never matched by those checks, so Claude Code warns at startup for each
36403
- * allow, deny, or ask rule in one of these unmatched forms" — so a canonical
36404
- * `write`/`notebookedit`/`glob` rule with a pattern is emitted in the form the
36405
- * docs prescribe instead. A tool-name rule with no path is unaffected: it
36406
- * matches the tool everywhere and produces no warning.
36407
- * @see https://code.claude.com/docs/en/permissions
36408
- */
36409
- function isPlainRecord(value) {
36410
- return typeof value === "object" && value !== null && !Array.isArray(value);
36411
- }
36412
- /**
36413
36603
  * Merge `patch` into `base`, recursing into plain objects so a sibling key at
36414
36604
  * any depth survives. Arrays and scalars are replaced, since a list the author
36415
36605
  * states is the list they mean.
@@ -36419,7 +36609,7 @@ function deepMergeRecords(base, patch) {
36419
36609
  for (const [key, value] of Object.entries(patch)) {
36420
36610
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36421
36611
  const existing = merged[key];
36422
- merged[key] = isPlainRecord(existing) && isPlainRecord(value) ? deepMergeRecords(existing, value) : value;
36612
+ merged[key] = isRecord$1(existing) && isRecord$1(value) ? deepMergeRecords(existing, value) : value;
36423
36613
  }
36424
36614
  return merged;
36425
36615
  }
@@ -36484,13 +36674,11 @@ const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadP
36484
36674
  * traversed rather than silently skipped.
36485
36675
  */
36486
36676
  function resolveSandboxParent({ root, segments }) {
36487
- let parent = root;
36488
- for (const segment of segments) {
36489
- const next = parent[segment];
36490
- if (!isPlainRecord(next)) return void 0;
36491
- parent = next;
36492
- }
36493
- return parent;
36677
+ const resolved = readSandboxPath({
36678
+ sandbox: root,
36679
+ path: segments
36680
+ });
36681
+ return isRecord$1(resolved) ? resolved : void 0;
36494
36682
  }
36495
36683
  /**
36496
36684
  * Deletes `path` from `target` in place and reports whether anything was there,
@@ -36524,18 +36712,6 @@ function deleteSandboxPath({ target, path }) {
36524
36712
  return true;
36525
36713
  }
36526
36714
  /**
36527
- * The one warning that names every trust-affecting setting this generate wrote
36528
- * to `relativeFilePath`. Emitted once per file: the individual reasons are what
36529
- * matter, but the "review this as you would a hook" framing only needs saying
36530
- * once, and repeating it per key buries the reasons in boilerplate.
36531
- */
36532
- function warnOnTrustAffectingEntries({ entries, relativeFilePath, logger }) {
36533
- if (entries.length === 0) return;
36534
- const one = entries.length === 1;
36535
- const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
36536
- logger?.warn(`Claude Code permissions: writing ${entries.length} trust-affecting ${one ? "setting" : "settings"} to ${relativeFilePath}; review ${one ? "it" : "them"} as you would a hook, especially if this permissions file came from 'rulesync fetch'. ${details}.`);
36537
- }
36538
- /**
36539
36715
  * The `permissions.defaultMode` values that start a session with fewer prompts
36540
36716
  * than the default. `plan` and `default` are absent because they do not widen
36541
36717
  * anything.
@@ -36580,18 +36756,6 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
36580
36756
  ["socatPath"]
36581
36757
  ];
36582
36758
  /**
36583
- * The predicates the "which value actually widens?" tables are built from.
36584
- * Each names the value that does *not* widen and reports everything else, never
36585
- * the reverse: the override is authored JSONC, so a key can carry any value at
36586
- * all, and one Claude Code coerces is still honored. Reporting an off-type value
36587
- * keeps the warning fail-safe — silence has to mean "this cannot loosen
36588
- * anything", not "this is not the type the table expected".
36589
- */
36590
- const isNotFalse = (value) => value !== false;
36591
- const isNotTrue = (value) => value !== true;
36592
- const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
36593
- const isNonEmptyMap = (value) => !isPlainRecord(value) || Object.keys(value).length > 0;
36594
- /**
36595
36759
  * `sandbox` paths that loosen the sandbox rather than naming something to run:
36596
36760
  * they let commands out of it, weaken the isolation it provides, or redirect
36597
36761
  * where its traffic goes. They are written like `env` is — the ordinary uses are
@@ -36694,30 +36858,6 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
36694
36858
  widens: () => true
36695
36859
  }
36696
36860
  ];
36697
- /**
36698
- * Every authored `sandbox` path that loosens the sandbox. Nothing is removed —
36699
- * the values are written, just not silently. Called on the filtered `sandbox`
36700
- * so it never claims to be writing a path the scope filters dropped.
36701
- */
36702
- function collectTrustAffectingSandboxPaths({ sandbox }) {
36703
- const entries = [];
36704
- for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
36705
- const leaf = path.at(-1);
36706
- if (leaf === void 0) continue;
36707
- const parent = resolveSandboxParent({
36708
- root: sandbox,
36709
- segments: path.slice(0, -1)
36710
- });
36711
- if (parent === void 0) continue;
36712
- const value = parent[leaf];
36713
- if (value === void 0 || !widens(value)) continue;
36714
- entries.push({
36715
- label: `sandbox.${path.join(".")}`,
36716
- reason
36717
- });
36718
- }
36719
- return entries;
36720
- }
36721
36861
  /** Paths that name an executable Claude Code runs. Refused in both scopes. */
36722
36862
  const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
36723
36863
  paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
@@ -36788,13 +36928,13 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
36788
36928
  */
36789
36929
  function stripProjectIgnoredMaskEntries({ sandbox, relativeFilePath, logger }) {
36790
36930
  const credentials = sandbox.credentials;
36791
- if (!isPlainRecord(credentials)) return sandbox;
36931
+ if (!isRecord$1(credentials)) return sandbox;
36792
36932
  const filteredCredentials = { ...credentials };
36793
36933
  let changed = false;
36794
36934
  for (const listKey of CLAUDECODE_MASKABLE_CREDENTIAL_LISTS) {
36795
36935
  const list = filteredCredentials[listKey];
36796
36936
  if (!Array.isArray(list)) continue;
36797
- const kept = list.filter((entry) => !(isPlainRecord(entry) && entry.mode === "mask"));
36937
+ const kept = list.filter((entry) => !(isRecord$1(entry) && entry.mode === "mask"));
36798
36938
  if (kept.length === list.length) continue;
36799
36939
  changed = true;
36800
36940
  const dropped = list.length - kept.length;
@@ -37073,6 +37213,16 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
37073
37213
  trustAffecting
37074
37214
  };
37075
37215
  }
37216
+ /**
37217
+ * Claude Code's file permission checks match only `Edit(path)` and `Read(path)`
37218
+ * rules. A `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted
37219
+ * but never matched by those checks, so Claude Code warns at startup for each
37220
+ * allow, deny, or ask rule in one of these unmatched forms" — so a canonical
37221
+ * `write`/`notebookedit`/`glob` rule with a pattern is emitted in the form the
37222
+ * docs prescribe instead. A tool-name rule with no path is unaffected: it
37223
+ * matches the tool everywhere and produces no warning.
37224
+ * @see https://code.claude.com/docs/en/permissions
37225
+ */
37076
37226
  const CLAUDE_PATH_RULE_ALIASES = {
37077
37227
  Write: "Edit",
37078
37228
  NotebookEdit: "Edit",
@@ -37153,7 +37303,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37153
37303
  };
37154
37304
  }
37155
37305
  const overrideSandbox = config.claudecode?.sandbox;
37156
- if (isPlainRecord(overrideSandbox)) {
37306
+ if (isRecord$1(overrideSandbox)) {
37157
37307
  const honorableSandbox = stripSandboxPaths({
37158
37308
  sandbox: overrideSandbox,
37159
37309
  refusals: [
@@ -37169,8 +37319,11 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37169
37319
  relativeFilePath: paths.relativeFilePath,
37170
37320
  logger
37171
37321
  });
37172
- trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
37173
- if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
37322
+ trustAffecting.push(...collectTrustAffectingSandboxPaths({
37323
+ sandbox: scopedSandbox,
37324
+ paths: CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS
37325
+ }));
37326
+ if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isRecord$1(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
37174
37327
  }
37175
37328
  const overrideTopLevel = {};
37176
37329
  for (const [key, value] of Object.entries(config.claudecode ?? {})) {
@@ -37188,8 +37341,9 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37188
37341
  trustAffecting.push(...trustAffectingTopLevel);
37189
37342
  if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
37190
37343
  warnOnTrustAffectingEntries({
37344
+ toolLabel: "Claude Code",
37191
37345
  entries: trustAffecting,
37192
- relativeFilePath: paths.relativeFilePath,
37346
+ relativeFilePath: toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)),
37193
37347
  logger
37194
37348
  });
37195
37349
  const managedToolNames = managedClaudeToolNames(config);
@@ -37232,7 +37386,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37232
37386
  const nonListFields = Object.fromEntries(Object.entries(permissionsRest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
37233
37387
  if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
37234
37388
  const { sandbox } = settings;
37235
- if (isPlainRecord(sandbox)) {
37389
+ if (isRecord$1(sandbox)) {
37236
37390
  const importedSandbox = structuredClone(sandbox);
37237
37391
  for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) deleteSandboxPath({
37238
37392
  target: importedSandbox,
@@ -39485,6 +39639,80 @@ function buildDevinPermissionEntry(scope, pattern) {
39485
39639
  if (pattern === "*") return scope;
39486
39640
  return `${scope}(${pattern})`;
39487
39641
  }
39642
+ function asDevinRecord(value) {
39643
+ return isRecord$1(value) ? { ...value } : {};
39644
+ }
39645
+ /**
39646
+ * `sandbox` paths whose authored value loosens the sandbox on its own: they let
39647
+ * a command out of it, or widen what a command left inside it may reach. They
39648
+ * are written — the ordinary uses are far too common to refuse — but never
39649
+ * silently, because a permissions file is shareable (`rulesync fetch` copies one
39650
+ * into a project) and should not be able to open the sandbox without saying so.
39651
+ * This is the same stance `CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS` takes for
39652
+ * the equivalent Claude Code keys, and `widens` follows the same convention of
39653
+ * naming the restrictive value rather than the permissive ones, so a spelling
39654
+ * Devin does not recognize is reported rather than waved through.
39655
+ *
39656
+ * The three keys that restrict — `allowed_domains` (an allowlist only while it
39657
+ * has entries), `denied_domains` and `excluded.deny` — are not here: they loosen
39658
+ * by losing entries, which `DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS` covers.
39659
+ *
39660
+ * @see https://docs.devin.ai/cli/sandbox
39661
+ */
39662
+ const DEVIN_TRUST_AFFECTING_SANDBOX_PATHS = [
39663
+ {
39664
+ path: ["network_mode"],
39665
+ reason: "anything but 'limited' lets sandboxed requests use every HTTP method, not just GET/HEAD/OPTIONS",
39666
+ widens: (value) => value !== "limited"
39667
+ },
39668
+ {
39669
+ path: ["excluded", "allow"],
39670
+ reason: "names commands that run outside the sandbox with no prompt and no sandbox policy",
39671
+ widens: isNonEmptyList
39672
+ },
39673
+ {
39674
+ path: ["excluded", "ask"],
39675
+ reason: "names commands that run outside the sandbox once confirmed, with no sandbox policy",
39676
+ widens: isNonEmptyList
39677
+ }
39678
+ ];
39679
+ /** How Devin is named in the warnings this file emits. */
39680
+ const DEVIN_TOOL_LABEL = "Devin";
39681
+ /**
39682
+ * `sandbox` paths that restrict, and that therefore loosen the policy by losing
39683
+ * entries rather than by holding a value. Devin's config is one file rather than
39684
+ * a stack of settings scopes, and the override is shallow-merged over the
39685
+ * existing `sandbox` at its top level: each of these lists is replaced whole,
39686
+ * and `excluded.deny` vanishes as soon as the override states any other
39687
+ * `excluded` key. Losing an entry has the same effect as adding one to the
39688
+ * permissive keys above, so it is announced the same way. Claude Code needs no
39689
+ * equivalent — it merges its lists across settings scopes, so a file can only
39690
+ * ever add to them.
39691
+ *
39692
+ * `loosens` is asked only about a `before` that actually restricted something,
39693
+ * and the two directions are not symmetric: `allowed_domains` restricts by
39694
+ * listing what is reachable, so it loosens by gaining entries or by emptying
39695
+ * out altogether, while the deny lists loosen by losing entries.
39696
+ *
39697
+ * @see https://docs.devin.ai/cli/sandbox
39698
+ */
39699
+ const DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS = [
39700
+ {
39701
+ path: ["allowed_domains"],
39702
+ reason: "adds to the proxy allowlist already in the file, or empties it so every domain becomes reachable again",
39703
+ loosens: ({ before, after }) => after.length === 0 || after.some((entry) => !before.includes(entry))
39704
+ },
39705
+ {
39706
+ path: ["denied_domains"],
39707
+ reason: "drops domains the deny list already in the file kept out of reach",
39708
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
39709
+ },
39710
+ {
39711
+ path: ["excluded", "deny"],
39712
+ reason: "drops commands the list already in the file pinned inside the sandbox",
39713
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
39714
+ }
39715
+ ];
39488
39716
  /**
39489
39717
  * Permissions generator for Devin Local (native `.devin/` configuration).
39490
39718
  *
@@ -39500,10 +39728,18 @@ function buildDevinPermissionEntry(scope, pattern) {
39500
39728
  *
39501
39729
  * In global mode the config file is shared with the hooks (`hooks`) feature
39502
39730
  * (MCP moved to the dedicated mcp_config.json in v3000.3), so reads and writes
39503
- * merge into the existing JSON and the file is never deleted; only the managed
39504
- * `permissions` key is rewritten.
39731
+ * merge into the existing JSON and the file is never deleted; only the keys
39732
+ * this feature manages are rewritten — `permissions`, plus `sandbox` in global
39733
+ * mode when the `devin` override authors it.
39734
+ *
39735
+ * The sibling `sandbox` block — which decides what a permitted command may
39736
+ * reach rather than which commands are permitted — has no canonical category
39737
+ * and is authored through the `devin` override in `.rulesync/permissions.jsonc`.
39738
+ * Devin documents it as a user-config-only key, so it is written at global
39739
+ * scope only.
39505
39740
  *
39506
39741
  * @see https://docs.devin.ai/cli/reference/permissions
39742
+ * @see https://docs.devin.ai/cli/sandbox
39507
39743
  */
39508
39744
  var DevinPermissions = class DevinPermissions extends ToolPermissions {
39509
39745
  constructor(params) {
@@ -39514,7 +39750,8 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39514
39750
  }
39515
39751
  /**
39516
39752
  * config.json may carry the MCP/hooks features' keys, so it is never deleted;
39517
- * only the managed `permissions` key is rewritten.
39753
+ * only the keys this feature manages are rewritten — `permissions`, plus
39754
+ * `sandbox` in global mode when the `devin` override authors it.
39518
39755
  */
39519
39756
  isDeletable() {
39520
39757
  return false;
@@ -39540,7 +39777,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39540
39777
  validate
39541
39778
  });
39542
39779
  }
39543
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true }) {
39780
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true, logger }) {
39544
39781
  const paths = DevinPermissions.getSettablePaths({ global });
39545
39782
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
39546
39783
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
@@ -39566,6 +39803,46 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39566
39803
  else delete mergedPermissions.ask;
39567
39804
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
39568
39805
  else delete mergedPermissions.deny;
39806
+ const patch = { permissions: mergedPermissions };
39807
+ const authoredSandbox = config.devin?.sandbox;
39808
+ if (authoredSandbox !== void 0) {
39809
+ const authoredSandboxRecord = asDevinRecord(authoredSandbox);
39810
+ if (global) {
39811
+ const existingSandbox = asDevinRecord(settings.sandbox);
39812
+ const mergedSandbox = {
39813
+ ...existingSandbox,
39814
+ ...authoredSandboxRecord
39815
+ };
39816
+ const writesSandbox = Object.keys(mergedSandbox).length > 0;
39817
+ if (writesSandbox) patch.sandbox = mergedSandbox;
39818
+ const replacesUnreadableSandbox = writesSandbox && settings.sandbox !== void 0 && !isRecord$1(settings.sandbox);
39819
+ warnOnTrustAffectingEntries({
39820
+ toolLabel: DEVIN_TOOL_LABEL,
39821
+ noun: "sandbox change",
39822
+ entries: [
39823
+ ...replacesUnreadableSandbox ? [{
39824
+ label: "sandbox",
39825
+ reason: replacedUnreadableReason({
39826
+ shape: "object",
39827
+ toolLabel: DEVIN_TOOL_LABEL
39828
+ })
39829
+ }] : [],
39830
+ ...collectTrustAffectingSandboxPaths({
39831
+ sandbox: authoredSandboxRecord,
39832
+ paths: DEVIN_TRUST_AFFECTING_SANDBOX_PATHS
39833
+ }),
39834
+ ...collectRestrictionLosingSandboxEntries({
39835
+ existing: existingSandbox,
39836
+ merged: mergedSandbox,
39837
+ paths: DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS,
39838
+ toolLabel: DEVIN_TOOL_LABEL
39839
+ })
39840
+ ],
39841
+ relativeFilePath: toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)),
39842
+ logger
39843
+ });
39844
+ } else if (Object.keys(authoredSandboxRecord).length > 0) logger?.warn("Devin reads 'sandbox' from the user config only, so the 'devin.sandbox' override was dropped from the project config. Generate with --global to author it.");
39845
+ }
39569
39846
  return new DevinPermissions({
39570
39847
  outputRoot,
39571
39848
  relativeDirPath: paths.relativeDirPath,
@@ -39574,7 +39851,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39574
39851
  fileKey: sharedConfigFileKey(paths),
39575
39852
  feature: "permissions",
39576
39853
  existingContent,
39577
- patch: { permissions: mergedPermissions },
39854
+ patch,
39578
39855
  filePath
39579
39856
  }),
39580
39857
  validate
@@ -39594,7 +39871,10 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39594
39871
  ask: Array.isArray(permissions.ask) ? permissions.ask : [],
39595
39872
  deny: Array.isArray(permissions.deny) ? permissions.deny : []
39596
39873
  });
39597
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
39874
+ const sandbox = asDevinRecord(settings.sandbox);
39875
+ const result = { ...config };
39876
+ if (Object.keys(sandbox).length > 0) result.devin = { sandbox };
39877
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
39598
39878
  }
39599
39879
  validate() {
39600
39880
  return {
@@ -42542,6 +42822,137 @@ function buildReasonixPermissionEntry(toolName, pattern) {
42542
42822
  if (pattern === "*") return toolName;
42543
42823
  return `${toolName}(${pattern})`;
42544
42824
  }
42825
+ /** How Reasonix is named in the warnings this file emits. */
42826
+ const REASONIX_TOOL_LABEL = "Reasonix";
42827
+ /** The `[permissions]` key the override's `allowDynamicBash` writes and reads. */
42828
+ const REASONIX_ALLOW_DYNAMIC_BASH_KEY = "allow_dynamic_bash";
42829
+ /**
42830
+ * `[sandbox]` keys whose authored value loosens the enforcement layer beneath
42831
+ * the permission policy: they take Bash out of its OS jail, open that jail to
42832
+ * the network, or widen where the file-writing built-ins may write. Written —
42833
+ * the ordinary uses are far too common to refuse — but never silently, the same
42834
+ * stance `DEVIN_TRUST_AFFECTING_SANDBOX_PATHS` takes, and `widens` likewise
42835
+ * names the restrictive value so a spelling Reasonix does not recognize is
42836
+ * reported rather than waved through.
42837
+ *
42838
+ * `forbid_read` is not here: it restricts, so it loosens by losing entries
42839
+ * rather than by holding one, which needs the before/after comparison
42840
+ * {@link REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS} below does instead.
42841
+ *
42842
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
42843
+ */
42844
+ const REASONIX_TRUST_AFFECTING_SANDBOX_PATHS = [
42845
+ {
42846
+ path: ["bash"],
42847
+ reason: "anything but 'enforce' takes Bash out of the OS sandbox, so a command may write and read wherever the user can",
42848
+ widens: (value) => value !== "enforce"
42849
+ },
42850
+ {
42851
+ path: ["network"],
42852
+ reason: "lets sandboxed Bash reach the network",
42853
+ widens: isNotFalse
42854
+ },
42855
+ {
42856
+ path: ["allow_write"],
42857
+ reason: "adds directories the file-writing tools may modify outside the workspace root, which a headless run would otherwise refuse",
42858
+ widens: isNonEmptyList
42859
+ },
42860
+ {
42861
+ path: ["workspace_root"],
42862
+ reason: "moves the root the file-writing tools and sandboxed Bash are confined to, so what they may reach is decided by this path rather than by the project directory",
42863
+ widens: escapesTheProject
42864
+ }
42865
+ ];
42866
+ /**
42867
+ * Whether a `workspace_root` points somewhere other than inside the project the
42868
+ * generate runs in. The key moves the write confinement rather than adding to
42869
+ * it, so the ordinary value — the project directory, spelled relatively — would
42870
+ * otherwise be announced on every generate; anything else is the case worth
42871
+ * naming, since it is how a fetched permissions file would put `~/.ssh` or
42872
+ * `C:\\Users\\<user>` inside the jail: an absolute path in either flavour, one
42873
+ * carrying a drive letter, a home-relative one, a shell or environment
42874
+ * expansion, and any path holding a `..` segment — even one that would land back
42875
+ * inside, since resolving it here would only be a guess at what Reasonix does.
42876
+ * Both path flavours are asked because the file is authored on one machine and
42877
+ * generated on another, so a Windows-shaped root reaching a POSIX check must not
42878
+ * read as relative. Anything that is not a string is reported, per the fail-safe
42879
+ * rule the predicates in `sandbox-trust.ts` follow.
42880
+ */
42881
+ function escapesTheProject(value) {
42882
+ if (typeof value !== "string") return true;
42883
+ const trimmed = value.trim();
42884
+ if (trimmed === "") return false;
42885
+ if (trimmed.startsWith("~")) return true;
42886
+ if (node_path.posix.isAbsolute(trimmed) || node_path.win32.isAbsolute(trimmed)) return true;
42887
+ if (/^[A-Za-z]:/.test(trimmed)) return true;
42888
+ if (trimmed.includes("$") || /%[^%]+%/.test(trimmed)) return true;
42889
+ return trimmed.split(/[\\/]/).includes("..");
42890
+ }
42891
+ /**
42892
+ * The `[sandbox]` key that restricts, and so loosens by losing entries rather
42893
+ * than by holding a value. The override is shallow-merged over the existing
42894
+ * `[sandbox]` at its top level, so an authored `forbid_read` replaces the list
42895
+ * the file had whole — emptying it, or dropping the `${HOME}/.ssh` entry
42896
+ * Reasonix's own example recommends, opens exactly what the list kept closed.
42897
+ *
42898
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
42899
+ */
42900
+ const REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS = [{
42901
+ path: ["forbid_read"],
42902
+ reason: "drops paths the list already in the file kept out of read, list and search",
42903
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
42904
+ }];
42905
+ /**
42906
+ * Everything worth naming about the `[sandbox]` table this generate is about to
42907
+ * write: the authored values that loosen enforcement, the restrictions the
42908
+ * shallow merge would drop, and a `[sandbox]` the file holds in some shape other
42909
+ * than a table, which the write replaces wholesale. The widening check reads the
42910
+ * authored block alone — a loosening value the file already held is the user's
42911
+ * own, and re-announcing it on every generate would bury the values rulesync
42912
+ * actually wrote — while a loss can only be seen from both sides.
42913
+ */
42914
+ function collectSandboxOverlayEntries({ existing, authored, merged }) {
42915
+ return [
42916
+ ...existing !== void 0 && !isRecord$1(existing) ? [{
42917
+ label: "sandbox",
42918
+ reason: replacedUnreadableReason({
42919
+ shape: "object",
42920
+ toolLabel: REASONIX_TOOL_LABEL
42921
+ })
42922
+ }] : [],
42923
+ ...collectTrustAffectingSandboxPaths({
42924
+ sandbox: asReasonixRecord(authored),
42925
+ paths: REASONIX_TRUST_AFFECTING_SANDBOX_PATHS
42926
+ }),
42927
+ ...collectRestrictionLosingSandboxEntries({
42928
+ existing: asReasonixRecord(existing),
42929
+ merged,
42930
+ paths: REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS,
42931
+ toolLabel: REASONIX_TOOL_LABEL
42932
+ })
42933
+ ];
42934
+ }
42935
+ /**
42936
+ * Writes the override's `allow_dynamic_bash` into `[permissions]`, where it sits
42937
+ * beside allow/ask/deny rather than in a table of its own, and reports it when
42938
+ * it is being turned on: it widens what a shareable permissions file lets run
42939
+ * with no human in the loop. Only an authored value is written — leaving the key
42940
+ * out of the override keeps whatever the file already had — and turning it off
42941
+ * narrows, so that stays quiet. Only a literal `false` is quiet, not everything
42942
+ * falsy: `getJson()` casts rather than parses, so a `--no-validate` run can put
42943
+ * a value the schema forbids here, and a value Reasonix might still coerce is
42944
+ * not something to write in silence. The entries are returned rather than logged so
42945
+ * one generate still produces one warning naming everything it wrote.
42946
+ */
42947
+ function applyAllowDynamicBash({ permissions, authored }) {
42948
+ if (authored === void 0) return [];
42949
+ permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY] = authored;
42950
+ if (!isNotFalse(authored)) return [];
42951
+ return [{
42952
+ label: `permissions.${REASONIX_ALLOW_DYNAMIC_BASH_KEY}`,
42953
+ reason: "lets an Allow fallback, Auto included, run the nested and indirect Bash that otherwise needs a human or an exact-literal grant — command and process substitution, a dynamic command name, 'eval', 'source', 'sh -c' and their kind"
42954
+ }];
42955
+ }
42545
42956
  function parseReasonixConfig(fileContent) {
42546
42957
  const parsed = smol_toml.parse(fileContent || smol_toml.stringify({}));
42547
42958
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -42598,6 +43009,7 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42598
43009
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
42599
43010
  const paths = this.getSettablePaths({ global });
42600
43011
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
43012
+ const relativeFilePathForLog = toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath));
42601
43013
  const existingContent = await readFileContentOrNull(filePath) ?? "";
42602
43014
  const parsed = parseReasonixConfig(existingContent);
42603
43015
  const config = rulesyncPermissions.getJson();
@@ -42642,11 +43054,23 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42642
43054
  ...deny,
42643
43055
  ...rawDeny
42644
43056
  ]);
43057
+ const trustAffecting = applyAllowDynamicBash({
43058
+ permissions: mergedPermissions,
43059
+ authored: override?.allowDynamicBash
43060
+ });
42645
43061
  const patch = { permissions: mergedPermissions };
42646
- if (override?.sandbox !== void 0) patch.sandbox = {
42647
- ...asReasonixRecord(parsed.sandbox),
42648
- ...asReasonixRecord(override.sandbox)
42649
- };
43062
+ if (override?.sandbox !== void 0) {
43063
+ const mergedSandbox = {
43064
+ ...asReasonixRecord(parsed.sandbox),
43065
+ ...asReasonixRecord(override.sandbox)
43066
+ };
43067
+ patch.sandbox = mergedSandbox;
43068
+ trustAffecting.push(...collectSandboxOverlayEntries({
43069
+ existing: parsed.sandbox,
43070
+ authored: override.sandbox,
43071
+ merged: mergedSandbox
43072
+ }));
43073
+ }
42650
43074
  if (override?.agent !== void 0) {
42651
43075
  const mergedAgent = {
42652
43076
  ...asReasonixRecord(parsed.agent),
@@ -42654,9 +43078,16 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42654
43078
  };
42655
43079
  const retired = REASONIX_RETIRED_AGENT_KEYS.filter((key) => mergedAgent[key] !== void 0);
42656
43080
  for (const key of retired) delete mergedAgent[key];
42657
- if (retired.length > 0) logger?.warn(`Reasonix permissions: removing ${retired.map((key) => `"${key}"`).join(", ")} from [agent] in ${filePath}; Reasonix took the key off its config surface in v1.17.18, so what it used to express now belongs in the shared \`permission\` block.`);
43081
+ if (retired.length > 0) logger?.warn(`Reasonix permissions: removing ${retired.map((key) => `"${key}"`).join(", ")} from [agent] in ${relativeFilePathForLog}; Reasonix took the key off its config surface in v1.17.18, so what it used to express now belongs in the shared \`permission\` block.`);
42658
43082
  patch.agent = mergedAgent;
42659
43083
  }
43084
+ warnOnTrustAffectingEntries({
43085
+ toolLabel: REASONIX_TOOL_LABEL,
43086
+ noun: "change",
43087
+ entries: trustAffecting,
43088
+ relativeFilePath: relativeFilePathForLog,
43089
+ logger
43090
+ });
42660
43091
  return new ReasonixPermissions({
42661
43092
  outputRoot,
42662
43093
  relativeDirPath: paths.relativeDirPath,
@@ -42691,6 +43122,8 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42691
43122
  const sandbox = asReasonixRecord(this.toml.sandbox);
42692
43123
  const agentPlanMode = pickReasonixKeys(this.toml.agent, [...REASONIX_OVERRIDE_AGENT_KEYS, ...REASONIX_RETIRED_AGENT_KEYS]);
42693
43124
  const reasonixOverride = {};
43125
+ const allowDynamicBash = permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY];
43126
+ if (typeof allowDynamicBash === "boolean") reasonixOverride.allowDynamicBash = allowDynamicBash;
42694
43127
  if (Object.keys(sandbox).length > 0) reasonixOverride.sandbox = sandbox;
42695
43128
  if (Object.keys(agentPlanMode).length > 0) reasonixOverride.agent = agentPlanMode;
42696
43129
  if (allowSplit.exact.length > 0) reasonixOverride.rawAllow = allowSplit.exact;