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.
@@ -3360,6 +3360,47 @@ var RulesyncFile = class extends AiFile {
3360
3360
  }
3361
3361
  };
3362
3362
  //#endregion
3363
+ //#region src/utils/bounded-walk.ts
3364
+ const ALIAS_HINT = "(a chain of YAML aliases may be amplifying the document)";
3365
+ /**
3366
+ * Create the bookkeeping for one walk. `subject` names the document kind in
3367
+ * every error ("Frontmatter", "Shared config"); `root`, when given, is entered
3368
+ * up front so the root object counts as the first nesting level, matching the
3369
+ * +1 that `enter` applies to every container nested inside it.
3370
+ */
3371
+ function createBoundedWalk({ subject, limits, root }) {
3372
+ const ancestors = /* @__PURE__ */ new WeakSet();
3373
+ let valuesRemaining = limits.maxValues;
3374
+ let stringCharsRemaining = limits.maxStringChars;
3375
+ let depth = 0;
3376
+ const chargeChars = (chars) => {
3377
+ stringCharsRemaining -= chars;
3378
+ if (stringCharsRemaining < 0) throw new Error(`${subject}'s string values expand to more than ${limits.maxStringChars} characters; refusing to process it ${ALIAS_HINT}`);
3379
+ };
3380
+ const chargeValue = (stringChars = 0) => {
3381
+ valuesRemaining -= 1;
3382
+ if (valuesRemaining < 0) throw new Error(`${subject} expands to more than ${limits.maxValues} values; refusing to process it ${ALIAS_HINT}`);
3383
+ chargeChars(stringChars);
3384
+ };
3385
+ const enter = (container) => {
3386
+ depth += 1;
3387
+ if (depth > limits.maxDepth) throw new Error(`${subject} nests more than ${limits.maxDepth} levels deep; refusing to process it ${ALIAS_HINT}`);
3388
+ ancestors.add(container);
3389
+ };
3390
+ const leave = (container) => {
3391
+ ancestors.delete(container);
3392
+ depth -= 1;
3393
+ };
3394
+ if (root !== void 0) enter(root);
3395
+ return {
3396
+ chargeValue,
3397
+ chargeChars,
3398
+ isAncestor: (container) => ancestors.has(container),
3399
+ enter,
3400
+ leave
3401
+ };
3402
+ }
3403
+ //#endregion
3363
3404
  //#region src/utils/prototype-pollution.ts
3364
3405
  /**
3365
3406
  * Keys that, if walked into when constructing or merging objects from
@@ -3499,30 +3540,6 @@ const MAX_FRONTMATTER_STRING_CHARS = 4e6;
3499
3540
  * manifest stays well under this.
3500
3541
  */
3501
3542
  const MAX_FRONTMATTER_RAW_CHARS = 65536;
