rulesync 16.22.0 → 16.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3385,6 +3385,47 @@ var RulesyncFile = class extends AiFile {
3385
3385
  }
3386
3386
  };
3387
3387
  //#endregion
3388
+ //#region src/utils/bounded-walk.ts
3389
+ const ALIAS_HINT = "(a chain of YAML aliases may be amplifying the document)";
3390
+ /**
3391
+ * Create the bookkeeping for one walk. `subject` names the document kind in
3392
+ * every error ("Frontmatter", "Shared config"); `root`, when given, is entered
3393
+ * up front so the root object counts as the first nesting level, matching the
3394
+ * +1 that `enter` applies to every container nested inside it.
3395
+ */
3396
+ function createBoundedWalk({ subject, limits, root }) {
3397
+ const ancestors = /* @__PURE__ */ new WeakSet();
3398
+ let valuesRemaining = limits.maxValues;
3399
+ let stringCharsRemaining = limits.maxStringChars;
3400
+ let depth = 0;
3401
+ const chargeChars = (chars) => {
3402
+ stringCharsRemaining -= chars;
3403
+ if (stringCharsRemaining < 0) throw new Error(`${subject}'s string values expand to more than ${limits.maxStringChars} characters; refusing to process it ${ALIAS_HINT}`);
3404
+ };
3405
+ const chargeValue = (stringChars = 0) => {
3406
+ valuesRemaining -= 1;
3407
+ if (valuesRemaining < 0) throw new Error(`${subject} expands to more than ${limits.maxValues} values; refusing to process it ${ALIAS_HINT}`);
3408
+ chargeChars(stringChars);
3409
+ };
3410
+ const enter = (container) => {
3411
+ depth += 1;
3412
+ if (depth > limits.maxDepth) throw new Error(`${subject} nests more than ${limits.maxDepth} levels deep; refusing to process it ${ALIAS_HINT}`);
3413
+ ancestors.add(container);
3414
+ };
3415
+ const leave = (container) => {
3416
+ ancestors.delete(container);
3417
+ depth -= 1;
3418
+ };
3419
+ if (root !== void 0) enter(root);
3420
+ return {
3421
+ chargeValue,
3422
+ chargeChars,
3423
+ isAncestor: (container) => ancestors.has(container),
3424
+ enter,
3425
+ leave
3426
+ };
3427
+ }
3428
+ //#endregion
3388
3429
  //#region src/utils/prototype-pollution.ts
3389
3430
  /**
3390
3431
  * Keys that, if walked into when constructing or merging objects from
@@ -3524,30 +3565,6 @@ const MAX_FRONTMATTER_STRING_CHARS = 4e6;
3524
3565
  * manifest stays well under this.
3525
3566
  */
3526
3567
  const MAX_FRONTMATTER_RAW_CHARS = 65536;
3527
- /** Charge string content (a string leaf or an object key) against the character budget. */
3528
- function chargeStringChars({ options, chars }) {
3529
- options.budget.stringCharsRemaining -= chars;
3530
- if (options.budget.stringCharsRemaining < 0) throw new Error(`Frontmatter's string values expand to more than ${MAX_FRONTMATTER_STRING_CHARS} characters; refusing to process it (a chain of YAML aliases may be amplifying the document)`);
3531
- }
3532
- function consumeBudget({ options, stringChars = 0 }) {
3533
- options.budget.remaining -= 1;
3534
- if (options.budget.remaining < 0) throw new Error(`Frontmatter expands to more than ${MAX_FRONTMATTER_VALUES} values; refusing to process it (a chain of YAML aliases may be amplifying the document)`);
3535
- chargeStringChars({
3536
- options,
3537
- chars: stringChars
3538
- });
3539
- }
3540
- /** Enter one more container level, throwing if the depth cap is exceeded. */
3541
- function enterContainer({ options, container }) {
3542
- options.depth += 1;
3543
- if (options.depth > 64) throw new Error(`Frontmatter nests more than 64 levels deep; refusing to process it (a chain of YAML aliases may be amplifying the document)`);
3544
- options.ancestors.add(container);
3545
- }
3546
- /** Leave a container level entered via {@link enterContainer}. */
3547
- function leaveContainer({ options, container }) {
3548
- options.ancestors.delete(container);
3549
- options.depth -= 1;
3550
- }
3551
3568
  /**
3552
3569
  * Estimate the serialized character cost of a leaf that is not a string (a
3553
3570
  * string leaf is charged by its own length instead).
@@ -3570,40 +3587,26 @@ function estimateLeafChars(value) {
3570
3587
  * bounded by {@link MAX_FRONTMATTER_VALUES} instead.
3571
3588
  */
