rulesync 16.22.1 → 16.24.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.
@@ -6658,10 +6658,25 @@ const CursorPermissionsOverrideSchema = zod_mini.z.looseObject({
6658
6658
  * Tool-scoped override block for Qwen Code. Qwen's `settings.json` exposes
6659
6659
  * autonomy/sandbox controls with no canonical permission category — under
6660
6660
  * `tools` (`approvalMode` = plan/default/auto-edit/auto/yolo, `autoAccept`,
6661
- * `sandbox`, `sandboxImage`, `disabled`) and `security` (`folderTrust`,
6662
- * `allowedHttpHookUrls`, `allowPrivateNetworkHooks` the latter is honored by
6663
- * Qwen Code only in user/system settings, so generate skips it in project scope).
6664
- * It also
6661
+ * `sandbox`, `sandboxImage`, `disabled`, `visible`, `listDirectory`,
6662
+ * `workflowsEnabled`) and `security` (`folderTrust`, `allowedHttpHookUrls`,
6663
+ * `allowPrivateNetworkHooks`, `allowedInsecureVoiceBaseUrls`). Qwen Code strips
6664
+ * `tools.workflowsEnabled`, `security.allowPrivateNetworkHooks` and
6665
+ * `security.allowedInsecureVoiceBaseUrls` out of workspace settings, so generate
6666
+ * skips those three in project scope and announces a granting value in global
6667
+ * scope. `security.allowedHttpHookUrls` (honored in a workspace only while no
6668
+ * higher scope sets it) and `security.folderTrust` (the initial trust decision
6669
+ * is made from user/system settings alone, before the workspace merge) are
6670
+ * written in both scopes, with a note in project scope and an announcement of
6671
+ * any global change. Every other key is honored in either scope, so a write that
6672
+ * changes what the file said is reported in either scope, naming what that key
6673
+ * decides there — the autonomy and containment
6674
+ * controls (`approvalMode`, `autoAccept`, `sandbox`, `sandboxImage`), the
6675
+ * registry controls (`disabled`, `visible`, `listDirectory`), the Auto Mode
6676
+ * classifier config, and, because these groups are loose objects, any key
6677
+ * rulesync does not model. Import flags a scope-dependent key only when it read
6678
+ * the project file, since that is the value a `--global` regenerate would
6679
+ * promote. It also
6665
6680
  * exposes `permissions.autoMode` (the Auto Mode classifier config:
6666
6681
  * `hints.{allow,softDeny,hardDeny}`, `environment`, `classifyAllShell` — see
6667
6682
  * https://qwenlm.github.io/qwen-code-docs/en/users/features/auto-mode/), which
@@ -6699,6 +6714,8 @@ const QwencodePermissionsOverrideSchema = zod_mini.z.looseObject({
6699
6714
  *
6700
6715
  * @example
6701
6716
  * { "sandbox": { "bash": "enforce", "network": false }, "agent": { "plan_mode_read_only_commands": ["gh pr diff"] } }
6717
+ * @example
6718
+ * { "allowDynamicBash": true, "rawAllow": ["Bash=pnpm test"] }
6702
6719
  */
6703
6720
  const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
6704
6721
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
@@ -6706,7 +6723,8 @@ const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
6706
6723
  agent: zod_mini.z.optional(zod_mini.z.looseObject({})),
6707
6724
  rawAllow: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
6708
6725
  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()))
6726
+ rawDeny: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
6727
+ allowDynamicBash: zod_mini.z.optional(zod_mini.z.boolean())
6710
6728
  });