3502
- /** Charge string content (a string leaf or an object key) against the character budget. */
3503
- function chargeStringChars({ options, chars }) {
3504
- options.budget.stringCharsRemaining -= chars;
3505
- 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)`);
3506
- }
3507
- function consumeBudget({ options, stringChars = 0 }) {
3508
- options.budget.remaining -= 1;
3509
- 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)`);
3510
- chargeStringChars({
3511
- options,
3512
- chars: stringChars
3513
- });
3514
- }
3515
- /** Enter one more container level, throwing if the depth cap is exceeded. */
3516
- function enterContainer({ options, container }) {
3517
- options.depth += 1;
3518
- 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)`);
3519
- options.ancestors.add(container);
3520
- }
3521
- /** Leave a container level entered via {@link enterContainer}. */
3522
- function leaveContainer({ options, container }) {
3523
- options.ancestors.delete(container);
3524
- options.depth -= 1;
3525
- }
3526
3543
  /**
3527
3544
  * Estimate the serialized character cost of a leaf that is not a string (a
3528
3545
  * string leaf is charged by its own length instead).
@@ -3545,40 +3562,26 @@ function estimateLeafChars(value) {
3545
3562
  * bounded by {@link MAX_FRONTMATTER_VALUES} instead.
3546
3563
  */
3547
3564
  function deepCleanValue(value, options) {
3548
- consumeBudget({
3549
- options,
3550
- stringChars: typeof value === "string" ? value.length : estimateLeafChars(value)
3551
- });
3565
+ const leafChars = typeof value === "string" ? value.length : estimateLeafChars(value);
3566
+ options.walk.chargeValue(leafChars);
3552
3567
  if (value === null || value === void 0) return;
3553
3568
  if (typeof value === "string") return options.transformString ? options.transformString(value) : value;
3554
3569
  if (Array.isArray(value)) {
3555
- if (options.ancestors.has(value)) return;
3556
- enterContainer({
3557
- options,
3558
- container: value
3559
- });
3570
+ if (options.walk.isAncestor(value)) return;
3571
+ options.walk.enter(value);
3560
3572
  const cleanedArray = [];
3561
3573
  for (const item of value) {
3562
3574
  const cleaned = deepCleanValue(item, options);
3563
3575
  if (cleaned !== void 0) cleanedArray.push(cleaned);
3564
3576
  }
3565
- leaveContainer({
3566
- options,
3567
- container: value
3568
- });
3577
+ options.walk.leave(value);
3569
3578
  return cleanedArray;
3570
3579
  }
3571
3580
  if (isPlainObject$1(value)) {
3572
- if (options.ancestors.has(value)) return;
3573
- enterContainer({
3574
- options,
3575
- container: value
3576
- });
3581
+ if (options.walk.isAncestor(value)) return;
3582
+ options.walk.enter(value);
3577
3583
  const result = cleanOwnEntries(value, options);
3578
- leaveContainer({
3579
- options,
3580
- container: value
3581
- });
3584
+ options.walk.leave(value);
3582
3585
  return result;
3583
3586
  }
3584
3587
  return value;
@@ -3597,10 +3600,7 @@ function deepCleanValue(value, options) {
3597
3600
  function cleanOwnEntries(obj, options) {
3598
3601
  const result = {};
3599
3602
  for (const [key, val] of Object.entries(obj)) {
3600
- chargeStringChars({
3601
- options,
3602
- chars: key.length
3603
- });
3603
+ options.walk.chargeChars(key.length);
3604
3604
  const cleaned = deepCleanValue(val, options);
3605
3605
  if (isPrototypePollutionKey(key)) continue;
3606
3606
  if (cleaned !== void 0) result[key] = cleaned;
@@ -3611,12 +3611,15 @@ function deepCleanObject(obj, options) {
3611
3611
  if (!obj || typeof obj !== "object") return {};
3612
3612
  return cleanOwnEntries(obj, {
3613
3613
  ...options,
3614
- ancestors: new WeakSet([obj]),
3615
- budget: {
3616
- remaining: MAX_FRONTMATTER_VALUES,
3617
- stringCharsRemaining: MAX_FRONTMATTER_STRING_CHARS
3618
- },
3619
- depth: 1
3614
+ walk: createBoundedWalk({
3615
+ subject: "Frontmatter",
3616
+ limits: {
3617
+ maxValues: MAX_FRONTMATTER_VALUES,
3618
+ maxStringChars: MAX_FRONTMATTER_STRING_CHARS,
3619
+ maxDepth: 64
3620
+ },
3621
+ root: obj
3622
+ })
3620
3623
  });
3621
3624
  }
3622
3625
  /** Drop null and undefined values, recursively. */
@@ -6671,6 +6674,8 @@ const QwencodePermissionsOverrideSchema = z.looseObject({
6671
6674
  *
6672
6675
  * @example
6673
6676
  * { "sandbox": { "bash": "enforce", "network": false }, "agent": { "plan_mode_read_only_commands": ["gh pr diff"] } }
6677
+ * @example
6678
+ * { "allowDynamicBash": true, "rawAllow": ["Bash=pnpm test"] }
6674
6679
  */
6675
6680
  const ReasonixPermissionsOverrideSchema = z.looseObject({
6676
6681
  permission: z.optional(ToolScopedPermissionSchema),
@@ -6678,7 +6683,8 @@ const ReasonixPermissionsOverrideSchema = z.looseObject({
6678
6683
  agent: z.optional(z.looseObject({})),
6679
6684
  rawAllow: z.optional(z.array(z.string())),
6680
6685
  rawAsk: z.optional(z.array(z.string())),
6681
- rawDeny: z.optional(z.array(z.string()))
6686
+ rawDeny: z.optional(z.array(z.string())),
6687
+ allowDynamicBash: z.optional(z.boolean())
6682
6688
  });
6683
6689
  /**
6684
6690
  * Tool-scoped override block for Factory Droid. Factory Droid's `settings.json`
@@ -7322,6 +7328,32 @@ const ZedPermissionsOverrideSchema = z.looseObject({
7322
7328
  })))
7323
7329
  });
7324
7330
  /**
7331
+ * Tool-scoped override block for Devin Local. `sandbox` is the sibling
7332
+ * top-level `config.json` block that governs the sandbox Devin runs commands
7333
+ * in: `allowed_domains` / `denied_domains` (proxy domain patterns, deny beating
7334
+ * allow), `network_mode` (`full`, the upstream default, allows every HTTP
7335
+ * method; `limited` only GET/HEAD/OPTIONS) and `excluded` (`allow` / `ask` /
7336
+ * `deny` lists of `Exec(...)` matchers deciding which commands run *outside* the
7337
+ * sandbox — `deny` pins them inside it). It constrains how
7338
+ * a permitted command runs rather than which commands are permitted, so it has
7339
+ * no canonical category and is authored here.
7340
+ *
7341
+ * Upstream lists `sandbox` as a **User Config Only** key, so it is emitted at
7342
+ * global scope only; at project scope it is dropped with a warning rather than
7343
+ * written into a file Devin would ignore.
7344
+ *
7345
+ * @example
7346
+ * { "sandbox": { "allowed_domains": ["github.com"], "network_mode": "limited" } }
7347
+ * @example
7348
+ * { "sandbox": { "excluded": { "allow": ["Exec(git status *)"], "deny": ["Exec(git tag *)"] } } }
7349
+ * @see https://docs.devin.ai/cli/sandbox
7350
+ * @see https://docs.devin.ai/cli/reference/configuration/config-file
7351
+ */
7352
+ const DevinPermissionsOverrideSchema = z.looseObject({
7353
+ permission: z.optional(ToolScopedPermissionSchema),
7354
+ sandbox: z.optional(z.looseObject({}))
7355
+ });
7356
+ /**
7325
7357
  * Permissions configuration.
7326
7358
  * Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
7327
7359
  * Values are pattern-to-action mappings for that tool category.
@@ -7371,10 +7403,10 @@ const PermissionsConfigSchema = z.looseObject({
7371
7403
  kiro: z.optional(KiroPermissionsOverrideSchema),
7372
7404
  codexcli: z.optional(CodexcliPermissionsOverrideSchema),
7373
7405
  zed: z.optional(ZedPermissionsOverrideSchema),
7406
+ devin: z.optional(DevinPermissionsOverrideSchema),
7374
7407
  "antigravity-ide": z.optional(CanonicalPermissionsOverrideSchema),
7375
7408
  copilot: z.optional(CanonicalPermissionsOverrideSchema),
7376
7409
  copilotcli: z.optional(CanonicalPermissionsOverrideSchema),
7377
- devin: z.optional(CanonicalPermissionsOverrideSchema),
7378
7410
  goose: z.optional(CanonicalPermissionsOverrideSchema),
7379
7411
  grokcli: z.optional(CanonicalPermissionsOverrideSchema),
7380
7412
  "kimi-code": z.optional(KimiCodePermissionsOverrideSchema),
@@ -11458,6 +11490,21 @@ function stripStrings(_key, value) {
11458
11490
  //#endregion
11459
11491
  //#region src/features/shared/shared-config-gateway.ts
11460
11492
  /**
11493
+ * Upper bound on the number of values a shared config document may expand
11494
+ * to once every YAML alias is written out. Real config files hold a few
11495
+ * hundred values at most; even a large MCP server catalog stays orders of
11496
+ * magnitude below the limit.
11497
+ */
11498
+ const MAX_SHARED_CONFIG_VALUES = 1e5;
11499
+ /**
11500
+ * Upper bound on the total character count of the string leaves and keys a
11501
+ * shared config document may expand to. The value budget bounds how many
11502
+ * values are visited, but one long string aliased thousands of times fits
11503
+ * that budget while the duplicated output balloons; charging every visited
11504
+ * string's length separately bounds the output regardless of alias count.
11505
+ */
11506
+ const MAX_SHARED_CONFIG_STRING_CHARS = 4e6;
11507
+ /**
11461
11508
  * Rebuild a parsed document without its prototype-pollution keys.
11462
11509
  *
11463
11510
  * Every object is rebuilt, not just the ones that are already plain: a literal
@@ -11472,15 +11519,61 @@ function stripStrings(_key, value) {
11472
11519
  *
11473
11520
  * Dates are the one object the YAML and TOML parsers produce that is not a
11474
11521
  * mapping, so they are passed through rather than flattened into `{}`.
11522
+ *
11523
+ * The rebuild is bounded, because a YAML alias makes one parsed container
11524
+ * reachable from many keys and every alias is copied out independently (the
11525
+ * writers dump with `noRefs: true`, so memoizing here would only move the
11526
+ * blowup into serialization). A small "alias bomb" of nested anchors would
11527
+ * otherwise cost exponential time and memory, and a self-referencing anchor
11528
+ * would recurse until the stack overflowed — both reachable from a config
11529
+ * file committed to a cloned repository. The walk therefore charges every
11530
+ * value against {@link MAX_SHARED_CONFIG_VALUES}, every string and key
11531
+ * against {@link MAX_SHARED_CONFIG_STRING_CHARS}, caps nesting at
11532
+ * {@link MAX_SHARED_CONFIG_DEPTH}, and refuses a reference back to an
11533
+ * ancestor outright, each with a clear error instead of a hang or a crash.
11475
11534
  */
11476
11535
  function sanitizeSharedConfigValue(value) {
11477
- if (Array.isArray(value)) return value.map(sanitizeSharedConfigValue);
11536
+ return sanitizeSharedConfigValueBounded(value, createBoundedWalk({
11537
+ subject: "Shared config",
11538
+ limits: {
11539
+ maxValues: MAX_SHARED_CONFIG_VALUES,
11540
+ maxStringChars: MAX_SHARED_CONFIG_STRING_CHARS,
11541
+ maxDepth: 64
11542
+ }
11543
+ }));
11544
+ }
11545
+ /**
11546
+ * Refuse a container that is already on the descent path. Unlike the
11547
+ * frontmatter cleaner, which drops such a cycle and keeps the rest of the
11548
+ * document, a shared config file is refused outright: silently dropping part
11549
+ * of a user's settings file would let a later write-back persist the loss.
11550
+ */
11551
+ function enterSharedConfigContainer(walk, container) {
11552
+ 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");
11553
+ walk.enter(container);
11554
+ }
11555
+ function sanitizeSharedConfigValueBounded(value, walk) {
11556
+ if (typeof value === "string") {
11557
+ walk.chargeValue(value.length);
11558
+ return value;
11559
+ }
11560
+ walk.chargeValue();
11561
+ if (Array.isArray(value)) {
11562
+ enterSharedConfigContainer(walk, value);
11563
+ const items = value.map((item) => sanitizeSharedConfigValueBounded(item, walk));
11564
+ walk.leave(value);
11565
+ return items;
11566
+ }
11478
11567
  if (value === null || typeof value !== "object" || value instanceof Date) return value;
11568
+ enterSharedConfigContainer(walk, value);
11479
11569
  const result = {};
11480
11570
  for (const [key, nested] of Object.entries(value)) {
11571
+ walk.chargeChars(key.length);
11572
+ const sanitized = sanitizeSharedConfigValueBounded(nested, walk);
11481
11573
  if (isPrototypePollutionKey(key)) continue;
11482
- result[key] = sanitizeSharedConfigValue(nested);
11574
+ result[key] = sanitized;
11483
11575
  }
11576
+ walk.leave(value);
11484
11577
  return result;
11485
11578
  }
11486
11579
  /**
@@ -11509,7 +11602,12 @@ function parseSharedConfig({ format, fileContent, filePath, invalidRootPolicy =
11509
11602
  throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
11510
11603
  }
11511
11604
  if (parsed === void 0 || parsed === null) return {};
11512
- const sanitized = sanitizeSharedConfigValue(parsed);
11605
+ let sanitized;
11606
+ try {
11607
+ sanitized = sanitizeSharedConfigValue(parsed);
11608
+ } catch (error) {
11609
+ throw new Error(`Failed to parse shared config${at}: ${formatError(error)}`, { cause: error });
11610
+ }
11513
11611
  if (!isPlainObject$1(sanitized)) {
11514
11612
  if (invalidRootPolicy === "error") throw new Error(`Failed to parse shared config${at}: expected a mapping at the root`);
11515
11613
  return {};
@@ -12534,7 +12632,7 @@ const SHARED_CONFIG_OWNERSHIP = {
12534
12632
  },
12535
12633
  permissions: {
12536
12634
  kind: "replace-owned-keys",
12537
- ownedKeys: ["permissions"]
12635
+ ownedKeys: ["permissions", "sandbox"]
12538
12636
  }
12539
12637
  }
12540
12638
  },
@@ -36255,6 +36353,180 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
36255
36353
  return { permission };
36256
36354
  }
36257
36355
  //#endregion
36356
+ //#region src/features/permissions/sandbox-trust.ts
36357
+ /** A key whose quiet value is an explicit `false`. */
36358
+ const isNotFalse = (value) => value !== false;
36359
+ /** A key whose quiet value is an explicit `true`. */
36360
+ const isNotTrue = (value) => value !== true;
36361
+ /** A list-valued key whose quiet value is the empty list. */
36362
+ const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
36363
+ /** The map-valued counterpart of {@link isNonEmptyList}. */
36364
+ const isNonEmptyMap = (value) => !isRecord$1(value) || Object.keys(value).length > 0;
36365
+ /**
36366
+ * What {@link readSandboxPath} returns when a container on the way to the leaf
36367
+ * is present but is not an object, so the leaf cannot be read at all. It is not
36368
+ * `undefined`, because the two mean opposite things to a caller: `undefined` is
36369
+ * "this path is not being written", while this is "something is being written
36370
+ * here and its shape hides what". The same fail-safe rule the predicates follow
36371
+ * applies to the walk — silence must mean "this cannot loosen anything", not
36372
+ * "this is not the shape the table expected".
36373
+ */
36374
+ const UNREADABLE_SANDBOX_PATH = Symbol("unreadable-sandbox-path");
36375
+ /**
36376
+ * Reads `sandbox` at `path`. Returns `undefined` when a segment is absent, and
36377
+ * {@link UNREADABLE_SANDBOX_PATH} when one is present but is not an object.
36378
+ * Shared by everything that addresses a `sandbox` path so a nested path added to
36379
+ * one of the tables is actually traversed rather than silently skipped, and so a
36380
+ * hostile shape (an array, a string, `null`) is reported rather than throwing.
36381
+ */
36382
+ function readSandboxPath({ sandbox, path }) {
36383
+ let cursor = sandbox;
36384
+ for (const segment of path) {
36385
+ if (cursor === void 0) return void 0;
36386
+ if (!isRecord$1(cursor)) return UNREADABLE_SANDBOX_PATH;
36387
+ cursor = cursor[segment];
36388
+ }
36389
+ return cursor;
36390
+ }
36391
+ /**
36392
+ * Every path in `paths` whose value in `sandbox` loosens the policy. Nothing is
36393
+ * removed — the values are written, just not silently. Call it on the block this
36394
+ * generate authored, after any scope filter has run: a value the file already
36395
+ * held is the user's own, not something rulesync opened, and a path a filter
36396
+ * dropped is not being written at all.
36397
+ */
36398
+ function collectTrustAffectingSandboxPaths({ sandbox, paths }) {
36399
+ const entries = [];
36400
+ const reportedContainers = /* @__PURE__ */ new Set();
36401
+ for (const { path, reason, widens } of paths) {
36402
+ const value = readSandboxPath({
36403
+ sandbox,
36404
+ path
36405
+ });
36406
+ if (value === void 0) continue;
36407
+ if (value === UNREADABLE_SANDBOX_PATH) {
36408
+ const label = findUnreadableContainer({
36409
+ sandbox,
36410
+ path
36411
+ });
36412
+ if (label === void 0 || reportedContainers.has(label)) continue;
36413
+ reportedContainers.add(label);
36414
+ entries.push({
36415
+ label,
36416
+ reason: UNREADABLE_CONTAINER_REASON
36417
+ });
36418
+ continue;
36419
+ }
36420
+ if (!widens(value)) continue;
36421
+ entries.push({
36422
+ label: `sandbox.${path.join(".")}`,
36423
+ reason
36424
+ });
36425
+ }
36426
+ return entries;
36427
+ }
36428
+ /** The reason printed for a container that hides the settings underneath it. */
36429
+ const UNREADABLE_CONTAINER_REASON = "is not the object it has to be, so nothing under it can be checked for what it opens";
36430
+ /**
36431
+ * The prefix of `path` that {@link readSandboxPath} could not walk past, as a
36432
+ * label. `undefined` when the walk was not blocked at all. Callers that report
36433
+ * an unreadable path name the container rather than the leaf, because the leaf
36434
+ * is not what the file actually holds.
36435
+ */
36436
+ function findUnreadableContainer({ sandbox, path }) {
36437
+ let cursor = sandbox;
36438
+ const walked = [];
36439
+ for (const segment of path) {
36440
+ if (cursor === void 0) return void 0;
36441
+ if (!isRecord$1(cursor)) return walked.length === 0 ? "sandbox" : `sandbox.${walked.join(".")}`;
36442
+ walked.push(segment);
36443
+ cursor = cursor[segment];
36444
+ }
36445
+ }
36446
+ /**
36447
+ * The reason printed for a value the file held in a shape that cannot be read,
36448
+ * which this generate is about to replace. `shape` names what the tool documents
36449
+ * there, so the message says which expectation the file's value missed.
36450
+ */
36451
+ 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`;
36452
+ /**
36453
+ * The restrictions this generate would weaken, compared between the `sandbox`
36454
+ * already in the file and the one about to replace it. A `before` that is
36455
+ * present but not a list is reported outright: a shape the tool may still honor
36456
+ * is not something to go quiet about just because it cannot be diffed. Shared by
36457
+ * every tool whose override replaces a restricting list whole rather than
36458
+ * merging into it — Claude Code needs no equivalent, because it merges its lists
36459
+ * across settings scopes, so a file can only ever add to them.
36460
+ */
36461
+ function collectRestrictionLosingSandboxEntries({ existing, merged, paths, toolLabel }) {
36462
+ const entries = [];
36463
+ const reportedContainers = /* @__PURE__ */ new Set();
36464
+ for (const { path, reason, loosens } of paths) {
36465
+ const before = readSandboxPath({
36466
+ sandbox: existing,
36467
+ path
36468
+ });
36469
+ if (before === void 0) continue;
36470
+ const [rootKey] = path;
36471
+ if (rootKey !== void 0 && existing[rootKey] === merged[rootKey]) continue;
36472
+ const after = readSandboxPath({
36473
+ sandbox: merged,
36474
+ path
36475
+ });
36476
+ const label = `sandbox.${path.join(".")}`;
36477
+ if (before === UNREADABLE_SANDBOX_PATH) {
36478
+ const container = findUnreadableContainer({
36479
+ sandbox: existing,
36480
+ path
36481
+ });
36482
+ if (container === void 0 || reportedContainers.has(container)) continue;
36483
+ reportedContainers.add(container);
36484
+ entries.push({
36485
+ label: container,
36486
+ reason: replacedUnreadableReason({
36487
+ shape: "object",
36488
+ toolLabel
36489
+ })
36490
+ });
36491
+ continue;
36492
+ }
36493
+ if (!Array.isArray(before)) {
36494
+ entries.push({
36495
+ label,
36496
+ reason: replacedUnreadableReason({
36497
+ shape: "list",
36498
+ toolLabel
36499
+ })
36500
+ });
36501
+ continue;
36502
+ }
36503
+ if (before.length === 0) continue;
36504
+ if (!loosens({
36505
+ before,
36506
+ after: Array.isArray(after) ? after : []
36507
+ })) continue;
36508
+ entries.push({
36509
+ label,
36510
+ reason
36511
+ });
36512
+ }
36513
+ return entries;
36514
+ }
36515
+ /**
36516
+ * The one warning that names every trust-affecting setting this generate wrote
36517
+ * to `relativeFilePath`. Emitted once per file: the individual reasons are what
36518
+ * matter, but the "review this as you would a hook" framing only needs saying
36519
+ * once, and repeating it per key buries the reasons in boilerplate. `noun` lets
36520
+ * a tool whose entries are not all additions call them something more accurate
36521
+ * than "setting".
36522
+ */
36523
+ function warnOnTrustAffectingEntries({ toolLabel, noun = "setting", entries, relativeFilePath, logger }) {
36524
+ if (entries.length === 0) return;
36525
+ const one = entries.length === 1;
36526
+ const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
36527
+ 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}.`);
36528
+ }
36529
+ //#endregion
36258
36530
  //#region src/features/permissions/claudecode-permissions.ts
36259
36531
  /**
36260
36532
  * Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
@@ -36303,19 +36575,6 @@ function parseClaudePermissionEntry(entry) {
36303
36575
  };
36304
36576
  }
36305
36577
  /**
36306
- * Claude Code's file permission checks match only `Edit(path)` and `Read(path)`
36307
- * rules. A `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted
36308
- * but never matched by those checks, so Claude Code warns at startup for each
36309
- * allow, deny, or ask rule in one of these unmatched forms" — so a canonical
36310
- * `write`/`notebookedit`/`glob` rule with a pattern is emitted in the form the
36311
- * docs prescribe instead. A tool-name rule with no path is unaffected: it
36312
- * matches the tool everywhere and produces no warning.
36313
- * @see https://code.claude.com/docs/en/permissions
36314
- */
36315
- function isPlainRecord(value) {
36316
- return typeof value === "object" && value !== null && !Array.isArray(value);
36317
- }
36318
- /**
36319
36578
  * Merge `patch` into `base`, recursing into plain objects so a sibling key at
36320
36579
  * any depth survives. Arrays and scalars are replaced, since a list the author
36321
36580
  * states is the list they mean.
@@ -36325,7 +36584,7 @@ function deepMergeRecords(base, patch) {
36325
36584
  for (const [key, value] of Object.entries(patch)) {
36326
36585
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
36327
36586
  const existing = merged[key];
36328
- merged[key] = isPlainRecord(existing) && isPlainRecord(value) ? deepMergeRecords(existing, value) : value;
36587
+ merged[key] = isRecord$1(existing) && isRecord$1(value) ? deepMergeRecords(existing, value) : value;
36329
36588
  }
36330
36589
  return merged;
36331
36590
  }
@@ -36390,13 +36649,11 @@ const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadP
36390
36649
  * traversed rather than silently skipped.
36391
36650
  */
