rulesync 16.14.0 → 16.15.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.
@@ -4422,11 +4422,26 @@ const KiloPermissionsOverrideSchema = zod_mini.z.looseObject({
4422
4422
  * rather than which commands are permitted — so it is a loose passthrough on
4423
4423
  * the same terms, merged into the top level of `.claude/settings.json`.
4424
4424
  *
4425
+ * Any other key is a plain top-level `settings.json` key (`editorMode`, `env`,
4426
+ * `model`, ...), deep-merged into the generated file verbatim so settings
4427
+ * Claude Code adds faster than an allowlist can track stay authorable.
4428
+ * The exceptions are the keys another feature owns (`hooks`) and `$schema`.
4429
+ * Keys the target file cannot honor — `Managed`-only and `~/.claude.json`-only
4430
+ * keys in either scope, plus user-scope keys at project scope — are dropped
4431
+ * with a warning rather than written where they would never apply. So are the
4432
+ * keys whose value is a command Claude Code executes (`apiKeyHelper`,
4433
+ * `statusLine`, ...): this file is shareable via `rulesync fetch`, and a file
4434
+ * named for restricting things is not where a command belongs — author those
4435
+ * in `.rulesync/hooks.jsonc` instead.
4436
+ *
4425
4437
  * @example
4426
4438
  * { "permissions": { "defaultMode": "acceptEdits", "additionalDirectories": ["../shared"] } }
4427
4439
  * @example
4428
4440
  * { "sandbox": { "network": { "allowedDomains": ["example.com"], "strictAllowlist": true } } }
4441
+ * @example
4442
+ * { "editorMode": "vim", "env": { "MY_VAR": "1" } }
4429
4443
  * @see https://code.claude.com/docs/en/sandboxing
4444
+ * @see https://code.claude.com/docs/en/settings-reference
4430
4445
  */
4431
4446
  const ClaudecodePermissionsOverrideSchema = zod_mini.z.looseObject({
4432
4447
  permission: zod_mini.z.optional(ToolScopedPermissionSchema),
@@ -5101,6 +5116,15 @@ const CodexcliPermissionsOverrideSchema = zod_mini.z.looseObject({
5101
5116
  sandbox_workspace_write: zod_mini.z.optional(zod_mini.z.looseObject({})),
5102
5117
  apps: zod_mini.z.optional(zod_mini.z.looseObject({})),
5103
5118
  approvals_reviewer: zod_mini.z.optional(zod_mini.z.union([CodexApprovalsReviewerSchema, zod_mini.z.looseObject({})])),
5119
+ /**
5120
+ * The `[tui]` table of `config.toml` (e.g. `vim_mode_default`, `keymap.*`).
5121
+ * Not a permission surface, but like `apps` it is a top-level table with no
5122
+ * canonical category, and Codex adds keys to it faster than an explicit model
5123
+ * could track — so it is a loose passthrough written verbatim.
5124
+ *
5125
+ * @see https://developers.openai.com/codex/config-reference
5126
+ */
5127
+ tui: zod_mini.z.optional(zod_mini.z.looseObject({})),
5104
5128
  git_write_rules: zod_mini.z.optional(zod_mini.z.boolean())
5105
5129
  });
5106
5130
  /**
@@ -7488,7 +7512,8 @@ const CODEXCLI_OVERRIDE_KEYS = [
7488
7512
  "sandbox_mode",
7489
7513
  "sandbox_workspace_write",
7490
7514
  "apps",
7491
- "approvals_reviewer"
7515
+ "approvals_reviewer",
7516
+ "tui"
7492
7517
  ];
7493
7518
  //#endregion
7494
7519
  //#region src/features/shared/shared-config-gateway.ts
@@ -29405,6 +29430,27 @@ function convertAugmentToRulesyncPermissions({ entries, logger }) {
29405
29430
  return { permission };
29406
29431
  }
29407
29432
  //#endregion
29433
+ //#region src/utils/control-characters.ts
29434
+ /**
29435
+ * Matches C0 controls, DEL, the C1 range (which includes the 8-bit CSI
29436
+ * introducer U+009B), the bidirectional overrides and isolates, and the Unicode
29437
+ * line and paragraph separators, and the plain LRM/RLM marks. A name or value
29438
+ * copied out of an untrusted config file, a fetched repository, or a tool's own
29439
+ * settings file must never reach the terminal with these intact: they let the
29440
+ * text forge log lines, reorder what is printed around them, or inject escape
29441
+ * sequences. LRM/RLM open no bidi scope of their own, but they still reorder the
29442
+ * neutral characters beside them, so they go too — a diagnostic line is not the
29443
+ * place to preserve the typography of a right-to-left name.
29444
+ */
29445
+ const CONTROL_CHARACTERS_PATTERN = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/g;
29446
+ /**
29447
+ * Removes every control character from `text` so it is safe to splice into a
29448
+ * log line or other terminal output.
29449
+ */
29450
+ function stripControlCharacters(text) {
29451
+ return text.replace(CONTROL_CHARACTERS_PATTERN, "");
29452
+ }
29453
+ //#endregion
29408
29454
  //#region src/features/permissions/claudecode-permissions.ts
29409
29455
  /**
29410
29456
  * Mapping from rulesync canonical tool category names (lowercase) to Claude Code tool names (PascalCase).
@@ -29489,8 +29535,9 @@ function deepMergeRecords(base, patch) {
29489
29535
  * emits them only under `--global`.
29490
29536
  *
29491
29537
  * Deliberately NOT listed:
29492
- * - `bwrapPath` / `socatPath`: v2.1.232 added them to the managed-settings
29493
- * approval dialog, which is a consent prompt, not a project-scope rejection.
29538
+ * - `ripgrep` / `bwrapPath` / `socatPath`: each names an executable, so
29539
+ * `stripCommandExecutingSandboxPaths` refuses them in both scopes rather than
29540
+ * emitting them under `--global`.
29494
29541
  * - `credentials.envVars` / `credentials.files`: the ignored-at-project-scope
29495
29542
  * unit is the individual entry's mode, not the settings key, and the same
29496
29543
  * lists carry `deny` entries that project settings *do* honor — dropping a
@@ -29498,7 +29545,6 @@ function deepMergeRecords(base, patch) {
29498
29545
  * filters those lists per entry instead.
29499
29546
  *
29500
29547
  * @see https://code.claude.com/docs/en/sandboxing
29501
- * @see https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md — v2.1.232 scoped `sandbox.ripgrep`
29502
29548
  */
29503
29549
  const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
29504
29550
  ["filesystem", "disabled"],
@@ -29507,10 +29553,228 @@ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
29507
29553
  ["credentials", "allowPlaintextInject"],
29508
29554
  ["credentials", "awsPairs"],
29509
29555
  ["credentials", "sigv4"],
29510
- ["allowAppleEvents"],
29511
- ["ripgrep"]
29556
+ ["allowAppleEvents"]
29557
+ ];
29558
+ /**
29559
+ * Walks `segments` from `root`, returning the record they name or `undefined` if
29560
+ * any step is missing or not a record. Shared by everything below that addresses
29561
+ * a `sandbox` path, so a nested path added to one of the tables is actually
29562
+ * traversed rather than silently skipped.
29563
+ */
29564
+ function resolveSandboxParent({ root, segments }) {
29565
+ let parent = root;
29566
+ for (const segment of segments) {
29567
+ const next = parent[segment];
29568
+ if (!isPlainRecord(next)) return void 0;
29569
+ parent = next;
29570
+ }
29571
+ return parent;
29572
+ }
29573
+ /**
29574
+ * Deletes `path` from `target` in place and reports whether anything was there,
29575
+ * dropping a container the removal emptied so no `"network": {}` noise is left
29576
+ * behind.
29577
+ */
29578
+ function deleteSandboxPath({ target, path }) {
29579
+ const leaf = path.at(-1);
29580
+ if (leaf === void 0) return false;
29581
+ const parentPath = path.slice(0, -1);
29582
+ const parent = resolveSandboxParent({
29583
+ root: target,
29584
+ segments: parentPath
29585
+ });
29586
+ if (parent === void 0 || parent[leaf] === void 0) return false;
29587
+ delete parent[leaf];
29588
+ for (let depth = parentPath.length; depth > 0; depth--) {
29589
+ const container = resolveSandboxParent({
29590
+ root: target,
29591
+ segments: parentPath.slice(0, depth)
29592
+ });
29593
+ if (container === void 0 || Object.keys(container).length > 0) break;
29594
+ const holder = resolveSandboxParent({
29595
+ root: target,
29596
+ segments: parentPath.slice(0, depth - 1)
29597
+ });
29598
+ const name = parentPath[depth - 1];
29599
+ if (holder === void 0 || name === void 0) break;
29600
+ delete holder[name];
29601
+ }
29602
+ return true;
29603
+ }
29604
+ /**
29605
+ * The `permissions.defaultMode` values that start a session with fewer prompts
29606
+ * than the default. `plan` and `default` are absent because they do not widen
29607
+ * anything.
29608
+ */
29609
+ const CLAUDECODE_WIDENING_DEFAULT_MODES = {
29610
+ acceptEdits: "every file edit is then applied without a prompt",
29611
+ auto: "shell commands are then auto-approved by a classifier rather than by you",
29612
+ bypassPermissions: "every session then starts with no permission prompts at all"
29613
+ };
29614
+ /**
29615
+ * The `permissions` fields that widen rather than restrict: a `defaultMode` that
29616
+ * removes prompts, and `additionalDirectories`, which moves the
29617
+ * working-directory boundary. Warned for the same reason `disableAllHooks` is: a
29618
+ * shareable permissions file should not loosen the permission system quietly.
29619
+ */
29620
+ function warnOnWideningPermissionFields({ fields, relativeFilePath, logger }) {
29621
+ const defaultMode = fields.defaultMode;
29622
+ if (typeof defaultMode === "string" && Object.hasOwn(CLAUDECODE_WIDENING_DEFAULT_MODES, defaultMode)) logger?.warn(`Claude Code permissions: writing 'permissions.defaultMode: "${defaultMode}"' to ${relativeFilePath}; ${CLAUDECODE_WIDENING_DEFAULT_MODES[defaultMode]}. Review it as you would a hook, especially if this permissions file came from 'rulesync fetch'.`);
29623
+ const additionalDirectories = fields.additionalDirectories;
29624
+ if (additionalDirectories !== void 0 && !(Array.isArray(additionalDirectories) && additionalDirectories.length === 0)) logger?.warn(`Claude Code permissions: writing 'permissions.additionalDirectories' to ${relativeFilePath}; it moves the boundary of what Claude Code may read and edit outside the project. Review the paths, especially if this permissions file came from 'rulesync fetch'.`);
29625
+ }
29626
+ /**
29627
+ * `sandbox` paths whose value names a binary Claude Code runs. `sandbox` has its
29628
+ * own merge branch, so the top-level refusal in `stripUnhonoredTopLevelKeys`
29629
+ * never sees them — they are refused here on the same grounds, in both scopes:
29630
+ * a fetched `.rulesync/permissions.jsonc` must not be able to point Claude Code
29631
+ * at an executable of its choosing.
29632
+ *
29633
+ * @see https://code.claude.com/docs/en/sandboxing
29634
+ */
29635
+ const CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS = [
29636
+ ["ripgrep"],
29637
+ ["bwrapPath"],
29638
+ ["socatPath"]
29639
+ ];
29640
+ /**
29641
+ * `sandbox` paths that loosen the sandbox rather than naming something to run:
29642
+ * they let commands out of it, weaken the isolation it provides, or redirect
29643
+ * where its traffic goes. They are written like `env` is — the ordinary uses are
29644
+ * too common to refuse — but never silently, because a fetched override should
29645
+ * not be able to open the sandbox without saying so. `widens` keeps the warning
29646
+ * to the value that actually loosens the policy, so authoring the restrictive
29647
+ * value (`allowUnsandboxedCommands: false`, an empty `excludedCommands`) stays
29648
+ * quiet. The `allow*` lists are here for a structural reason: Claude Code merges
29649
+ * a list across every settings scope rather than replacing it, so a project file
29650
+ * can only ever add to them. Their counterparts — `denyRead`, `denyWrite`,
29651
+ * `deniedDomains` — merge the same way, but adding to a deny list only ever
29652
+ * narrows the policy, so they are absent.
29653
+ *
29654
+ * @see https://code.claude.com/docs/en/sandboxing
29655
+ */
29656
+ const CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS = [
29657
+ {
29658
+ path: ["allowAppleEvents"],
29659
+ reason: "lets sandboxed commands send Apple Events, which removes code-execution isolation",
29660
+ widens: (value) => value === true
29661
+ },
29662
+ {
29663
+ path: ["allowUnsandboxedCommands"],
29664
+ reason: "controls whether Claude may retry a blocked command outside the sandbox",
29665
+ widens: (value) => value !== false
29666
+ },
29667
+ {
29668
+ path: ["autoAllowBashIfSandboxed"],
29669
+ reason: "controls whether every Bash command the sandbox accepts runs without a prompt",
29670
+ widens: (value) => value !== false
29671
+ },
29672
+ {
29673
+ path: ["enableWeakerNestedSandbox"],
29674
+ reason: "runs the Linux sandbox inside an unprivileged container, which weakens it",
29675
+ widens: (value) => value === true
29676
+ },
29677
+ {
29678
+ path: ["enableWeakerNetworkIsolation"],
29679
+ reason: "weakens the sandbox's network isolation on macOS",
29680
+ widens: (value) => value === true
29681
+ },
29682
+ {
29683
+ path: ["enabled"],
29684
+ reason: "turns the sandbox on, and sandboxed Bash commands then run without a permission prompt unless `autoAllowBashIfSandboxed` is false",
29685
+ widens: (value) => value === true
29686
+ },
29687
+ {
29688
+ path: ["excludedCommands"],
29689
+ reason: "names commands that always run outside the sandbox, with no sandbox policy applied",
29690
+ widens: (value) => !Array.isArray(value) || value.length > 0
29691
+ },
29692
+ {
29693
+ path: ["filesystem", "allowRead"],
29694
+ reason: "re-opens reading inside a region the sandbox's `denyRead` blocks",
29695
+ widens: (value) => !Array.isArray(value) || value.length > 0
29696
+ },
29697
+ {
29698
+ path: ["filesystem", "allowWrite"],
29699
+ reason: "adds paths sandboxed commands may write to, outside the working directory",
29700
+ widens: (value) => !Array.isArray(value) || value.length > 0
29701
+ },
29702
+ {
29703
+ path: ["ignoreViolations"],
29704
+ reason: "hides the sandbox violations it names, so a blocked access stops being reported",
29705
+ widens: (value) => isPlainRecord(value) ? Object.keys(value).length > 0 : value !== false
29706
+ },
29707
+ {
29708
+ path: ["network", "allowAllUnixSockets"],
29709
+ reason: "lets sandboxed commands connect to every Unix socket",
29710
+ widens: (value) => value === true
29711
+ },
29712
+ {
29713
+ path: ["network", "allowedDomains"],
29714
+ reason: "pre-allows domains sandboxed commands may reach without a prompt",
29715
+ widens: (value) => !Array.isArray(value) || value.length > 0
29716
+ },
29717
+ {
29718
+ path: ["network", "allowLocalBinding"],
29719
+ reason: "lets sandboxed commands bind local ports",
29720
+ widens: (value) => value === true
29721
+ },
29722
+ {
29723
+ path: ["network", "allowMachLookup"],
29724
+ reason: "names the macOS services sandboxed commands may reach, and `*` means every service",
29725
+ widens: (value) => !Array.isArray(value) || value.length > 0
29726
+ },
29727
+ {
29728
+ path: ["network", "allowUnixSockets"],
29729
+ reason: "names Unix sockets sandboxed commands may reach, and one such as `/var/run/docker.sock` is host access",
29730
+ widens: (value) => !Array.isArray(value) || value.length > 0
29731
+ },
29732
+ {
29733
+ path: ["network", "httpProxyPort"],
29734
+ reason: "routes the sandbox's HTTP traffic through the port it names",
29735
+ widens: () => true
29736
+ },
29737
+ {
29738
+ path: ["network", "socksProxyPort"],
29739
+ reason: "routes the sandbox's SOCKS traffic through the port it names",
29740
+ widens: () => true
29741
+ }
29512
29742
  ];
29513
29743
  /**
29744
+ * Warns once per authored `sandbox` path that loosens the sandbox. Nothing is
29745
+ * removed — the value is written, just not silently. Called on the filtered
29746
+ * `sandbox` so it never claims to be writing a path the scope filters dropped.
29747
+ */
29748
+ function warnOnTrustAffectingSandboxPaths({ sandbox, relativeFilePath, logger }) {
29749
+ for (const { path, reason, widens } of CLAUDECODE_TRUST_AFFECTING_SANDBOX_PATHS) {
29750
+ const leaf = path.at(-1);
29751
+ if (leaf === void 0) continue;
29752
+ const parent = resolveSandboxParent({
29753
+ root: sandbox,
29754
+ segments: path.slice(0, -1)
29755
+ });
29756
+ if (parent === void 0) continue;
29757
+ const value = parent[leaf];
29758
+ if (value === void 0 || !widens(value)) continue;
29759
+ logger?.warn(`Claude Code permissions: writing 'sandbox.${path.join(".")}' to ${relativeFilePath}; it ${reason}. Review the value as you would a hook, especially if this permissions file came from 'rulesync fetch'.`);
29760
+ }
29761
+ }
29762
+ /**
29763
+ * Copy of the authored `sandbox` override with the paths that name an
29764
+ * executable removed, warning once per dropped path.
29765
+ */
29766
+ function stripCommandExecutingSandboxPaths({ sandbox, relativeFilePath, logger }) {
29767
+ const filtered = structuredClone(sandbox);
29768
+ for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) {
29769
+ if (!deleteSandboxPath({
29770
+ target: filtered,
29771
+ path
29772
+ })) continue;
29773
+ logger?.warn(`Claude Code permissions: 'sandbox.${path.join(".")}' names an executable Claude Code runs, so rulesync does not write it to ${relativeFilePath}. A permissions file is shareable — 'rulesync fetch' copies one into a project — and is not where a reviewer looks for a command to run; set this path in ${relativeFilePath} by hand.`);
29774
+ }
29775
+ return filtered;
29776
+ }
29777
+ /**
29514
29778
  * Copy of the authored `sandbox` override with the user/managed-only paths
29515
29779
  * removed, warning once per dropped path. Only the override copy is filtered —
29516
29780
  * a value already hand-written in the target file is left untouched, matching
@@ -29519,24 +29783,10 @@ const CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS = [
29519
29783
  function stripGlobalOnlySandboxPaths({ sandbox, relativeFilePath, logger }) {
29520
29784
  const filtered = structuredClone(sandbox);
29521
29785
  for (const path of CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS) {
29522
- const leaf = path.at(-1);
29523
- if (leaf === void 0) continue;
29524
- const parentPath = path.slice(0, -1);
29525
- let parent = filtered;
29526
- for (const segment of parentPath) {
29527
- const next = parent[segment];
29528
- if (!isPlainRecord(next)) {
29529
- parent = {};
29530
- break;
29531
- }
29532
- parent = next;
29533
- }
29534
- if (parent[leaf] === void 0) continue;
29535
- delete parent[leaf];
29536
- const [container] = parentPath;
29537
- if (container !== void 0 && isPlainRecord(filtered[container])) {
29538
- if (Object.keys(filtered[container]).length === 0) delete filtered[container];
29539
- }
29786
+ if (!deleteSandboxPath({
29787
+ target: filtered,
29788
+ path
29789
+ })) continue;
29540
29790
  logger?.warn(`Claude Code permissions: 'sandbox.${path.join(".")}' is only honored in user/managed/--settings settings, so it is not written to the project-scoped ${relativeFilePath}. Author it in the global scope instead, and check that file for a stale value an earlier generate may have left there.`);
29541
29791
  }
29542
29792
  return filtered;
@@ -29592,6 +29842,215 @@ function stripProjectIgnoredMaskEntries({ sandbox, relativeFilePath, logger }) {
29592
29842
  else filtered.credentials = filteredCredentials;
29593
29843
  return filtered;
29594
29844
  }
29845
+ /**
29846
+ * Top-level `.claude/settings.json` keys another feature owns, derived from
29847
+ * {@link SHARED_CONFIG_OWNERSHIP} rather than restated here so a feature that
29848
+ * starts owning a new key is excluded from the passthrough automatically
29849
+ * (today: `hooks`, from the hooks feature). Only `replace-owned-keys` entries
29850
+ * name keys; the `custom` policies on this file (`ignore`, `permissions`) own
29851
+ * entries *inside* `permissions`, which the passthrough excludes wholesale.
29852
+ */
29853
+ const CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS = Object.entries(SHARED_CONFIG_OWNERSHIP[".claude/settings.json"]?.features ?? {}).flatMap(([feature, policy]) => feature !== "permissions" && policy.kind === "replace-owned-keys" ? policy.ownedKeys : []);
29854
+ /**
29855
+ * Top-level `.claude/settings.json` keys the generic `claudecode` override
29856
+ * passthrough must not carry. `permissions` and `sandbox` have their own merge
29857
+ * branches (the managed `allow`/`ask`/`deny` arrays and the scope filtering
29858
+ * respectively), `permission` is rulesync's own canonical tool-scoped block
29859
+ * rather than a settings key, `$schema` is an editor pointer rather than a
29860
+ * Claude Code setting, and the rest belong to the other features writing this
29861
+ * shared file.
29862
+ */
29863
+ const CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS = /* @__PURE__ */ new Set([
29864
+ "permission",
29865
+ "permissions",
29866
+ "sandbox",
29867
+ "$schema",
29868
+ ...CLAUDECODE_FEATURE_OWNED_SETTINGS_KEYS
29869
+ ]);
29870
+ /**
29871
+ * Top-level settings keys Claude Code reads only from user settings, managed
29872
+ * settings and the `--settings` CLI flag — the same restriction
29873
+ * `CLAUDECODE_GLOBAL_ONLY_SANDBOX_PATHS` records for the `sandbox` subtree, and
29874
+ * the reason the passthrough drops them at project scope instead of committing
29875
+ * a setting that never applies. `rulesync generate --global` writes the user
29876
+ * settings file, so they are emitted there.
29877
+ *
29878
+ * Derived from the per-key **Scope** column of the settings reference: every
29879
+ * top-level key documented as `User or managed` or `User, local, or managed`.
29880
+ *
29881
+ * @see https://code.claude.com/docs/en/settings-reference
29882
+ */
29883
+ const CLAUDECODE_USER_SCOPE_ONLY_KEYS = /* @__PURE__ */ new Set([
29884
+ "askUserQuestionTimeout",
29885
+ "autoMode",
29886
+ "dialogExpiry",
29887
+ "enableArtifact",
29888
+ "footerLinksRegexes",
29889
+ "pluginConfigs",
29890
+ "skipAutoPermissionPrompt",
29891
+ "skipDangerousModePermissionPrompt",
29892
+ "spellcheck",
29893
+ "sshConfigs",
29894
+ "syncClaudeAiSkills",
29895
+ "useAutoModeDuringPlan",
29896
+ "vimInsertModeRemaps"
29897
+ ]);
29898
+ /**
29899
+ * Top-level settings keys neither file rulesync writes can honor, with the file
29900
+ * that does. `Managed` keys are read only from the settings file an organization
29901
+ * deploys, and `Global config` keys only from `~/.claude.json` — rulesync writes
29902
+ * `.claude/settings.json` and `~/.claude/settings.json`, so authoring either
29903
+ * kind through the override would produce a policy that silently never applies.
29904
+ *
29905
+ * Derived from the per-key **Scope** column of the settings reference.
29906
+ *
29907
+ * @see https://code.claude.com/docs/en/settings-reference
29908
+ */
29909
+ const CLAUDECODE_UNHONORED_KEY_SOURCES = {
29910
+ allowAllClaudeAiMcps: "managed settings",
29911
+ allowedChannelPlugins: "managed settings",
29912
+ allowManagedHooksOnly: "managed settings",
29913
+ allowManagedMcpServersOnly: "managed settings",
29914
+ allowManagedPermissionRulesOnly: "managed settings",
29915
+ autoConnectIde: "~/.claude.json",
29916
+ autoInstallIdeExtension: "~/.claude.json",
29917
+ blockedMarketplaces: "managed settings",
29918
+ browserExternalPageTools: "managed settings",
29919
+ channelsEnabled: "managed settings",
29920
+ claudeMd: "managed settings",
29921
+ diffTool: "~/.claude.json",
29922
+ disableBrowserExternalNavigation: "managed settings",
29923
+ disableCommandPluginSources: "managed settings",
29924
+ disableMobileSimulatorTools: "managed settings",
29925
+ disableSideloadFlags: "managed settings",
29926
+ externalEditorContext: "~/.claude.json",
29927
+ forceLoginGatewayUrl: "managed settings",
29928
+ forceRemoteSettingsRefresh: "managed settings",
29929
+ parentSettingsBehavior: "managed settings",
29930
+ permissionExplainerEnabled: "~/.claude.json",
29931
+ pluginSuggestionMarketplaces: "managed settings",
29932
+ pluginTrustMessage: "managed settings",
29933
+ requiredMaximumVersion: "managed settings",
29934
+ requiredMinimumVersion: "managed settings",
29935
+ sshHostAllowlist: "managed settings",
29936
+ strictKnownMarketplaces: "managed settings",
29937
+ strictPluginOnlyCustomization: "managed settings",
29938
+ teammateDefaultModel: "~/.claude.json",
29939
+ wslInheritsWindowsSettings: "managed settings"
29940
+ };
29941
+ /**
29942
+ * Top-level settings keys whose value Claude Code **executes**. The generic
29943
+ * passthrough refuses them outright rather than warning: `.rulesync/*` files
29944
+ * are shareable — `rulesync fetch` copies a third party's `permissions.jsonc`
29945
+ * straight into a project — and a file named for *restricting* what an agent
29946
+ * may do is not somewhere a reviewer looks for a command to run. Commands
29947
+ * belong in `.rulesync/hooks.jsonc` and `.rulesync/.mcp.json`, which are read
29948
+ * as executable by anyone reviewing them. Set these by hand in the settings
29949
+ * file if you need them.
29950
+ *
29951
+ * The value is the reason, spliced into the warning.
29952
+ *
29953
+ * @see https://code.claude.com/docs/en/settings-reference
29954
+ */
29955
+ const CLAUDECODE_COMMAND_EXECUTING_KEYS = {
29956
+ apiKeyHelper: "runs the script it names to mint an API key",
29957
+ awsAuthRefresh: "runs the command it names to refresh AWS credentials",
29958
+ awsCredentialExport: "runs the command it names to export AWS credentials",
29959
+ fileSuggestion: "runs its `command` on every `@` file completion",
29960
+ gcpAuthRefresh: "runs the command it names to refresh Google Cloud credentials",
29961
+ otelHeadersHelper: "runs the script it names to build OpenTelemetry headers",
29962
+ policyHelper: "runs the executable it names to compute the managed settings",
29963
+ processWrapper: "wraps every process Claude Code spawns",
29964
+ statusLine: "runs its `command` on every status-line render",
29965
+ subagentStatusLine: "runs its `command` on every subagent status row"
29966
+ };
29967
+ /**
29968
+ * Top-level settings keys the passthrough does write, but never silently: each
29969
+ * one widens what Claude Code trusts or where it sends data, so a value that
29970
+ * arrived with a fetched `.rulesync/permissions.jsonc` should be looked at
29971
+ * deliberately. Warning on write follows the precedent set for Warp's
29972
+ * `command_denylist`, which also replaces a protection when rulesync writes it.
29973
+ *
29974
+ * The value is the reason, spliced into the warning.
29975
+ */
29976
+ const CLAUDECODE_TRUST_AFFECTING_KEYS = {
29977
+ agent: "starts every session as the named subagent, with that subagent's prompt, tools and model",
29978
+ allowedHttpHookUrls: "limits which URLs an HTTP hook may target, and an empty list means every URL",
29979
+ allowedMcpServers: "allowlists the MCP servers that may be used, and entries from every settings file merge into one list, so an entry here widens an allowlist deployed elsewhere",
29980
+ autoMode: "auto-approves shell commands with a classifier rather than with a prompt",
29981
+ disableAllHooks: "controls whether hooks run at all",
29982
+ disableSkillShellExecution: "re-opens the inline shell commands in a skill or custom command that a user setting had turned off",
29983
+ enableAllProjectMcpServers: "auto-approves every server in the project `.mcp.json`",
29984
+ enabledMcpjsonServers: "auto-approves the named servers in the project `.mcp.json`",
29985
+ enabledPlugins: "enables plugins, which can ship their own hooks",
29986
+ env: "sets environment variables for every process Claude Code spawns, so a value such as `NODE_OPTIONS` or `PATH` runs code and `ANTHROPIC_BASE_URL` redirects every prompt",
29987
+ extraKnownMarketplaces: "registers plugin marketplace sources",
29988
+ httpHookAllowedEnvVars: "controls which environment variables an HTTP hook may put in a request header, credentials included",
29989
+ outputStyle: "replaces the system prompt every session runs with",
29990
+ skipAutoPermissionPrompt: "removes the confirmation shown before auto-approval mode starts",
29991
+ skipDangerousModePermissionPrompt: "removes the confirmation shown before the mode that skips every permission check starts"
29992
+ };
29993
+ /**
29994
+ * The keys from the table above that only widen at one particular value.
29995
+ * `disableSkillShellExecution: true` turns inline shell execution off, which
29996
+ * restricts; the `false` that turns it back on is what a fetched override could
29997
+ * use to undo a user setting, so only that value is warned about.
29998
+ */
29999
+ const CLAUDECODE_TRUST_KEY_WIDENING_VALUES = { disableSkillShellExecution: (value) => value === false };
30000
+ /**
30001
+ * A key name is authored data that ends up in a log line, so strip the control
30002
+ * characters that would let it forge a line or hide the warnings beside it, and
30003
+ * cap the length.
30004
+ */
30005
+ function displayKey(key) {
30006
+ const stripped = stripControlCharacters(key);
30007
+ return stripped.length > 80 ? `${stripped.slice(0, 80)}…` : stripped;
30008
+ }
30009
+ /**
30010
+ * Alternate spellings Claude Code accepts for a top-level settings key, mapped
30011
+ * to the canonical key whose **Scope** the alias inherits: "In any settings
30012
+ * file that accepts the canonical key, Claude Code reads the alias exactly as
30013
+ * it reads the canonical key." Resolving through this map before the scope
30014
+ * check keeps an alias from slipping past a restriction its canonical spelling
30015
+ * is caught by — `allowedMarketplaces` is `Managed`, like
30016
+ * `strictKnownMarketplaces`. Both aliases require Claude Code v2.1.232+.
30017
+ *
30018
+ * @see https://code.claude.com/docs/en/settings-reference#marketplace-key-aliases
30019
+ */
30020
+ const CLAUDECODE_SETTINGS_KEY_ALIASES = {
30021
+ additionalMarketplaces: "extraKnownMarketplaces",
30022
+ allowedMarketplaces: "strictKnownMarketplaces"
30023
+ };
30024
+ /**
30025
+ * Copy of the authored top-level passthrough with the keys the target file
30026
+ * cannot honor removed, warning once per dropped key. Like
30027
+ * `stripGlobalOnlySandboxPaths`, only the override copy is filtered — a value
30028
+ * already hand-written in the target file is left untouched, which is why the
30029
+ * warning points at it.
30030
+ */
30031
+ function stripUnhonoredTopLevelKeys({ overrides, global, relativeFilePath, logger }) {
30032
+ const filtered = {};
30033
+ for (const [key, value] of Object.entries(overrides)) {
30034
+ const shown = displayKey(key);
30035
+ const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
30036
+ if (Object.hasOwn(CLAUDECODE_COMMAND_EXECUTING_KEYS, canonicalKey)) {
30037
+ logger?.warn(`Claude Code permissions: '${shown}' ${CLAUDECODE_COMMAND_EXECUTING_KEYS[canonicalKey]}, so rulesync does not write it to ${relativeFilePath}. A permissions file is shareable — 'rulesync fetch' copies one into a project — and is not where a reviewer looks for a command to run; author commands in .rulesync/hooks.jsonc, or set this key in ${relativeFilePath} by hand.`);
30038
+ continue;
30039
+ }
30040
+ if (Object.hasOwn(CLAUDECODE_UNHONORED_KEY_SOURCES, canonicalKey)) {
30041
+ logger?.warn(`Claude Code permissions: '${shown}' is only honored in ${CLAUDECODE_UNHONORED_KEY_SOURCES[canonicalKey]}, which rulesync does not generate, so it is not written to ${relativeFilePath}. Set it in that file by hand, and check ${relativeFilePath} for a stale value an earlier generate may have left there.`);
30042
+ continue;
30043
+ }
30044
+ if (!global && CLAUDECODE_USER_SCOPE_ONLY_KEYS.has(canonicalKey)) {
30045
+ logger?.warn(`Claude Code permissions: '${shown}' is not honored in the project-scoped ${relativeFilePath}, so it is not written there — Claude Code reads it from user, local or managed settings. Author it in the global scope instead, and check that file for a stale value an earlier generate may have left there.`);
30046
+ continue;
30047
+ }
30048
+ const widensAtValue = CLAUDECODE_TRUST_KEY_WIDENING_VALUES[canonicalKey];
30049
+ if (Object.hasOwn(CLAUDECODE_TRUST_AFFECTING_KEYS, canonicalKey) && (widensAtValue === void 0 || widensAtValue(value))) logger?.warn(`Claude Code permissions: writing '${shown}' to ${relativeFilePath}; it ${CLAUDECODE_TRUST_AFFECTING_KEYS[canonicalKey]}. Review the value as you would a hook, especially if this permissions file came from 'rulesync fetch'.`);
30050
+ filtered[key] = value;
30051
+ }
30052
+ return filtered;
30053
+ }
29595
30054
  const CLAUDE_PATH_RULE_ALIASES = {
29596
30055
  Write: "Edit",
29597
30056
  NotebookEdit: "Edit",
@@ -29662,7 +30121,13 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
29662
30121
  });
29663
30122
  const overridePermissions = config.claudecode?.permissions;
29664
30123
  if (overridePermissions && typeof overridePermissions === "object") {
29665
- const { allow: _a, ask: _k, deny: _d, ...nonListFields } = overridePermissions;
30124
+ const { allow: _a, ask: _k, deny: _d, ...rest } = overridePermissions;
30125
+ const nonListFields = Object.fromEntries(Object.entries(rest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
30126
+ warnOnWideningPermissionFields({
30127
+ fields: nonListFields,
30128
+ relativeFilePath: paths.relativeFilePath,
30129
+ logger
30130
+ });
29666
30131
  settings.permissions = {
29667
30132
  ...settings.permissions,
29668
30133
  ...nonListFields
@@ -29670,17 +30135,41 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
29670
30135
  }
29671
30136
  const overrideSandbox = config.claudecode?.sandbox;
29672
30137
  if (isPlainRecord(overrideSandbox)) {
29673
- const scopedSandbox = global ? overrideSandbox : stripProjectIgnoredMaskEntries({
30138
+ const executableFreeSandbox = stripCommandExecutingSandboxPaths({
30139
+ sandbox: overrideSandbox,
30140
+ relativeFilePath: paths.relativeFilePath,
30141
+ logger
30142
+ });
30143
+ const scopedSandbox = global ? executableFreeSandbox : stripProjectIgnoredMaskEntries({
29674
30144
  sandbox: stripGlobalOnlySandboxPaths({
29675
- sandbox: overrideSandbox,
30145
+ sandbox: executableFreeSandbox,
29676
30146
  relativeFilePath: paths.relativeFilePath,
29677
30147
  logger
29678
30148
  }),
29679
30149
  relativeFilePath: paths.relativeFilePath,
29680
30150
  logger
29681
30151
  });
30152
+ warnOnTrustAffectingSandboxPaths({
30153
+ sandbox: scopedSandbox,
30154
+ relativeFilePath: paths.relativeFilePath,
30155
+ logger
30156
+ });
29682
30157
  if (Object.keys(scopedSandbox).length > 0) settings.sandbox = deepMergeRecords(isPlainRecord(settings.sandbox) ? settings.sandbox : {}, scopedSandbox);
29683
30158
  }
30159
+ const overrideTopLevel = {};
30160
+ for (const [key, value] of Object.entries(config.claudecode ?? {})) {
30161
+ if (CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS.has(key)) continue;
30162
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
30163
+ if (value === void 0) continue;
30164
+ overrideTopLevel[key] = value;
30165
+ }
30166
+ const scopedTopLevel = stripUnhonoredTopLevelKeys({
30167
+ overrides: overrideTopLevel,
30168
+ global,
30169
+ relativeFilePath: paths.relativeFilePath,
30170
+ logger
30171
+ });
30172
+ if (Object.keys(scopedTopLevel).length > 0) settings = deepMergeRecords(settings, scopedTopLevel);
29684
30173
  const managedToolNames = managedClaudeToolNames(config);
29685
30174
  const merged = applyPermissions({
29686
30175
  settings,
@@ -29713,12 +30202,33 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
29713
30202
  ask: permissions.ask ?? [],
29714
30203
  deny: permissions.deny ?? []
29715
30204
  });
29716
- const { allow: _a, ask: _k, deny: _d, ...nonListFields } = permissions;
30205
+ const { allow: _a, ask: _k, deny: _d, ...permissionsRest } = permissions;
30206
+ const nonListFields = Object.fromEntries(Object.entries(permissionsRest).filter(([key]) => !PROTOTYPE_POLLUTION_KEYS.has(key)));
29717
30207
  if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
29718
30208
  const { sandbox } = settings;
29719
- if (isPlainRecord(sandbox) && Object.keys(sandbox).length > 0) config.claudecode = {
30209
+ if (isPlainRecord(sandbox)) {
30210
+ const importedSandbox = structuredClone(sandbox);
30211
+ for (const path of CLAUDECODE_COMMAND_EXECUTING_SANDBOX_PATHS) deleteSandboxPath({
30212
+ target: importedSandbox,
30213
+ path
30214
+ });
30215
+ if (Object.keys(importedSandbox).length > 0) config.claudecode = {
30216
+ ...config.claudecode,
30217
+ sandbox: importedSandbox
30218
+ };
30219
+ }
30220
+ const topLevelPassthrough = {};
30221
+ for (const [key, value] of Object.entries(settings)) {
30222
+ if (CLAUDECODE_NON_PASSTHROUGH_OVERRIDE_KEYS.has(key)) continue;
30223
+ const canonicalKey = Object.hasOwn(CLAUDECODE_SETTINGS_KEY_ALIASES, key) ? CLAUDECODE_SETTINGS_KEY_ALIASES[key] : key;
30224
+ if (Object.hasOwn(CLAUDECODE_COMMAND_EXECUTING_KEYS, canonicalKey)) continue;
30225
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
30226
+ if (value === void 0) continue;
30227
+ topLevelPassthrough[key] = value;
30228
+ }
30229
+ if (Object.keys(topLevelPassthrough).length > 0) config.claudecode = {
29720
30230
  ...config.claudecode,
29721
- sandbox
30231
+ ...topLevelPassthrough
29722
30232
  };
29723
30233
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
29724
30234
  }
@@ -56606,6 +57116,12 @@ Object.defineProperty(exports, "stringifyFrontmatter", {
56606
57116
  return stringifyFrontmatter;
56607
57117
  }
56608
57118
  });
57119
+ Object.defineProperty(exports, "stripControlCharacters", {
57120
+ enumerable: true,
57121
+ get: function() {
57122
+ return stripControlCharacters;
57123
+ }
57124
+ });
56609
57125
  Object.defineProperty(exports, "toPosixPath", {
56610
57126
  enumerable: true,
56611
57127
  get: function() {