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.
@@ -3029,8 +3029,8 @@ const DEVIN_SKILLS_DIR_PATH = (0, node_path.join)(DEVIN_DIR, "skills");
3029
3029
  const DEVIN_AGENTS_DIR_PATH = (0, node_path.join)(DEVIN_DIR, "agents");
3030
3030
  const DEVIN_GLOBAL_CONFIG_DIR_PATH = (0, node_path.join)(".config", "devin");
3031
3031
  const DEVIN_GLOBAL_AGENTS_DIR_PATH = (0, node_path.join)(DEVIN_GLOBAL_CONFIG_DIR_PATH, "agents");
3032
+ const DEVIN_GLOBAL_SKILLS_DIR_PATH = (0, node_path.join)(DEVIN_GLOBAL_CONFIG_DIR_PATH, "skills");
3032
3033
  const CODEIUM_WINDSURF_GLOBAL_WORKFLOWS_DIR_PATH = (0, node_path.join)(CODEIUM_WINDSURF_DIR, "global_workflows");
3033
- const CODEIUM_WINDSURF_SKILLS_DIR_PATH = (0, node_path.join)(CODEIUM_WINDSURF_DIR, "skills");
3034
3034
  const DEVIN_CONFIG_FILE_NAME = "config.json";
3035
3035
  const DEVIN_HOOKS_V1_FILE_NAME = "hooks.v1.json";
3036
3036
  const DEVIN_GLOBAL_AGENTS_FILE_NAME = "AGENTS.md";