3572
3589
  function deepCleanValue(value, options) {
3573
- consumeBudget({
3574
- options,
3575
- stringChars: typeof value === "string" ? value.length : estimateLeafChars(value)
3576
- });
3590
+ const leafChars = typeof value === "string" ? value.length : estimateLeafChars(value);
3591
+ options.walk.chargeValue(leafChars);
3577
3592
  if (value === null || value === void 0) return;
3578
3593
  if (typeof value === "string") return options.transformString ? options.transformString(value) : value;
3579
3594
  if (Array.isArray(value)) {
3580
- if (options.ancestors.has(value)) return;
3581
- enterContainer({
3582
- options,
3583
- container: value
3584
- });
3595
+ if (options.walk.isAncestor(value)) return;
3596
+ options.walk.enter(value);
3585
3597
  const cleanedArray = [];
3586
3598
  for (const item of value) {
3587
3599
  const cleaned = deepCleanValue(item, options);
3588
3600
  if (cleaned !== void 0) cleanedArray.push(cleaned);
3589
3601
  }
3590
- leaveContainer({
3591
- options,
3592
- container: value
3593
- });
3602
+ options.walk.leave(value);
3594
3603
  return cleanedArray;
3595
3604
  }
3596
3605
  if (isPlainObject$1(value)) {
3597
- if (options.ancestors.has(value)) return;
3598
- enterContainer({
3599
- options,
3600
- container: value
3601
- });
3606
+ if (options.walk.isAncestor(value)) return;
3607
+ options.walk.enter(value);
3602
3608
  const result = cleanOwnEntries(value, options);
3603
- leaveContainer({
3604
- options,
3605
- container: value
3606
- });
3609
+ options.walk.leave(value);
3607
3610
  return result;
3608
3611
  }
3609
3612
  return value;
@@ -3622,10 +3625,7 @@ function deepCleanValue(value, options) {
3622
3625
  function cleanOwnEntries(obj, options) {
3623
3626
  const result = {};
3624
3627
  for (const [key, val] of Object.entries(obj)) {
3625
- chargeStringChars({
3626
- options,
3627
- chars: key.length
3628
- });
3628
+ options.walk.chargeChars(key.length);
3629
3629
  const cleaned = deepCleanValue(val, options);
3630
3630
  if (isPrototypePollutionKey(key)) continue;
3631
3631
  if (cleaned !== void 0) result[key] = cleaned;
@@ -3636,12 +3636,15 @@ function deepCleanObject(obj, options) {
3636
3636
  if (!obj || typeof obj !== "object") return {};
3637
3637
  return cleanOwnEntries(obj, {
3638
3638
  ...options,
3639
- ancestors: new WeakSet([obj]),
3640
- budget: {
3641
- remaining: MAX_FRONTMATTER_VALUES,
3642
- stringCharsRemaining: MAX_FRONTMATTER_STRING_CHARS
3643
- },
3644
- depth: 1
3639
+ walk: createBoundedWalk({
3640
+ subject: "Frontmatter",
3641
+ limits: {
3642
+ maxValues: MAX_FRONTMATTER_VALUES,
3643
+ maxStringChars: MAX_FRONTMATTER_STRING_CHARS,
3644
+ maxDepth: 64
3645
+ },
3646
+ root: obj
3647
+ })
3645
3648
  });
3646
3649
  }
3647
3650
  /** Drop null and undefined values, recursively. */
@@ -6696,6 +6699,8 @@ const QwencodePermissionsOverrideSchema = zod_mini.z.looseObject({
6696
6699
  *
6697
6700
  * @example
6698
6701
  * { "sandbox": { "bash": "enforce", "network": false }, "agent": { "plan_mode_read_only_commands": ["gh pr diff"] } }
6702
+ * @example
6703
+ * { "allowDynamicBash": true, "rawAllow": ["Bash=pnpm test"] }
6699
6704
  */
6700
6705
  const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
6701
6706
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
@@ -6703,7 +6708,8 @@ const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
6703
6708
  agent: zod_mini.z.optional(zod_mini.z.looseObject({})),
6704
6709
  rawAllow: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
6705
6710
  rawAsk: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
6706
- rawDeny: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string()))
6711
+ rawDeny: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
6712
+ allowDynamicBash: zod_mini.z.optional(zod_mini.z.boolean())
6707
6713
  });
6708
6714
  /**
6709
6715
  * Tool-scoped override block for Factory Droid. Factory Droid's `settings.json`
@@ -7347,6 +7353,32 @@ const ZedPermissionsOverrideSchema = zod_mini.z.looseObject({
7347
7353
  })))
7348
7354
  });
7349
7355
  /**
7356
+ * Tool-scoped override block for Devin Local. `sandbox` is the sibling
7357
+ * top-level `config.json` block that governs the sandbox Devin runs commands
7358
+ * in: `allowed_domains` / `denied_domains` (proxy domain patterns, deny beating
7359
+ * allow), `network_mode` (`full`, the upstream default, allows every HTTP
7360
+ * method; `limited` only GET/HEAD/OPTIONS) and `excluded` (`allow` / `ask` /
7361
+ * `deny` lists of `Exec(...)` matchers deciding which commands run *outside* the
7362
+ * sandbox — `deny` pins them inside it). It constrains how
7363
+ * a permitted command runs rather than which commands are permitted, so it has
7364
+ * no canonical category and is authored here.
7365
+ *
7366
+ * Upstream lists `sandbox` as a **User Config Only** key, so it is emitted at
7367
+ * global scope only; at project scope it is dropped with a warning rather than
7368
+ * written into a file Devin would ignore.
7369
+ *
7370
+ * @example
7371
+ * { "sandbox": { "allowed_domains": ["github.com"], "network_mode": "limited" } }
7372
+ * @example
7373
+ * { "sandbox": { "excluded": { "allow": ["Exec(git status *)"], "deny": ["Exec(git tag *)"] } } }
7374
+ * @see https://docs.devin.ai/cli/sandbox
7375
+ * @see https://docs.devin.ai/cli/reference/configuration/config-file
7376
+ */
7377
+ const DevinPermissionsOverrideSchema = zod_mini.z.looseObject({
7378
+ permission: zod_mini.z.optional(ToolScopedPermissionSchema),
7379
+ sandbox: zod_mini.z.optional(zod_mini.z.looseObject({}))
7380
+ });
7381
+ /**
7350
7382
  * Permissions configuration.
7351
7383
  * Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
7352
7384
  * Values are pattern-to-action mappings for that tool category.
@@ -7396,10 +7428,10 @@ const PermissionsConfigSchema = zod_mini.z.looseObject({
7396
7428
  kiro: zod_mini.z.optional(KiroPermissionsOverrideSchema),
7397
7429
  codexcli: zod_mini.z.optional(CodexcliPermissionsOverrideSchema),
7398
7430
  zed: zod_mini.z.optional(ZedPermissionsOverrideSchema),
7431
+ devin: zod_mini.z.optional(DevinPermissionsOverrideSchema),
7399
7432
  "antigravity-ide": zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7400
7433
  copilot: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7401
7434
  copilotcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7402
- devin: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7403
7435
  goose: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7404
7436
  grokcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
7405
7437
  "kimi-code": zod_mini.z.optional(KimiCodePermissionsOverrideSchema),
@@ -11483,6 +11515,21 @@ function stripStrings(_key, value) {
11483
11515
  //#endregion
11484
11516
  //#region src/features/shared/shared-config-gateway.ts
11485
11517
  /**
11518
+ * Upper bound on the number of values a shared config document may expand
11519
+ * to once every YAML alias is written out. Real config files hold a few
11520
+ * hundred values at most; even a large MCP server catalog stays orders of
11521
+ * magnitude below the limit.
11522
+ */
11523
+ const MAX_SHARED_CONFIG_VALUES = 1e5;
11524
+ /**
11525
+ * Upper bound on the total character count of the string leaves and keys a
11526
+ * shared config document may expand to. The value budget bounds how many
11527
+ * values are visited, but one long string aliased thousands of times fits
11528
+ * that budget while the duplicated output balloons; charging every visited
11529
+ * string's length separately bounds the output regardless of alias count.
11530
+ */
11531
+ const MAX_SHARED_CONFIG_STRING_CHARS = 4e6;
11532
+ /**
11486
11533
  * Rebuild a parsed document without its prototype-pollution keys.
11487
11534
  *
11488
11535
  * Every object is rebuilt, not just the ones that are already plain: a literal
@@ -11497,15 +11544,61 @@ function stripStrings(_key, value) {
11497
11544
  *
11498
11545
  * Dates are the one object the YAML and TOML parsers produce that is not a
11499
11546
  * mapping, so they are passed through rather than flattened into `{}`.
11547
+ *
11548
+ * The rebuild is bounded, because a YAML alias makes one parsed container
11549
+ * reachable from many keys and every alias is copied out independently (the
11550
+ * writers dump with `noRefs: true`, so memoizing here would only move the
11551
+ * blowup into serialization). A small "alias bomb" of nested anchors would
11552
+ * otherwise cost exponential time and memory, and a self-referencing anchor
11553
+ * would recurse until the stack overflowed — both reachable from a config
11554
+ * file committed to a cloned repository. The walk therefore charges every
11555
+ * value against {@link MAX_SHARED_CONFIG_VALUES}, every string and key
11556
+ * against {@link MAX_SHARED_CONFIG_STRING_CHARS}, caps nesting at
11557
+ * {@link MAX_SHARED_CONFIG_DEPTH}, and refuses a reference back to an
11558
+ * ancestor outright, each with a clear error instead of a hang or a crash.
11500
11559
  */
11501
11560
  function sanitizeSharedConfigValue(value) {
11502
- if (Array.isArray(value)) return value.map(sanitizeSharedConfigValue);
11561
+ return sanitizeSharedConfigValueBounded(value, createBoundedWalk({
11562
+ subject: "Shared config",
11563
+ limits: {
11564
+ maxValues: MAX_SHARED_CONFIG_VALUES,
11565
+ maxStringChars: MAX_SHARED_CONFIG_STRING_CHARS,
11566
+ maxDepth: 64
11567
+ }
11568
+ }));
11569
+ }
11570
+ /**
11571
+ * Refuse a container that is already on the descent path. Unlike the
11572
+ * frontmatter cleaner, which drops such a cycle and keeps the rest of the
11573
+ * document, a shared config file is refused outright: silently dropping part
11574
+ * of a user's settings file would let a later write-back persist the loss.
11575
+ */
11576
+ function enterSharedConfigContainer(walk, container) {
11577
+ if (walk.isAncestor(container)) throw new Error("Shared config contains a value that refers back to itself (a circular YAML alias); refusing to process it");
11578
+ walk.enter(container);
11579
+ }
11580
+ function sanitizeSharedConfigValueBounded(value, walk) {
11581
+ if (typeof value === "string") {
11582
+ walk.chargeValue(value.length);
11583
+ return value;
11584
+ }
11585
+ walk.chargeValue();
11586
+ if (Array.isArray(value)) {
11587
+ enterSharedConfigContainer(walk, value);
11588
+ const items = value.map((item) => sanitizeSharedConfigValueBounded(item, walk));
11589
+ walk.leave(value);
11590
+ return items;
11591
+ }
11503
11592
  if (value === null || typeof value !== "object" || value instanceof Date) return value;
11593
+ enterSharedConfigContainer(walk, value);
11504
11594
  const result = {};
11505
11595
  for (const [key, nested] of Object.entries(value)) {
11596
+ walk.chargeChars(key.length);
11597
+ const sanitized = sanitizeSharedConfigValueBounded(nested, walk);
11506
11598
  if (isPrototypePollutionKey(key)) continue;
11507
- result[key] = sanitizeSharedConfigValue(nested);
11599
+ result[key] = sanitized;
11508
11600
  }
11601
+ walk.leave(value);
11509
11602
  return result;
11510
11603
  }
11511
11604
  /**
@@ -11534,7 +11627,12 @@ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy =
11534
11627
  throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
11535
11628
  }
11536
11629
  if (parsed === void 0 || parsed === null) return {};
11537
- const sanitized = sanitizeSharedConfigValue(parsed);
11630
+ let sanitized;
11631
+ try {
11632
+ sanitized = sanitizeSharedConfigValue(parsed);
11633
+ } catch (error) {
11634
+ throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
11635
+ }
11538
11636
  if (!isPlainObject$1(sanitized)) {
11539
11637
  if (invalidRootPolicy === "error") throw new Error(`Failed to parse shared config${at}: expected a mapping at the root`);
11540
11638
  return {};
@@ -12559,7 +12657,7 @@ const SHARED_CONFIG_OWNERSHIP = {
12559
12657
  },
12560
12658
  permissions: {
12561
12659
  kind: "replace-owned-keys",
12562
- ownedKeys: ["permissions"]
12660
+ ownedKeys: ["permissions", "sandbox"]
12563
12661
  }
12564
12662
  }
12565
12663
  },
@@ -36280,6 +36378,180 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
36280
36378
  return { permission };
36281
36379
  }
36282
36380
  //#endregion
36381
+ //#region src/features/permissions/sandbox-trust.ts
36382
+ /** A key whose quiet value is an explicit `false`. */
36383
+ const isNotFalse = (value) => value !== false;
36384
+ /** A key whose quiet value is an explicit `true`. */
36385
+ const isNotTrue = (value) => value !== true;
36386
+ /** A list-valued key whose quiet value is the empty list. */
36387
+ const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
36388
+ /** The map-valued counterpart of {@link isNonEmptyList}. */
36389
+ const isNonEmptyMap = (value) => !isRecord$1(value) || Object.keys(value).length > 0;
36390
+ /**
36391
+ * What {@link readSandboxPath} returns when a container on the way to the leaf
36392
+ * is present but is not an object, so the leaf cannot be read at all. It is not
36393
+ * `undefined`, because the two mean opposite things to a caller: `undefined` is
36394
+ * "this path is not being written", while this is "something is being written
36395
+ * here and its shape hides what". The same fail-safe rule the predicates follow
36396
+ * applies to the walk — silence must mean "this cannot loosen anything", not
36397
+ * "this is not the shape the table expected".
36398
+ */
36399
+ const UNREADABLE_SANDBOX_PATH = Symbol("unreadable-sandbox-path");
36400
+ /**
36401
+ * Reads `sandbox` at `path`. Returns `undefined` when a segment is absent, and
36402
+ * {@link UNREADABLE_SANDBOX_PATH} when one is present but is not an object.
36403
+ * Shared by everything that addresses a `sandbox` path so a nested path added to
36404
+ * one of the tables is actually traversed rather than silently skipped, and so a
36405
+ * hostile shape (an array, a string, `null`) is reported rather than throwing.
36406
+ */
36407
+ function readSandboxPath({ sandbox, path }) {
36408
+ let cursor = sandbox;
36409
+ for (const segment of path) {
36410
+ if (cursor === void 0) return void 0;
36411
+ if (!isRecord$1(cursor)) return UNREADABLE_SANDBOX_PATH;
36412
+ cursor = cursor[segment];
36413
+ }
36414
+ return cursor;
36415
+ }
36416
+ /**
36417
+ * Every path in `paths` whose value in `sandbox` loosens the policy. Nothing is
36418
+ * removed — the values are written, just not silently. Call it on the block this
36419
+ * generate authored, after any scope filter has run: a value the file already
36420
+ * held is the user's own, not something rulesync opened, and a path a filter
36421
+ * dropped is not being written at all.
36422
+ */
36423
+ function collectTrustAffectingSandboxPaths({ sandbox, paths }) {
36424
+ const entries = [];
36425
+ const reportedContainers = /* @__PURE__ */ new Set();
36426
+ for (const { path, reason, widens } of paths) {
36427
+ const value = readSandboxPath({
36428
+ sandbox,
36429
+ path
36430
+ });
36431
+ if (value === void 0) continue;
36432
+ if (value === UNREADABLE_SANDBOX_PATH) {
36433
+ const label = findUnreadableContainer({
36434
+ sandbox,
36435
+ path
36436
+ });
36437
+ if (label === void 0 || reportedContainers.has(label)) continue;
36438
+ reportedContainers.add(label);
36439
+ entries.push({
36440
+ label,
36441
+ reason: UNREADABLE_CONTAINER_REASON
36442
+ });
36443
+ continue;
36444
+ }
36445
+ if (!widens(value)) continue;
36446
+ entries.push({
36447
+ label: `sandbox.${path.join(".")}`,
36448
+ reason
36449
+ });
36450
+ }
36451
+ return entries;
36452
+ }
36453
+ /** The reason printed for a container that hides the settings underneath it. */
36454
+ const UNREADABLE_CONTAINER_REASON = "is not the object it has to be, so nothing under it can be checked for what it opens";
36455
+ /**
36456
+ * The prefix of `path` that {@link readSandboxPath} could not walk past, as a
36457
+ * label. `undefined` when the walk was not blocked at all. Callers that report
36458
+ * an unreadable path name the container rather than the leaf, because the leaf
36459
+ * is not what the file actually holds.
36460
+ */
36461
+ function findUnreadableContainer({ sandbox, path }) {
36462
+ let cursor = sandbox;
36463
+ const walked = [];
36464
+ for (const segment of path) {
36465
+ if (cursor === void 0) return void 0;
36466
+ if (!isRecord$1(cursor)) return walked.length === 0 ? "sandbox" : `sandbox.${walked.join(".")}`;
36467
+ walked.push(segment);
36468
+ cursor = cursor[segment];
36469
+ }
36470
+ }
36471
+ /**
36472
+ * The reason printed for a value the file held in a shape that cannot be read,
36473
+ * which this generate is about to replace. `shape` names what the tool documents
36474
+ * there, so the message says which expectation the file's value missed.
36475
+ */
36476
+ const replacedUnreadableReason = ({ shape, toolLabel }) => `replaces a value already in the file that is not the ${shape} ${toolLabel} documents, so what it restricted cannot be read`;
36477
+ /**
36478
+ * The restrictions this generate would weaken, compared between the `sandbox`
36479
+ * already in the file and the one about to replace it. A `before` that is
36480
+ * present but not a list is reported outright: a shape the tool may still honor
36481
+ * is not something to go quiet about just because it cannot be diffed. Shared by
36482
+ * every tool whose override replaces a restricting list whole rather than
36483
+ * merging into it — Claude Code needs no equivalent, because it merges its lists
36484
+ * across settings scopes, so a file can only ever add to them.
36485
+ */
36486
+ function collectRestrictionLosingSandboxEntries({ existing, merged, paths, toolLabel }) {
36487
+ const entries = [];
36488
+ const reportedContainers = /* @__PURE__ */ new Set();
36489
+ for (const { path, reason, loosens } of paths) {
36490
+ const before = readSandboxPath({
36491
+ sandbox: existing,
36492
+ path
36493
+ });
36494
+ if (before === void 0) continue;
36495
+ const [rootKey] = path;
36496
+ if (rootKey !== void 0 && existing[rootKey] === merged[rootKey]) continue;
36497
+ const after = readSandboxPath({
36498
+ sandbox: merged,
36499
+ path
36500
+ });
36501
+ const label = `sandbox.${path.join(".")}`;
36502
+ if (before === UNREADABLE_SANDBOX_PATH) {
36503
+ const container = findUnreadableContainer({
36504
+ sandbox: existing,
36505
+ path
36506
+ });
36507
+ if (container === void 0 || reportedContainers.has(container)) continue;
36508
+ reportedContainers.add(container);
36509
+ entries.push({
36510
+ label: container,
36511
+ reason: replacedUnreadableReason({
36512
+ shape: "object",
36513
+ toolLabel
36514
+ })
36515
+ });
36516
+ continue;
36517
+ }
36518
+ if (!Array.isArray(before)) {
36519
+ entries.push({
36520
+ label,
36521
+ reason: replacedUnreadableReason({
36522
+ shape: "list",
36523
+ toolLabel
36524
+ })
36525
+ });
36526
+ continue;
36527
+ }
36528
+ if (before.length === 0) continue;
36529
+ if (!loosens({
36530
+ before,
36531
+ after: Array.isArray(after) ? after : []
36532
+ })) continue;
36533
+ entries.push({
36534
+ label,
36535
+ reason
36536
+ });
36537
+ }
36538
+ return entries;
36539
+ }
36540
+ /**
36541
+ * The one warning that names every trust-affecting setting this generate wrote
36542
+ * to `relativeFilePath`. Emitted once per file: the individual reasons are what
36543
+ * matter, but the "review this as you would a hook" framing only needs saying
36544
+ * once, and repeating it per key buries the reasons in boilerplate. `noun` lets
36545
+ * a tool whose entries are not all additions call them something more accurate
36546
+ * than "setting".
36547
+ */
36548
+ function warnOnTrustAffectingEntries({ toolLabel, noun = "setting", entries, relativeFilePath, logger }) {
36549
+ if (entries.length === 0) return;
36550
+ const one = entries.length === 1;
36551
+ const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
36552
+ logger?.warn(`${toolLabel} permissions: writing ${entries.length} trust-affecting ${noun}${one ? "" : "s"} to ${relativeFilePath}; review ${one ? "it" : "them"} as you would a hook, especially if this permissions file came from 'rulesync fetch'. ${details}.`);
36553
+ }
36554
+ //#endregion
36283
36555
  //#region src/features/permissions/claudecode-permissions.ts
36284
36556
  /**
36285
36557
  * Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
@@ -36328,19 +36600,6 @@ function parseClaudePermissionEntry(entry) {
36328
36600
  };
36329
36601
  }
36330
36602
  /**
36331
- * Claude Code's file permission checks match only `Edit(path)` and `Read(path)`
36332
- * rules. A `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted
36333
- * but never matched by those checks, so Claude Code warns at startup for each
36334
- * allow, deny, or ask rule in one of these unmatched forms" — so a canonical
36335
- * `write`/`notebookedit`/`glob` rule with a pattern is emitted in the form the
36336
- * docs prescribe instead. A tool-name rule with no path is unaffected: it
36337
- * matches the tool everywhere and produces no warning.
36338
- * @see https://code.claude.com/docs/en/permissions
36339
- */
36340
- function isPlainRecord(value) {
36341
- return typeof value === "object" && value !== null && !Array.isArray(value);
36342
- }
36343
- /**
36344
36603
  * Merge `patch` into `base`, recursing into plain objects so a sibling key at
36345
36604
  * any depth survives. Arrays and scalars are replaced, since a list the author
36346
36605
  * states is the list they mean.
@@ -36350,7 +36609,7 @@ function deepMergeRecords(base, patch) {
36350
36609
  for (const [key, value] of Object.entries(patch)) {
36351
36610
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36352
36611
  const existing = merged[key];
36353
- merged[key] = isPlainRecord(existing) && isPlainRecord(value) ? deepMergeRecords(existing, value) : value;
36612
+ merged[key] = isRecord$1(existing) && isRecord$1(value) ? deepMergeRecords(existing, value) : value;
36354
36613
  }
36355
36614
  return merged;
36356
36615
  }
@@ -36415,13 +36674,11 @@ const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadP
36415
36674
  * traversed rather than silently skipped.
36416
36675
  */
36417
36676
  function resolveSandboxParent({ root, segments }) {
36418
- let parent = root;
36419
- for (const segment of segments) {
36420
- const next = parent[segment];
36421
- if (!isPlainRecord(next)) return void 0;
36422
- parent = next;
36423
- }
36424
- return parent;
36677
+ const resolved = readSandboxPath({
36678
+ sandbox: root,
36679
+ path: segments
36680
+ });
36681
+ return isRecord$1(resolved) ? resolved : void 0;
36425
36682
  }
36426
36683
  /**
36427
36684
  * Deletes `path` from `target` in place and reports whether anything was there,
@@ -36455,18 +36712,6 @@ function deleteSandboxPath({ target, path }) {
36455
36712
  return true;
36456
36713
  }
36457
36714
  /**
36458
- * The one warning that names every trust-affecting setting this generate wrote
36459
- * to `relativeFilePath`. Emitted once per file: the individual reasons are what
36460
- * matter, but the "review this as you would a hook" framing only needs saying
36461
- * once, and repeating it per key buries the reasons in boilerplate.
36462
- */
36463
- function warnOnTrustAffectingEntries({ entries, relativeFilePath, logger }) {
36464
- if (entries.length === 0) return;
36465
- const one = entries.length === 1;
36466
- const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
36467
- 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}.`);
36468
- }
36469
- /**
36470
36715
  * The `permissions.defaultMode` values that start a session with fewer prompts
36471
36716
  * than the default. `plan` and `default` are absent because they do not widen
36472
36717
  * anything.
@@ -36511,18 +36756,6 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
36511
36756
  ["socatPath"]
36512
36757
  ];
36513
36758
  /**
36514
- * The predicates the "which value actually widens?" tables are built from.
36515
- * Each names the value that does *not* widen and reports everything else, never
36516
- * the reverse: the override is authored JSONC, so a key can carry any value at
36517
- * all, and one Claude Code coerces is still honored. Reporting an off-type value
36518
- * keeps the warning fail-safe — silence has to mean "this cannot loosen
36519
- * anything", not "this is not the type the table expected".
36520
- */
36521
- const isNotFalse = (value) => value !== false;
36522
- const isNotTrue = (value) => value !== true;
36523
- const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
36524
- const isNonEmptyMap = (value) => !isPlainRecord(value) || Object.keys(value).length > 0;
36525
- /**
36526
36759
  * `sandbox` paths that loosen the sandbox rather than naming something to run:
36527
36760
  * they let commands out of it, weaken the isolation it provides, or redirect
36528
36761
  * where its traffic goes. They are written like `env` is — the ordinary uses are
@@ -36625,30 +36858,6 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
36625
36858
  widens: () => true
36626
36859
  }
36627
36860
  ];
36628
- /**
36629
- * Every authored `sandbox` path that loosens the sandbox. Nothing is removed —
36630
- * the values are written, just not silently. Called on the filtered `sandbox`
36631
- * so it never claims to be writing a path the scope filters dropped.
36632
- */
36633
- function collectTrustAffectingSandboxPaths({ sandbox }) {
36634
- const entries = [];
36635
- for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
36636
- const leaf = path.at(-1);
36637
- if (leaf === void 0) continue;
36638
- const parent = resolveSandboxParent({
36639
- root: sandbox,
36640
- segments: path.slice(0, -1)
36641
- });
36642
- if (parent === void 0) continue;
36643
- const value = parent[leaf];
36644
- if (value === void 0 || !widens(value)) continue;
36645
- entries.push({
36646
- label: `sandbox.${path.join(".")}`,
36647
- reason
36648
- });
36649
- }
36650
- return entries;
36651
- }
36652
36861
  /** Paths that name an executable Claude Code runs. Refused in both scopes. */
36653
36862
  const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
36654
36863
  paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
@@ -36719,13 +36928,13 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
36719
36928
  */
36720
36929
  function stripProjectIgnoredMaskEntries({ sandbox, relativeFilePath, logger }) {
36721
36930
  const credentials = sandbox.credentials;
36722
- if (!isPlainRecord(credentials)) return sandbox;
36931
+ if (!isRecord$1(credentials)) return sandbox;
36723
36932
  const filteredCredentials = { ...credentials };
36724
36933
  let changed = false;
36725
36934
  for (const listKey of CLAUDECODE_MASKABLE_CREDENTIAL_LISTS) {
36726
36935
  const list = filteredCredentials[listKey];
36727
36936
  if (!Array.isArray(list)) continue;
36728
- const kept = list.filter((entry) => !(isPlainRecord(entry) && entry.mode === "mask"));
36937
+ const kept = list.filter((entry) => !(isRecord$1(entry) && entry.mode === "mask"));
36729
36938
  if (kept.length === list.length) continue;
36730
36939
  changed = true;
36731
36940
  const dropped = list.length - kept.length;
@@ -37004,6 +37213,16 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
37004
37213
  trustAffecting
37005
37214
  };
37006
37215
  }
37216
+ /**
37217
+ * Claude Code's file permission checks match only `Edit(path)` and `Read(path)`
37218
+ * rules. A `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted
37219
+ * but never matched by those checks, so Claude Code warns at startup for each
37220
+ * allow, deny, or ask rule in one of these unmatched forms" — so a canonical
37221
+ * `write`/`notebookedit`/`glob` rule with a pattern is emitted in the form the
37222
+ * docs prescribe instead. A tool-name rule with no path is unaffected: it
37223
+ * matches the tool everywhere and produces no warning.
37224
+ * @see https://code.claude.com/docs/en/permissions
37225
+ */
37007
37226
  const CLAUDE_PATH_RULE_ALIASES = {
37008
37227
  Write: "Edit",
37009
37228
  NotebookEdit: "Edit",
@@ -37084,7 +37303,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37084
37303
  };
37085
37304
  }
37086
37305
  const overrideSandbox = config.claudecode?.sandbox;
37087
- if (isPlainRecord(overrideSandbox)) {
37306
+ if (isRecord$1(overrideSandbox)) {
37088
37307
  const honorableSandbox = stripSandboxPaths({
37089
37308
  sandbox: overrideSandbox,
37090
37309
  refusals: [
@@ -37100,8 +37319,11 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37100
37319
  relativeFilePath: paths.relativeFilePath,
37101
37320
  logger
37102
37321
  });
37103
- trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
37104
- if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
37322
+ trustAffecting.push(...collectTrustAffectingSandboxPaths({
37323
+ sandbox: scopedSandbox,
37324
+ paths: CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS
37325
+ }));
37326
+ if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isRecord$1(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
37105
37327
  }
37106
37328
  const overrideTopLevel = {};
37107
37329
  for (const [key, value] of Object.entries(config.claudecode ?? {})) {
@@ -37119,8 +37341,9 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37119
37341
  trustAffecting.push(...trustAffectingTopLevel);
37120
37342
  if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
37121
37343
  warnOnTrustAffectingEntries({
37344
+ toolLabel: "Claude Code",
37122
37345
  entries: trustAffecting,
37123
- relativeFilePath: paths.relativeFilePath,
37346
+ relativeFilePath: toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)),
37124
37347
  logger
37125
37348
  });
37126
37349
  const managedToolNames = managedClaudeToolNames(config);
@@ -37163,7 +37386,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37163
37386
  const nonListFields = Object.fromEntries(Object.entries(permissionsRest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
37164
37387
  if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
37165
37388
  const { sandbox } = settings;
37166
- if (isPlainRecord(sandbox)) {
37389
+ if (isRecord$1(sandbox)) {
37167
37390
  const importedSandbox = structuredClone(sandbox);
37168
37391
  for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) deleteSandboxPath({
37169
37392
  target: importedSandbox,
@@ -39416,6 +39639,80 @@ function buildDevinPermissionEntry(scope, pattern) {
39416
39639
  if (pattern === "*") return scope;
39417
39640
  return `${scope}(${pattern})`;
39418
39641
  }
39642
+ function asDevinRecord(value) {
39643
+ return isRecord$1(value) ? { ...value } : {};
39644
+ }
39645
+ /**
39646
+ * `sandbox` paths whose authored value loosens the sandbox on its own: they let
39647
+ * a command out of it, or widen what a command left inside it may reach. They
39648
+ * are written — the ordinary uses are far too common to refuse — but never
39649
+ * silently, because a permissions file is shareable (`rulesync fetch` copies one
39650
+ * into a project) and should not be able to open the sandbox without saying so.
39651
+ * This is the same stance `CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS` takes for
39652
+ * the equivalent Claude Code keys, and `widens` follows the same convention of
39653
+ * naming the restrictive value rather than the permissive ones, so a spelling
39654
+ * Devin does not recognize is reported rather than waved through.
39655
+ *
39656
+ * The three keys that restrict — `allowed_domains` (an allowlist only while it
39657
+ * has entries), `denied_domains` and `excluded.deny` — are not here: they loosen
39658
+ * by losing entries, which `DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS` covers.
39659
+ *
39660
+ * @see https://docs.devin.ai/cli/sandbox
39661
+ */
39662
+ const DEVIN_TRUST_AFFECTING_SANDBOX_PATHS = [
39663
+ {
39664
+ path: ["network_mode"],
39665
+ reason: "anything but 'limited' lets sandboxed requests use every HTTP method, not just GET/HEAD/OPTIONS",
39666
+ widens: (value) => value !== "limited"
39667
+ },
39668
+ {
39669
+ path: ["excluded", "allow"],
39670
+ reason: "names commands that run outside the sandbox with no prompt and no sandbox policy",
39671
+ widens: isNonEmptyList
39672
+ },
39673
+ {
39674
+ path: ["excluded", "ask"],
39675
+ reason: "names commands that run outside the sandbox once confirmed, with no sandbox policy",
39676
+ widens: isNonEmptyList
39677
+ }
39678
+ ];
39679
+ /** How Devin is named in the warnings this file emits. */
39680
+ const DEVIN_TOOL_LABEL = "Devin";
39681
+ /**
39682
+ * `sandbox` paths that restrict, and that therefore loosen the policy by losing
39683
+ * entries rather than by holding a value. Devin's config is one file rather than
39684
+ * a stack of settings scopes, and the override is shallow-merged over the
39685
+ * existing `sandbox` at its top level: each of these lists is replaced whole,
39686
+ * and `excluded.deny` vanishes as soon as the override states any other
39687
+ * `excluded` key. Losing an entry has the same effect as adding one to the
39688
+ * permissive keys above, so it is announced the same way. Claude Code needs no
39689
+ * equivalent — it merges its lists across settings scopes, so a file can only
39690
+ * ever add to them.
39691
+ *
39692
+ * `loosens` is asked only about a `before` that actually restricted something,
39693
+ * and the two directions are not symmetric: `allowed_domains` restricts by
39694
+ * listing what is reachable, so it loosens by gaining entries or by emptying
39695
+ * out altogether, while the deny lists loosen by losing entries.
39696
+ *
39697
+ * @see https://docs.devin.ai/cli/sandbox
39698
+ */
39699
+ const DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS = [
39700
+ {
39701
+ path: ["allowed_domains"],
39702
+ reason: "adds to the proxy allowlist already in the file, or empties it so every domain becomes reachable again",
39703
+ loosens: ({ before, after }) => after.length === 0 || after.some((entry) => !before.includes(entry))
39704
+ },
39705
+ {
39706
+ path: ["denied_domains"],
39707
+ reason: "drops domains the deny list already in the file kept out of reach",
39708
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
39709
+ },
39710
+ {
39711
+ path: ["excluded", "deny"],
39712
+ reason: "drops commands the list already in the file pinned inside the sandbox",
39713
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
39714
+ }
39715
+ ];
39419
39716
  /**
39420
39717
  * Permissions generator for Devin Local (native `.devin/` configuration).
39421
39718
  *
@@ -39431,10 +39728,18 @@ function buildDevinPermissionEntry(scope, pattern) {
39431
39728
  *
39432
39729
  * In global mode the config file is shared with the hooks (`hooks`) feature
39433
39730
  * (MCP moved to the dedicated mcp_config.json in v3000.3), so reads and writes
39434
- * merge into the existing JSON and the file is never deleted; only the managed
39435
- * `permissions` key is rewritten.
39731
+ * merge into the existing JSON and the file is never deleted; only the keys
39732
+ * this feature manages are rewritten — `permissions`, plus `sandbox` in global
39733
+ * mode when the `devin` override authors it.
39734
+ *
39735
+ * The sibling `sandbox` block — which decides what a permitted command may
39736
+ * reach rather than which commands are permitted — has no canonical category
39737
+ * and is authored through the `devin` override in `.rulesync/permissions.jsonc`.
39738
+ * Devin documents it as a user-config-only key, so it is written at global
39739
+ * scope only.
39436
39740
  *
39437
39741
  * @see https://docs.devin.ai/cli/reference/permissions
39742
+ * @see https://docs.devin.ai/cli/sandbox
39438
39743
  */
39439
39744
  var DevinPermissions = class DevinPermissions extends ToolPermissions {
39440
39745
  constructor(params) {
@@ -39445,7 +39750,8 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39445
39750
  }
39446
39751
  /**
39447
39752
  * config.json may carry the MCP/hooks features' keys, so it is never deleted;
39448
- * only the managed `permissions` key is rewritten.
39753
+ * only the keys this feature manages are rewritten — `permissions`, plus
39754
+ * `sandbox` in global mode when the `devin` override authors it.
39449
39755
  */
39450
39756
  isDeletable() {
39451
39757
  return false;
@@ -39471,7 +39777,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39471
39777
  validate
39472
39778
  });
39473
39779
  }
39474
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true }) {
39780
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true, logger }) {
39475
39781
  const paths = DevinPermissions.getSettablePaths({ global });
39476
39782
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
39477
39783
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
@@ -39497,6 +39803,46 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39497
39803
  else delete mergedPermissions.ask;
39498
39804
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
39499
39805
  else delete mergedPermissions.deny;
39806
+ const patch = { permissions: mergedPermissions };
39807
+ const authoredSandbox = config.devin?.sandbox;
39808
+ if (authoredSandbox !== void 0) {
39809
+ const authoredSandboxRecord = asDevinRecord(authoredSandbox);
39810
+ if (global) {
39811
+ const existingSandbox = asDevinRecord(settings.sandbox);
39812
+ const mergedSandbox = {
39813
+ ...existingSandbox,
39814
+ ...authoredSandboxRecord
39815
+ };
39816
+ const writesSandbox = Object.keys(mergedSandbox).length > 0;
39817
+ if (writesSandbox) patch.sandbox = mergedSandbox;
39818
+ const replacesUnreadableSandbox = writesSandbox && settings.sandbox !== void 0 && !isRecord$1(settings.sandbox);
39819
+ warnOnTrustAffectingEntries({
39820
+ toolLabel: DEVIN_TOOL_LABEL,
39821
+ noun: "sandbox change",
39822
+ entries: [
39823
+ ...replacesUnreadableSandbox ? [{
39824
+ label: "sandbox",
39825
+ reason: replacedUnreadableReason({
39826
+ shape: "object",
39827
+ toolLabel: DEVIN_TOOL_LABEL
39828
+ })
39829
+ }] : [],
39830
+ ...collectTrustAffectingSandboxPaths({
39831
+ sandbox: authoredSandboxRecord,
39832
+ paths: DEVIN_TRUST_AFFECTING_SANDBOX_PATHS
39833
+ }),
39834
+ ...collectRestrictionLosingSandboxEntries({
39835
+ existing: existingSandbox,
39836
+ merged: mergedSandbox,
39837
+ paths: DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS,
39838
+ toolLabel: DEVIN_TOOL_LABEL
39839
+ })
39840
+ ],
39841
+ relativeFilePath: toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)),
39842
+ logger
39843
+ });
39844
+ } else if (Object.keys(authoredSandboxRecord).length > 0) logger?.warn("Devin reads 'sandbox' from the user config only, so the 'devin.sandbox' override was dropped from the project config. Generate with --global to author it.");
39845
+ }
39500
39846
  return new DevinPermissions({
39501
39847
  outputRoot,
39502
39848
  relativeDirPath: paths.relativeDirPath,
@@ -39505,7 +39851,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39505
39851
  fileKey: sharedConfigFileKey(paths),
39506
39852
  feature: "permissions",
39507
39853
  existingContent,
39508
- patch: { permissions: mergedPermissions },
39854
+ patch,
39509
39855
  filePath
39510
39856
  }),
39511
39857
  validate
@@ -39525,7 +39871,10 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39525
39871
  ask: Array.isArray(permissions.ask) ? permissions.ask : [],
39526
39872
  deny: Array.isArray(permissions.deny) ? permissions.deny : []
39527
39873
  });
39528
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
39874
+ const sandbox = asDevinRecord(settings.sandbox);
39875
+ const result = { ...config };
39876
+ if (Object.keys(sandbox).length > 0) result.devin = { sandbox };
39877
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
39529
39878
  }
