rulesync 9.2.0 → 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
  /**
@@ -12306,6 +12330,38 @@ const RULESYNC_TO_CODEX_FIELD_MAP = {
12306
12330
  envVars: "env_vars"
12307
12331
  };
12308
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
+ }
12309
12365
  function convertFromCodexFormat(codexMcp) {
12310
12366
  const result = {};
12311
12367
  for (const [name, config] of Object.entries(codexMcp)) {
@@ -12315,7 +12371,8 @@ function convertFromCodexFormat(codexMcp) {
12315
12371
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
12316
12372
  if (key === "enabled") {
12317
12373
  if (value === false) converted["disabled"] = true;
12318
- } 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)) {
12319
12376
  const mappedKey = CODEX_TO_RULESYNC_FIELD_MAP[key];
12320
12377
  if (mappedKey) if (isStringArray(value)) converted[mappedKey] = value;
12321
12378
  else warnWithFallback(void 0, `Ignored malformed array for ${key} in MCP server ${name}`);
@@ -12335,7 +12392,8 @@ function convertToCodexFormat(mcpServers) {
12335
12392
  if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue;
12336
12393
  if (key === "disabled") {
12337
12394
  if (value === true) converted["enabled"] = false;
12338
- } 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)) {
12339
12397
  const mappedKey = RULESYNC_TO_CODEX_FIELD_MAP[key];
12340
12398
  if (mappedKey) if (isStringArray(value)) converted[mappedKey] = value;
12341
12399
  else warnWithFallback(void 0, `[CodexCliMcp] Skipping invalid value type for mapped key '${key}': expected string array, got ${typeof value}`);
@@ -13551,10 +13609,12 @@ function resolveHermesTimeout(config) {
13551
13609
  *
13552
13610
  * Hermes is close to the MCP spec but not identical: `command` must be a single
13553
13611
  * executable string (an array's tail folds into `args`), a server is disabled
13554
- * via `enabled: false` (not the canonical `disabled: true`), and remote servers
13555
- * use `url`/`headers`. Only fields Hermes understands are emitted, so the shared
13556
- * `config.yaml` is not polluted with canonical-only aliases (`type`, `transport`,
13557
- * `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`, ...).
13558
13618
  */
13559
13619
  function convertServerToHermes(config) {
13560
13620
  const out = {};
@@ -13578,6 +13638,10 @@ function convertServerToHermes(config) {
13578
13638
  if (config.disabled === true) out.enabled = false;
13579
13639
  const timeout = resolveHermesTimeout(config);
13580
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;
13581
13645
  return out;
13582
13646
  }
13583
13647
  /**
@@ -13619,6 +13683,10 @@ function convertFromHermesFormat(mcpServers) {
13619
13683
  if (isPlainObject(config.headers)) server.headers = omitPrototypePollutionKeys(config.headers);
13620
13684
  if (config.enabled === false) server.disabled = true;
13621
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
+ }
13622
13690
  result[name] = server;
13623
13691
  }
13624
13692
  return result;
@@ -15774,17 +15842,232 @@ const PermissionActionSchema = zod_mini.z.enum([
15774
15842
  */
15775
15843
  const PermissionRulesSchema = zod_mini.z.record(zod_mini.z.string(), PermissionActionSchema);
15776
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
+ /**
15777
16040
  * Permissions configuration.
15778
16041
  * Keys are tool category names (e.g., "bash", "edit", "read", "webfetch").
15779
16042
  * Values are pattern-to-action mappings for that tool category.
15780
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
+ *
15781
16050
  * @example
15782
16051
  * {
15783
16052
  * "bash": { "*": "ask", "git *": "allow", "rm *": "deny" },
15784
16053
  * "edit": { "*": "deny", "src/**": "allow" }
15785
16054
  * }
15786
16055
  */
15787
- 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
+ });
15788
16071
  /**
15789
16072
  * Full permissions file schema including optional $schema field.
15790
16073
  */
@@ -17155,6 +17438,14 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
17155
17438
  }
17156
17439
  const config = rulesyncPermissions.getJson();
17157
17440
  const { allow, ask, deny } = convertRulesyncToClaudePermissions(config);
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
+ };
17448
+ }
17158
17449
  const managedToolNames = new Set(Object.keys(config.permission).map((category) => toClaudeToolName(category)));
17159
17450
  const merged = applyPermissions({
17160
17451
  settings,
@@ -17187,6 +17478,8 @@ var ClaudecodePermissions = class ClaudecodePermissions extends ToolPermissions
17187
17478
  ask: permissions.ask ?? [],
17188
17479
  deny: permissions.deny ?? []
17189
17480
  });
17481
+ const { allow: _a, ask: _k, deny: _d, ...nonListFields } = permissions;
17482
+ if (Object.keys(nonListFields).length > 0) config.claudecode = { permissions: nonListFields };
17190
17483
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
17191
17484
  }
17192
17485
  validate() {
@@ -17360,7 +17653,8 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
17360
17653
  } catch (error) {
17361
17654
  throw new Error(`Failed to parse existing Cline command-permissions at ${filePath}: ${formatError(error)}`, { cause: error });
17362
17655
  }