@@ -5715,6 +5715,8 @@ const DEEPAGENTS_HOOK_EVENTS = [
5715
5715
  "sessionEnd",
5716
5716
  "beforeSubmitPrompt",
5717
5717
  "permissionRequest",
5718
+ "preToolUse",
5719
+ "postToolUse",
5718
5720
  "postToolUseFailure",
5719
5721
  "stop",
5720
5722
  "preCompact",
@@ -5740,7 +5742,13 @@ const CODEXCLI_HOOK_EVENTS = [
5740
5742
  * Goose adopts the Open Plugins hooks spec: each plugin's `hooks/hooks.json`
5741
5743
  * maps PascalCase event names to matcher/handler arrays. Every Goose event has a
5742
5744
  * 1:1 canonical equivalent, so no new canonical events are required.
5743
- * @see https://goose-docs.ai/blog/2026/05/14/goose-hooks/
5745
+ *
5746
+ * Goose's `HookEvent` enum defines exactly these 11 events (v1.41.0). Notably it
5747
+ * has NO `SubagentStart`/`SubagentStop` arms — emitting them would write keys
5748
+ * Goose silently ignores, so `subagentStart`/`subagentStop` are intentionally
5749
+ * excluded here and from `CANONICAL_TO_GOOSE_EVENT_NAMES`.
5750
+ * @see https://github.com/block/goose/blob/v1.41.0/crates/goose/src/hooks/mod.rs
5751
+ * @see https://block.github.io/goose/docs/guides/context-engineering/hooks/
5744
5752
  */
5745
5753
  const GOOSE_HOOK_EVENTS = [
5746
5754
  "sessionStart",
@@ -5753,9 +5761,7 @@ const GOOSE_HOOK_EVENTS = [
5753
5761
  "beforeReadFile",
5754
5762
  "afterFileEdit",
5755
5763
  "beforeShellExecution",
5756
- "afterShellExecution",
5757
- "subagentStart",
5758
- "subagentStop"
5764
+ "afterShellExecution"
5759
5765
  ];
5760
5766
  /** Hook events supported by Kiro CLI. */
5761
5767
  const KIRO_HOOK_EVENTS = [
@@ -6208,9 +6214,7 @@ const CANONICAL_TO_GOOSE_EVENT_NAMES = {
6208
6214
  beforeReadFile: "BeforeReadFile",
6209
6215
  afterFileEdit: "AfterFileEdit",
6210
6216
  beforeShellExecution: "BeforeShellExecution",
6211
- afterShellExecution: "AfterShellExecution",
6212
- subagentStart: "SubagentStart",
6213
- subagentStop: "SubagentStop"
6217
+ afterShellExecution: "AfterShellExecution"
6214
6218
  };
6215
6219
  /**
6216
6220
  * Map Goose PascalCase event names to canonical camelCase.
@@ -6224,6 +6228,8 @@ const CANONICAL_TO_DEEPAGENTS_EVENT_NAMES = {
6224
6228
  sessionEnd: "session.end",
6225
6229
  beforeSubmitPrompt: "user.prompt",
6226
6230
  permissionRequest: "permission.request",
6231
+ preToolUse: "tool.use",
6232
+ postToolUse: "tool.result",
6227
6233
  postToolUseFailure: "tool.error",
6228
6234
  stop: "task.complete",
6229
6235
  preCompact: "context.compact",
@@ -8218,7 +8224,7 @@ const GOOSE_CONVERTER_CONFIG = {
8218
8224
  *
8219
8225
  * The JSON shape matches Claude Code's: each PascalCase event maps to an array of
8220
8226
  * `{ matcher, hooks: [{ type: "command", command }] }` entries.
8221
- * @see https://goose-docs.ai/blog/2026/05/14/goose-hooks/
8227
+ * @see https://block.github.io/goose/docs/guides/context-engineering/hooks/
8222
8228
  */
8223
8229
  var GooseHooks = class GooseHooks extends ToolHooks {
8224
8230
  constructor(params) {
@@ -8269,7 +8275,7 @@ var GooseHooks = class GooseHooks extends ToolHooks {
8269
8275
  throw new Error(`Failed to parse Goose hooks content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
8270
8276
  }
8271
8277
  const hooks = toolHooksToCanonical({
8272
- hooks: parsed.hooks,
8278
+ 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,
8273
8279
  converterConfig: GOOSE_CONVERTER_CONFIG
8274
8280
  });
8275
8281
  return this.toRulesyncHooksDefault({ fileContent: JSON.stringify({
@@ -8324,6 +8330,24 @@ function mergeHermesConfig(fileContent, patch) {
8324
8330
  ...patch
8325
8331
  });
8326
8332
  }
8333
+ /**
8334
+ * Recursively merge `patch` into `base` (patch wins). Nested plain objects are
8335
+ * merged key-by-key; every other value (arrays, scalars) is replaced wholesale.
8336
+ * Used to overlay the Hermes-scoped permission override onto the natively
8337
+ * emitted `approvals`/`security` structures without one clobbering the other
8338
+ * (e.g. `approvals.deny` from canonical deny rules coexisting with an
8339
+ * `approvals.mode` from the override). Prototype-pollution keys are dropped.
8340
+ */
8341
+ function deepMergeHermesConfig(base, patch) {
8342
+ const result = { ...base };
8343
+ for (const [key, patchValue] of Object.entries(patch)) {
8344
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
8345
+ const baseValue = result[key];
8346
+ if (isPlainObject(baseValue) && isPlainObject(patchValue)) result[key] = deepMergeHermesConfig(baseValue, patchValue);
8347
+ else result[key] = sanitizeHermesConfigValue(patchValue);
8348
+ }
8349
+ return result;
8350
+ }
8327
8351
  //#endregion
8328
8352
  //#region src/features/hooks/hermesagent-hooks.ts
8329
8353
  /**
@@ -10489,6 +10513,66 @@ var AugmentcodeIgnore = class AugmentcodeIgnore extends ToolIgnore {
10489
10513
  }
10490
10514
  };
10491
10515
  //#endregion
10516
+ //#region src/features/claudecode-settings-gateway.ts
10517
+ /**
10518
+ * Single owner of the `.claude/settings.json` `permissions` block, which both
10519
+ * `ignore` (writes `Read(...)` into `permissions.deny`) and `permissions`
10520
+ * (writes the whole `allow`/`ask`/`deny`) read-modify-write. The entry format,
10521
+ * the merge, and the cross-feature ownership rule (permissions' explicit `Read`
10522
+ * rules win over ignore-derived `Read` denies) used to be duplicated across both
10523
+ * feature files; they live here once so each feature just states its intent and
10524
+ * never reasons about the other's existence.
10525
+ */
10526
+ const READ_TOOL_NAME = "Read";
10527
+ const isReadDenyEntry = (entry) => entry.startsWith(`${READ_TOOL_NAME}(`) && entry.endsWith(")");
10528
+ const buildReadDenyEntry = (pattern) => `${READ_TOOL_NAME}(${pattern})`;
10529
+ const parsePermissionsBlock = (settings) => {
10530
+ const permissions = settings.permissions ?? {};
10531
+ return {
10532
+ allow: permissions.allow ?? [],
10533
+ ask: permissions.ask ?? [],
10534
+ deny: permissions.deny ?? []
10535
+ };
10536
+ };
10537
+ const withPermissions = (settings, next) => {
10538
+ const permissions = { ...settings.permissions };
10539
+ const assign = (key, values) => {
10540
+ if (values.length > 0) permissions[key] = values;
10541
+ else delete permissions[key];
10542
+ };
10543
+ assign("allow", next.allow);
10544
+ assign("ask", next.ask);
10545
+ assign("deny", next.deny);
10546
+ return {
10547
+ ...settings,
10548
+ permissions
10549
+ };
10550
+ };
10551
+ const applyIgnoreReadDenies = (params) => {
10552
+ const { settings, readDenies } = params;
10553
+ const current = parsePermissionsBlock(settings);
10554
+ const preservedDeny = current.deny.filter((entry) => !isReadDenyEntry(entry) || readDenies.includes(entry));
10555
+ return withPermissions(settings, {
10556
+ allow: current.allow,
10557
+ ask: current.ask,
10558
+ deny: (0, es_toolkit.uniq)([...preservedDeny, ...readDenies].toSorted())
10559
+ });
10560
+ };
10561
+ const applyPermissions = (params) => {
10562
+ const { settings, managedToolNames, toolNameOf, allow, ask, deny, logger } = params;
10563
+ const current = parsePermissionsBlock(settings);
10564
+ const keepUnmanaged = (entries) => entries.filter((entry) => !managedToolNames.has(toolNameOf(entry)));
10565
+ if (logger && managedToolNames.has(READ_TOOL_NAME)) {
10566
+ const overwrittenReadDenies = current.deny.filter((entry) => toolNameOf(entry) === READ_TOOL_NAME);
10567
+ 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.`);
10568
+ }
10569
+ return withPermissions(settings, {
10570
+ allow: (0, es_toolkit.uniq)([...keepUnmanaged(current.allow), ...allow].toSorted()),
10571
+ ask: (0, es_toolkit.uniq)([...keepUnmanaged(current.ask), ...ask].toSorted()),
10572
+ deny: (0, es_toolkit.uniq)([...keepUnmanaged(current.deny), ...deny].toSorted())
10573
+ });
10574
+ };
10575
+ //#endregion
10492
10576
  //#region src/features/ignore/claudecode-ignore.ts
10493
10577
  const DEFAULT_FILE_MODE = "shared";
10494
10578
  /**
@@ -10535,7 +10619,7 @@ var ClaudecodeIgnore = class ClaudecodeIgnore extends ToolIgnore {
10535
10619
  }
10536
10620
  toRulesyncIgnore() {
10537
10621
  const fileContent = this.patterns.map((pattern) => {
10538
- if (pattern.startsWith("Read(") && pattern.endsWith(")")) return pattern.slice(5, -1);
10622
+ if (isReadDenyEntry(pattern)) return pattern.slice(5, -1);
10539
10623
  return pattern;
10540
10624
  }).filter((pattern) => pattern.length > 0).join("\n");
10541
10625
  return new RulesyncIgnore({
@@ -10546,22 +10630,20 @@ var ClaudecodeIgnore = class ClaudecodeIgnore extends ToolIgnore {
10546
10630
  });
10547
10631
  }
10548
10632
  static async fromRulesyncIgnore({ outputRoot = process.cwd(), rulesyncIgnore, options }) {
10549
- const deniedValues = rulesyncIgnore.getFileContent().split(/\r?\n|\r/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).map((pattern) => `Read(${pattern})`);
10633
+ const deniedValues = rulesyncIgnore.getFileContent().split(/\r?\n|\r/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).map((pattern) => buildReadDenyEntry(pattern));
10550
10634
  const paths = this.getSettablePaths({ options });
10551
10635
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
10552
10636
  const existingFileContent = await fileExists(filePath) ? await readFileContent(filePath) : "{}";
10553
- const existingJsonValue = JSON.parse(existingFileContent);
10554
- const preservedDenies = (existingJsonValue.permissions?.deny ?? []).filter((deny) => {
10555
- if (deny.startsWith("Read(") && deny.endsWith(")")) return deniedValues.includes(deny);
10556
- return true;
10637
+ let existingJsonValue;
10638
+ try {
10639
+ existingJsonValue = JSON.parse(existingFileContent);
10640
+ } catch (error) {
10641
+ throw new Error(`Failed to parse existing Claude settings at ${filePath}: ${formatError(error)}`, { cause: error });
10642
+ }
10643
+ const jsonValue = applyIgnoreReadDenies({
10644
+ settings: existingJsonValue,
10645
+ readDenies: deniedValues
10557
10646
  });
10558
- const jsonValue = {
10559
- ...existingJsonValue,
10560
- permissions: {
10561
- ...existingJsonValue.permissions,
10562
- deny: (0, es_toolkit.uniq)([...preservedDenies, ...deniedValues].toSorted())
10563
- }
10564
- };
10565
10647
  return new ClaudecodeIgnore({
10566
10648
  outputRoot,
10567
10649
  relativeDirPath: paths.relativeDirPath,
@@ -12248,6 +12330,38 @@ const RULESYNC_TO_CODEX_FIELD_MAP = {
12248
12330
  envVars: "env_vars"
12249
12331
  };
12250
12332
  const MAX_REMOVE_EMPTY_ENTRIES_DEPTH$1 = 32;
12333
+ /**
12334
+ * Translate a server's `oauth` table from the canonical rulesync shape (Claude
12335
+ * Code style camelCase) into the shape Codex CLI understands. Codex expects the
12336
+ * OAuth client id under snake_case `client_id`; without it `codex mcp login`
12337
+ * falls back to dynamic client registration and fails for providers that do not
12338
+ * support it (e.g. Slack, see #2158). The canonical `clientId` is kept alongside
12339
+ * the added `client_id` so tools that read the camelCase shape keep working and
12340
+ * the round-trip stays stable.
12341
+ */
12342
+ function mapOauthToCodex(oauth) {
12343
+ const result = omitPrototypePollutionKeys(oauth);
12344
+ if (typeof oauth["clientId"] === "string" && !("client_id" in result)) result["client_id"] = oauth["clientId"];
12345
+ return result;
12346
+ }
12347
+ /**
12348
+ * Reverse of {@link mapOauthToCodex}: collapse Codex's `oauth.client_id` back to
12349
+ * the canonical `clientId` on import. When both keys are present (the shape
12350
+ * rulesync itself emits) the canonical `clientId` wins and `client_id` is
12351
+ * dropped so a subsequent generate does not accumulate duplicates.
12352
+ */
12353
+ function mapOauthFromCodex(oauth) {
12354
+ const result = {};
12355
+ for (const [key, value] of Object.entries(oauth)) {
12356
+ if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
12357
+ if (key === "client_id") {
12358
+ if (!("clientId" in oauth)) result["clientId"] = value;
12359
+ continue;
12360
+ }
12361
+ result[key] = value;
12362
+ }
12363
+ return result;
12364
+ }
12251
12365
  function convertFromCodexFormat(codexMcp) {
12252
12366
  const result = {};
12253
12367
  for (const [name, config] of Object.entries(codexMcp)) {
@@ -12257,7 +12371,8 @@ function convertFromCodexFormat(codexMcp) {
12257
12371
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
12258
12372
  if (key === "enabled") {
12259
12373
  if (value === false) converted["disabled"] = true;
12260
- } else if (Object.hasOwn(CODEX_TO_RULESYNC_FIELD_MAP, key)) {
12374
+ } else if (key === "oauth" && isRecord(value)) converted[key] = mapOauthFromCodex(value);
12375
+ else if (Object.hasOwn(CODEX_TO_RULESYNC_FIELD_MAP, key)) {
12261
12376
  const mappedKey = CODEX_TO_RULESYNC_FIELD_MAP[key];
12262
12377
  if (mappedKey) if (isStringArray(value)) converted[mappedKey] = value;
12263
12378
  else warnWithFallback(void 0, `Ignored malformed array for ${key} in MCP server ${name}`);
@@ -12277,7 +12392,8 @@ function convertToCodexFormat(mcpServers) {
12277
12392
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
12278
12393
  if (key === "disabled") {
12279
12394
  if (value === true) converted["enabled"] = false;
12280
- } else if (Object.hasOwn(RULESYNC_TO_CODEX_FIELD_MAP, key)) {
12395
+ } else if (key === "oauth" && isRecord(value)) converted[key] = mapOauthToCodex(value);
12396
+ else if (Object.hasOwn(RULESYNC_TO_CODEX_FIELD_MAP, key)) {
12281
12397
  const mappedKey = RULESYNC_TO_CODEX_FIELD_MAP[key];
12282
12398
  if (mappedKey) if (isStringArray(value)) converted[mappedKey] = value;
12283
12399
  else warnWithFallback(void 0, `[CodexCliMcp] Skipping invalid value type for mapped key '${key}': expected string array, got ${typeof value}`);
@@ -13493,10 +13609,12 @@ function resolveHermesTimeout(config) {
13493
13609
  *
13494
13610
  * Hermes is close to the MCP spec but not identical: `command` must be a single
13495
13611
  * executable string (an array's tail folds into `args`), a server is disabled
13496
- * via `enabled: false` (not the canonical `disabled: true`), and remote servers
13497
- * use `url`/`headers`. Only fields Hermes understands are emitted, so the shared
13498
- * `config.yaml` is not polluted with canonical-only aliases (`type`, `transport`,
13499
- * `httpUrl`, `networkTimeout`, tool-filter keys, ...).
13612
+ * via `enabled: false` (not the canonical `disabled: true`), remote servers use
13613
+ * `url`/`headers`, and per-server tool scoping lives under a `tools: { include,
13614
+ * exclude }` block (from the canonical `enabledTools`/`disabledTools`). Only
13615
+ * fields Hermes understands are emitted, so the shared `config.yaml` is not
13616
+ * polluted with canonical-only aliases (`type`, `transport`, `httpUrl`,
13617
+ * `networkTimeout`, ...).
13500
13618
  */
13501
13619
  function convertServerToHermes(config) {
13502
13620
  const out = {};
@@ -13520,6 +13638,10 @@ function convertServerToHermes(config) {
13520
13638
  if (config.disabled === true) out.enabled = false;
13521
13639
  const timeout = resolveHermesTimeout(config);
13522
13640
  if (timeout !== void 0) out.timeout = timeout;
13641
+ const tools = {};
13642
+ if (isStringArray(config.enabledTools)) tools.include = config.enabledTools;
13643
+ if (isStringArray(config.disabledTools)) tools.exclude = config.disabledTools;
13644
+ if (Object.keys(tools).length > 0) out.tools = tools;
13523
13645
  return out;
13524
13646
  }
13525
13647
  /**
@@ -13561,6 +13683,10 @@ function convertFromHermesFormat(mcpServers) {
13561
13683
  if (isPlainObject(config.headers)) server.headers = omitPrototypePollutionKeys(config.headers);
13562
13684
  if (config.enabled === false) server.disabled = true;
13563
13685
  if (typeof config.timeout === "number") server.networkTimeout = config.timeout;
13686
+ if (isRecord(config.tools)) {
13687
+ if (isStringArray(config.tools.include)) server.enabledTools = config.tools.include;
13688
+ if (isStringArray(config.tools.exclude)) server.disabledTools = config.tools.exclude;
13689
+ }
13564
13690
  result[name] = server;
13565
13691
  }
13566
13692
  return result;
@@ -15716,17 +15842,232 @@ const PermissionActionSchema = zod_mini.z.enum([
15716
15842
  */
15717
15843
  const PermissionRulesSchema = zod_mini.z.record(zod_mini.z.string(), PermissionActionSchema);
15718
15844
  /**
15845
+ * OpenCode-specific permission value. Unlike the shared canonical block, which
15846
+ * only accepts a pattern-to-action map, OpenCode also allows a bare action
15847
+ * string that applies to the whole category (e.g. `"external_directory": "deny"`).
15848
+ * This mirrors OpenCode's own permission schema in `opencode-permissions.ts`.
15849
+ */
15850
+ const OpencodeOverridePermissionValueSchema = zod_mini.z.union([PermissionActionSchema, PermissionRulesSchema]);
15851
+ /**
15852
+ * Tool-scoped override block for OpenCode. Permission categories placed here
15853
+ * (e.g. OpenCode-only categories such as `external_directory`) are emitted only
15854
+ * into OpenCode's config and never leak into other tools' permission files. It
15855
+ * also lets a shared category be overridden with an OpenCode-specific value.
15856
+ * Kept `looseObject` so future OpenCode categories are accepted.
15857
+ *
15858
+ * @example
15859
+ * { "permission": { "external_directory": "deny", "webfetch": "allow" } }
15860
+ */
15861
+ const OpencodePermissionsOverrideSchema = zod_mini.z.looseObject({ permission: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), OpencodeOverridePermissionValueSchema)) });
15862
+ /**
15863
+ * Tool-scoped override block for Hermes Agent. Keys placed here are deep-merged
15864
+ * into Hermes's `~/.hermes/config.yaml` and never leak into other tools' configs.
15865
+ * It carries Hermes-specific approval/security controls that have no canonical
15866
+ * permission category — e.g. `approvals` (`mode`, `cron_mode`, ...),
15867
+ * `security` (`allow_private_urls`, ...), `skills.write_approval`,
15868
+ * `memory.write_approval`. Kept `looseObject` (a verbatim passthrough) so any
15869
+ * current or future Hermes config key can be authored without modeling each one.
15870
+ *
15871
+ * @example
15872
+ * { "approvals": { "mode": "smart" }, "security": { "allow_private_urls": false } }
15873
+ */
15874
+ const HermesPermissionsOverrideSchema = zod_mini.z.looseObject({});
15875
+ /**
15876
+ * Tool-scoped override block for Cline. Cline's `command-permissions.json`
15877
+ * carries a single global `allowRedirects` boolean (gates shell redirection
15878
+ * operators `>`/`>>`/`<`) that has no per-command dimension and therefore no
15879
+ * canonical permission category. Placing it here lets users author it
15880
+ * declaratively; it is emitted only into Cline's config. Kept `looseObject` so
15881
+ * future Cline-only knobs can be added.
15882
+ *
15883
+ * @example
15884
+ * { "allowRedirects": true }
15885
+ */
15886
+ const ClinePermissionsOverrideSchema = zod_mini.z.looseObject({ allowRedirects: zod_mini.z.optional(zod_mini.z.boolean()) });
15887
+ /**
15888
+ * Tool-scoped override block for Kilo Code (an OpenCode fork). Kilo's permission
15889
+ * object is a free-form record with tool-specific keys that have no canonical
15890
+ * category — OpenCode-inherited ones (`external_directory`, `doom_loop`, `lsp`,
15891
+ * `question`, `todowrite`, `skill`, `task`, `list`) and Kilo-unique ones
15892
+ * (`agent_manager`, `notebook_read`, `notebook_edit`, `notebook_execute`,
15893
+ * `repo_clone`, `repo_overview`). Placing them here makes them authorable and
15894
+ * portable and keeps them out of other tools' configs. Mirrors the OpenCode
15895
+ * override; each value may be a bare action string or a pattern map.
15896
+ *
15897
+ * @example
15898
+ * { "permission": { "external_directory": "deny", "doom_loop": "ask" } }
15899
+ */
15900
+ const KiloPermissionsOverrideSchema = zod_mini.z.looseObject({ permission: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), OpencodeOverridePermissionValueSchema)) });
15901
+ /**
15902
+ * Tool-scoped override block for Claude Code. Claude Code's `permissions` object
15903
+ * (in `.claude/settings.json`) carries non-list fields that have no canonical
15904
+ * permission category — `defaultMode` (the session-start permission mode) and
15905
+ * `additionalDirectories` (extra working directories) being the primary ones.
15906
+ * Fields placed under `claudecode.permissions` are merged into the settings
15907
+ * `permissions` object and emitted only for Claude Code, while the shared
15908
+ * `permission` block continues to drive the `allow`/`ask`/`deny` arrays. Kept a
15909
+ * `looseObject` passthrough so any current or future `permissions` field can be
15910
+ * authored without modeling each one; the managed `allow`/`ask`/`deny` arrays are
15911
+ * ignored here (rulesync owns them).
15912
+ *
15913
+ * @example
15914
+ * { "permissions": { "defaultMode": "acceptEdits", "additionalDirectories": ["../shared"] } }
15915
+ */
15916
+ const ClaudecodePermissionsOverrideSchema = zod_mini.z.looseObject({ permissions: zod_mini.z.optional(zod_mini.z.looseObject({})) });
15917
+ /**
15918
+ * Tool-scoped override block for Mistral Vibe. Vibe's per-tool `BaseToolConfig`
15919
+ * carries a `sensitive_patterns` list — patterns that escalate to ASK even when
15920
+ * the tool's base permission is ALWAYS (allow). The canonical model can only set
15921
+ * a pattern to a single `allow`/`ask`/`deny`, so an "allow by default but ask on
15922
+ * these patterns" escalation cannot be expressed. Entries under
15923
+ * `vibe.permission.<category>.sensitive_patterns` carry that list per canonical
15924
+ * category; the shared `permission` block still drives the base permission and
15925
+ * allow/deny lists. Keyed by canonical category (e.g. `bash`, `edit`).
15926
+ *
15927
+ * @example
15928
+ * { "permission": { "bash": { "sensitive_patterns": ["rm *", "sudo *"] } } }
15929
+ */
15930
+ const VibePermissionsOverrideSchema = zod_mini.z.looseObject({ permission: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), zod_mini.z.looseObject({ sensitive_patterns: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())) }))) });
15931
+ /**
15932
+ * Tool-scoped override block for Cursor CLI. Cursor's `cli.json` carries scalar
15933
+ * autonomy settings with no canonical permission category — `approvalMode`
15934
+ * (`allowlist` | `auto-review` | `unrestricted`) and a `sandbox` object
15935
+ * (`mode`/`networkAccess`). Fields placed here are merged into the top-level of
15936
+ * `.cursor/cli.json` (project) / `~/.cursor/cli-config.json` (global) and emitted
15937
+ * only for Cursor, while the shared `permission` block continues to drive the
15938
+ * `permissions.allow`/`permissions.deny` arrays. Kept a `looseObject` so extra
15939
+ * `cli.json` keys can be authored (they are merged verbatim on generate);
15940
+ * `sandbox`'s accepted values are not documented so it passes through verbatim.
15941
+ * Note: only `approvalMode` and `sandbox` round-trip back on import — other keys
15942
+ * authored here reach `cli.json` on generate but are not re-extracted.
15943
+ *
15944
+ * @example
15945
+ * { "approvalMode": "auto-review" }
15946
+ */
15947
+ const CursorPermissionsOverrideSchema = zod_mini.z.looseObject({
15948
+ approvalMode: zod_mini.z.optional(zod_mini.z.string()),
15949
+ sandbox: zod_mini.z.optional(zod_mini.z.looseObject({}))
15950
+ });
15951
+ /**
15952
+ * Tool-scoped override block for Qwen Code. Qwen's `settings.json` exposes
15953
+ * autonomy/sandbox controls with no canonical permission category — under
15954
+ * `tools` (`approvalMode` = plan/default/auto-edit/auto/yolo, `autoAccept`,
15955
+ * `sandbox`, `sandboxImage`, `disabled`) and `security` (`folderTrust`). Fields
15956
+ * placed here are merged into the matching `settings.json` group and emitted
15957
+ * only for Qwen, while the shared `permission` block continues to drive the
15958
+ * `permissions.allow`/`ask`/`deny` arrays. Kept `looseObject` (verbatim
15959
+ * passthrough) so any current or future `tools`/`security` key can be authored.
15960
+ *
15961
+ * @example
15962
+ * { "tools": { "approvalMode": "auto-edit" }, "security": { "folderTrust": { "enabled": true } } }
15963
+ */
15964
+ const QwencodePermissionsOverrideSchema = zod_mini.z.looseObject({
15965
+ tools: zod_mini.z.optional(zod_mini.z.looseObject({})),
15966
+ security: zod_mini.z.optional(zod_mini.z.looseObject({}))
15967
+ });
15968
+ /**
15969
+ * Tool-scoped override block for Reasonix. Reasonix has security axes orthogonal
15970
+ * to per-tool allow/ask/deny with no canonical category — the `[sandbox]`
15971
+ * enforcement table (`workspace_root`, `allow_write`, `forbid_read`, `bash`,
15972
+ * `network`) and plan-mode read-only trust lists under `[agent]`
15973
+ * (`plan_mode_allowed_tools`, `plan_mode_read_only_commands`). Fields placed here
15974
+ * are merged into the matching `reasonix.toml` table and emitted only for
15975
+ * Reasonix, while the shared `permission` block continues to drive
15976
+ * `[permissions].allow`/`ask`/`deny`. Kept `looseObject` (verbatim passthrough).
15977
+ * Note: the whole `[sandbox]` table round-trips, but only the plan-mode keys are
15978
+ * re-extracted from `[agent]` on import — other `agent` keys authored here reach
15979
+ * `reasonix.toml` on generate but are not re-extracted back into the override.
15980
+ *
15981
+ * @example
15982
+ * { "sandbox": { "bash": "enforce", "network": false }, "agent": { "plan_mode_read_only_commands": ["gh pr diff"] } }
15983
+ */
15984
+ const ReasonixPermissionsOverrideSchema = zod_mini.z.looseObject({
15985
+ sandbox: zod_mini.z.optional(zod_mini.z.looseObject({})),
15986
+ agent: zod_mini.z.optional(zod_mini.z.looseObject({}))
15987
+ });
15988
+ /**
15989
+ * Tool-scoped override block for Factory Droid. Factory Droid's `settings.json`
15990
+ * exposes security controls with no canonical per-command allow/ask/deny slot —
15991
+ * `commandBlocklist` (a hard-block tier that can never be approved, distinct from
15992
+ * an approvable `deny`), `networkPolicy` (`allowedIps`), `sandbox`
15993
+ * (`enabled`/`mode`/`filesystem`/`network`), `mcpPolicy`, `enableDroidShield`,
15994
+ * and autonomy settings (`sessionDefaultSettings`, `maxAutonomyLevel`,
15995
+ * `interactionMode`). Fields placed here are merged into `settings.json` and
15996
+ * emitted only for Factory Droid, while the shared `permission` block continues
15997
+ * to drive `commandAllowlist`/`commandDenylist`. Kept `looseObject` passthrough.
15998
+ *
15999
+ * @example
16000
+ * { "commandBlocklist": ["rm -rf /*"], "sandbox": { "enabled": true } }
16001
+ */
16002
+ const FactorydroidPermissionsOverrideSchema = zod_mini.z.looseObject({ commandBlocklist: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())) });
16003
+ /**
16004
+ * Tool-scoped override block for Warp. Warp's `[agents.profiles]` table exposes
16005
+ * file-read/read-only autonomy keys with no canonical per-command allow/ask/deny
16006
+ * slot — `agent_mode_coding_permissions`
16007
+ * (`always_ask_before_reading` | `always_allow_reading` | `allow_reading_specific_files`),
16008
+ * `agent_mode_coding_file_read_allowlist` (a path array), and
16009
+ * `agent_mode_execute_readonly_commands` (a read-only auto-execution boolean).
16010
+ * Fields placed here are merged into `[agents.profiles]` of Warp's global
16011
+ * `settings.toml`, while the shared `permission` block continues to drive the
16012
+ * `agent_mode_command_execution_allowlist`/`_denylist` command regex arrays.
16013
+ * Warp permissions are global-only.
16014
+ *
16015
+ * @example
16016
+ * { "agent_mode_coding_permissions": "always_allow_reading", "agent_mode_execute_readonly_commands": true }
16017
+ */
16018
+ const WarpPermissionsOverrideSchema = zod_mini.z.looseObject({
16019
+ agent_mode_coding_permissions: zod_mini.z.optional(zod_mini.z.string()),
16020
+ agent_mode_coding_file_read_allowlist: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
16021
+ agent_mode_execute_readonly_commands: zod_mini.z.optional(zod_mini.z.boolean())
16022
+ });
16023
+ /**
16024
+ * Tool-scoped override block for JetBrains Junie. Junie's `allowlist.json` has
16025
+ * two top-level autonomy knobs with no canonical per-glob slot:
16026
+ * `allowReadonlyCommands` (a boolean auto-allowing read-only commands) and
16027
+ * `defaultBehavior` (the fallback action applied when no rule matches — Junie
16028
+ * documents only `allow`/`ask`). Fields placed here are merged onto the
16029
+ * top level of `allowlist.json`, while the shared `permission` block continues
16030
+ * to drive the per-category `rules` groups.
16031
+ *
16032
+ * @example
16033
+ * { "allowReadonlyCommands": true, "defaultBehavior": "ask" }
16034
+ */
16035
+ const JuniePermissionsOverrideSchema = zod_mini.z.looseObject({
16036
+ allowReadonlyCommands: zod_mini.z.optional(zod_mini.z.boolean()),
16037
+ defaultBehavior: zod_mini.z.optional(zod_mini.z.string())
16038
+ });
16039
+ /**
15719
16040
  * Permissions configuration.
15720
16041
  * Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
15721
16042
  * Values are pattern-to-action mappings for that tool category.
15722
16043
  *
16044
+ * The optional `opencode`/`hermes`/`cline`/`kilo`/`claudecode`/`vibe`/`cursor`/
16045
+ * `qwencode`/`reasonix`/`factorydroid`/`warp`/`junie` keys are tool-scoped
16046
+ * overrides consumed only by their respective translator (see the matching
16047
+ * `*PermissionsOverrideSchema`); every other tool reads the shared `permission`
16048
+ * block and ignores them.
16049
+ *
15723
16050
  * @example
15724
16051
  * {
15725
16052
  * "bash": { "*": "ask", "git *": "allow", "rm *": "deny" },
15726
16053
  * "edit": { "*": "deny", "src/**": "allow" }
15727
16054
  * }
15728
16055
  */
15729
- const PermissionsConfigSchema = zod_mini.z.looseObject({ permission: zod_mini.z.record(zod_mini.z.string(), PermissionRulesSchema) });
16056
+ const PermissionsConfigSchema = zod_mini.z.looseObject({
16057
+ permission: zod_mini.z.record(zod_mini.z.string(), PermissionRulesSchema),
16058
+ opencode: zod_mini.z.optional(OpencodePermissionsOverrideSchema),
16059
+ hermes: zod_mini.z.optional(HermesPermissionsOverrideSchema),
16060
+ cline: zod_mini.z.optional(ClinePermissionsOverrideSchema),
16061
+ kilo: zod_mini.z.optional(KiloPermissionsOverrideSchema),
16062
+ claudecode: zod_mini.z.optional(ClaudecodePermissionsOverrideSchema),
16063
+ vibe: zod_mini.z.optional(VibePermissionsOverrideSchema),
16064
+ cursor: zod_mini.z.optional(CursorPermissionsOverrideSchema),
16065
+ qwencode: zod_mini.z.optional(QwencodePermissionsOverrideSchema),
16066
+ reasonix: zod_mini.z.optional(ReasonixPermissionsOverrideSchema),
16067
+ factorydroid: zod_mini.z.optional(FactorydroidPermissionsOverrideSchema),
16068
+ warp: zod_mini.z.optional(WarpPermissionsOverrideSchema),
16069
+ junie: zod_mini.z.optional(JuniePermissionsOverrideSchema)
16070
+ });
15730
16071
  /**
15731
16072
  * Full permissions file schema including optional $schema field.
15732
16073
  */
@@ -17097,32 +17438,24 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
17097
17438
  }
17098
17439
  const config = rulesyncPermissions.getJson();
17099
17440
  const { allow, ask, deny } = convertRulesyncToClaudePermissions(config);
17100
- const managedToolNames = new Set(Object.keys(config.permission).map((category) => toClaudeToolName(category)));
17101
- const existingPermissions = settings.permissions ?? {};
17102
- const preservedAllow = (existingPermissions.allow ?? []).filter((entry) => !managedToolNames.has(parseClaudePermissionEntry(entry).toolName));
17103
- const preservedAsk = (existingPermissions.ask ?? []).filter((entry) => !managedToolNames.has(parseClaudePermissionEntry(entry).toolName));
17104
- const preservedDeny = (existingPermissions.deny ?? []).filter((entry) => !managedToolNames.has(parseClaudePermissionEntry(entry).toolName));
17105
- if (logger && managedToolNames.has("Read")) {
17106
- const droppedReadDenyEntries = (existingPermissions.deny ?? []).filter((entry) => {
17107
- const { toolName } = parseClaudePermissionEntry(entry);
17108
- return toolName === "Read";
17109
- });
17110
- 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.`);
17441
+ const overridePermissions = config.claudecode?.permissions;
17442
+ if (overridePermissions && typeof overridePermissions === "object") {
17443
+ const { allow: _a, ask: _k, deny: _d, ...nonListFields } = overridePermissions;
17444
+ settings.permissions = {
17445
+ ...settings.permissions,
17446
+ ...nonListFields
17447
+ };
17111
17448
  }
17112
- const mergedPermissions = { ...existingPermissions };
17113
- const mergedAllow = (0, es_toolkit.uniq)([...preservedAllow, ...allow].toSorted());
17114
- const mergedAsk = (0, es_toolkit.uniq)([...preservedAsk, ...ask].toSorted());
17115
- const mergedDeny = (0, es_toolkit.uniq)([...preservedDeny, ...deny].toSorted());
17116
- if (mergedAllow.length > 0) mergedPermissions.allow = mergedAllow;
17117
- else delete mergedPermissions.allow;
17118
- if (mergedAsk.length > 0) mergedPermissions.ask = mergedAsk;
17119
- else delete mergedPermissions.ask;
17120
- if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
17121
- else delete mergedPermissions.deny;
17122
- const merged = {
17123
- ...settings,
17124
- permissions: mergedPermissions
17125
- };
17449
+ const managedToolNames = new Set(Object.keys(config.permission).map((category) => toClaudeToolName(category)));
17450
+ const merged = applyPermissions({
17451
+ settings,
17452
+ managedToolNames,
17453
+ toolNameOf: (entry) => parseClaudePermissionEntry(entry).toolName,
17454
+ allow,
17455
+ ask,
17456
+ deny,
17457
+ logger
17458
+ });
17126
17459
  const fileContent = JSON.stringify(merged, null, 2);
17127
17460
  return new ClaudecodePermissions({
17128
17461
  outputRoot,
@@ -17145,6 +17478,8 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
17145
17478
  ask: permissions.ask ?? [],
17146
17479
  deny: permissions.deny ?? []
17147
17480
  });
17481
+ const { allow: _a, ask: _k, deny: _d, ...nonListFields } = permissions;
17482
+ if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
17148
17483
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
17149
17484
  }
17150
17485
  validate() {
@@ -17318,7 +17653,8 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
17318
17653
  } catch (error) {
17319
17654
  throw new Error(`Failed to parse existing Cline command-permissions at ${filePath}: ${formatError(error)}`, { cause: error });
17320
17655
  }
17321
- const { allow, deny, droppedCategories, translatedAskPatterns } = translateClinePermissions(rulesyncPermissions.getJson().permission);
17656
+ const config = rulesyncPermissions.getJson();
17657
+ const { allow, deny, droppedCategories, translatedAskPatterns } = translateClinePermissions(config.permission);
17322
17658
  warnClineTranslationNotices({
17323
17659
  droppedCategories,
17324
17660
  translatedAskPatterns,
@@ -17334,7 +17670,7 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
17334
17670
  ...existing,
17335
17671
  allow: dedupedAllow,
17336
17672
  deny: mergedDeny,
17337
- allowRedirects: existing.allowRedirects ?? false
17673
+ allowRedirects: config.cline?.allowRedirects ?? existing.allowRedirects ?? false
17338
17674
  };
17339
17675
  return new ClinePermissions({
17340
17676
  outputRoot,
@@ -17358,6 +17694,7 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
17358
17694
  for (const pattern of parsed.allow ?? []) bashRules[pattern] = "allow";
17359
17695
  for (const pattern of parsed.deny ?? []) bashRules[pattern] = "deny";
17360
17696
  const config = Object.keys(bashRules).length > 0 ? { permission: { bash: bashRules } } : { permission: {} };
17697
+ if (parsed.allowRedirects === true) config.cline = { allowRedirects: true };
17361
17698
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
17362
17699
  }
17363
17700
  validate() {
@@ -17794,13 +18131,13 @@ const CURSOR_TYPE_TO_CANONICAL = {
17794
18131
  WebFetch: "webfetch",
17795
18132
  Mcp: "mcp"
17796
18133
  };
17797
- const MCP_CANONICAL_PREFIX = "mcp__";
18134
+ const MCP_CANONICAL_PREFIX$1 = "mcp__";
17798
18135
  /**
17799
18136
  * Returns true if the canonical category is the per-tool MCP form
17800
18137
  * `mcp__<server>__<tool>`.
17801
18138
  */
17802
18139
  function isMcpScopedCategory(canonical) {
17803
- return canonical.startsWith(MCP_CANONICAL_PREFIX) && canonical.length > 5;
18140
+ return canonical.startsWith(MCP_CANONICAL_PREFIX$1) && canonical.length > 5;
17804
18141
  }
17805
18142
  function toCursorType(canonical) {
17806
18143
  if (isMcpScopedCategory(canonical)) return "Mcp";
@@ -17831,7 +18168,7 @@ function toCursorPattern(canonical, pattern) {
17831
18168
  function toCanonicalCategory$1(cursorType, pattern) {
17832
18169
  if (cursorType === "Mcp") {
17833
18170
  const match = pattern.match(/^([^:()]+):([^:()]+)$/);
17834
- if (match) return `${MCP_CANONICAL_PREFIX}${match[1] ?? "*"}__${match[2] ?? "*"}`;
18171
+ if (match) return `${MCP_CANONICAL_PREFIX$1}${match[1] ?? "*"}__${match[2] ?? "*"}`;
17835
18172
  return CURSOR_TYPE_TO_CANONICAL[cursorType] ?? cursorType.toLowerCase();
17836
18173
  }
17837
18174
  return CURSOR_TYPE_TO_CANONICAL[cursorType] ?? cursorType.toLowerCase();
@@ -17965,8 +18302,10 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
17965
18302
  mergedPermissions.allow = mergedAllow;
17966
18303
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
17967
18304
  else delete mergedPermissions.deny;
18305
+ const cursorOverride = config.cursor;
17968
18306
  const merged = {
17969
18307
  ...settings,
18308
+ ...cursorOverride,
17970
18309
  version: settings.version ?? 1,
17971
18310
  editor: {
17972
18311
  ...existingEditor,
@@ -17992,11 +18331,15 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
17992
18331
  }
17993
18332
  const permissionsRaw = settings.permissions;
17994
18333
  const permissions = permissionsRaw !== null && typeof permissionsRaw === "object" && !Array.isArray(permissionsRaw) ? permissionsRaw : {};
17995
- const config = convertCursorToRulesyncPermissions({
18334
+ const result = { ...convertCursorToRulesyncPermissions({
17996
18335
  allow: asCursorPermissionEntryArray(permissions.allow),
17997
18336
  deny: asCursorPermissionEntryArray(permissions.deny)
17998
- });
17999
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
18337
+ }) };
18338
+ const cursorOverride = {};
18339
+ if (settings.approvalMode !== void 0) cursorOverride.approvalMode = settings.approvalMode;
18340
+ if (settings.sandbox !== null && typeof settings.sandbox === "object" && !Array.isArray(settings.sandbox)) cursorOverride.sandbox = settings.sandbox;
18341
+ if (Object.keys(cursorOverride).length > 0) result.cursor = cursorOverride;
18342
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
18000
18343
  }
18001
18344
  validate() {
18002
18345
  return {
@@ -18055,7 +18398,7 @@ function convertCursorToRulesyncPermissions(params) {
18055
18398
  const { type, pattern } = parseCursorPermissionEntry(entry);
18056
18399
  const canonical = toCanonicalCategory$1(type, pattern);
18057
18400
  if (!permission[canonical]) permission[canonical] = {};
18058
- const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX) ? "*" : pattern;
18401
+ const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX$1) ? "*" : pattern;
18059
18402
  permission[canonical][canonicalPattern] = action;
18060
18403
  }
18061
18404
  };
@@ -18304,6 +18647,16 @@ function convertDevinToRulesyncPermissions(params) {
18304
18647
  }
18305
18648
  //#endregion
18306
18649
  //#region src/features/permissions/factorydroid-permissions.ts
18650
+ const FACTORYDROID_OVERRIDE_KEYS = [
18651
+ "commandBlocklist",
18652
+ "networkPolicy",
18653
+ "sandbox",
18654
+ "mcpPolicy",
18655
+ "enableDroidShield",
18656
+ "sessionDefaultSettings",
18657
+ "maxAutonomyLevel",
18658
+ "interactionMode"
18659
+ ];
18307
18660
  /**
18308
18661
  * Permissions adapter for Factory Droid.
18309
18662
  *
@@ -18321,18 +18674,14 @@ function convertDevinToRulesyncPermissions(params) {
18321
18674
  * skipped (with a warning when they carry `deny` rules, to surface the gap).
18322
18675
  *
18323
18676
  * Factory Droid also has a stronger `commandBlocklist` tier — commands that can
18324
- * never run, not even under full autonomy. rulesync's canonical action model
18325
- * has only `allow | ask | deny`, with no equivalent of a hard block that can
18326
- * never be approved. So on **import** a `commandBlocklist` entry is collapsed
18327
- * onto canonical `deny` (lossy: the never-runs guarantee is weakened to a deny
18328
- * the user can still approve), rather than being silently dropped. On **export**
18329
- * there is no canonical `block` to emit one from, so rulesync never writes
18330
- * `commandBlocklist`; an existing one on disk is preserved verbatim as an
18331
- * unmanaged key. (A consequence of the lossy collapse: importing a
18332
- * `commandBlocklist` and re-exporting it to a *fresh* config writes it back as
18333
- * `commandDenylist`, not `commandBlocklist` — the hard-block tier is not
18334
- * reconstructed. Re-running over the original file keeps it intact via the
18335
- * verbatim preservation above.)
18677
+ * never run, not even under full autonomy plus other security controls
18678
+ * (`networkPolicy`, `sandbox`, `mcpPolicy`, `enableDroidShield`, autonomy
18679
+ * settings) that do not fit the canonical `allow | ask | deny` per-command
18680
+ * model. These are authored and round-tripped through the `factorydroid`
18681
+ * override namespace (see `FactorydroidPermissionsOverrideSchema`): on **import**
18682
+ * they are lifted from `settings.json` into the override, and on **export** they
18683
+ * are merged back in so `commandBlocklist`'s never-runs guarantee is preserved
18684
+ * faithfully rather than being collapsed onto an approvable `deny`.
18336
18685
  */
18337
18686
  var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissions {
18338
18687
  constructor(params) {
@@ -18375,11 +18724,16 @@ var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissi
18375
18724
  } catch (error) {
18376
18725
  throw new Error(`Failed to parse existing Factory Droid settings at ${filePath}: ${formatError(error)}`, { cause: error });
18377
18726
  }
18727
+ const config = rulesyncPermissions.getJson();
18378
18728
  const { allow, deny } = convertRulesyncToFactorydroidPermissions({
18379
- config: rulesyncPermissions.getJson(),
18729
+ config,
18380
18730
  logger
18381
18731
  });
18382
- const merged = { ...settings };
18732
+ const override = config.factorydroid;
18733
+ const merged = {
18734
+ ...settings,
18735
+ ...override !== void 0 && typeof override === "object" ? override : {}
18736
+ };
18383
18737
  const mergedAllow = (0, es_toolkit.uniq)(allow.toSorted());
18384
18738
  const mergedDeny = (0, es_toolkit.uniq)(deny.toSorted());
18385
18739
  if (mergedAllow.length > 0) merged.commandAllowlist = mergedAllow;
@@ -18403,10 +18757,13 @@ var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissi
18403
18757
  }
18404
18758
  const config = convertFactorydroidToRulesyncPermissions({
18405
18759
  allow: Array.isArray(settings.commandAllowlist) ? settings.commandAllowlist : [],
18406
- deny: Array.isArray(settings.commandDenylist) ? settings.commandDenylist : [],
18407
- block: Array.isArray(settings.commandBlocklist) ? settings.commandBlocklist : []
18760
+ deny: Array.isArray(settings.commandDenylist) ? settings.commandDenylist : []
18408
18761
  });
18409
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
18762
+ const factorydroidOverride = {};
18763
+ for (const key of FACTORYDROID_OVERRIDE_KEYS) if (settings[key] !== void 0) factorydroidOverride[key] = settings[key];
18764
+ const result = { ...config };
18765
+ if (Object.keys(factorydroidOverride).length > 0) result.factorydroid = factorydroidOverride;
18766
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
18410
18767
  }
18411
18768
  validate() {
18412
18769
  return {
@@ -18453,18 +18810,18 @@ function convertRulesyncToFactorydroidPermissions({ config, logger }) {
18453
18810
  };
18454
18811
  }
18455
18812
  /**
18456
- * Convert Factory Droid allow/deny/block command lists back to rulesync config
18457
- * under the `bash` category.
18813
+ * Convert Factory Droid allow/deny command lists back to rulesync config under
18814
+ * the `bash` category.
18458
18815
  *
18459
- * `commandBlocklist` (hard block) has no canonical equivalent, so it collapses
18460
- * onto `deny` lossy (a deny can still be approved), but preferable to dropping
18461
- * the rule entirely.
18816
+ * `commandBlocklist` (the hard-block tier) is no longer collapsed here it has
18817
+ * no canonical equivalent and now round-trips through the `factorydroid`
18818
+ * override so the never-runs guarantee is preserved instead of being weakened to
18819
+ * an approvable `deny`.
18462
18820
  */
18463
- function convertFactorydroidToRulesyncPermissions({ allow, deny, block }) {
18821
+ function convertFactorydroidToRulesyncPermissions({ allow, deny }) {
18464
18822
  const bash = {};
18465
18823
  for (const pattern of allow) bash[pattern] = "allow";
18466
18824
  for (const pattern of deny) bash[pattern] = "deny";
18467
- for (const pattern of block) bash[pattern] = "deny";
18468
18825
  return { permission: Object.keys(bash).length > 0 ? { bash } : {} };
18469
18826
  }
18470
18827
  //#endregion
@@ -18658,30 +19015,114 @@ function convertGoosePermissionConfigToRulesync(userPermission) {
18658
19015
  const GROKCLI_GLOBAL_ONLY_MESSAGE = "Grok CLI permissions are global-only; use --global to sync ~/.grok/config.toml";
18659
19016
  const GROKCLI_UI_KEY = "ui";
18660
19017
  const GROKCLI_PERMISSION_MODE_KEY = "permission_mode";
19018
+ const GROKCLI_PERMISSION_KEY = "permission";
18661
19019
  const CATCH_ALL_PATTERN$2 = "*";
19020
+ const MCP_CANONICAL_PREFIX = "mcp__";
19021
+ const CATEGORY_TO_GROK_TOOL = {
19022
+ bash: "Bash",
19023
+ read: "Read",
19024
+ edit: "Edit",
19025
+ write: "Edit",
19026
+ grep: "Grep",
19027
+ webfetch: "WebFetch"
19028
+ };
19029
+ const GROK_TOOL_TO_CATEGORY = {
19030
+ Bash: "bash",
19031
+ Read: "read",
19032
+ Edit: "edit",
19033
+ Grep: "grep",
19034
+ WebFetch: "webfetch"
19035
+ };
19036
+ const GROK_MCP_TOOL = "MCPTool";
19037
+ /**
19038
+ * Build a Grok Claude-style permission entry (e.g. `Bash(git *)`, `Read`,
19039
+ * `MCPTool(server__tool)`) from a canonical category + pattern. Returns `null`
19040
+ * for categories Grok cannot express so callers can skip them.
19041
+ *
19042
+ * Scoped MCP categories (`mcp__<remainder>`) fold their address into the
19043
+ * parentheses (`MCPTool(<remainder>)`); the bare `mcp` category becomes
19044
+ * `MCPTool`. For every other tool, a `*` pattern emits the bare tool name and a
19045
+ * concrete pattern emits `Tool(pattern)`.
19046
+ */
19047
+ function buildGrokEntry(category, pattern) {
19048
+ if (category.startsWith(MCP_CANONICAL_PREFIX)) {
19049
+ const remainder = category.slice(5);
19050
+ return remainder.length > 0 ? `${GROK_MCP_TOOL}(${remainder})` : GROK_MCP_TOOL;
19051
+ }
19052
+ if (category === "mcp") return pattern === CATCH_ALL_PATTERN$2 || pattern === "" ? GROK_MCP_TOOL : `${GROK_MCP_TOOL}(${pattern})`;
19053
+ const tool = CATEGORY_TO_GROK_TOOL[category];
19054
+ if (tool === void 0) return null;
19055
+ return pattern === CATCH_ALL_PATTERN$2 || pattern === "" ? tool : `${tool}(${pattern})`;
19056
+ }
19057
+ /**
19058
+ * Parse a Grok Claude-style permission entry back into a canonical category +
19059
+ * pattern. Entries without parentheses are treated as catch-all (`*`). Returns
19060
+ * `null` for tool prefixes with no canonical equivalent (e.g. `any`).
19061
+ */
19062
+ function parseGrokEntry(entry) {
19063
+ const trimmed = entry.trim();
19064
+ const parenIndex = trimmed.indexOf("(");
19065
+ let tool;
19066
+ let inner;
19067
+ if (parenIndex === -1 || !trimmed.endsWith(")")) {
19068
+ tool = trimmed;
19069
+ inner = "";
19070
+ } else {
19071
+ tool = trimmed.slice(0, parenIndex);
19072
+ inner = trimmed.slice(parenIndex + 1, -1).trim();
19073
+ }
19074
+ if (tool === GROK_MCP_TOOL) return inner.length > 0 ? {
19075
+ category: `${MCP_CANONICAL_PREFIX}${inner}`,
19076
+ pattern: CATCH_ALL_PATTERN$2
19077
+ } : {
19078
+ category: "mcp",
19079
+ pattern: CATCH_ALL_PATTERN$2
19080
+ };
19081
+ const category = GROK_TOOL_TO_CATEGORY[tool];
19082
+ if (category === void 0) return null;
19083
+ return {
19084
+ category,
19085
+ pattern: inner.length > 0 ? inner : CATCH_ALL_PATTERN$2
19086
+ };
19087
+ }
18662
19088
  /**
18663
19089
  * Permissions adapter for the xAI Grok Build CLI (`grokcli`).
18664
19090
  *
18665
- * Grok has no per-tool / per-pattern permission rules. Tool gating is a single
18666
- * coarse toggle, `[ui] permission_mode`, in `~/.grok/config.toml`:
18667
- * - `ask` prompt before each tool call (Grok's default).
18668
- * - `always-approve` skip prompts.
18669
- *
18670
- * rulesync's permission model is per-category, per-pattern `allow`/`ask`/`deny`,
18671
- * so the mapping is **lossy** (a single mode cannot express per-pattern rules):
18672
- * - Generate: any `deny` or `ask` rule anywhere `ask` (conservative — keep
18673
- * prompting whenever the user expressed any restriction); otherwise, if at
18674
- * least one `allow` rule exists and nothing is denied/asked
18675
- * `always-approve`; an empty config falls back to the safe default `ask`.
18676
- * - Import: `always-approve` `bash: { "*": "allow" }`; `ask` (or unset)
18677
- * `bash: { "*": "ask" }`. `bash` is used as the representative catch-all
18678
- * because it is the primary permission-gated surface, and this round-trips
18679
- * the generate mapping.
18680
- *
18681
- * This surface is **global only** `permission_mode` lives in the user-level
18682
- * `~/.grok/config.toml`; Grok has no project-scoped permission file. The shared
18683
- * config is merged in place: the `[ui] permission_mode` value is set and every
18684
- * other key (e.g. `[mcp_servers]`, legacy `approval_mode`) is preserved. The
19091
+ * Grok Build CLI ships a Claude-style rule system under `[permission]` in
19092
+ * `~/.grok/config.toml`: `allow` / `deny` / `ask` arrays of entries such as
19093
+ * `Bash(git *)`, `Read(src/**)`, `Edit`, `Grep`, `MCPTool(server__tool)`, and
19094
+ * `WebFetch`, evaluated with precedence `deny > ask > allow`
19095
+ * (https://docs.x.ai/build/settings/reference). rulesync's canonical
19096
+ * per-category, per-pattern model maps almost 1:1:
19097
+ * - Generate: each `permission.<category>.<pattern> = allow|ask|deny` becomes
19098
+ * the matching Grok entry and is bucketed into the `[permission]` array for
19099
+ * that action. `bash|read|edit|grep|webfetch` map to their Grok tool;
19100
+ * `write` collapses onto `Edit` (Grok has no `Write` tool); `mcp__*` maps to
19101
+ * `MCPTool(...)` (a scoped MCP category folds its address into the
19102
+ * parentheses, so a non-`*` argument pattern on it is not represented).
19103
+ * Categories with no Grok tool (`websearch`, `glob`, `notebookedit`,
19104
+ * `agent`) are skipped (with a warning when they carry a `deny` rule, to
19105
+ * surface the gap). When two canonical rules collapse onto the same Grok
19106
+ * entry with different actions (e.g. `edit` allow + `write` deny → `Edit`),
19107
+ * the strictest wins (`deny > ask > allow`) and a warning is logged, so the
19108
+ * entry never lands contradictorily in two arrays.
19109
+ * - Import: the `[permission]` arrays are parsed back into canonical
19110
+ * categories. When no `[permission]` section is present (older configs), the
19111
+ * coarse `[ui] permission_mode` is used as a fallback.
19112
+ *
19113
+ * The coarse `[ui] permission_mode` toggle is still written for backward
19114
+ * compatibility with older Grok versions: `always-approve` when the config is
19115
+ * pure-`allow`, otherwise `ask` (conservative — it is never `always-approve`
19116
+ * while any `deny`/`ask` rule exists, so it never contradicts the fine-grained
19117
+ * arrays).
19118
+ *
19119
+ * This surface is **global only** — the adapter syncs the user-level
19120
+ * `~/.grok/config.toml`. (Grok also supports a project-scoped `[permission]`
19121
+ * file; project scope is not modeled here.) The shared config is merged in
19122
+ * place: rulesync owns the `[permission]` `allow`/`deny`/`ask` entries for the
19123
+ * tools it models and the `[ui] permission_mode` value, while every other key
19124
+ * (e.g. `[mcp_servers]`, `[permission] rules`, `[sandbox]`) and any user-authored
19125
+ * entries for tools rulesync cannot model (e.g. `WebSearch`) are preserved. The
18685
19126
  * file is never deleted.
18686
19127
  */
18687
19128
  var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
@@ -18713,7 +19154,7 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18713
19154
  global: true
18714
19155
  });
18715
19156
  }