6711
6729
  /**
6712
6730
  * Tool-scoped override block for Factory Droid. Factory Droid's `settings.json`
@@ -7350,6 +7368,32 @@ const ZedPermissionsOverrideSchema = zod_mini.z.looseObject({
7350
7368
  })))
7351
7369
  });
7352
7370
  /**
7371
+ * Tool-scoped override block for Devin Local. `sandbox` is the sibling
7372
+ * top-level `config.json` block that governs the sandbox Devin runs commands
7373
+ * in: `allowed_domains` / `denied_domains` (proxy domain patterns, deny beating
7374
+ * allow), `network_mode` (`full`, the upstream default, allows every HTTP
7375
+ * method; `limited` only GET/HEAD/OPTIONS) and `excluded` (`allow` / `ask` /
7376
+ * `deny` lists of `Exec(...)` matchers deciding which commands run *outside* the
7377
+ * sandbox — `deny` pins them inside it). It constrains how
7378
+ * a permitted command runs rather than which commands are permitted, so it has
7379
+ * no canonical category and is authored here.
7380
+ *
7381
+ * Upstream lists `sandbox` as a **User Config Only** key, so it is emitted at
7382
+ * global scope only; at project scope it is dropped with a warning rather than
7383
+ * written into a file Devin would ignore.
7384
+ *
7385
+ * @example
7386
+ * { "sandbox": { "allowed_domains": ["github.com"], "network_mode": "limited" } }
7387
+ * @example
7388
+ * { "sandbox": { "excluded": { "allow": ["Exec(git status *)"], "deny": ["Exec(git tag *)"] } } }
7389
+ * @see https://docs.devin.ai/cli/sandbox
7390
+ * @see https://docs.devin.ai/cli/reference/configuration/config-file
7391
+ */
7392
+ const DevinPermissionsOverrideSchema = zod_mini.z.looseObject({
7393
+ permission: zod_mini.z.optional(ToolScopedPermissionSchema),
7394
+ sandbox: zod_mini.z.optional(zod_mini.z.looseObject({}))
7395
+ });
7396
+ /**
7353
7397
  * Permissions configuration.
7354
7398
  * Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
7355
7399
  * Values are pattern-to-action mappings for that tool category.
@@ -7399,10 +7443,10 @@ const PermissionsConfigSchema = zod_mini.z.looseObject({
7399
7443
  kiro: zod_mini.z.optional(KiroPermissionsOverrideSchema),
7400
7444
  codexcli: zod_mini.z.optional(CodexcliPermissionsOverrideSchema),
7401
7445
  zed: zod_mini.z.optional(ZedPermissionsOverrideSchema),
7446
+ devin: zod_mini.z.optional(DevinPermissionsOverrideSchema),
7402
7447
  "antigravity-ide": zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7403
7448
  copilot: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7404
7449
  copilotcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7405
- devin: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7406
7450
  goose: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7407
7451
  grokcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7408
7452
  "kimi-code": zod_mini.z.optional(KimiCodePermissionsOverrideSchema),
@@ -12628,7 +12672,7 @@ const SHARED_CONFIG_OWNERSHIP = {
12628
12672
  },
12629
12673
  permissions: {
12630
12674
  kind: "replace-owned-keys",
12631
- ownedKeys: ["permissions"]
12675
+ ownedKeys: ["permissions", "sandbox"]
12632
12676
  }
12633
12677
  }
12634
12678
  },
@@ -36349,6 +36393,180 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
36349
36393
  return { permission };
36350
36394
  }
36351
36395
  //#endregion
36396
+ //#region src/features/permissions/sandbox-trust.ts
36397
+ /** A key whose quiet value is an explicit `false`. */
36398
+ const isNotFalse = (value) => value !== false;
36399
+ /** A key whose quiet value is an explicit `true`. */
36400
+ const isNotTrue = (value) => value !== true;
36401
+ /** A list-valued key whose quiet value is the empty list. */
36402
+ const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
36403
+ /** The map-valued counterpart of {@link isNonEmptyList}. */
36404
+ const isNonEmptyMap = (value) => !isRecord$1(value) || Object.keys(value).length > 0;
36405
+ /**
36406
+ * What {@link readSandboxPath} returns when a container on the way to the leaf
36407
+ * is present but is not an object, so the leaf cannot be read at all. It is not
36408
+ * `undefined`, because the two mean opposite things to a caller: `undefined` is
36409
+ * "this path is not being written", while this is "something is being written
36410
+ * here and its shape hides what". The same fail-safe rule the predicates follow
36411
+ * applies to the walk — silence must mean "this cannot loosen anything", not
36412
+ * "this is not the shape the table expected".
36413
+ */
36414
+ const UNREADABLE_SANDBOX_PATH = Symbol("unreadable-sandbox-path");
36415
+ /**
36416
+ * Reads `sandbox` at `path`. Returns `undefined` when a segment is absent, and
36417
+ * {@link UNREADABLE_SANDBOX_PATH} when one is present but is not an object.
36418
+ * Shared by everything that addresses a `sandbox` path so a nested path added to
36419
+ * one of the tables is actually traversed rather than silently skipped, and so a
36420
+ * hostile shape (an array, a string, `null`) is reported rather than throwing.
36421
+ */
36422
+ function readSandboxPath({ sandbox, path }) {
36423
+ let cursor = sandbox;
36424
+ for (const segment of path) {
36425
+ if (cursor === void 0) return void 0;
36426
+ if (!isRecord$1(cursor)) return UNREADABLE_SANDBOX_PATH;
36427
+ cursor = cursor[segment];
36428
+ }
36429
+ return cursor;
36430
+ }
36431
+ /**
36432
+ * Every path in `paths` whose value in `sandbox` loosens the policy. Nothing is
36433
+ * removed — the values are written, just not silently. Call it on the block this
36434
+ * generate authored, after any scope filter has run: a value the file already
36435
+ * held is the user's own, not something rulesync opened, and a path a filter
36436
+ * dropped is not being written at all.
36437
+ */
36438
+ function collectTrustAffectingSandboxPaths({ sandbox, paths }) {
36439
+ const entries = [];
36440
+ const reportedContainers = /* @__PURE__ */ new Set();
36441
+ for (const { path, reason, widens } of paths) {
36442
+ const value = readSandboxPath({
36443
+ sandbox,
36444
+ path
36445
+ });
36446
+ if (value === void 0) continue;
36447
+ if (value === UNREADABLE_SANDBOX_PATH) {
36448
+ const label = findUnreadableContainer({
36449
+ sandbox,
36450
+ path
36451
+ });
36452
+ if (label === void 0 || reportedContainers.has(label)) continue;
36453
+ reportedContainers.add(label);
36454
+ entries.push({
36455
+ label,
36456
+ reason: UNREADABLE_CONTAINER_REASON
36457
+ });
36458
+ continue;
36459
+ }
36460
+ if (!widens(value)) continue;
36461
+ entries.push({
36462
+ label: `sandbox.${path.join(".")}`,
36463
+ reason
36464
+ });
36465
+ }
36466
+ return entries;
36467
+ }
36468
+ /** The reason printed for a container that hides the settings underneath it. */
36469
+ const UNREADABLE_CONTAINER_REASON = "is not the object it has to be, so nothing under it can be checked for what it opens";
36470
+ /**
36471
+ * The prefix of `path` that {@link readSandboxPath} could not walk past, as a
36472
+ * label. `undefined` when the walk was not blocked at all. Callers that report
36473
+ * an unreadable path name the container rather than the leaf, because the leaf
36474
+ * is not what the file actually holds.
36475
+ */
36476
+ function findUnreadableContainer({ sandbox, path }) {
36477
+ let cursor = sandbox;
36478
+ const walked = [];
36479
+ for (const segment of path) {
36480
+ if (cursor === void 0) return void 0;
36481
+ if (!isRecord$1(cursor)) return walked.length === 0 ? "sandbox" : `sandbox.${walked.join(".")}`;
36482
+ walked.push(segment);
36483
+ cursor = cursor[segment];
36484
+ }
36485
+ }
36486
+ /**
36487
+ * The reason printed for a value the file held in a shape that cannot be read,
36488
+ * which this generate is about to replace. `shape` names what the tool documents
36489
+ * there, so the message says which expectation the file's value missed.
36490
+ */
36491
+ 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`;
36492
+ /**
36493
+ * The restrictions this generate would weaken, compared between the `sandbox`
36494
+ * already in the file and the one about to replace it. A `before` that is
36495
+ * present but not a list is reported outright: a shape the tool may still honor
36496
+ * is not something to go quiet about just because it cannot be diffed. Shared by
36497
+ * every tool whose override replaces a restricting list whole rather than
36498
+ * merging into it — Claude Code needs no equivalent, because it merges its lists
36499
+ * across settings scopes, so a file can only ever add to them.
36500
+ */
36501
+ function collectRestrictionLosingSandboxEntries({ existing, merged, paths, toolLabel }) {
36502
+ const entries = [];
36503
+ const reportedContainers = /* @__PURE__ */ new Set();
36504
+ for (const { path, reason, loosens } of paths) {
36505
+ const before = readSandboxPath({
36506
+ sandbox: existing,
36507
+ path
36508
+ });
36509
+ if (before === void 0) continue;
36510
+ const [rootKey] = path;
36511
+ if (rootKey !== void 0 && existing[rootKey] === merged[rootKey]) continue;
36512
+ const after = readSandboxPath({
36513
+ sandbox: merged,
36514
+ path
36515
+ });
36516
+ const label = `sandbox.${path.join(".")}`;
36517
+ if (before === UNREADABLE_SANDBOX_PATH) {
36518
+ const container = findUnreadableContainer({
36519
+ sandbox: existing,
36520
+ path
36521
+ });
36522
+ if (container === void 0 || reportedContainers.has(container)) continue;
36523
+ reportedContainers.add(container);
36524
+ entries.push({
36525
+ label: container,
36526
+ reason: replacedUnreadableReason({
36527
+ shape: "object",
36528
+ toolLabel
36529
+ })
36530
+ });
36531
+ continue;
36532
+ }
36533
+ if (!Array.isArray(before)) {
36534
+ entries.push({
36535
+ label,
36536
+ reason: replacedUnreadableReason({
36537
+ shape: "list",
36538
+ toolLabel
36539
+ })
36540
+ });
36541
+ continue;
36542
+ }
36543
+ if (before.length === 0) continue;
36544
+ if (!loosens({
36545
+ before,
36546
+ after: Array.isArray(after) ? after : []
36547
+ })) continue;
36548
+ entries.push({
36549
+ label,
36550
+ reason
36551
+ });
36552
+ }
36553
+ return entries;
36554
+ }
36555
+ /**
36556
+ * The one warning that names every trust-affecting setting this generate wrote
36557
+ * to `relativeFilePath`. Emitted once per file: the individual reasons are what
36558
+ * matter, but the "review this as you would a hook" framing only needs saying
36559
+ * once, and repeating it per key buries the reasons in boilerplate. `noun` lets
36560
+ * a tool whose entries are not all additions call them something more accurate
36561
+ * than "setting".
36562
+ */
36563
+ function warnOnTrustAffectingEntries({ toolLabel, noun = "setting", entries, relativeFilePath, logger }) {
36564
+ if (entries.length === 0) return;
36565
+ const one = entries.length === 1;
36566
+ const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
36567
+ 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}.`);
36568
+ }
36569
+ //#endregion
36352
36570
  //#region src/features/permissions/claudecode-permissions.ts
36353
36571
  /**
36354
36572
  * Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
@@ -36397,19 +36615,6 @@ function parseClaudePermissionEntry(entry) {
36397
36615
  };
36398
36616
  }
36399
36617
  /**
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
36618
  * Merge `patch` into `base`, recursing into plain objects so a sibling key at
36414
36619
  * any depth survives. Arrays and scalars are replaced, since a list the author
36415
36620
  * states is the list they mean.
@@ -36419,7 +36624,7 @@ function deepMergeRecords(base, patch) {
36419
36624
  for (const [key, value] of Object.entries(patch)) {
36420
36625
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36421
36626
  const existing = merged[key];
36422
- merged[key] = isPlainRecord(existing) && isPlainRecord(value) ? deepMergeRecords(existing, value) : value;
36627
+ merged[key] = isRecord$1(existing) && isRecord$1(value) ? deepMergeRecords(existing, value) : value;
36423
36628
  }
36424
36629
  return merged;
36425
36630
  }
@@ -36484,13 +36689,11 @@ const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadP
36484
36689
  * traversed rather than silently skipped.
36485
36690
  */
36486
36691
  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;
36692
+ const resolved = readSandboxPath({
36693
+ sandbox: root,
36694
+ path: segments
36695
+ });
36696
+ return isRecord$1(resolved) ? resolved : void 0;
36494
36697
  }