17363
- const { allow, deny, droppedCategories, translatedAskPatterns } = translateClinePermissions(rulesyncPermissions.getJson().permission);
17656
+ const config = rulesyncPermissions.getJson();
17657
+ const { allow, deny, droppedCategories, translatedAskPatterns } = translateClinePermissions(config.permission);
17364
17658
  warnClineTranslationNotices({
17365
17659
  droppedCategories,
17366
17660
  translatedAskPatterns,
@@ -17376,7 +17670,7 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
17376
17670
  ...existing,
17377
17671
  allow: dedupedAllow,
17378
17672
  deny: mergedDeny,
17379
- allowRedirects: existing.allowRedirects ?? false
17673
+ allowRedirects: config.cline?.allowRedirects ?? existing.allowRedirects ?? false
17380
17674
  };
17381
17675
  return new ClinePermissions({
17382
17676
  outputRoot,
@@ -17400,6 +17694,7 @@ var ClinePermissions = class ClinePermissions extends ToolPermissions {
17400
17694
  for (const pattern of parsed.allow ?? []) bashRules[pattern] = "allow";
17401
17695
  for (const pattern of parsed.deny ?? []) bashRules[pattern] = "deny";
17402
17696
  const config = Object.keys(bashRules).length > 0 ? { permission: { bash: bashRules } } : { permission: {} };
17697
+ if (parsed.allowRedirects === true) config.cline = { allowRedirects: true };
17403
17698
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2) });
17404
17699
  }
17405
17700
  validate() {
@@ -17836,13 +18131,13 @@ const CURSOR_TYPE_TO_CANONICAL = {
17836
18131
  WebFetch: "webfetch",
17837
18132
  Mcp: "mcp"
17838
18133
  };
17839
- const MCP_CANONICAL_PREFIX = "mcp__";
18134
+ const MCP_CANONICAL_PREFIX$1 = "mcp__";
17840
18135
  /**
17841
18136
  * Returns true if the canonical category is the per-tool MCP form
17842
18137
  * `mcp__<server>__<tool>`.
17843
18138
  */
17844
18139
  function isMcpScopedCategory(canonical) {
17845
- return canonical.startsWith(MCP_CANONICAL_PREFIX) && canonical.length > 5;
18140
+ return canonical.startsWith(MCP_CANONICAL_PREFIX$1) && canonical.length > 5;
17846
18141
  }
17847
18142
  function toCursorType(canonical) {
17848
18143
  if (isMcpScopedCategory(canonical)) return "Mcp";
@@ -17873,7 +18168,7 @@ function toCursorPattern(canonical, pattern) {
17873
18168
  function toCanonicalCategory$1(cursorType, pattern) {
17874
18169
  if (cursorType === "Mcp") {
17875
18170
  const match = pattern.match(/^([^:()]+):([^:()]+)$/);
17876
- if (match) return `${MCP_CANONICAL_PREFIX}${match[1] ?? "*"}__${match[2] ?? "*"}`;
18171
+ if (match) return `${MCP_CANONICAL_PREFIX$1}${match[1] ?? "*"}__${match[2] ?? "*"}`;
17877
18172
  return CURSOR_TYPE_TO_CANONICAL[cursorType] ?? cursorType.toLowerCase();
17878
18173
  }
17879
18174
  return CURSOR_TYPE_TO_CANONICAL[cursorType] ?? cursorType.toLowerCase();
@@ -18007,8 +18302,10 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
18007
18302
  mergedPermissions.allow = mergedAllow;
18008
18303
  if (mergedDeny.length > 0) mergedPermissions.deny = mergedDeny;
18009
18304
  else delete mergedPermissions.deny;
18305
+ const cursorOverride = config.cursor;
18010
18306
  const merged = {
18011
18307
  ...settings,
18308
+ ...cursorOverride,
18012
18309
  version: settings.version ?? 1,
18013
18310
  editor: {
18014
18311
  ...existingEditor,
@@ -18034,11 +18331,15 @@ var CursorPermissions = class CursorPermissions extends ToolPermissions {
18034
18331
  }
18035
18332
  const permissionsRaw = settings.permissions;
18036
18333
  const permissions = permissionsRaw !== null && typeof permissionsRaw === "object" && !Array.isArray(permissionsRaw) ? permissionsRaw : {};
18037
- const config = convertCursorToRulesyncPermissions({
18334
+ const result = { ...convertCursorToRulesyncPermissions({
18038
18335
  allow: asCursorPermissionEntryArray(permissions.allow),
18039
18336
  deny: asCursorPermissionEntryArray(permissions.deny)
18040
- });
18041
- 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) });
18042
18343
  }
18043
18344
  validate() {
18044
18345
  return {
@@ -18097,7 +18398,7 @@ function convertCursorToRulesyncPermissions(params) {
18097
18398
  const { type, pattern } = parseCursorPermissionEntry(entry);
18098
18399
  const canonical = toCanonicalCategory$1(type, pattern);
18099
18400
  if (!permission[canonical]) permission[canonical] = {};
18100
- const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX) ? "*" : pattern;
18401
+ const canonicalPattern = type === "Mcp" && canonical.startsWith(MCP_CANONICAL_PREFIX$1) ? "*" : pattern;
18101
18402
  permission[canonical][canonicalPattern] = action;
18102
18403
  }
18103
18404
  };
@@ -18346,6 +18647,16 @@ function convertDevinToRulesyncPermissions(params) {
18346
18647
  }
18347
18648
  //#endregion
18348
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
+ ];
18349
18660
  /**
18350
18661
  * Permissions adapter for Factory Droid.
18351
18662
  *
@@ -18363,18 +18674,14 @@ function convertDevinToRulesyncPermissions(params) {
18363
18674
  * skipped (with a warning when they carry `deny` rules, to surface the gap).
18364
18675
  *
18365
18676
  * Factory Droid also has a stronger `commandBlocklist` tier — commands that can
18366
- * never run, not even under full autonomy. rulesync's canonical action model
18367
- * has only `allow | ask | deny`, with no equivalent of a hard block that can
18368
- * never be approved. So on **import** a `commandBlocklist` entry is collapsed
18369
- * onto canonical `deny` (lossy: the never-runs guarantee is weakened to a deny
18370
- * the user can still approve), rather than being silently dropped. On **export**
18371
- * there is no canonical `block` to emit one from, so rulesync never writes
18372
- * `commandBlocklist`; an existing one on disk is preserved verbatim as an
18373
- * unmanaged key. (A consequence of the lossy collapse: importing a
18374
- * `commandBlocklist` and re-exporting it to a *fresh* config writes it back as
18375
- * `commandDenylist`, not `commandBlocklist` — the hard-block tier is not
18376
- * reconstructed. Re-running over the original file keeps it intact via the
18377
- * 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`.
18378
18685
  */
18379
18686
  var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissions {
18380
18687
  constructor(params) {
@@ -18417,11 +18724,16 @@ var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissi
18417
18724
  } catch (error) {
18418
18725
  throw new Error(`Failed to parse existing Factory Droid settings at ${filePath}: ${formatError(error)}`, { cause: error });
18419
18726
  }
18727
+ const config = rulesyncPermissions.getJson();
18420
18728
  const { allow, deny } = convertRulesyncToFactorydroidPermissions({
18421
- config: rulesyncPermissions.getJson(),
18729
+ config,
18422
18730
  logger
18423
18731
  });
18424
- const merged = { ...settings };
18732
+ const override = config.factorydroid;
18733
+ const merged = {
18734
+ ...settings,
18735
+ ...override !== void 0 && typeof override === "object" ? override : {}
18736
+ };
18425
18737
  const mergedAllow = (0, es_toolkit.uniq)(allow.toSorted());
18426
18738
  const mergedDeny = (0, es_toolkit.uniq)(deny.toSorted());
18427
18739
  if (mergedAllow.length > 0) merged.commandAllowlist = mergedAllow;
@@ -18445,10 +18757,13 @@ var FactorydroidPermissions = class FactorydroidPermissions extends ToolPermissi
18445
18757
  }
18446
18758
  const config = convertFactorydroidToRulesyncPermissions({
18447
18759
  allow: Array.isArray(settings.commandAllowlist) ? settings.commandAllowlist : [],
18448
- deny: Array.isArray(settings.commandDenylist) ? settings.commandDenylist : [],
18449
- block: Array.isArray(settings.commandBlocklist) ? settings.commandBlocklist : []
18760
+ deny: Array.isArray(settings.commandDenylist) ? settings.commandDenylist : []
18450
18761
  });
18451
- 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) });
18452
18767
  }