36392
36651
  function resolveSandboxParent({ root, segments }) {
36393
- let parent = root;
36394
- for (const segment of segments) {
36395
- const next = parent[segment];
36396
- if (!isPlainRecord(next)) return void 0;
36397
- parent = next;
36398
- }
36399
- return parent;
36652
+ const resolved = readSandboxPath({
36653
+ sandbox: root,
36654
+ path: segments
36655
+ });
36656
+ return isRecord$1(resolved) ? resolved : void 0;
36400
36657
  }
36401
36658
  /**
36402
36659
  * Deletes `path` from `target` in place and reports whether anything was there,
@@ -36430,18 +36687,6 @@ function deleteSandboxPath({ target, path }) {
36430
36687
  return true;
36431
36688
  }
36432
36689
  /**
36433
- * The one warning that names every trust-affecting setting this generate wrote
36434
- * to `relativeFilePath`. Emitted once per file: the individual reasons are what
36435
- * matter, but the "review this as you would a hook" framing only needs saying
36436
- * once, and repeating it per key buries the reasons in boilerplate.
36437
- */
36438
- function warnOnTrustAffectingEntries({ entries, relativeFilePath, logger }) {
36439
- if (entries.length === 0) return;
36440
- const one = entries.length === 1;
36441
- const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
36442
- 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}.`);
36443
- }
36444
- /**
36445
36690
  * The `permissions.defaultMode` values that start a session with fewer prompts
36446
36691
  * than the default. `plan` and `default` are absent because they do not widen
36447
36692
  * anything.
@@ -36486,18 +36731,6 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
36486
36731
  ["socatPath"]
36487
36732
  ];
36488
36733
  /**
36489
- * The predicates the "which value actually widens?" tables are built from.
36490
- * Each names the value that does *not* widen and reports everything else, never
36491
- * the reverse: the override is authored JSONC, so a key can carry any value at
36492
- * all, and one Claude Code coerces is still honored. Reporting an off-type value
36493
- * keeps the warning fail-safe — silence has to mean "this cannot loosen
36494
- * anything", not "this is not the type the table expected".
36495
- */
36496
- const isNotFalse = (value) => value !== false;
36497
- const isNotTrue = (value) => value !== true;
36498
- const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
36499
- const isNonEmptyMap = (value) => !isPlainRecord(value) || Object.keys(value).length > 0;
36500
- /**
36501
36734
  * `sandbox` paths that loosen the sandbox rather than naming something to run:
36502
36735
  * they let commands out of it, weaken the isolation it provides, or redirect
36503
36736
  * where its traffic goes. They are written like `env` is — the ordinary uses are
@@ -36600,30 +36833,6 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
36600
36833
  widens: () => true
36601
36834
  }
36602
36835
  ];
36603
- /**
36604
- * Every authored `sandbox` path that loosens the sandbox. Nothing is removed —
36605
- * the values are written, just not silently. Called on the filtered `sandbox`
36606
- * so it never claims to be writing a path the scope filters dropped.
36607
- */
36608
- function collectTrustAffectingSandboxPaths({ sandbox }) {
36609
- const entries = [];
36610
- for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
36611
- const leaf = path.at(-1);
36612
- if (leaf === void 0) continue;
36613
- const parent = resolveSandboxParent({
36614
- root: sandbox,
36615
- segments: path.slice(0, -1)
36616
- });
36617
- if (parent === void 0) continue;
36618
- const value = parent[leaf];
36619
- if (value === void 0 || !widens(value)) continue;
36620
- entries.push({
36621
- label: `sandbox.${path.join(".")}`,
36622
- reason
36623
- });
36624
- }
36625
- return entries;
36626
- }
36627
36836
  /** Paths that name an executable Claude Code runs. Refused in both scopes. */
36628
36837
  const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
36629
36838
  paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
@@ -36694,13 +36903,13 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
36694
36903
  */
36695
36904
  function stripProjectIgnoredMaskEntries({ sandbox, relativeFilePath, logger }) {
36696
36905
  const credentials = sandbox.credentials;
36697
- if (!isPlainRecord(credentials)) return sandbox;
36906
+ if (!isRecord$1(credentials)) return sandbox;
36698
36907
  const filteredCredentials = { ...credentials };
36699
36908
  let changed = false;
36700
36909
  for (const listKey of CLAUDECODE_MASKABLE_CREDENTIAL_LISTS) {
36701
36910
  const list = filteredCredentials[listKey];
36702
36911
  if (!Array.isArray(list)) continue;
36703
- const kept = list.filter((entry) => !(isPlainRecord(entry) && entry.mode === "mask"));
36912
+ const kept = list.filter((entry) => !(isRecord$1(entry) && entry.mode === "mask"));
36704
36913
  if (kept.length === list.length) continue;
36705
36914
  changed = true;
36706
36915
  const dropped = list.length - kept.length;
@@ -36979,6 +37188,16 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
36979
37188
  trustAffecting
36980
37189
  };
36981
37190
  }
37191
+ /**
37192
+ * Claude Code's file permission checks match only `Edit(path)` and `Read(path)`
37193
+ * rules. A `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted
37194
+ * but never matched by those checks, so Claude Code warns at startup for each
37195
+ * allow, deny, or ask rule in one of these unmatched forms" — so a canonical
37196
+ * `write`/`notebookedit`/`glob` rule with a pattern is emitted in the form the
37197
+ * docs prescribe instead. A tool-name rule with no path is unaffected: it
37198
+ * matches the tool everywhere and produces no warning.
37199
+ * @see https://code.claude.com/docs/en/permissions
37200
+ */
36982
37201
  const CLAUDE_PATH_RULE_ALIASES = {
36983
37202
  Write: "Edit",
36984
37203
  NotebookEdit: "Edit",
@@ -37059,7 +37278,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37059
37278
  };