36495
36698
  /**
36496
36699
  * Deletes `path` from `target` in place and reports whether anything was there,
@@ -36524,18 +36727,6 @@ function deleteSandboxPath({ target, path }) {
36524
36727
  return true;
36525
36728
  }
36526
36729
  /**
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
36730
  * The `permissions.defaultMode` values that start a session with fewer prompts
36540
36731
  * than the default. `plan` and `default` are absent because they do not widen
36541
36732
  * anything.
@@ -36580,18 +36771,6 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
36580
36771
  ["socatPath"]
36581
36772
  ];
36582
36773
  /**
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
36774
  * `sandbox` paths that loosen the sandbox rather than naming something to run:
36596
36775
  * they let commands out of it, weaken the isolation it provides, or redirect
36597
36776
  * where its traffic goes. They are written like `env` is — the ordinary uses are
@@ -36694,30 +36873,6 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
36694
36873
  widens: () => true
36695
36874
  }
36696
36875
  ];
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
36876
  /** Paths that name an executable Claude Code runs. Refused in both scopes. */
36722
36877
  const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
36723
36878
  paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
@@ -36788,13 +36943,13 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
36788
36943
  */
36789
36944
  function stripProjectIgnoredMaskEntries({ sandbox, relativeFilePath, logger }) {
36790
36945
  const credentials = sandbox.credentials;
36791
- if (!isPlainRecord(credentials)) return sandbox;
36946
+ if (!isRecord$1(credentials)) return sandbox;
36792
36947
  const filteredCredentials = { ...credentials };
36793
36948
  let changed = false;
36794
36949
  for (const listKey of CLAUDECODE_MASKABLE_CREDENTIAL_LISTS) {
36795
36950
  const list = filteredCredentials[listKey];
36796
36951
  if (!Array.isArray(list)) continue;
36797
- const kept = list.filter((entry) => !(isPlainRecord(entry) && entry.mode === "mask"));
36952
+ const kept = list.filter((entry) => !(isRecord$1(entry) && entry.mode === "mask"));
36798
36953
  if (kept.length === list.length) continue;
36799
36954
  changed = true;
36800
36955
  const dropped = list.length - kept.length;
@@ -37073,6 +37228,16 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
37073
37228
  trustAffecting
37074
37229
  };
37075
37230
  }
37231
+ /**
37232
+ * Claude Code's file permission checks match only `Edit(path)` and `Read(path)`
37233
+ * rules. A `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted
37234
+ * but never matched by those checks, so Claude Code warns at startup for each
37235
+ * allow, deny, or ask rule in one of these unmatched forms" — so a canonical
37236
+ * `write`/`notebookedit`/`glob` rule with a pattern is emitted in the form the
37237
+ * docs prescribe instead. A tool-name rule with no path is unaffected: it
37238
+ * matches the tool everywhere and produces no warning.
37239
+ * @see https://code.claude.com/docs/en/permissions
37240
+ */
37076
37241
  const CLAUDE_PATH_RULE_ALIASES = {
37077
37242
  Write: "Edit",
37078
37243
  NotebookEdit: "Edit",
@@ -37153,7 +37318,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37153
37318
  };
37154
37319
  }
37155
37320
  const overrideSandbox = config.claudecode?.sandbox;