18453
18768
  validate() {
18454
18769
  return {
@@ -18495,18 +18810,18 @@ function convertRulesyncToFactorydroidPermissions({ config, logger }) {
18495
18810
  };
18496
18811
  }
18497
18812
  /**
18498
- * Convert Factory Droid allow/deny/block command lists back to rulesync config
18499
- * under the `bash` category.
18813
+ * Convert Factory Droid allow/deny command lists back to rulesync config under
18814
+ * the `bash` category.
18500
18815
  *
18501
- * `commandBlocklist` (hard block) has no canonical equivalent, so it collapses
18502
- * onto `deny` lossy (a deny can still be approved), but preferable to dropping
18503
- * 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`.
18504
18820
  */
18505
- function convertFactorydroidToRulesyncPermissions({ allow, deny, block }) {
18821
+ function convertFactorydroidToRulesyncPermissions({ allow, deny }) {
18506
18822
  const bash = {};
18507
18823
  for (const pattern of allow) bash[pattern] = "allow";
18508
18824
  for (const pattern of deny) bash[pattern] = "deny";
18509
- for (const pattern of block) bash[pattern] = "deny";
18510
18825
  return { permission: Object.keys(bash).length > 0 ? { bash } : {} };
18511
18826
  }
18512
18827
  //#endregion
@@ -18700,30 +19015,114 @@ function convertGoosePermissionConfigToRulesync(userPermission) {
18700
19015
  const GROKCLI_GLOBAL_ONLY_MESSAGE = "Grok CLI permissions are global-only; use --global to sync ~/.grok/config.toml";
18701
19016
  const GROKCLI_UI_KEY = "ui";
18702
19017
  const GROKCLI_PERMISSION_MODE_KEY = "permission_mode";
19018
+ const GROKCLI_PERMISSION_KEY = "permission";
18703
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
+ }
18704
19088
  /**
18705
19089
  * Permissions adapter for the xAI Grok Build CLI (`grokcli`).
18706
19090
  *
18707
- * Grok has no per-tool / per-pattern permission rules. Tool gating is a single
18708
- * coarse toggle, `[ui] permission_mode`, in `~/.grok/config.toml`:
18709
- * - `ask` prompt before each tool call (Grok's default).
18710
- * - `always-approve` skip prompts.
18711
- *
18712
- * rulesync's permission model is per-category, per-pattern `allow`/`ask`/`deny`,
18713
- * so the mapping is **lossy** (a single mode cannot express per-pattern rules):
18714
- * - Generate: any `deny` or `ask` rule anywhere `ask` (conservative — keep
18715
- * prompting whenever the user expressed any restriction); otherwise, if at
18716
- * least one `allow` rule exists and nothing is denied/asked
18717
- * `always-approve`; an empty config falls back to the safe default `ask`.
18718
- * - Import: `always-approve` `bash: { "*": "allow" }`; `ask` (or unset)
18719
- * `bash: { "*": "ask" }`. `bash` is used as the representative catch-all
18720
- * because it is the primary permission-gated surface, and this round-trips
18721
- * the generate mapping.
18722
- *
18723
- * This surface is **global only** `permission_mode` lives in the user-level
18724
- * `~/.grok/config.toml`; Grok has no project-scoped permission file. The shared
18725
- * config is merged in place: the `[ui] permission_mode` value is set and every
18726
- * 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
18727
19126
  * file is never deleted.
18728
19127
  */
18729
19128
  var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
@@ -18755,7 +19154,7 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18755
19154
  global: true
18756
19155
  });