18716
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false }) {
19157
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger, global = false }) {
18717
19158
  if (!global) throw new Error(GROKCLI_GLOBAL_ONLY_MESSAGE);
18718
19159
  const paths = GrokcliPermissions.getSettablePaths({ global });
18719
19160
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
@@ -18724,7 +19165,16 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18724
19165
  } catch (error) {
18725
19166
  throw new Error(`Failed to parse existing Grok config.toml at ${filePath}: ${formatError(error)}`, { cause: error });
18726
19167
  }
18727
- const mode = deriveGrokPermissionMode(rulesyncPermissions.getJson());
19168
+ const config = rulesyncPermissions.getJson();
19169
+ const existingPermission = isRecord(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {};
19170
+ const buckets = buildGrokPermissionArrays(config, existingPermission, logger);
19171
+ parsed[GROKCLI_PERMISSION_KEY] = {
19172
+ ...existingPermission,
19173
+ allow: buckets.allow,
19174
+ deny: buckets.deny,
19175
+ ask: buckets.ask
19176
+ };
19177
+ const mode = deriveGrokPermissionMode(config);
18728
19178
  const existingUi = isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {};
18729
19179
  parsed[GROKCLI_UI_KEY] = {
18730
19180
  ...existingUi,
@@ -18747,8 +19197,8 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18747
19197
  } catch (error) {
18748
19198
  throw new Error(`Failed to parse Grok config.toml content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
18749
19199
  }
18750
- const action = (isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {})[GROKCLI_PERMISSION_MODE_KEY] === "always-approve" ? "allow" : "ask";
18751
- const rulesyncConfig = { permission: { bash: { [CATCH_ALL_PATTERN$2]: action } } };
19200
+ const fineGrained = parseGrokPermissionArrays(isRecord(parsed[GROKCLI_PERMISSION_KEY]) ? parsed[GROKCLI_PERMISSION_KEY] : {});
19201
+ const rulesyncConfig = fineGrained ? { permission: fineGrained } : { permission: { bash: { [CATCH_ALL_PATTERN$2]: legacyModeAction(parsed) } } };
18752
19202
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
18753
19203
  }
18754
19204
  validate() {
@@ -18768,6 +19218,84 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18768
19218
  });
18769
19219
  }
18770
19220
  };
19221
+ const ACTION_RANK = {
19222
+ allow: 0,
19223
+ ask: 1,
19224
+ deny: 2
19225
+ };
19226
+ /**
19227
+ * Collect user-authored entries from an existing `[permission]` array whose
19228
+ * tool prefix rulesync cannot model (e.g. `WebSearch`, `any`). Such entries are
19229
+ * preserved verbatim so replacing the arrays does not silently drop them
19230
+ * (mirrors the Cursor adapter's preservation of unmanaged types).
19231
+ */
19232
+ function unmanagedEntries(existingPermission, key) {
19233
+ return (isStringArray(existingPermission[key]) ? existingPermission[key] : []).filter((entry) => parseGrokEntry(entry) === null);
19234
+ }
19235
+ /**
19236
+ * Bucket canonical rules into Grok's `[permission]` allow/deny/ask arrays of
19237
+ * Claude-style entries, resolving collapse collisions via `deny > ask > allow`.
19238
+ * Categories Grok cannot express are skipped (with a warning when they carry a
19239
+ * `deny` rule); entries the user authored for tools rulesync cannot model are
19240
+ * preserved from `existingPermission`.
19241
+ */
19242
+ function buildGrokPermissionArrays(config, existingPermission, logger) {
19243
+ const ranked = /* @__PURE__ */ new Map();
19244
+ for (const [category, rules] of Object.entries(config.permission)) for (const [pattern, action] of Object.entries(rules)) {
19245
+ const entry = buildGrokEntry(category, pattern);
19246
+ if (entry === null) {
19247
+ 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.`);
19248
+ continue;
19249
+ }
19250
+ const existing = ranked.get(entry);
19251
+ 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).`);
19252
+ if (existing === void 0 || ACTION_RANK[action] > ACTION_RANK[existing]) ranked.set(entry, action);
19253
+ }
19254
+ const allow = unmanagedEntries(existingPermission, "allow");
19255
+ const deny = unmanagedEntries(existingPermission, "deny");
19256
+ const ask = unmanagedEntries(existingPermission, "ask");
19257
+ for (const [entry, action] of ranked) if (action === "allow") allow.push(entry);
19258
+ else if (action === "deny") deny.push(entry);
19259
+ else ask.push(entry);
19260
+ return {
19261
+ allow: (0, es_toolkit.uniq)(allow.toSorted()),
19262
+ deny: (0, es_toolkit.uniq)(deny.toSorted()),
19263
+ ask: (0, es_toolkit.uniq)(ask.toSorted())
19264
+ };
19265
+ }
19266
+ /**
19267
+ * Parse Grok's `[permission]` allow/deny/ask arrays back into a canonical
19268
+ * permission map. Returns `null` when the section defines none of the three
19269
+ * arrays, so the caller can fall back to the coarse `permission_mode`.
19270
+ * Precedence `deny > ask > allow` is applied so a tool listed in multiple
19271
+ * arrays resolves to the strictest action.
19272
+ */
19273
+ function parseGrokPermissionArrays(permission) {
19274
+ const allow = isStringArray(permission.allow) ? permission.allow : void 0;
19275
+ const deny = isStringArray(permission.deny) ? permission.deny : void 0;
19276
+ const ask = isStringArray(permission.ask) ? permission.ask : void 0;
19277
+ if ((allow?.length ?? 0) + (deny?.length ?? 0) + (ask?.length ?? 0) === 0) return null;
19278
+ const result = {};
19279
+ const apply = (entries, action) => {
19280
+ for (const entry of entries ?? []) {
19281
+ const parsed = parseGrokEntry(entry);
19282
+ if (parsed === null) continue;
19283
+ const bucket = result[parsed.category] ??= {};
19284
+ bucket[parsed.pattern] = action;
19285
+ }
19286
+ };
19287
+ apply(allow, "allow");
19288
+ apply(ask, "ask");
19289
+ apply(deny, "deny");
19290
+ return result;
19291
+ }
19292
+ /**
19293
+ * Legacy coarse fallback: map `[ui] permission_mode` to a canonical action.
19294
+ * `always-approve` ⇒ `allow`; anything else (including a missing mode) ⇒ `ask`.
19295
+ */
19296
+ function legacyModeAction(parsed) {
19297
+ return (isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {})[GROKCLI_PERMISSION_MODE_KEY] === "always-approve" ? "allow" : "ask";
19298
+ }
18771
19299
  /**
18772
19300
  * Collapse a rulesync permissions config into Grok's single coarse mode.
18773
19301
  * Any `deny`/`ask` rule anywhere keeps prompting (`ask`); otherwise an existing
@@ -18783,6 +19311,10 @@ function deriveGrokPermissionMode(config) {
18783
19311
  }
18784
19312
  //#endregion
18785
19313
  //#region src/features/permissions/hermesagent-permissions.ts
19314
+ /** Collect the glob patterns in a canonical category that carry a given action. */
19315
+ function patternsByAction(category, action) {
19316
+ return Object.entries(category ?? {}).filter(([, value]) => value === action).map(([pattern]) => pattern);
19317
+ }
18786
19318
  var HermesagentPermissions = class HermesagentPermissions extends ToolPermissions {
18787
19319
  static getSettablePaths() {
18788
19320
  return {
@@ -18806,7 +19338,11 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18806
19338
  return true;
18807
19339
  }
18808
19340
  setFileContent(fileContent) {
18809
- this.fileContent = mergeHermesConfig(fileContent, parseHermesConfig(this.fileContent));
19341
+ const existing = parseHermesConfig(fileContent);
19342
+ const generated = parseHermesConfig(this.fileContent);
19343
+ const merged = deepMergeHermesConfig(existing, generated);
19344
+ if (generated.permissions !== void 0) merged.permissions = generated.permissions;
19345
+ this.fileContent = stringifyHermesConfig(merged);
18810
19346
  }
18811
19347
  toRulesyncPermissions() {
18812
19348
  const config = parseHermesConfig(this.getFileContent());
@@ -18819,12 +19355,22 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18819
19355
  }
18820
19356
  static fromRulesyncPermissions({ outputRoot, rulesyncPermissions }) {
18821
19357
  const permissions = rulesyncPermissions.getJson();
19358
+ const permissionBlock = permissions.permission ?? {};
19359
+ const commandAllowlist = Object.entries(permissionBlock).flatMap(([, patterns]) => patternsByAction(patterns, "allow"));
19360
+ const bashDeny = patternsByAction(permissionBlock.bash, "deny");
19361
+ const webfetchDeny = patternsByAction(permissionBlock.webfetch, "deny");
19362
+ let config = {};
19363
+ if (commandAllowlist.length > 0) config.command_allowlist = commandAllowlist;
19364
+ if (bashDeny.length > 0) config.approvals = { deny: bashDeny };
19365
+ if (webfetchDeny.length > 0) config.security = { website_blocklist: {
19366
+ enabled: true,
19367
+ domains: webfetchDeny
19368
+ } };
19369
+ if (permissions.hermes && typeof permissions.hermes === "object") config = deepMergeHermesConfig(config, permissions.hermes);
19370
+ config.permissions = { rulesync: permissions };
18822
19371
  return new HermesagentPermissions({
18823
19372
  outputRoot,
18824
- fileContent: stringifyHermesConfig({
18825
- command_allowlist: Object.entries(permissions.permission ?? {}).flatMap(([, patterns]) => Object.entries(patterns).filter(([, action]) => action === "allow").map(([command]) => command)),
18826
- permissions: { rulesync: permissions }
18827
- })
19373
+ fileContent: stringifyHermesConfig(config)
18828
19374
  });
18829
19375
  }
18830
19376
  };
@@ -18845,14 +19391,18 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18845
19391
  * "executables": [ { "prefix": "git ", "action": "allow" } ],
18846
19392
  * "fileEditing": [ { "pattern": "src/**", "action": "allow" } ],
18847
19393
  * "mcpTools": [ { "prefix": "search", "action": "allow" } ],
18848
- * "readOutsideProject":[ { "pattern": "/etc/**", "action": "deny" } ]
19394
+ * "readOutsideProject":[ { "pattern": "/etc/**", "action": "ask" } ]
18849
19395
  * }
18850
19396
  * }
18851
19397
  * ```