37156
- if (isPlainRecord(overrideSandbox)) {
37321
+ if (isRecord$1(overrideSandbox)) {
37157
37322
  const honorableSandbox = stripSandboxPaths({
37158
37323
  sandbox: overrideSandbox,
37159
37324
  refusals: [
@@ -37169,8 +37334,11 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37169
37334
  relativeFilePath: paths.relativeFilePath,
37170
37335
  logger
37171
37336
  });
37172
- trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
37173
- if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
37337
+ trustAffecting.push(...collectTrustAffectingSandboxPaths({
37338
+ sandbox: scopedSandbox,
37339
+ paths: CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS
37340
+ }));
37341
+ if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isRecord$1(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
37174
37342
  }
37175
37343
  const overrideTopLevel = {};
37176
37344
  for (const [key, value] of Object.entries(config.claudecode ?? {})) {
@@ -37188,8 +37356,9 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37188
37356
  trustAffecting.push(...trustAffectingTopLevel);
37189
37357
  if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
37190
37358
  warnOnTrustAffectingEntries({
37359
+ toolLabel: "Claude Code",
37191
37360
  entries: trustAffecting,
37192
- relativeFilePath: paths.relativeFilePath,
37361
+ relativeFilePath: toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)),
37193
37362
  logger
37194
37363
  });
37195
37364
  const managedToolNames = managedClaudeToolNames(config);
@@ -37232,7 +37401,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37232
37401
  const nonListFields = Object.fromEntries(Object.entries(permissionsRest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
37233
37402
  if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
37234
37403
  const { sandbox } = settings;
37235
- if (isPlainRecord(sandbox)) {
37404
+ if (isRecord$1(sandbox)) {
37236
37405
  const importedSandbox = structuredClone(sandbox);
37237
37406
  for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) deleteSandboxPath({
37238
37407
  target: importedSandbox,
@@ -39485,6 +39654,80 @@ function buildDevinPermissionEntry(scope, pattern) {
39485
39654
  if (pattern === "*") return scope;
39486
39655
  return `${scope}(${pattern})`;
39487
39656
  }
39657
+ function asDevinRecord(value) {
39658
+ return isRecord$1(value) ? { ...value } : {};
39659
+ }
39660
+ /**
39661
+ * `sandbox` paths whose authored value loosens the sandbox on its own: they let
39662
+ * a command out of it, or widen what a command left inside it may reach. They
39663
+ * are written — the ordinary uses are far too common to refuse — but never
39664
+ * silently, because a permissions file is shareable (`rulesync fetch` copies one
39665
+ * into a project) and should not be able to open the sandbox without saying so.
39666
+ * This is the same stance `CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS` takes for
39667
+ * the equivalent Claude Code keys, and `widens` follows the same convention of
39668
+ * naming the restrictive value rather than the permissive ones, so a spelling
39669
+ * Devin does not recognize is reported rather than waved through.
39670
+ *
39671
+ * The three keys that restrict — `allowed_domains` (an allowlist only while it
39672
+ * has entries), `denied_domains` and `excluded.deny` — are not here: they loosen
39673
+ * by losing entries, which `DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS` covers.
39674
+ *
39675
+ * @see https://docs.devin.ai/cli/sandbox
39676
+ */
39677
+ const DEVIN_TRUST_AFFECTING_SANDBOX_PATHS = [
39678
+ {
39679
+ path: ["network_mode"],
39680
+ reason: "anything but 'limited' lets sandboxed requests use every HTTP method, not just GET/HEAD/OPTIONS",
39681
+ widens: (value) => value !== "limited"
39682
+ },
39683
+ {
39684
+ path: ["excluded", "allow"],
39685
+ reason: "names commands that run outside the sandbox with no prompt and no sandbox policy",
39686
+ widens: isNonEmptyList
39687
+ },
39688
+ {
39689
+ path: ["excluded", "ask"],
39690
+ reason: "names commands that run outside the sandbox once confirmed, with no sandbox policy",
39691
+ widens: isNonEmptyList
39692
+ }
39693
+ ];
39694
+ /** How Devin is named in the warnings this file emits. */
39695
+ const DEVIN_TOOL_LABEL = "Devin";
39696
+ /**
39697
+ * `sandbox` paths that restrict, and that therefore loosen the policy by losing
39698
+ * entries rather than by holding a value. Devin's config is one file rather than
39699
+ * a stack of settings scopes, and the override is shallow-merged over the
39700
+ * existing `sandbox` at its top level: each of these lists is replaced whole,
39701
+ * and `excluded.deny` vanishes as soon as the override states any other
39702
+ * `excluded` key. Losing an entry has the same effect as adding one to the
39703
+ * permissive keys above, so it is announced the same way. Claude Code needs no
39704
+ * equivalent — it merges its lists across settings scopes, so a file can only
39705
+ * ever add to them.
39706
+ *
39707
+ * `loosens` is asked only about a `before` that actually restricted something,
39708
+ * and the two directions are not symmetric: `allowed_domains` restricts by
39709
+ * listing what is reachable, so it loosens by gaining entries or by emptying
39710
+ * out altogether, while the deny lists loosen by losing entries.
39711
+ *
39712
+ * @see https://docs.devin.ai/cli/sandbox
39713
+ */
39714
+ const DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS = [
39715
+ {
39716
+ path: ["allowed_domains"],
39717
+ reason: "adds to the proxy allowlist already in the file, or empties it so every domain becomes reachable again",
39718
+ loosens: ({ before, after }) => after.length === 0 || after.some((entry) => !before.includes(entry))
39719
+ },
39720
+ {
39721
+ path: ["denied_domains"],
39722
+ reason: "drops domains the deny list already in the file kept out of reach",
39723
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
39724
+ },
39725
+ {
39726
+ path: ["excluded", "deny"],
39727
+ reason: "drops commands the list already in the file pinned inside the sandbox",
39728
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
39729
+ }
39730
+ ];
39488
39731
  /**
39489
39732
  * Permissions generator for Devin Local (native `.devin/` configuration).
39490
39733
  *
@@ -39500,10 +39743,18 @@ function buildDevinPermissionEntry(scope, pattern) {
39500
39743
  *
39501
39744
  * In global mode the config file is shared with the hooks (`hooks`) feature
39502
39745
  * (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.
39746
+ * merge into the existing JSON and the file is never deleted; only the keys
39747
+ * this feature manages are rewritten — `permissions`, plus `sandbox` in global
39748
+ * mode when the `devin` override authors it.
39749
+ *
39750
+ * The sibling `sandbox` block — which decides what a permitted command may
39751
+ * reach rather than which commands are permitted — has no canonical category
39752
+ * and is authored through the `devin` override in `.rulesync/permissions.jsonc`.
39753
+ * Devin documents it as a user-config-only key, so it is written at global
39754
+ * scope only.
39505
39755
  *
39506
39756
  * @see https://docs.devin.ai/cli/reference/permissions
39757
+ * @see https://docs.devin.ai/cli/sandbox
39507
39758
  */
39508
39759
  var DevinPermissions = class DevinPermissions extends ToolPermissions {
39509
39760
  constructor(params) {
@@ -39514,7 +39765,8 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39514
39765
  }
39515
39766
  /**
39516
39767
  * config.json may carry the MCP/hooks features' keys, so it is never deleted;
39517
- * only the managed `permissions` key is rewritten.
39768
+ * only the keys this feature manages are rewritten — `permissions`, plus
39769
+ * `sandbox` in global mode when the `devin` override authors it.
39518
39770
  */
39519
39771
  isDeletable() {
39520
39772
  return false;
@@ -39540,7 +39792,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39540
39792
  validate
39541
39793
  });
39542
39794
  }
39543
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true }) {
39795
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true, logger }) {
39544
39796
  const paths = DevinPermissions.getSettablePaths({ global });
39545
39797
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
39546
39798
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
@@ -39566,6 +39818,46 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39566
39818
  else delete mergedPermissions.ask;
39567
39819
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
39568
39820
  else delete mergedPermissions.deny;
39821
+ const patch = { permissions: mergedPermissions };
39822
+ const authoredSandbox = config.devin?.sandbox;
39823
+ if (authoredSandbox !== void 0) {
39824
+ const authoredSandboxRecord = asDevinRecord(authoredSandbox);
39825
+ if (global) {
39826
+ const existingSandbox = asDevinRecord(settings.sandbox);
39827
+ const mergedSandbox = {
39828
+ ...existingSandbox,
39829
+ ...authoredSandboxRecord
39830
+ };
39831
+ const writesSandbox = Object.keys(mergedSandbox).length > 0;
39832
+ if (writesSandbox) patch.sandbox = mergedSandbox;
39833
+ const replacesUnreadableSandbox = writesSandbox && settings.sandbox !== void 0 && !isRecord$1(settings.sandbox);
39834
+ warnOnTrustAffectingEntries({
39835
+ toolLabel: DEVIN_TOOL_LABEL,
39836
+ noun: "sandbox change",
39837
+ entries: [
39838
+ ...replacesUnreadableSandbox ? [{
39839
+ label: "sandbox",
39840
+ reason: replacedUnreadableReason({
39841
+ shape: "object",
39842
+ toolLabel: DEVIN_TOOL_LABEL
39843
+ })
39844
+ }] : [],
39845
+ ...collectTrustAffectingSandboxPaths({
39846
+ sandbox: authoredSandboxRecord,
39847
+ paths: DEVIN_TRUST_AFFECTING_SANDBOX_PATHS
39848
+ }),
39849
+ ...collectRestrictionLosingSandboxEntries({
39850
+ existing: existingSandbox,
39851
+ merged: mergedSandbox,
39852
+ paths: DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS,
39853
+ toolLabel: DEVIN_TOOL_LABEL
39854
+ })
39855
+ ],
39856
+ relativeFilePath: toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)),
39857
+ logger
39858
+ });
39859
+ } 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.");
39860
+ }
39569
39861
  return new DevinPermissions({
39570
39862
  outputRoot,
39571
39863
  relativeDirPath: paths.relativeDirPath,
@@ -39574,7 +39866,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39574
39866
  fileKey: sharedConfigFileKey(paths),
39575
39867
  feature: "permissions",
39576
39868
  existingContent,
39577
- patch: { permissions: mergedPermissions },
39869
+ patch,
39578
39870
  filePath
39579
39871
  }),
39580
39872
  validate
@@ -39594,7 +39886,10 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39594
39886
  ask: Array.isArray(permissions.ask) ? permissions.ask : [],
39595
39887
  deny: Array.isArray(permissions.deny) ? permissions.deny : []
39596
39888
  });
39597
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
39889
+ const sandbox = asDevinRecord(settings.sandbox);
39890
+ const result = { ...config };
39891
+ if (Object.keys(sandbox).length > 0) result.devin = { sandbox };
39892
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
39598
39893
  }