18757
19156
  }
18758
- static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, global = false }) {
19157
+ static async fromRulesyncPermissions({ outputRoot = process.cwd(), rulesyncPermissions, logger, global = false }) {
18759
19158
  if (!global) throw new Error(GROKCLI_GLOBAL_ONLY_MESSAGE);
18760
19159
  const paths = GrokcliPermissions.getSettablePaths({ global });
18761
19160
  const filePath = (0, node_path.join)(outputRoot, paths.relativeDirPath, paths.relativeFilePath);
@@ -18766,7 +19165,16 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18766
19165
  } catch (error) {
18767
19166
  throw new Error(`Failed to parse existing Grok config.toml at ${filePath}: ${formatError(error)}`, { cause: error });
18768
19167
  }
18769
- 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);
18770
19178
  const existingUi = isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {};
18771
19179
  parsed[GROKCLI_UI_KEY] = {
18772
19180
  ...existingUi,
@@ -18789,8 +19197,8 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18789
19197
  } catch (error) {
18790
19198
  throw new Error(`Failed to parse Grok config.toml content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
18791
19199
  }
18792
- const action = (isRecord(parsed[GROKCLI_UI_KEY]) ? parsed[GROKCLI_UI_KEY] : {})[GROKCLI_PERMISSION_MODE_KEY] === "always-approve" ? "allow" : "ask";
18793
- 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) } } };
18794
19202
  return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(rulesyncConfig, null, 2) });
18795
19203
  }
18796
19204
  validate() {
@@ -18810,6 +19218,84 @@ var GrokcliPermissions = class GrokcliPermissions extends ToolPermissions {
18810
19218
  });
18811
19219
  }
18812
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
+ }
18813
19299
  /**
18814
19300
  * Collapse a rulesync permissions config into Grok's single coarse mode.
18815
19301
  * Any `deny`/`ask` rule anywhere keeps prompting (`ask`); otherwise an existing
@@ -18825,6 +19311,10 @@ function deriveGrokPermissionMode(config) {
18825
19311
  }
18826
19312
  //#endregion
18827
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
+ }
18828
19318
  var HermesagentPermissions = class HermesagentPermissions extends ToolPermissions {
18829
19319
  static getSettablePaths() {
18830
19320
  return {
@@ -18848,7 +19338,11 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18848
19338
  return true;
18849
19339
  }
18850
19340
  setFileContent(fileContent) {
18851
- 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);
18852
19346
  }
18853
19347
  toRulesyncPermissions() {
18854
19348
  const config = parseHermesConfig(this.getFileContent());
@@ -18861,12 +19355,22 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18861
19355
  }
18862
19356
  static fromRulesyncPermissions({ outputRoot, rulesyncPermissions }) {
18863
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 };
18864
19371
  return new HermesagentPermissions({
18865
19372
  outputRoot,
18866
- fileContent: stringifyHermesConfig({
18867
- command_allowlist: Object.entries(permissions.permission ?? {}).flatMap(([, patterns]) => Object.entries(patterns).filter(([, action]) => action === "allow").map(([command]) => command)),
18868
- permissions: { rulesync: permissions }
18869
- })
19373
+ fileContent: stringifyHermesConfig(config)
18870
19374
  });
18871
19375
  }
18872
19376
  };
@@ -18887,14 +19391,18 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18887
19391
  * "executables": [ { "prefix": "git ", "action": "allow" } ],
18888
19392
  * "fileEditing": [ { "pattern": "src/**", "action": "allow" } ],
18889
19393
  * "mcpTools": [ { "prefix": "search", "action": "allow" } ],
18890
- * "readOutsideProject":[ { "pattern": "/etc/**", "action": "deny" } ]
19394
+ * "readOutsideProject":[ { "pattern": "/etc/**", "action": "ask" } ]
18891
19395
  * }
18892
19396
  * }
18893
19397
  * ```
18894
19398
  *
18895
19399
  * Each rule carries a literal `prefix` (matches commands that start with it) or
18896
- * a glob `pattern` (`*`, `**`, `?`, `[abc]`, `[!abc]`) plus an `action`
18897
- * (`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.
18898
19406
  *
18899
19407
  * Category mapping (rulesync canonical <-> Junie rule group):
18900
19408
  * - `bash` <-> `executables`
@@ -18904,8 +19412,11 @@ var HermesagentPermissions = class HermesagentPermissions extends ToolPermission
18904
19412
  *
18905
19413
  * Categories Junie cannot represent (e.g. `webfetch`) are skipped on export
18906
19414
  * (with a warning when they carry rules). The top-level `defaultBehavior` and
18907
- * `allowReadonlyCommands` settings have no canonical equivalent: they are
18908
- * 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.
18909
19420
  *
18910
19421
  * @see https://junie.jetbrains.com/docs/action-allowlist-junie-cli.html
18911
19422
  */
@@ -18928,7 +19439,6 @@ const JUNIE_GROUP_TO_CANONICAL = {
18928
19439
  mcpTools: "mcp",
18929
19440
  readOutsideProject: "read"
18930
19441
  };
18931
- const JUNIE_DEFAULT_BEHAVIOR = "ask";
18932
19442
  function isPermissionAction$1(value) {
18933
19443
  return PermissionActionSchema.safeParse(value).success;
18934
19444
  }
@@ -18978,13 +19488,16 @@ var JuniePermissions = class JuniePermissions extends ToolPermissions {
18978
19488
  } catch (error) {
18979
19489
  throw new Error(`Failed to parse existing Junie allowlist at ${filePath}: ${formatError(error)}`, { cause: error });
18980
19490
  }
19491
+ const config = rulesyncPermissions.getJson();
18981
19492
  const rules = convertRulesyncToJunieRules({
18982
- config: rulesyncPermissions.getJson(),
19493
+ config,
18983
19494
  logger
18984
19495
  });
19496
+ const override = config.junie;
19497
+ const overrideObj = override !== void 0 && typeof override === "object" ? override : {};
18985
19498
  const merged = {
18986
19499
  ...existing,
18987
- defaultBehavior: existing.defaultBehavior ?? JUNIE_DEFAULT_BEHAVIOR,
19500
+ ...overrideObj,
18988
19501
  rules
18989
19502
  };
18990
19503
  return new JuniePermissions({
@@ -19004,7 +19517,12 @@ var JuniePermissions = class JuniePermissions extends ToolPermissions {
19004
19517
  throw new Error(`Failed to parse Junie permissions content in ${(0, node_path.join)(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, { cause: error });
19005
19518
  }
19006
19519
  const config = convertJunieToRulesyncPermissions({ allowlist });
19007
- 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) });
19008
19526
  }