39530
39879
  validate() {
39531
39880
  return {
@@ -42473,6 +42822,137 @@ function buildReasonixPermissionEntry(toolName, pattern) {
42473
42822
  if (pattern === "*") return toolName;
42474
42823
  return `${toolName}(${pattern})`;
42475
42824
  }
42825
+ /** How Reasonix is named in the warnings this file emits. */
42826
+ const REASONIX_TOOL_LABEL = "Reasonix";
42827
+ /** The `[permissions]` key the override's `allowDynamicBash` writes and reads. */
42828
+ const REASONIX_ALLOW_DYNAMIC_BASH_KEY = "allow_dynamic_bash";
42829
+ /**
42830
+ * `[sandbox]` keys whose authored value loosens the enforcement layer beneath
42831
+ * the permission policy: they take Bash out of its OS jail, open that jail to
42832
+ * the network, or widen where the file-writing built-ins may write. Written —
42833
+ * the ordinary uses are far too common to refuse — but never silently, the same
42834
+ * stance `DEVIN_TRUST_AFFECTING_SANDBOX_PATHS` takes, and `widens` likewise
42835
+ * names the restrictive value so a spelling Reasonix does not recognize is
42836
+ * reported rather than waved through.
42837
+ *
42838
+ * `forbid_read` is not here: it restricts, so it loosens by losing entries
42839
+ * rather than by holding one, which needs the before/after comparison
42840
+ * {@link REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS} below does instead.
42841
+ *
42842
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
42843
+ */
42844
+ const REASONIX_TRUST_AFFECTING_SANDBOX_PATHS = [
42845
+ {
42846
+ path: ["bash"],
42847
+ reason: "anything but 'enforce' takes Bash out of the OS sandbox, so a command may write and read wherever the user can",
42848
+ widens: (value) => value !== "enforce"
42849
+ },
42850
+ {
42851
+ path: ["network"],
42852
+ reason: "lets sandboxed Bash reach the network",
42853
+ widens: isNotFalse
42854
+ },
42855
+ {
42856
+ path: ["allow_write"],
42857
+ reason: "adds directories the file-writing tools may modify outside the workspace root, which a headless run would otherwise refuse",
42858
+ widens: isNonEmptyList
42859
+ },
42860
+ {
42861
+ path: ["workspace_root"],
42862
+ reason: "moves the root the file-writing tools and sandboxed Bash are confined to, so what they may reach is decided by this path rather than by the project directory",
42863
+ widens: escapesTheProject
42864
+ }
42865
+ ];
42866
+ /**
42867
+ * Whether a `workspace_root` points somewhere other than inside the project the
42868
+ * generate runs in. The key moves the write confinement rather than adding to
42869
+ * it, so the ordinary value — the project directory, spelled relatively — would
42870
+ * otherwise be announced on every generate; anything else is the case worth
42871
+ * naming, since it is how a fetched permissions file would put `~/.ssh` or
42872
+ * `C:\\Users\\<user>` inside the jail: an absolute path in either flavour, one
42873
+ * carrying a drive letter, a home-relative one, a shell or environment
42874
+ * expansion, and any path holding a `..` segment — even one that would land back
42875
+ * inside, since resolving it here would only be a guess at what Reasonix does.
42876
+ * Both path flavours are asked because the file is authored on one machine and
42877
+ * generated on another, so a Windows-shaped root reaching a POSIX check must not
42878
+ * read as relative. Anything that is not a string is reported, per the fail-safe
42879
+ * rule the predicates in `sandbox-trust.ts` follow.
42880
+ */
42881
+ function escapesTheProject(value) {
42882
+ if (typeof value !== "string") return true;
42883
+ const trimmed = value.trim();
42884
+ if (trimmed === "") return false;
42885
+ if (trimmed.startsWith("~")) return true;
42886
+ if (node_path.posix.isAbsolute(trimmed) || node_path.win32.isAbsolute(trimmed)) return true;
42887
+ if (/^[A-Za-z]:/.test(trimmed)) return true;
42888
+ if (trimmed.includes("$") || /%[^%]+%/.test(trimmed)) return true;
42889
+ return trimmed.split(/[\\/]/).includes("..");
42890
+ }
42891
+ /**
42892
+ * The `[sandbox]` key that restricts, and so loosens by losing entries rather
42893
+ * than by holding a value. The override is shallow-merged over the existing
42894
+ * `[sandbox]` at its top level, so an authored `forbid_read` replaces the list
42895
+ * the file had whole — emptying it, or dropping the `${HOME}/.ssh` entry
42896
+ * Reasonix's own example recommends, opens exactly what the list kept closed.
42897
+ *
42898
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
42899
+ */
42900
+ const REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS = [{
42901
+ path: ["forbid_read"],
42902
+ reason: "drops paths the list already in the file kept out of read, list and search",
42903
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
42904
+ }];
42905
+ /**
42906
+ * Everything worth naming about the `[sandbox]` table this generate is about to
42907
+ * write: the authored values that loosen enforcement, the restrictions the
42908
+ * shallow merge would drop, and a `[sandbox]` the file holds in some shape other
42909
+ * than a table, which the write replaces wholesale. The widening check reads the
42910
+ * authored block alone — a loosening value the file already held is the user's
42911
+ * own, and re-announcing it on every generate would bury the values rulesync
42912
+ * actually wrote — while a loss can only be seen from both sides.
42913
+ */
42914
+ function collectSandboxOverlayEntries({ existing, authored, merged }) {
42915
+ return [
42916
+ ...existing !== void 0 && !isRecord$1(existing) ? [{
42917
+ label: "sandbox",
42918
+ reason: replacedUnreadableReason({
42919
+ shape: "object",
42920
+ toolLabel: REASONIX_TOOL_LABEL
42921
+ })
42922
+ }] : [],
42923
+ ...collectTrustAffectingSandboxPaths({
42924
+ sandbox: asReasonixRecord(authored),
42925
+ paths: REASONIX_TRUST_AFFECTING_SANDBOX_PATHS
42926
+ }),
42927
+ ...collectRestrictionLosingSandboxEntries({
42928
+ existing: asReasonixRecord(existing),
42929
+ merged,
42930
+ paths: REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS,
42931
+ toolLabel: REASONIX_TOOL_LABEL
42932
+ })
42933
+ ];
42934
+ }
42935
+ /**
42936
+ * Writes the override's `allow_dynamic_bash` into `[permissions]`, where it sits
42937
+ * beside allow/ask/deny rather than in a table of its own, and reports it when
42938
+ * it is being turned on: it widens what a shareable permissions file lets run
42939
+ * with no human in the loop. Only an authored value is written — leaving the key
42940
+ * out of the override keeps whatever the file already had — and turning it off
42941
+ * narrows, so that stays quiet. Only a literal `false` is quiet, not everything
42942
+ * falsy: `getJson()` casts rather than parses, so a `--no-validate` run can put
42943
+ * a value the schema forbids here, and a value Reasonix might still coerce is
42944
+ * not something to write in silence. The entries are returned rather than logged so
42945
+ * one generate still produces one warning naming everything it wrote.
42946
+ */
42947
+ function applyAllowDynamicBash({ permissions, authored }) {
42948
+ if (authored === void 0) return [];
42949
+ permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY] = authored;
42950
+ if (!isNotFalse(authored)) return [];
42951
+ return [{
42952
+ label: `permissions.${REASONIX_ALLOW_DYNAMIC_BASH_KEY}`,
42953
+ reason: "lets an Allow fallback, Auto included, run the nested and indirect Bash that otherwise needs a human or an exact-literal grant — command and process substitution, a dynamic command name, 'eval', 'source', 'sh -c' and their kind"
42954
+ }];
42955
+ }
42476
42956
  function parseReasonixConfig(fileContent) {
42477
42957
  const parsed = smol_toml.parse(fileContent || smol_toml.stringify({}));
42478
42958
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -42529,6 +43009,7 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42529
43009
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
42530
43010
  const paths = this.getSettablePaths({ global });
42531
43011
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
43012
+ const relativeFilePathForLog = toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath));
42532
43013
  const existingContent = await readFileContentOrNull(filePath) ?? "";