18852
19398
  *
18853
19399
  * Each rule carries a literal `prefix` (matches commands that start with it) or
18854
- * a glob `pattern` (`*`, `**`, `?`, `[abc]`, `[!abc]`) plus an `action`
18855
- * (`allow` | `ask` | `deny`). rulesync's canonical actions map 1:1 onto Junie's.
19400
+ * a glob `pattern` (`*`, `**`, `?`, `[abc]`, `[!abc]`) plus an `action`. Junie
19401
+ * documents only `allow` and `ask` as valid actions there is **no `deny`**
19402
+ * (https://junie.jetbrains.com/docs/action-allowlist-junie-cli.html). rulesync's
19403
+ * canonical `allow`/`ask` map 1:1; a canonical `deny` has no Junie equivalent
19404
+ * and is mapped to the nearest valid action, `ask` (still withholds
19405
+ * auto-approval), with a warning so the downgrade is surfaced.
18856
19406
  *
18857
19407
  * Category mapping (rulesync canonical <-> Junie rule group):
18858
19408
  * - `bash` <-> `executables`
@@ -18862,8 +19412,11 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18862
19412
  *
18863
19413
  * Categories Junie cannot represent (e.g. `webfetch`) are skipped on export
18864
19414
  * (with a warning when they carry rules). The top-level `defaultBehavior` and
18865
- * `allowReadonlyCommands` settings have no canonical equivalent: they are
18866
- * preserved verbatim on export but not imported into the rulesync model.
19415
+ * `allowReadonlyCommands` settings have no canonical per-glob slot: they are
19416
+ * authored and round-tripped through the `junie` override namespace (see
19417
+ * `JuniePermissionsOverrideSchema`) — lifted into the override on import and
19418
+ * merged back onto the top level on export — and any other unmodeled top-level
19419
+ * key is preserved verbatim.
18867
19420
  *
18868
19421
  * @see https://junie.jetbrains.com/docs/action-allowlist-junie-cli.html
18869
19422
  */
@@ -18886,7 +19439,6 @@ const JUNIE_GROUP_TO_CANONICAL = {
18886
19439
  mcpTools: "mcp",
18887
19440
  readOutsideProject: "read"
18888
19441
  };
18889
- const JUNIE_DEFAULT_BEHAVIOR = "ask";
18890
19442
  function isPermissionAction$1(value) {
18891
19443
  return PermissionActionSchema.safeParse(value).success;
18892
19444
  }
@@ -18936,13 +19488,16 @@ var JuniePermissions = class JuniePermissions extends ToolPermissions {
18936
19488
  } catch (error) {
18937
19489
  throw new Error(`Failed to parse existing Junie allowlist at ${filePath}: ${formatError(error)}`, { cause: error });
18938
19490
  }
19491
+ const config = rulesyncPermissions.getJson();
18939
19492
  const rules = convertRulesyncToJunieRules({
18940
- config: rulesyncPermissions.getJson(),
19493
+ config,
18941
19494
  logger
18942
19495
  });
19496
+ const override = config.junie;
19497
+ const overrideObj = override !== void 0 && typeof override === "object" ? override : {};
18943
19498
  const merged = {
18944
19499
  ...existing,
18945
- defaultBehavior: existing.defaultBehavior ?? JUNIE_DEFAULT_BEHAVIOR,
19500
+ ...overrideObj,
18946
19501
  rules
18947
19502
  };
18948
19503
  return new JuniePermissions({
@@ -18962,7 +19517,12 @@ var JuniePermissions = class JuniePermissions extends ToolPermissions {
18962
19517
  throw new Error(`Failed to parse Junie permissions content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
18963
19518
  }
18964
19519
  const config = convertJunieToRulesyncPermissions({ allowlist });
18965
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
19520
+ const junieOverride = {};
19521
+ if (typeof allowlist.allowReadonlyCommands === "boolean") junieOverride.allowReadonlyCommands = allowlist.allowReadonlyCommands;
19522
+ if (typeof allowlist.defaultBehavior === "string") junieOverride.defaultBehavior = allowlist.defaultBehavior;
19523
+ const result = { ...config };
19524
+ if (Object.keys(junieOverride).length > 0) result.junie = junieOverride;
19525
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
18966
19526
  }
18967
19527
  validate() {
18968
19528
  return {
@@ -18994,12 +19554,13 @@ function convertRulesyncToJunieRules({ config, logger }) {
18994
19554
  continue;
18995
19555
  }
18996
19556
  for (const [pattern, action] of Object.entries(patterns)) {
19557
+ const junieAction = toJunieAction(action, category, pattern, logger);
18997
19558
  const rule = isGlobPattern(pattern) ? {
18998
19559
  pattern,
18999
- action
19560
+ action: junieAction
19000
19561
  } : {
19001
19562
  prefix: pattern,
19002
- action
19563
+ action: junieAction
19003
19564
  };
19004
19565
  (rules[group] ??= []).push(rule);
19005
19566
  }
@@ -19007,6 +19568,19 @@ function convertRulesyncToJunieRules({ config, logger }) {
19007
19568
  return rules;
19008
19569
  }
19009
19570
  /**
19571
+ * Map a canonical action onto a valid Junie allowlist action. Junie supports
19572
+ * only `allow` and `ask`; a canonical `deny` is downgraded to `ask` (the
19573
+ * nearest valid action — both withhold auto-approval) with a warning, so
19574
+ * rulesync never emits a `deny` that Junie would silently ignore.
19575
+ */
19576
+ function toJunieAction(action, category, pattern, logger) {
19577
+ if (action === "deny") {
19578
+ logger?.warn(`Junie's allowlist supports only 'allow'/'ask' actions; the '${category}' deny rule for '${pattern}' was downgraded to 'ask' (Junie has no 'deny').`);
19579
+ return "ask";
19580
+ }
19581
+ return action;
19582
+ }
19583
+ /**
19010
19584
  * Convert a Junie allowlist back into rulesync permissions config. The
19011
19585
  * top-level `defaultBehavior` / `allowReadonlyCommands` settings have no
19012
19586
  * canonical equivalent and are not imported.
@@ -19040,6 +19614,31 @@ const KiloPermissionSchema = zod_mini.z.union([zod_mini.z.enum([
19040
19614
  ]))]);
19041
19615
  const KiloPermissionsConfigSchema = zod_mini.z.looseObject({ permission: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), KiloPermissionSchema)) });
19042
19616
  /**
19617
+ * Kilo permission keys that share a name with a canonical rulesync category and
19618
+ * therefore stay in the shared `permission` block. Everything else (Kilo-only
19619
+ * keys such as `external_directory`, `doom_loop`, `notebook_edit`, ...) is
19620
+ * routed into the `kilo` override on import so it does not leak into other
19621
+ * tools' configs. Kilo folds `write` into `edit`, uses `notebook_edit`/`task`
19622
+ * rather than the canonical `notebookedit`/`agent`, and has no `mcp` key (MCP is
19623
+ * addressed via `mcp__*` tool-name keys), so those canonical names are not Kilo
19624
+ * keys in practice — they simply never appear on the shared side.
19625
+ */
19626
+ const KILO_SHARED_CATEGORIES = /* @__PURE__ */ new Set([
19627
+ "bash",
19628
+ "read",
19629
+ "edit",
19630
+ "write",
19631
+ "webfetch",
19632
+ "websearch",
19633
+ "grep",
19634
+ "glob",
19635
+ "notebookedit",
19636
+ "agent"
19637
+ ]);
19638
+ function isSharedKiloCategory(key) {
19639
+ return key === "*" || KILO_SHARED_CATEGORIES.has(key) || key.startsWith("mcp__");
19640
+ }
19641
+ /**
19043
19642
  * Parse a JSONC string and throw on syntax errors. The `jsonc-parser` `parse()` function is
19044
19643
  * non-throwing best-effort: invalid input silently yields a partial value (often `undefined`,
19045
19644
  * coerced to `{}` by callers). That behavior would silently drop a user's existing `deny` rules
@@ -19151,12 +19750,16 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19151
19750
  const parsed = parseKiloJsoncStrict(await readFileContentOrNull(filePath) ?? "{}", filePath);
19152
19751
  const parsedPermission = parsed.permission;
19153
19752
  const existingPermission = parsedPermission && typeof parsedPermission === "object" && !Array.isArray(parsedPermission) ? { ...parsedPermission } : {};
19154
- const rulesyncPermission = rulesyncPermissions.getJson().permission;
19753
+ const rulesyncJson = rulesyncPermissions.getJson();
19754
+ const kiloOverride = rulesyncJson.kilo;
19755
+ const incomingPermission = {
19756
+ ...rulesyncJson.permission,
19757
+ ...kiloOverride?.permission
19758
+ };
19155
19759
  const droppedDenyByKey = {};
19156
- for (const key of Object.keys(rulesyncPermission)) {
19157
- const previous = existingPermission[key];
19158
- const previousDenyPatterns = collectKiloDenyPatterns(previous);
19159
- const nextDenyPatterns = new Set(collectKiloDenyPatterns(rulesyncPermission[key]));
19760
+ for (const [key, value] of Object.entries(incomingPermission)) {
19761
+ const previousDenyPatterns = collectKiloDenyPatterns(existingPermission[key]);
19762
+ const nextDenyPatterns = new Set(collectKiloDenyPatterns(value));
19160
19763
  const dropped = previousDenyPatterns.filter((p) => !nextDenyPatterns.has(p));
19161
19764
  if (dropped.length > 0) droppedDenyByKey[key] = dropped;
19162
19765
  }
@@ -19164,8 +19767,10 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19164
19767
  const summary = Object.entries(droppedDenyByKey).map(([key, patterns]) => `${key}: [${patterns.join(", ")}]`).join("; ");
19165
19768
  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'.`);
19166
19769
  }
19167
- const mergedPermission = { ...existingPermission };
19168
- for (const [key, value] of Object.entries(rulesyncPermission)) mergedPermission[key] = value;
19770
+ const mergedPermission = {
19771
+ ...existingPermission,
19772
+ ...incomingPermission
19773
+ };
19169
19774
  const nextJson = {
19170
19775
  ...parsed,
19171
19776
  permission: mergedPermission
@@ -19179,8 +19784,16 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19179
19784
  });
19180
19785
  }
19181
19786
  toRulesyncPermissions() {
19182
- const permission = this.normalizePermission(this.json.permission);
19183
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
19787
+ const rawPermission = this.json.permission ?? {};
19788
+ const shared = {};
19789
+ const overrideOnly = {};
19790
+ for (const [key, value] of Object.entries(rawPermission)) if (isSharedKiloCategory(key)) shared[key] = typeof value === "string" ? { "*": value } : value;
19791
+ else overrideOnly[key] = value;
19792
+ const json = Object.keys(overrideOnly).length > 0 ? {
19793
+ permission: shared,
19794
+ kilo: { permission: overrideOnly }
19795
+ } : { permission: shared };
19796
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(json, null, 2) });
19184
19797
  }
19185
19798
  validate() {
19186
19799
  try {
@@ -19210,10 +19823,6 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19210
19823
  validate: false
19211
19824
  });
19212
19825
  }
19213
- normalizePermission(permission) {
19214
- if (!permission) return {};
19215
- return Object.fromEntries(Object.entries(permission).map(([tool, value]) => [tool, typeof value === "string" ? { "*": value } : value]));
19216
- }
19217
19826
  };
19218
19827
  //#endregion
19219
19828
  //#region src/features/permissions/kiro-permissions.ts
@@ -19271,29 +19880,16 @@ var KiroPermissions = class KiroPermissions extends ToolPermissions {
19271
19880
  }
19272
19881
  const permission = {};
19273
19882
  const toolsSettings = parsed.toolsSettings ?? {};
19274
- const shellSettings = asRecord$1(toolsSettings.shell);
19275
- const shellAllow = asStringArray(shellSettings.allowedCommands);
19276
- const shellDeny = asStringArray(shellSettings.deniedCommands);
19277
- if (shellAllow.length > 0 || shellDeny.length > 0) {
19278
- permission.bash = {};
19279
- for (const pattern of shellAllow) permission.bash[pattern] = "allow";
19280
- for (const pattern of shellDeny) permission.bash[pattern] = "deny";
19281
- }
19282
- const readSettings = asRecord$1(toolsSettings.read);
19283
- const readAllow = asStringArray(readSettings.allowedPaths);
19284
- const readDeny = asStringArray(readSettings.deniedPaths);
19285
- if (readAllow.length > 0 || readDeny.length > 0) {
19286
- permission.read = {};
19287
- for (const pattern of readAllow) permission.read[pattern] = "allow";
19288
- for (const pattern of readDeny) permission.read[pattern] = "deny";
19289
- }
19290
- const writeSettings = asRecord$1(toolsSettings.write);
19291
- const writeAllow = asStringArray(writeSettings.allowedPaths);
19292
- const writeDeny = asStringArray(writeSettings.deniedPaths);
19293
- if (writeAllow.length > 0 || writeDeny.length > 0) {
19294
- permission.write = {};
19295
- for (const pattern of writeAllow) permission.write[pattern] = "allow";
19296
- for (const pattern of writeDeny) permission.write[pattern] = "deny";
19883
+ const shellRules = rulesFromArrays(asRecord$1(toolsSettings.shell), "allowedCommands", "deniedCommands");
19884
+ if (Object.keys(shellRules).length > 0) permission.bash = shellRules;
19885
+ for (const category of [
19886
+ "read",
19887
+ "write",
19888
+ "grep",
19889
+ "glob"
19890
+ ]) {
19891
+ const rules = rulesFromArrays(asRecord$1(toolsSettings[category]), "allowedPaths", "deniedPaths");
19892
+ if (Object.keys(rules).length > 0) permission[category] = rules;
19297
19893
  }
19298
19894
  const allowedTools = new Set(parsed.allowedTools ?? []);
19299
19895
  if (allowedTools.has("web_fetch")) permission.webfetch = { "*": "allow" };
@@ -19319,45 +19915,63 @@ var KiroPermissions = class KiroPermissions extends ToolPermissions {
19319
19915
  function buildKiroPermissionsFromRulesync({ config, logger, existing }) {
19320
19916
  const nextAllowedTools = new Set(existing.allowedTools ?? []);
19321
19917
  const nextToolsSettings = { ...asRecord$1(existing.toolsSettings) };
19918
+ const pathBuckets = {};
19919
+ const pushPath = (key, action, pattern) => {
19920
+ const bucket = pathBuckets[key] ??= {
19921
+ allow: [],
19922
+ deny: []
19923
+ };
19924
+ (action === "allow" ? bucket.allow : bucket.deny).push(pattern);
19925
+ };
19322
19926
  const shell = {
19323
19927
  allowedCommands: [],
19324
19928
  deniedCommands: []
19325
19929
  };
19326
- const read = {
19327
- allowedPaths: [],
19328
- deniedPaths: []
19329
- };
19330
- const write = {
19331
- allowedPaths: [],
19332
- deniedPaths: []
19333
- };
19334
19930
  for (const [category, rules] of Object.entries(config.permission)) for (const [pattern, action] of Object.entries(rules)) {
19335
19931
  if (action === "ask") {
19336
19932
  logger?.warn(`Kiro permissions do not support "ask". Skipping ${category}:${pattern}`);
19337
19933
  continue;
19338
19934
  }
19339
19935
  if (category === "bash") (action === "allow" ? shell.allowedCommands : shell.deniedCommands).push(pattern);
19340
- else if (category === "read") (action === "allow" ? read.allowedPaths : read.deniedPaths).push(pattern);
19341
- else if (category === "edit" || category === "write") (action === "allow" ? write.allowedPaths : write.deniedPaths).push(pattern);
19342
- else if (category === "webfetch" || category === "websearch") {
19343
- if (pattern !== "*") {
19344
- logger?.warn(`Kiro ${category} supports only wildcard (*) via allowedTools. Skipping rule: ${pattern}`);
19345
- continue;
19346
- }
19347
- const toolName = category === "webfetch" ? "web_fetch" : "web_search";
19348
- if (action === "allow") nextAllowedTools.add(toolName);
19349
- else nextAllowedTools.delete(toolName);
19350
- } else logger?.warn(`Kiro permissions do not support category: ${category}. Skipping.`);
19936
+ else if (category === "read" || category === "grep" || category === "glob") pushPath(category, action, pattern);
19937
+ else if (category === "edit" || category === "write") pushPath("write", action, pattern);
19938
+ else if (category === "webfetch" || category === "websearch") applyKiroWebPermission({
19939
+ category,
19940
+ pattern,
19941
+ action,
19942
+ nextAllowedTools,
19943
+ logger
19944
+ });
19945
+ else logger?.warn(`Kiro permissions do not support category: ${category}. Skipping.`);
19351
19946
  }
19352
19947
  nextToolsSettings.shell = shell;
19353
- nextToolsSettings.read = read;
19354
- nextToolsSettings.write = write;
19948
+ nextToolsSettings.read = pathTable(pathBuckets.read);
19949
+ nextToolsSettings.write = pathTable(pathBuckets.write);
19950
+ for (const key of ["grep", "glob"]) {
19951
+ const bucket = pathBuckets[key];
19952
+ if (bucket && (bucket.allow.length > 0 || bucket.deny.length > 0)) nextToolsSettings[key] = pathTable(bucket);
19953
+ }
19355
19954
  return {
19356
19955
  ...existing,
19357
19956
  allowedTools: [...nextAllowedTools].toSorted(),
19358
19957
  toolsSettings: nextToolsSettings
19359
19958
  };
19360
19959
  }
19960
+ function pathTable(bucket) {
19961
+ return {
19962
+ allowedPaths: bucket?.allow ?? [],
19963
+ deniedPaths: bucket?.deny ?? []
19964
+ };
19965
+ }
19966
+ function applyKiroWebPermission({ category, pattern, action, nextAllowedTools, logger }) {
19967
+ if (pattern !== "*") {
19968
+ logger?.warn(`Kiro ${category} supports only wildcard (*) via allowedTools. Skipping rule: ${pattern}`);
19969
+ return;
19970
+ }
19971
+ const toolName = category === "webfetch" ? "web_fetch" : "web_search";
19972
+ if (action === "allow") nextAllowedTools.add(toolName);
19973
+ else nextAllowedTools.delete(toolName);
19974
+ }
19361
19975
  function asRecord$1(value) {
19362
19976
  const result = UnknownRecordSchema.safeParse(value);
19363
19977
  return result.success ? result.data : {};
@@ -19365,6 +19979,17 @@ function asRecord$1(value) {
19365
19979
  function asStringArray(value) {
19366
19980
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
19367
19981
  }
19982
+ /**
19983
+ * Build a canonical `{ pattern: action }` map from a Kiro tool settings record's
19984
+ * allow/deny string arrays (e.g. `allowedPaths`/`deniedPaths` or
19985
+ * `allowedCommands`/`deniedCommands`).
19986
+ */
19987
+ function rulesFromArrays(settings, allowKey, denyKey) {
19988
+ const rules = {};
19989
+ for (const pattern of asStringArray(settings[allowKey])) rules[pattern] = "allow";
19990
+ for (const pattern of asStringArray(settings[denyKey])) rules[pattern] = "deny";
19991
+ return rules;
19992
+ }
19368
19993
  //#endregion
19369
19994
  //#region src/features/permissions/opencode-permissions.ts
19370
19995
  const OpencodePermissionSchema = zod_mini.z.union([zod_mini.z.enum([
@@ -19376,6 +20001,28 @@ const OpencodePermissionSchema = zod_mini.z.union([zod_mini.z.enum([
19376
20001
  "ask",
19377
20002
  "deny"
19378
20003
  ]))]);
20004
+ /**
20005
+ * Canonical rulesync permission categories that carry a cross-tool meaning (see
20006
+ * the "Supported tool categories" list in `docs/reference/file-formats.md`).
20007
+ * On import, any OpenCode category outside this set — plus MCP tool names — is
20008
+ * treated as OpenCode-only and routed into the `opencode` override block so a
20009
+ * subsequent `rulesync generate` does not leak it into other tools' configs.
20010
+ */
20011
+ const CANONICAL_PERMISSION_CATEGORIES = /* @__PURE__ */ new Set([
20012
+ "bash",
20013
+ "read",
20014
+ "edit",
20015
+ "write",
20016
+ "webfetch",
20017
+ "websearch",
20018
+ "grep",
20019
+ "glob",
20020
+ "notebookedit",
20021
+ "agent"
20022
+ ]);
20023
+ function isSharedPermissionCategory(category) {
20024
+ return category === "*" || CANONICAL_PERMISSION_CATEGORIES.has(category) || category.startsWith("mcp__");
20025
+ }
19379
20026
  const OpencodePermissionsConfigSchema = zod_mini.z.looseObject({ permission: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.enum([
19380
20027
  "allow",
19381
20028
  "ask",
@@ -19437,9 +20084,15 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
19437
20084
  fileContent = await readFileContentOrNull(jsonPath);
19438
20085
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
19439
20086
  }
20087
+ const parsed = (0, jsonc_parser.parse)(fileContent ?? "{}");
20088
+ const rulesyncJson = rulesyncPermissions.getJson();
20089
+ const overridePermission = rulesyncJson.opencode?.permission ?? {};
19440
20090
  const nextJson = {
19441
- ...(0, jsonc_parser.parse)(fileContent ?? "{}"),
19442
- permission: rulesyncPermissions.getJson().permission
20091
+ ...parsed,
20092
+ permission: {
20093
+ ...rulesyncJson.permission,
20094
+ ...overridePermission
20095
+ }
19443
20096
  };
19444
20097
  return new OpencodePermissions({
19445
20098
  outputRoot,
@@ -19450,8 +20103,20 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
19450
20103
  });
19451
20104
  }
19452
20105
  toRulesyncPermissions() {
19453
- const permission = this.normalizePermission(this.json.permission);
19454
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
20106
+ const rawPermission = this.json.permission;
20107
+ if (rawPermission === void 0 || typeof rawPermission === "string") {
20108
+ const permission = this.normalizePermission(rawPermission);
20109
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
20110
+ }
20111
+ const shared = {};
20112
+ const overrideOnly = {};
20113
+ for (const [category, value] of Object.entries(rawPermission)) if (isSharedPermissionCategory(category)) shared[category] = typeof value === "string" ? { "*": value } : value;
20114
+ else overrideOnly[category] = value;
20115
+ const json = Object.keys(overrideOnly).length > 0 ? {
20116
+ permission: shared,
20117
+ opencode: { permission: overrideOnly }
20118
+ } : { permission: shared };
20119
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(json, null, 2) });
19455
20120
  }
19456
20121
  validate() {
19457
20122
  try {
@@ -19481,10 +20146,15 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
19481
20146
  validate: false
19482
20147
  });
19483
20148
  }
20149
+ /**
20150
+ * Normalize the uniform/undefined forms of OpenCode's `permission` field into
20151
+ * the canonical rulesync shape. The object form is handled directly in
20152
+ * `toRulesyncPermissions` (it needs to split shared vs OpenCode-only
20153
+ * categories), so this only covers the two remaining cases.
20154
+ */
19484
20155
  normalizePermission(permission) {
19485
20156
  if (!permission) return {};
19486
- if (typeof permission === "string") return { "*": { "*": permission } };
19487
- return Object.fromEntries(Object.entries(permission).map(([tool, value]) => [tool, typeof value === "string" ? { "*": value } : value]));
20157
+ return { "*": { "*": permission } };
19488
20158
  }
19489
20159
  };
19490
20160
  //#endregion
@@ -19558,6 +20228,24 @@ function buildQwenPermissionEntry(toolName, pattern) {
19558
20228
  if (pattern === "*") return toolName;
19559
20229
  return `${toolName}(${pattern})`;
19560
20230
  }
20231
+ const QWEN_OVERRIDE_TOOLS_KEYS = [
20232
+ "approvalMode",
20233
+ "autoAccept",
20234
+ "sandbox",
20235
+ "sandboxImage",
20236
+ "disabled"
20237
+ ];
20238
+ const QWEN_OVERRIDE_SECURITY_KEYS = ["folderTrust"];
20239
+ function asPlainRecord(value) {
20240
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
20241
+ }
20242
+ /** Pick the override-managed keys out of a settings group into a fresh record. */
20243
+ function pickQwenOverrideKeys(group, keys) {
20244
+ const source = asPlainRecord(group);
20245
+ const picked = {};
20246
+ for (const key of keys) if (source[key] !== void 0) picked[key] = source[key];
20247
+ return picked;
20248
+ }
19561
20249
  var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
19562
20250
  constructor(params) {
19563
20251
  super({
@@ -19623,6 +20311,15 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
19623
20311
  ...settings,
19624
20312
  permissions: mergedPermissions
19625
20313
  };
20314
+ const override = config.qwencode;
20315
+ if (override?.tools !== void 0) merged.tools = {
20316
+ ...asPlainRecord(settings.tools),
20317
+ ...asPlainRecord(override.tools)
20318
+ };
20319
+ if (override?.security !== void 0) merged.security = {
20320
+ ...asPlainRecord(settings.security),
20321
+ ...asPlainRecord(override.security)
20322
+ };
19626
20323
  const fileContent = JSON.stringify(merged, null, 2);
19627
20324
  return new QwencodePermissions({
19628
20325
  outputRoot,
@@ -19648,7 +20345,14 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
19648
20345
  ask: permissions.ask ?? [],
19649
20346
  deny: permissions.deny ?? []
19650
20347
  });
19651
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
20348
+ const overrideTools = pickQwenOverrideKeys(settings.tools, QWEN_OVERRIDE_TOOLS_KEYS);
20349
+ const overrideSecurity = pickQwenOverrideKeys(settings.security, QWEN_OVERRIDE_SECURITY_KEYS);
20350
+ const qwencodeOverride = {};
20351
+ if (Object.keys(overrideTools).length > 0) qwencodeOverride.tools = overrideTools;
20352
+ if (Object.keys(overrideSecurity).length > 0) qwencodeOverride.security = overrideSecurity;
20353
+ const result = { ...config };
20354
+ if (Object.keys(qwencodeOverride).length > 0) result.qwencode = qwencodeOverride;
20355
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
19652
20356
  }
19653
20357
  validate() {
19654
20358
  try {
@@ -19809,6 +20513,16 @@ function toPermissionsTable(value) {
19809
20513
  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
19810
20514
  return { ...value };
19811
20515
  }
20516
+ const REASONIX_OVERRIDE_AGENT_KEYS = ["plan_mode_allowed_tools", "plan_mode_read_only_commands"];
20517
+ function asReasonixRecord(value) {
20518
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
20519
+ }
20520
+ function pickReasonixKeys(source, keys) {
20521
+ const record = asReasonixRecord(source);
20522
+ const picked = {};
20523
+ for (const key of keys) if (record[key] !== void 0) picked[key] = record[key];
20524
+ return picked;
20525
+ }
19812
20526
  var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
19813
20527
  toml;
19814
20528
  constructor(params) {
@@ -19870,6 +20584,15 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
19870
20584
  ...parsed,
19871
20585
  permissions: mergedPermissions
19872
20586
  };
20587
+ const override = config.reasonix;
20588
+ if (override?.sandbox !== void 0) merged.sandbox = {
20589
+ ...asReasonixRecord(parsed.sandbox),
20590
+ ...asReasonixRecord(override.sandbox)
20591
+ };
20592
+ if (override?.agent !== void 0) merged.agent = {
20593
+ ...asReasonixRecord(parsed.agent),
20594
+ ...asReasonixRecord(override.agent)
20595
+ };
19873
20596
  const fileContent = smol_toml.stringify(merged);
19874
20597
  return new ReasonixPermissions({
19875
20598
  outputRoot,
@@ -19886,7 +20609,14 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
19886
20609
  ask: toStringArray$1(permissions.ask),
19887
20610
  deny: toStringArray$1(permissions.deny)
19888
20611
  });
19889
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
20612
+ const sandbox = asReasonixRecord(this.toml.sandbox);
20613
+ const agentPlanMode = pickReasonixKeys(this.toml.agent, REASONIX_OVERRIDE_AGENT_KEYS);
20614
+ const reasonixOverride = {};
20615
+ if (Object.keys(sandbox).length > 0) reasonixOverride.sandbox = sandbox;
20616
+ if (Object.keys(agentPlanMode).length > 0) reasonixOverride.agent = agentPlanMode;
20617
+ const result = { ...config };
20618
+ if (Object.keys(reasonixOverride).length > 0) result.reasonix = reasonixOverride;
20619
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
19890
20620
  }
19891
20621
  validate() {
19892
20622
  try {
@@ -20450,6 +21180,7 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
20450
21180
  if (deny.size > 0) nextTool.denylist = [...deny].toSorted();
20451
21181
  tools[vibeToolName] = nextTool;
20452
21182
  }
21183
+ applyVibeSensitivePatterns(tools, rulesyncPermissions.getJson().vibe);
20453
21184
  config.tools = tools;
20454
21185
  if (enabledTools.size > 0) config.enabled_tools = [...enabledTools].toSorted();
20455
21186
  else delete config.enabled_tools;
@@ -20466,16 +21197,25 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
20466
21197
  }
20467
21198
  toRulesyncPermissions() {
20468
21199
  const permission = {};
21200
+ const vibeOverridePermission = {};
20469
21201
  for (const tool of toStringArray(this.toml.enabled_tools)) ensurePermission(permission, toCanonicalToolName$1(tool))["*"] = "allow";
20470
21202
  for (const tool of toStringArray(this.toml.disabled_tools)) ensurePermission(permission, toCanonicalToolName$1(tool))["*"] = "deny";
20471
21203
  for (const [vibeToolName, toolConfig] of Object.entries(toVibeToolsRecord(this.toml.tools))) {
20472
- const rules = ensurePermission(permission, toCanonicalToolName$1(vibeToolName));
21204
+ const category = toCanonicalToolName$1(vibeToolName);
21205
+ const rules = ensurePermission(permission, category);
20473
21206
  const action = fromVibePermission(toolConfig.permission);
20474
21207
  if (action !== void 0) rules["*"] = action;
20475
21208
  for (const pattern of toStringArray(toolConfig.allow ?? toolConfig.allowlist)) rules[pattern] = "allow";
20476
21209
  for (const pattern of toStringArray(toolConfig.deny ?? toolConfig.denylist)) rules[pattern] = "deny";
21210
+ const sensitivePatterns = toStringArray(toolConfig.sensitive_patterns);
21211
+ if (sensitivePatterns.length > 0) vibeOverridePermission[category] = { sensitive_patterns: sensitivePatterns };
20477
21212
  }
20478
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify({ permission }, null, 2) });
21213
+ for (const [category, rules] of Object.entries(permission)) if (Object.keys(rules).length === 0) delete permission[category];
21214
+ const json = Object.keys(vibeOverridePermission).length > 0 ? {
21215
+ permission,
21216
+ vibe: { permission: vibeOverridePermission }
21217
+ } : { permission };
21218
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(json, null, 2) });
20479
21219
  }
20480
21220
  validate() {
20481
21221
  try {
@@ -20502,6 +21242,22 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
20502
21242
  });
20503
21243
  }
20504
21244
  };