19009
19527
  validate() {
19010
19528
  return {
@@ -19036,12 +19554,13 @@ function convertRulesyncToJunieRules({ config, logger }) {
19036
19554
  continue;
19037
19555
  }
19038
19556
  for (const [pattern, action] of Object.entries(patterns)) {
19557
+ const junieAction = toJunieAction(action, category, pattern, logger);
19039
19558
  const rule = isGlobPattern(pattern) ? {
19040
19559
  pattern,
19041
- action
19560
+ action: junieAction
19042
19561
  } : {
19043
19562
  prefix: pattern,
19044
- action
19563
+ action: junieAction
19045
19564
  };
19046
19565
  (rules[group] ??= []).push(rule);
19047
19566
  }
@@ -19049,6 +19568,19 @@ function convertRulesyncToJunieRules({ config, logger }) {
19049
19568
  return rules;
19050
19569
  }
19051
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
+ /**
19052
19584
  * Convert a Junie allowlist back into rulesync permissions config. The
19053
19585
  * top-level `defaultBehavior` / `allowReadonlyCommands` settings have no
19054
19586
  * canonical equivalent and are not imported.
@@ -19082,6 +19614,31 @@ const KiloPermissionSchema = zod_mini.z.union([zod_mini.z.enum([
19082
19614
  ]))]);
19083
19615
  const KiloPermissionsConfigSchema = zod_mini.z.looseObject({ permission: zod_mini.z.optional(zod_mini.z.record(zod_mini.z.string(), KiloPermissionSchema)) });
19084
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
+ /**
19085
19642
  * Parse a JSONC string and throw on syntax errors. The `jsonc-parser` `parse()` function is
19086
19643
  * non-throwing best-effort: invalid input silently yields a partial value (often `undefined`,
19087
19644
  * coerced to `{}` by callers). That behavior would silently drop a user's existing `deny` rules
@@ -19193,12 +19750,16 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19193
19750
  const parsed = parseKiloJsoncStrict(await readFileContentOrNull(filePath) ?? "{}", filePath);
19194
19751
  const parsedPermission = parsed.permission;
19195
19752
  const existingPermission = parsedPermission && typeof parsedPermission === "object" && !Array.isArray(parsedPermission) ? { ...parsedPermission } : {};
19196
- 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
+ };
19197
19759
  const droppedDenyByKey = {};
19198
- for (const key of Object.keys(rulesyncPermission)) {
19199
- const previous = existingPermission[key];
19200
- const previousDenyPatterns = collectKiloDenyPatterns(previous);
19201
- 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));
19202
19763
  const dropped = previousDenyPatterns.filter((p) => !nextDenyPatterns.has(p));
19203
19764
  if (dropped.length > 0) droppedDenyByKey[key] = dropped;
19204
19765
  }
@@ -19206,8 +19767,10 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19206
19767
  const summary = Object.entries(droppedDenyByKey).map(([key, patterns]) => `${key}: [${patterns.join(", ")}]`).join("; ");
19207
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'.`);
19208
19769
  }
19209
- const mergedPermission = { ...existingPermission };
19210
- for (const [key, value] of Object.entries(rulesyncPermission)) mergedPermission[key] = value;
19770
+ const mergedPermission = {
19771
+ ...existingPermission,
19772
+ ...incomingPermission
19773
+ };
19211
19774
  const nextJson = {
19212
19775
  ...parsed,
19213
19776
  permission: mergedPermission
@@ -19221,8 +19784,16 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19221
19784
  });
19222
19785
  }
19223
19786
  toRulesyncPermissions() {
19224
- const permission = this.normalizePermission(this.json.permission);
19225
- 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) });
19226
19797
  }
19227
19798
  validate() {
19228
19799
  try {
@@ -19252,10 +19823,6 @@ var KiloPermissions = class KiloPermissions extends ToolPermissions {
19252
19823
  validate: false
19253
19824
  });
19254
19825
  }
19255
- normalizePermission(permission) {
19256
- if (!permission) return {};
19257
- return Object.fromEntries(Object.entries(permission).map(([tool, value]) => [tool, typeof value === "string" ? { "*": value } : value]));
19258
- }
19259
19826
  };
19260
19827
  //#endregion
19261
19828
  //#region src/features/permissions/kiro-permissions.ts
@@ -19313,29 +19880,16 @@ var KiroPermissions = class KiroPermissions extends ToolPermissions {
19313
19880
  }
19314
19881
  const permission = {};
19315
19882
  const toolsSettings = parsed.toolsSettings ?? {};
19316
- const shellSettings = asRecord$1(toolsSettings.shell);
19317
- const shellAllow = asStringArray(shellSettings.allowedCommands);
19318
- const shellDeny = asStringArray(shellSettings.deniedCommands);
19319
- if (shellAllow.length > 0 || shellDeny.length > 0) {
19320
- permission.bash = {};
19321
- for (const pattern of shellAllow) permission.bash[pattern] = "allow";
19322
- for (const pattern of shellDeny) permission.bash[pattern] = "deny";
19323
- }
19324
- const readSettings = asRecord$1(toolsSettings.read);
19325
- const readAllow = asStringArray(readSettings.allowedPaths);
19326
- const readDeny = asStringArray(readSettings.deniedPaths);
19327
- if (readAllow.length > 0 || readDeny.length > 0) {
19328
- permission.read = {};
19329
- for (const pattern of readAllow) permission.read[pattern] = "allow";
19330
- for (const pattern of readDeny) permission.read[pattern] = "deny";
19331
- }
19332
- const writeSettings = asRecord$1(toolsSettings.write);
19333
- const writeAllow = asStringArray(writeSettings.allowedPaths);
19334
- const writeDeny = asStringArray(writeSettings.deniedPaths);
19335
- if (writeAllow.length > 0 || writeDeny.length > 0) {
19336
- permission.write = {};
19337
- for (const pattern of writeAllow) permission.write[pattern] = "allow";
19338
- 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;
19339
19893
  }
19340
19894
  const allowedTools = new Set(parsed.allowedTools ?? []);
19341
19895
  if (allowedTools.has("web_fetch")) permission.webfetch = { "*": "allow" };
@@ -19361,45 +19915,63 @@ var KiroPermissions = class KiroPermissions extends ToolPermissions {
19361
19915
  function buildKiroPermissionsFromRulesync({ config, logger, existing }) {
19362
19916
  const nextAllowedTools = new Set(existing.allowedTools ?? []);
19363
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
+ };
19364
19926
  const shell = {
19365
19927
  allowedCommands: [],
19366
19928
  deniedCommands: []
19367
19929
  };
19368
- const read = {
19369
- allowedPaths: [],
19370
- deniedPaths: []
19371
- };
19372
- const write = {
19373
- allowedPaths: [],
19374
- deniedPaths: []
19375
- };
19376
19930
  for (const [category, rules] of Object.entries(config.permission)) for (const [pattern, action] of Object.entries(rules)) {
19377
19931
  if (action === "ask") {
19378
19932
  logger?.warn(`Kiro permissions do not support "ask". Skipping ${category}:${pattern}`);
19379
19933
  continue;
19380
19934
  }
19381
19935
  if (category === "bash") (action === "allow" ? shell.allowedCommands : shell.deniedCommands).push(pattern);
19382
- else if (category === "read") (action === "allow" ? read.allowedPaths : read.deniedPaths).push(pattern);
19383
- else if (category === "edit" || category === "write") (action === "allow" ? write.allowedPaths : write.deniedPaths).push(pattern);
19384
- else if (category === "webfetch" || category === "websearch") {
19385
- if (pattern !== "*") {
19386
- logger?.warn(`Kiro ${category} supports only wildcard (*) via allowedTools. Skipping rule: ${pattern}`);
19387
- continue;
19388
- }
19389
- const toolName = category === "webfetch" ? "web_fetch" : "web_search";
19390
- if (action === "allow") nextAllowedTools.add(toolName);
19391
- else nextAllowedTools.delete(toolName);
19392
- } 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.`);
19393
19946
  }
19394
19947
  nextToolsSettings.shell = shell;
19395
- nextToolsSettings.read = read;
19396
- 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
+ }
19397
19954
  return {
19398
19955
  ...existing,
19399
19956
  allowedTools: [...nextAllowedTools].toSorted(),
19400
19957
  toolsSettings: nextToolsSettings
19401
19958
  };
19402
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
+ }
19403
19975
  function asRecord$1(value) {
19404
19976
  const result = UnknownRecordSchema.safeParse(value);
19405
19977
  return result.success ? result.data : {};
@@ -19407,6 +19979,17 @@ function asRecord$1(value) {
19407
19979
  function asStringArray(value) {
19408
19980
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
19409
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
+ }
19410
19993
  //#endregion
19411
19994
  //#region src/features/permissions/opencode-permissions.ts
19412
19995
  const OpencodePermissionSchema = zod_mini.z.union([zod_mini.z.enum([
@@ -19418,6 +20001,28 @@ const OpencodePermissionSchema = zod_mini.z.union([zod_mini.z.enum([
19418
20001
  "ask",
19419
20002
  "deny"
19420
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
+ }
19421
20026
  const OpencodePermissionsConfigSchema = zod_mini.z.looseObject({ permission: zod_mini.z.optional(zod_mini.z.union([zod_mini.z.enum([
19422
20027
  "allow",
19423
20028
  "ask",
@@ -19479,9 +20084,15 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
19479
20084
  fileContent = await readFileContentOrNull(jsonPath);
19480
20085
  if (fileContent) relativeFilePath = OPENCODE_JSON_FILE_NAME;
19481
20086
  }
20087
+ const parsed = (0, jsonc_parser.parse)(fileContent ?? "{}");
20088
+ const rulesyncJson = rulesyncPermissions.getJson();
20089
+ const overridePermission = rulesyncJson.opencode?.permission ?? {};
19482
20090
  const nextJson = {
19483
- ...(0, jsonc_parser.parse)(fileContent ?? "{}"),
19484
- permission: rulesyncPermissions.getJson().permission
20091
+ ...parsed,
20092
+ permission: {
20093
+ ...rulesyncJson.permission,
20094
+ ...overridePermission
20095
+ }
19485
20096
  };
19486
20097
  return new OpencodePermissions({
19487
20098
  outputRoot,
@@ -19492,8 +20103,20 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
19492
20103
  });
19493
20104
  }
19494
20105
  toRulesyncPermissions() {
19495
- const permission = this.normalizePermission(this.json.permission);
19496
- 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) });
19497
20120
  }
19498
20121
  validate() {
19499
20122
  try {
@@ -19523,10 +20146,15 @@ var OpencodePermissions = class OpencodePermissions extends ToolPermissions {
19523
20146
  validate: false
19524
20147
  });
19525
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
+ */
19526
20155
  normalizePermission(permission) {
19527
20156
  if (!permission) return {};
19528
- if (typeof permission === "string") return { "*": { "*": permission } };
19529
- return Object.fromEntries(Object.entries(permission).map(([tool, value]) => [tool, typeof value === "string" ? { "*": value } : value]));
20157
+ return { "*": { "*": permission } };
19530
20158
  }
19531
20159
  };
19532
20160
  //#endregion
@@ -19600,6 +20228,24 @@ function buildQwenPermissionEntry(toolName, pattern) {
19600
20228
  if (pattern === "*") return toolName;
19601
20229
  return `${toolName}(${pattern})`;
19602
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
+ }
19603
20249
  var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
19604
20250
  constructor(params) {
19605
20251
  super({
@@ -19665,6 +20311,15 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
19665
20311
  ...settings,
19666
20312
  permissions: mergedPermissions
19667
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
+ };
19668
20323
  const fileContent = JSON.stringify(merged, null, 2);
19669
20324
  return new QwencodePermissions({
19670
20325
  outputRoot,
@@ -19690,7 +20345,14 @@ var QwencodePermissions = class QwencodePermissions extends ToolPermissions {
19690
20345
  ask: permissions.ask ?? [],
19691
20346
  deny: permissions.deny ?? []
19692
20347
  });
19693
- 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) });
19694
20356
  }
