diff --git a/README.md b/README.md index 2f3374308..7768e9bc7 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Cline | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | Kilo Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Roo Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | -| Rovodev (Atlassian) | ✅ | | ✅ | | ✅ | ✅ | | | +| Rovodev (Atlassian) | ✅ | | ✅ | | ✅ | ✅ | | ✅ | | Takt | ✅ | | | ✅ | ✅ | ✅ | | | | Vibe Code | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | | Qwen Code | ✅ | ✅ | | | | | | ✅ | diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 4c8b8670a..918b01d4a 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -765,4 +765,6 @@ For Warp, this generates the `agent_mode_command_execution_allowlist` / `agent_m For the Antigravity IDE, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in the committable workspace `.antigravity/settings.json` (**project mode only**). Antigravity 2.0 evaluates these `Deny > Ask > Allow` and uses `action(target)` entries; rulesync maps canonical categories onto the IDE action vocabulary: `read` → `read_file`, `edit`/`write` → `write_file`, `bash` → `command`, `webfetch`/`websearch` → `read_url`, `mcp` → `mcp` (the IDE-only `execute_url` / `unsandboxed` actions have no canonical equivalent and pass through verbatim). Because `edit`/`write` collapse to `write_file` and `webfetch`/`websearch` collapse to `read_url`, importing normalizes back to `write` / `webfetch` (a documented, lossy mapping). The `settings.json` file holds other workspace settings, so the `permissions` block is merged in place — entries for unmanaged actions are preserved — and the file is never deleted. The User-scope settings file is a platform-dependent VS-Code-style path outside rulesync's home-relative global model, so **global mode is not supported**; the workspace file is intended to be checked into git. See the [Antigravity permissions docs](https://antigravity.google/docs/permissions). +For Rovo Dev CLI, this generates the `toolPermissions` block of the global `~/.rovodev/config.yml` (**global mode only** — Rovo Dev has no project-scoped permissions file, mirroring the Rovodev MCP adapter). Rovo Dev's three levels (`allow`/`ask`/`deny`) are an exact 1:1 with rulesync's canonical actions, so action values pass through verbatim. The `bash` category maps the catch-all `*` pattern to `bash.default` and every other pattern to a `bash.commands[]` entry `{ command: , permission }` (Rovo Dev matches commands as regexes, so author `bash` patterns accordingly). The `read` category maps to the inspection tools (`open_files`, `expand_code_chunks`, `expand_folder`, `grep`) and `edit`/`write` to the mutation tools (`find_and_replace_code`, `create_file`, `delete_file`, `move_file`); because these per-tool keys hold a single level (no per-pattern rules), only the catch-all `*` of each category sets the level. Because `edit` and `write` both map onto the same mutation tools, a conflicting catch-all between them cannot be represented; `edit` takes precedence and a warning is logged. Non-catch-all `allow` paths in those categories are surfaced as `allowedExternalPaths` so explicit grants are not dropped; non-`allow` non-catch-all rules cannot be expressed per-path and are skipped with a warning. Categories without a clean Rovo Dev target (e.g. `webfetch`) are skipped with a warning. `config.yml` holds all of Rovo Dev's settings (`agent`, `sessions`, `mcp`, etc.), so the `toolPermissions` block is merged in place — every other top-level key (and any unmanaged keys inside `toolPermissions`) is preserved (values only — YAML comments and formatting in the existing file are not retained on rewrite) — and the file is never deleted. See the [Rovo Dev CLI settings](https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/) and [tool permissions](https://support.atlassian.com/rovo/docs/use-tools-in-rovo-dev-cli/) docs. + > **Note: Interaction with ignore feature.** Both the ignore feature and the permissions feature can manage `Read` tool deny entries in `.claude/settings.json`. When both features configure the `Read` tool, the **permissions feature takes precedence** and a warning is emitted. If you only need to restrict file reads based on glob patterns, use the ignore feature (`.rulesync/.aiignore`). Use permissions only when you need fine-grained `allow`/`ask`/`deny` control over the `Read` tool. diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 3bb79a2f5..2cecb9588 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -20,7 +20,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Cline | cline | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ | | Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | -| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | | +| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | 🌏 | | Takt | takt | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | | Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Qwen Code | qwencode | ✅ | ✅ | | | | | | ✅ 🌏 | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 4c8b8670a..918b01d4a 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -765,4 +765,6 @@ For Warp, this generates the `agent_mode_command_execution_allowlist` / `agent_m For the Antigravity IDE, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in the committable workspace `.antigravity/settings.json` (**project mode only**). Antigravity 2.0 evaluates these `Deny > Ask > Allow` and uses `action(target)` entries; rulesync maps canonical categories onto the IDE action vocabulary: `read` → `read_file`, `edit`/`write` → `write_file`, `bash` → `command`, `webfetch`/`websearch` → `read_url`, `mcp` → `mcp` (the IDE-only `execute_url` / `unsandboxed` actions have no canonical equivalent and pass through verbatim). Because `edit`/`write` collapse to `write_file` and `webfetch`/`websearch` collapse to `read_url`, importing normalizes back to `write` / `webfetch` (a documented, lossy mapping). The `settings.json` file holds other workspace settings, so the `permissions` block is merged in place — entries for unmanaged actions are preserved — and the file is never deleted. The User-scope settings file is a platform-dependent VS-Code-style path outside rulesync's home-relative global model, so **global mode is not supported**; the workspace file is intended to be checked into git. See the [Antigravity permissions docs](https://antigravity.google/docs/permissions). +For Rovo Dev CLI, this generates the `toolPermissions` block of the global `~/.rovodev/config.yml` (**global mode only** — Rovo Dev has no project-scoped permissions file, mirroring the Rovodev MCP adapter). Rovo Dev's three levels (`allow`/`ask`/`deny`) are an exact 1:1 with rulesync's canonical actions, so action values pass through verbatim. The `bash` category maps the catch-all `*` pattern to `bash.default` and every other pattern to a `bash.commands[]` entry `{ command: , permission }` (Rovo Dev matches commands as regexes, so author `bash` patterns accordingly). The `read` category maps to the inspection tools (`open_files`, `expand_code_chunks`, `expand_folder`, `grep`) and `edit`/`write` to the mutation tools (`find_and_replace_code`, `create_file`, `delete_file`, `move_file`); because these per-tool keys hold a single level (no per-pattern rules), only the catch-all `*` of each category sets the level. Because `edit` and `write` both map onto the same mutation tools, a conflicting catch-all between them cannot be represented; `edit` takes precedence and a warning is logged. Non-catch-all `allow` paths in those categories are surfaced as `allowedExternalPaths` so explicit grants are not dropped; non-`allow` non-catch-all rules cannot be expressed per-path and are skipped with a warning. Categories without a clean Rovo Dev target (e.g. `webfetch`) are skipped with a warning. `config.yml` holds all of Rovo Dev's settings (`agent`, `sessions`, `mcp`, etc.), so the `toolPermissions` block is merged in place — every other top-level key (and any unmanaged keys inside `toolPermissions`) is preserved (values only — YAML comments and formatting in the existing file are not retained on rewrite) — and the file is never deleted. See the [Rovo Dev CLI settings](https://support.atlassian.com/rovo/docs/manage-rovo-dev-cli-settings/) and [tool permissions](https://support.atlassian.com/rovo/docs/use-tools-in-rovo-dev-cli/) docs. + > **Note: Interaction with ignore feature.** Both the ignore feature and the permissions feature can manage `Read` tool deny entries in `.claude/settings.json`. When both features configure the `Read` tool, the **permissions feature takes precedence** and a warning is emitted. If you only need to restrict file reads based on glob patterns, use the ignore feature (`.rulesync/.aiignore`). Use permissions only when you need fine-grained `allow`/`ask`/`deny` control over the `Read` tool. diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 3bb79a2f5..2cecb9588 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -20,7 +20,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Cline | cline | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ | | Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | -| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | | +| Rovodev (Atlassian) | rovodev | ✅ 🌏 | | 🌏 | | ✅ 🌏 | ✅ 🌏 | | 🌏 | | Takt | takt | ✅ 🌏 | | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | | Vibe Code | vibe | ✅ 🌏 | ✅ | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Qwen Code | qwencode | ✅ | ✅ | | | | | | ✅ 🌏 | diff --git a/src/constants/rovodev-paths.ts b/src/constants/rovodev-paths.ts index d88a406cd..bbe383937 100644 --- a/src/constants/rovodev-paths.ts +++ b/src/constants/rovodev-paths.ts @@ -7,4 +7,5 @@ export const ROVODEV_MODULAR_RULES_DIR_PATH = join(ROVODEV_DIR, ".rulesync", "mo export const ROVODEV_RULE_FILE_NAME = "AGENTS.md"; export const ROVODEV_LEGACY_RULE_FILE_NAME = "AGENTS.local.md"; export const ROVODEV_MCP_FILE_NAME = "mcp.json"; +export const ROVODEV_CONFIG_FILE_NAME = "config.yml"; export const ROVODEV_AGENTS_SKILLS_DIR_PATH = join(".agents", "skills"); diff --git a/src/e2e/e2e-permissions.spec.ts b/src/e2e/e2e-permissions.spec.ts index ab822d649..82f4e6f45 100644 --- a/src/e2e/e2e-permissions.spec.ts +++ b/src/e2e/e2e-permissions.spec.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; +import { load } from "js-yaml"; import * as smolToml from "smol-toml"; import { describe, expect, it } from "vitest"; @@ -1351,6 +1352,57 @@ describe("E2E: permissions (global mode)", () => { expect(bash.allow).toEqual(["git status"]); expect(parsed.disabled_tools).toContain("write_file"); }); + + it("should generate rovodev permissions in home directory with --global", async () => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + + await writeFileContent( + join(projectDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), + JSON.stringify( + { + permission: { + bash: { "*": "ask", "git status": "allow", "rm -rf .*": "deny" }, + read: { "*": "allow" }, + edit: { "*": "deny" }, + }, + }, + null, + 2, + ), + ); + + // Pre-seed config.yml with unrelated user settings to verify the + // non-destructive merge into ~/.rovodev/config.yml. + await writeFileContent( + join(homeDir, ".rovodev", "config.yml"), + "agent:\n model: claude\nsessions:\n retention: 30\n", + ); + + await runGenerate({ + target: "rovodev", + features: "permissions", + global: true, + env: { HOME_DIR: homeDir }, + }); + + const parsed = load(await readFileContent(join(homeDir, ".rovodev", "config.yml"))); + const root = toTable(parsed); + const toolPermissions = toTable(root.toolPermissions); + const bash = toTable(toolPermissions.bash); + // `bash` catch-all -> bash.default; other patterns -> bash.commands. + expect(bash.default).toBe("ask"); + expect(bash.commands).toEqual([ + { command: "git status", permission: "allow" }, + { command: "rm -rf .*", permission: "deny" }, + ]); + // `read` -> inspection tools, `edit` -> mutation tools. + expect(toolPermissions.open_files).toBe("allow"); + expect(toolPermissions.create_file).toBe("deny"); + // Unrelated user settings preserved by the non-destructive merge. + expect(toTable(root.agent).model).toBe("claude"); + expect(toTable(root.sessions).retention).toBe(30); + }); }); type AugmentEntry = { diff --git a/src/features/permissions/permissions-processor.test.ts b/src/features/permissions/permissions-processor.test.ts index fb1bde97c..3a0b86b76 100644 --- a/src/features/permissions/permissions-processor.test.ts +++ b/src/features/permissions/permissions-processor.test.ts @@ -113,6 +113,7 @@ describe("PermissionsProcessor", () => { "kilo", "opencode", "qwencode", + "rovodev", "vibe", "warp", "zed", diff --git a/src/features/permissions/permissions-processor.ts b/src/features/permissions/permissions-processor.ts index b0a0f2d9c..216bc77e8 100644 --- a/src/features/permissions/permissions-processor.ts +++ b/src/features/permissions/permissions-processor.ts @@ -21,6 +21,7 @@ import { KiloPermissions } from "./kilo-permissions.js"; import { KiroPermissions } from "./kiro-permissions.js"; import { OpencodePermissions } from "./opencode-permissions.js"; import { QwencodePermissions } from "./qwencode-permissions.js"; +import { RovodevPermissions } from "./rovodev-permissions.js"; import { RulesyncPermissions } from "./rulesync-permissions.js"; import type { ToolPermissionsForDeletionParams, @@ -50,6 +51,7 @@ const permissionsProcessorToolTargetTuple = [ "kiro-ide", "opencode", "qwencode", + "rovodev", "vibe", "warp", "zed", @@ -270,6 +272,21 @@ export const toolPermissionsFactories = new Map< }, }, ], + [ + "rovodev", + { + class: RovodevPermissions, + meta: { + // Rovo Dev CLI reads tool permissions only from the global + // `~/.rovodev/config.yml` (`toolPermissions` block); there is no + // project-scoped Rovo Dev permissions file (mirrors the Rovodev MCP + // adapter). + supportsProject: false, + supportsGlobal: true, + supportsImport: true, + }, + }, + ], [ "vibe", { diff --git a/src/features/permissions/rovodev-permissions.test.ts b/src/features/permissions/rovodev-permissions.test.ts new file mode 100644 index 000000000..73ae08e29 --- /dev/null +++ b/src/features/permissions/rovodev-permissions.test.ts @@ -0,0 +1,241 @@ +import { join } from "node:path"; + +import { dump, load } from "js-yaml"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createMockLogger } from "../../test-utils/mock-logger.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { isRecord } from "../../utils/type-guards.js"; +import { RovodevPermissions } from "./rovodev-permissions.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; + +function rulesyncPermissions( + permission: Record>, +): RulesyncPermissions { + return new RulesyncPermissions({ + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ permission }), + }); +} + +function toolPermissionsOf(yamlContent: string): Record { + const parsed = load(yamlContent); + if (!isRecord(parsed)) return {}; + return isRecord(parsed.toolPermissions) ? parsed.toolPermissions : {}; +} + +describe("RovodevPermissions", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("targets config.yml in the ~/.rovodev directory", () => { + const paths = RovodevPermissions.getSettablePaths(); + expect(paths.relativeDirPath).toBe(".rovodev"); + expect(paths.relativeFilePath).toBe("config.yml"); + }); + }); + + describe("isDeletable", () => { + it("is never deletable (shared config.yml)", () => { + const perms = new RovodevPermissions({ + relativeDirPath: ".rovodev", + relativeFilePath: "config.yml", + fileContent: "", + validate: false, + }); + expect(perms.isDeletable()).toBe(false); + }); + }); + + describe("global-only enforcement", () => { + it("throws on non-global fromRulesyncPermissions", async () => { + await expect( + RovodevPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions: rulesyncPermissions({ bash: { "*": "ask" } }), + global: false, + }), + ).rejects.toThrow(/global-only/); + }); + + it("throws on non-global fromFile", async () => { + await expect( + RovodevPermissions.fromFile({ outputRoot: testDir, global: false }), + ).rejects.toThrow(/global-only/); + }); + }); + + describe("fromRulesyncPermissions", () => { + it("maps bash catch-all to bash.default and patterns to bash.commands", async () => { + const perms = await RovodevPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions: rulesyncPermissions({ + bash: { "*": "ask", "git status": "allow", "rm -rf .*": "deny" }, + }), + global: true, + }); + + const tp = toolPermissionsOf(perms.getFileContent()); + const bash = isRecord(tp.bash) ? tp.bash : {}; + expect(bash.default).toBe("ask"); + expect(bash.commands).toEqual([ + { command: "git status", permission: "allow" }, + { command: "rm -rf .*", permission: "deny" }, + ]); + }); + + it("maps read/edit catch-alls to the matching per-tool keys", async () => { + const perms = await RovodevPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions: rulesyncPermissions({ + read: { "*": "allow" }, + edit: { "*": "deny" }, + }), + global: true, + }); + + const tp = toolPermissionsOf(perms.getFileContent()); + expect(tp.open_files).toBe("allow"); + expect(tp.grep).toBe("allow"); + expect(tp.expand_code_chunks).toBe("allow"); + expect(tp.expand_folder).toBe("allow"); + expect(tp.find_and_replace_code).toBe("deny"); + expect(tp.create_file).toBe("deny"); + expect(tp.delete_file).toBe("deny"); + expect(tp.move_file).toBe("deny"); + }); + + it("routes non-catch-all allow paths to allowedExternalPaths", async () => { + const perms = await RovodevPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions: rulesyncPermissions({ + read: { "/tmp/shared": "allow", "/var/data": "allow" }, + }), + global: true, + }); + + const tp = toolPermissionsOf(perms.getFileContent()); + expect(tp.allowedExternalPaths).toEqual(["/tmp/shared", "/var/data"]); + }); + + it("warns and skips categories without a clean Rovo Dev target", async () => { + const mockLogger = createMockLogger(); + const perms = await RovodevPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions: rulesyncPermissions({ + webfetch: { "github.com": "allow" }, + }), + logger: mockLogger, + global: true, + }); + + const tp = toolPermissionsOf(perms.getFileContent()); + expect(tp.webfetch).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("webfetch")); + }); + + it("warns and lets edit win when edit and write set conflicting catch-alls", async () => { + const mockLogger = createMockLogger(); + const perms = await RovodevPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions: rulesyncPermissions({ + write: { "*": "allow" }, + edit: { "*": "deny" }, + }), + logger: mockLogger, + global: true, + }); + + const tp = toolPermissionsOf(perms.getFileContent()); + // edit (deny) deterministically wins over write (allow) on the shared tools. + expect(tp.create_file).toBe("deny"); + expect(tp.find_and_replace_code).toBe("deny"); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('"edit" value takes precedence'), + ); + }); + + it("merges into config.yml preserving all other top-level keys", async () => { + const dirPath = join(testDir, ".rovodev"); + await ensureDir(dirPath); + await writeFileContent( + join(dirPath, "config.yml"), + dump({ + agent: { model: "claude" }, + sessions: { retention: 30 }, + mcp: { someSetting: true }, + toolPermissions: { grep: "allow", customKey: "preserved" }, + }), + ); + + const perms = await RovodevPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions: rulesyncPermissions({ bash: { "*": "ask" } }), + global: true, + }); + + const parsed = load(perms.getFileContent()); + if (!isRecord(parsed)) throw new Error("expected object"); + // Unrelated top-level keys preserved. + expect(parsed.agent).toEqual({ model: "claude" }); + expect(parsed.sessions).toEqual({ retention: 30 }); + expect(parsed.mcp).toEqual({ someSetting: true }); + // Managed block merged in; unmanaged keys inside it preserved. + const tp = isRecord(parsed.toolPermissions) ? parsed.toolPermissions : {}; + expect(isRecord(tp.bash) ? tp.bash.default : undefined).toBe("ask"); + expect(tp.customKey).toBe("preserved"); + }); + }); + + describe("round-trip", () => { + it("maps rulesync -> rovodev -> rulesync preserving bash and per-tool levels", async () => { + const original = rulesyncPermissions({ + bash: { "*": "ask", "git status": "allow" }, + read: { "*": "allow" }, + edit: { "*": "deny" }, + }); + + const toolPerms = await RovodevPermissions.fromRulesyncPermissions({ + outputRoot: testDir, + rulesyncPermissions: original, + global: true, + }); + + const roundTripped = toolPerms.toRulesyncPermissions(); + const json = JSON.parse(roundTripped.getFileContent()); + + expect(json.permission.bash["*"]).toBe("ask"); + expect(json.permission.bash["git status"]).toBe("allow"); + expect(json.permission.read["*"]).toBe("allow"); + expect(json.permission.edit["*"]).toBe("deny"); + }); + }); + + describe("fromFile", () => { + it("reads an existing config.yml from the home-relative path", async () => { + const dirPath = join(testDir, ".rovodev"); + await ensureDir(dirPath); + await writeFileContent( + join(dirPath, "config.yml"), + dump({ toolPermissions: { grep: "deny" } }), + ); + + const perms = await RovodevPermissions.fromFile({ outputRoot: testDir, global: true }); + const tp = toolPermissionsOf(perms.getFileContent()); + expect(tp.grep).toBe("deny"); + }); + }); +}); diff --git a/src/features/permissions/rovodev-permissions.ts b/src/features/permissions/rovodev-permissions.ts new file mode 100644 index 000000000..97107ed9c --- /dev/null +++ b/src/features/permissions/rovodev-permissions.ts @@ -0,0 +1,388 @@ +import { join } from "node:path"; + +import { dump, load } from "js-yaml"; + +import { ROVODEV_CONFIG_FILE_NAME, ROVODEV_DIR } from "../../constants/rovodev-paths.js"; +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import type { PermissionAction, PermissionsConfig } from "../../types/permissions.js"; +import { PermissionActionSchema } from "../../types/permissions.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContentOrNull } from "../../utils/file.js"; +import type { Logger } from "../../utils/logger.js"; +import { isRecord, isStringArray } from "../../utils/type-guards.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; +import { + ToolPermissions, + type ToolPermissionsForDeletionParams, + type ToolPermissionsFromFileParams, + type ToolPermissionsFromRulesyncPermissionsParams, + type ToolPermissionsSettablePaths, +} from "./tool-permissions.js"; + +const ROVODEV_GLOBAL_ONLY_MESSAGE = + "Rovodev permissions are global-only; use --global to sync ~/.rovodev/config.yml"; + +// The catch-all pattern in a rulesync category. It maps to a Rovo Dev per-tool +// default level (or `bash.default`) rather than to a `bash.commands[]` regex. +const CATCH_ALL_PATTERN = "*"; + +// Rovo Dev's per-tool permission keys. Each holds a single level (no per-pattern +// rules), so a whole rulesync category maps to one of these via its catch-all. +// https://support.atlassian.com/rovo/docs/use-tools-in-rovo-dev-cli/ +type RovodevToolPermissionKey = + | "create_file" + | "delete_file" + | "move_file" + | "find_and_replace_code" + | "open_files" + | "expand_code_chunks" + | "expand_folder" + | "grep"; + +// Maps a rulesync canonical category to the Rovo Dev per-tool keys it controls. +// The mapping is intentionally conservative: only categories with a clean Rovo +// Dev counterpart are translated; everything else is reported and skipped. +// - `read` -> the read/inspection tools. +// - `edit`/`write` -> the file mutation tools. +// `bash` is handled separately (it owns `bash.default`/`bash.commands`). +const CATEGORY_TO_TOOL_KEYS: Record = { + read: ["open_files", "expand_code_chunks", "expand_folder", "grep"], + edit: ["find_and_replace_code", "create_file", "delete_file", "move_file"], + write: ["create_file", "delete_file", "move_file", "find_and_replace_code"], +}; + +// Reverse of CATEGORY_TO_TOOL_KEYS: which canonical category a Rovo Dev tool key +// maps back to on import. `edit` is preferred over `write` because the forward +// mapping keys overlap and `edit` is the canonical mutation category rulesync +// emits first. +const TOOL_KEY_TO_CATEGORY: Record = { + open_files: "read", + expand_code_chunks: "read", + expand_folder: "read", + grep: "read", + find_and_replace_code: "edit", + create_file: "edit", + delete_file: "edit", + move_file: "edit", +}; + +type RovodevBashCommand = { + command: string; + permission: PermissionAction; +}; + +type RovodevBash = { + default?: PermissionAction; + commands?: RovodevBashCommand[]; +}; + +type RovodevToolPermissions = { + bash?: RovodevBash; + allowedExternalPaths?: string[]; +} & Partial>; + +/** + * Permissions adapter for Rovo Dev CLI. + * + * Rovo Dev reads tool permissions from the `toolPermissions` block of the global + * `~/.rovodev/config.yml`. This surface is **global only** — there is no + * project-scoped Rovo Dev permissions file (mirrors the Rovodev MCP adapter). + * + * Rovo Dev's three levels (`allow`/`ask`/`deny`) are an exact 1:1 with rulesync's + * canonical action enum, so action values pass through verbatim. + * + * Mapping decisions (rulesync canonical -> Rovo Dev): + * - `bash`: the catch-all `*` pattern -> `bash.default`; every other pattern -> + * a `bash.commands[]` entry `{ command: , permission }`. + * - `read` -> the inspection tools (`open_files`, `expand_code_chunks`, + * `expand_folder`, `grep`); `edit`/`write` -> the mutation tools + * (`find_and_replace_code`, `create_file`, `delete_file`, `move_file`). + * These Rovo Dev keys hold a single level (no per-pattern rules), so only the + * catch-all `*` of each category sets the level. Non-catch-all `allow` rules + * in those categories are surfaced as `allowedExternalPaths` so explicit path + * grants are not silently dropped; non-`allow` non-catch-all rules cannot be + * expressed per-path and are reported via `logger.warn` and skipped. + * - Any other canonical category has no clean Rovo Dev target and is reported + * and skipped rather than invented. + * + * `config.yml` holds all of Rovo Dev's settings (`agent`, `sessions`, `mcp`, + * etc.), so the `toolPermissions` block is merged in place, every other top-level + * key is preserved, and the file is never deleted. + */ +export class RovodevPermissions extends ToolPermissions { + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? "", + }); + } + + override isDeletable(): boolean { + return false; + } + + static getSettablePaths(_options?: { global?: boolean }): ToolPermissionsSettablePaths { + return { + relativeDirPath: ROVODEV_DIR, + relativeFilePath: ROVODEV_CONFIG_FILE_NAME, + }; + } + + static async fromFile({ + outputRoot = process.cwd(), + validate = true, + global = false, + }: ToolPermissionsFromFileParams): Promise { + if (!global) { + throw new Error(ROVODEV_GLOBAL_ONLY_MESSAGE); + } + const paths = RovodevPermissions.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + const fileContent = (await readFileContentOrNull(filePath)) ?? ""; + return new RovodevPermissions({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + global: true, + }); + } + + static async fromRulesyncPermissions({ + outputRoot = process.cwd(), + rulesyncPermissions, + logger, + global = false, + }: ToolPermissionsFromRulesyncPermissionsParams): Promise { + if (!global) { + throw new Error(ROVODEV_GLOBAL_ONLY_MESSAGE); + } + const paths = RovodevPermissions.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, paths.relativeFilePath); + // Read without initializing so a dry-run/check does not create the user's + // global config.yml as a side effect (mirrors the Warp/Zed adapters). + const existingContent = (await readFileContentOrNull(filePath)) ?? ""; + + let parsed: unknown; + try { + parsed = existingContent.trim() === "" ? {} : load(existingContent); + } catch (error) { + throw new Error( + `Failed to parse existing Rovodev config at ${filePath}: ${formatError(error)}`, + { cause: error }, + ); + } + const config = isRecord(parsed) ? { ...parsed } : {}; + + const toolPermissions = convertRulesyncToRovodevToolPermissions({ + config: rulesyncPermissions.getJson(), + logger, + }); + + // Merge into `toolPermissions`, preserving every other top-level key + // (`agent`, `sessions`, `mcp`, etc.) and any unmanaged keys inside the + // existing `toolPermissions` block. + const existingToolPermissions = isRecord(config.toolPermissions) + ? { ...config.toolPermissions } + : {}; + config.toolPermissions = { ...existingToolPermissions, ...toolPermissions }; + + return new RovodevPermissions({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent: dump(config), + validate: true, + global: true, + }); + } + + toRulesyncPermissions(): RulesyncPermissions { + let parsed: unknown; + try { + const content = this.getFileContent(); + parsed = content.trim() === "" ? {} : load(content); + } catch (error) { + throw new Error( + `Failed to parse Rovodev permissions content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, + { cause: error }, + ); + } + + const config = isRecord(parsed) ? parsed : {}; + const toolPermissions = isRecord(config.toolPermissions) ? config.toolPermissions : {}; + const rulesyncConfig = convertRovodevToolPermissionsToRulesync(toolPermissions); + + return this.toRulesyncPermissionsDefault({ + fileContent: JSON.stringify(rulesyncConfig, null, 2), + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolPermissionsForDeletionParams): RovodevPermissions { + return new RovodevPermissions({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: "", + validate: false, + global: true, + }); + } +} + +/** + * Convert a rulesync permissions config into a Rovo Dev `toolPermissions` block. + */ +function convertRulesyncToRovodevToolPermissions({ + config, + logger, +}: { + config: PermissionsConfig; + logger?: Logger; +}): RovodevToolPermissions { + const toolPermissions: RovodevToolPermissions = {}; + const allowedExternalPaths: string[] = []; + + // `edit` and `write` collapse onto the same Rovo Dev file-mutation tools, so a + // conflicting catch-all between them cannot be represented. Warn that the loss + // is happening, and resolve it deterministically in favour of `edit` (the + // canonical category the mutation tools map back to on import). + const editCatchAll = config.permission.edit?.[CATCH_ALL_PATTERN]; + const writeCatchAll = config.permission.write?.[CATCH_ALL_PATTERN]; + if (editCatchAll && writeCatchAll && editCatchAll !== writeCatchAll) { + logger?.warn( + `Rovo Dev maps both "edit" and "write" onto the same file-mutation tools, but they have ` + + `conflicting catch-all permissions ("edit": "${editCatchAll}", "write": "${writeCatchAll}"). ` + + `The "edit" value takes precedence.`, + ); + } + + // Apply `edit` after `write` so the overlapping tool keys resolve deterministically + // to `edit`, consistent with the import direction. + const orderedEntries = Object.entries(config.permission).toSorted( + ([a], [b]) => (a === "edit" ? 1 : 0) - (b === "edit" ? 1 : 0), + ); + for (const [category, rules] of orderedEntries) { + if (category === "bash") { + const bash = convertBashRules(rules); + if (bash) { + toolPermissions.bash = bash; + } + continue; + } + + const toolKeys = CATEGORY_TO_TOOL_KEYS[category]; + if (!toolKeys) { + logger?.warn( + `Rovo Dev permissions have no target for the "${category}" category. Skipping it.`, + ); + continue; + } + + for (const [pattern, action] of Object.entries(rules)) { + if (pattern === CATCH_ALL_PATTERN) { + for (const toolKey of toolKeys) { + toolPermissions[toolKey] = action; + } + continue; + } + + // Per-tool keys carry a single level (no per-pattern rules). A path that + // is explicitly allowed can still be surfaced via `allowedExternalPaths`; + // anything else cannot be expressed per-path, so warn and skip. + if (action === "allow") { + allowedExternalPaths.push(pattern); + continue; + } + logger?.warn( + `Rovo Dev cannot express per-path "${action}" for the "${category}" category (pattern "${pattern}"). Skipping it.`, + ); + } + } + + if (allowedExternalPaths.length > 0) { + toolPermissions.allowedExternalPaths = [...new Set(allowedExternalPaths)].toSorted(); + } + + return toolPermissions; +} + +function convertBashRules(rules: Record): RovodevBash | undefined { + const bash: RovodevBash = {}; + const commands: RovodevBashCommand[] = []; + + for (const [pattern, action] of Object.entries(rules)) { + if (pattern === CATCH_ALL_PATTERN) { + bash.default = action; + continue; + } + commands.push({ command: pattern, permission: action }); + } + + if (commands.length > 0) { + bash.commands = commands; + } + + return Object.keys(bash).length > 0 ? bash : undefined; +} + +/** + * Convert a Rovo Dev `toolPermissions` block back into a rulesync config. + */ +function convertRovodevToolPermissionsToRulesync( + toolPermissions: Record, +): PermissionsConfig { + const permission: PermissionsConfig["permission"] = {}; + + const bash = toolPermissions.bash; + if (isRecord(bash)) { + const bashRules: Record = {}; + if (isPermissionAction(bash.default)) { + bashRules[CATCH_ALL_PATTERN] = bash.default; + } + if (Array.isArray(bash.commands)) { + for (const entry of bash.commands) { + if ( + isRecord(entry) && + typeof entry.command === "string" && + isPermissionAction(entry.permission) + ) { + bashRules[entry.command] = entry.permission; + } + } + } + if (Object.keys(bashRules).length > 0) { + permission.bash = bashRules; + } + } + + for (const [toolKey, category] of Object.entries(TOOL_KEY_TO_CATEGORY)) { + const value = toolPermissions[toolKey]; + if (isPermissionAction(value)) { + permission[category] ??= {}; + permission[category][CATCH_ALL_PATTERN] = value; + } + } + + if (isStringArray(toolPermissions.allowedExternalPaths)) { + for (const path of toolPermissions.allowedExternalPaths) { + permission.read ??= {}; + permission.read[path] = "allow"; + } + } + + return { permission }; +} + +function isPermissionAction(value: unknown): value is PermissionAction { + return PermissionActionSchema.safeParse(value).success; +}