21245
+ /**
21246
+ * Apply the Vibe-scoped override's per-tool `sensitive_patterns` (patterns that
21247
+ * escalate to ASK even when the base permission is ALWAYS). rulesync owns this
21248
+ * list for any category the override names: a present list is set, an empty
21249
+ * one clears it. Categories not named keep whatever the existing file had.
21250
+ */
21251
+ function applyVibeSensitivePatterns(tools, vibeOverride) {
21252
+ for (const [category, toolOverride] of Object.entries(vibeOverride?.permission ?? {})) {
21253
+ const vibeToolName = toVibeToolName(category);
21254
+ const nextTool = toVibeToolConfig(tools[vibeToolName]);
21255
+ const patterns = toStringArray(toolOverride.sensitive_patterns);
21256
+ if (patterns.length > 0) nextTool.sensitive_patterns = [...patterns].toSorted();
21257
+ else delete nextTool.sensitive_patterns;
21258
+ tools[vibeToolName] = nextTool;
21259
+ }
21260
+ }
20505
21261
  function parseVibeConfig(fileContent) {
20506
21262
  const parsed = smol_toml.parse(fileContent || smol_toml.stringify({}));
20507
21263
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -20546,6 +21302,11 @@ function ensurePermission(permission, category) {
20546
21302
  const WARP_GLOBAL_ONLY_MESSAGE = "Warp permissions are global-only; use --global to sync Warp's settings.toml";
20547
21303
  const ALLOWLIST_KEY = "agent_mode_command_execution_allowlist";
20548
21304
  const DENYLIST_KEY = "agent_mode_command_execution_denylist";
21305
+ const WARP_OVERRIDE_KEYS = [
21306
+ "agent_mode_coding_permissions",
21307
+ "agent_mode_coding_file_read_allowlist",
21308
+ "agent_mode_execute_readonly_commands"
21309
+ ];
20549
21310
  /**
20550
21311
  * Warp's `settings.toml` lives in a different directory per platform (Stable
20551
21312
  * channel). The home directory is resolved by the processor through
@@ -20581,8 +21342,16 @@ function warpSettingsDir() {
20581
21342
  * patterns as regexes when targeting Warp (mirrors the Zed permissions
20582
21343
  * adapter). Warp has no per-command "ask" list, so `ask` rules are dropped; and
20583
21344
  * the command lists only model shell commands, so non-`bash` categories are
20584
- * skipped (with a warning when they carry `deny` rules). MCP allow/deny and the
20585
- * file-read permissions are separate surfaces not modeled here.
21345
+ * skipped (with a warning when they carry `deny` rules).
21346
+ *
21347
+ * Warp's `[agents.profiles]` table also exposes file-read/read-only autonomy
21348
+ * knobs that do not fit the canonical `allow | ask | deny` per-command model:
21349
+ * `agent_mode_coding_permissions`, `agent_mode_coding_file_read_allowlist`, and
21350
+ * `agent_mode_execute_readonly_commands`. These are authored and round-tripped
21351
+ * through the `warp` override namespace (see `WarpPermissionsOverrideSchema`):
21352
+ * on **import** they are lifted from `settings.toml` into the override, and on
21353
+ * **export** they are merged back into `[agents.profiles]` (the override wins).
21354
+ * MCP allow/deny is a separate surface not modeled here.
20586
21355
  *
20587
21356
  * The `settings.toml` file holds all of Warp's settings, so the
20588
21357
  * `[agents.profiles]` block is merged in place and the file is never deleted.
@@ -20627,12 +21396,15 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
20627
21396
  } catch (error) {
20628
21397
  throw new Error(`Failed to parse existing Warp settings at ${filePath}: ${formatError(error)}`, { cause: error });
20629
21398
  }
21399
+ const config = rulesyncPermissions.getJson();
20630
21400
  const { allow, deny } = convertRulesyncToWarpPermissions({
20631
- config: rulesyncPermissions.getJson(),
21401
+ config,
20632
21402
  logger
20633
21403
  });
20634
21404
  const agents = isRecord(settings.agents) ? { ...settings.agents } : {};
20635
21405
  const profiles = isRecord(agents.profiles) ? { ...agents.profiles } : {};
21406
+ const override = config.warp;
21407
+ if (isRecord(override)) Object.assign(profiles, override);
20636
21408
  const mergedAllow = (0, es_toolkit.uniq)(allow.toSorted());
20637
21409
  const mergedDeny = (0, es_toolkit.uniq)(deny.toSorted());
20638
21410
  if (mergedAllow.length > 0) profiles[ALLOWLIST_KEY] = mergedAllow;
@@ -20663,7 +21435,11 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
20663
21435
  allow: isStringArray(profiles[ALLOWLIST_KEY]) ? profiles[ALLOWLIST_KEY] : [],
20664
21436
  deny: isStringArray(profiles[DENYLIST_KEY]) ? profiles[DENYLIST_KEY] : []
20665
21437
  });
20666
- return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
21438
+ const warpOverride = {};
21439
+ for (const key of WARP_OVERRIDE_KEYS) if (profiles[key] !== void 0) warpOverride[key] = profiles[key];
21440
+ const result = { ...config };
21441
+ if (Object.keys(warpOverride).length > 0) result.warp = warpOverride;
21442
+ return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(result, null, 2) });
20667
21443
  }
20668
21444
  validate() {
20669
21445
  return {
@@ -24029,8 +24805,9 @@ const DevinSkillFrontmatterSchema = zod_mini.z.looseObject({
24029
24805
  * Represents a Devin (now Devin Desktop) skill directory.
24030
24806
  * Devin supports skills in both project mode under .devin/skills/
24031
24807
  * (preferred since the Devin Desktop rebrand; .devin/skills/ is the legacy
24032
- * fallback the tool still reads) and global mode under ~/.codeium/windsurf/skills/
24033
- * (unchanged by the rebrand).
24808
+ * fallback the tool still reads) and global mode under the Devin-native
24809
+ * ~/.config/devin/skills/ (consistent with Devin's global agents/rules paths;
24810
+ * the legacy channel-dependent ~/.codeium/<channel>/skills/ is no longer emitted).
24034
24811
  */
24035
24812
  var DevinSkill = class DevinSkill extends ToolSkill {
24036
24813
  constructor({ outputRoot = process.cwd(), relativeDirPath = DevinSkill.getSettablePaths().relativeDirPath, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
@@ -24052,7 +24829,7 @@ var DevinSkill = class DevinSkill extends ToolSkill {
24052
24829
  }
24053
24830
  }
24054
24831
  static getSettablePaths({ global = false } = {}) {
24055
- if (global) return { relativeDirPath: CODEIUM_WINDSURF_SKILLS_DIR_PATH };
24832
+ if (global) return { relativeDirPath: DEVIN_GLOBAL_SKILLS_DIR_PATH };
24056
24833
  return { relativeDirPath: DEVIN_SKILLS_DIR_PATH };
24057
24834
  }
24058
24835
  getFrontmatter() {
@@ -27111,7 +27888,7 @@ const QwencodeSubagentFrontmatterSchema = zod_mini.z.looseObject({
27111
27888
  disallowedTools: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
27112
27889
  maxTurns: zod_mini.z.optional(zod_mini.z.number()),
27113
27890
  color: zod_mini.z.optional(zod_mini.z.string()),
27114
- mcpServers: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
27891
+ mcpServers: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.array(zod_mini.z.string()), zod_mini.z.record(zod_mini.z.string(), zod_mini.z.looseObject({}))])),
27115
27892
  hooks: zod_mini.z.optional(zod_mini.z.unknown())
27116
27893
  });
27117
27894
  var QwencodeSubagent = class QwencodeSubagent extends ToolSubagent {