19695
20357
  validate() {
19696
20358
  try {
@@ -19851,6 +20513,16 @@ function toPermissionsTable(value) {
19851
20513
  if (!value || typeof value !== "object" || Array.isArray(value)) return {};
19852
20514
  return { ...value };
19853
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
+ }
19854
20526
  var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
19855
20527
  toml;
19856
20528
  constructor(params) {
@@ -19912,6 +20584,15 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
19912
20584
  ...parsed,
19913
20585
  permissions: mergedPermissions
19914
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
+ };
19915
20596
  const fileContent = smol_toml.stringify(merged);
19916
20597
  return new ReasonixPermissions({
19917
20598
  outputRoot,
@@ -19928,7 +20609,14 @@ var ReasonixPermissions = class ReasonixPermissions extends ToolPermissions {
19928
20609
  ask: toStringArray$1(permissions.ask),
19929
20610
  deny: toStringArray$1(permissions.deny)
19930
20611
  });
19931
- 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) });
19932
20620
  }
19933
20621
  validate() {
19934
20622
  try {
@@ -20492,6 +21180,7 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
20492
21180
  if (deny.size > 0) nextTool.denylist = [...deny].toSorted();
20493
21181
  tools[vibeToolName] = nextTool;
20494
21182
  }
21183
+ applyVibeSensitivePatterns(tools, rulesyncPermissions.getJson().vibe);
20495
21184
  config.tools = tools;
20496
21185
  if (enabledTools.size > 0) config.enabled_tools = [...enabledTools].toSorted();
20497
21186
  else delete config.enabled_tools;
@@ -20508,16 +21197,25 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
20508
21197
  }
