Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ See [Quick Start guide](https://dyoshikawa.github.io/rulesync/getting-started/qu
| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | |
| deepagents-cli | deepagents | ✅ | | ✅ 🌏 | | ✅ | ✅ | 🌏 | |
| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | 🎮 | 🎮 | 🎮 | ✅ 🌏 | |
| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
| Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | |
| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | |
| Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | |
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,4 +382,6 @@ Example:

For Claude Code, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in `.claude/settings.json` using PascalCase tool names (e.g., `Bash(git *)`, `Edit(src/**)`, `Read(.env)`).

For OpenCode, this generates the `permission` object in `opencode.json` / `opencode.jsonc` (project mode) or `.config/opencode/opencode.json` / `.config/opencode/opencode.jsonc` (global mode), preserving other existing OpenCode config fields.

> **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.
2 changes: 1 addition & 1 deletion docs/reference/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
| Goose | goose | ✅ 🌏 | ✅ | | | | | | |
| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | |
| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | 🎮 | 🎮 | 🎮 | ✅ 🌏 | |
| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
| Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | |
| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | |
| Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | |
Expand Down
2 changes: 2 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,4 +382,6 @@ Example:

For Claude Code, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in `.claude/settings.json` using PascalCase tool names (e.g., `Bash(git *)`, `Edit(src/**)`, `Read(.env)`).

For OpenCode, this generates the `permission` object in `opencode.json` / `opencode.jsonc` (project mode) or `.config/opencode/opencode.json` / `.config/opencode/opencode.jsonc` (global mode), preserving other existing OpenCode config fields.

> **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.
2 changes: 1 addition & 1 deletion skills/rulesync/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
| Goose | goose | ✅ 🌏 | ✅ | | | | | | |
| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | |
| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | 🎮 | 🎮 | 🎮 | ✅ 🌏 | |
| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
| Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | |
| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | |
| Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | |
Expand Down
105 changes: 105 additions & 0 deletions src/e2e/e2e-permissions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { join } from "node:path";

import { describe, expect, it } from "vitest";

import { RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH } from "../constants/rulesync-paths.js";
import { readFileContent, writeFileContent } from "../utils/file.js";
import {
runGenerate,
runImport,
useGlobalTestDirectories,
useTestDirectory,
} from "./e2e-helper.js";

describe("E2E: permissions", () => {
const { getTestDir } = useTestDirectory();

it("should generate opencode permissions from .rulesync/permissions.json", async () => {
const testDir = getTestDir();

await writeFileContent(
join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH),
JSON.stringify(
{
permission: {
bash: { "*": "ask", "git *": "allow" },
read: { ".env": "deny" },
},
},
null,
2,
),
);

await runGenerate({ target: "opencode", features: "permissions" });

const content = JSON.parse(await readFileContent(join(testDir, "opencode.jsonc")));
expect(content.permission.bash["git *"]).toBe("allow");
expect(content.permission.read[".env"]).toBe("deny");
});
});

describe("E2E: permissions (import)", () => {
const { getTestDir } = useTestDirectory();

it("should import opencode permissions into .rulesync/permissions.json", async () => {
const testDir = getTestDir();

await writeFileContent(
join(testDir, "opencode.json"),
JSON.stringify(
{
permission: {
bash: { "*": "ask", "npm *": "allow" },
read: { ".env": "deny" },
},
},
null,
2,
),
);

await runImport({ target: "opencode", features: "permissions" });

const content = JSON.parse(
await readFileContent(join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH)),
);
expect(content.permission.bash["npm *"]).toBe("allow");
expect(content.permission.read[".env"]).toBe("deny");
});
});

describe("E2E: permissions (global mode)", () => {
const { getProjectDir, getHomeDir } = useGlobalTestDirectories();

it("should generate opencode permissions in home directory with --global", async () => {
const projectDir = getProjectDir();
const homeDir = getHomeDir();

await writeFileContent(
join(projectDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH),
JSON.stringify(
{
root: true,
permission: {
bash: { "*": "ask", "git status *": "allow" },
},
},
null,
2,
),
);

await runGenerate({
target: "opencode",
features: "permissions",
global: true,
env: { HOME_DIR: homeDir },
});

const generated = JSON.parse(
await readFileContent(join(homeDir, ".config", "opencode", "opencode.jsonc")),
);
expect(generated.permission.bash["git status *"]).toBe("allow");
});
});
85 changes: 85 additions & 0 deletions src/features/permissions/opencode-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { join } from "node:path";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
RULESYNC_PERMISSIONS_FILE_NAME,
RULESYNC_RELATIVE_DIR_PATH,
} from "../../constants/rulesync-paths.js";
import { setupTestDirectory } from "../../test-utils/test-directories.js";
import { ensureDir, writeFileContent } from "../../utils/file.js";
import { OpencodePermissions } from "./opencode-permissions.js";
import { RulesyncPermissions } from "./rulesync-permissions.js";

describe("OpencodePermissions", () => {
let testDir: string;
let cleanup: () => Promise<void>;

beforeEach(async () => {
({ testDir, cleanup } = await setupTestDirectory());
vi.spyOn(process, "cwd").mockReturnValue(testDir);
});

afterEach(async () => {
await cleanup();
vi.restoreAllMocks();
});

it("should resolve project and global settable paths", () => {
expect(OpencodePermissions.getSettablePaths()).toEqual({
relativeDirPath: ".",
relativeFilePath: "opencode.json",
});
expect(OpencodePermissions.getSettablePaths({ global: true })).toEqual({
relativeDirPath: join(".config", "opencode"),
relativeFilePath: "opencode.json",
});
});

it("should load opencode.jsonc and initialize permission", async () => {
await writeFileContent(join(testDir, "opencode.jsonc"), JSON.stringify({ model: "x" }));

const instance = await OpencodePermissions.fromFile({ baseDir: testDir });
const json = instance.getJson();

expect(instance.getRelativeFilePath()).toBe("opencode.jsonc");
expect(json.permission).toEqual({});
});

it("should merge rulesync permission into existing opencode config", async () => {
await writeFileContent(join(testDir, "opencode.json"), JSON.stringify({ model: "x" }));
const rulesyncPermissions = new RulesyncPermissions({
baseDir: testDir,
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({
permission: {
bash: { "*": "ask", "git *": "allow" },
},
}),
});

const instance = await OpencodePermissions.fromRulesyncPermissions({
baseDir: testDir,
rulesyncPermissions,
});
const json = JSON.parse(instance.getFileContent());

expect(instance.getRelativeFilePath()).toBe("opencode.json");
expect(json.model).toBe("x");
expect(json.permission.bash["git *"]).toBe("allow");
});

it("should support global mode file resolution", async () => {
await ensureDir(join(testDir, ".config", "opencode"));
await writeFileContent(
join(testDir, ".config", "opencode", "opencode.jsonc"),
JSON.stringify({ permission: { bash: "ask" } }),
);

const instance = await OpencodePermissions.fromFile({ baseDir: testDir, global: true });
const rulesync = instance.toRulesyncPermissions().getJson();

expect(rulesync.permission.bash).toEqual({ "*": "ask" });
});
});
Loading
Loading