rulesync 9.1.1 → 9.3.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.
@@ -3003,8 +3003,8 @@ const DEVIN_SKILLS_DIR_PATH = join(DEVIN_DIR, "skills");
3003
3003
  const DEVIN_AGENTS_DIR_PATH = join(DEVIN_DIR, "agents");
3004
3004
  const DEVIN_GLOBAL_CONFIG_DIR_PATH = join(".config", "devin");
3005
3005
  const DEVIN_GLOBAL_AGENTS_DIR_PATH = join(DEVIN_GLOBAL_CONFIG_DIR_PATH, "agents");
3006
+ const DEVIN_GLOBAL_SKILLS_DIR_PATH = join(DEVIN_GLOBAL_CONFIG_DIR_PATH, "skills");
3006
3007
  const CODEIUM_WINDSURF_GLOBAL_WORKFLOWS_DIR_PATH = join(CODEIUM_WINDSURF_DIR, "global_workflows");
3007
- const CODEIUM_WINDSURF_SKILLS_DIR_PATH = join(CODEIUM_WINDSURF_DIR, "skills");
3008
3008
  const DEVIN_CONFIG_FILE_NAME = "config.json";
3009
3009
  const DEVIN_HOOKS_V1_FILE_NAME = "hooks.v1.json";
3010
3010
  const DEVIN_GLOBAL_AGENTS_FILE_NAME = "AGENTS.md";