20509
21198
  toRulesyncPermissions() {
20510
21199
  const permission = {};
21200
+ const vibeOverridePermission = {};
20511
21201
  for (const tool of toStringArray(this.toml.enabled_tools)) ensurePermission(permission, toCanonicalToolName$1(tool))["*"] = "allow";
20512
21202
  for (const tool of toStringArray(this.toml.disabled_tools)) ensurePermission(permission, toCanonicalToolName$1(tool))["*"] = "deny";
20513
21203
  for (const [vibeToolName, toolConfig] of Object.entries(toVibeToolsRecord(this.toml.tools))) {
20514
- const rules = ensurePermission(permission, toCanonicalToolName$1(vibeToolName));
21204
+ const category = toCanonicalToolName$1(vibeToolName);
21205
+ const rules = ensurePermission(permission, category);
20515
21206
  const action = fromVibePermission(toolConfig.permission);
20516
21207
  if (action !== void 0) rules["*"] = action;
20517
21208
  for (const pattern of toStringArray(toolConfig.allow ?? toolConfig.allowlist)) rules[pattern] = "allow";
20518
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 };
20519
21212
  }
20520
- 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) });
20521
21219
  }
20522
21220
  validate() {
20523
21221
  try {
@@ -20544,6 +21242,22 @@ var VibePermissions = class VibePermissions extends ToolPermissions {
20544
21242
  });
20545
21243
  }