39599
39894
  validate() {
39600
39895
  return {
@@ -42224,15 +42519,129 @@ const QWEN_OVERRIDE_TOOLS_KEYS = [
42224
42519
  "sandbox",
42225
42520
  "sandboxImage",
42226
42521
  "disabled",
42227
- "visible"
42522
+ "visible",
42523
+ "listDirectory",
42524
+ "workflowsEnabled"
42228
42525
  ];
42229
42526
  const QWEN_OVERRIDE_SECURITY_KEYS = [
42230
42527
  "folderTrust",
42231
42528
  "allowedHttpHookUrls",
42232
- "allowPrivateNetworkHooks"
42529
+ "allowPrivateNetworkHooks",
42530
+ "allowedInsecureVoiceBaseUrls"
42233
42531
  ];
42234
- const QWEN_GLOBAL_ONLY_SECURITY_KEYS = ["allowPrivateNetworkHooks"];
42532
+ /**
42533
+ * What each rule means for the two directions, so a key's behavior is declared
42534
+ * once beside its rule rather than spelled out at every branch.
42535
+ *
42536
+ * `stripInProjectScope` separates the keys a project file cannot use at all from
42537
+ * the ones whose project value still does something, just not unconditionally —
42538
+ * only the former are dropped. `announceOnlyGrants` marks the rule whose risk is
42539
+ * a value that turns something on; for the others the dangerous value is often
42540
+ * the falsy one (an empty allow-all list, folder trust switched off, a sandbox
42541
+ * turned off), so every change is announced instead. `announceOnImport` marks
42542
+ * the keys whose meaning depends on the scope they are written in — importing
42543
+ * one and regenerating globally turns a value a workspace could not decide for
42544
+ * itself into one Qwen Code enforces everywhere. `global-machine-wide` keys are
42545
+ * honored identically in either scope, so import neither promotes nor weakens
42546
+ * them and the announcement on the generate side is the control point.
42547
+ * `projectNote` is `null` for a rule that has nothing to say about the project
42548
+ * scope. A `globalNote` here is the rule's default; a key whose meaning the
42549
+ * default does not describe carries its own.
42550
+ */
42551
+ const QWEN_SCOPE_RULES = {
42552
+ "workspace-stripped": {
42553
+ stripInProjectScope: true,
42554
+ announceOnlyGrants: true,
42555
+ announceOnImport: true,
42556
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} is only honored in user/system settings, so it is skipped for the project-scoped ${filePath} (a value already in that file is left as it is). Check it if the override arrived with a repository you cloned, and author it in the global scope only if that is a value you want every project on this machine to run under.`,
42557
+ globalNote: "Qwen Code ignores this key in workspace settings so a repository cannot grant it per project; in the global scope it applies to every project on this machine."
42558
+ },
42559
+ "workspace-non-overriding": {
42560
+ stripInProjectScope: false,
42561
+ announceOnlyGrants: false,
42562
+ announceOnImport: true,
42563
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, but Qwen Code honors a workspace value for it only while no user, system, or system-defaults scope sets the key — a repository cannot replace the list a user configured in a higher scope. It does replace one written by hand in this same file, and an empty list is Qwen Code's allow-all, so check what it now says. Author it in the global scope if it has to apply unconditionally.`,
42564
+ globalNote: "A global value outranks every repository's own list for this key, and an empty list means allow-all, so this decides where HTTP hooks may send data for every project on this machine."
42565
+ },
42566
+ "user-scope-trust-check": {
42567
+ stripInProjectScope: false,
42568
+ announceOnlyGrants: false,
42569
+ announceOnImport: true,
42570
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, but Qwen Code makes the initial trust decision from user and system settings alone, before a workspace file is merged, so a project value cannot decide whether this workspace is trusted (it still drives folder trust once the workspace is trusted). Check it if the override arrived with a repository you cloned, and author it in the global scope only if that is a value you want every project on this machine to run under.`,
42571
+ globalNote: "Qwen Code reads this key from the global scope when it decides whether a workspace is trusted, so this changes which projects on this machine it trusts."
42572
+ },
42573
+ "global-machine-wide": {
42574
+ stripInProjectScope: false,
42575
+ announceOnlyGrants: false,
42576
+ announceOnImport: false,
42577
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}. Qwen Code honors this key in a workspace file, so it takes effect while you work in this repository. Check it if the override arrived with a repository you cloned.`,
42578
+ globalNote: "Qwen Code honors this key wherever it is written, so in the global scope it settles how much the agent may do unattended — how far approvals are skipped, and whether tool calls are contained at all — for every project on this machine."
42579
+ },
42580
+ unmodeled: {
42581
+ stripInProjectScope: false,
42582
+ announceOnlyGrants: false,
42583
+ announceOnImport: false,
42584
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}. This is not a key rulesync models, so it cannot say what Qwen Code does with it — and some settings in these groups are potent (\`tools.discoveryCommand\` and \`tools.callCommand\` are spawned as commands when Qwen Code starts). Check it if the override arrived with a repository you cloned.`,
42585
+ globalNote: "This is not a key rulesync models, so it cannot say what Qwen Code does with it — and some settings in these groups are potent (`tools.discoveryCommand` and `tools.callCommand` are spawned as commands when Qwen Code starts). Check what it now says for every project on this machine."
42586
+ }
42587
+ };
42588
+ const QWEN_OVERRIDE_GROUPS = [{
42589
+ groupName: "tools",
42590
+ overrideKeys: QWEN_OVERRIDE_TOOLS_KEYS,
42591
+ scopedKeys: {
42592
+ workflowsEnabled: { rule: "workspace-stripped" },
42593
+ approvalMode: {
42594
+ rule: "global-machine-wide",
42595
+ projectNote: autonomyProjectNote
42596
+ },
42597
+ autoAccept: {
42598
+ rule: "global-machine-wide",
42599
+ projectNote: autonomyProjectNote
42600
+ },
42601
+ sandbox: {
42602
+ rule: "global-machine-wide",
42603
+ projectNote: autonomyProjectNote
42604
+ },
42605
+ sandboxImage: {
42606
+ rule: "global-machine-wide",
42607
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so a sandboxed run in this repository executes inside the named image — check that it is one you trust to run your code.`,
42608
+ globalNote: "Qwen Code honors this key wherever it is written, so in the global scope every sandboxed run on this machine executes inside the named image — check that it is one you trust to run your code."
42609
+ },
42610
+ disabled: {
42611
+ rule: "global-machine-wide",
42612
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, replacing the list in that file rather than adding to it, so a name that is gone is a tool the model can call again in this repository unless another scope still disables it.`,
42613
+ globalNote: "Qwen Code honors this key wherever it is written, and the override replaces the list in this file rather than adding to it, so in the global scope this decides which tools stay out of the registry for every project on this machine — dropping a name hands that tool back to the model unless another scope still disables it."
42614
+ },
42615
+ visible: {
42616
+ rule: "global-machine-wide",
42617
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so it decides which deferred tools are visible at startup in this repository.`,
42618
+ globalNote: "Qwen Code honors this key wherever it is written, so in the global scope this decides which deferred tools are visible at startup for every project on this machine."
42619
+ },
42620
+ listDirectory: {
42621
+ rule: "global-machine-wide",
42622
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, so it decides whether the built-in \`list_directory\` tool is registered in this repository.`,
42623
+ globalNote: "Qwen Code honors this key wherever it is written, so in the global scope this decides whether the built-in `list_directory` tool is registered for every project on this machine."
42624
+ }
42625
+ }
42626
+ }, {
42627
+ groupName: "security",
42628
+ overrideKeys: QWEN_OVERRIDE_SECURITY_KEYS,
42629
+ scopedKeys: {
42630
+ allowPrivateNetworkHooks: { rule: "workspace-stripped" },
42631
+ allowedInsecureVoiceBaseUrls: {
42632
+ rule: "workspace-stripped",
42633
+ grants: isNonEmptyArray
42634
+ },
42635
+ allowedHttpHookUrls: { rule: "workspace-non-overriding" },
42636
+ folderTrust: { rule: "user-scope-trust-check" }
42637
+ }
42638
+ }];
42235
42639
  const QWEN_OVERRIDE_PERMISSIONS_KEYS = ["autoMode"];
42640
+ const QWEN_SCOPED_PERMISSIONS_KEYS = { autoMode: {
42641
+ rule: "global-machine-wide",
42642
+ projectNote: ({ qualifiedKey, quotedValue, filePath }) => `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}, replacing the whole block rather than merging into it, so it rewrites the instructions Auto Mode's classifier follows in this repository — including any deny hint you had configured.`,
42643
+ globalNote: "Qwen Code honors this key wherever it is written, and the override replaces the whole block rather than merging into it, so in the global scope this rewrites the instructions Auto Mode's classifier follows — including any deny hint you had configured — for every project on this machine."
42644
+ } };
42236
42645
  function asPlainRecord(value) {
42237
42646
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
42238
42647
  }
@@ -42244,20 +42653,172 @@ function pickQwenOverrideKeys(group, keys) {
42244
42653
  return picked;
42245
42654
  }
42246
42655
  /**
42247
- * Drop the global-only `security` keys from the override when generating project
42248
- * settings, warning once per dropped key. Only the override copy is filtered, so
42249
- * a value the user already wrote into the project file stays untouched.
42656
+ * Whether a value written for a scoped key actually grants something. Qwen Code
42657
+ * reads the boolean ones with a plain truthiness check, so anything truthy turns
42658
+ * the capability on — `1` and the string `"false"` included, which is why this is
42659
+ * not a `=== true` test. Not all of them are booleans either:
42660
+ * `security.allowedInsecureVoiceBaseUrls` is a list of base URLs, and an empty
42661
+ * list grants nothing.
42662
+ *
42663
+ * Only the rules whose `announceOnlyGrants` is set consult this, because "empty"
42664
+ * does not mean the same thing for every list: an empty
42665
+ * `security.allowedHttpHookUrls` is Qwen Code's allow-all, the widest value there
42666
+ * is, so that key is announced whatever it says.
42250
42667
  */
42251
- function scopeOverrideSecurity(overrideSecurity, { global, relativeFilePath, logger }) {
42252
- const scoped = { ...asPlainRecord(overrideSecurity) };
42253
- if (global) return scoped;
42254
- for (const key of QWEN_GLOBAL_ONLY_SECURITY_KEYS) {
42255
- if (scoped[key] === void 0) continue;
42256
- delete scoped[key];
42257
- logger?.warn(`Qwen permissions: 'security.${key}' is only honored in user/system settings, so it is skipped for the project-scoped ${relativeFilePath}. Author it in the global scope instead.`);
42668
+ function isNonEmptyArray(value) {
42669
+ return Array.isArray(value) && value.length > 0;
42670
+ }
42671
+ /**
42672
+ * Whether a value turns something on, read the way Qwen Code reads it.
42673
+ *
42674
+ * Plain truthiness by default, because that is what Qwen Code applies to these
42675
+ * settings: `1` and the string `"false"` enable them, and so does an empty array
42676
+ * — which is why an empty list cannot be treated as harmless everywhere. A key
42677
+ * whose list Qwen Code matches against, rather than tests for truth, carries its
42678
+ * own `grants` instead.
42679
+ */
42680
+ function grantsSomething(value, scopedKey) {
42681
+ return (scopedKey?.grants ?? Boolean)(value);
42682
+ }
42683
+ /**
42684
+ * The autonomy and containment controls say the same thing in project scope, so
42685
+ * they share one note rather than repeating it three times.
42686
+ */
42687
+ function autonomyProjectNote({ qualifiedKey, quotedValue, filePath }) {
42688
+ return `${qualifiedKey} = ${quotedValue} was written to the project-scoped ${filePath}. Qwen Code honors this key in a workspace file, so it settles how far approvals are skipped and whether tool calls are contained while you work in this repository. Check it if the override arrived with a repository you cloned.`;
42689
+ }
42690
+ /**
42691
+ * Quote a `group.key` pair for a warning. The key goes through the same quoting
42692
+ * a value does, because it is one: the override's groups are `z.looseObject`s,
42693
+ * so a key the scope gate names may have been written by whoever authored the
42694
+ * `.rulesync/permissions.jsonc` rather than chosen from a list rulesync knows.
42695
+ * Serializing it — rather than wrapping it in quotes of our own — is what keeps
42696
+ * a key that contains a quote from closing it and continuing the sentence.
42697
+ */
42698
+ function quoteQualifiedKey({ groupName, key }) {
42699
+ return quoteValueForWarning(`${groupName}.${key}`);
42700
+ }
42701
+ /**
42702
+ * Compare two settings values without letting key order decide the answer, so
42703
+ * re-emitting `{ enabled: true, note: "x" }` as `{ note: "x", enabled: true }`
42704
+ * is not announced as a change.
42705
+ */
42706
+ function stableStringify(value) {
42707
+ return JSON.stringify(value, (_key, nested) => nested !== null && typeof nested === "object" && !Array.isArray(nested) ? Object.fromEntries(Object.entries(nested).toSorted(([left], [right]) => left.localeCompare(right))) : nested) ?? "undefined";
42708
+ }
42709
+ function sameSettingsValue(a, b) {
42710
+ return stableStringify(a) === stableStringify(b);
42711
+ }
42712
+ /**
42713
+ * Apply the scope rules for a settings group's scoped keys.
42714
+ *
42715
+ * Generating project settings drops the keys Qwen Code strips from a workspace
42716
+ * file before the merge — writing one there would be dead configuration — and
42717
+ * explains the keys whose project value is honored only conditionally rather
42718
+ * than dropping those. Either way only the override copy is touched, so a value
42719
+ * the user already wrote into the project file stays where it is.
42720
+ *
42721
+ * Generating global settings keeps every key, and announces the write: the
42722
+ * `.rulesync/permissions.jsonc` carrying it may have arrived with a cloned or
42723
+ * fetched repository, and the global file applies to every project on this
42724
+ * machine. So the new value is named alongside the one it replaces, exactly as
42725
+ * the deepagents startup override announces its own relaxations. Every key the
42726
+ * override writes is announced, not only the ones with a scope rule — the
42727
+ * groups are `z.looseObject`s, so an unmodeled key falls back to the
42728
+ * `unmodeled` rule rather than slipping past the gate that the modeled ones
42729
+ * pass through.
42730
+ */
42731
+ function scopeOverrideGroup(overrideGroup, { groupName, scopedKeys, existingGroup, global, filePath, logger }) {
42732
+ const scoped = { ...asPlainRecord(overrideGroup) };
42733
+ const previous = asPlainRecord(existingGroup);
42734
+ const rulesByKey = new Map(Object.entries(scopedKeys));
42735
+ for (const key of Object.keys(scoped)) {
42736
+ const value = scoped[key];
42737
+ if (value === void 0) continue;
42738
+ const scopedKey = rulesByKey.get(key);
42739
+ const rule = scopedKey?.rule ?? "unmodeled";
42740
+ const { stripInProjectScope, announceOnlyGrants } = QWEN_SCOPE_RULES[rule];
42741
+ const projectNote = scopedKey?.projectNote ?? QWEN_SCOPE_RULES[rule].projectNote;
42742
+ const globalNote = scopedKey?.globalNote ?? QWEN_SCOPE_RULES[rule].globalNote;
42743
+ const previousValue = Object.hasOwn(previous, key) ? previous[key] : void 0;
42744
+ const unchanged = sameSettingsValue(previousValue, value);
42745
+ if (!global) {
42746
+ if (stripInProjectScope) delete scoped[key];
42747
+ else if (unchanged) continue;
42748
+ if (projectNote) warnWithFallback(logger, `Qwen permissions: ${projectNote({
42749
+ qualifiedKey: quoteQualifiedKey({
42750
+ groupName,
42751
+ key
42752
+ }),
42753
+ quotedValue: quoteValueForWarning(value),
42754
+ filePath
42755
+ })}`);
42756
+ continue;
42757
+ }
42758
+ if (unchanged) continue;
42759
+ if (announceOnlyGrants && !grantsSomething(value, scopedKey)) continue;
42760
+ const replaced = previousValue === void 0 ? "" : ` (was ${quoteValueForWarning(previousValue)})`;
42761
+ warnWithFallback(logger, `Qwen permissions: the qwencode override wrote ${quoteQualifiedKey({
42762
+ groupName,
42763
+ key
42764
+ })} = ${quoteValueForWarning(value)}${replaced} into ${filePath}, your global Qwen Code settings. ${globalNote}`);
42258
42765
  }
42259
42766
  return scoped;
42260
42767
  }
42768
+ /**
42769
+ * Build the `tools`/`security` patch groups contributed by the `qwencode`
42770
+ * override. Each group is shallow-merged over what `settings.json` already has,
42771
+ * after the scope gate has handled the keys Qwen Code honors in one scope only.
42772
+ */
42773
+ function buildOverrideGroupsPatch({ settings, override, global, filePath, logger }) {
42774
+ const patch = {};
42775
+ for (const { groupName, scopedKeys } of QWEN_OVERRIDE_GROUPS) {
42776
+ const overrideGroup = override?.[groupName];
42777
+ if (overrideGroup === void 0) continue;
42778
+ const existingGroup = settings[groupName];
42779
+ const scoped = scopeOverrideGroup(overrideGroup, {
42780
+ groupName,
42781
+ scopedKeys,
42782
+ existingGroup,
42783
+ global,
42784
+ filePath,
42785
+ logger
42786
+ });
42787
+ const merged = {
42788
+ ...asPlainRecord(existingGroup),
42789
+ ...scoped
42790
+ };
42791
+ if (Object.keys(merged).length > 0) patch[groupName] = merged;
42792
+ }
42793
+ return patch;
42794
+ }
42795
+ /**
42796
+ * Warn about a scoped key lifted out of a *project* settings file being
42797
+ * imported. The file carries no scope marker, so import keeps the key either
42798
+ * way — but regenerating in the global scope would turn a value a workspace
42799
+ * could not decide for itself into one Qwen Code enforces everywhere, and a
42800
+ * settings file read out of a cloned repository is exactly where such a value
42801
+ * comes from. Importing the global file is the case this has nothing to say
42802
+ * about: the value is already in the scope the warning would send it to.
42803
+ */
42804
+ function warnAboutImportedScopedKeys(groups) {
42805
+ for (const { groupName, scopedKeys } of QWEN_OVERRIDE_GROUPS) {
42806
+ const group = groups[groupName];
42807
+ for (const [key, scopedKey] of Object.entries(scopedKeys)) {
42808
+ const { rule } = scopedKey;
42809
+ const value = group[key];
42810
+ if (value === void 0) continue;
42811
+ const { announceOnlyGrants, announceOnImport } = QWEN_SCOPE_RULES[rule];
42812
+ if (!announceOnImport) continue;
42813
+ if (announceOnlyGrants && !grantsSomething(value, scopedKey)) continue;
42814
+ const globalNote = scopedKey.globalNote ?? QWEN_SCOPE_RULES[rule].globalNote;
42815
+ moduleLogger$1.warn(`Qwen permissions: imported ${quoteQualifiedKey({
42816
+ groupName,
42817
+ key
42818
+ })} = ${quoteValueForWarning(value)}. ${globalNote} Review it before generating with the global scope.`);
42819
+ }
42820
+ }
42821
+ }
42261
42822
  var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
42262
42823
  constructor(params) {
42263
42824
  super({
@@ -42286,7 +42847,8 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
42286
42847
  relativeDirPath: paths.relativeDirPath,
42287
42848
  relativeFilePath: paths.relativeFilePath,
42288
42849
  fileContent,
42289
- validate
42850
+ validate,
42851
+ global
42290
42852
  });
42291
42853
  }
42292
42854
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, logger }) {
@@ -42302,6 +42864,7 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
42302
42864
  } catch (error) {
42303
42865
  throw new Error(`Failed to parse existing Qwen settings at ${filePath}: ${formatError(error)}`, { cause: error });
42304
42866
  }
42867
+ const displayPath = toPosixPath((0, node_path.join)(global ? "~" : ".", paths.relativeDirPath, paths.relativeFilePath));
42305
42868
  const config = rulesyncPermissions.getJson();
42306
42869
  const { allow, ask, deny } = convertRulesyncToQwenPermissions(config);
42307
42870
  const managedToolNames = new Set(Object.keys(config.permission).map((category) => toQwenToolName(category)));
@@ -42320,24 +42883,25 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
42320
42883
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
42321
42884
  else delete mergedPermissions.deny;
42322
42885
  const override = config.qwencode;
42323
- if (override?.autoMode !== void 0) mergedPermissions.autoMode = override.autoMode;
42324
- const patch = { permissions: mergedPermissions };
42325
- if (override?.tools !== void 0) patch.tools = {
42326
- ...asPlainRecord(settings.tools),
42327
- ...asPlainRecord(override.tools)
42328
- };
42329
- if (override?.security !== void 0) {
42330
- const scopedSecurity = scopeOverrideSecurity(override.security, {
42886
+ if (override?.autoMode !== void 0) {
42887
+ const scopedAutoMode = scopeOverrideGroup({ autoMode: override.autoMode }, {
42888
+ groupName: "permissions",
42889
+ scopedKeys: QWEN_SCOPED_PERMISSIONS_KEYS,
42890
+ existingGroup: existingPermissions,
42331
42891
  global,
42332
- relativeFilePath: paths.relativeFilePath,
42892
+ filePath: displayPath,
42333
42893
  logger
42334
42894
  });
42335
- const mergedSecurity = {
42336
- ...asPlainRecord(settings.security),
42337
- ...scopedSecurity
42338
- };
42339
- if (Object.keys(mergedSecurity).length > 0) patch.security = mergedSecurity;
42895
+ if (scopedAutoMode.autoMode !== void 0) mergedPermissions.autoMode = scopedAutoMode.autoMode;
42340
42896
  }
42897
+ const patch = { permissions: mergedPermissions };
42898
+ Object.assign(patch, buildOverrideGroupsPatch({
42899
+ settings,
42900
+ override,
42901
+ global,
42902
+ filePath: displayPath,
42903
+ logger
42904
+ }));
42341
42905
  const fileContent = applySharedConfigPatch({
42342
42906
  fileKey: sharedConfigFileKey(paths),
42343
42907
  feature: "permissions",
@@ -42369,16 +42933,17 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
42369
42933
  ask: permissions.ask ?? [],
42370
42934
  deny: permissions.deny ?? []
42371
42935
  });
42372
- const overrideTools = pickQwenOverrideKeys(settings.tools, QWEN_OVERRIDE_TOOLS_KEYS);
42373
- const overrideSecurity = pickQwenOverrideKeys(settings.security, QWEN_OVERRIDE_SECURITY_KEYS);
42374
- for (const key of QWEN_GLOBAL_ONLY_SECURITY_KEYS) {
42375
- if (overrideSecurity[key] === void 0) continue;
42376
- moduleLogger$1.warn(`Qwen permissions: imported 'security.${key}'. Qwen Code ignores it in workspace settings but enforces it in user/system settings, so review it before generating with the global scope.`);
42377
- }
42936
+ const overrideGroups = {
42937
+ tools: pickQwenOverrideKeys(settings.tools, QWEN_OVERRIDE_TOOLS_KEYS),
42938
+ security: pickQwenOverrideKeys(settings.security, QWEN_OVERRIDE_SECURITY_KEYS)
42939
+ };
42940
+ if (!this.global) warnAboutImportedScopedKeys(overrideGroups);
42378
42941
  const overridePermissions = pickQwenOverrideKeys(settings.permissions, QWEN_OVERRIDE_PERMISSIONS_KEYS);
42379
42942
  const qwencodeOverride = {};
42380
- if (Object.keys(overrideTools).length > 0) qwencodeOverride.tools = overrideTools;
42381
- if (Object.keys(overrideSecurity).length > 0) qwencodeOverride.security = overrideSecurity;
42943
+ for (const { groupName } of QWEN_OVERRIDE_GROUPS) {
42944
+ const group = overrideGroups[groupName];
42945
+ if (Object.keys(group).length > 0) qwencodeOverride[groupName] = group;
42946
+ }
42382
42947
  if (overridePermissions.autoMode !== void 0) qwencodeOverride.autoMode = overridePermissions.autoMode;
42383
42948
  const result = { ...config };
42384
42949
  if (Object.keys(qwencodeOverride).length > 0) result.qwencode = qwencodeOverride;
@@ -42542,6 +43107,137 @@ function buildReasonixPermissionEntry(toolName, pattern) {
42542
43107
  if (pattern === "*") return toolName;
42543
43108
  return `${toolName}(${pattern})`;
42544
43109
  }
43110
+ /** How Reasonix is named in the warnings this file emits. */
43111
+ const REASONIX_TOOL_LABEL = "Reasonix";
43112
+ /** The `[permissions]` key the override's `allowDynamicBash` writes and reads. */
43113
+ const REASONIX_ALLOW_DYNAMIC_BASH_KEY = "allow_dynamic_bash";
43114
+ /**
43115
+ * `[sandbox]` keys whose authored value loosens the enforcement layer beneath
43116
+ * the permission policy: they take Bash out of its OS jail, open that jail to
43117
+ * the network, or widen where the file-writing built-ins may write. Written —
43118
+ * the ordinary uses are far too common to refuse — but never silently, the same
43119
+ * stance `DEVIN_TRUST_AFFECTING_SANDBOX_PATHS` takes, and `widens` likewise
43120
+ * names the restrictive value so a spelling Reasonix does not recognize is
43121
+ * reported rather than waved through.
43122
+ *
43123
+ * `forbid_read` is not here: it restricts, so it loosens by losing entries
43124
+ * rather than by holding one, which needs the before/after comparison
43125
+ * {@link REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS} below does instead.
43126
+ *
43127
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
43128
+ */
43129
+ const REASONIX_TRUST_AFFECTING_SANDBOX_PATHS = [
43130
+ {
43131
+ path: ["bash"],
43132
+ reason: "anything but 'enforce' takes Bash out of the OS sandbox, so a command may write and read wherever the user can",
43133
+ widens: (value) => value !== "enforce"
43134
+ },
43135
+ {
43136
+ path: ["network"],
43137
+ reason: "lets sandboxed Bash reach the network",
43138
+ widens: isNotFalse
43139
+ },
43140
+ {
43141
+ path: ["allow_write"],
43142
+ reason: "adds directories the file-writing tools may modify outside the workspace root, which a headless run would otherwise refuse",
43143
+ widens: isNonEmptyList
43144
+ },
43145
+ {
43146
+ path: ["workspace_root"],
43147
+ 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",
43148
+ widens: escapesTheProject
43149
+ }
43150
+ ];
43151
+ /**
43152
+ * Whether a `workspace_root` points somewhere other than inside the project the
43153
+ * generate runs in. The key moves the write confinement rather than adding to
43154
+ * it, so the ordinary value — the project directory, spelled relatively — would
43155
+ * otherwise be announced on every generate; anything else is the case worth
43156
+ * naming, since it is how a fetched permissions file would put `~/.ssh` or
43157
+ * `C:\\Users\\<user>` inside the jail: an absolute path in either flavour, one
43158
+ * carrying a drive letter, a home-relative one, a shell or environment
43159
+ * expansion, and any path holding a `..` segment — even one that would land back
43160
+ * inside, since resolving it here would only be a guess at what Reasonix does.
43161
+ * Both path flavours are asked because the file is authored on one machine and
43162
+ * generated on another, so a Windows-shaped root reaching a POSIX check must not
43163
+ * read as relative. Anything that is not a string is reported, per the fail-safe
43164
+ * rule the predicates in `sandbox-trust.ts` follow.
43165
+ */
43166
+ function escapesTheProject(value) {
43167
+ if (typeof value !== "string") return true;
43168
+ const trimmed = value.trim();
43169
+ if (trimmed === "") return false;
43170
+ if (trimmed.startsWith("~")) return true;
43171
+ if (node_path.posix.isAbsolute(trimmed) || node_path.win32.isAbsolute(trimmed)) return true;
43172
+ if (/^[A-Za-z]:/.test(trimmed)) return true;
43173
+ if (trimmed.includes("$") || /%[^%]+%/.test(trimmed)) return true;
43174
+ return trimmed.split(/[\\/]/).includes("..");
43175
+ }
43176
+ /**
43177
+ * The `[sandbox]` key that restricts, and so loosens by losing entries rather
43178
+ * than by holding a value. The override is shallow-merged over the existing
43179
+ * `[sandbox]` at its top level, so an authored `forbid_read` replaces the list
43180
+ * the file had whole — emptying it, or dropping the `${HOME}/.ssh` entry
43181
+ * Reasonix's own example recommends, opens exactly what the list kept closed.
43182
+ *
43183
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
43184
+ */
43185
+ const REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS = [{
43186
+ path: ["forbid_read"],
43187
+ reason: "drops paths the list already in the file kept out of read, list and search",
43188
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
43189
+ }];
43190
+ /**
43191
+ * Everything worth naming about the `[sandbox]` table this generate is about to
43192
+ * write: the authored values that loosen enforcement, the restrictions the
43193
+ * shallow merge would drop, and a `[sandbox]` the file holds in some shape other
43194
+ * than a table, which the write replaces wholesale. The widening check reads the
43195
+ * authored block alone — a loosening value the file already held is the user's
43196
+ * own, and re-announcing it on every generate would bury the values rulesync
43197
+ * actually wrote — while a loss can only be seen from both sides.
43198
+ */
43199
+ function collectSandboxOverlayEntries({ existing, authored, merged }) {
43200
+ return [
43201
+ ...existing !== void 0 && !isRecord$1(existing) ? [{
43202
+ label: "sandbox",
43203
+ reason: replacedUnreadableReason({
43204
+ shape: "object",
43205
+ toolLabel: REASONIX_TOOL_LABEL
43206
+ })
43207
+ }] : [],
43208
+ ...collectTrustAffectingSandboxPaths({
43209
+ sandbox: asReasonixRecord(authored),
43210
+ paths: REASONIX_TRUST_AFFECTING_SANDBOX_PATHS
43211
+ }),
43212
+ ...collectRestrictionLosingSandboxEntries({
43213
+ existing: asReasonixRecord(existing),
43214
+ merged,
43215
+ paths: REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS,
43216
+ toolLabel: REASONIX_TOOL_LABEL
43217
+ })
43218
+ ];
43219
+ }
43220
+ /**
43221
+ * Writes the override's `allow_dynamic_bash` into `[permissions]`, where it sits
43222
+ * beside allow/ask/deny rather than in a table of its own, and reports it when
43223
+ * it is being turned on: it widens what a shareable permissions file lets run
43224
+ * with no human in the loop. Only an authored value is written — leaving the key
43225
+ * out of the override keeps whatever the file already had — and turning it off
43226
+ * narrows, so that stays quiet. Only a literal `false` is quiet, not everything
43227
+ * falsy: `getJson()` casts rather than parses, so a `--no-validate` run can put
43228
+ * a value the schema forbids here, and a value Reasonix might still coerce is
43229
+ * not something to write in silence. The entries are returned rather than logged so
43230
+ * one generate still produces one warning naming everything it wrote.
43231
+ */
43232
+ function applyAllowDynamicBash({ permissions, authored }) {
43233
+ if (authored === void 0) return [];
43234
+ permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY] = authored;
43235
+ if (!isNotFalse(authored)) return [];
43236
+ return [{
43237
+ label: `permissions.${REASONIX_ALLOW_DYNAMIC_BASH_KEY}`,
43238
+ 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"
43239
+ }];
43240
+ }
42545
43241
  function parseReasonixConfig(fileContent) {
42546
43242
  const parsed = smol_toml.parse(fileContent || smol_toml.stringify({}));
42547
43243
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -42598,6 +43294,7 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42598
43294
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
42599
43295
  const paths = this.getSettablePaths({ global });
42600
43296
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
43297
+ const relativeFilePathForLog = toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath));
42601
43298
  const existingContent = await readFileContentOrNull(filePath) ?? "";
42602
43299
  const parsed = parseReasonixConfig(existingContent);
42603
43300
  const config = rulesyncPermissions.getJson();
@@ -42642,11 +43339,23 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42642
43339
  ...deny,
42643
43340
  ...rawDeny
42644
43341
  ]);
