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