20546
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
+ }
20547
21261
  function parseVibeConfig(fileContent) {
20548
21262
  const parsed = smol_toml.parse(fileContent || smol_toml.stringify({}));
20549
21263
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
@@ -20588,6 +21302,11 @@ function ensurePermission(permission, category) {
20588
21302
  const WARP_GLOBAL_ONLY_MESSAGE = "Warp permissions are global-only; use --global to sync Warp's settings.toml";
20589
21303
  const ALLOWLIST_KEY = "agent_mode_command_execution_allowlist";
20590
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
+ ];
20591
21310
  /**
20592
21311
  * Warp's `settings.toml` lives in a different directory per platform (Stable
20593
21312
  * channel). The home directory is resolved by the processor through
@@ -20623,8 +21342,16 @@ function warpSettingsDir() {
20623
21342
  * patterns as regexes when targeting Warp (mirrors the Zed permissions
20624
21343
  * adapter). Warp has no per-command "ask" list, so `ask` rules are dropped; and
20625
21344
  * the command lists only model shell commands, so non-`bash` categories are
20626
- * skipped (with a warning when they carry `deny` rules). MCP allow/deny and the
20627
- * 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.
20628
21355
  *
20629
21356
  * The `settings.toml` file holds all of Warp's settings, so the
20630
21357
  * `[agents.profiles]` block is merged in place and the file is never deleted.
@@ -20669,12 +21396,15 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
20669
21396
  } catch (error) {
20670
21397
  throw new Error(`Failed to parse existing Warp settings at ${filePath}: ${formatError(error)}`, { cause: error });
20671
21398
  }
21399
+ const config = rulesyncPermissions.getJson();
20672
21400
  const { allow, deny } = convertRulesyncToWarpPermissions({
20673
- config: rulesyncPermissions.getJson(),
21401
+ config,
20674
21402
  logger
20675
21403
  });
20676
21404
  const agents = isRecord(settings.agents) ? { ...settings.agents } : {};
20677
21405
  const profiles = isRecord(agents.profiles) ? { ...agents.profiles } : {};
21406
+ const override = config.warp;
21407
+ if (isRecord(override)) Object.assign(profiles, override);
20678
21408
  const mergedAllow = (0, es_toolkit.uniq)(allow.toSorted());
20679
21409
  const mergedDeny = (0, es_toolkit.uniq)(deny.toSorted());
20680
21410
  if (mergedAllow.length > 0) profiles[ALLOWLIST_KEY] = mergedAllow;
@@ -20705,7 +21435,11 @@ var WarpPermissions = class WarpPermissions extends ToolPermissions {
20705
21435
  allow: isStringArray(profiles[ALLOWLIST_KEY]) ? profiles[ALLOWLIST_KEY] : [],
20706
21436
  deny: isStringArray(profiles[DENYLIST_KEY]) ? profiles[DENYLIST_KEY] : []
20707
21437
  });
20708
- 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) });
20709
21443
  }
20710
21444
  validate() {
20711
21445
  return {
@@ -24071,8 +24805,9 @@ const DevinSkillFrontmatterSchema = zod_mini.z.looseObject({
24071
24805
  * Represents a Devin (now Devin Desktop) skill directory.
24072
24806
  * Devin supports skills in both project mode under .devin/skills/
24073
24807
  * (preferred since the Devin Desktop rebrand; .devin/skills/ is the legacy
24074
- * fallback the tool still reads) and global mode under ~/.codeium/windsurf/skills/
24075
- * (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).
24076
24811
  */
24077
24812
  var DevinSkill = class DevinSkill extends ToolSkill {
24078
24813
  constructor({ outputRoot = process.cwd(), relativeDirPath = DevinSkill.getSettablePaths().relativeDirPath, dirName, frontmatter, body, otherFiles = [], validate = true, global = false }) {
@@ -24094,7 +24829,7 @@ var DevinSkill = class DevinSkill extends ToolSkill {
24094
24829
  }
24095
24830
  }
24096
24831
  static getSettablePaths({ global = false } = {}) {
24097
- if (global) return { relativeDirPath: CODEIUM_WINDSURF_SKILLS_DIR_PATH };
24832
+ if (global) return { relativeDirPath: DEVIN_GLOBAL_SKILLS_DIR_PATH };
24098
24833
  return { relativeDirPath: DEVIN_SKILLS_DIR_PATH };
24099
24834
  }
24100
24835
  getFrontmatter() {
@@ -27153,7 +27888,7 @@ const QwencodeSubagentFrontmatterSchema = zod_mini.z.looseObject({
27153
27888
  disallowedTools: zod_mini.z.optional(zod_mini.z.array(zod_mini.z.string())),
27154
27889
  maxTurns: zod_mini.z.optional(zod_mini.z.number()),
27155
27890
  color: zod_mini.z.optional(zod_mini.z.string()),
27156
- 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({}))])),
27157
27892
  hooks: zod_mini.z.optional(zod_mini.z.unknown())
27158
27893
  });
27159
27894
  var QwencodeSubagent = class QwencodeSubagent extends ToolSubagent {