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.
@@ -6633,10 +6633,25 @@ const CursorPermissionsOverrideSchema = z.looseObject({
6633
6633
  * Tool-scoped override block for Qwen Code. Qwen's `settings.json` exposes
6634
6634
  * autonomy/sandbox controls with no canonical permission category — under
6635
6635
  * `tools` (`approvalMode` = plan/default/auto-edit/auto/yolo, `autoAccept`,
6636
- * `sandbox`, `sandboxImage`, `disabled`) and `security` (`folderTrust`,
6637
- * `allowedHttpHookUrls`, `allowPrivateNetworkHooks` the latter is honored by
6638
- * Qwen Code only in user/system settings, so generate skips it in project scope).
6639
- * It also
6636
+ * `sandbox`, `sandboxImage`, `disabled`, `visible`, `listDirectory`,
6637
+ * `workflowsEnabled`) and `security` (`folderTrust`, `allowedHttpHookUrls`,
6638
+ * `allowPrivateNetworkHooks`, `allowedInsecureVoiceBaseUrls`). Qwen Code strips
6639
+ * `tools.workflowsEnabled`, `security.allowPrivateNetworkHooks` and
6640
+ * `security.allowedInsecureVoiceBaseUrls` out of workspace settings, so generate
6641
+ * skips those three in project scope and announces a granting value in global
6642
+ * scope. `security.allowedHttpHookUrls` (honored in a workspace only while no
6643
+ * higher scope sets it) and `security.folderTrust` (the initial trust decision
6644
+ * is made from user/system settings alone, before the workspace merge) are
6645
+ * written in both scopes, with a note in project scope and an announcement of
6646
+ * any global change. Every other key is honored in either scope, so a write that
6647
+ * changes what the file said is reported in either scope, naming what that key
6648
+ * decides there — the autonomy and containment
6649
+ * controls (`approvalMode`, `autoAccept`, `sandbox`, `sandboxImage`), the
6650
+ * registry controls (`disabled`, `visible`, `listDirectory`), the Auto Mode
6651
+ * classifier config, and, because these groups are loose objects, any key
6652
+ * rulesync does not model. Import flags a scope-dependent key only when it read
6653
+ * the project file, since that is the value a `--global` regenerate would
6654
+ * promote. It also
6640
6655
  * exposes `permissions.autoMode` (the Auto Mode classifier config:
6641
6656
  * `hints.{allow,softDeny,hardDeny}`, `environment`, `classifyAllShell` — see
6642
6657
  * https://qwenlm.github.io/qwen-code-docs/en/users/features/auto-mode/), which
@@ -6674,6 +6689,8 @@ const QwencodePermissionsOverrideSchema = z.looseObject({
6674
6689
  *
6675
6690
  * @example
6676
6691
  * { "sandbox": { "bash": "enforce", "network": false }, "agent": { "plan_mode_read_only_commands": ["gh pr diff"] } }
6692
+ * @example
6693
+ * { "allowDynamicBash": true, "rawAllow": ["Bash=pnpm test"] }
6677
6694
  */
6678
6695
  const ReasonixPermissionsOverrideSchema = z.looseObject({
6679
6696
  permission: z.optional(ToolScopedPermissionSchema),
@@ -6681,7 +6698,8 @@ const ReasonixPermissionsOverrideSchema = z.looseObject({
6681
6698
  agent: z.optional(z.looseObject({})),
6682
6699
  rawAllow: z.optional(z.array(z.string())),
6683
6700
  rawAsk: z.optional(z.array(z.string())),
6684
- rawDeny: z.optional(z.array(z.string()))
6701
+ rawDeny: z.optional(z.array(z.string())),
6702
+ allowDynamicBash: z.optional(z.boolean())
6685
6703
  });
6686
6704
  /**
6687
6705
  * Tool-scoped override block for Factory Droid. Factory Droid's `settings.json`
@@ -7325,6 +7343,32 @@ const ZedPermissionsOverrideSchema = z.looseObject({
7325
7343
  })))
7326
7344
  });
7327
7345
  /**
7346
+ * Tool-scoped override block for Devin Local. `sandbox` is the sibling
7347
+ * top-level `config.json` block that governs the sandbox Devin runs commands
7348
+ * in: `allowed_domains` / `denied_domains` (proxy domain patterns, deny beating
7349
+ * allow), `network_mode` (`full`, the upstream default, allows every HTTP
7350
+ * method; `limited` only GET/HEAD/OPTIONS) and `excluded` (`allow` / `ask` /
7351
+ * `deny` lists of `Exec(...)` matchers deciding which commands run *outside* the
7352
+ * sandbox — `deny` pins them inside it). It constrains how
7353
+ * a permitted command runs rather than which commands are permitted, so it has
7354
+ * no canonical category and is authored here.
7355
+ *
7356
+ * Upstream lists `sandbox` as a **User Config Only** key, so it is emitted at
7357
+ * global scope only; at project scope it is dropped with a warning rather than
7358
+ * written into a file Devin would ignore.
7359
+ *
7360
+ * @example
7361
+ * { "sandbox": { "allowed_domains": ["github.com"], "network_mode": "limited" } }
7362
+ * @example
7363
+ * { "sandbox": { "excluded": { "allow": ["Exec(git status *)"], "deny": ["Exec(git tag *)"] } } }
7364
+ * @see https://docs.devin.ai/cli/sandbox
7365
+ * @see https://docs.devin.ai/cli/reference/configuration/config-file
7366
+ */
7367
+ const DevinPermissionsOverrideSchema = z.looseObject({
7368
+ permission: z.optional(ToolScopedPermissionSchema),
7369
+ sandbox: z.optional(z.looseObject({}))
7370
+ });
7371
+ /**
7328
7372
  * Permissions configuration.
7329
7373
  * Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
7330
7374
  * Values are pattern-to-action mappings for that tool category.
@@ -7374,10 +7418,10 @@ const PermissionsConfigSchema = z.looseObject({
7374
7418
  kiro: z.optional(KiroPermissionsOverrideSchema),
7375
7419
  codexcli: z.optional(CodexcliPermissionsOverrideSchema),
7376
7420
  zed: z.optional(ZedPermissionsOverrideSchema),
7421
+ devin: z.optional(DevinPermissionsOverrideSchema),
7377
7422
  "antigravity-ide": z.optional(CanonicalPermissionsOverrideSchema),
7378
7423
  copilot: z.optional(CanonicalPermissionsOverrideSchema),
7379
7424
  copilotcli: z.optional(CanonicalPermissionsOverrideSchema),
7380
- devin: z.optional(CanonicalPermissionsOverrideSchema),
7381
7425
  goose: z.optional(CanonicalPermissionsOverrideSchema),
7382
7426
  grokcli: z.optional(CanonicalPermissionsOverrideSchema),
7383
7427
  "kimi-code": z.optional(KimiCodePermissionsOverrideSchema),
@@ -12603,7 +12647,7 @@ const SHARED_CONFIG_OWNERSHIP = {
12603
12647
  },
12604
12648
  permissions: {
12605
12649
  kind: "replace-owned-keys",
12606
- ownedKeys: ["permissions"]
12650
+ ownedKeys: ["permissions", "sandbox"]
12607
12651
  }
12608
12652
  }
12609
12653
  },
@@ -36324,6 +36368,180 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
36324
36368
  return { permission };
36325
36369
  }
36326
36370
  //#endregion
36371
+ //#region src/features/permissions/sandbox-trust.ts
36372
+ /** A key whose quiet value is an explicit `false`. */
36373
+ const isNotFalse = (value) => value !== false;
36374
+ /** A key whose quiet value is an explicit `true`. */
36375
+ const isNotTrue = (value) => value !== true;
36376
+ /** A list-valued key whose quiet value is the empty list. */
36377
+ const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
36378
+ /** The map-valued counterpart of {@link isNonEmptyList}. */
36379
+ const isNonEmptyMap = (value) => !isRecord$1(value) || Object.keys(value).length > 0;
36380
+ /**
36381
+ * What {@link readSandboxPath} returns when a container on the way to the leaf
36382
+ * is present but is not an object, so the leaf cannot be read at all. It is not
36383
+ * `undefined`, because the two mean opposite things to a caller: `undefined` is
36384
+ * "this path is not being written", while this is "something is being written
36385
+ * here and its shape hides what". The same fail-safe rule the predicates follow
36386
+ * applies to the walk — silence must mean "this cannot loosen anything", not
36387
+ * "this is not the shape the table expected".
36388
+ */
36389
+ const UNREADABLE_SANDBOX_PATH = Symbol("unreadable-sandbox-path");
36390
+ /**
36391
+ * Reads `sandbox` at `path`. Returns `undefined` when a segment is absent, and
36392
+ * {@link UNREADABLE_SANDBOX_PATH} when one is present but is not an object.
36393
+ * Shared by everything that addresses a `sandbox` path so a nested path added to
36394
+ * one of the tables is actually traversed rather than silently skipped, and so a
36395
+ * hostile shape (an array, a string, `null`) is reported rather than throwing.
36396
+ */
36397
+ function readSandboxPath({ sandbox, path }) {
36398
+ let cursor = sandbox;
36399
+ for (const segment of path) {
36400
+ if (cursor === void 0) return void 0;
36401
+ if (!isRecord$1(cursor)) return UNREADABLE_SANDBOX_PATH;
36402
+ cursor = cursor[segment];
36403
+ }
36404
+ return cursor;
36405
+ }
36406
+ /**
36407
+ * Every path in `paths` whose value in `sandbox` loosens the policy. Nothing is
36408
+ * removed — the values are written, just not silently. Call it on the block this
36409
+ * generate authored, after any scope filter has run: a value the file already
36410
+ * held is the user's own, not something rulesync opened, and a path a filter
36411
+ * dropped is not being written at all.
36412
+ */
36413
+ function collectTrustAffectingSandboxPaths({ sandbox, paths }) {
36414
+ const entries = [];
36415
+ const reportedContainers = /* @__PURE__ */ new Set();
36416
+ for (const { path, reason, widens } of paths) {
36417
+ const value = readSandboxPath({
36418
+ sandbox,
36419
+ path
36420
+ });
36421
+ if (value === void 0) continue;
36422
+ if (value === UNREADABLE_SANDBOX_PATH) {
36423
+ const label = findUnreadableContainer({
36424
+ sandbox,
36425
+ path
36426
+ });
36427
+ if (label === void 0 || reportedContainers.has(label)) continue;
36428
+ reportedContainers.add(label);
36429
+ entries.push({
36430
+ label,
36431
+ reason: UNREADABLE_CONTAINER_REASON
36432
+ });
36433
+ continue;
36434
+ }
36435
+ if (!widens(value)) continue;
36436
+ entries.push({
36437
+ label: `sandbox.${path.join(".")}`,
36438
+ reason
36439
+ });
36440
+ }
36441
+ return entries;
36442
+ }
36443
+ /** The reason printed for a container that hides the settings underneath it. */
36444
+ const UNREADABLE_CONTAINER_REASON = "is not the object it has to be, so nothing under it can be checked for what it opens";
36445
+ /**
36446
+ * The prefix of `path` that {@link readSandboxPath} could not walk past, as a
36447
+ * label. `undefined` when the walk was not blocked at all. Callers that report
36448
+ * an unreadable path name the container rather than the leaf, because the leaf
36449
+ * is not what the file actually holds.
36450
+ */
36451
+ function findUnreadableContainer({ sandbox, path }) {
36452
+ let cursor = sandbox;
36453
+ const walked = [];
36454
+ for (const segment of path) {
36455
+ if (cursor === void 0) return void 0;
36456
+ if (!isRecord$1(cursor)) return walked.length === 0 ? "sandbox" : `sandbox.${walked.join(".")}`;
36457
+ walked.push(segment);
36458
+ cursor = cursor[segment];
36459
+ }
36460
+ }
36461
+ /**
36462
+ * The reason printed for a value the file held in a shape that cannot be read,
36463
+ * which this generate is about to replace. `shape` names what the tool documents
36464
+ * there, so the message says which expectation the file's value missed.
36465
+ */
36466
+ 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`;
36467
+ /**
36468
+ * The restrictions this generate would weaken, compared between the `sandbox`
36469
+ * already in the file and the one about to replace it. A `before` that is
36470
+ * present but not a list is reported outright: a shape the tool may still honor
36471
+ * is not something to go quiet about just because it cannot be diffed. Shared by
36472
+ * every tool whose override replaces a restricting list whole rather than
36473
+ * merging into it — Claude Code needs no equivalent, because it merges its lists
36474
+ * across settings scopes, so a file can only ever add to them.
36475
+ */
36476
+ function collectRestrictionLosingSandboxEntries({ existing, merged, paths, toolLabel }) {
36477
+ const entries = [];
36478
+ const reportedContainers = /* @__PURE__ */ new Set();
36479
+ for (const { path, reason, loosens } of paths) {
36480
+ const before = readSandboxPath({
36481
+ sandbox: existing,
36482
+ path
36483
+ });
36484
+ if (before === void 0) continue;
36485
+ const [rootKey] = path;
36486
+ if (rootKey !== void 0 && existing[rootKey] === merged[rootKey]) continue;
36487
+ const after = readSandboxPath({
36488
+ sandbox: merged,
36489
+ path
36490
+ });
36491
+ const label = `sandbox.${path.join(".")}`;
36492
+ if (before === UNREADABLE_SANDBOX_PATH) {
36493
+ const container = findUnreadableContainer({
36494
+ sandbox: existing,
36495
+ path
36496
+ });
36497
+ if (container === void 0 || reportedContainers.has(container)) continue;
36498
+ reportedContainers.add(container);
36499
+ entries.push({
36500
+ label: container,
36501
+ reason: replacedUnreadableReason({
36502
+ shape: "object",
36503
+ toolLabel
36504
+ })
36505
+ });
36506
+ continue;
36507
+ }
36508
+ if (!Array.isArray(before)) {
36509
+ entries.push({
36510
+ label,
36511
+ reason: replacedUnreadableReason({
36512
+ shape: "list",
36513
+ toolLabel
36514
+ })
36515
+ });
36516
+ continue;
36517
+ }
36518
+ if (before.length === 0) continue;
36519
+ if (!loosens({
36520
+ before,
36521
+ after: Array.isArray(after) ? after : []
36522
+ })) continue;
36523
+ entries.push({
36524
+ label,
36525
+ reason
36526
+ });
36527
+ }
36528
+ return entries;
36529
+ }
36530
+ /**
36531
+ * The one warning that names every trust-affecting setting this generate wrote
36532
+ * to `relativeFilePath`. Emitted once per file: the individual reasons are what
36533
+ * matter, but the "review this as you would a hook" framing only needs saying
36534
+ * once, and repeating it per key buries the reasons in boilerplate. `noun` lets
36535
+ * a tool whose entries are not all additions call them something more accurate
36536
+ * than "setting".
36537
+ */
36538
+ function warnOnTrustAffectingEntries({ toolLabel, noun = "setting", entries, relativeFilePath, logger }) {
36539
+ if (entries.length === 0) return;
36540
+ const one = entries.length === 1;
36541
+ const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
36542
+ 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}.`);
36543
+ }
36544
+ //#endregion
36327
36545
  //#region src/features/permissions/claudecode-permissions.ts
36328
36546
  /**
36329
36547
  * Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
@@ -36372,19 +36590,6 @@ function parseClaudePermissionEntry(entry) {
36372
36590
  };
36373
36591
  }
36374
36592
  /**
36375
- * Claude Code's file permission checks match only `Edit(path)` and `Read(path)`
36376
- * rules. A `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted
36377
- * but never matched by those checks, so Claude Code warns at startup for each
36378
- * allow, deny, or ask rule in one of these unmatched forms" — so a canonical
36379
- * `write`/`notebookedit`/`glob` rule with a pattern is emitted in the form the
36380
- * docs prescribe instead. A tool-name rule with no path is unaffected: it
36381
- * matches the tool everywhere and produces no warning.
36382
- * @see https://code.claude.com/docs/en/permissions
36383
- */
36384
- function isPlainRecord(value) {
36385
- return typeof value === "object" && value !== null && !Array.isArray(value);
36386
- }
36387
- /**
36388
36593
  * Merge `patch` into `base`, recursing into plain objects so a sibling key at
36389
36594
  * any depth survives. Arrays and scalars are replaced, since a list the author
36390
36595
  * states is the list they mean.
@@ -36394,7 +36599,7 @@ function deepMergeRecords(base, patch) {
36394
36599
  for (const [key, value] of Object.entries(patch)) {
36395
36600
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36396
36601
  const existing = merged[key];
36397
- merged[key] = isPlainRecord(existing) && isPlainRecord(value) ? deepMergeRecords(existing, value) : value;
36602
+ merged[key] = isRecord$1(existing) && isRecord$1(value) ? deepMergeRecords(existing, value) : value;
36398
36603
  }
36399
36604
  return merged;
36400
36605
  }
@@ -36459,13 +36664,11 @@ const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadP
36459
36664
  * traversed rather than silently skipped.
36460
36665
  */
36461
36666
  function resolveSandboxParent({ root, segments }) {
36462
- let parent = root;
36463
- for (const segment of segments) {
36464
- const next = parent[segment];
36465
- if (!isPlainRecord(next)) return void 0;
36466
- parent = next;
36467
- }
36468
- return parent;
36667
+ const resolved = readSandboxPath({
36668
+ sandbox: root,
36669
+ path: segments
36670
+ });
36671
+ return isRecord$1(resolved) ? resolved : void 0;
36469
36672
  }
36470
36673
  /**
36471
36674
  * Deletes `path` from `target` in place and reports whether anything was there,
@@ -36499,18 +36702,6 @@ function deleteSandboxPath({ target, path }) {
36499
36702
  return true;
36500
36703
  }
36501
36704
  /**
36502
- * The one warning that names every trust-affecting setting this generate wrote
36503
- * to `relativeFilePath`. Emitted once per file: the individual reasons are what
36504
- * matter, but the "review this as you would a hook" framing only needs saying
36505
- * once, and repeating it per key buries the reasons in boilerplate.
36506
- */
36507
- function warnOnTrustAffectingEntries({ entries, relativeFilePath, logger }) {
36508
- if (entries.length === 0) return;
36509
- const one = entries.length === 1;
36510
- const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
36511
- 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}.`);
36512
- }
36513
- /**
36514
36705
  * The `permissions.defaultMode` values that start a session with fewer prompts
36515
36706
  * than the default. `plan` and `default` are absent because they do not widen
36516
36707
  * anything.
@@ -36555,18 +36746,6 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
36555
36746
  ["socatPath"]
36556
36747
  ];
36557
36748
  /**
36558
- * The predicates the "which value actually widens?" tables are built from.
36559
- * Each names the value that does *not* widen and reports everything else, never
36560
- * the reverse: the override is authored JSONC, so a key can carry any value at
36561
- * all, and one Claude Code coerces is still honored. Reporting an off-type value
36562
- * keeps the warning fail-safe — silence has to mean "this cannot loosen
36563
- * anything", not "this is not the type the table expected".
36564
- */
36565
- const isNotFalse = (value) => value !== false;
36566
- const isNotTrue = (value) => value !== true;
36567
- const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
36568
- const isNonEmptyMap = (value) => !isPlainRecord(value) || Object.keys(value).length > 0;
36569
- /**
36570
36749
  * `sandbox` paths that loosen the sandbox rather than naming something to run:
36571
36750
  * they let commands out of it, weaken the isolation it provides, or redirect
36572
36751
  * where its traffic goes. They are written like `env` is — the ordinary uses are
@@ -36669,30 +36848,6 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
36669
36848
  widens: () => true
36670
36849
  }
36671
36850
  ];
36672
- /**
36673
- * Every authored `sandbox` path that loosens the sandbox. Nothing is removed —
36674
- * the values are written, just not silently. Called on the filtered `sandbox`
36675
- * so it never claims to be writing a path the scope filters dropped.
36676
- */
36677
- function collectTrustAffectingSandboxPaths({ sandbox }) {
36678
- const entries = [];
36679
- for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
36680
- const leaf = path.at(-1);
36681
- if (leaf === void 0) continue;
36682
- const parent = resolveSandboxParent({
36683
- root: sandbox,
36684
- segments: path.slice(0, -1)
36685
- });
36686
- if (parent === void 0) continue;
36687
- const value = parent[leaf];
36688
- if (value === void 0 || !widens(value)) continue;
36689
- entries.push({
36690
- label: `sandbox.${path.join(".")}`,
36691
- reason
36692
- });
36693
- }
36694
- return entries;
36695
- }
36696
36851
  /** Paths that name an executable Claude Code runs. Refused in both scopes. */
36697
36852
  const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
36698
36853
  paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
@@ -36763,13 +36918,13 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
36763
36918
  */
36764
36919
  function stripProjectIgnoredMaskEntries({ sandbox, relativeFilePath, logger }) {
36765
36920
  const credentials = sandbox.credentials;
36766
- if (!isPlainRecord(credentials)) return sandbox;
36921
+ if (!isRecord$1(credentials)) return sandbox;
36767
36922
  const filteredCredentials = { ...credentials };
36768
36923
  let changed = false;
36769
36924
  for (const listKey of CLAUDECODE_MASKABLE_CREDENTIAL_LISTS) {
36770
36925
  const list = filteredCredentials[listKey];
36771
36926
  if (!Array.isArray(list)) continue;
36772
- const kept = list.filter((entry) => !(isPlainRecord(entry) && entry.mode === "mask"));
36927
+ const kept = list.filter((entry) => !(isRecord$1(entry) && entry.mode === "mask"));
36773
36928
  if (kept.length === list.length) continue;
36774
36929
  changed = true;
36775
36930
  const dropped = list.length - kept.length;
@@ -37048,6 +37203,16 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
37048
37203
  trustAffecting
37049
37204
  };
37050
37205
  }
37206
+ /**
37207
+ * Claude Code's file permission checks match only `Edit(path)` and `Read(path)`
37208
+ * rules. A `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted
37209
+ * but never matched by those checks, so Claude Code warns at startup for each
37210
+ * allow, deny, or ask rule in one of these unmatched forms" — so a canonical
37211
+ * `write`/`notebookedit`/`glob` rule with a pattern is emitted in the form the
37212
+ * docs prescribe instead. A tool-name rule with no path is unaffected: it
37213
+ * matches the tool everywhere and produces no warning.
37214
+ * @see https://code.claude.com/docs/en/permissions
37215
+ */
37051
37216
  const CLAUDE_PATH_RULE_ALIASES = {
37052
37217
  Write: "Edit",
37053
37218
  NotebookEdit: "Edit",
@@ -37128,7 +37293,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37128
37293
  };
37129
37294
  }
37130
37295
  const overrideSandbox = config.claudecode?.sandbox;
37131
- if (isPlainRecord(overrideSandbox)) {
37296
+ if (isRecord$1(overrideSandbox)) {
37132
37297
  const honorableSandbox = stripSandboxPaths({
37133
37298
  sandbox: overrideSandbox,
37134
37299
  refusals: [
@@ -37144,8 +37309,11 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37144
37309
  relativeFilePath: paths.relativeFilePath,
37145
37310
  logger
37146
37311
  });
37147
- trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
37148
- if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
37312
+ trustAffecting.push(...collectTrustAffectingSandboxPaths({
37313
+ sandbox: scopedSandbox,
37314
+ paths: CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS
37315
+ }));
37316
+ if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isRecord$1(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
37149
37317
  }
37150
37318
  const overrideTopLevel = {};
37151
37319
  for (const [key, value] of Object.entries(config.claudecode ?? {})) {
@@ -37163,8 +37331,9 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37163
37331
  trustAffecting.push(...trustAffectingTopLevel);
37164
37332
  if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
37165
37333
  warnOnTrustAffectingEntries({
37334
+ toolLabel: "Claude Code",
37166
37335
  entries: trustAffecting,
37167
- relativeFilePath: paths.relativeFilePath,
37336
+ relativeFilePath: toPosixPath(join(paths.relativeDirPath, paths.relativeFilePath)),
37168
37337
  logger
37169
37338
  });
37170
37339
  const managedToolNames = managedClaudeToolNames(config);
@@ -37207,7 +37376,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37207
37376
  const nonListFields = Object.fromEntries(Object.entries(permissionsRest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
37208
37377
  if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
37209
37378
  const { sandbox } = settings;
37210
- if (isPlainRecord(sandbox)) {
37379
+ if (isRecord$1(sandbox)) {
37211
37380
  const importedSandbox = structuredClone(sandbox);
37212
37381
  for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) deleteSandboxPath({
37213
37382
  target: importedSandbox,
@@ -39460,6 +39629,80 @@ function buildDevinPermissionEntry(scope, pattern) {
39460
39629
  if (pattern === "*") return scope;
39461
39630
  return `${scope}(${pattern})`;
39462
39631
  }
39632
+ function asDevinRecord(value) {
39633
+ return isRecord$1(value) ? { ...value } : {};
39634
+ }
39635
+ /**
39636
+ * `sandbox` paths whose authored value loosens the sandbox on its own: they let
39637
+ * a command out of it, or widen what a command left inside it may reach. They
39638
+ * are written — the ordinary uses are far too common to refuse — but never
39639
+ * silently, because a permissions file is shareable (`rulesync fetch` copies one
39640
+ * into a project) and should not be able to open the sandbox without saying so.
39641
+ * This is the same stance `CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS` takes for
39642
+ * the equivalent Claude Code keys, and `widens` follows the same convention of
39643
+ * naming the restrictive value rather than the permissive ones, so a spelling
39644
+ * Devin does not recognize is reported rather than waved through.
39645
+ *
39646
+ * The three keys that restrict — `allowed_domains` (an allowlist only while it
39647
+ * has entries), `denied_domains` and `excluded.deny` — are not here: they loosen
39648
+ * by losing entries, which `DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS` covers.
39649
+ *
39650
+ * @see https://docs.devin.ai/cli/sandbox
39651
+ */
39652
+ const DEVIN_TRUST_AFFECTING_SANDBOX_PATHS = [
39653
+ {
39654
+ path: ["network_mode"],
39655
+ reason: "anything but 'limited' lets sandboxed requests use every HTTP method, not just GET/HEAD/OPTIONS",
39656
+ widens: (value) => value !== "limited"
39657
+ },
39658
+ {
39659
+ path: ["excluded", "allow"],
39660
+ reason: "names commands that run outside the sandbox with no prompt and no sandbox policy",
39661
+ widens: isNonEmptyList
39662
+ },
39663
+ {
39664
+ path: ["excluded", "ask"],
39665
+ reason: "names commands that run outside the sandbox once confirmed, with no sandbox policy",
39666
+ widens: isNonEmptyList
39667
+ }
39668
+ ];
39669
+ /** How Devin is named in the warnings this file emits. */
39670
+ const DEVIN_TOOL_LABEL = "Devin";
39671
+ /**
39672
+ * `sandbox` paths that restrict, and that therefore loosen the policy by losing
39673
+ * entries rather than by holding a value. Devin's config is one file rather than
39674
+ * a stack of settings scopes, and the override is shallow-merged over the
39675
+ * existing `sandbox` at its top level: each of these lists is replaced whole,
39676
+ * and `excluded.deny` vanishes as soon as the override states any other
39677
+ * `excluded` key. Losing an entry has the same effect as adding one to the
39678
+ * permissive keys above, so it is announced the same way. Claude Code needs no
39679
+ * equivalent — it merges its lists across settings scopes, so a file can only
39680
+ * ever add to them.
39681
+ *
39682
+ * `loosens` is asked only about a `before` that actually restricted something,
39683
+ * and the two directions are not symmetric: `allowed_domains` restricts by
39684
+ * listing what is reachable, so it loosens by gaining entries or by emptying
39685
+ * out altogether, while the deny lists loosen by losing entries.
39686
+ *
39687
+ * @see https://docs.devin.ai/cli/sandbox
39688
+ */
39689
+ const DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS = [
39690
+ {
39691
+ path: ["allowed_domains"],
39692
+ reason: "adds to the proxy allowlist already in the file, or empties it so every domain becomes reachable again",
39693
+ loosens: ({ before, after }) => after.length === 0 || after.some((entry) => !before.includes(entry))
39694
+ },
39695
+ {
39696
+ path: ["denied_domains"],
39697
+ reason: "drops domains the deny list already in the file kept out of reach",
39698
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
39699
+ },
39700
+ {
39701
+ path: ["excluded", "deny"],
39702
+ reason: "drops commands the list already in the file pinned inside the sandbox",
39703
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
39704
+ }
39705
+ ];
39463
39706
  /**
39464
39707
  * Permissions generator for Devin Local (native `.devin/` configuration).
39465
39708
  *
@@ -39475,10 +39718,18 @@ function buildDevinPermissionEntry(scope, pattern) {
39475
39718
  *
39476
39719
  * In global mode the config file is shared with the hooks (`hooks`) feature
39477
39720
  * (MCP moved to the dedicated mcp_config.json in v3000.3), so reads and writes
39478
- * merge into the existing JSON and the file is never deleted; only the managed
39479
- * `permissions` key is rewritten.
39721
+ * merge into the existing JSON and the file is never deleted; only the keys
39722
+ * this feature manages are rewritten — `permissions`, plus `sandbox` in global
39723
+ * mode when the `devin` override authors it.
39724
+ *
39725
+ * The sibling `sandbox` block — which decides what a permitted command may
39726
+ * reach rather than which commands are permitted — has no canonical category
39727
+ * and is authored through the `devin` override in `.rulesync/permissions.jsonc`.
39728
+ * Devin documents it as a user-config-only key, so it is written at global
39729
+ * scope only.
39480
39730
  *
39481
39731
  * @see https://docs.devin.ai/cli/reference/permissions
39732
+ * @see https://docs.devin.ai/cli/sandbox
39482
39733
  */
39483
39734
  var DevinPermissions = class DevinPermissions extends ToolPermissions {
39484
39735
  constructor(params) {
@@ -39489,7 +39740,8 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39489
39740
  }
39490
39741
  /**
39491
39742
  * config.json may carry the MCP/hooks features' keys, so it is never deleted;
39492
- * only the managed `permissions` key is rewritten.
39743
+ * only the keys this feature manages are rewritten — `permissions`, plus
39744
+ * `sandbox` in global mode when the `devin` override authors it.
39493
39745
  */
39494
39746
  isDeletable() {
39495
39747
  return false;
@@ -39515,7 +39767,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39515
39767
  validate
39516
39768
  });
39517
39769
  }
39518
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true }) {
39770
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true, logger }) {
39519
39771
  const paths = DevinPermissions.getSettablePaths({ global });
39520
39772
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
39521
39773
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
@@ -39541,6 +39793,46 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39541
39793
  else delete mergedPermissions.ask;
39542
39794
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
39543
39795
  else delete mergedPermissions.deny;
39796
+ const patch = { permissions: mergedPermissions };
39797
+ const authoredSandbox = config.devin?.sandbox;
39798
+ if (authoredSandbox !== void 0) {
39799
+ const authoredSandboxRecord = asDevinRecord(authoredSandbox);
39800
+ if (global) {
39801
+ const existingSandbox = asDevinRecord(settings.sandbox);
39802
+ const mergedSandbox = {
39803
+ ...existingSandbox,
39804
+ ...authoredSandboxRecord
39805
+ };
39806
+ const writesSandbox = Object.keys(mergedSandbox).length > 0;
39807
+ if (writesSandbox) patch.sandbox = mergedSandbox;
39808
+ const replacesUnreadableSandbox = writesSandbox && settings.sandbox !== void 0 && !isRecord$1(settings.sandbox);
39809
+ warnOnTrustAffectingEntries({
39810
+ toolLabel: DEVIN_TOOL_LABEL,
39811
+ noun: "sandbox change",
39812
+ entries: [
39813
+ ...replacesUnreadableSandbox ? [{
39814
+ label: "sandbox",
39815
+ reason: replacedUnreadableReason({
39816
+ shape: "object",
39817
+ toolLabel: DEVIN_TOOL_LABEL
39818
+ })
39819
+ }] : [],
39820
+ ...collectTrustAffectingSandboxPaths({
39821
+ sandbox: authoredSandboxRecord,
39822
+ paths: DEVIN_TRUST_AFFECTING_SANDBOX_PATHS
39823
+ }),
39824
+ ...collectRestrictionLosingSandboxEntries({
39825
+ existing: existingSandbox,
39826
+ merged: mergedSandbox,
39827
+ paths: DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS,
39828
+ toolLabel: DEVIN_TOOL_LABEL
39829
+ })
39830
+ ],
39831
+ relativeFilePath: toPosixPath(join(paths.relativeDirPath, paths.relativeFilePath)),
39832
+ logger
39833
+ });
39834
+ } 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.");
39835
+ }
39544
39836
  return new DevinPermissions({
39545
39837
  outputRoot,
39546
39838
  relativeDirPath: paths.relativeDirPath,
@@ -39549,7 +39841,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39549
39841
  fileKey: sharedConfigFileKey(paths),
39550
39842
  feature: "permissions",
39551
39843
  existingContent,
39552
- patch: { permissions: mergedPermissions },
39844
+ patch,
39553
39845
  filePath
39554
39846
  }),
39555
39847
  validate
@@ -39569,7 +39861,10 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39569
39861
  ask: Array.isArray(permissions.ask) ? permissions.ask : [],
39570
39862
  deny: Array.isArray(permissions.deny) ? permissions.deny : []
39571
39863
  });
39572
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
39864
+ const sandbox = asDevinRecord(settings.sandbox);
39865
+ const result = { ...config };
39866
+ if (Object.keys(sandbox).length > 0) result.devin = { sandbox };
39867
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
39573
39868
  }
39574
39869
  validate() {
39575
39870
  return {
@@ -42199,15 +42494,129 @@ const QWEN_OVERRIDE_TOOLS_KEYS = [
42199
42494
  "sandbox",
42200
42495
  "sandboxImage",
42201
42496
  "disabled",
42202
- "visible"
42497
+ "visible",
42498
+ "listDirectory",
42499
+ "workflowsEnabled"
42203
42500
  ];
42204
42501
  const QWEN_OVERRIDE_SECURITY_KEYS = [
42205
42502
  "folderTrust",
42206
42503
  "allowedHttpHookUrls",
42207
- "allowPrivateNetworkHooks"
42504
+ "allowPrivateNetworkHooks",
42505
+ "allowedInsecureVoiceBaseUrls"
42208
42506
  ];
42209
- const QWEN_GLOBAL_ONLY_SECURITY_KEYS = ["allowPrivateNetworkHooks"];
42507
+ /**
42508
+ * What each rule means for the two directions, so a key's behavior is declared
42509
+ * once beside its rule rather than spelled out at every branch.
42510
+ *
42511
+ * `stripInProjectScope` separates the keys a project file cannot use at all from
42512
+ * the ones whose project value still does something, just not unconditionally —
42513
+ * only the former are dropped. `announceOnlyGrants` marks the rule whose risk is
42514
+ * a value that turns something on; for the others the dangerous value is often
42515
+ * the falsy one (an empty allow-all list, folder trust switched off, a sandbox
42516
+ * turned off), so every change is announced instead. `announceOnImport` marks
42517
+ * the keys whose meaning depends on the scope they are written in — importing
42518
+ * one and regenerating globally turns a value a workspace could not decide for
42519
+ * itself into one Qwen Code enforces everywhere. `global-machine-wide` keys are
42520
+ * honored identically in either scope, so import neither promotes nor weakens
42521
+ * them and the announcement on the generate side is the control point.
42522
+ * `projectNote` is `null` for a rule that has nothing to say about the project
42523
+ * scope. A `globalNote` here is the rule's default; a key whose meaning the
42524
+ * default does not describe carries its own.
42525
+ */
42526
+ const QWEN_SCOPE_RULES = {
42527
+ "workspace-stripped": {
42528
+ stripInProjectScope: true,
42529
+ announceOnlyGrants: true,
42530
+ announceOnImport: true,
42531
+ 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.`,
42532
+ 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."
42533
+ },
42534
+ "workspace-non-overriding": {
42535
+ stripInProjectScope: false,
42536
+ announceOnlyGrants: false,
42537
+ announceOnImport: true,
42538
+ 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.`,
42539
+ 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."
42540
+ },
42541
+ "user-scope-trust-check": {
42542
+ stripInProjectScope: false,
42543
+ announceOnlyGrants: false,
42544
+ announceOnImport: true,
42545
+ 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.`,
42546
+ 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."
42547
+ },
42548
+ "global-machine-wide": {
42549
+ stripInProjectScope: false,
42550
+ announceOnlyGrants: false,
42551
+ announceOnImport: false,
42552
+ 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.`,
42553
+ 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."
42554
+ },
42555
+ unmodeled: {
42556
+ stripInProjectScope: false,
42557
+ announceOnlyGrants: false,
42558
+ announceOnImport: false,
42559
+ 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.`,
42560
+ 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."
42561
+ }
42562
+ };
42563
+ const QWEN_OVERRIDE_GROUPS = [{
42564
+ groupName: "tools",
42565
+ overrideKeys: QWEN_OVERRIDE_TOOLS_KEYS,
42566
+ scopedKeys: {
42567
+ workflowsEnabled: { rule: "workspace-stripped" },
42568
+ approvalMode: {
42569
+ rule: "global-machine-wide",
42570
+ projectNote: autonomyProjectNote
42571
+ },
42572
+ autoAccept: {
42573
+ rule: "global-machine-wide",
42574
+ projectNote: autonomyProjectNote
42575
+ },
42576
+ sandbox: {
42577
+ rule: "global-machine-wide",
42578
+ projectNote: autonomyProjectNote
42579
+ },
42580
+ sandboxImage: {
42581
+ rule: "global-machine-wide",
42582
+ 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.`,
42583
+ 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."
42584
+ },
42585
+ disabled: {
42586
+ rule: "global-machine-wide",
42587
+ 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.`,
42588
+ 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."
42589
+ },
42590
+ visible: {
42591
+ rule: "global-machine-wide",
42592
+ 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.`,
42593
+ 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."
42594
+ },
42595
+ listDirectory: {
42596
+ rule: "global-machine-wide",
42597
+ 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.`,
42598
+ 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."
42599
+ }
42600
+ }
42601
+ }, {
42602
+ groupName: "security",
42603
+ overrideKeys: QWEN_OVERRIDE_SECURITY_KEYS,
42604
+ scopedKeys: {
42605
+ allowPrivateNetworkHooks: { rule: "workspace-stripped" },
42606
+ allowedInsecureVoiceBaseUrls: {
42607
+ rule: "workspace-stripped",
42608
+ grants: isNonEmptyArray
42609
+ },
42610
+ allowedHttpHookUrls: { rule: "workspace-non-overriding" },
42611
+ folderTrust: { rule: "user-scope-trust-check" }
42612
+ }
42613
+ }];
42210
42614
  const QWEN_OVERRIDE_PERMISSIONS_KEYS = ["autoMode"];
42615
+ const QWEN_SCOPED_PERMISSIONS_KEYS = { autoMode: {
42616
+ rule: "global-machine-wide",
42617
+ 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.`,
42618
+ 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."
42619
+ } };
42211
42620
  function asPlainRecord(value) {
42212
42621
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
42213
42622
  }
@@ -42219,20 +42628,172 @@ function pickQwenOverrideKeys(group, keys) {
42219
42628
  return picked;
42220
42629
  }
42221
42630
  /**
42222
- * Drop the global-only `security` keys from the override when generating project
42223
- * settings, warning once per dropped key. Only the override copy is filtered, so
42224
- * a value the user already wrote into the project file stays untouched.
42631
+ * Whether a value written for a scoped key actually grants something. Qwen Code
42632
+ * reads the boolean ones with a plain truthiness check, so anything truthy turns
42633
+ * the capability on — `1` and the string `"false"` included, which is why this is
42634
+ * not a `=== true` test. Not all of them are booleans either:
42635
+ * `security.allowedInsecureVoiceBaseUrls` is a list of base URLs, and an empty
42636
+ * list grants nothing.
42637
+ *
42638
+ * Only the rules whose `announceOnlyGrants` is set consult this, because "empty"
42639
+ * does not mean the same thing for every list: an empty
42640
+ * `security.allowedHttpHookUrls` is Qwen Code's allow-all, the widest value there
42641
+ * is, so that key is announced whatever it says.
42225
42642
  */
42226
- function scopeOverrideSecurity(overrideSecurity, { global, relativeFilePath, logger }) {
42227
- const scoped = { ...asPlainRecord(overrideSecurity) };
42228
- if (global) return scoped;
42229
- for (const key of QWEN_GLOBAL_ONLY_SECURITY_KEYS) {
42230
- if (scoped[key] === void 0) continue;
42231
- delete scoped[key];
42232
- 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.`);
42643
+ function isNonEmptyArray(value) {
42644
+ return Array.isArray(value) && value.length > 0;
42645
+ }
42646
+ /**
42647
+ * Whether a value turns something on, read the way Qwen Code reads it.
42648
+ *
42649
+ * Plain truthiness by default, because that is what Qwen Code applies to these
42650
+ * settings: `1` and the string `"false"` enable them, and so does an empty array
42651
+ * — which is why an empty list cannot be treated as harmless everywhere. A key
42652
+ * whose list Qwen Code matches against, rather than tests for truth, carries its
42653
+ * own `grants` instead.
42654
+ */
42655
+ function grantsSomething(value, scopedKey) {
42656
+ return (scopedKey?.grants ?? Boolean)(value);
42657
+ }
42658
+ /**
42659
+ * The autonomy and containment controls say the same thing in project scope, so
42660
+ * they share one note rather than repeating it three times.
42661
+ */
42662
+ function autonomyProjectNote({ qualifiedKey, quotedValue, filePath }) {
42663
+ 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.`;
42664
+ }
42665
+ /**
42666
+ * Quote a `group.key` pair for a warning. The key goes through the same quoting
42667
+ * a value does, because it is one: the override's groups are `z.looseObject`s,
42668
+ * so a key the scope gate names may have been written by whoever authored the
42669
+ * `.rulesync/permissions.jsonc` rather than chosen from a list rulesync knows.
42670
+ * Serializing it — rather than wrapping it in quotes of our own — is what keeps
42671
+ * a key that contains a quote from closing it and continuing the sentence.
42672
+ */
42673
+ function quoteQualifiedKey({ groupName, key }) {
42674
+ return quoteValueForWarning(`${groupName}.${key}`);
42675
+ }
42676
+ /**
42677
+ * Compare two settings values without letting key order decide the answer, so
42678
+ * re-emitting `{ enabled: true, note: "x" }` as `{ note: "x", enabled: true }`
42679
+ * is not announced as a change.
42680
+ */
42681
+ function stableStringify(value) {
42682
+ 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";
42683
+ }
42684
+ function sameSettingsValue(a, b) {
42685
+ return stableStringify(a) === stableStringify(b);
42686
+ }
42687
+ /**
42688
+ * Apply the scope rules for a settings group's scoped keys.
42689
+ *
42690
+ * Generating project settings drops the keys Qwen Code strips from a workspace
42691
+ * file before the merge — writing one there would be dead configuration — and
42692
+ * explains the keys whose project value is honored only conditionally rather
42693
+ * than dropping those. Either way only the override copy is touched, so a value
42694
+ * the user already wrote into the project file stays where it is.
42695
+ *
42696
+ * Generating global settings keeps every key, and announces the write: the
42697
+ * `.rulesync/permissions.jsonc` carrying it may have arrived with a cloned or
42698
+ * fetched repository, and the global file applies to every project on this
42699
+ * machine. So the new value is named alongside the one it replaces, exactly as
42700
+ * the deepagents startup override announces its own relaxations. Every key the
42701
+ * override writes is announced, not only the ones with a scope rule — the
42702
+ * groups are `z.looseObject`s, so an unmodeled key falls back to the
42703
+ * `unmodeled` rule rather than slipping past the gate that the modeled ones
42704
+ * pass through.
42705
+ */
42706
+ function scopeOverrideGroup(overrideGroup, { groupName, scopedKeys, existingGroup, global, filePath, logger }) {
42707
+ const scoped = { ...asPlainRecord(overrideGroup) };
42708
+ const previous = asPlainRecord(existingGroup);
42709
+ const rulesByKey = new Map(Object.entries(scopedKeys));
42710
+ for (const key of Object.keys(scoped)) {
42711
+ const value = scoped[key];
42712
+ if (value === void 0) continue;
42713
+ const scopedKey = rulesByKey.get(key);
42714
+ const rule = scopedKey?.rule ?? "unmodeled";
42715
+ const { stripInProjectScope, announceOnlyGrants } = QWEN_SCOPE_RULES[rule];
42716
+ const projectNote = scopedKey?.projectNote ?? QWEN_SCOPE_RULES[rule].projectNote;
42717
+ const globalNote = scopedKey?.globalNote ?? QWEN_SCOPE_RULES[rule].globalNote;
42718
+ const previousValue = Object.hasOwn(previous, key) ? previous[key] : void 0;
42719
+ const unchanged = sameSettingsValue(previousValue, value);
42720
+ if (!global) {
42721
+ if (stripInProjectScope) delete scoped[key];
42722
+ else if (unchanged) continue;
42723
+ if (projectNote) warnWithFallback(logger, `Qwen permissions: ${projectNote({
42724
+ qualifiedKey: quoteQualifiedKey({
42725
+ groupName,
42726
+ key
42727
+ }),
42728
+ quotedValue: quoteValueForWarning(value),
42729
+ filePath
42730
+ })}`);
42731
+ continue;
42732
+ }
42733
+ if (unchanged) continue;
42734
+ if (announceOnlyGrants && !grantsSomething(value, scopedKey)) continue;
42735
+ const replaced = previousValue === void 0 ? "" : ` (was ${quoteValueForWarning(previousValue)})`;
42736
+ warnWithFallback(logger, `Qwen permissions: the qwencode override wrote ${quoteQualifiedKey({
42737
+ groupName,
42738
+ key
42739
+ })} = ${quoteValueForWarning(value)}${replaced} into ${filePath}, your global Qwen Code settings. ${globalNote}`);
42233
42740
  }
42234
42741
  return scoped;
42235
42742
  }
42743
+ /**
42744
+ * Build the `tools`/`security` patch groups contributed by the `qwencode`
42745
+ * override. Each group is shallow-merged over what `settings.json` already has,
42746
+ * after the scope gate has handled the keys Qwen Code honors in one scope only.
42747
+ */
42748
+ function buildOverrideGroupsPatch({ settings, override, global, filePath, logger }) {
42749
+ const patch = {};
42750
+ for (const { groupName, scopedKeys } of QWEN_OVERRIDE_GROUPS) {
42751
+ const overrideGroup = override?.[groupName];
42752
+ if (overrideGroup === void 0) continue;
42753
+ const existingGroup = settings[groupName];
42754
+ const scoped = scopeOverrideGroup(overrideGroup, {
42755
+ groupName,
42756
+ scopedKeys,
42757
+ existingGroup,
42758
+ global,
42759
+ filePath,
42760
+ logger
42761
+ });
42762
+ const merged = {
42763
+ ...asPlainRecord(existingGroup),
42764
+ ...scoped
42765
+ };
42766
+ if (Object.keys(merged).length > 0) patch[groupName] = merged;
42767
+ }
42768
+ return patch;
42769
+ }
42770
+ /**
42771
+ * Warn about a scoped key lifted out of a *project* settings file being
42772
+ * imported. The file carries no scope marker, so import keeps the key either
42773
+ * way — but regenerating in the global scope would turn a value a workspace
42774
+ * could not decide for itself into one Qwen Code enforces everywhere, and a
42775
+ * settings file read out of a cloned repository is exactly where such a value
42776
+ * comes from. Importing the global file is the case this has nothing to say
42777
+ * about: the value is already in the scope the warning would send it to.
42778
+ */
42779
+ function warnAboutImportedScopedKeys(groups) {
42780
+ for (const { groupName, scopedKeys } of QWEN_OVERRIDE_GROUPS) {
42781
+ const group = groups[groupName];
42782
+ for (const [key, scopedKey] of Object.entries(scopedKeys)) {
42783
+ const { rule } = scopedKey;
42784
+ const value = group[key];
42785
+ if (value === void 0) continue;
42786
+ const { announceOnlyGrants, announceOnImport } = QWEN_SCOPE_RULES[rule];
42787
+ if (!announceOnImport) continue;
42788
+ if (announceOnlyGrants && !grantsSomething(value, scopedKey)) continue;
42789
+ const globalNote = scopedKey.globalNote ?? QWEN_SCOPE_RULES[rule].globalNote;
42790
+ moduleLogger$1.warn(`Qwen permissions: imported ${quoteQualifiedKey({
42791
+ groupName,
42792
+ key
42793
+ })} = ${quoteValueForWarning(value)}. ${globalNote} Review it before generating with the global scope.`);
42794
+ }
42795
+ }
42796
+ }
42236
42797
  var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
42237
42798
  constructor(params) {
42238
42799
  super({
@@ -42261,7 +42822,8 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
42261
42822
  relativeDirPath: paths.relativeDirPath,
42262
42823
  relativeFilePath: paths.relativeFilePath,
42263
42824
  fileContent,
42264
- validate
42825
+ validate,
42826
+ global
42265
42827
  });
42266
42828
  }
42267
42829
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, logger }) {
@@ -42277,6 +42839,7 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
42277
42839
  } catch (error) {
42278
42840
  throw new Error(`Failed to parse existing Qwen settings at ${filePath}: ${formatError(error)}`, { cause: error });
42279
42841
  }
42842
+ const displayPath = toPosixPath(join(global ? "~" : ".", paths.relativeDirPath, paths.relativeFilePath));
42280
42843
  const config = rulesyncPermissions.getJson();
42281
42844
  const { allow, ask, deny } = convertRulesyncToQwenPermissions(config);
42282
42845
  const managedToolNames = new Set(Object.keys(config.permission).map((category) => toQwenToolName(category)));
@@ -42295,24 +42858,25 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
42295
42858
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
42296
42859
  else delete mergedPermissions.deny;
42297
42860
  const override = config.qwencode;
42298
- if (override?.autoMode !== void 0) mergedPermissions.autoMode = override.autoMode;
42299
- const patch = { permissions: mergedPermissions };
42300
- if (override?.tools !== void 0) patch.tools = {
42301
- ...asPlainRecord(settings.tools),
42302
- ...asPlainRecord(override.tools)
42303
- };
42304
- if (override?.security !== void 0) {
42305
- const scopedSecurity = scopeOverrideSecurity(override.security, {
42861
+ if (override?.autoMode !== void 0) {
42862
+ const scopedAutoMode = scopeOverrideGroup({ autoMode: override.autoMode }, {
42863
+ groupName: "permissions",
42864
+ scopedKeys: QWEN_SCOPED_PERMISSIONS_KEYS,
42865
+ existingGroup: existingPermissions,
42306
42866
  global,
42307
- relativeFilePath: paths.relativeFilePath,
42867
+ filePath: displayPath,
42308
42868
  logger
42309
42869
  });
42310
- const mergedSecurity = {
42311
- ...asPlainRecord(settings.security),
42312
- ...scopedSecurity
42313
- };
42314
- if (Object.keys(mergedSecurity).length > 0) patch.security = mergedSecurity;
42870
+ if (scopedAutoMode.autoMode !== void 0) mergedPermissions.autoMode = scopedAutoMode.autoMode;
42315
42871
  }
42872
+ const patch = { permissions: mergedPermissions };
42873
+ Object.assign(patch, buildOverrideGroupsPatch({
42874
+ settings,
42875
+ override,
42876
+ global,
42877
+ filePath: displayPath,
42878
+ logger
42879
+ }));
42316
42880
  const fileContent = applySharedConfigPatch({
42317
42881
  fileKey: sharedConfigFileKey(paths),
42318
42882
  feature: "permissions",
@@ -42344,16 +42908,17 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
42344
42908
  ask: permissions.ask ?? [],
42345
42909
  deny: permissions.deny ?? []
42346
42910
  });
42347
- const overrideTools = pickQwenOverrideKeys(settings.tools, QWEN_OVERRIDE_TOOLS_KEYS);
42348
- const overrideSecurity = pickQwenOverrideKeys(settings.security, QWEN_OVERRIDE_SECURITY_KEYS);
42349
- for (const key of QWEN_GLOBAL_ONLY_SECURITY_KEYS) {
42350
- if (overrideSecurity[key] === void 0) continue;
42351
- 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.`);
42352
- }
42911
+ const overrideGroups = {
42912
+ tools: pickQwenOverrideKeys(settings.tools, QWEN_OVERRIDE_TOOLS_KEYS),
42913
+ security: pickQwenOverrideKeys(settings.security, QWEN_OVERRIDE_SECURITY_KEYS)
42914
+ };
42915
+ if (!this.global) warnAboutImportedScopedKeys(overrideGroups);
42353
42916
  const overridePermissions = pickQwenOverrideKeys(settings.permissions, QWEN_OVERRIDE_PERMISSIONS_KEYS);
42354
42917
  const qwencodeOverride = {};
42355
- if (Object.keys(overrideTools).length > 0) qwencodeOverride.tools = overrideTools;
42356
- if (Object.keys(overrideSecurity).length > 0) qwencodeOverride.security = overrideSecurity;
42918
+ for (const { groupName } of QWEN_OVERRIDE_GROUPS) {
42919
+ const group = overrideGroups[groupName];
42920
+ if (Object.keys(group).length > 0) qwencodeOverride[groupName] = group;
42921
+ }
42357
42922
  if (overridePermissions.autoMode !== void 0) qwencodeOverride.autoMode = overridePermissions.autoMode;
42358
42923
  const result = { ...config };
42359
42924
  if (Object.keys(qwencodeOverride).length > 0) result.qwencode = qwencodeOverride;
@@ -42517,6 +43082,137 @@ function buildReasonixPermissionEntry(toolName, pattern) {
42517
43082
  if (pattern === "*") return toolName;
42518
43083
  return `${toolName}(${pattern})`;
42519
43084
  }
43085
+ /** How Reasonix is named in the warnings this file emits. */
43086
+ const REASONIX_TOOL_LABEL = "Reasonix";
43087
+ /** The `[permissions]` key the override's `allowDynamicBash` writes and reads. */
43088
+ const REASONIX_ALLOW_DYNAMIC_BASH_KEY = "allow_dynamic_bash";
43089
+ /**
43090
+ * `[sandbox]` keys whose authored value loosens the enforcement layer beneath
43091
+ * the permission policy: they take Bash out of its OS jail, open that jail to
43092
+ * the network, or widen where the file-writing built-ins may write. Written —
43093
+ * the ordinary uses are far too common to refuse — but never silently, the same
43094
+ * stance `DEVIN_TRUST_AFFECTING_SANDBOX_PATHS` takes, and `widens` likewise
43095
+ * names the restrictive value so a spelling Reasonix does not recognize is
43096
+ * reported rather than waved through.
43097
+ *
43098
+ * `forbid_read` is not here: it restricts, so it loosens by losing entries
43099
+ * rather than by holding one, which needs the before/after comparison
43100
+ * {@link REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS} below does instead.
43101
+ *
43102
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
43103
+ */
43104
+ const REASONIX_TRUST_AFFECTING_SANDBOX_PATHS = [
43105
+ {
43106
+ path: ["bash"],
43107
+ reason: "anything but 'enforce' takes Bash out of the OS sandbox, so a command may write and read wherever the user can",
43108
+ widens: (value) => value !== "enforce"
43109
+ },
43110
+ {
43111
+ path: ["network"],
43112
+ reason: "lets sandboxed Bash reach the network",
43113
+ widens: isNotFalse
43114
+ },
43115
+ {
43116
+ path: ["allow_write"],
43117
+ reason: "adds directories the file-writing tools may modify outside the workspace root, which a headless run would otherwise refuse",
43118
+ widens: isNonEmptyList
43119
+ },
43120
+ {
43121
+ path: ["workspace_root"],
43122
+ 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",
43123
+ widens: escapesTheProject
43124
+ }
43125
+ ];
43126
+ /**
43127
+ * Whether a `workspace_root` points somewhere other than inside the project the
43128
+ * generate runs in. The key moves the write confinement rather than adding to
43129
+ * it, so the ordinary value — the project directory, spelled relatively — would
43130
+ * otherwise be announced on every generate; anything else is the case worth
43131
+ * naming, since it is how a fetched permissions file would put `~/.ssh` or
43132
+ * `C:\\Users\\<user>` inside the jail: an absolute path in either flavour, one
43133
+ * carrying a drive letter, a home-relative one, a shell or environment
43134
+ * expansion, and any path holding a `..` segment — even one that would land back
43135
+ * inside, since resolving it here would only be a guess at what Reasonix does.
43136
+ * Both path flavours are asked because the file is authored on one machine and
43137
+ * generated on another, so a Windows-shaped root reaching a POSIX check must not
43138
+ * read as relative. Anything that is not a string is reported, per the fail-safe
43139
+ * rule the predicates in `sandbox-trust.ts` follow.
43140
+ */
43141
+ function escapesTheProject(value) {
43142
+ if (typeof value !== "string") return true;
43143
+ const trimmed = value.trim();
43144
+ if (trimmed === "") return false;
43145
+ if (trimmed.startsWith("~")) return true;
43146
+ if (posix.isAbsolute(trimmed) || win32.isAbsolute(trimmed)) return true;
43147
+ if (/^[A-Za-z]:/.test(trimmed)) return true;
43148
+ if (trimmed.includes("$") || /%[^%]+%/.test(trimmed)) return true;
43149
+ return trimmed.split(/[\\/]/).includes("..");
43150
+ }
43151
+ /**
43152
+ * The `[sandbox]` key that restricts, and so loosens by losing entries rather
43153
+ * than by holding a value. The override is shallow-merged over the existing
43154
+ * `[sandbox]` at its top level, so an authored `forbid_read` replaces the list
43155
+ * the file had whole — emptying it, or dropping the `${HOME}/.ssh` entry
43156
+ * Reasonix's own example recommends, opens exactly what the list kept closed.
43157
+ *
43158
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
43159
+ */
43160
+ const REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS = [{
43161
+ path: ["forbid_read"],
43162
+ reason: "drops paths the list already in the file kept out of read, list and search",
43163
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
43164
+ }];
43165
+ /**
43166
+ * Everything worth naming about the `[sandbox]` table this generate is about to
43167
+ * write: the authored values that loosen enforcement, the restrictions the
43168
+ * shallow merge would drop, and a `[sandbox]` the file holds in some shape other
43169
+ * than a table, which the write replaces wholesale. The widening check reads the
43170
+ * authored block alone — a loosening value the file already held is the user's
43171
+ * own, and re-announcing it on every generate would bury the values rulesync
43172
+ * actually wrote — while a loss can only be seen from both sides.
43173
+ */
43174
+ function collectSandboxOverlayEntries({ existing, authored, merged }) {
43175
+ return [
43176
+ ...existing !== void 0 && !isRecord$1(existing) ? [{
43177
+ label: "sandbox",
43178
+ reason: replacedUnreadableReason({
43179
+ shape: "object",
43180
+ toolLabel: REASONIX_TOOL_LABEL
43181
+ })
43182
+ }] : [],
43183
+ ...collectTrustAffectingSandboxPaths({
43184
+ sandbox: asReasonixRecord(authored),
43185
+ paths: REASONIX_TRUST_AFFECTING_SANDBOX_PATHS
43186
+ }),
43187
+ ...collectRestrictionLosingSandboxEntries({
43188
+ existing: asReasonixRecord(existing),
43189
+ merged,
43190
+ paths: REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS,
43191
+ toolLabel: REASONIX_TOOL_LABEL
43192
+ })
43193
+ ];
43194
+ }
43195
+ /**
43196
+ * Writes the override's `allow_dynamic_bash` into `[permissions]`, where it sits
43197
+ * beside allow/ask/deny rather than in a table of its own, and reports it when
43198
+ * it is being turned on: it widens what a shareable permissions file lets run
43199
+ * with no human in the loop. Only an authored value is written — leaving the key
43200
+ * out of the override keeps whatever the file already had — and turning it off
43201
+ * narrows, so that stays quiet. Only a literal `false` is quiet, not everything
43202
+ * falsy: `getJson()` casts rather than parses, so a `--no-validate` run can put
43203
+ * a value the schema forbids here, and a value Reasonix might still coerce is
43204
+ * not something to write in silence. The entries are returned rather than logged so
43205
+ * one generate still produces one warning naming everything it wrote.
43206
+ */
43207
+ function applyAllowDynamicBash({ permissions, authored }) {
43208
+ if (authored === void 0) return [];
43209
+ permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY] = authored;
43210
+ if (!isNotFalse(authored)) return [];
43211
+ return [{
43212
+ label: `permissions.${REASONIX_ALLOW_DYNAMIC_BASH_KEY}`,
43213
+ 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"
43214
+ }];
43215
+ }
42520
43216
  function parseReasonixConfig(fileContent) {
42521
43217
  const parsed = smolToml.parse(fileContent || smolToml.stringify({}));
42522
43218
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -42573,6 +43269,7 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42573
43269
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
42574
43270
  const paths = this.getSettablePaths({ global });
42575
43271
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
43272
+ const relativeFilePathForLog = toPosixPath(join(paths.relativeDirPath, paths.relativeFilePath));
42576
43273
  const existingContent = await readFileContentOrNull(filePath) ?? "";
42577
43274
  const parsed = parseReasonixConfig(existingContent);
42578
43275
  const config = rulesyncPermissions.getJson();
@@ -42617,11 +43314,23 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42617
43314
  ...deny,
42618
43315
  ...rawDeny
42619
43316
  ]);
43317
+ const trustAffecting = applyAllowDynamicBash({
43318
+ permissions: mergedPermissions,
43319
+ authored: override?.allowDynamicBash
43320
+ });
42620
43321
  const patch = { permissions: mergedPermissions };
42621
- if (override?.sandbox !== void 0) patch.sandbox = {
42622
- ...asReasonixRecord(parsed.sandbox),
42623
- ...asReasonixRecord(override.sandbox)
42624
- };
43322
+ if (override?.sandbox !== void 0) {
43323
+ const mergedSandbox = {
43324
+ ...asReasonixRecord(parsed.sandbox),
43325
+ ...asReasonixRecord(override.sandbox)
43326
+ };
43327
+ patch.sandbox = mergedSandbox;
43328
+ trustAffecting.push(...collectSandboxOverlayEntries({
43329
+ existing: parsed.sandbox,
43330
+ authored: override.sandbox,
43331
+ merged: mergedSandbox
43332
+ }));
43333
+ }
42625
43334
  if (override?.agent !== void 0) {
42626
43335
  const mergedAgent = {
42627
43336
  ...asReasonixRecord(parsed.agent),
@@ -42629,9 +43338,16 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42629
43338
  };
42630
43339
  const retired = REASONIX_RETIRED_AGENT_KEYS.filter((key) => mergedAgent[key] !== void 0);
42631
43340
  for (const key of retired) delete mergedAgent[key];
42632
- 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.`);
43341
+ 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.`);
42633
43342
  patch.agent = mergedAgent;
42634
43343
  }
43344
+ warnOnTrustAffectingEntries({
43345
+ toolLabel: REASONIX_TOOL_LABEL,
43346
+ noun: "change",
43347
+ entries: trustAffecting,
43348
+ relativeFilePath: relativeFilePathForLog,
43349
+ logger
43350
+ });
42635
43351
  return new ReasonixPermissions({
42636
43352
  outputRoot,
42637
43353
  relativeDirPath: paths.relativeDirPath,
@@ -42666,6 +43382,8 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42666
43382
  const sandbox = asReasonixRecord(this.toml.sandbox);
42667
43383
  const agentPlanMode = pickReasonixKeys(this.toml.agent, [...REASONIX_OVERRIDE_AGENT_KEYS, ...REASONIX_RETIRED_AGENT_KEYS]);
42668
43384
  const reasonixOverride = {};
43385
+ const allowDynamicBash = permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY];
43386
+ if (typeof allowDynamicBash === "boolean") reasonixOverride.allowDynamicBash = allowDynamicBash;
42669
43387
  if (Object.keys(sandbox).length > 0) reasonixOverride.sandbox = sandbox;
42670
43388
  if (Object.keys(agentPlanMode).length > 0) reasonixOverride.agent = agentPlanMode;
42671
43389
  if (allowSplit.exact.length > 0) reasonixOverride.rawAllow = allowSplit.exact;
@@ -68704,4 +69422,4 @@ async function importChecksCore(params) {
68704
69422
  //#endregion
68705
69423
  export { RulesyncCheckFrontmatterSchema as $, ALL_TOOL_TARGETS_WITH_WILDCARD as $t, FACTORYDROID_DIR as A, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as An, fileExists as At, RulesyncSkillFrontmatterSchema as B, stripControlCharacters as Bn, readFileContent as Bt, CODEXCLI_BASH_RULES_FILE_NAME as C, RULESYNC_PERMISSIONS_LEGACY_FILE_NAME as Cn, assertDirectoryIfExists as Ct, CLAUDECODE_MEMORIES_DIR_NAME as D, RULESYNC_RULES_RELATIVE_DIR_PATH as Dn, createTempDirectory as Dt, CLAUDECODE_LOCAL_RULE_FILE_NAME as E, RULESYNC_RELATIVE_DIR_PATH as En, checkPathTraversal as Et, AUGMENTCODE_SETTINGS_LOCAL_FILE_NAME as F, formatError as Fn, isSymlink as Ft, RulesyncIgnore as G, removeFileStrict as Gt, RulesyncRuleFrontmatterSchema as H, stripHiddenCharacters as Hn, removeDirectory as Ht, getLocalSkillDirNames as I, truncateText as In, listDirectoryEntryNames as It, resolveRulesyncSourceWritePath as J, runWithDirectoryRollback as Jt, RulesyncHooks as K, removeTempDirectory as Kt, RulesyncSubagent as L, hasDeceptiveHiddenCharacters as Ln, listFilePathsRecursively as Lt, caseFoldIdentity as M, ALL_FEATURES as Mn, getHomeDirectory as Mt, groupSpellingsByCaseFoldedIdentity as N, ALL_FEATURES_WITH_WILDCARD as Nn, isFileNotFoundError as Nt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as O, RULESYNC_SKILLS_RELATIVE_DIR_PATH as On, directoryExists as Ot, AUGMENTCODE_DIR as P, DEPRECATED_FEATURE_REPLACEMENTS as Pn, isFileSystemError as Pt, RulesyncCheck as Q, ALL_TOOL_TARGETS as Qt, RulesyncSubagentFrontmatterSchema as R, hasEnclosingMarkOutsideKeycap as Rn, listSubdirectoryNames as Rt, ChecksProcessor as S, RULESYNC_PERMISSIONS_FILE_NAME as Sn, applyFileMode as St, CLAUDECODE_DIR as T, RULESYNC_PERMISSIONS_SCHEMA_URL as Tn, assertWritablePathInsideRoot as Tt, RulesyncPermissions as U, removeDirectoryStrict as Ut, RulesyncRule as V, stripControlCharactersKeepingLineFeeds as Vn, readFileContentOrNull as Vt, RulesyncMcp as W, removeFile as Wt, RulesyncCommand as X, writeFileBuffer as Xt, parseJsonc as Y, toPosixPath as Yt, RulesyncCommandFrontmatterSchema as Z, writeFileContent as Zt, IgnoreProcessor as _, RULESYNC_MCP_FILE_NAME as _n, withFallbackLoggerTarget as _t, getProcessorRegistryEntry as a, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as an, mergeInputRootConfigs as at, QWENCODE_DIR as b, RULESYNC_MCP_SCHEMA_URL as bn, CLIError as bt, RulesProcessor as c, RULESYNC_CONFIG_RELATIVE_FILE_PATH as cn, ConfigFileSchema as ct, CODEBUDDY_DIR as d, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as dn, findControlCharacter as dt, PACKAGING_TOOL_TARGETS as en, stringifyFrontmatter as et, CODEBUDDY_LOCAL_RULE_FILE_NAME as f, RULESYNC_HOOKS_FILE_NAME as fn, ConsoleLogger as ft, McpProcessor as g, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as gn, warnOnConflictingFlags as gt, shortenToWidth as h, RULESYNC_IGNORE_RELATIVE_FILE_PATH as hn, fallbackLogger as ht, inspectInputRoots as i, RULESYNC_AIIGNORE_FILE_NAME as in, ConfigResolver as it, FACTORYDROID_SETTINGS_LOCAL_FILE_NAME as j, parseCommaSeparatedList as jn, getFileSize as jt, CLAUDECODE_SKILLS_DIR_PATH as k, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as kn, ensureDir as kt, SubagentsProcessor as l, RULESYNC_CONFIG_SCHEMA_URL as ln, GITIGNORE_DESTINATION_KEY as lt, displayWidthOf as m, RULESYNC_HOOKS_RELATIVE_FILE_PATH as mn, WarningCollectingLogger as mt, formatSourceLoadFailure as n, CURATED_RULES_FEATURE_SUBDIR as nn, SHARED_USER_MANAGED_CONFIG_PATHS as nt, convertFromTool as o, RULESYNC_CHECKS_RELATIVE_DIR_PATH as on, resolveEffectiveInputRoots as ot, ELLIPSIS_WIDTH as p, RULESYNC_HOOKS_LEGACY_FILE_NAME as pn, JsonLogger as pt, getRulesyncSourceCandidates as q, resolvePath as qt, generate as r, MAX_FILE_SIZE as rn, SKILL_FILE_NAME as rt, isPackagingToolTarget as s, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as sn, CONFLICTING_TARGET_PAIRS as st, importFromTool as t, ToolTargetSchema as tn, loadYaml as tt, SkillsProcessor as u, RULESYNC_CURATED_RULES_RELATIVE_DIR_PATH as un, SourceEntrySchema as ut, HooksProcessor as v, RULESYNC_MCP_LEGACY_FILE_NAME as vn, resetRunWarningState as vt, CODEXCLI_DIR as w, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as wn, assertTreeContainsNoSymlinks as wt, QWENCODE_LOCAL_RULE_FILE_NAME as x, RULESYNC_NPM_SOURCES_LOCK_RELATIVE_FILE_PATH as xn, ErrorCodes as xt, CommandsProcessor as y, RULESYNC_MCP_RELATIVE_FILE_PATH as yn, withWarnOnceScope as yt, RulesyncSkill as z, quoteForLog as zn, pathEscapesRoot as zt };
68706
69424
 
68707
- //# sourceMappingURL=import-DUE1P1zV.js.map
69425
+ //# sourceMappingURL=import-DyWRanf6.js.map