42533
43014
  const parsed = parseReasonixConfig(existingContent);
42534
43015
  const config = rulesyncPermissions.getJson();
@@ -42573,11 +43054,23 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42573
43054
  ...deny,
42574
43055
  ...rawDeny
42575
43056
  ]);
43057
+ const trustAffecting = applyAllowDynamicBash({
43058
+ permissions: mergedPermissions,
43059
+ authored: override?.allowDynamicBash
43060
+ });
42576
43061
  const patch = { permissions: mergedPermissions };
42577
- if (override?.sandbox !== void 0) patch.sandbox = {
42578
- ...asReasonixRecord(parsed.sandbox),
42579
- ...asReasonixRecord(override.sandbox)
42580
- };
43062
+ if (override?.sandbox !== void 0) {
43063
+ const mergedSandbox = {
43064
+ ...asReasonixRecord(parsed.sandbox),
43065
+ ...asReasonixRecord(override.sandbox)
43066
+ };
43067
+ patch.sandbox = mergedSandbox;
43068
+ trustAffecting.push(...collectSandboxOverlayEntries({
43069
+ existing: parsed.sandbox,
43070
+ authored: override.sandbox,
43071
+ merged: mergedSandbox
43072
+ }));
43073
+ }
42581
43074
  if (override?.agent !== void 0) {
42582
43075
  const mergedAgent = {
42583
43076
  ...asReasonixRecord(parsed.agent),
@@ -42585,9 +43078,16 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42585
43078
  };
42586
43079
  const retired = REASONIX_RETIRED_AGENT_KEYS.filter((key) => mergedAgent[key] !== void 0);
42587
43080
  for (const key of retired) delete mergedAgent[key];
42588
- if (retired.length > 0) logger?.warn(`Reasonix permissions: removing ${retired.map((key) => `"${key}"`).join(", ")} from [agent] in ${filePath}; Reasonix took the key off its config surface in v1.17.18, so what it used to express now belongs in the shared \`permission\` block.`);
43081
+ if (retired.length > 0) logger?.warn(`Reasonix permissions: removing ${retired.map((key) => `"${key}"`).join(", ")} from [agent] in ${relativeFilePathForLog}; Reasonix took the key off its config surface in v1.17.18, so what it used to express now belongs in the shared \`permission\` block.`);
42589
43082
  patch.agent = mergedAgent;
42590
43083
  }