@@ -5688,6 +5688,8 @@ const DEEPAGENTS_HOOK_EVENTS = [
5688
5688
  "sessionEnd",
5689
5689
  "beforeSubmitPrompt",
5690
5690
  "permissionRequest",
5691
+ "preToolUse",
5692
+ "postToolUse",
5691
5693
  "postToolUseFailure",
5692
5694
  "stop",
5693
5695
  "preCompact",
@@ -5713,7 +5715,13 @@ const CODEXCLI_HOOK_EVENTS = [
5713
5715
  * Goose adopts the Open Plugins hooks spec: each plugin's `hooks/hooks.json`
5714
5716
  * maps PascalCase event names to matcher/handler arrays. Every Goose event has a
5715
5717
  * 1:1 canonical equivalent, so no new canonical events are required.
5716
- * @see https://goose-docs.ai/blog/2026/05/14/goose-hooks/
5718
+ *
5719
+ * Goose's `HookEvent` enum defines exactly these 11 events (v1.41.0). Notably it
5720
+ * has NO `SubagentStart`/`SubagentStop` arms — emitting them would write keys
5721
+ * Goose silently ignores, so `subagentStart`/`subagentStop` are intentionally
5722
+ * excluded here and from `CANONICAL_TO_GOOSE_EVENT_NAMES`.
5723
+ * @see https://github.com/block/goose/blob/v1.41.0/crates/goose/src/hooks/mod.rs
5724
+ * @see https://block.github.io/goose/docs/guides/context-engineering/hooks/
5717
5725
  */
5718
5726
  const GOOSE_HOOK_EVENTS = [
5719
5727
  "sessionStart",
@@ -5726,9 +5734,7 @@ const GOOSE_HOOK_EVENTS = [
5726
5734
  "beforeReadFile",
5727
5735
  "afterFileEdit",
5728
5736
  "beforeShellExecution",
5729
- "afterShellExecution",
5730
- "subagentStart",
5731
- "subagentStop"
5737
+ "afterShellExecution"
5732
5738
  ];
5733
5739
  /** Hook events supported by Kiro CLI. */
5734
5740
  const KIRO_HOOK_EVENTS = [
@@ -6181,9 +6187,7 @@ const CANONICAL_TO_GOOSE_EVENT_NAMES = {
6181
6187
  beforeReadFile: "BeforeReadFile",
6182
6188
  afterFileEdit: "AfterFileEdit",
6183
6189
  beforeShellExecution: "BeforeShellExecution",
6184
- afterShellExecution: "AfterShellExecution",
6185
- subagentStart: "SubagentStart",
6186
- subagentStop: "SubagentStop"
6190
+ afterShellExecution: "AfterShellExecution"
6187
6191
  };
6188
6192
  /**
6189
6193
  * Map Goose PascalCase event names to canonical camelCase.
@@ -6197,6 +6201,8 @@ const CANONICAL_TO_DEEPAGENTS_EVENT_NAMES = {
6197
6201
  sessionEnd: "session.end",
6198
6202
  beforeSubmitPrompt: "user.prompt",
6199
6203
  permissionRequest: "permission.request",
6204
+ preToolUse: "tool.use",
6205
+ postToolUse: "tool.result",
6200
6206
  postToolUseFailure: "tool.error",
6201
6207
  stop: "task.complete",
6202
6208
  preCompact: "context.compact",
@@ -8191,7 +8197,7 @@ const GOOSE_CONVERTER_CONFIG = {
8191
8197
  *
8192
8198
  * The JSON shape matches Claude Code's: each PascalCase event maps to an array of
8193
8199
  * `{ matcher, hooks: [{ type: "command", command }] }` entries.
8194
- * @see https://goose-docs.ai/blog/2026/05/14/goose-hooks/
8200
+ * @see https://block.github.io/goose/docs/guides/context-engineering/hooks/
8195
8201
  */
8196
8202
  var GooseHooks = class GooseHooks extends ToolHooks {
8197
8203
  constructor(params) {
@@ -8242,7 +8248,7 @@ var GooseHooks = class GooseHooks extends ToolHooks {
8242
8248
  throw new Error(`Failed to parse Goose hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
8243
8249
  }
8244
8250
  const hooks = toolHooksToCanonical({
8245
- hooks: parsed.hooks,
8251
+ hooks: parsed.hooks && typeof parsed.hooks === "object" && !Array.isArray(parsed.hooks) ? Object.fromEntries(Object.entries(parsed.hooks).filter(([eventName]) => Object.hasOwn(GOOSE_TO_CANONICAL_EVENT_NAMES, eventName))) : parsed.hooks,
8246
8252
  converterConfig: GOOSE_CONVERTER_CONFIG
8247
8253
  });
8248
8254
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify({
@@ -8297,6 +8303,24 @@ function mergeHermesConfig(fileContent, patch) {
8297
8303
  ...patch
8298
8304
  });
8299
8305
  }
8306
+ /**
8307
+ * Recursively merge `patch` into `base` (patch wins). Nested plain objects are
8308
+ * merged key-by-key; every other value (arrays, scalars) is replaced wholesale.
8309
+ * Used to overlay the Hermes-scoped permission override onto the natively
8310
+ * emitted `approvals`/`security` structures without one clobbering the other
8311
+ * (e.g. `approvals.deny` from canonical deny rules coexisting with an
8312
+ * `approvals.mode` from the override). Prototype-pollution keys are dropped.
8313
+ */
8314
+ function deepMergeHermesConfig(base, patch) {
8315
+ const result = { ...base };
8316
+ for (const [key, patchValue] of Object.entries(patch)) {
8317
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
8318
+ const baseValue = result[key];
8319
+ if (isPlainObject(baseValue) && isPlainObject(patchValue)) result[key] = deepMergeHermesConfig(baseValue, patchValue);
8320
+ else result[key] = sanitizeHermesConfigValue(patchValue);
8321
+ }
8322
+ return result;
8323
+ }
8300
8324
  //#endregion
8301
8325
  //#region src/features/hooks/hermesagent-hooks.ts
8302
8326
  /**
@@ -10462,6 +10486,66 @@ var AugmentcodeIgnore = class AugmentcodeIgnore extends ToolIgnore {
10462
10486
  }
10463
10487
  };
10464
10488
  //#endregion
10489
+ //#region src/features/claudecode-settings-gateway.ts
10490
+ /**
10491
+ * Single owner of the `.claude/settings.json` `permissions` block, which both
10492
+ * `ignore` (writes `Read(...)` into `permissions.deny`) and `permissions`
10493
+ * (writes the whole `allow`/`ask`/`deny`) read-modify-write. The entry format,
10494
+ * the merge, and the cross-feature ownership rule (permissions' explicit `Read`
10495
+ * rules win over ignore-derived `Read` denies) used to be duplicated across both
10496
+ * feature files; they live here once so each feature just states its intent and
10497
+ * never reasons about the other's existence.
10498
+ */
10499
+ const READ_TOOL_NAME = "Read";
10500
+ const isReadDenyEntry = (entry) => entry.startsWith(`${READ_TOOL_NAME}(`) && entry.endsWith(")");
10501
+ const buildReadDenyEntry = (pattern) => `${READ_TOOL_NAME}(${pattern})`;
10502
+ const parsePermissionsBlock = (settings) => {
10503
+ const permissions = settings.permissions ?? {};
10504
+ return {
10505
+ allow: permissions.allow ?? [],
10506
+ ask: permissions.ask ?? [],
10507
+ deny: permissions.deny ?? []
10508
+ };
10509
+ };
10510
+ const withPermissions = (settings, next) => {
10511
+ const permissions = { ...settings.permissions };
10512
+ const assign = (key, values) => {
10513
+ if (values.length > 0) permissions[key] = values;
10514
+ else delete permissions[key];
10515
+ };
10516
+ assign("allow", next.allow);
10517
+ assign("ask", next.ask);
10518
+ assign("deny", next.deny);
10519
+ return {
10520
+ ...settings,
10521
+ permissions
10522
+ };
10523
+ };
10524
+ const applyIgnoreReadDenies = (params) => {
10525
+ const { settings, readDenies } = params;
10526
+ const current = parsePermissionsBlock(settings);
10527
+ const preservedDeny = current.deny.filter((entry) => !isReadDenyEntry(entry) || readDenies.includes(entry));
10528
+ return withPermissions(settings, {
10529
+ allow: current.allow,
10530
+ ask: current.ask,
10531
+ deny: uniq([...preservedDeny, ...readDenies].toSorted())
10532
+ });
10533
+ };
10534
+ const applyPermissions = (params) => {
10535
+ const { settings, managedToolNames, toolNameOf, allow, ask, deny, logger } = params;
10536
+ const current = parsePermissionsBlock(settings);
10537
+ const keepUnmanaged = (entries) => entries.filter((entry) => !managedToolNames.has(toolNameOf(entry)));
10538
+ if (logger && managedToolNames.has(READ_TOOL_NAME)) {
10539
+ const overwrittenReadDenies = current.deny.filter((entry) => toolNameOf(entry) === READ_TOOL_NAME);
10540
+ if (overwrittenReadDenies.length > 0) logger.warn(`Permissions feature manages '${READ_TOOL_NAME}' tool and will overwrite ${overwrittenReadDenies.length} existing ${READ_TOOL_NAME} deny entries. Permissions take precedence.`);
10541
+ }
10542
+ return withPermissions(settings, {
10543
+ allow: uniq([...keepUnmanaged(current.allow), ...allow].toSorted()),
10544
+ ask: uniq([...keepUnmanaged(current.ask), ...ask].toSorted()),
10545
+ deny: uniq([...keepUnmanaged(current.deny), ...deny].toSorted())
10546
+ });
10547
+ };
10548
+ //#endregion
10465
10549
  //#region src/features/ignore/claudecode-ignore.ts
10466
10550
  const DEFAULT_FILE_MODE = "shared";
10467
10551
  /**
@@ -10508,7 +10592,7 @@ var ClaudecodeIgnore = class ClaudecodeIgnore extends ToolIgnore {
10508
10592
  }
10509
10593
  toRulesyncIgnore() {
10510
10594
  const fileContent = this.patterns.map((pattern) => {
10511
- if (pattern.startsWith("Read(") && pattern.endsWith(")")) return pattern.slice(5, -1);
10595
+ if (isReadDenyEntry(pattern)) return pattern.slice(5, -1);
10512
10596
  return pattern;
10513
10597
  }).filter((pattern) => pattern.length > 0).join("\n");
10514
10598
  return new RulesyncIgnore({
@@ -10519,22 +10603,20 @@ var ClaudecodeIgnore = class ClaudecodeIgnore extends ToolIgnore {
10519
10603
  });
10520
10604
  }
10521
10605
  static async fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore, options }) {
10522
- const deniedValues = rulesyncIgnore.getFileContent().split(/\r?\n|\r/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).map((pattern) => `Read(${pattern})`);
10606
+ const deniedValues = rulesyncIgnore.getFileContent().split(/\r?\n|\r/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).map((pattern) => buildReadDenyEntry(pattern));
10523
10607
  const paths = this.getSettablePaths({ options });
10524
10608
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
10525
10609
  const existingFileContent = await fileExists(filePath) ? await readFileContent(filePath) : "{}";
10526
- const existingJsonValue = JSON.parse(existingFileContent);
10527
- const preservedDenies = (existingJsonValue.permissions?.deny ?? []).filter((deny) => {
10528
- if (deny.startsWith("Read(") && deny.endsWith(")")) return deniedValues.includes(deny);
10529
- return true;
10610
+ let existingJsonValue;
10611
+ try {
10612
+ existingJsonValue = JSON.parse(existingFileContent);
10613
+ } catch (error) {
10614
+ throw new Error(`Failed to parse existing Claude settings at ${filePath}: ${formatError(error)}`, { cause: error });
10615
+ }
10616
+ const jsonValue = applyIgnoreReadDenies({
10617
+ settings: existingJsonValue,
10618
+ readDenies: deniedValues
10530
10619
  });
10531
- const jsonValue = {
10532
- ...existingJsonValue,
10533
- permissions: {
10534
- ...existingJsonValue.permissions,
10535
- deny: uniq([...preservedDenies, ...deniedValues].toSorted())
10536
- }
10537
- };
10538
10620
  return new ClaudecodeIgnore({
10539
10621
  outputRoot,
10540
10622
  relativeDirPath: paths.relativeDirPath,
@@ -12221,6 +12303,38 @@ const RULESYNC_TO_CODEX_FIELD_MAP = {
12221
12303
  envVars: "env_vars"
12222
12304
  };
12223
12305
  const MAX_REMOVE_EMPTY_ENTRIES_DEPTH$1 = 32;
12306
+ /**
12307
+ * Translate a server's `oauth` table from the canonical rulesync shape (Claude
12308
+ * Code style camelCase) into the shape Codex CLI understands. Codex expects the
12309
+ * OAuth client id under snake_case `client_id`; without it `codex mcp login`
12310
+ * falls back to dynamic client registration and fails for providers that do not
12311
+ * support it (e.g. Slack, see #2158). The canonical `clientId` is kept alongside
12312
+ * the added `client_id` so tools that read the camelCase shape keep working and
12313
+ * the round-trip stays stable.
12314
+ */
12315
+ function mapOauthToCodex(oauth) {
12316
+ const result = omitPrototypePollutionKeys(oauth);
12317
+ if (typeof oauth["clientId"] === "string" && !("client_id" in result)) result["client_id"] = oauth["clientId"];
12318
+ return result;
12319
+ }
12320
+ /**
12321
+ * Reverse of {@link mapOauthToCodex}: collapse Codex's `oauth.client_id` back to
12322
+ * the canonical `clientId` on import. When both keys are present (the shape
12323
+ * rulesync itself emits) the canonical `clientId` wins and `client_id` is
12324
+ * dropped so a subsequent generate does not accumulate duplicates.
12325
+ */
12326
+ function mapOauthFromCodex(oauth) {
12327
+ const result = {};
12328
+ for (const [key, value] of Object.entries(oauth)) {
12329
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
12330
+ if (key === "client_id") {
12331
+ if (!("clientId" in oauth)) result["clientId"] = value;
12332
+ continue;
12333
+ }
12334
+ result[key] = value;
12335
+ }
12336
+ return result;
12337
+ }
12224
12338
  function convertFromCodexFormat(codexMcp) {
12225
12339
  const result = {};
12226
12340
  for (const [name, config] of Object.entries(codexMcp)) {
@@ -12230,7 +12344,8 @@ function convertFromCodexFormat(codexMcp) {
12230
12344
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
12231
12345
  if (key === "enabled") {
12232
12346
  if (value === false) converted["disabled"] = true;
12233
- } else if (Object.hasOwn(CODEX_TO_RULESYNC_FIELD_MAP, key)) {
12347
+ } else if (key === "oauth" && isRecord(value)) converted[key] = mapOauthFromCodex(value);
12348
+ else if (Object.hasOwn(CODEX_TO_RULESYNC_FIELD_MAP, key)) {
12234
12349
  const mappedKey = CODEX_TO_RULESYNC_FIELD_MAP[key];
12235
12350
  if (mappedKey) if (isStringArray(value)) converted[mappedKey] = value;
12236
12351
  else warnWithFallback(void 0, `Ignored malformed array for ${key} in MCP server ${name}`);
@@ -12250,7 +12365,8 @@ function convertToCodexFormat(mcpServers) {
12250
12365
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
12251
12366
  if (key === "disabled") {
12252
12367
  if (value === true) converted["enabled"] = false;
12253
- } else if (Object.hasOwn(RULESYNC_TO_CODEX_FIELD_MAP, key)) {
12368
+ } else if (key === "oauth" && isRecord(value)) converted[key] = mapOauthToCodex(value);
12369
+ else if (Object.hasOwn(RULESYNC_TO_CODEX_FIELD_MAP, key)) {
12254
12370
  const mappedKey = RULESYNC_TO_CODEX_FIELD_MAP[key];
12255
12371
  if (mappedKey) if (isStringArray(value)) converted[mappedKey] = value;
12256
12372
  else warnWithFallback(void 0, `[CodexCliMcp] Skipping invalid value type for mapped key '${key}': expected string array, got ${typeof value}`);
@@ -13466,10 +13582,12 @@ function resolveHermesTimeout(config) {
13466
13582
  *
13467
13583
  * Hermes is close to the MCP spec but not identical: `command` must be a single
13468
13584
  * executable string (an array's tail folds into `args`), a server is disabled
13469
- * via `enabled: false` (not the canonical `disabled: true`), and remote servers
13470
- * use `url`/`headers`. Only fields Hermes understands are emitted, so the shared
13471
- * `config.yaml` is not polluted with canonical-only aliases (`type`, `transport`,
13472
- * `httpUrl`, `networkTimeout`, tool-filter keys, ...).
13585
+ * via `enabled: false` (not the canonical `disabled: true`), remote servers use
13586
+ * `url`/`headers`, and per-server tool scoping lives under a `tools: { include,
13587
+ * exclude }` block (from the canonical `enabledTools`/`disabledTools`). Only
13588
+ * fields Hermes understands are emitted, so the shared `config.yaml` is not
13589
+ * polluted with canonical-only aliases (`type`, `transport`, `httpUrl`,
13590
+ * `networkTimeout`, ...).
13473
13591
  */
13474
13592
  function convertServerToHermes(config) {
13475
13593
  const out = {};
@@ -13493,6 +13611,10 @@ function convertServerToHermes(config) {
13493
13611
  if (config.disabled === true) out.enabled = false;
13494
13612
  const timeout = resolveHermesTimeout(config);
13495
13613
  if (timeout !== void 0) out.timeout = timeout;
13614
+ const tools = {};
13615
+ if (isStringArray(config.enabledTools)) tools.include = config.enabledTools;
13616
+ if (isStringArray(config.disabledTools)) tools.exclude = config.disabledTools;
13617
+ if (Object.keys(tools).length > 0) out.tools = tools;
13496
13618
  return out;
13497
13619
  }
13498
13620
  /**
@@ -13534,6 +13656,10 @@ function convertFromHermesFormat(mcpServers) {
13534
13656
  if (isPlainObject(config.headers)) server.headers = omitPrototypePollutionKeys(config.headers);
13535
13657
  if (config.enabled === false) server.disabled = true;
13536
13658
  if (typeof config.timeout === "number") server.networkTimeout = config.timeout;
13659
+ if (isRecord(config.tools)) {
13660
+ if (isStringArray(config.tools.include)) server.enabledTools = config.tools.include;
13661
+ if (isStringArray(config.tools.exclude)) server.disabledTools = config.tools.exclude;
13662
+ }
13537
13663
  result[name] = server;
13538
13664
  }
13539
13665
  return result;
@@ -15689,17 +15815,232 @@ const PermissionActionSchema = z.enum([
15689
15815
  */
15690
15816
  const PermissionRulesSchema = z.record(z.string(), PermissionActionSchema);
15691
15817
  /**
15818
+ * OpenCode-specific permission value. Unlike the shared canonical block, which
15819
+ * only accepts a pattern-to-action map, OpenCode also allows a bare action
15820
+ * string that applies to the whole category (e.g. `"external_directory": "deny"`).
15821
+ * This mirrors OpenCode's own permission schema in `opencode-permissions.ts`.
15822
+ */
15823
+ const OpencodeOverridePermissionValueSchema = z.union([PermissionActionSchema, PermissionRulesSchema]);
15824
+ /**
15825
+ * Tool-scoped override block for OpenCode. Permission categories placed here
15826
+ * (e.g. OpenCode-only categories such as `external_directory`) are emitted only
15827
+ * into OpenCode's config and never leak into other tools' permission files. It
15828
+ * also lets a shared category be overridden with an OpenCode-specific value.
15829
+ * Kept `looseObject` so future OpenCode categories are accepted.
15830
+ *
15831
+ * @example
15832
+ * { "permission": { "external_directory": "deny", "webfetch": "allow" } }
15833
+ */
15834
+ const OpencodePermissionsOverrideSchema = z.looseObject({ permission: z.optional(z.record(z.string(), OpencodeOverridePermissionValueSchema)) });
15835
+ /**
15836
+ * Tool-scoped override block for Hermes Agent. Keys placed here are deep-merged
15837
+ * into Hermes's `~/.hermes/config.yaml` and never leak into other tools' configs.
15838
+ * It carries Hermes-specific approval/security controls that have no canonical
15839
+ * permission category — e.g. `approvals` (`mode`, `cron_mode`, ...),
15840
+ * `security` (`allow_private_urls`, ...), `skills.write_approval`,
15841
+ * `memory.write_approval`. Kept `looseObject` (a verbatim passthrough) so any
15842
+ * current or future Hermes config key can be authored without modeling each one.
15843
+ *
15844
+ * @example
15845
+ * { "approvals": { "mode": "smart" }, "security": { "allow_private_urls": false } }
15846
+ */
15847
+ const HermesPermissionsOverrideSchema = z.looseObject({});
15848
+ /**
15849
+ * Tool-scoped override block for Cline. Cline's `command-permissions.json`
15850
+ * carries a single global `allowRedirects` boolean (gates shell redirection
15851
+ * operators `>`/`>>`/`<`) that has no per-command dimension and therefore no
15852
+ * canonical permission category. Placing it here lets users author it
15853
+ * declaratively; it is emitted only into Cline's config. Kept `looseObject` so
15854
+ * future Cline-only knobs can be added.
15855
+ *
15856
+ * @example
15857
+ * { "allowRedirects": true }
15858
+ */
15859
+ const ClinePermissionsOverrideSchema = z.looseObject({ allowRedirects: z.optional(z.boolean()) });
15860
+ /**
15861
+ * Tool-scoped override block for Kilo Code (an OpenCode fork). Kilo's permission
15862
+ * object is a free-form record with tool-specific keys that have no canonical
15863
+ * category — OpenCode-inherited ones (`external_directory`, `doom_loop`, `lsp`,
15864
+ * `question`, `todowrite`, `skill`, `task`, `list`) and Kilo-unique ones
15865
+ * (`agent_manager`, `notebook_read`, `notebook_edit`, `notebook_execute`,
15866
+ * `repo_clone`, `repo_overview`). Placing them here makes them authorable and
15867
+ * portable and keeps them out of other tools' configs. Mirrors the OpenCode
15868
+ * override; each value may be a bare action string or a pattern map.
15869
+ *
15870
+ * @example
15871
+ * { "permission": { "external_directory": "deny", "doom_loop": "ask" } }
15872
+ */
15873
+ const KiloPermissionsOverrideSchema = z.looseObject({ permission: z.optional(z.record(z.string(), OpencodeOverridePermissionValueSchema)) });
15874
+ /**
15875
+ * Tool-scoped override block for Claude Code. Claude Code's `permissions` object
15876
+ * (in `.claude/settings.json`) carries non-list fields that have no canonical
15877
+ * permission category — `defaultMode` (the session-start permission mode) and
15878
+ * `additionalDirectories` (extra working directories) being the primary ones.
15879
+ * Fields placed under `claudecode.permissions` are merged into the settings
15880
+ * `permissions` object and emitted only for Claude Code, while the shared
15881
+ * `permission` block continues to drive the `allow`/`ask`/`deny` arrays. Kept a
15882
+ * `looseObject` passthrough so any current or future `permissions` field can be
15883
+ * authored without modeling each one; the managed `allow`/`ask`/`deny` arrays are
15884
+ * ignored here (rulesync owns them).
15885
+ *
15886
+ * @example
15887
+ * { "permissions": { "defaultMode": "acceptEdits", "additionalDirectories": ["../shared"] } }
15888
+ */
15889
+ const ClaudecodePermissionsOverrideSchema = z.looseObject({ permissions: z.optional(z.looseObject({})) });
15890
+ /**
15891
+ * Tool-scoped override block for Mistral Vibe. Vibe's per-tool `BaseToolConfig`
15892
+ * carries a `sensitive_patterns` list — patterns that escalate to ASK even when
15893
+ * the tool's base permission is ALWAYS (allow). The canonical model can only set
15894
+ * a pattern to a single `allow`/`ask`/`deny`, so an "allow by default but ask on
15895
+ * these patterns" escalation cannot be expressed. Entries under
15896
+ * `vibe.permission.<category>.sensitive_patterns` carry that list per canonical
15897
+ * category; the shared `permission` block still drives the base permission and
15898
+ * allow/deny lists. Keyed by canonical category (e.g. `bash`, `edit`).
15899
+ *
15900
+ * @example
15901
+ * { "permission": { "bash": { "sensitive_patterns": ["rm *", "sudo *"] } } }
15902
+ */
15903
+ const VibePermissionsOverrideSchema = z.looseObject({ permission: z.optional(z.record(z.string(), z.looseObject({ sensitive_patterns: z.optional(z.array(z.string())) }))) });
15904
+ /**
15905
+ * Tool-scoped override block for Cursor CLI. Cursor's `cli.json` carries scalar
15906
+ * autonomy settings with no canonical permission category — `approvalMode`
15907
+ * (`allowlist` | `auto-review` | `unrestricted`) and a `sandbox` object
15908
+ * (`mode`/`networkAccess`). Fields placed here are merged into the top-level of
15909
+ * `.cursor/cli.json` (project) / `~/.cursor/cli-config.json` (global) and emitted
15910
+ * only for Cursor, while the shared `permission` block continues to drive the
15911
+ * `permissions.allow`/`permissions.deny` arrays. Kept a `looseObject` so extra
15912
+ * `cli.json` keys can be authored (they are merged verbatim on generate);
15913
+ * `sandbox`'s accepted values are not documented so it passes through verbatim.
15914
+ * Note: only `approvalMode` and `sandbox` round-trip back on import — other keys
15915
+ * authored here reach `cli.json` on generate but are not re-extracted.
15916
+ *
15917
+ * @example
15918
+ * { "approvalMode": "auto-review" }
15919
+ */
15920
+ const CursorPermissionsOverrideSchema = z.looseObject({
15921
+ approvalMode: z.optional(z.string()),
15922
+ sandbox: z.optional(z.looseObject({}))
15923
+ });
15924
+ /**
15925
+ * Tool-scoped override block for Qwen Code. Qwen's `settings.json` exposes
15926
+ * autonomy/sandbox controls with no canonical permission category — under
15927
+ * `tools` (`approvalMode` = plan/default/auto-edit/auto/yolo, `autoAccept`,
15928
+ * `sandbox`, `sandboxImage`, `disabled`) and `security` (`folderTrust`). Fields
15929
+ * placed here are merged into the matching `settings.json` group and emitted
15930
+ * only for Qwen, while the shared `permission` block continues to drive the
15931
+ * `permissions.allow`/`ask`/`deny` arrays. Kept `looseObject` (verbatim
15932
+ * passthrough) so any current or future `tools`/`security` key can be authored.
15933
+ *
15934
+ * @example
15935
+ * { "tools": { "approvalMode": "auto-edit" }, "security": { "folderTrust": { "enabled": true } } }
15936
+ */
15937
+ const QwencodePermissionsOverrideSchema = z.looseObject({
15938
+ tools: z.optional(z.looseObject({})),
15939
+ security: z.optional(z.looseObject({}))
15940
+ });
15941
+ /**
15942
+ * Tool-scoped override block for Reasonix. Reasonix has security axes orthogonal
15943
+ * to per-tool allow/ask/deny with no canonical category — the `[sandbox]`
15944
+ * enforcement table (`workspace_root`, `allow_write`, `forbid_read`, `bash`,
15945
+ * `network`) and plan-mode read-only trust lists under `[agent]`
15946
+ * (`plan_mode_allowed_tools`, `plan_mode_read_only_commands`). Fields placed here
15947
+ * are merged into the matching `reasonix.toml` table and emitted only for
15948
+ * Reasonix, while the shared `permission` block continues to drive
15949
+ * `[permissions].allow`/`ask`/`deny`. Kept `looseObject` (verbatim passthrough).
15950
+ * Note: the whole `[sandbox]` table round-trips, but only the plan-mode keys are
15951
+ * re-extracted from `[agent]` on import — other `agent` keys authored here reach
15952
+ * `reasonix.toml` on generate but are not re-extracted back into the override.
15953
+ *
15954
+ * @example
15955
+ * { "sandbox": { "bash": "enforce", "network": false }, "agent": { "plan_mode_read_only_commands": ["gh pr diff"] } }
15956
+ */
15957
+ const ReasonixPermissionsOverrideSchema = z.looseObject({
15958
+ sandbox: z.optional(z.looseObject({})),
15959
+ agent: z.optional(z.looseObject({}))
15960
+ });
15961
+ /**
15962
+ * Tool-scoped override block for Factory Droid. Factory Droid's `settings.json`
15963
+ * exposes security controls with no canonical per-command allow/ask/deny slot —
15964
+ * `commandBlocklist` (a hard-block tier that can never be approved, distinct from
15965
+ * an approvable `deny`), `networkPolicy` (`allowedIps`), `sandbox`
15966
+ * (`enabled`/`mode`/`filesystem`/`network`), `mcpPolicy`, `enableDroidShield`,
15967
+ * and autonomy settings (`sessionDefaultSettings`, `maxAutonomyLevel`,
15968
+ * `interactionMode`). Fields placed here are merged into `settings.json` and
15969
+ * emitted only for Factory Droid, while the shared `permission` block continues
15970
+ * to drive `commandAllowlist`/`commandDenylist`. Kept `looseObject` passthrough.
15971
+ *
15972
+ * @example
15973
+ * { "commandBlocklist": ["rm -rf /*"], "sandbox": { "enabled": true } }
15974
+ */
15975
+ const FactorydroidPermissionsOverrideSchema = z.looseObject({ commandBlocklist: z.optional(z.array(z.string())) });
15976
+ /**
15977
+ * Tool-scoped override block for Warp. Warp's `[agents.profiles]` table exposes
15978
+ * file-read/read-only autonomy keys with no canonical per-command allow/ask/deny
15979
+ * slot — `agent_mode_coding_permissions`
15980
+ * (`always_ask_before_reading` | `always_allow_reading` | `allow_reading_specific_files`),
15981
+ * `agent_mode_coding_file_read_allowlist` (a path array), and
15982
+ * `agent_mode_execute_readonly_commands` (a read-only auto-execution boolean).
15983
+ * Fields placed here are merged into `[agents.profiles]` of Warp's global
15984
+ * `settings.toml`, while the shared `permission` block continues to drive the
15985
+ * `agent_mode_command_execution_allowlist`/`_denylist` command regex arrays.
15986
+ * Warp permissions are global-only.
15987
+ *
15988
+ * @example
15989
+ * { "agent_mode_coding_permissions": "always_allow_reading", "agent_mode_execute_readonly_commands": true }
15990
+ */
15991
+ const WarpPermissionsOverrideSchema = z.looseObject({
15992
+ agent_mode_coding_permissions: z.optional(z.string()),
15993
+ agent_mode_coding_file_read_allowlist: z.optional(z.array(z.string())),
15994
+ agent_mode_execute_readonly_commands: z.optional(z.boolean())
15995
+ });
15996
+ /**
15997
+ * Tool-scoped override block for JetBrains Junie. Junie's `allowlist.json` has
15998
+ * two top-level autonomy knobs with no canonical per-glob slot:
15999
+ * `allowReadonlyCommands` (a boolean auto-allowing read-only commands) and
16000
+ * `defaultBehavior` (the fallback action applied when no rule matches — Junie
16001
+ * documents only `allow`/`ask`). Fields placed here are merged onto the
16002
+ * top level of `allowlist.json`, while the shared `permission` block continues
16003
+ * to drive the per-category `rules` groups.
16004
+ *
16005
+ * @example
16006
+ * { "allowReadonlyCommands": true, "defaultBehavior": "ask" }
16007
+ */
16008
+ const JuniePermissionsOverrideSchema = z.looseObject({
16009
+ allowReadonlyCommands: z.optional(z.boolean()),
16010
+ defaultBehavior: z.optional(z.string())
16011
+ });
16012
+ /**
15692
16013
  * Permissions configuration.
15693
16014
  * Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
15694
16015
  * Values are pattern-to-action mappings for that tool category.
15695
16016
  *
16017
+ * The optional `opencode`/`hermes`/`cline`/`kilo`/`claudecode`/`vibe`/`cursor`/
16018
+ * `qwencode`/`reasonix`/`factorydroid`/`warp`/`junie` keys are tool-scoped
16019
+ * overrides consumed only by their respective translator (see the matching
16020
+ * `*PermissionsOverrideSchema`); every other tool reads the shared `permission`
16021
+ * block and ignores them.
16022
+ *
15696
16023
  * @example
15697
16024
  * {
15698
16025
  * "bash": { "*": "ask", "git *": "allow", "rm *": "deny" },
15699
16026
  * "edit": { "*": "deny", "src/**": "allow" }
15700
16027
  * }
15701
16028
  */
15702
- const PermissionsConfigSchema = z.looseObject({ permission: z.record(z.string(), PermissionRulesSchema) });
16029
+ const PermissionsConfigSchema = z.looseObject({
16030
+ permission: z.record(z.string(), PermissionRulesSchema),
16031
+ opencode: z.optional(OpencodePermissionsOverrideSchema),
16032
+ hermes: z.optional(HermesPermissionsOverrideSchema),
16033
+ cline: z.optional(ClinePermissionsOverrideSchema),
16034
+ kilo: z.optional(KiloPermissionsOverrideSchema),
16035
+ claudecode: z.optional(ClaudecodePermissionsOverrideSchema),
16036
+ vibe: z.optional(VibePermissionsOverrideSchema),
16037
+ cursor: z.optional(CursorPermissionsOverrideSchema),
16038
+ qwencode: z.optional(QwencodePermissionsOverrideSchema),
16039
+ reasonix: z.optional(ReasonixPermissionsOverrideSchema),
16040
+ factorydroid: z.optional(FactorydroidPermissionsOverrideSchema),
16041
+ warp: z.optional(WarpPermissionsOverrideSchema),
16042
+ junie: z.optional(JuniePermissionsOverrideSchema)
16043
+ });
15703
16044
  /**
15704
16045
  * Full permissions file schema including optional $schema field.
15705
16046
  */
@@ -17070,32 +17411,24 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
17070
17411
  }
17071
17412
  const config = rulesyncPermissions.getJson();
17072
17413
  const { allow, ask, deny } = convertRulesyncToClaudePermissions(config);
17073
- const managedToolNames = new Set(Object.keys(config.permission).map((category) => toClaudeToolName(category)));
17074
- const existingPermissions = settings.permissions ?? {};
17075
- const preservedAllow = (existingPermissions.allow ?? []).filter((entry) => !managedToolNames.has(parseClaudePermissionEntry(entry).toolName));
17076
- const preservedAsk = (existingPermissions.ask ?? []).filter((entry) => !managedToolNames.has(parseClaudePermissionEntry(entry).toolName));
17077
- const preservedDeny = (existingPermissions.deny ?? []).filter((entry) => !managedToolNames.has(parseClaudePermissionEntry(entry).toolName));
17078
- if (logger && managedToolNames.has("Read")) {
17079
- const droppedReadDenyEntries = (existingPermissions.deny ?? []).filter((entry) => {
17080
- const { toolName } = parseClaudePermissionEntry(entry);
17081
- return toolName === "Read";
17082
- });
17083
- if (droppedReadDenyEntries.length > 0) logger.warn(`Permissions feature manages 'Read' tool and will overwrite ${droppedReadDenyEntries.length} existing Read deny entries (possibly from ignore feature). Permissions take precedence.`);
17414
+ const overridePermissions = config.claudecode?.permissions;
17415
+ if (overridePermissions && typeof overridePermissions === "object") {
17416
+ const { allow: _a, ask: _k, deny: _d, ...nonListFields } = overridePermissions;
17417
+ settings.permissions = {
17418
+ ...settings.permissions,
17419
+ ...nonListFields
17420
+ };
17084
17421
  }
17085
- const mergedPermissions = { ...existingPermissions };
17086
- const mergedAllow = uniq([...preservedAllow, ...allow].toSorted());
17087
- const mergedAsk = uniq([...preservedAsk, ...ask].toSorted());
17088
- const mergedDeny = uniq([...preservedDeny, ...deny].toSorted());
17089
- if (mergedAllow.length > 0) mergedPermissions.allow = mergedAllow;
17090
- else delete mergedPermissions.allow;
17091
- if (mergedAsk.length > 0) mergedPermissions.ask = mergedAsk;
17092
- else delete mergedPermissions.ask;
17093
- if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
17094
- else delete mergedPermissions.deny;
17095
- const merged = {
17096
- ...settings,
17097
- permissions: mergedPermissions
17098
- };
17422
+ const managedToolNames = new Set(Object.keys(config.permission).map((category) => toClaudeToolName(category)));
17423
+ const merged = applyPermissions({
17424
+ settings,
17425
+ managedToolNames,
17426
+ toolNameOf: (entry) => parseClaudePermissionEntry(entry).toolName,
17427
+ allow,
17428
+ ask,
17429
+ deny,
17430
+ logger
17431
+ });
17099
17432
  const fileContent = JSON.stringify(merged, null, 2);
17100
17433
  return new ClaudecodePermissions({
17101
17434
  outputRoot,
@@ -17118,6 +17451,8 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
17118
17451
  ask: permissions.ask ?? [],
17119
17452
  deny: permissions.deny ?? []
17120
17453
  });
17454
+ const { allow: _a, ask: _k, deny: _d, ...nonListFields } = permissions;
17455
+ if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
17121
17456
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
17122
17457
  }
17123
17458
  validate() {
@@ -17291,7 +17626,8 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
17291
17626
  } catch (error) {
17292
17627
  throw new Error(`Failed to parse existing Cline command-permissions at ${filePath}: ${formatError(error)}`, { cause: error });
17293
17628
  }
17294
- const { allow, deny, droppedCategories, translatedAskPatterns } = translateClinePermissions(rulesyncPermissions.getJson().permission);
17629
+ const config = rulesyncPermissions.getJson();
17630
+ const { allow, deny, droppedCategories, translatedAskPatterns } = translateClinePermissions(config.permission);
17295
17631
  warnClineTranslationNotices({
17296
17632
  droppedCategories,
17297
17633
  translatedAskPatterns,
@@ -17307,7 +17643,7 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
17307
17643
  ...existing,
17308
17644
  allow: dedupedAllow,
17309
17645
  deny: mergedDeny,
17310
- allowRedirects: existing.allowRedirects ?? false
17646
+ allowRedirects: config.cline?.allowRedirects ?? existing.allowRedirects ?? false
17311
17647
  };
17312
17648
  return new ClinePermissions({
17313
17649
  outputRoot,
@@ -17331,6 +17667,7 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
17331
17667
  for (const pattern of parsed.allow ?? []) bashRules[pattern] = "allow";
17332
17668
  for (const pattern of parsed.deny ?? []) bashRules[pattern] = "deny";
17333
17669
  const config = Object.keys(bashRules).length > 0 ? { permission: { bash: bashRules } } : { permission: {} };
17670
+ if (parsed.allowRedirects === true) config.cline = { allowRedirects: true };
17334
17671
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
17335
17672
  }
17336
17673
  validate() {
@@ -17767,13 +18104,13 @@ const CURSOR_TYPE_TO_CANONICAL = {
17767
18104
  WebFetch: "webfetch",
17768
18105
  Mcp: "mcp"
17769
18106
  };
17770
- const MCP_CANONICAL_PREFIX = "mcp__";
18107
+ const MCP_CANONICAL_PREFIX$1 = "mcp__";
17771
18108
  /**
17772
18109
  * Returns true if the canonical category is the per-tool MCP form
17773
18110
  * `mcp__<server>__<tool>`.
17774
18111
  */
17775
18112
  function isMcpScopedCategory(canonical) {
17776
- return canonical.startsWith(MCP_CANONICAL_PREFIX) && canonical.length > 5;
18113
+ return canonical.startsWith(MCP_CANONICAL_PREFIX$1) && canonical.length > 5;
17777
18114
  }
17778
18115
  function toCursorType(canonical) {
17779
18116
  if (isMcpScopedCategory(canonical)) return "Mcp";
@@ -17804,7 +18141,7 @@ function toCursorPattern(canonical, pattern) {
17804
18141
  function toCanonicalCategory$1(cursorType, pattern) {
17805
18142
  if (cursorType === "Mcp") {
17806
18143
  const match = pattern.match(/^([^:()]+):([^:()]+)$/);
17807
- if (match) return `${MCP_CANONICAL_PREFIX}${match[1] ?? "*"}__${match[2] ?? "*"}`;
18144
+ if (match) return `${MCP_CANONICAL_PREFIX$1}${match[1] ?? "*"}__${match[2] ?? "*"}`;
17808
18145
  return CURSOR_TYPE_TO_CANONICAL[cursorType] ?? cursorType.toLowerCase();
17809
18146
  }
17810
18147
  return CURSOR_TYPE_TO_CANONICAL[cursorType] ?? cursorType.toLowerCase();
@@ -17938,8 +18275,10 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
17938
18275
  mergedPermissions.allow = mergedAllow;
17939
18276
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
17940
18277
  else delete mergedPermissions.deny;
18278
+ const cursorOverride = config.cursor;
17941
18279
  const merged = {
17942
18280
  ...settings,
18281
+ ...cursorOverride,
17943
18282
  version: settings.version ?? 1,
17944
18283
  editor: {
17945
18284
  ...existingEditor,
@@ -17965,11 +18304,15 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
17965
18304
  }
17966
18305
  const permissionsRaw = settings.permissions;
17967
18306
  const permissions = permissionsRaw !== null && typeof permissionsRaw === "object" && !Array.isArray(permissionsRaw) ? permissionsRaw : {};
17968
- const config = convertCursorToRulesyncPermissions({
18307
+ const result = { ...convertCursorToRulesyncPermissions({
17969
18308
  allow: asCursorPermissionEntryArray(permissions.allow),
17970
18309
  deny: asCursorPermissionEntryArray(permissions.deny)
17971
- });
17972
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
18310
+ }) };
18311
+ const cursorOverride = {};
18312
+ if (settings.approvalMode !== void 0) cursorOverride.approvalMode = settings.approvalMode;
18313
+ if (settings.sandbox !== null && typeof settings.sandbox === "object" && !Array.isArray(settings.sandbox)) cursorOverride.sandbox = settings.sandbox;
18314
+ if (Object.keys(cursorOverride).length > 0) result.cursor = cursorOverride;
18315
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
17973
18316
  }
17974
18317
  validate() {
17975
18318
  return {
@@ -18028,7 +18371,7 @@ function convertCursorToRulesyncPermissions(params) {
18028
18371
  const { type, pattern } = parseCursorPermissionEntry(entry);
18029
18372
  const canonical = toCanonicalCategory$1(type, pattern);
18030
18373
  if (!permission[canonical]) permission[canonical] = {};
18031
- const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX) ? "*" : pattern;
18374
+ const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX$1) ? "*" : pattern;
18032
18375
  permission[canonical][canonicalPattern] = action;
18033
18376
  }
18034
18377
  };
@@ -18277,6 +18620,16 @@ function convertDevinToRulesyncPermissions(params) {
18277
18620
  }
18278
18621
  //#endregion
18279
18622
  //#region src/features/permissions/factorydroid-permissions.ts
18623
+ const FACTORYDROID_OVERRIDE_KEYS = [
18624
+ "commandBlocklist",
18625
+ "networkPolicy",
18626
+ "sandbox",
18627
+ "mcpPolicy",
18628
+ "enableDroidShield",
18629
+ "sessionDefaultSettings",
18630
+ "maxAutonomyLevel",
18631
+ "interactionMode"
18632
+ ];
18280
18633
  /**
18281
18634
  * Permissions adapter for Factory Droid.
18282
18635
  *
@@ -18294,18 +18647,14 @@ function convertDevinToRulesyncPermissions(params) {
18294
18647
  * skipped (with a warning when they carry `deny` rules, to surface the gap).
18295
18648
  *
18296
18649
  * Factory Droid also has a stronger `commandBlocklist` tier — commands that can
18297
- * never run, not even under full autonomy. rulesync's canonical action model
18298
- * has only `allow | ask | deny`, with no equivalent of a hard block that can
18299
- * never be approved. So on **import** a `commandBlocklist` entry is collapsed
18300
- * onto canonical `deny` (lossy: the never-runs guarantee is weakened to a deny
18301
- * the user can still approve), rather than being silently dropped. On **export**
18302
- * there is no canonical `block` to emit one from, so rulesync never writes
18303
- * `commandBlocklist`; an existing one on disk is preserved verbatim as an
18304
- * unmanaged key. (A consequence of the lossy collapse: importing a
18305
- * `commandBlocklist` and re-exporting it to a *fresh* config writes it back as
18306
- * `commandDenylist`, not `commandBlocklist` — the hard-block tier is not
18307
- * reconstructed. Re-running over the original file keeps it intact via the
18308
- * verbatim preservation above.)
18650
+ * never run, not even under full autonomy plus other security controls
18651
+ * (`networkPolicy`, `sandbox`, `mcpPolicy`, `enableDroidShield`, autonomy
18652
+ * settings) that do not fit the canonical `allow | ask | deny` per-command
18653
+ * model. These are authored and round-tripped through the `factorydroid`
18654
+ * override namespace (see `FactorydroidPermissionsOverrideSchema`): on **import**
18655
+ * they are lifted from `settings.json` into the override, and on **export** they
18656
+ * are merged back in so `commandBlocklist`'s never-runs guarantee is preserved
18657
+ * faithfully rather than being collapsed onto an approvable `deny`.
18309
18658
  */
18310
18659
  var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissions {
18311
18660
  constructor(params) {
@@ -18348,11 +18697,16 @@ var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissi
18348
18697
  } catch (error) {
18349
18698
  throw new Error(`Failed to parse existing Factory Droid settings at ${filePath}: ${formatError(error)}`, { cause: error });
18350
18699
  }
18700
+ const config = rulesyncPermissions.getJson();
18351
18701
  const { allow, deny } = convertRulesyncToFactorydroidPermissions({
18352
- config: rulesyncPermissions.getJson(),
18702
+ config,
18353
18703
  logger
18354
18704
  });
18355
- const merged = { ...settings };
18705
+ const override = config.factorydroid;
18706
+ const merged = {
18707
+ ...settings,
18708
+ ...override !== void 0 && typeof override === "object" ? override : {}
18709
+ };
18356
18710
  const mergedAllow = uniq(allow.toSorted());
18357
18711
  const mergedDeny = uniq(deny.toSorted());
18358
18712
  if (mergedAllow.length > 0) merged.commandAllowlist = mergedAllow;
@@ -18376,10 +18730,13 @@ var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissi
18376
18730
  }
18377
18731
  const config = convertFactorydroidToRulesyncPermissions({
18378
18732
  allow: Array.isArray(settings.commandAllowlist) ? settings.commandAllowlist : [],
18379
- deny: Array.isArray(settings.commandDenylist) ? settings.commandDenylist : [],
18380
- block: Array.isArray(settings.commandBlocklist) ? settings.commandBlocklist : []
18733
+ deny: Array.isArray(settings.commandDenylist) ? settings.commandDenylist : []
18381
18734
  });
18382
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
18735
+ const factorydroidOverride = {};
18736
+ for (const key of FACTORYDROID_OVERRIDE_KEYS) if (settings[key] !== void 0) factorydroidOverride[key] = settings[key];
18737
+ const result = { ...config };
18738
+ if (Object.keys(factorydroidOverride).length > 0) result.factorydroid = factorydroidOverride;
18739
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
18383
18740
  }
18384
18741
  validate() {
18385
18742
  return {
@@ -18426,18 +18783,18 @@ function convertRulesyncToFactorydroidPermissions({ config, logger }) {
18426
18783
  };
18427
18784
  }
18428
18785
  /**
18429
- * Convert Factory Droid allow/deny/block command lists back to rulesync config
18430
- * under the `bash` category.
18786
+ * Convert Factory Droid allow/deny command lists back to rulesync config under
18787
+ * the `bash` category.
18431
18788
  *
18432
- * `commandBlocklist` (hard block) has no canonical equivalent, so it collapses
18433
- * onto `deny` lossy (a deny can still be approved), but preferable to dropping
18434
- * the rule entirely.
18789
+ * `commandBlocklist` (the hard-block tier) is no longer collapsed here it has
18790
+ * no canonical equivalent and now round-trips through the `factorydroid`
18791
+ * override so the never-runs guarantee is preserved instead of being weakened to
18792
+ * an approvable `deny`.
18435
18793
  */
18436
- function convertFactorydroidToRulesyncPermissions({ allow, deny, block }) {
18794
+ function convertFactorydroidToRulesyncPermissions({ allow, deny }) {
18437
18795
  const bash = {};
18438
18796
  for (const pattern of allow) bash[pattern] = "allow";
18439
18797
  for (const pattern of deny) bash[pattern] = "deny";
18440
- for (const pattern of block) bash[pattern] = "deny";
18441
18798
  return { permission: Object.keys(bash).length > 0 ? { bash } : {} };
18442
18799
  }
18443
18800
  //#endregion
@@ -18631,30 +18988,114 @@ function convertGoosePermissionConfigToRulesync(userPermission) {
18631
18988
  const GROKCLI_GLOBAL_ONLY_MESSAGE = "Grok CLI permissions are global-only; use --global to sync ~/.grok/config.toml";
18632
18989
  const GROKCLI_UI_KEY = "ui";
18633
18990
  const GROKCLI_PERMISSION_MODE_KEY = "permission_mode";
18991
+ const GROKCLI_PERMISSION_KEY = "permission";
18634
18992
  const CATCH_ALL_PATTERN$2 = "*";
18993
+ const MCP_CANONICAL_PREFIX = "mcp__";
18994
+ const CATEGORY_TO_GROK_TOOL = {
18995
+ bash: "Bash",
18996
+ read: "Read",
18997
+ edit: "Edit",
18998
+ write: "Edit",
18999
+ grep: "Grep",
19000
+ webfetch: "WebFetch"
19001
+ };
19002
+ const GROK_TOOL_TO_CATEGORY = {
19003
+ Bash: "bash",
19004
+ Read: "read",
19005
+ Edit: "edit",
19006
+ Grep: "grep",
19007
+ WebFetch: "webfetch"
19008
+ };
19009
+ const GROK_MCP_TOOL = "MCPTool";
19010
+ /**
19011
+ * Build a Grok Claude-style permission entry (e.g. `Bash(git *)`, `Read`,
19012
+ * `MCPTool(server__tool)`) from a canonical category + pattern. Returns `null`
19013
+ * for categories Grok cannot express so callers can skip them.
19014
+ *
19015
+ * Scoped MCP categories (`mcp__<remainder>`) fold their address into the
19016
+ * parentheses (`MCPTool(<remainder>)`); the bare `mcp` category becomes
19017
+ * `MCPTool`. For every other tool, a `*` pattern emits the bare tool name and a
19018
+ * concrete pattern emits `Tool(pattern)`.
19019
+ */
19020
+ function buildGrokEntry(category, pattern) {
19021
+ if (category.startsWith(MCP_CANONICAL_PREFIX)) {
19022
+ const remainder = category.slice(5);
19023
+ return remainder.length > 0 ? `${GROK_MCP_TOOL}(${remainder})` : GROK_MCP_TOOL;
19024
+ }
19025
+ if (category === "mcp") return pattern === CATCH_ALL_PATTERN$2 || pattern === "" ? GROK_MCP_TOOL : `${GROK_MCP_TOOL}(${pattern})`;
19026
+ const tool = CATEGORY_TO_GROK_TOOL[category];
19027
+ if (tool === void 0) return null;
19028
+ return pattern === CATCH_ALL_PATTERN$2 || pattern === "" ? tool : `${tool}(${pattern})`;
19029
+ }
19030
+ /**
19031
+ * Parse a Grok Claude-style permission entry back into a canonical category +
19032
+ * pattern. Entries without parentheses are treated as catch-all (`*`). Returns
19033
+ * `null` for tool prefixes with no canonical equivalent (e.g. `any`).
19034
+ */
19035
+ function parseGrokEntry(entry) {
19036
+ const trimmed = entry.trim();
19037
+ const parenIndex = trimmed.indexOf("(");
19038
+ let tool;
19039
+ let inner;
19040
+ if (parenIndex === -1 || !trimmed.endsWith(")")) {
19041
+ tool = trimmed;
19042
+ inner = "";
19043
+ } else {
19044
+ tool = trimmed.slice(0, parenIndex);
19045
+ inner = trimmed.slice(parenIndex + 1, -1).trim();
19046
+ }
19047
+ if (tool === GROK_MCP_TOOL) return inner.length > 0 ? {
19048
+ category: `${MCP_CANONICAL_PREFIX}${inner}`,
19049
+ pattern: CATCH_ALL_PATTERN$2
19050
+ } : {
19051
+ category: "mcp",
19052
+ pattern: CATCH_ALL_PATTERN$2
19053
+ };
19054
+ const category = GROK_TOOL_TO_CATEGORY[tool];
19055
+ if (category === void 0) return null;
19056
+ return {
19057
+ category,
19058
+ pattern: inner.length > 0 ? inner : CATCH_ALL_PATTERN$2
19059
+ };
19060
+ }
18635
19061
  /**
18636
19062
  * Permissions adapter for the xAI Grok Build CLI (`grokcli`).
18637
19063
  *
18638
- * Grok has no per-tool / per-pattern permission rules. Tool gating is a single
18639
- * coarse toggle, `[ui] permission_mode`, in `~/.grok/config.toml`:
18640
- * - `ask` prompt before each tool call (Grok's default).
18641
- * - `always-approve` skip prompts.
18642
- *
18643
- * rulesync's permission model is per-category, per-pattern `allow`/`ask`/`deny`,
18644
- * so the mapping is **lossy** (a single mode cannot express per-pattern rules):
18645
- * - Generate: any `deny` or `ask` rule anywhere `ask` (conservative — keep
18646
- * prompting whenever the user expressed any restriction); otherwise, if at
18647
- * least one `allow` rule exists and nothing is denied/asked
18648
- * `always-approve`; an empty config falls back to the safe default `ask`.
18649
- * - Import: `always-approve` `bash: { "*": "allow" }`; `ask` (or unset)
18650
- * `bash: { "*": "ask" }`. `bash` is used as the representative catch-all
18651
- * because it is the primary permission-gated surface, and this round-trips
18652
- * the generate mapping.
18653
- *
18654
- * This surface is **global only** `permission_mode` lives in the user-level
18655
- * `~/.grok/config.toml`; Grok has no project-scoped permission file. The shared
18656
- * config is merged in place: the `[ui] permission_mode` value is set and every
18657
- * other key (e.g. `[mcp_servers]`, legacy `approval_mode`) is preserved. The
19064
+ * Grok Build CLI ships a Claude-style rule system under `[permission]` in
19065
+ * `~/.grok/config.toml`: `allow` / `deny` / `ask` arrays of entries such as
19066
+ * `Bash(git *)`, `Read(src/**)`, `Edit`, `Grep`, `MCPTool(server__tool)`, and
19067
+ * `WebFetch`, evaluated with precedence `deny > ask > allow`
19068
+ * (https://docs.x.ai/build/settings/reference). rulesync's canonical
19069
+ * per-category, per-pattern model maps almost 1:1:
19070
+ * - Generate: each `permission.<category>.<pattern> = allow|ask|deny` becomes
19071
+ * the matching Grok entry and is bucketed into the `[permission]` array for
19072
+ * that action. `bash|read|edit|grep|webfetch` map to their Grok tool;
19073
+ * `write` collapses onto `Edit` (Grok has no `Write` tool); `mcp__*` maps to
19074
+ * `MCPTool(...)` (a scoped MCP category folds its address into the
19075
+ * parentheses, so a non-`*` argument pattern on it is not represented).
19076
+ * Categories with no Grok tool (`websearch`, `glob`, `notebookedit`,
19077
+ * `agent`) are skipped (with a warning when they carry a `deny` rule, to
19078
+ * surface the gap). When two canonical rules collapse onto the same Grok
19079
+ * entry with different actions (e.g. `edit` allow + `write` deny → `Edit`),
19080
+ * the strictest wins (`deny > ask > allow`) and a warning is logged, so the
19081
+ * entry never lands contradictorily in two arrays.
19082
+ * - Import: the `[permission]` arrays are parsed back into canonical
19083
+ * categories. When no `[permission]` section is present (older configs), the
19084
+ * coarse `[ui] permission_mode` is used as a fallback.
19085
+ *
19086
+ * The coarse `[ui] permission_mode` toggle is still written for backward
19087
+ * compatibility with older Grok versions: `always-approve` when the config is
19088
+ * pure-`allow`, otherwise `ask` (conservative — it is never `always-approve`
19089
+ * while any `deny`/`ask` rule exists, so it never contradicts the fine-grained
19090
+ * arrays).
19091
+ *
19092
+ * This surface is **global only** — the adapter syncs the user-level
19093
+ * `~/.grok/config.toml`. (Grok also supports a project-scoped `[permission]`
19094
+ * file; project scope is not modeled here.) The shared config is merged in
19095
+ * place: rulesync owns the `[permission]` `allow`/`deny`/`ask` entries for the
19096
+ * tools it models and the `[ui] permission_mode` value, while every other key
19097
+ * (e.g. `[mcp_servers]`, `[permission] rules`, `[sandbox]`) and any user-authored
19098
+ * entries for tools rulesync cannot model (e.g. `WebSearch`) are preserved. The
18658
19099
  * file is never deleted.
18659
19100
  */
18660
19101
  var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
@@ -18686,7 +19127,7 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18686
19127
  global: true
18687
19128
  });
18688
19129
  }
18689
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false }) {
19130
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger, global = false }) {
18690
19131
  if (!global) throw new Error(GROKCLI_GLOBAL_ONLY_MESSAGE);
18691
19132
  const paths = GrokcliPermissions.getSettablePaths({ global });
18692
19133
  const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
@@ -18697,7 +19138,16 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18697
19138
  } catch (error) {
18698
19139
  throw new Error(`Failed to parse existing Grok config.toml at ${filePath}: ${formatError(error)}`, { cause: error });
18699
19140
  }
18700
- const mode = deriveGrokPermissionMode(rulesyncPermissions.getJson());
19141
+ const config = rulesyncPermissions.getJson();
19142
+ const existingPermission = isRecord(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {};
19143
+ const buckets = buildGrokPermissionArrays(config, existingPermission, logger);
19144
+ parsed[GROKCLI_PERMISSION_KEY] = {
19145
+ ...existingPermission,
19146
+ allow: buckets.allow,
19147
+ deny: buckets.deny,
19148
+ ask: buckets.ask
19149
+ };
19150
+ const mode = deriveGrokPermissionMode(config);
18701
19151
  const existingUi = isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {};
18702
19152
  parsed[GROKCLI_UI_KEY] = {
18703
19153
  ...existingUi,
@@ -18720,8 +19170,8 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18720
19170
  } catch (error) {
18721
19171
  throw new Error(`Failed to parse Grok config.toml content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
18722
19172
  }
18723
- const action = (isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {})[GROKCLI_PERMISSION_MODE_KEY] === "always-approve" ? "allow" : "ask";
18724
- const rulesyncConfig = { permission: { bash: { [CATCH_ALL_PATTERN$2]: action } } };
19173
+ const fineGrained = parseGrokPermissionArrays(isRecord(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {});
19174
+ const rulesyncConfig = fineGrained ? { permission: fineGrained } : { permission: { bash: { [CATCH_ALL_PATTERN$2]: legacyModeAction(parsed) } } };
18725
19175
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
18726
19176
  }
18727
19177
  validate() {
@@ -18741,6 +19191,84 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18741
19191
  });
18742
19192
  }
18743
19193
  };
19194
+ const ACTION_RANK = {
19195
+ allow: 0,
19196
+ ask: 1,
19197
+ deny: 2
19198
+ };
19199
+ /**
19200
+ * Collect user-authored entries from an existing `[permission]` array whose
19201
+ * tool prefix rulesync cannot model (e.g. `WebSearch`, `any`). Such entries are
19202
+ * preserved verbatim so replacing the arrays does not silently drop them
19203
+ * (mirrors the Cursor adapter's preservation of unmanaged types).
19204
+ */
19205
+ function unmanagedEntries(existingPermission, key) {
19206
+ return (isStringArray(existingPermission[key]) ? existingPermission[key] : []).filter((entry) => parseGrokEntry(entry) === null);
19207
+ }
19208
+ /**
19209
+ * Bucket canonical rules into Grok's `[permission]` allow/deny/ask arrays of
19210
+ * Claude-style entries, resolving collapse collisions via `deny > ask > allow`.
19211
+ * Categories Grok cannot express are skipped (with a warning when they carry a
19212
+ * `deny` rule); entries the user authored for tools rulesync cannot model are
19213
+ * preserved from `existingPermission`.
19214
+ */
19215
+ function buildGrokPermissionArrays(config, existingPermission, logger) {
19216
+ const ranked = /* @__PURE__ */ new Map();
19217
+ for (const [category, rules] of Object.entries(config.permission)) for (const [pattern, action] of Object.entries(rules)) {
19218
+ const entry = buildGrokEntry(category, pattern);
19219
+ if (entry === null) {
19220
+ if (action === "deny" && logger) logger.warn(`Grok CLI has no permission tool for the '${category}' category; its 'deny' rule could not be represented and was skipped.`);
19221
+ continue;
19222
+ }
19223
+ const existing = ranked.get(entry);
19224
+ if (existing !== void 0 && existing !== action) logger?.warn(`Grok permission entry '${entry}' received conflicting actions ('${existing}' and '${action}'); keeping the stricter one (deny > ask > allow).`);
19225
+ if (existing === void 0 || ACTION_RANK[action] > ACTION_RANK[existing]) ranked.set(entry, action);
19226
+ }
19227
+ const allow = unmanagedEntries(existingPermission, "allow");
19228
+ const deny = unmanagedEntries(existingPermission, "deny");
19229
+ const ask = unmanagedEntries(existingPermission, "ask");
19230
+ for (const [entry, action] of ranked) if (action === "allow") allow.push(entry);
19231
+ else if (action === "deny") deny.push(entry);
19232
+ else ask.push(entry);
19233
+ return {
19234
+ allow: uniq(allow.toSorted()),
19235
+ deny: uniq(deny.toSorted()),
19236
+ ask: uniq(ask.toSorted())
19237
+ };
19238
+ }
19239
+ /**
19240
+ * Parse Grok's `[permission]` allow/deny/ask arrays back into a canonical
19241
+ * permission map. Returns `null` when the section defines none of the three
19242
+ * arrays, so the caller can fall back to the coarse `permission_mode`.
19243
+ * Precedence `deny > ask > allow` is applied so a tool listed in multiple
19244
+ * arrays resolves to the strictest action.
19245
+ */
19246
+ function parseGrokPermissionArrays(permission) {
19247
+ const allow = isStringArray(permission.allow) ? permission.allow : void 0;
19248
+ const deny = isStringArray(permission.deny) ? permission.deny : void 0;
19249
+ const ask = isStringArray(permission.ask) ? permission.ask : void 0;
19250
+ if ((allow?.length ?? 0) + (deny?.length ?? 0) + (ask?.length ?? 0) === 0) return null;
19251
+ const result = {};
19252
+ const apply = (entries, action) => {
19253
+ for (const entry of entries ?? []) {
19254
+ const parsed = parseGrokEntry(entry);
19255
+ if (parsed === null) continue;
19256
+ const bucket = result[parsed.category] ??= {};
19257
+ bucket[parsed.pattern] = action;
19258
+ }
19259
+ };
19260
+ apply(allow, "allow");
19261
+ apply(ask, "ask");
19262
+ apply(deny, "deny");
19263
+ return result;
19264
+ }
19265
+ /**
19266
+ * Legacy coarse fallback: map `[ui] permission_mode` to a canonical action.
19267
+ * `always-approve` ⇒ `allow`; anything else (including a missing mode) ⇒ `ask`.
19268
+ */
19269
+ function legacyModeAction(parsed) {
19270
+ return (isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {})[GROKCLI_PERMISSION_MODE_KEY] === "always-approve" ? "allow" : "ask";
19271
+ }
18744
19272
  /**
18745
19273
  * Collapse a rulesync permissions config into Grok's single coarse mode.
18746
19274
  * Any `deny`/`ask` rule anywhere keeps prompting (`ask`); otherwise an existing
@@ -18756,6 +19284,10 @@ function deriveGrokPermissionMode(config) {
18756
19284
  }
18757
19285
  //#endregion
18758
19286
  //#region src/features/permissions/hermesagent-permissions.ts
19287
+ /** Collect the glob patterns in a canonical category that carry a given action. */
19288
+ function patternsByAction(category, action) {
19289
+ return Object.entries(category ?? {}).filter(([, value]) => value === action).map(([pattern]) => pattern);
19290
+ }
18759
19291
  var HermesagentPermissions = class HermesagentPermissions extends ToolPermissions {
18760
19292
  static getSettablePaths() {
18761
19293
  return {
@@ -18779,7 +19311,11 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18779
19311
  return true;
18780
19312
  }
18781
19313
  setFileContent(fileContent) {
18782
- this.fileContent = mergeHermesConfig(fileContent, parseHermesConfig(this.fileContent));
19314
+ const existing = parseHermesConfig(fileContent);
19315
+ const generated = parseHermesConfig(this.fileContent);
19316
+ const merged = deepMergeHermesConfig(existing, generated);
19317
+ if (generated.permissions !== void 0) merged.permissions = generated.permissions;
19318
+ this.fileContent = stringifyHermesConfig(merged);
18783
19319
  }
18784
19320
  toRulesyncPermissions() {
18785
19321
  const config = parseHermesConfig(this.getFileContent());
@@ -18792,12 +19328,22 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18792
19328
  }
18793
19329
  static fromRulesyncPermissions({ outputRoot, rulesyncPermissions }) {
18794
19330
  const permissions = rulesyncPermissions.getJson();
19331
+ const permissionBlock = permissions.permission ?? {};
19332
+ const commandAllowlist = Object.entries(permissionBlock).flatMap(([, patterns]) => patternsByAction(patterns, "allow"));
19333
+ const bashDeny = patternsByAction(permissionBlock.bash, "deny");
19334
+ const webfetchDeny = patternsByAction(permissionBlock.webfetch, "deny");
19335
+ let config = {};
19336
+ if (commandAllowlist.length > 0) config.command_allowlist = commandAllowlist;
19337
+ if (bashDeny.length > 0) config.approvals = { deny: bashDeny };
19338
+ if (webfetchDeny.length > 0) config.security = { website_blocklist: {
19339
+ enabled: true,
19340
+ domains: webfetchDeny
19341
+ } };
19342
+ if (permissions.hermes && typeof permissions.hermes === "object") config = deepMergeHermesConfig(config, permissions.hermes);
19343
+ config.permissions = { rulesync: permissions };
18795
19344
  return new HermesagentPermissions({
18796
19345
  outputRoot,
18797
- fileContent: stringifyHermesConfig({
18798
- command_allowlist: Object.entries(permissions.permission ?? {}).flatMap(([, patterns]) => Object.entries(patterns).filter(([, action]) => action === "allow").map(([command]) => command)),
18799
- permissions: { rulesync: permissions }
18800
- })
19346
+ fileContent: stringifyHermesConfig(config)
18801
19347
  });
18802
19348
  }
18803
19349
  };
@@ -18818,14 +19364,18 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18818
19364
  * "executables": [ { "prefix": "git ", "action": "allow" } ],
18819
19365
  * "fileEditing": [ { "pattern": "src/**", "action": "allow" } ],
18820
19366
  * "mcpTools": [ { "prefix": "search", "action": "allow" } ],
18821
- * "readOutsideProject":[ { "pattern": "/etc/**", "action": "deny" } ]
19367
+ * "readOutsideProject":[ { "pattern": "/etc/**", "action": "ask" } ]
18822
19368
  * }
18823
19369
  * }
18824
19370
  * ```
18825
19371
  *
18826
19372
  * Each rule carries a literal `prefix` (matches commands that start with it) or
18827
- * a glob `pattern` (`*`, `**`, `?`, `[abc]`, `[!abc]`) plus an `action`
18828
- * (`allow` | `ask` | `deny`). rulesync's canonical actions map 1:1 onto Junie's.
19373
+ * a glob `pattern` (`*`, `**`, `?`, `[abc]`, `[!abc]`) plus an `action`. Junie
19374
+ * documents only `allow` and `ask` as valid actions there is **no `deny`**
19375
+ * (https://junie.jetbrains.com/docs/action-allowlist-junie-cli.html). rulesync's
19376
+ * canonical `allow`/`ask` map 1:1; a canonical `deny` has no Junie equivalent
19377
+ * and is mapped to the nearest valid action, `ask` (still withholds
19378
+ * auto-approval), with a warning so the downgrade is surfaced.
18829
19379
  *
18830
19380
  * Category mapping (rulesync canonical <-> Junie rule group):
18831
19381
  * - `bash` <-> `executables`
@@ -18835,8 +19385,11 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18835
19385
  *
18836
19386
  * Categories Junie cannot represent (e.g. `webfetch`) are skipped on export
18837
19387
  * (with a warning when they carry rules). The top-level `defaultBehavior` and
18838
- * `allowReadonlyCommands` settings have no canonical equivalent: they are
18839
- * preserved verbatim on export but not imported into the rulesync model.
19388
+ * `allowReadonlyCommands` settings have no canonical per-glob slot: they are
19389
+ * authored and round-tripped through the `junie` override namespace (see
19390
+ * `JuniePermissionsOverrideSchema`) — lifted into the override on import and
19391
+ * merged back onto the top level on export — and any other unmodeled top-level
19392
+ * key is preserved verbatim.
18840
19393
  *
18841
19394
  * @see https://junie.jetbrains.com/docs/action-allowlist-junie-cli.html
18842
19395
  */
@@ -18859,7 +19412,6 @@ const JUNIE_GROUP_TO_CANONICAL = {
18859
19412
  mcpTools: "mcp",
18860
19413
  readOutsideProject: "read"
18861
19414
  };
18862
- const JUNIE_DEFAULT_BEHAVIOR = "ask";
18863
19415
  function isPermissionAction$1(value) {
18864
19416
  return PermissionActionSchema.safeParse(value).success;
18865
19417
  }
@@ -18909,13 +19461,16 @@ var JuniePermissions = class JuniePermissions extends ToolPermissions {
18909
19461
  } catch (error) {
18910
19462
  throw new Error(`Failed to parse existing Junie allowlist at ${filePath}: ${formatError(error)}`, { cause: error });
18911
19463
  }
19464
+ const config = rulesyncPermissions.getJson();
18912
19465
  const rules = convertRulesyncToJunieRules({
18913
- config: rulesyncPermissions.getJson(),
19466
+ config,
18914
19467
  logger
18915
19468
  });
19469
+ const override = config.junie;
19470
+ const overrideObj = override !== void 0 && typeof override === "object" ? override : {};
18916
19471
  const merged = {
18917
19472
  ...existing,
18918
- defaultBehavior: existing.defaultBehavior ?? JUNIE_DEFAULT_BEHAVIOR,
19473
+ ...overrideObj,
18919
19474
  rules
18920
19475
  };
18921
19476
  return new JuniePermissions({
@@ -18935,7 +19490,12 @@ var JuniePermissions = class JuniePermissions extends ToolPermissions {
18935
19490
  throw new Error(`Failed to parse Junie permissions content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
18936
19491
  }
18937
19492
  const config = convertJunieToRulesyncPermissions({ allowlist });
18938
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
19493
+ const junieOverride = {};
19494
+ if (typeof allowlist.allowReadonlyCommands === "boolean") junieOverride.allowReadonlyCommands = allowlist.allowReadonlyCommands;
19495
+ if (typeof allowlist.defaultBehavior === "string") junieOverride.defaultBehavior = allowlist.defaultBehavior;
19496
+ const result = { ...config };
19497
+ if (Object.keys(junieOverride).length > 0) result.junie = junieOverride;
19498
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
18939
19499
  }
18940
19500
  validate() {
18941
19501
  return {
@@ -18967,12 +19527,13 @@ function convertRulesyncToJunieRules({ config, logger }) {
18967
19527
  continue;
18968
19528
  }
18969
19529
  for (const [pattern, action] of Object.entries(patterns)) {
19530
+ const junieAction = toJunieAction(action, category, pattern, logger);
18970
19531
  const rule = isGlobPattern(pattern) ? {
18971
19532
  pattern,
18972
- action
19533
+ action: junieAction
18973
19534
  } : {
18974
19535
  prefix: pattern,
18975
- action
19536
+ action: junieAction
18976
19537
  };
18977
19538
  (rules[group] ??= []).push(rule);
18978
19539
  }
@@ -18980,6 +19541,19 @@ function convertRulesyncToJunieRules({ config, logger }) {
18980
19541
  return rules;
18981
19542
  }
18982
19543
  /**
19544
+ * Map a canonical action onto a valid Junie allowlist action. Junie supports
19545
+ * only `allow` and `ask`; a canonical `deny` is downgraded to `ask` (the
19546
+ * nearest valid action — both withhold auto-approval) with a warning, so
19547
+ * rulesync never emits a `deny` that Junie would silently ignore.
19548
+ */
19549
+ function toJunieAction(action, category, pattern, logger) {
19550
+ if (action === "deny") {
19551
+ logger?.warn(`Junie's allowlist supports only 'allow'/'ask' actions; the '${category}' deny rule for '${pattern}' was downgraded to 'ask' (Junie has no 'deny').`);
19552
+ return "ask";
19553
+ }
19554
+ return action;
19555
+ }
19556
+ /**
18983
19557
  * Convert a Junie allowlist back into rulesync permissions config. The
18984
19558
  * top-level `defaultBehavior` / `allowReadonlyCommands` settings have no
18985
19559
  * canonical equivalent and are not imported.
@@ -19013,6 +19587,31 @@ const KiloPermissionSchema = z.union([z.enum([
19013
19587
  ]))]);
19014
19588
  const KiloPermissionsConfigSchema = z.looseObject({ permission: z.optional(z.record(z.string(), KiloPermissionSchema)) });
19015
19589
  /**
19590
+ * Kilo permission keys that share a name with a canonical rulesync category and
19591
+ * therefore stay in the shared `permission` block. Everything else (Kilo-only
19592
+ * keys such as `external_directory`, `doom_loop`, `notebook_edit`, ...) is
19593
+ * routed into the `kilo` override on import so it does not leak into other
19594
+ * tools' configs. Kilo folds `write` into `edit`, uses `notebook_edit`/`task`
19595
+ * rather than the canonical `notebookedit`/`agent`, and has no `mcp` key (MCP is
19596
+ * addressed via `mcp__*` tool-name keys), so those canonical names are not Kilo
19597
+ * keys in practice — they simply never appear on the shared side.
19598
+ */
19599
+ const KILO_SHARED_CATEGORIES = /* @__PURE__ */ new Set([
19600
+ "bash",
19601
+ "read",
19602
+ "edit",
19603
+ "write",
19604
+ "webfetch",
19605
+ "websearch",
19606
+ "grep",
19607
+ "glob",
19608
+ "notebookedit",
19609
+ "agent"
19610
+ ]);
19611
+ function isSharedKiloCategory(key) {
19612
+ return key === "*" || KILO_SHARED_CATEGORIES.has(key) || key.startsWith("mcp__");
19613
+ }
19614
+ /**
19016
19615
  * Parse a JSONC string and throw on syntax errors. The `jsonc-parser` `parse()` function is
19017
19616
  * non-throwing best-effort: invalid input silently yields a partial value (often `undefined`,
19018
19617
  * coerced to `{}` by callers). That behavior would silently drop a user's existing `deny` rules
@@ -19124,12 +19723,16 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19124
19723
  const parsed = parseKiloJsoncStrict(await readFileContentOrNull(filePath) ?? "{}", filePath);
19125
19724
  const parsedPermission = parsed.permission;
19126
19725
  const existingPermission = parsedPermission && typeof parsedPermission === "object" && !Array.isArray(parsedPermission) ? { ...parsedPermission } : {};
19127
- const rulesyncPermission = rulesyncPermissions.getJson().permission;
19726
+ const rulesyncJson = rulesyncPermissions.getJson();
19727
+ const kiloOverride = rulesyncJson.kilo;
19728
+ const incomingPermission = {
19729
+ ...rulesyncJson.permission,
19730
+ ...kiloOverride?.permission
19731
+ };
19128
19732
  const droppedDenyByKey = {};
19129
- for (const key of Object.keys(rulesyncPermission)) {
19130
- const previous = existingPermission[key];
19131
- const previousDenyPatterns = collectKiloDenyPatterns(previous);
19132
- const nextDenyPatterns = new Set(collectKiloDenyPatterns(rulesyncPermission[key]));
19733
+ for (const [key, value] of Object.entries(incomingPermission)) {
19734
+ const previousDenyPatterns = collectKiloDenyPatterns(existingPermission[key]);
19735
+ const nextDenyPatterns = new Set(collectKiloDenyPatterns(value));
19133
19736
  const dropped = previousDenyPatterns.filter((p) => !nextDenyPatterns.has(p));
19134
19737
  if (dropped.length > 0) droppedDenyByKey[key] = dropped;
19135
19738
  }
@@ -19137,8 +19740,10 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19137
19740
  const summary = Object.entries(droppedDenyByKey).map(([key, patterns]) => `${key}: [${patterns.join(", ")}]`).join("; ");
19138
19741
  logger?.warn(`WARNING: Kilo permissions regeneration drops existing 'deny' rule(s) because rulesync output owns these tool keys. Dropped — ${summary}. To preserve these denies, add them to '.rulesync/permissions.json'.`);
19139
19742
  }
19140
- const mergedPermission = { ...existingPermission };
19141
- for (const [key, value] of Object.entries(rulesyncPermission)) mergedPermission[key] = value;
19743
+ const mergedPermission = {
19744
+ ...existingPermission,
19745
+ ...incomingPermission
19746
+ };
19142
19747
  const nextJson = {
19143
19748
  ...parsed,
19144
19749
  permission: mergedPermission
@@ -19152,8 +19757,16 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19152
19757
  });
19153
19758
  }
19154
19759
  toRulesyncPermissions() {
19155
- const permission = this.normalizePermission(this.json.permission);
19156
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
19760
+ const rawPermission = this.json.permission ?? {};
19761
+ const shared = {};
19762
+ const overrideOnly = {};
19763
+ for (const [key, value] of Object.entries(rawPermission)) if (isSharedKiloCategory(key)) shared[key] = typeof value === "string" ? { "*": value } : value;
19764
+ else overrideOnly[key] = value;
19765
+ const json = Object.keys(overrideOnly).length > 0 ? {
19766
+ permission: shared,
19767
+ kilo: { permission: overrideOnly }
19768
+ } : { permission: shared };
19769
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(json, null, 2) });
19157
19770
  }
19158
19771
  validate() {
19159
19772
  try {
@@ -19183,10 +19796,6 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19183
19796
  validate: false
19184
19797
  });
19185
19798
  }
19186
- normalizePermission(permission) {
19187
- if (!permission) return {};
19188
- return Object.fromEntries(Object.entries(permission).map(([tool, value]) => [tool, typeof value === "string" ? { "*": value } : value]));
19189
- }
19190
19799
  };
19191
19800
  //#endregion
19192
19801
  //#region src/features/permissions/kiro-permissions.ts
@@ -19244,29 +19853,16 @@ var KiroPermissions = class KiroPermissions extends ToolPermissions {
19244
19853
  }
19245
19854
  const permission = {};
19246
19855
  const toolsSettings = parsed.toolsSettings ?? {};
19247
- const shellSettings = asRecord$1(toolsSettings.shell);
19248
- const shellAllow = asStringArray(shellSettings.allowedCommands);
19249
- const shellDeny = asStringArray(shellSettings.deniedCommands);
19250
- if (shellAllow.length > 0 || shellDeny.length > 0) {
19251
- permission.bash = {};
19252
- for (const pattern of shellAllow) permission.bash[pattern] = "allow";
19253
- for (const pattern of shellDeny) permission.bash[pattern] = "deny";
19254
- }
19255
- const readSettings = asRecord$1(toolsSettings.read);
19256
- const readAllow = asStringArray(readSettings.allowedPaths);
19257
- const readDeny = asStringArray(readSettings.deniedPaths);
19258
- if (readAllow.length > 0 || readDeny.length > 0) {
19259
- permission.read = {};
19260
- for (const pattern of readAllow) permission.read[pattern] = "allow";
19261
- for (const pattern of readDeny) permission.read[pattern] = "deny";
19262
- }
19263
- const writeSettings = asRecord$1(toolsSettings.write);
19264
- const writeAllow = asStringArray(writeSettings.allowedPaths);
19265
- const writeDeny = asStringArray(writeSettings.deniedPaths);
19266
- if (writeAllow.length > 0 || writeDeny.length > 0) {
19267
- permission.write = {};
19268
- for (const pattern of writeAllow) permission.write[pattern] = "allow";
19269
- for (const pattern of writeDeny) permission.write[pattern] = "deny";
19856
+ const shellRules = rulesFromArrays(asRecord$1(toolsSettings.shell), "allowedCommands", "deniedCommands");
19857
+ if (Object.keys(shellRules).length > 0) permission.bash = shellRules;
19858
+ for (const category of [
19859
+ "read",
19860
+ "write",
19861
+ "grep",
19862
+ "glob"
19863
+ ]) {
19864
+ const rules = rulesFromArrays(asRecord$1(toolsSettings[category]), "allowedPaths", "deniedPaths");
19865
+ if (Object.keys(rules).length > 0) permission[category] = rules;
19270
19866
  }
19271
19867
  const allowedTools = new Set(parsed.allowedTools ?? []);
19272
19868
  if (allowedTools.has("web_fetch")) permission.webfetch = { "*": "allow" };
@@ -19292,45 +19888,63 @@ var KiroPermissions = class KiroPermissions extends ToolPermissions {
19292
19888
  function buildKiroPermissionsFromRulesync({ config, logger, existing }) {
19293
19889
  const nextAllowedTools = new Set(existing.allowedTools ?? []);
19294
19890
  const nextToolsSettings = { ...asRecord$1(existing.toolsSettings) };
19891
+ const pathBuckets = {};
19892
+ const pushPath = (key, action, pattern) => {
19893
+ const bucket = pathBuckets[key] ??= {
19894
+ allow: [],
19895
+ deny: []
19896
+ };
19897
+ (action === "allow" ? bucket.allow : bucket.deny).push(pattern);
19898
+ };
19295
19899
  const shell = {
19296
19900
  allowedCommands: [],
19297
19901
  deniedCommands: []
19298
19902
  };
19299
- const read = {
19300
- allowedPaths: [],
19301
- deniedPaths: []
19302
- };
19303
- const write = {
19304
- allowedPaths: [],
19305
- deniedPaths: []
19306
- };
19307
19903
  for (const [category, rules] of Object.entries(config.permission)) for (const [pattern, action] of Object.entries(rules)) {
19308
19904
  if (action === "ask") {
19309
19905
  logger?.warn(`Kiro permissions do not support "ask". Skipping ${category}:${pattern}`);
19310
19906
  continue;
19311
19907
  }
19312
19908
  if (category === "bash") (action === "allow" ? shell.allowedCommands : shell.deniedCommands).push(pattern);
19313
- else if (category === "read") (action === "allow" ? read.allowedPaths : read.deniedPaths).push(pattern);
19314
- else if (category === "edit" || category === "write") (action === "allow" ? write.allowedPaths : write.deniedPaths).push(pattern);
19315
- else if (category === "webfetch" || category === "websearch") {
19316
- if (pattern !== "*") {
19317
- logger?.warn(`Kiro ${category} supports only wildcard (*) via allowedTools. Skipping rule: ${pattern}`);
19318
- continue;
19319
- }
19320
- const toolName = category === "webfetch" ? "web_fetch" : "web_search";
19321
- if (action === "allow") nextAllowedTools.add(toolName);
19322
- else nextAllowedTools.delete(toolName);
19323
- } else logger?.warn(`Kiro permissions do not support category: ${category}. Skipping.`);
19909
+ else if (category === "read" || category === "grep" || category === "glob") pushPath(category, action, pattern);
19910
+ else if (category === "edit" || category === "write") pushPath("write", action, pattern);
19911
+ else if (category === "webfetch" || category === "websearch") applyKiroWebPermission({
19912
+ category,
19913
+ pattern,
19914
+ action,
19915
+ nextAllowedTools,
19916
+ logger
19917
+ });
19918
+ else logger?.warn(`Kiro permissions do not support category: ${category}. Skipping.`);
19324
19919
  }
19325
19920
  nextToolsSettings.shell = shell;
19326
- nextToolsSettings.read = read;
19327
- nextToolsSettings.write = write;
19921
+ nextToolsSettings.read = pathTable(pathBuckets.read);
19922
+ nextToolsSettings.write = pathTable(pathBuckets.write);
19923
+ for (const key of ["grep", "glob"]) {
19924
+ const bucket = pathBuckets[key];
19925
+ if (bucket && (bucket.allow.length > 0 || bucket.deny.length > 0)) nextToolsSettings[key] = pathTable(bucket);
19926
+ }
19328
19927
  return {
19329
19928
  ...existing,
19330
19929
  allowedTools: [...nextAllowedTools].toSorted(),
19331
19930
  toolsSettings: nextToolsSettings
19332
19931
  };
19333
19932
  }
19933
+ function pathTable(bucket) {
19934
+ return {
19935
+ allowedPaths: bucket?.allow ?? [],
19936
+ deniedPaths: bucket?.deny ?? []
19937
+ };
19938
+ }
19939
+ function applyKiroWebPermission({ category, pattern, action, nextAllowedTools, logger }) {
19940
+ if (pattern !== "*") {
19941
+ logger?.warn(`Kiro ${category} supports only wildcard (*) via allowedTools. Skipping rule: ${pattern}`);
19942
+ return;
19943
+ }
19944
+ const toolName = category === "webfetch" ? "web_fetch" : "web_search";
19945
+ if (action === "allow") nextAllowedTools.add(toolName);
19946
+ else nextAllowedTools.delete(toolName);
19947
+ }
19334
19948
  function asRecord$1(value) {
19335
19949
  const result = UnknownRecordSchema.safeParse(value);
19336
19950
  return result.success ? result.data : {};
@@ -19338,6 +19952,17 @@ function asRecord$1(value) {
19338
19952
  function asStringArray(value) {
19339
19953
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
19340
19954
  }
19955
+ /**
19956
+ * Build a canonical `{ pattern: action }` map from a Kiro tool settings record's
19957
+ * allow/deny string arrays (e.g. `allowedPaths`/`deniedPaths` or
19958
+ * `allowedCommands`/`deniedCommands`).
19959
+ */
19960
+ function rulesFromArrays(settings, allowKey, denyKey) {
19961
+ const rules = {};
19962
+ for (const pattern of asStringArray(settings[allowKey])) rules[pattern] = "allow";
19963
+ for (const pattern of asStringArray(settings[denyKey])) rules[pattern] = "deny";
19964
+ return rules;
19965
+ }
19341
19966
  //#endregion
19342
19967
  //#region src/features/permissions/opencode-permissions.ts
19343
19968
  const OpencodePermissionSchema = z.union([z.enum([
@@ -19349,6 +19974,28 @@ const OpencodePermissionSchema = z.union([z.enum([
19349
19974
  "ask",
19350
19975
  "deny"
19351
19976
  ]))]);
19977
+ /**
19978
+ * Canonical rulesync permission categories that carry a cross-tool meaning (see
19979
+ * the "Supported tool categories" list in `docs/reference/file-formats.md`).
19980
+ * On import, any OpenCode category outside this set — plus MCP tool names — is
19981
+ * treated as OpenCode-only and routed into the `opencode` override block so a
19982
+ * subsequent `rulesync generate` does not leak it into other tools' configs.
19983
+ */
19984
+ const CANONICAL_PERMISSION_CATEGORIES = /* @__PURE__ */ new Set([
19985
+ "bash",
19986
+ "read",
19987
+ "edit",
19988
+ "write",
19989
+ "webfetch",
19990
+ "websearch",
19991
+ "grep",
19992
+ "glob",
19993
+ "notebookedit",
19994
+ "agent"
19995
+ ]);
19996
+ function isSharedPermissionCategory(category) {
19997
+ return category === "*" || CANONICAL_PERMISSION_CATEGORIES.has(category) || category.startsWith("mcp__");
19998
+ }
19352
19999
  const OpencodePermissionsConfigSchema = z.looseObject({ permission: z.optional(z.union([z.enum([
19353
20000
  "allow",
19354
20001
  "ask",
@@ -19410,9 +20057,15 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
19410
20057
  fileContent = await readFileContentOrNull(jsonPath);
19411
20058
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
19412
20059
  }
20060
+ const parsed = parse(fileContent ?? "{}");
20061
+ const rulesyncJson = rulesyncPermissions.getJson();
20062
+ const overridePermission = rulesyncJson.opencode?.permission ?? {};
19413
20063
  const nextJson = {
19414
- ...parse(fileContent ?? "{}"),
19415
- permission: rulesyncPermissions.getJson().permission
20064
+ ...parsed,
20065
+ permission: {
20066
+ ...rulesyncJson.permission,
20067
+ ...overridePermission
20068
+ }
19416
20069
  };
19417
20070
  return new OpencodePermissions({
19418
20071
  outputRoot,
@@ -19423,8 +20076,20 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
19423
20076
  });
19424
20077
  }
19425
20078
  toRulesyncPermissions() {
19426
- const permission = this.normalizePermission(this.json.permission);
19427
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
20079
+ const rawPermission = this.json.permission;
20080
+ if (rawPermission === void 0 || typeof rawPermission === "string") {
20081
+ const permission = this.normalizePermission(rawPermission);
20082
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
20083
+ }
20084
+ const shared = {};
20085
+ const overrideOnly = {};
20086
+ for (const [category, value] of Object.entries(rawPermission)) if (isSharedPermissionCategory(category)) shared[category] = typeof value === "string" ? { "*": value } : value;
20087
+ else overrideOnly[category] = value;
20088
+ const json = Object.keys(overrideOnly).length > 0 ? {
20089
+ permission: shared,
20090
+ opencode: { permission: overrideOnly }
20091
+ } : { permission: shared };
20092
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(json, null, 2) });
19428
20093
  }
19429
20094
  validate() {
19430
20095
  try {
@@ -19454,10 +20119,15 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
19454
20119
  validate: false
19455
20120
  });
19456
20121
  }
20122
+ /**
20123
+ * Normalize the uniform/undefined forms of OpenCode's `permission` field into
20124
+ * the canonical rulesync shape. The object form is handled directly in
20125
+ * `toRulesyncPermissions` (it needs to split shared vs OpenCode-only
20126
+ * categories), so this only covers the two remaining cases.
20127
+ */
19457
20128
  normalizePermission(permission) {
19458
20129
  if (!permission) return {};
19459
- if (typeof permission === "string") return { "*": { "*": permission } };
19460
- return Object.fromEntries(Object.entries(permission).map(([tool, value]) => [tool, typeof value === "string" ? { "*": value } : value]));
20130
+ return { "*": { "*": permission } };
19461
20131
  }
19462
20132
  };
19463
20133
  //#endregion
@@ -19531,6 +20201,24 @@ function buildQwenPermissionEntry(toolName, pattern) {
19531
20201
  if (pattern === "*") return toolName;
19532
20202
  return `${toolName}(${pattern})`;
19533
20203
  }
20204
+ const QWEN_OVERRIDE_TOOLS_KEYS = [
20205
+ "approvalMode",
20206
+ "autoAccept",
20207
+ "sandbox",
20208
+ "sandboxImage",
20209
+ "disabled"
20210
+ ];
20211
+ const QWEN_OVERRIDE_SECURITY_KEYS = ["folderTrust"];
20212
+ function asPlainRecord(value) {
20213
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
20214
+ }
20215
+ /** Pick the override-managed keys out of a settings group into a fresh record. */
20216
+ function pickQwenOverrideKeys(group, keys) {
20217
+ const source = asPlainRecord(group);
20218
+ const picked = {};
20219
+ for (const key of keys) if (source[key] !== void 0) picked[key] = source[key];
20220
+ return picked;
20221
+ }
19534
20222
  var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
19535
20223
  constructor(params) {
19536
20224
  super({
@@ -19596,6 +20284,15 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
19596
20284
  ...settings,
19597
20285
  permissions: mergedPermissions
19598
20286
  };
20287
+ const override = config.qwencode;
20288
+ if (override?.tools !== void 0) merged.tools = {
20289
+ ...asPlainRecord(settings.tools),
20290
+ ...asPlainRecord(override.tools)
20291
+ };
20292
+ if (override?.security !== void 0) merged.security = {
20293
+ ...asPlainRecord(settings.security),
20294
+ ...asPlainRecord(override.security)
20295
+ };
19599
20296
  const fileContent = JSON.stringify(merged, null, 2);
19600
20297
  return new QwencodePermissions({
19601
20298
  outputRoot,
@@ -19621,7 +20318,14 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
19621
20318
  ask: permissions.ask ?? [],
19622
20319
  deny: permissions.deny ?? []
19623
20320
  });
19624
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
20321
+ const overrideTools = pickQwenOverrideKeys(settings.tools, QWEN_OVERRIDE_TOOLS_KEYS);
20322
+ const overrideSecurity = pickQwenOverrideKeys(settings.security, QWEN_OVERRIDE_SECURITY_KEYS);
20323
+ const qwencodeOverride = {};
20324
+ if (Object.keys(overrideTools).length > 0) qwencodeOverride.tools = overrideTools;
20325
+ if (Object.keys(overrideSecurity).length > 0) qwencodeOverride.security = overrideSecurity;
20326
+ const result = { ...config };
20327
+ if (Object.keys(qwencodeOverride).length > 0) result.qwencode = qwencodeOverride;
20328
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
19625
20329
  }
19626
20330
  validate() {
19627
20331
  try {
@@ -19782,6 +20486,16 @@ function toPermissionsTable(value) {
19782
20486
  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
19783
20487
  return { ...value };
19784
20488
  }
20489
+ const REASONIX_OVERRIDE_AGENT_KEYS = ["plan_mode_allowed_tools", "plan_mode_read_only_commands"];
20490
+ function asReasonixRecord(value) {
20491
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
20492
+ }
20493
+ function pickReasonixKeys(source, keys) {
20494
+ const record = asReasonixRecord(source);
20495
+ const picked = {};
20496
+ for (const key of keys) if (record[key] !== void 0) picked[key] = record[key];
20497
+ return picked;
20498
+ }
19785
20499
  var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
19786
20500
  toml;
19787
20501
  constructor(params) {
@@ -19843,6 +20557,15 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
19843
20557
  ...parsed,
19844
20558
  permissions: mergedPermissions
19845
20559
  };
20560
+ const override = config.reasonix;
20561
+ if (override?.sandbox !== void 0) merged.sandbox = {
20562
+ ...asReasonixRecord(parsed.sandbox),
20563
+ ...asReasonixRecord(override.sandbox)
20564
+ };
20565
+ if (override?.agent !== void 0) merged.agent = {
20566
+ ...asReasonixRecord(parsed.agent),
20567
+ ...asReasonixRecord(override.agent)
20568
+ };
19846
20569
  const fileContent = smolToml.stringify(merged);
19847
20570
  return new ReasonixPermissions({
19848
20571
  outputRoot,
@@ -19859,7 +20582,14 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
19859
20582
  ask: toStringArray$1(permissions.ask),
19860
20583
  deny: toStringArray$1(permissions.deny)
19861
20584
  });
19862
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
20585
+ const sandbox = asReasonixRecord(this.toml.sandbox);
20586
+ const agentPlanMode = pickReasonixKeys(this.toml.agent, REASONIX_OVERRIDE_AGENT_KEYS);
20587
+ const reasonixOverride = {};
20588
+ if (Object.keys(sandbox).length > 0) reasonixOverride.sandbox = sandbox;
20589
+ if (Object.keys(agentPlanMode).length > 0) reasonixOverride.agent = agentPlanMode;
20590
+ const result = { ...config };
20591
+ if (Object.keys(reasonixOverride).length > 0) result.reasonix = reasonixOverride;
20592
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
19863
20593
  }
19864
20594
  validate() {
19865
20595
  try {
@@ -20423,6 +21153,7 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
20423
21153
  if (deny.size > 0) nextTool.denylist = [...deny].toSorted();
20424
21154
  tools[vibeToolName] = nextTool;
20425
21155
  }
21156
+ applyVibeSensitivePatterns(tools, rulesyncPermissions.getJson().vibe);
20426
21157
  config.tools = tools;
20427
21158
  if (enabledTools.size > 0) config.enabled_tools = [...enabledTools].toSorted();
20428
21159
  else delete config.enabled_tools;
@@ -20439,16 +21170,25 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
20439
21170
  }
20440
21171
  toRulesyncPermissions() {
20441
21172
  const permission = {};
21173
+ const vibeOverridePermission = {};
20442
21174
  for (const tool of toStringArray(this.toml.enabled_tools)) ensurePermission(permission, toCanonicalToolName$1(tool))["*"] = "allow";
20443
21175
  for (const tool of toStringArray(this.toml.disabled_tools)) ensurePermission(permission, toCanonicalToolName$1(tool))["*"] = "deny";
20444
21176
  for (const [vibeToolName, toolConfig] of Object.entries(toVibeToolsRecord(this.toml.tools))) {
20445
- const rules = ensurePermission(permission, toCanonicalToolName$1(vibeToolName));
21177
+ const category = toCanonicalToolName$1(vibeToolName);
21178
+ const rules = ensurePermission(permission, category);
20446
21179
  const action = fromVibePermission(toolConfig.permission);
20447
21180
  if (action !== void 0) rules["*"] = action;
20448
21181
  for (const pattern of toStringArray(toolConfig.allow ?? toolConfig.allowlist)) rules[pattern] = "allow";
20449
21182
  for (const pattern of toStringArray(toolConfig.deny ?? toolConfig.denylist)) rules[pattern] = "deny";
21183
+ const sensitivePatterns = toStringArray(toolConfig.sensitive_patterns);
21184
+ if (sensitivePatterns.length > 0) vibeOverridePermission[category] = { sensitive_patterns: sensitivePatterns };
20450
21185
  }
20451
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
21186
+ for (const [category, rules] of Object.entries(permission)) if (Object.keys(rules).length === 0) delete permission[category];
21187
+ const json = Object.keys(vibeOverridePermission).length > 0 ? {
21188
+ permission,
21189
+ vibe: { permission: vibeOverridePermission }
21190
+ } : { permission };
21191
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(json, null, 2) });
20452
21192
  }
20453
21193
  validate() {
20454
21194
  try {
@@ -20475,6 +21215,22 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
20475
21215
  });
20476
21216
  }
20477
21217
  };
21218
+ /**
21219
+ * Apply the Vibe-scoped override's per-tool `sensitive_patterns` (patterns that
21220
+ * escalate to ASK even when the base permission is ALWAYS). rulesync owns this
21221
+ * list for any category the override names: a present list is set, an empty
21222
+ * one clears it. Categories not named keep whatever the existing file had.
21223
+ */
21224
+ function applyVibeSensitivePatterns(tools, vibeOverride) {
21225
+ for (const [category, toolOverride] of Object.entries(vibeOverride?.permission ?? {})) {
21226
+ const vibeToolName = toVibeToolName(category);
21227
+ const nextTool = toVibeToolConfig(tools[vibeToolName]);
21228
+ const patterns = toStringArray(toolOverride.sensitive_patterns);
21229
+ if (patterns.length > 0) nextTool.sensitive_patterns = [...patterns].toSorted();
21230
+ else delete nextTool.sensitive_patterns;
21231
+ tools[vibeToolName] = nextTool;
21232
+ }
21233
+ }
20478
21234
  function parseVibeConfig(fileContent) {
20479
21235
  const parsed = smolToml.parse(fileContent || smolToml.stringify({}));
20480
21236
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -20519,6 +21275,11 @@ function ensurePermission(permission, category) {
20519
21275
  const WARP_GLOBAL_ONLY_MESSAGE = "Warp permissions are global-only; use --global to sync Warp's settings.toml";
20520
21276
  const ALLOWLIST_KEY = "agent_mode_command_execution_allowlist";
20521
21277
  const DENYLIST_KEY = "agent_mode_command_execution_denylist";
21278
+ const WARP_OVERRIDE_KEYS = [
21279
+ "agent_mode_coding_permissions",
21280
+ "agent_mode_coding_file_read_allowlist",
21281
+ "agent_mode_execute_readonly_commands"
21282
+ ];
20522
21283
  /**
20523
21284
  * Warp's `settings.toml` lives in a different directory per platform (Stable
20524
21285
  * channel). The home directory is resolved by the processor through
@@ -20554,8 +21315,16 @@ function warpSettingsDir() {
20554
21315
  * patterns as regexes when targeting Warp (mirrors the Zed permissions
20555
21316
  * adapter). Warp has no per-command "ask" list, so `ask` rules are dropped; and
20556
21317
  * the command lists only model shell commands, so non-`bash` categories are
20557
- * skipped (with a warning when they carry `deny` rules). MCP allow/deny and the
20558
- * file-read permissions are separate surfaces not modeled here.
21318
+ * skipped (with a warning when they carry `deny` rules).
21319
+ *
21320
+ * Warp's `[agents.profiles]` table also exposes file-read/read-only autonomy
21321
+ * knobs that do not fit the canonical `allow | ask | deny` per-command model:
21322
+ * `agent_mode_coding_permissions`, `agent_mode_coding_file_read_allowlist`, and
21323
+ * `agent_mode_execute_readonly_commands`. These are authored and round-tripped
21324
+ * through the `warp` override namespace (see `WarpPermissionsOverrideSchema`):
21325
+ * on **import** they are lifted from `settings.toml` into the override, and on
21326
+ * **export** they are merged back into `[agents.profiles]` (the override wins).
21327
+ * MCP allow/deny is a separate surface not modeled here.
20559
21328
  *
20560
21329
  * The `settings.toml` file holds all of Warp's settings, so the
20561
21330
  * `[agents.profiles]` block is merged in place and the file is never deleted.
@@ -20600,12 +21369,15 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
20600
21369
  } catch (error) {
20601
21370
  throw new Error(`Failed to parse existing Warp settings at ${filePath}: ${formatError(error)}`, { cause: error });
20602
21371
  }
21372
+ const config = rulesyncPermissions.getJson();
20603
21373
  const { allow, deny } = convertRulesyncToWarpPermissions({
20604
- config: rulesyncPermissions.getJson(),
21374
+ config,
20605
21375
  logger
20606
21376
  });
20607
21377
  const agents = isRecord(settings.agents) ? { ...settings.agents } : {};
20608
21378
  const profiles = isRecord(agents.profiles) ? { ...agents.profiles } : {};
21379
+ const override = config.warp;
21380
+ if (isRecord(override)) Object.assign(profiles, override);
20609
21381
  const mergedAllow = uniq(allow.toSorted());
20610
21382
  const mergedDeny = uniq(deny.toSorted());
20611
21383
  if (mergedAllow.length > 0) profiles[ALLOWLIST_KEY] = mergedAllow;
@@ -20636,7 +21408,11 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
20636
21408
  allow: isStringArray(profiles[ALLOWLIST_KEY]) ? profiles[ALLOWLIST_KEY] : [],
20637
21409
  deny: isStringArray(profiles[DENYLIST_KEY]) ? profiles[DENYLIST_KEY] : []
20638
21410
  });
20639
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
21411
+ const warpOverride = {};
21412
+ for (const key of WARP_OVERRIDE_KEYS) if (profiles[key] !== void 0) warpOverride[key] = profiles[key];
21413
+ const result = { ...config };
21414
+ if (Object.keys(warpOverride).length > 0) result.warp = warpOverride;
21415
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
20640
21416
  }
20641
21417
  validate() {
20642
21418
  return {
@@ -24002,8 +24778,9 @@ const DevinSkillFrontmatterSchema = z.looseObject({
24002
24778
  * Represents a Devin (now Devin Desktop) skill directory.
24003
24779
  * Devin supports skills in both project mode under .devin/skills/
24004
24780
  * (preferred since the Devin Desktop rebrand; .devin/skills/ is the legacy
24005
- * fallback the tool still reads) and global mode under ~/.codeium/windsurf/skills/
24006
- * (unchanged by the rebrand).
24781
+ * fallback the tool still reads) and global mode under the Devin-native
24782
+ * ~/.config/devin/skills/ (consistent with Devin's global agents/rules paths;
24783
+ * the legacy channel-dependent ~/.codeium/<channel>/skills/ is no longer emitted).
24007
24784
  */
24008
24785
  var DevinSkill = class DevinSkill extends ToolSkill {
24009
24786
  constructor({ outputRoot = process.cwd(), relativeDirPath = DevinSkill.getSettablePaths().relativeDirPath, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
@@ -24025,7 +24802,7 @@ var DevinSkill = class DevinSkill extends ToolSkill {
24025
24802
  }
24026
24803
  }
24027
24804
  static getSettablePaths({ global = false } = {}) {
24028
- if (global) return { relativeDirPath: CODEIUM_WINDSURF_SKILLS_DIR_PATH };
24805
+ if (global) return { relativeDirPath: DEVIN_GLOBAL_SKILLS_DIR_PATH };
24029
24806
  return { relativeDirPath: DEVIN_SKILLS_DIR_PATH };
24030
24807
  }
24031
24808
  getFrontmatter() {
@@ -27084,7 +27861,7 @@ const QwencodeSubagentFrontmatterSchema = z.looseObject({
27084
27861
  disallowedTools: z.optional(z.array(z.string())),
27085
27862
  maxTurns: z.optional(z.number()),
27086
27863
  color: z.optional(z.string()),
27087
- mcpServers: z.optional(z.array(z.string())),
27864
+ mcpServers: z.optional(z.union([z.array(z.string()), z.record(z.string(), z.looseObject({}))])),
27088
27865
  hooks: z.optional(z.unknown())
27089
27866
  });
27090
27867
  var QwencodeSubagent = class QwencodeSubagent extends ToolSubagent {
@@ -37065,4 +37842,4 @@ async function importPermissionsCore(params) {
37065
37842
  //#endregion
37066
37843
  export { CLIError as $, IgnoreProcessor as A, RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH as At, toolCommandFactories as B, RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH as Bt, PermissionsProcessorToolTargetSchema as C, RULESYNC_COMMANDS_RELATIVE_DIR_PATH as Ct, McpProcessorToolTargetSchema as D, RULESYNC_HOOKS_FILE_NAME as Dt, McpProcessor as E, RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH as Et, HooksProcessorToolTargetSchema as F, RULESYNC_PERMISSIONS_FILE_NAME as Ft, CLAUDECODE_SKILLS_DIR_PATH as G, CLAUDECODE_LOCAL_RULE_FILE_NAME as H, formatError as Ht, toolHooksFactories as I, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH as It, stringifyFrontmatter as J, RulesyncCommand as K, RulesyncHooks as L, RULESYNC_RELATIVE_DIR_PATH as Lt, toolIgnoreFactories as M, RULESYNC_MCP_RELATIVE_FILE_PATH as Mt, RulesyncIgnore as N, RULESYNC_MCP_SCHEMA_URL as Nt, toolMcpFactories as O, RULESYNC_HOOKS_RELATIVE_FILE_PATH as Ot, HooksProcessor as P, RULESYNC_OVERVIEW_FILE_NAME as Pt, JsonLogger as Q, CommandsProcessor as R, RULESYNC_RULES_RELATIVE_DIR_PATH as Rt, PermissionsProcessor as S, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH as St, RulesyncPermissions as T, RULESYNC_CONFIG_SCHEMA_URL as Tt, CLAUDECODE_MEMORIES_DIR_NAME as U, ALL_FEATURES as Ut, CLAUDECODE_DIR as V, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH as Vt, CLAUDECODE_SETTINGS_LOCAL_FILE_NAME as W, ALL_FEATURES_WITH_WILDCARD as Wt, findControlCharacter as X, ConfigResolver as Y, ConsoleLogger as Z, toolSkillFactories as _, ALL_TOOL_TARGETS as _t, RulesProcessor as a, fileExists as at, RulesyncSkillFrontmatterSchema as b, MAX_FILE_SIZE as bt, RulesyncRule as c, getHomeDirectory as ct, SubagentsProcessorToolTargetSchema as d, readFileContent as dt, ErrorCodes as et, toolSubagentFactories as f, removeDirectory as ft, SkillsProcessorToolTargetSchema as g, writeFileContent as gt, SkillsProcessor as h, toPosixPath as ht, convertFromTool as i, ensureDir as it, IgnoreProcessorToolTargetSchema as j, RULESYNC_MCP_FILE_NAME as jt, RulesyncMcp as k, RULESYNC_IGNORE_RELATIVE_FILE_PATH as kt, RulesyncRuleFrontmatterSchema as l, isSymlink as lt, RulesyncSubagentFrontmatterSchema as m, removeTempDirectory as mt, checkRulesyncDirExists as n, createTempDirectory as nt, RulesProcessorToolTargetSchema as o, findFilesByGlobs as ot, RulesyncSubagent as p, removeFile as pt, RulesyncCommandFrontmatterSchema as q, generate as r, directoryExists as rt, toolRuleFactories as s, getFileSize as st, importFromTool as t, checkPathTraversal as tt, SubagentsProcessor as u, listDirectoryFiles as ut, getLocalSkillDirNames as v, ALL_TOOL_TARGETS_WITH_WILDCARD as vt, toolPermissionsFactories as w, RULESYNC_CONFIG_RELATIVE_FILE_PATH as wt, SKILL_FILE_NAME as x, RULESYNC_AIIGNORE_FILE_NAME as xt, RulesyncSkill as y, ToolTargetSchema as yt, CommandsProcessorToolTargetSchema as z, RULESYNC_SKILLS_RELATIVE_DIR_PATH as zt };
37067
37844
 
37068
- //# sourceMappingURL=import-DywcWsgJ.js.map
37845
+ //# sourceMappingURL=import-B7VzSVUK.js.map