rulesync 16.22.1 → 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.
- 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-D420jbVB.cjs} +523 -90
- package/dist/{import-DUE1P1zV.js → import-DIDEUv63.js} +524 -91
- package/dist/{import-DUE1P1zV.js.map → import-DIDEUv63.js.map} +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -6699,6 +6699,8 @@ const QwencodePermissionsOverrideSchema = zod_mini.z.looseObject({
|
|
|
6699
6699
|
*
|
|
6700
6700
|
* @example
|
|
6701
6701
|
* { "sandbox": { "bash": "enforce", "network": false }, "agent": { "plan_mode_read_only_commands": ["gh pr diff"] } }
|
|
6702
|
+
* @example
|
|
6703
|
+
* { "allowDynamicBash": true, "rawAllow": ["Bash=pnpm test"] }
|
|
6702
6704
|
*/
|
|
6703
6705
|
const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
|
|
6704
6706
|
permission: zod_mini.z.optional(ToolScopedPermissionSchema),
|
|
@@ -6706,7 +6708,8 @@ const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
|
|
|
6706
6708
|
agent: zod_mini.z.optional(zod_mini.z.looseObject({})),
|
|
6707
6709
|
rawAllow: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
|
|
6708
6710
|
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()))
|
|
6711
|
+
rawDeny: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
|
|
6712
|
+
allowDynamicBash: zod_mini.z.optional(zod_mini.z.boolean())
|
|
6710
6713
|
});
|
|
6711
6714
|
/**
|
|
6712
6715
|
* Tool-scoped override block for Factory Droid. Factory Droid's `settings.json`
|
|
@@ -7350,6 +7353,32 @@ const ZedPermissionsOverrideSchema = zod_mini.z.looseObject({
|
|
|
7350
7353
|
})))
|
|
7351
7354
|
});
|
|
7352
7355
|
/**
|
|
7356
|
+
* Tool-scoped override block for Devin Local. `sandbox` is the sibling
|
|
7357
|
+
* top-level `config.json` block that governs the sandbox Devin runs commands
|
|
7358
|
+
* in: `allowed_domains` / `denied_domains` (proxy domain patterns, deny beating
|
|
7359
|
+
* allow), `network_mode` (`full`, the upstream default, allows every HTTP
|
|
7360
|
+
* method; `limited` only GET/HEAD/OPTIONS) and `excluded` (`allow` / `ask` /
|
|
7361
|
+
* `deny` lists of `Exec(...)` matchers deciding which commands run *outside* the
|
|
7362
|
+
* sandbox — `deny` pins them inside it). It constrains how
|
|
7363
|
+
* a permitted command runs rather than which commands are permitted, so it has
|
|
7364
|
+
* no canonical category and is authored here.
|
|
7365
|
+
*
|
|
7366
|
+
* Upstream lists `sandbox` as a **User Config Only** key, so it is emitted at
|
|
7367
|
+
* global scope only; at project scope it is dropped with a warning rather than
|
|
7368
|
+
* written into a file Devin would ignore.
|
|
7369
|
+
*
|
|
7370
|
+
* @example
|
|
7371
|
+
* { "sandbox": { "allowed_domains": ["github.com"], "network_mode": "limited" } }
|
|
7372
|
+
* @example
|
|
7373
|
+
* { "sandbox": { "excluded": { "allow": ["Exec(git status *)"], "deny": ["Exec(git tag *)"] } } }
|
|
7374
|
+
* @see https://docs.devin.ai/cli/sandbox
|
|
7375
|
+
* @see https://docs.devin.ai/cli/reference/configuration/config-file
|
|
7376
|
+
*/
|
|
7377
|
+
const DevinPermissionsOverrideSchema = zod_mini.z.looseObject({
|
|
7378
|
+
permission: zod_mini.z.optional(ToolScopedPermissionSchema),
|
|
7379
|
+
sandbox: zod_mini.z.optional(zod_mini.z.looseObject({}))
|
|
7380
|
+
});
|
|
7381
|
+
/**
|
|
7353
7382
|
* Permissions configuration.
|
|
7354
7383
|
* Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
|
|
7355
7384
|
* Values are pattern-to-action mappings for that tool category.
|
|
@@ -7399,10 +7428,10 @@ const PermissionsConfigSchema = zod_mini.z.looseObject({
|
|
|
7399
7428
|
kiro: zod_mini.z.optional(KiroPermissionsOverrideSchema),
|
|
7400
7429
|
codexcli: zod_mini.z.optional(CodexcliPermissionsOverrideSchema),
|
|
7401
7430
|
zed: zod_mini.z.optional(ZedPermissionsOverrideSchema),
|
|
7431
|
+
devin: zod_mini.z.optional(DevinPermissionsOverrideSchema),
|
|
7402
7432
|
"antigravity-ide": zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
|
|
7403
7433
|
copilot: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
|
|
7404
7434
|
copilotcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
|
|
7405
|
-
devin: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
|
|
7406
7435
|
goose: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
|
|
7407
7436
|
grokcli: zod_mini.z.optional(CanonicalPermissionsOverrideSchema),
|
|
7408
7437
|
"kimi-code": zod_mini.z.optional(KimiCodePermissionsOverrideSchema),
|
|
@@ -12628,7 +12657,7 @@ const SHARED_CONFIG_OWNERSHIP = {
|
|
|
12628
12657
|
},
|
|
12629
12658
|
permissions: {
|
|
12630
12659
|
kind: "replace-owned-keys",
|
|
12631
|
-
ownedKeys: ["permissions"]
|
|
12660
|
+
ownedKeys: ["permissions", "sandbox"]
|
|
12632
12661
|
}
|
|
12633
12662
|
}
|
|
12634
12663
|
},
|
|
@@ -36349,6 +36378,180 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
|
|
|
36349
36378
|
return { permission };
|
|
36350
36379
|
}
|
|
36351
36380
|
//#endregion
|
|
36381
|
+
//#region src/features/permissions/sandbox-trust.ts
|
|
36382
|
+
/** A key whose quiet value is an explicit `false`. */
|
|
36383
|
+
const isNotFalse = (value) => value !== false;
|
|
36384
|
+
/** A key whose quiet value is an explicit `true`. */
|
|
36385
|
+
const isNotTrue = (value) => value !== true;
|
|
36386
|
+
/** A list-valued key whose quiet value is the empty list. */
|
|
36387
|
+
const isNonEmptyList = (value) => !Array.isArray(value) || value.length > 0;
|
|
36388
|
+
/** The map-valued counterpart of {@link isNonEmptyList}. */
|
|
36389
|
+
const isNonEmptyMap = (value) => !isRecord$1(value) || Object.keys(value).length > 0;
|
|
36390
|
+
/**
|
|
36391
|
+
* What {@link readSandboxPath} returns when a container on the way to the leaf
|
|
36392
|
+
* is present but is not an object, so the leaf cannot be read at all. It is not
|
|
36393
|
+
* `undefined`, because the two mean opposite things to a caller: `undefined` is
|
|
36394
|
+
* "this path is not being written", while this is "something is being written
|
|
36395
|
+
* here and its shape hides what". The same fail-safe rule the predicates follow
|
|
36396
|
+
* applies to the walk — silence must mean "this cannot loosen anything", not
|
|
36397
|
+
* "this is not the shape the table expected".
|
|
36398
|
+
*/
|
|
36399
|
+
const UNREADABLE_SANDBOX_PATH = Symbol("unreadable-sandbox-path");
|
|
36400
|
+
/**
|
|
36401
|
+
* Reads `sandbox` at `path`. Returns `undefined` when a segment is absent, and
|
|
36402
|
+
* {@link UNREADABLE_SANDBOX_PATH} when one is present but is not an object.
|
|
36403
|
+
* Shared by everything that addresses a `sandbox` path so a nested path added to
|
|
36404
|
+
* one of the tables is actually traversed rather than silently skipped, and so a
|
|
36405
|
+
* hostile shape (an array, a string, `null`) is reported rather than throwing.
|
|
36406
|
+
*/
|
|
36407
|
+
function readSandboxPath({ sandbox, path }) {
|
|
36408
|
+
let cursor = sandbox;
|
|
36409
|
+
for (const segment of path) {
|
|
36410
|
+
if (cursor === void 0) return void 0;
|
|
36411
|
+
if (!isRecord$1(cursor)) return UNREADABLE_SANDBOX_PATH;
|
|
36412
|
+
cursor = cursor[segment];
|
|
36413
|
+
}
|
|
36414
|
+
return cursor;
|
|
36415
|
+
}
|
|
36416
|
+
/**
|
|
36417
|
+
* Every path in `paths` whose value in `sandbox` loosens the policy. Nothing is
|
|
36418
|
+
* removed — the values are written, just not silently. Call it on the block this
|
|
36419
|
+
* generate authored, after any scope filter has run: a value the file already
|
|
36420
|
+
* held is the user's own, not something rulesync opened, and a path a filter
|
|
36421
|
+
* dropped is not being written at all.
|
|
36422
|
+
*/
|
|
36423
|
+
function collectTrustAffectingSandboxPaths({ sandbox, paths }) {
|
|
36424
|
+
const entries = [];
|
|
36425
|
+
const reportedContainers = /* @__PURE__ */ new Set();
|
|
36426
|
+
for (const { path, reason, widens } of paths) {
|
|
36427
|
+
const value = readSandboxPath({
|
|
36428
|
+
sandbox,
|
|
36429
|
+
path
|
|
36430
|
+
});
|
|
36431
|
+
if (value === void 0) continue;
|
|
36432
|
+
if (value === UNREADABLE_SANDBOX_PATH) {
|
|
36433
|
+
const label = findUnreadableContainer({
|
|
36434
|
+
sandbox,
|
|
36435
|
+
path
|
|
36436
|
+
});
|
|
36437
|
+
if (label === void 0 || reportedContainers.has(label)) continue;
|
|
36438
|
+
reportedContainers.add(label);
|
|
36439
|
+
entries.push({
|
|
36440
|
+
label,
|
|
36441
|
+
reason: UNREADABLE_CONTAINER_REASON
|
|
36442
|
+
});
|
|
36443
|
+
continue;
|
|
36444
|
+
}
|
|
36445
|
+
if (!widens(value)) continue;
|
|
36446
|
+
entries.push({
|
|
36447
|
+
label: `sandbox.${path.join(".")}`,
|
|
36448
|
+
reason
|
|
36449
|
+
});
|
|
36450
|
+
}
|
|
36451
|
+
return entries;
|
|
36452
|
+
}
|
|
36453
|
+
/** The reason printed for a container that hides the settings underneath it. */
|
|
36454
|
+
const UNREADABLE_CONTAINER_REASON = "is not the object it has to be, so nothing under it can be checked for what it opens";
|
|
36455
|
+
/**
|
|
36456
|
+
* The prefix of `path` that {@link readSandboxPath} could not walk past, as a
|
|
36457
|
+
* label. `undefined` when the walk was not blocked at all. Callers that report
|
|
36458
|
+
* an unreadable path name the container rather than the leaf, because the leaf
|
|
36459
|
+
* is not what the file actually holds.
|
|
36460
|
+
*/
|
|
36461
|
+
function findUnreadableContainer({ sandbox, path }) {
|
|
36462
|
+
let cursor = sandbox;
|
|
36463
|
+
const walked = [];
|
|
36464
|
+
for (const segment of path) {
|
|
36465
|
+
if (cursor === void 0) return void 0;
|
|
36466
|
+
if (!isRecord$1(cursor)) return walked.length === 0 ? "sandbox" : `sandbox.${walked.join(".")}`;
|
|
36467
|
+
walked.push(segment);
|
|
36468
|
+
cursor = cursor[segment];
|
|
36469
|
+
}
|
|
36470
|
+
}
|
|
36471
|
+
/**
|
|
36472
|
+
* The reason printed for a value the file held in a shape that cannot be read,
|
|
36473
|
+
* which this generate is about to replace. `shape` names what the tool documents
|
|
36474
|
+
* there, so the message says which expectation the file's value missed.
|
|
36475
|
+
*/
|
|
36476
|
+
const replacedUnreadableReason = ({ shape, toolLabel }) => `replaces a value already in the file that is not the ${shape} ${toolLabel} documents, so what it restricted cannot be read`;
|
|
36477
|
+
/**
|
|
36478
|
+
* The restrictions this generate would weaken, compared between the `sandbox`
|
|
36479
|
+
* already in the file and the one about to replace it. A `before` that is
|
|
36480
|
+
* present but not a list is reported outright: a shape the tool may still honor
|
|
36481
|
+
* is not something to go quiet about just because it cannot be diffed. Shared by
|
|
36482
|
+
* every tool whose override replaces a restricting list whole rather than
|
|
36483
|
+
* merging into it — Claude Code needs no equivalent, because it merges its lists
|
|
36484
|
+
* across settings scopes, so a file can only ever add to them.
|
|
36485
|
+
*/
|
|
36486
|
+
function collectRestrictionLosingSandboxEntries({ existing, merged, paths, toolLabel }) {
|
|
36487
|
+
const entries = [];
|
|
36488
|
+
const reportedContainers = /* @__PURE__ */ new Set();
|
|
36489
|
+
for (const { path, reason, loosens } of paths) {
|
|
36490
|
+
const before = readSandboxPath({
|
|
36491
|
+
sandbox: existing,
|
|
36492
|
+
path
|
|
36493
|
+
});
|
|
36494
|
+
if (before === void 0) continue;
|
|
36495
|
+
const [rootKey] = path;
|
|
36496
|
+
if (rootKey !== void 0 && existing[rootKey] === merged[rootKey]) continue;
|
|
36497
|
+
const after = readSandboxPath({
|
|
36498
|
+
sandbox: merged,
|
|
36499
|
+
path
|
|
36500
|
+
});
|
|
36501
|
+
const label = `sandbox.${path.join(".")}`;
|
|
36502
|
+
if (before === UNREADABLE_SANDBOX_PATH) {
|
|
36503
|
+
const container = findUnreadableContainer({
|
|
36504
|
+
sandbox: existing,
|
|
36505
|
+
path
|
|
36506
|
+
});
|
|
36507
|
+
if (container === void 0 || reportedContainers.has(container)) continue;
|
|
36508
|
+
reportedContainers.add(container);
|
|
36509
|
+
entries.push({
|
|
36510
|
+
label: container,
|
|
36511
|
+
reason: replacedUnreadableReason({
|
|
36512
|
+
shape: "object",
|
|
36513
|
+
toolLabel
|
|
36514
|
+
})
|
|
36515
|
+
});
|
|
36516
|
+
continue;
|
|
36517
|
+
}
|
|
36518
|
+
if (!Array.isArray(before)) {
|
|
36519
|
+
entries.push({
|
|
36520
|
+
label,
|
|
36521
|
+
reason: replacedUnreadableReason({
|
|
36522
|
+
shape: "list",
|
|
36523
|
+
toolLabel
|
|
36524
|
+
})
|
|
36525
|
+
});
|
|
36526
|
+
continue;
|
|
36527
|
+
}
|
|
36528
|
+
if (before.length === 0) continue;
|
|
36529
|
+
if (!loosens({
|
|
36530
|
+
before,
|
|
36531
|
+
after: Array.isArray(after) ? after : []
|
|
36532
|
+
})) continue;
|
|
36533
|
+
entries.push({
|
|
36534
|
+
label,
|
|
36535
|
+
reason
|
|
36536
|
+
});
|
|
36537
|
+
}
|
|
36538
|
+
return entries;
|
|
36539
|
+
}
|
|
36540
|
+
/**
|
|
36541
|
+
* The one warning that names every trust-affecting setting this generate wrote
|
|
36542
|
+
* to `relativeFilePath`. Emitted once per file: the individual reasons are what
|
|
36543
|
+
* matter, but the "review this as you would a hook" framing only needs saying
|
|
36544
|
+
* once, and repeating it per key buries the reasons in boilerplate. `noun` lets
|
|
36545
|
+
* a tool whose entries are not all additions call them something more accurate
|
|
36546
|
+
* than "setting".
|
|
36547
|
+
*/
|
|
36548
|
+
function warnOnTrustAffectingEntries({ toolLabel, noun = "setting", entries, relativeFilePath, logger }) {
|
|
36549
|
+
if (entries.length === 0) return;
|
|
36550
|
+
const one = entries.length === 1;
|
|
36551
|
+
const details = entries.map(({ label, reason }) => `'${label}' — ${reason}`).join("; ");
|
|
36552
|
+
logger?.warn(`${toolLabel} permissions: writing ${entries.length} trust-affecting ${noun}${one ? "" : "s"} to ${relativeFilePath}; review ${one ? "it" : "them"} as you would a hook, especially if this permissions file came from 'rulesync fetch'. ${details}.`);
|
|
36553
|
+
}
|
|
36554
|
+
//#endregion
|
|
36352
36555
|
//#region src/features/permissions/claudecode-permissions.ts
|
|
36353
36556
|
/**
|
|
36354
36557
|
* Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
|
|
@@ -36397,19 +36600,6 @@ function parseClaudePermissionEntry(entry) {
|
|
|
36397
36600
|
};
|
|
36398
36601
|
}
|
|
36399
36602
|
/**
|
|
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
36603
|
* Merge `patch` into `base`, recursing into plain objects so a sibling key at
|
|
36414
36604
|
* any depth survives. Arrays and scalars are replaced, since a list the author
|
|
36415
36605
|
* states is the list they mean.
|
|
@@ -36419,7 +36609,7 @@ function deepMergeRecords(base, patch) {
|
|
|
36419
36609
|
for (const [key, value] of Object.entries(patch)) {
|
|
36420
36610
|
if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
|
|
36421
36611
|
const existing = merged[key];
|
|
36422
|
-
merged[key] =
|
|
36612
|
+
merged[key] = isRecord$1(existing) && isRecord$1(value) ? deepMergeRecords(existing, value) : value;
|
|
36423
36613
|
}
|
|
36424
36614
|
return merged;
|
|
36425
36615
|
}
|
|
@@ -36484,13 +36674,11 @@ const CLAUDECODE_MANAGED_ONLY_SANDBOX_PATHS = [["filesystem", "allowManagedReadP
|
|
|
36484
36674
|
* traversed rather than silently skipped.
|
|
36485
36675
|
*/
|
|
36486
36676
|
function resolveSandboxParent({ root, segments }) {
|
|
36487
|
-
|
|
36488
|
-
|
|
36489
|
-
|
|
36490
|
-
|
|
36491
|
-
|
|
36492
|
-
}
|
|
36493
|
-
return parent;
|
|
36677
|
+
const resolved = readSandboxPath({
|
|
36678
|
+
sandbox: root,
|
|
36679
|
+
path: segments
|
|
36680
|
+
});
|
|
36681
|
+
return isRecord$1(resolved) ? resolved : void 0;
|
|
36494
36682
|
}
|
|
36495
36683
|
/**
|
|
36496
36684
|
* Deletes `path` from `target` in place and reports whether anything was there,
|
|
@@ -36524,18 +36712,6 @@ function deleteSandboxPath({ target, path }) {
|
|
|
36524
36712
|
return true;
|
|
36525
36713
|
}
|
|
36526
36714
|
/**
|
|
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
36715
|
* The `permissions.defaultMode` values that start a session with fewer prompts
|
|
36540
36716
|
* than the default. `plan` and `default` are absent because they do not widen
|
|
36541
36717
|
* anything.
|
|
@@ -36580,18 +36756,6 @@ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
|
|
|
36580
36756
|
["socatPath"]
|
|
36581
36757
|
];
|
|
36582
36758
|
/**
|
|
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
36759
|
* `sandbox` paths that loosen the sandbox rather than naming something to run:
|
|
36596
36760
|
* they let commands out of it, weaken the isolation it provides, or redirect
|
|
36597
36761
|
* where its traffic goes. They are written like `env` is — the ordinary uses are
|
|
@@ -36694,30 +36858,6 @@ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
|
|
|
36694
36858
|
widens: () => true
|
|
36695
36859
|
}
|
|
36696
36860
|
];
|
|
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
36861
|
/** Paths that name an executable Claude Code runs. Refused in both scopes. */
|
|
36722
36862
|
const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_REFUSAL = {
|
|
36723
36863
|
paths: CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS,
|
|
@@ -36788,13 +36928,13 @@ const CLAUDECODE_MASKABLE_CREDENTIAL_LISTS = ["envVars", "files"];
|
|
|
36788
36928
|
*/
|
|
36789
36929
|
function stripProjectIgnoredMaskEntries({ sandbox, relativeFilePath, logger }) {
|
|
36790
36930
|
const credentials = sandbox.credentials;
|
|
36791
|
-
if (!
|
|
36931
|
+
if (!isRecord$1(credentials)) return sandbox;
|
|
36792
36932
|
const filteredCredentials = { ...credentials };
|
|
36793
36933
|
let changed = false;
|
|
36794
36934
|
for (const listKey of CLAUDECODE_MASKABLE_CREDENTIAL_LISTS) {
|
|
36795
36935
|
const list = filteredCredentials[listKey];
|
|
36796
36936
|
if (!Array.isArray(list)) continue;
|
|
36797
|
-
const kept = list.filter((entry) => !(
|
|
36937
|
+
const kept = list.filter((entry) => !(isRecord$1(entry) && entry.mode === "mask"));
|
|
36798
36938
|
if (kept.length === list.length) continue;
|
|
36799
36939
|
changed = true;
|
|
36800
36940
|
const dropped = list.length - kept.length;
|
|
@@ -37073,6 +37213,16 @@ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logge
|
|
|
37073
37213
|
trustAffecting
|
|
37074
37214
|
};
|
|
37075
37215
|
}
|
|
37216
|
+
/**
|
|
37217
|
+
* Claude Code's file permission checks match only `Edit(path)` and `Read(path)`
|
|
37218
|
+
* rules. A `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted
|
|
37219
|
+
* but never matched by those checks, so Claude Code warns at startup for each
|
|
37220
|
+
* allow, deny, or ask rule in one of these unmatched forms" — so a canonical
|
|
37221
|
+
* `write`/`notebookedit`/`glob` rule with a pattern is emitted in the form the
|
|
37222
|
+
* docs prescribe instead. A tool-name rule with no path is unaffected: it
|
|
37223
|
+
* matches the tool everywhere and produces no warning.
|
|
37224
|
+
* @see https://code.claude.com/docs/en/permissions
|
|
37225
|
+
*/
|
|
37076
37226
|
const CLAUDE_PATH_RULE_ALIASES = {
|
|
37077
37227
|
Write: "Edit",
|
|
37078
37228
|
NotebookEdit: "Edit",
|
|
@@ -37153,7 +37303,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
37153
37303
|
};
|
|
37154
37304
|
}
|
|
37155
37305
|
const overrideSandbox = config.claudecode?.sandbox;
|
|
37156
|
-
if (
|
|
37306
|
+
if (isRecord$1(overrideSandbox)) {
|
|
37157
37307
|
const honorableSandbox = stripSandboxPaths({
|
|
37158
37308
|
sandbox: overrideSandbox,
|
|
37159
37309
|
refusals: [
|
|
@@ -37169,8 +37319,11 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
37169
37319
|
relativeFilePath: paths.relativeFilePath,
|
|
37170
37320
|
logger
|
|
37171
37321
|
});
|
|
37172
|
-
trustAffecting.push(...collectTrustAffectingSandboxPaths({
|
|
37173
|
-
|
|
37322
|
+
trustAffecting.push(...collectTrustAffectingSandboxPaths({
|
|
37323
|
+
sandbox: scopedSandbox,
|
|
37324
|
+
paths: CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS
|
|
37325
|
+
}));
|
|
37326
|
+
if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isRecord$1(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
|
|
37174
37327
|
}
|
|
37175
37328
|
const overrideTopLevel = {};
|
|
37176
37329
|
for (const [key, value] of Object.entries(config.claudecode ?? {})) {
|
|
@@ -37188,8 +37341,9 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
37188
37341
|
trustAffecting.push(...trustAffectingTopLevel);
|
|
37189
37342
|
if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
|
|
37190
37343
|
warnOnTrustAffectingEntries({
|
|
37344
|
+
toolLabel: "Claude Code",
|
|
37191
37345
|
entries: trustAffecting,
|
|
37192
|
-
relativeFilePath: paths.relativeFilePath,
|
|
37346
|
+
relativeFilePath: toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)),
|
|
37193
37347
|
logger
|
|
37194
37348
|
});
|
|
37195
37349
|
const managedToolNames = managedClaudeToolNames(config);
|
|
@@ -37232,7 +37386,7 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
|
|
|
37232
37386
|
const nonListFields = Object.fromEntries(Object.entries(permissionsRest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
|
|
37233
37387
|
if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
|
|
37234
37388
|
const { sandbox } = settings;
|
|
37235
|
-
if (
|
|
37389
|
+
if (isRecord$1(sandbox)) {
|
|
37236
37390
|
const importedSandbox = structuredClone(sandbox);
|
|
37237
37391
|
for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) deleteSandboxPath({
|
|
37238
37392
|
target: importedSandbox,
|
|
@@ -39485,6 +39639,80 @@ function buildDevinPermissionEntry(scope, pattern) {
|
|
|
39485
39639
|
if (pattern === "*") return scope;
|
|
39486
39640
|
return `${scope}(${pattern})`;
|
|
39487
39641
|
}
|
|
39642
|
+
function asDevinRecord(value) {
|
|
39643
|
+
return isRecord$1(value) ? { ...value } : {};
|
|
39644
|
+
}
|
|
39645
|
+
/**
|
|
39646
|
+
* `sandbox` paths whose authored value loosens the sandbox on its own: they let
|
|
39647
|
+
* a command out of it, or widen what a command left inside it may reach. They
|
|
39648
|
+
* are written — the ordinary uses are far too common to refuse — but never
|
|
39649
|
+
* silently, because a permissions file is shareable (`rulesync fetch` copies one
|
|
39650
|
+
* into a project) and should not be able to open the sandbox without saying so.
|
|
39651
|
+
* This is the same stance `CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS` takes for
|
|
39652
|
+
* the equivalent Claude Code keys, and `widens` follows the same convention of
|
|
39653
|
+
* naming the restrictive value rather than the permissive ones, so a spelling
|
|
39654
|
+
* Devin does not recognize is reported rather than waved through.
|
|
39655
|
+
*
|
|
39656
|
+
* The three keys that restrict — `allowed_domains` (an allowlist only while it
|
|
39657
|
+
* has entries), `denied_domains` and `excluded.deny` — are not here: they loosen
|
|
39658
|
+
* by losing entries, which `DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS` covers.
|
|
39659
|
+
*
|
|
39660
|
+
* @see https://docs.devin.ai/cli/sandbox
|
|
39661
|
+
*/
|
|
39662
|
+
const DEVIN_TRUST_AFFECTING_SANDBOX_PATHS = [
|
|
39663
|
+
{
|
|
39664
|
+
path: ["network_mode"],
|
|
39665
|
+
reason: "anything but 'limited' lets sandboxed requests use every HTTP method, not just GET/HEAD/OPTIONS",
|
|
39666
|
+
widens: (value) => value !== "limited"
|
|
39667
|
+
},
|
|
39668
|
+
{
|
|
39669
|
+
path: ["excluded", "allow"],
|
|
39670
|
+
reason: "names commands that run outside the sandbox with no prompt and no sandbox policy",
|
|
39671
|
+
widens: isNonEmptyList
|
|
39672
|
+
},
|
|
39673
|
+
{
|
|
39674
|
+
path: ["excluded", "ask"],
|
|
39675
|
+
reason: "names commands that run outside the sandbox once confirmed, with no sandbox policy",
|
|
39676
|
+
widens: isNonEmptyList
|
|
39677
|
+
}
|
|
39678
|
+
];
|
|
39679
|
+
/** How Devin is named in the warnings this file emits. */
|
|
39680
|
+
const DEVIN_TOOL_LABEL = "Devin";
|
|
39681
|
+
/**
|
|
39682
|
+
* `sandbox` paths that restrict, and that therefore loosen the policy by losing
|
|
39683
|
+
* entries rather than by holding a value. Devin's config is one file rather than
|
|
39684
|
+
* a stack of settings scopes, and the override is shallow-merged over the
|
|
39685
|
+
* existing `sandbox` at its top level: each of these lists is replaced whole,
|
|
39686
|
+
* and `excluded.deny` vanishes as soon as the override states any other
|
|
39687
|
+
* `excluded` key. Losing an entry has the same effect as adding one to the
|
|
39688
|
+
* permissive keys above, so it is announced the same way. Claude Code needs no
|
|
39689
|
+
* equivalent — it merges its lists across settings scopes, so a file can only
|
|
39690
|
+
* ever add to them.
|
|
39691
|
+
*
|
|
39692
|
+
* `loosens` is asked only about a `before` that actually restricted something,
|
|
39693
|
+
* and the two directions are not symmetric: `allowed_domains` restricts by
|
|
39694
|
+
* listing what is reachable, so it loosens by gaining entries or by emptying
|
|
39695
|
+
* out altogether, while the deny lists loosen by losing entries.
|
|
39696
|
+
*
|
|
39697
|
+
* @see https://docs.devin.ai/cli/sandbox
|
|
39698
|
+
*/
|
|
39699
|
+
const DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS = [
|
|
39700
|
+
{
|
|
39701
|
+
path: ["allowed_domains"],
|
|
39702
|
+
reason: "adds to the proxy allowlist already in the file, or empties it so every domain becomes reachable again",
|
|
39703
|
+
loosens: ({ before, after }) => after.length === 0 || after.some((entry) => !before.includes(entry))
|
|
39704
|
+
},
|
|
39705
|
+
{
|
|
39706
|
+
path: ["denied_domains"],
|
|
39707
|
+
reason: "drops domains the deny list already in the file kept out of reach",
|
|
39708
|
+
loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
|
|
39709
|
+
},
|
|
39710
|
+
{
|
|
39711
|
+
path: ["excluded", "deny"],
|
|
39712
|
+
reason: "drops commands the list already in the file pinned inside the sandbox",
|
|
39713
|
+
loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
|
|
39714
|
+
}
|
|
39715
|
+
];
|
|
39488
39716
|
/**
|
|
39489
39717
|
* Permissions generator for Devin Local (native `.devin/` configuration).
|
|
39490
39718
|
*
|
|
@@ -39500,10 +39728,18 @@ function buildDevinPermissionEntry(scope, pattern) {
|
|
|
39500
39728
|
*
|
|
39501
39729
|
* In global mode the config file is shared with the hooks (`hooks`) feature
|
|
39502
39730
|
* (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`
|
|
39731
|
+
* merge into the existing JSON and the file is never deleted; only the keys
|
|
39732
|
+
* this feature manages are rewritten — `permissions`, plus `sandbox` in global
|
|
39733
|
+
* mode when the `devin` override authors it.
|
|
39734
|
+
*
|
|
39735
|
+
* The sibling `sandbox` block — which decides what a permitted command may
|
|
39736
|
+
* reach rather than which commands are permitted — has no canonical category
|
|
39737
|
+
* and is authored through the `devin` override in `.rulesync/permissions.jsonc`.
|
|
39738
|
+
* Devin documents it as a user-config-only key, so it is written at global
|
|
39739
|
+
* scope only.
|
|
39505
39740
|
*
|
|
39506
39741
|
* @see https://docs.devin.ai/cli/reference/permissions
|
|
39742
|
+
* @see https://docs.devin.ai/cli/sandbox
|
|
39507
39743
|
*/
|
|
39508
39744
|
var DevinPermissions = class DevinPermissions extends ToolPermissions {
|
|
39509
39745
|
constructor(params) {
|
|
@@ -39514,7 +39750,8 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
|
|
|
39514
39750
|
}
|
|
39515
39751
|
/**
|
|
39516
39752
|
* config.json may carry the MCP/hooks features' keys, so it is never deleted;
|
|
39517
|
-
* only the
|
|
39753
|
+
* only the keys this feature manages are rewritten — `permissions`, plus
|
|
39754
|
+
* `sandbox` in global mode when the `devin` override authors it.
|
|
39518
39755
|
*/
|
|
39519
39756
|
isDeletable() {
|
|
39520
39757
|
return false;
|
|
@@ -39540,7 +39777,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
|
|
|
39540
39777
|
validate
|
|
39541
39778
|
});
|
|
39542
39779
|
}
|
|
39543
|
-
static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true }) {
|
|
39780
|
+
static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false, validate = true, logger }) {
|
|
39544
39781
|
const paths = DevinPermissions.getSettablePaths({ global });
|
|
39545
39782
|
const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
|
|
39546
39783
|
const existingContent = await readFileContentOrNull(filePath) ?? JSON.stringify({}, null, 2);
|
|
@@ -39566,6 +39803,46 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
|
|
|
39566
39803
|
else delete mergedPermissions.ask;
|
|
39567
39804
|
if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
|
|
39568
39805
|
else delete mergedPermissions.deny;
|
|
39806
|
+
const patch = { permissions: mergedPermissions };
|
|
39807
|
+
const authoredSandbox = config.devin?.sandbox;
|
|
39808
|
+
if (authoredSandbox !== void 0) {
|
|
39809
|
+
const authoredSandboxRecord = asDevinRecord(authoredSandbox);
|
|
39810
|
+
if (global) {
|
|
39811
|
+
const existingSandbox = asDevinRecord(settings.sandbox);
|
|
39812
|
+
const mergedSandbox = {
|
|
39813
|
+
...existingSandbox,
|
|
39814
|
+
...authoredSandboxRecord
|
|
39815
|
+
};
|
|
39816
|
+
const writesSandbox = Object.keys(mergedSandbox).length > 0;
|
|
39817
|
+
if (writesSandbox) patch.sandbox = mergedSandbox;
|
|
39818
|
+
const replacesUnreadableSandbox = writesSandbox && settings.sandbox !== void 0 && !isRecord$1(settings.sandbox);
|
|
39819
|
+
warnOnTrustAffectingEntries({
|
|
39820
|
+
toolLabel: DEVIN_TOOL_LABEL,
|
|
39821
|
+
noun: "sandbox change",
|
|
39822
|
+
entries: [
|
|
39823
|
+
...replacesUnreadableSandbox ? [{
|
|
39824
|
+
label: "sandbox",
|
|
39825
|
+
reason: replacedUnreadableReason({
|
|
39826
|
+
shape: "object",
|
|
39827
|
+
toolLabel: DEVIN_TOOL_LABEL
|
|
39828
|
+
})
|
|
39829
|
+
}] : [],
|
|
39830
|
+
...collectTrustAffectingSandboxPaths({
|
|
39831
|
+
sandbox: authoredSandboxRecord,
|
|
39832
|
+
paths: DEVIN_TRUST_AFFECTING_SANDBOX_PATHS
|
|
39833
|
+
}),
|
|
39834
|
+
...collectRestrictionLosingSandboxEntries({
|
|
39835
|
+
existing: existingSandbox,
|
|
39836
|
+
merged: mergedSandbox,
|
|
39837
|
+
paths: DEVIN_RESTRICTION_LOSING_SANDBOX_PATHS,
|
|
39838
|
+
toolLabel: DEVIN_TOOL_LABEL
|
|
39839
|
+
})
|
|
39840
|
+
],
|
|
39841
|
+
relativeFilePath: toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath)),
|
|
39842
|
+
logger
|
|
39843
|
+
});
|
|
39844
|
+
} else if (Object.keys(authoredSandboxRecord).length > 0) logger?.warn("Devin reads 'sandbox' from the user config only, so the 'devin.sandbox' override was dropped from the project config. Generate with --global to author it.");
|
|
39845
|
+
}
|
|
39569
39846
|
return new DevinPermissions({
|
|
39570
39847
|
outputRoot,
|
|
39571
39848
|
relativeDirPath: paths.relativeDirPath,
|
|
@@ -39574,7 +39851,7 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
|
|
|
39574
39851
|
fileKey: sharedConfigFileKey(paths),
|
|
39575
39852
|
feature: "permissions",
|
|
39576
39853
|
existingContent,
|
|
39577
|
-
patch
|
|
39854
|
+
patch,
|
|
39578
39855
|
filePath
|
|
39579
39856
|
}),
|
|
39580
39857
|
validate
|
|
@@ -39594,7 +39871,10 @@ var DevinPermissions = class DevinPermissions extends ToolPermissions {
|
|
|
39594
39871
|
ask: Array.isArray(permissions.ask) ? permissions.ask : [],
|
|
39595
39872
|
deny: Array.isArray(permissions.deny) ? permissions.deny : []
|
|
39596
39873
|
});
|
|
39597
|
-
|
|
39874
|
+
const sandbox = asDevinRecord(settings.sandbox);
|
|
39875
|
+
const result = { ...config };
|
|
39876
|
+
if (Object.keys(sandbox).length > 0) result.devin = { sandbox };
|
|
39877
|
+
return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
|
|
39598
39878
|
}
|
|
39599
39879
|
validate() {
|
|
39600
39880
|
return {
|
|
@@ -42542,6 +42822,137 @@ function buildReasonixPermissionEntry(toolName, pattern) {
|
|
|
42542
42822
|
if (pattern === "*") return toolName;
|
|
42543
42823
|
return `${toolName}(${pattern})`;
|
|
42544
42824
|
}
|
|
42825
|
+
/** How Reasonix is named in the warnings this file emits. */
|
|
42826
|
+
const REASONIX_TOOL_LABEL = "Reasonix";
|
|
42827
|
+
/** The `[permissions]` key the override's `allowDynamicBash` writes and reads. */
|
|
42828
|
+
const REASONIX_ALLOW_DYNAMIC_BASH_KEY = "allow_dynamic_bash";
|
|
42829
|
+
/**
|
|
42830
|
+
* `[sandbox]` keys whose authored value loosens the enforcement layer beneath
|
|
42831
|
+
* the permission policy: they take Bash out of its OS jail, open that jail to
|
|
42832
|
+
* the network, or widen where the file-writing built-ins may write. Written —
|
|
42833
|
+
* the ordinary uses are far too common to refuse — but never silently, the same
|
|
42834
|
+
* stance `DEVIN_TRUST_AFFECTING_SANDBOX_PATHS` takes, and `widens` likewise
|
|
42835
|
+
* names the restrictive value so a spelling Reasonix does not recognize is
|
|
42836
|
+
* reported rather than waved through.
|
|
42837
|
+
*
|
|
42838
|
+
* `forbid_read` is not here: it restricts, so it loosens by losing entries
|
|
42839
|
+
* rather than by holding one, which needs the before/after comparison
|
|
42840
|
+
* {@link REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS} below does instead.
|
|
42841
|
+
*
|
|
42842
|
+
* @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
|
|
42843
|
+
*/
|
|
42844
|
+
const REASONIX_TRUST_AFFECTING_SANDBOX_PATHS = [
|
|
42845
|
+
{
|
|
42846
|
+
path: ["bash"],
|
|
42847
|
+
reason: "anything but 'enforce' takes Bash out of the OS sandbox, so a command may write and read wherever the user can",
|
|
42848
|
+
widens: (value) => value !== "enforce"
|
|
42849
|
+
},
|
|
42850
|
+
{
|
|
42851
|
+
path: ["network"],
|
|
42852
|
+
reason: "lets sandboxed Bash reach the network",
|
|
42853
|
+
widens: isNotFalse
|
|
42854
|
+
},
|
|
42855
|
+
{
|
|
42856
|
+
path: ["allow_write"],
|
|
42857
|
+
reason: "adds directories the file-writing tools may modify outside the workspace root, which a headless run would otherwise refuse",
|
|
42858
|
+
widens: isNonEmptyList
|
|
42859
|
+
},
|
|
42860
|
+
{
|
|
42861
|
+
path: ["workspace_root"],
|
|
42862
|
+
reason: "moves the root the file-writing tools and sandboxed Bash are confined to, so what they may reach is decided by this path rather than by the project directory",
|
|
42863
|
+
widens: escapesTheProject
|
|
42864
|
+
}
|
|
42865
|
+
];
|
|
42866
|
+
/**
|
|
42867
|
+
* Whether a `workspace_root` points somewhere other than inside the project the
|
|
42868
|
+
* generate runs in. The key moves the write confinement rather than adding to
|
|
42869
|
+
* it, so the ordinary value — the project directory, spelled relatively — would
|
|
42870
|
+
* otherwise be announced on every generate; anything else is the case worth
|
|
42871
|
+
* naming, since it is how a fetched permissions file would put `~/.ssh` or
|
|
42872
|
+
* `C:\\Users\\<user>` inside the jail: an absolute path in either flavour, one
|
|
42873
|
+
* carrying a drive letter, a home-relative one, a shell or environment
|
|
42874
|
+
* expansion, and any path holding a `..` segment — even one that would land back
|
|
42875
|
+
* inside, since resolving it here would only be a guess at what Reasonix does.
|
|
42876
|
+
* Both path flavours are asked because the file is authored on one machine and
|
|
42877
|
+
* generated on another, so a Windows-shaped root reaching a POSIX check must not
|
|
42878
|
+
* read as relative. Anything that is not a string is reported, per the fail-safe
|
|
42879
|
+
* rule the predicates in `sandbox-trust.ts` follow.
|
|
42880
|
+
*/
|
|
42881
|
+
function escapesTheProject(value) {
|
|
42882
|
+
if (typeof value !== "string") return true;
|
|
42883
|
+
const trimmed = value.trim();
|
|
42884
|
+
if (trimmed === "") return false;
|
|
42885
|
+
if (trimmed.startsWith("~")) return true;
|
|
42886
|
+
if (node_path.posix.isAbsolute(trimmed) || node_path.win32.isAbsolute(trimmed)) return true;
|
|
42887
|
+
if (/^[A-Za-z]:/.test(trimmed)) return true;
|
|
42888
|
+
if (trimmed.includes("$") || /%[^%]+%/.test(trimmed)) return true;
|
|
42889
|
+
return trimmed.split(/[\\/]/).includes("..");
|
|
42890
|
+
}
|
|
42891
|
+
/**
|
|
42892
|
+
* The `[sandbox]` key that restricts, and so loosens by losing entries rather
|
|
42893
|
+
* than by holding a value. The override is shallow-merged over the existing
|
|
42894
|
+
* `[sandbox]` at its top level, so an authored `forbid_read` replaces the list
|
|
42895
|
+
* the file had whole — emptying it, or dropping the `${HOME}/.ssh` entry
|
|
42896
|
+
* Reasonix's own example recommends, opens exactly what the list kept closed.
|
|
42897
|
+
*
|
|
42898
|
+
* @see https://github.com/esengine/DeepSeek-Reasonix/blob/main-v2/docs/SPEC.md
|
|
42899
|
+
*/
|
|
42900
|
+
const REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS = [{
|
|
42901
|
+
path: ["forbid_read"],
|
|
42902
|
+
reason: "drops paths the list already in the file kept out of read, list and search",
|
|
42903
|
+
loosens: ({ before, after }) => before.some((entry) => !after.includes(entry))
|
|
42904
|
+
}];
|
|
42905
|
+
/**
|
|
42906
|
+
* Everything worth naming about the `[sandbox]` table this generate is about to
|
|
42907
|
+
* write: the authored values that loosen enforcement, the restrictions the
|
|
42908
|
+
* shallow merge would drop, and a `[sandbox]` the file holds in some shape other
|
|
42909
|
+
* than a table, which the write replaces wholesale. The widening check reads the
|
|
42910
|
+
* authored block alone — a loosening value the file already held is the user's
|
|
42911
|
+
* own, and re-announcing it on every generate would bury the values rulesync
|
|
42912
|
+
* actually wrote — while a loss can only be seen from both sides.
|
|
42913
|
+
*/
|
|
42914
|
+
function collectSandboxOverlayEntries({ existing, authored, merged }) {
|
|
42915
|
+
return [
|
|
42916
|
+
...existing !== void 0 && !isRecord$1(existing) ? [{
|
|
42917
|
+
label: "sandbox",
|
|
42918
|
+
reason: replacedUnreadableReason({
|
|
42919
|
+
shape: "object",
|
|
42920
|
+
toolLabel: REASONIX_TOOL_LABEL
|
|
42921
|
+
})
|
|
42922
|
+
}] : [],
|
|
42923
|
+
...collectTrustAffectingSandboxPaths({
|
|
42924
|
+
sandbox: asReasonixRecord(authored),
|
|
42925
|
+
paths: REASONIX_TRUST_AFFECTING_SANDBOX_PATHS
|
|
42926
|
+
}),
|
|
42927
|
+
...collectRestrictionLosingSandboxEntries({
|
|
42928
|
+
existing: asReasonixRecord(existing),
|
|
42929
|
+
merged,
|
|
42930
|
+
paths: REASONIX_RESTRICTION_LOSING_SANDBOX_PATHS,
|
|
42931
|
+
toolLabel: REASONIX_TOOL_LABEL
|
|
42932
|
+
})
|
|
42933
|
+
];
|
|
42934
|
+
}
|
|
42935
|
+
/**
|
|
42936
|
+
* Writes the override's `allow_dynamic_bash` into `[permissions]`, where it sits
|
|
42937
|
+
* beside allow/ask/deny rather than in a table of its own, and reports it when
|
|
42938
|
+
* it is being turned on: it widens what a shareable permissions file lets run
|
|
42939
|
+
* with no human in the loop. Only an authored value is written — leaving the key
|
|
42940
|
+
* out of the override keeps whatever the file already had — and turning it off
|
|
42941
|
+
* narrows, so that stays quiet. Only a literal `false` is quiet, not everything
|
|
42942
|
+
* falsy: `getJson()` casts rather than parses, so a `--no-validate` run can put
|
|
42943
|
+
* a value the schema forbids here, and a value Reasonix might still coerce is
|
|
42944
|
+
* not something to write in silence. The entries are returned rather than logged so
|
|
42945
|
+
* one generate still produces one warning naming everything it wrote.
|
|
42946
|
+
*/
|
|
42947
|
+
function applyAllowDynamicBash({ permissions, authored }) {
|
|
42948
|
+
if (authored === void 0) return [];
|
|
42949
|
+
permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY] = authored;
|
|
42950
|
+
if (!isNotFalse(authored)) return [];
|
|
42951
|
+
return [{
|
|
42952
|
+
label: `permissions.${REASONIX_ALLOW_DYNAMIC_BASH_KEY}`,
|
|
42953
|
+
reason: "lets an Allow fallback, Auto included, run the nested and indirect Bash that otherwise needs a human or an exact-literal grant — command and process substitution, a dynamic command name, 'eval', 'source', 'sh -c' and their kind"
|
|
42954
|
+
}];
|
|
42955
|
+
}
|
|
42545
42956
|
function parseReasonixConfig(fileContent) {
|
|
42546
42957
|
const parsed = smol_toml.parse(fileContent || smol_toml.stringify({}));
|
|
42547
42958
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
@@ -42598,6 +43009,7 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
|
|
|
42598
43009
|
static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, validate = true, logger, global = false }) {
|
|
42599
43010
|
const paths = this.getSettablePaths({ global });
|
|
42600
43011
|
const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
|
|
43012
|
+
const relativeFilePathForLog = toPosixPath((0, node_path.join)(paths.relativeDirPath, paths.relativeFilePath));
|
|
42601
43013
|
const existingContent = await readFileContentOrNull(filePath) ?? "";
|
|
42602
43014
|
const parsed = parseReasonixConfig(existingContent);
|
|
42603
43015
|
const config = rulesyncPermissions.getJson();
|
|
@@ -42642,11 +43054,23 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
|
|
|
42642
43054
|
...deny,
|
|
42643
43055
|
...rawDeny
|
|
42644
43056
|
]);
|
|
43057
|
+
const trustAffecting = applyAllowDynamicBash({
|
|
43058
|
+
permissions: mergedPermissions,
|
|
43059
|
+
authored: override?.allowDynamicBash
|
|
43060
|
+
});
|
|
42645
43061
|
const patch = { permissions: mergedPermissions };
|
|
42646
|
-
if (override?.sandbox !== void 0)
|
|
42647
|
-
|
|
42648
|
-
|
|
42649
|
-
|
|
43062
|
+
if (override?.sandbox !== void 0) {
|
|
43063
|
+
const mergedSandbox = {
|
|
43064
|
+
...asReasonixRecord(parsed.sandbox),
|
|
43065
|
+
...asReasonixRecord(override.sandbox)
|
|
43066
|
+
};
|
|
43067
|
+
patch.sandbox = mergedSandbox;
|
|
43068
|
+
trustAffecting.push(...collectSandboxOverlayEntries({
|
|
43069
|
+
existing: parsed.sandbox,
|
|
43070
|
+
authored: override.sandbox,
|
|
43071
|
+
merged: mergedSandbox
|
|
43072
|
+
}));
|
|
43073
|
+
}
|
|
42650
43074
|
if (override?.agent !== void 0) {
|
|
42651
43075
|
const mergedAgent = {
|
|
42652
43076
|
...asReasonixRecord(parsed.agent),
|
|
@@ -42654,9 +43078,16 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
|
|
|
42654
43078
|
};
|
|
42655
43079
|
const retired = REASONIX_RETIRED_AGENT_KEYS.filter((key) => mergedAgent[key] !== void 0);
|
|
42656
43080
|
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 ${
|
|
43081
|
+
if (retired.length > 0) logger?.warn(`Reasonix permissions: removing ${retired.map((key) => `"${key}"`).join(", ")} from [agent] in ${relativeFilePathForLog}; Reasonix took the key off its config surface in v1.17.18, so what it used to express now belongs in the shared \`permission\` block.`);
|
|
42658
43082
|
patch.agent = mergedAgent;
|
|
42659
43083
|
}
|
|
43084
|
+
warnOnTrustAffectingEntries({
|
|
43085
|
+
toolLabel: REASONIX_TOOL_LABEL,
|
|
43086
|
+
noun: "change",
|
|
43087
|
+
entries: trustAffecting,
|
|
43088
|
+
relativeFilePath: relativeFilePathForLog,
|
|
43089
|
+
logger
|
|
43090
|
+
});
|
|
42660
43091
|
return new ReasonixPermissions({
|
|
42661
43092
|
outputRoot,
|
|
42662
43093
|
relativeDirPath: paths.relativeDirPath,
|
|
@@ -42691,6 +43122,8 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
|
|
|
42691
43122
|
const sandbox = asReasonixRecord(this.toml.sandbox);
|
|
42692
43123
|
const agentPlanMode = pickReasonixKeys(this.toml.agent, [...REASONIX_OVERRIDE_AGENT_KEYS, ...REASONIX_RETIRED_AGENT_KEYS]);
|
|
42693
43124
|
const reasonixOverride = {};
|
|
43125
|
+
const allowDynamicBash = permissions[REASONIX_ALLOW_DYNAMIC_BASH_KEY];
|
|
43126
|
+
if (typeof allowDynamicBash === "boolean") reasonixOverride.allowDynamicBash = allowDynamicBash;
|
|
42694
43127
|
if (Object.keys(sandbox).length > 0) reasonixOverride.sandbox = sandbox;
|
|
42695
43128
|
if (Object.keys(agentPlanMode).length > 0) reasonixOverride.agent = agentPlanMode;
|
|
42696
43129
|
if (allowSplit.exact.length > 0) reasonixOverride.rawAllow = allowSplit.exact;
|