43084
+ warnOnTrustAffectingEntries({
43085
+ toolLabel: REASONIX_TOOL_LABEL,
43086
+ noun: "change",
43087
+ entries: trustAffecting,
43088
+ relativeFilePath: relativeFilePathForLog,
43089
+ logger
43090
+ });
42591
43091
  return new ReasonixPermissions({
42592
43092
  outputRoot,
42593
43093
  relativeDirPath: paths.relativeDirPath,
@@ -42622,6 +43122,8 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42622
43122
  const sandbox = asReasonixRecord(this.toml.sandbox);
42623
43123
  const agentPlanMode = pickReasonixKeys(this.toml.agent, [...REASONIX_OVERRIDE_AGENT_KEYS, ...REASONIX_RETIRED_AGENT_KEYS]);
42624
43124
  const reasonixOverride = {};
43125
+ const allowDynamicBash = permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY];
43126
+ if (typeof allowDynamicBash === "boolean") reasonixOverride.allowDynamicBash = allowDynamicBash;
42625
43127
  if (Object.keys(sandbox).length > 0) reasonixOverride.sandbox = sandbox;
42626
43128
  if (Object.keys(agentPlanMode).length > 0) reasonixOverride.agent = agentPlanMode;
42627
43129
  if (allowSplit.exact.length > 0) reasonixOverride.rawAllow = allowSplit.exact;