43342
+ const trustAffecting = applyAllowDynamicBash({
43343
+ permissions: mergedPermissions,
43344
+ authored: override?.allowDynamicBash
43345
+ });
42645
43346
  const patch = { permissions: mergedPermissions };
42646
- if (override?.sandbox !== void 0) patch.sandbox = {
42647
- ...asReasonixRecord(parsed.sandbox),
42648
- ...asReasonixRecord(override.sandbox)
42649
- };
43347
+ if (override?.sandbox !== void 0) {
43348
+ const mergedSandbox = {
43349
+ ...asReasonixRecord(parsed.sandbox),
43350
+ ...asReasonixRecord(override.sandbox)
43351
+ };
43352
+ patch.sandbox = mergedSandbox;
43353
+ trustAffecting.push(...collectSandboxOverlayEntries({
43354
+ existing: parsed.sandbox,
43355
+ authored: override.sandbox,
43356
+ merged: mergedSandbox
43357
+ }));
43358
+ }
42650
43359
  if (override?.agent !== void 0) {
42651
43360
  const mergedAgent = {
42652
43361
  ...asReasonixRecord(parsed.agent),
@@ -42654,9 +43363,16 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42654
43363
  };
42655
43364
  const retired = REASONIX_RETIRED_AGENT_KEYS.filter((key) => mergedAgent[key] !== void 0);
42656
43365
  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.`);
43366
+ 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
43367
  patch.agent = mergedAgent;
42659
43368
  }
43369
+ warnOnTrustAffectingEntries({
43370
+ toolLabel: REASONIX_TOOL_LABEL,
43371
+ noun: "change",
43372
+ entries: trustAffecting,
43373
+ relativeFilePath: relativeFilePathForLog,
43374
+ logger
43375
+ });
42660
43376
  return new ReasonixPermissions({
42661
43377
  outputRoot,
42662
43378
  relativeDirPath: paths.relativeDirPath,
@@ -42691,6 +43407,8 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42691
43407
  const sandbox = asReasonixRecord(this.toml.sandbox);
42692
43408
  const agentPlanMode = pickReasonixKeys(this.toml.agent, [...REASONIX_OVERRIDE_AGENT_KEYS, ...REASONIX_RETIRED_AGENT_KEYS]);
42693
43409
  const reasonixOverride = {};
43410
+ const allowDynamicBash = permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY];
43411
+ if (typeof allowDynamicBash === "boolean") reasonixOverride.allowDynamicBash = allowDynamicBash;
42694
43412
  if (Object.keys(sandbox).length > 0) reasonixOverride.sandbox = sandbox;
42695
43413
  if (Object.keys(agentPlanMode).length > 0) reasonixOverride.agent = agentPlanMode;
42696
43414
  if (allowSplit.exact.length > 0) reasonixOverride.rawAllow = allowSplit.exact;