37060
37279
  }
37061
37280
  const overrideSandbox = config.claudecode?.sandbox;
37062
- if (isPlainRecord(overrideSandbox)) {
37281
+ if (isRecord$1(overrideSandbox)) {
37063
37282
  const honorableSandbox = stripSandboxPaths({
37064
37283
  sandbox: overrideSandbox,
37065
37284
  refusals: [
@@ -37075,8 +37294,11 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37075
37294
  relativeFilePath: paths.relativeFilePath,
37076
37295
  logger
37077
37296
  });
37078
- trustAffecting.push(...collectTrustAffectingSandboxPaths({ sandbox: scopedSandbox }));
37079
- if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
37297
+ trustAffecting.push(...collectTrustAffectingSandboxPaths({
37298
+ sandbox: scopedSandbox,
37299
+ paths: CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS
37300
+ }));
37301
+ if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isRecord$1(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
37080
37302
  }
37081
37303
  const overrideTopLevel = {};
37082
37304
  for (const [key, value] of Object.entries(config.claudecode ?? {})) {
@@ -37094,8 +37316,9 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37094
37316
  trustAffecting.push(...trustAffectingTopLevel);
37095
37317
  if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
37096
37318
  warnOnTrustAffectingEntries({
37319
+ toolLabel: "Claude Code",
37097
37320
  entries: trustAffecting,
37098
- relativeFilePath: paths.relativeFilePath,
37321
+ relativeFilePath: toPosixPath(join(paths.relativeDirPath, paths.relativeFilePath)),
37099
37322
  logger
37100
37323
  });
37101
37324
  const managedToolNames = managedClaudeToolNames(config);
@@ -37138,7 +37361,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
37138
37361
  const nonListFields = Object.fromEntries(Object.entries(permissionsRest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
37139
37362
  if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
37140
37363
  const { sandbox } = settings;
37141
- if (isPlainRecord(sandbox)) {
37364
+ if (isRecord$1(sandbox)) {
37142
37365
  const importedSandbox = structuredClone(sandbox);
37143
37366
  for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) deleteSandboxPath({
37144
37367
  target: importedSandbox,
@@ -39391,6 +39614,80 @@ function buildDevinPermissionEntry(scope, pattern) {
39391
39614
  if (pattern === "*") return scope;
39392
39615
  return `${scope}(${pattern})`;
39393
39616
  }
39617
+ function asDevinRecord(value) {
39618
+ return isRecord$1(value) ? { ...value } : {};
39619
+ }
39620
+ /**
39621
+ * `sandbox` paths whose authored value loosens the sandbox on its own: they let
39622
+ * a command out of it, or widen what a command left inside it may reach. They
39623
+ * are written — the ordinary uses are far too common to refuse — but never
39624
+ * silently, because a permissions file is shareable (`rulesync fetch` copies one
39625
+ * into a project) and should not be able to open the sandbox without saying so.
39626
+ * This is the same stance `CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS` takes for
39627
+ * the equivalent Claude Code keys, and `widens` follows the same convention of
39628
+ * naming the restrictive value rather than the permissive ones, so a spelling
39629
+ * Devin does not recognize is reported rather than waved through.
39630
+ *
39631
+ * The three keys that restrict — `allowed_domains` (an allowlist only while it
39632
+ * has entries), `denied_domains` and `excluded.deny` — are not here: they loosen
39633
+ * by losing entries, which `DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS` covers.
39634
+ *
39635
+ * @see https://docs.devin.ai/cli/sandbox
39636
+ */
39637
+ const DEVIN_TRUST_AFFECTING_SANDBOX_PATHS = [
39638
+ {
39639
+ path: ["network_mode"],
39640
+ reason: "anything but 'limited' lets sandboxed requests use every HTTP method, not just GET/HEAD/OPTIONS",
39641
+ widens: (value) => value !== "limited"
39642
+ },
39643
+ {
39644
+ path: ["excluded", "allow"],
39645
+ reason: "names commands that run outside the sandbox with no prompt and no sandbox policy",
39646
+ widens: isNonEmptyList
39647
+ },
39648
+ {
39649
+ path: ["excluded", "ask"],
39650
+ reason: "names commands that run outside the sandbox once confirmed, with no sandbox policy",
39651
+ widens: isNonEmptyList
39652
+ }
39653
+ ];
39654
+ /** How Devin is named in the warnings this file emits. */
39655
+ const DEVIN_TOOL_LABEL = "Devin";
39656
+ /**
39657
+ * `sandbox` paths that restrict, and that therefore loosen the policy by losing
39658
+ * entries rather than by holding a value. Devin's config is one file rather than
39659
+ * a stack of settings scopes, and the override is shallow-merged over the
39660
+ * existing `sandbox` at its top level: each of these lists is replaced whole,
39661
+ * and `excluded.deny` vanishes as soon as the override states any other
39662
+ * `excluded` key. Losing an entry has the same effect as adding one to the
39663
+ * permissive keys above, so it is announced the same way. Claude Code needs no
39664
+ * equivalent — it merges its lists across settings scopes, so a file can only
39665
+ * ever add to them.
39666
+ *
39667
+ * `loosens` is asked only about a `before` that actually restricted something,
39668
+ * and the two directions are not symmetric: `allowed_domains` restricts by
39669
+ * listing what is reachable, so it loosens by gaining entries or by emptying
39670
+ * out altogether, while the deny lists loosen by losing entries.
39671
+ *
39672
+ * @see https://docs.devin.ai/cli/sandbox
39673
+ */
39674
+ const DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS = [
39675
+ {
39676
+ path: ["allowed_domains"],
39677
+ reason: "adds to the proxy allowlist already in the file, or empties it so every domain becomes reachable again",
39678
+ loosens: ({ before, after }) => after.length === 0 || after.some((entry) => !before.includes(entry))
39679
+ },
39680
+ {
39681
+ path: ["denied_domains"],
39682
+ reason: "drops domains the deny list already in the file kept out of reach",
39683
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
39684
+ },
39685
+ {
39686
+ path: ["excluded", "deny"],
39687
+ reason: "drops commands the list already in the file pinned inside the sandbox",
39688
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
39689
+ }
39690
+ ];
39394
39691
  /**
39395
39692
  * Permissions generator for Devin Local (native `.devin/` configuration).
39396
39693
  *
@@ -39406,10 +39703,18 @@ function buildDevinPermissionEntry(scope, pattern) {
39406
39703
  *
39407
39704
  * In global mode the config file is shared with the hooks (`hooks`) feature
39408
39705
  * (MCP moved to the dedicated mcp_config.json in v3000.3), so reads and writes
39409
- * merge into the existing JSON and the file is never deleted; only the managed
39410
- * `permissions` key is rewritten.
39706
+ * merge into the existing JSON and the file is never deleted; only the keys
39707
+ * this feature manages are rewritten — `permissions`, plus `sandbox` in global
39708
+ * mode when the `devin` override authors it.
39709
+ *
39710
+ * The sibling `sandbox` block — which decides what a permitted command may
39711
+ * reach rather than which commands are permitted — has no canonical category
39712
+ * and is authored through the `devin` override in `.rulesync/permissions.jsonc`.
39713
+ * Devin documents it as a user-config-only key, so it is written at global
39714
+ * scope only.
39411
39715
  *
39412
39716
  * @see https://docs.devin.ai/cli/reference/permissions
39717
+ * @see https://docs.devin.ai/cli/sandbox
39413
39718
  */
39414
39719
  var DevinPermissions = class DevinPermissions extends ToolPermissions {
39415
39720
  constructor(params) {
@@ -39420,7 +39725,8 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39420
39725
  }
39421
39726
  /**
39422
39727
  * config.json may carry the MCP/hooks features' keys, so it is never deleted;
39423
- * only the managed `permissions` key is rewritten.
39728
+ * only the keys this feature manages are rewritten — `permissions`, plus
39729
+ * `sandbox` in global mode when the `devin` override authors it.
39424
39730
  */
39425
39731
  isDeletable() {
39426
39732
  return false;
@@ -39446,7 +39752,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39446
39752
  validate
39447
39753
  });
39448
39754
  }
39449
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true }) {
39755
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true, logger }) {
39450
39756
  const paths = DevinPermissions.getSettablePaths({ global });
39451
39757
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
39452
39758
  const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
@@ -39472,6 +39778,46 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39472
39778
  else delete mergedPermissions.ask;
39473
39779
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
39474
39780
  else delete mergedPermissions.deny;
39781
+ const patch = { permissions: mergedPermissions };
39782
+ const authoredSandbox = config.devin?.sandbox;
39783
+ if (authoredSandbox !== void 0) {
39784
+ const authoredSandboxRecord = asDevinRecord(authoredSandbox);
39785
+ if (global) {
39786
+ const existingSandbox = asDevinRecord(settings.sandbox);
39787
+ const mergedSandbox = {
39788
+ ...existingSandbox,
39789
+ ...authoredSandboxRecord
39790
+ };
39791
+ const writesSandbox = Object.keys(mergedSandbox).length > 0;
39792
+ if (writesSandbox) patch.sandbox = mergedSandbox;
39793
+ const replacesUnreadableSandbox = writesSandbox && settings.sandbox !== void 0 && !isRecord$1(settings.sandbox);
39794
+ warnOnTrustAffectingEntries({
39795
+ toolLabel: DEVIN_TOOL_LABEL,
39796
+ noun: "sandbox change",
39797
+ entries: [
39798
+ ...replacesUnreadableSandbox ? [{
39799
+ label: "sandbox",
39800
+ reason: replacedUnreadableReason({
39801
+ shape: "object",
39802
+ toolLabel: DEVIN_TOOL_LABEL
39803
+ })
39804
+ }] : [],
39805
+ ...collectTrustAffectingSandboxPaths({
39806
+ sandbox: authoredSandboxRecord,
39807
+ paths: DEVIN_TRUST_AFFECTING_SANDBOX_PATHS
39808
+ }),
39809
+ ...collectRestrictionLosingSandboxEntries({
39810
+ existing: existingSandbox,
39811
+ merged: mergedSandbox,
39812
+ paths: DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS,
39813
+ toolLabel: DEVIN_TOOL_LABEL
39814
+ })
39815
+ ],
39816
+ relativeFilePath: toPosixPath(join(paths.relativeDirPath, paths.relativeFilePath)),
39817
+ logger
39818
+ });
39819
+ } 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.");
39820
+ }
39475
39821
  return new DevinPermissions({
39476
39822
  outputRoot,
39477
39823
  relativeDirPath: paths.relativeDirPath,
@@ -39480,7 +39826,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39480
39826
  fileKey: sharedConfigFileKey(paths),
39481
39827
  feature: "permissions",
39482
39828
  existingContent,
39483
- patch: { permissions: mergedPermissions },
39829
+ patch,
39484
39830
  filePath
39485
39831
  }),
39486
39832
  validate
@@ -39500,7 +39846,10 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
39500
39846
  ask: Array.isArray(permissions.ask) ? permissions.ask : [],
39501
39847
  deny: Array.isArray(permissions.deny) ? permissions.deny : []
39502
39848
  });
39503
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
39849
+ const sandbox = asDevinRecord(settings.sandbox);
39850
+ const result = { ...config };
39851
+ if (Object.keys(sandbox).length > 0) result.devin = { sandbox };
39852
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
39504
39853
  }
39505
39854
  validate() {
39506
39855
  return {
@@ -42448,6 +42797,137 @@ function buildReasonixPermissionEntry(toolName, pattern) {
42448
42797
  if (pattern === "*") return toolName;
42449
42798
  return `${toolName}(${pattern})`;
42450
42799
  }
42800
+ /** How Reasonix is named in the warnings this file emits. */
42801
+ const REASONIX_TOOL_LABEL = "Reasonix";
42802
+ /** The `[permissions]` key the override's `allowDynamicBash` writes and reads. */
42803
+ const REASONIX_ALLOW_DYNAMIC_BASH_KEY = "allow_dynamic_bash";
42804
+ /**
42805
+ * `[sandbox]` keys whose authored value loosens the enforcement layer beneath
42806
+ * the permission policy: they take Bash out of its OS jail, open that jail to
42807
+ * the network, or widen where the file-writing built-ins may write. Written —
42808
+ * the ordinary uses are far too common to refuse — but never silently, the same
42809
+ * stance `DEVIN_TRUST_AFFECTING_SANDBOX_PATHS` takes, and `widens` likewise
42810
+ * names the restrictive value so a spelling Reasonix does not recognize is
42811
+ * reported rather than waved through.
42812
+ *
42813
+ * `forbid_read` is not here: it restricts, so it loosens by losing entries
42814
+ * rather than by holding one, which needs the before/after comparison
42815
+ * {@link REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS} below does instead.
42816
+ *
42817
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
42818
+ */
42819
+ const REASONIX_TRUST_AFFECTING_SANDBOX_PATHS = [
42820
+ {
42821
+ path: ["bash"],
42822
+ reason: "anything but 'enforce' takes Bash out of the OS sandbox, so a command may write and read wherever the user can",
42823
+ widens: (value) => value !== "enforce"
42824
+ },
42825
+ {
42826
+ path: ["network"],
42827
+ reason: "lets sandboxed Bash reach the network",
42828
+ widens: isNotFalse
42829
+ },
42830
+ {
42831
+ path: ["allow_write"],
42832
+ reason: "adds directories the file-writing tools may modify outside the workspace root, which a headless run would otherwise refuse",
42833
+ widens: isNonEmptyList
42834
+ },
42835
+ {
42836
+ path: ["workspace_root"],
42837
+ 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",
42838
+ widens: escapesTheProject
42839
+ }
42840
+ ];
42841
+ /**
42842
+ * Whether a `workspace_root` points somewhere other than inside the project the
42843
+ * generate runs in. The key moves the write confinement rather than adding to
42844
+ * it, so the ordinary value — the project directory, spelled relatively — would
42845
+ * otherwise be announced on every generate; anything else is the case worth
42846
+ * naming, since it is how a fetched permissions file would put `~/.ssh` or
42847
+ * `C:\\Users\\<user>` inside the jail: an absolute path in either flavour, one
42848
+ * carrying a drive letter, a home-relative one, a shell or environment
42849
+ * expansion, and any path holding a `..` segment — even one that would land back
42850
+ * inside, since resolving it here would only be a guess at what Reasonix does.
42851
+ * Both path flavours are asked because the file is authored on one machine and
42852
+ * generated on another, so a Windows-shaped root reaching a POSIX check must not
42853
+ * read as relative. Anything that is not a string is reported, per the fail-safe
42854
+ * rule the predicates in `sandbox-trust.ts` follow.
42855
+ */
42856
+ function escapesTheProject(value) {
42857
+ if (typeof value !== "string") return true;
42858
+ const trimmed = value.trim();
42859
+ if (trimmed === "") return false;
42860
+ if (trimmed.startsWith("~")) return true;
42861
+ if (posix.isAbsolute(trimmed) || win32.isAbsolute(trimmed)) return true;
42862
+ if (/^[A-Za-z]:/.test(trimmed)) return true;
42863
+ if (trimmed.includes("$") || /%[^%]+%/.test(trimmed)) return true;
42864
+ return trimmed.split(/[\\/]/).includes("..");
42865
+ }
42866
+ /**
42867
+ * The `[sandbox]` key that restricts, and so loosens by losing entries rather
42868
+ * than by holding a value. The override is shallow-merged over the existing
42869
+ * `[sandbox]` at its top level, so an authored `forbid_read` replaces the list
42870
+ * the file had whole — emptying it, or dropping the `${HOME}/.ssh` entry
42871
+ * Reasonix's own example recommends, opens exactly what the list kept closed.
42872
+ *
42873
+ * @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
42874
+ */
42875
+ const REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS = [{
42876
+ path: ["forbid_read"],
42877
+ reason: "drops paths the list already in the file kept out of read, list and search",
42878
+ loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
42879
+ }];
42880
+ /**
42881
+ * Everything worth naming about the `[sandbox]` table this generate is about to
42882
+ * write: the authored values that loosen enforcement, the restrictions the
42883
+ * shallow merge would drop, and a `[sandbox]` the file holds in some shape other
42884
+ * than a table, which the write replaces wholesale. The widening check reads the
42885
+ * authored block alone — a loosening value the file already held is the user's
42886
+ * own, and re-announcing it on every generate would bury the values rulesync
42887
+ * actually wrote — while a loss can only be seen from both sides.
42888
+ */
42889
+ function collectSandboxOverlayEntries({ existing, authored, merged }) {
42890
+ return [
42891
+ ...existing !== void 0 && !isRecord$1(existing) ? [{
42892
+ label: "sandbox",
42893
+ reason: replacedUnreadableReason({
42894
+ shape: "object",
42895
+ toolLabel: REASONIX_TOOL_LABEL
42896
+ })
42897
+ }] : [],
42898
+ ...collectTrustAffectingSandboxPaths({
42899
+ sandbox: asReasonixRecord(authored),
42900
+ paths: REASONIX_TRUST_AFFECTING_SANDBOX_PATHS
42901
+ }),
42902
+ ...collectRestrictionLosingSandboxEntries({
42903
+ existing: asReasonixRecord(existing),
42904
+ merged,
42905
+ paths: REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS,
42906
+ toolLabel: REASONIX_TOOL_LABEL
42907
+ })
42908
+ ];
42909
+ }
42910
+ /**
42911
+ * Writes the override's `allow_dynamic_bash` into `[permissions]`, where it sits
42912
+ * beside allow/ask/deny rather than in a table of its own, and reports it when
42913
+ * it is being turned on: it widens what a shareable permissions file lets run
42914
+ * with no human in the loop. Only an authored value is written — leaving the key
42915
+ * out of the override keeps whatever the file already had — and turning it off
42916
+ * narrows, so that stays quiet. Only a literal `false` is quiet, not everything
42917
+ * falsy: `getJson()` casts rather than parses, so a `--no-validate` run can put
42918
+ * a value the schema forbids here, and a value Reasonix might still coerce is
42919
+ * not something to write in silence. The entries are returned rather than logged so
42920
+ * one generate still produces one warning naming everything it wrote.
42921
+ */
42922
+ function applyAllowDynamicBash({ permissions, authored }) {
42923
+ if (authored === void 0) return [];
42924
+ permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY] = authored;
42925
+ if (!isNotFalse(authored)) return [];
42926
+ return [{
42927
+ label: `permissions.${REASONIX_ALLOW_DYNAMIC_BASH_KEY}`,
42928
+ 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"
42929
+ }];
42930
+ }
42451
42931
  function parseReasonixConfig(fileContent) {
42452
42932
  const parsed = smolToml.parse(fileContent || smolToml.stringify({}));
42453
42933
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -42504,6 +42984,7 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42504
42984
  static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
42505
42985
  const paths = this.getSettablePaths({ global });
42506
42986
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
42987
+ const relativeFilePathForLog = toPosixPath(join(paths.relativeDirPath, paths.relativeFilePath));
42507
42988
  const existingContent = await readFileContentOrNull(filePath) ?? "";
42508
42989
  const parsed = parseReasonixConfig(existingContent);
42509
42990
  const config = rulesyncPermissions.getJson();
@@ -42548,11 +43029,23 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42548
43029
  ...deny,
42549
43030
  ...rawDeny
42550
43031
  ]);
43032
+ const trustAffecting = applyAllowDynamicBash({
43033
+ permissions: mergedPermissions,
43034
+ authored: override?.allowDynamicBash
43035
+ });
42551
43036
  const patch = { permissions: mergedPermissions };
42552
- if (override?.sandbox !== void 0) patch.sandbox = {
42553
- ...asReasonixRecord(parsed.sandbox),
42554
- ...asReasonixRecord(override.sandbox)
42555
- };
43037
+ if (override?.sandbox !== void 0) {
43038
+ const mergedSandbox = {
43039
+ ...asReasonixRecord(parsed.sandbox),
43040
+ ...asReasonixRecord(override.sandbox)
43041
+ };
43042
+ patch.sandbox = mergedSandbox;
43043
+ trustAffecting.push(...collectSandboxOverlayEntries({
43044
+ existing: parsed.sandbox,
43045
+ authored: override.sandbox,
43046
+ merged: mergedSandbox
43047
+ }));
43048
+ }
42556
43049
  if (override?.agent !== void 0) {
42557
43050
  const mergedAgent = {
42558
43051
  ...asReasonixRecord(parsed.agent),
@@ -42560,9 +43053,16 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42560
43053
  };
42561
43054
  const retired = REASONIX_RETIRED_AGENT_KEYS.filter((key) => mergedAgent[key] !== void 0);
42562
43055
  for (const key of retired) delete mergedAgent[key];
42563
- 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.`);
43056
+ 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.`);
42564
43057
  patch.agent = mergedAgent;
42565
43058
  }
43059
+ warnOnTrustAffectingEntries({
43060
+ toolLabel: REASONIX_TOOL_LABEL,
43061
+ noun: "change",
43062
+ entries: trustAffecting,
43063
+ relativeFilePath: relativeFilePathForLog,
43064
+ logger
43065
+ });
42566
43066
  return new ReasonixPermissions({
42567
43067
  outputRoot,
42568
43068
  relativeDirPath: paths.relativeDirPath,
@@ -42597,6 +43097,8 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
42597
43097
  const sandbox = asReasonixRecord(this.toml.sandbox);
42598
43098
  const agentPlanMode = pickReasonixKeys(this.toml.agent, [...REASONIX_OVERRIDE_AGENT_KEYS, ...REASONIX_RETIRED_AGENT_KEYS]);
42599
43099
  const reasonixOverride = {};
43100
+ const allowDynamicBash = permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY];
43101
+ if (typeof allowDynamicBash === "boolean") reasonixOverride.allowDynamicBash = allowDynamicBash;
42600
43102
  if (Object.keys(sandbox).length > 0) reasonixOverride.sandbox = sandbox;
42601
43103
  if (Object.keys(agentPlanMode).length > 0) reasonixOverride.agent = agentPlanMode;
42602
43104
  if (allowSplit.exact.length > 0) reasonixOverride.rawAllow = allowSplit.exact;
@@ -68635,4 +69137,4 @@ async function importChecksCore(params) {
68635
69137
  //#endregion
68636
69138
  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 };
68637
69139
 
68638
- //# sourceMappingURL=import-BKqbq4Ut.js.map
69140
+ //# sourceMappingURL=import-DIDEUv63.js.map