diff --git a/README.md b/README.md index 241b5988e..d0b69837d 100644 --- a/README.md +++ b/README.md @@ -62,30 +62,30 @@ See [Quick Start guide](https://dyoshikawa.github.io/rulesync/getting-started/qu ## Supported Tools and Features -| Tool | --targets | rules | ignore | mcp | commands | subagents | skills | hooks | -| ------------------ | ------------ | :---: | :----: | :------: | :------: | :-------: | :----: | :---: | -| AGENTS.md | agentsmd | ✅ | | | 🎮 | 🎮 | 🎮 | | -| AgentsSkills | agentsskills | | | | | | ✅ | | -| Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ | ✅ 🌏 | | -| Gemini CLI | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | 🎮 | ✅ 🌏 | ✅ 🌏 | -| Goose | goose | ✅ 🌏 | ✅ | | | | | | -| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | -| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | -| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | -| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | -| Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | -| Qwen Code | qwencode | ✅ | ✅ | | | | | | -| Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | -| Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | -| JetBrains Junie | junie | ✅ | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | -| AugmentCode | augmentcode | ✅ | ✅ | | | | | | -| Windsurf | windsurf | ✅ | ✅ | | | | | | -| Warp | warp | ✅ | | | | | | | -| Replit | replit | ✅ | | | | | ✅ | | -| Zed | zed | | ✅ | | | | | | +| Tool | --targets | rules | ignore | mcp | commands | subagents | skills | hooks | permissions | +| ------------------ | ------------ | :---: | :----: | :------: | :------: | :-------: | :----: | :---: | :---------: | +| AGENTS.md | agentsmd | ✅ | | | 🎮 | 🎮 | 🎮 | | | +| AgentsSkills | agentsskills | | | | | | ✅ | | | +| Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | +| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ | ✅ 🌏 | | ✅ | +| Gemini CLI | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | 🎮 | ✅ 🌏 | ✅ 🌏 | | +| Goose | goose | ✅ 🌏 | ✅ | | | | | | | +| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | | +| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | | +| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | +| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | +| Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | +| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | +| Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | +| Qwen Code | qwencode | ✅ | ✅ | | | | | | | +| Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | +| Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | | +| JetBrains Junie | junie | ✅ | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | +| AugmentCode | augmentcode | ✅ | ✅ | | | | | | | +| Windsurf | windsurf | ✅ | ✅ | | | | | | | +| Warp | warp | ✅ | | | | | | | | +| Replit | replit | ✅ | | | | | ✅ | | | +| Zed | zed | | ✅ | | | | | | | - ✅: Supports project mode - 🌏: Supports global mode diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 51c36a8c6..e1d6eab5d 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -254,6 +254,51 @@ You can control which individual tools from an MCP server are enabled or disable - `enabledTools`: An array of tool names that should be explicitly enabled for this server. - `disabledTools`: An array of tool names that should be explicitly disabled for this server. +## `.rulesync/permissions.json` + +Permissions define tool-level access controls (allow/ask/deny) for AI coding tools. Each entry specifies a tool, a pattern, and an action. + +- **Supported targets:** Claude Code (`claudecode`), OpenCode (`opencode`), Codex CLI (`codexcli`) +- Codex CLI only supports `bash` tool permissions; non-bash entries are skipped with a warning. + +Example: + +```json +{ + "$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json", + "permissions": [ + { "tool": "bash", "pattern": ["npm", "*"], "action": "allow" }, + { "tool": "bash", "pattern": ["rm", "-rf", "*"], "action": "deny" }, + { "tool": "read", "pattern": ["*", ".env"], "action": "ask" }, + { "tool": "read", "pattern": ["src", "**"], "action": "allow" }, + { "tool": "edit", "pattern": ["src", "**"], "action": "allow" } + ] +} +``` + +**Fields:** + +- `tool`: Canonical tool name (`bash`, `read`, `edit`, `write`, `webfetch`, `grep`, `glob`, or MCP tool names with `mcp__` prefix) +- `pattern`: Array of pattern segments. For `bash`, segments are space-separated (e.g., `["npm", "*"]` → `npm *`). For file path tools, segments are `/`-separated (e.g., `["src", "**"]` → `src/**`). +- `action`: One of `allow`, `ask`, or `deny` + +**Tool-specific output:** + +- **Claude Code** (`.claude/settings.json`): Stored as `{ "permissions": { "allow": ["Bash(npm *)"], "deny": ["Bash(rm -rf *)"] } }` with PascalCase tool names +- **OpenCode** (`opencode.json`): Stored as `{ "permission": { "bash": { "npm *": "allow" } } }` with lowercase tool names +- **Codex CLI** (`.codex/config.toml`): Stored as TOML `rules.prefix_rules` with action mappings (`allow` → `allow`, `ask` → `prompt`, `deny` → `forbidden`) + +#### JSON Schema Support + +Rulesync provides a JSON Schema for editor validation and autocompletion. Add the `$schema` property to your `.rulesync/permissions.json`: + +```json +{ + "$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json", + "permissions": [] +} +``` + ## `.rulesync/.aiignore` or `.rulesyncignore` Rulesync supports a single ignore list that can live in either location below: diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 4331a3433..21454d6fa 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -2,30 +2,30 @@ Rulesync supports both **generation** and **import** for All of the major AI coding tools: -| Tool | --targets | rules | ignore | mcp | commands | subagents | skills | hooks | -| ------------------ | ------------ | :---: | :----: | :------: | :------: | :-------: | :----: | :---: | -| AGENTS.md | agentsmd | ✅ | | | 🎮 | 🎮 | 🎮 | | -| AgentsSkills | agentsskills | | | | | | ✅ | | -| Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ | ✅ 🌏 | | -| Gemini CLI | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | 🎮 | ✅ 🌏 | ✅ 🌏 | -| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | -| Goose | goose | ✅ 🌏 | ✅ | | | | | | -| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | -| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | -| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | -| Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | -| Qwen Code | qwencode | ✅ | ✅ | | | | | | -| Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | -| Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | -| JetBrains Junie | junie | ✅ | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | -| AugmentCode | augmentcode | ✅ | ✅ | | | | | | -| Windsurf | windsurf | ✅ | ✅ | | | | | | -| Warp | warp | ✅ | | | | | | | -| Replit | replit | ✅ | | | | | ✅ | | -| Zed | zed | | ✅ | | | | | | +| Tool | --targets | rules | ignore | mcp | commands | subagents | skills | hooks | permissions | +| ------------------ | ------------ | :---: | :----: | :------: | :------: | :-------: | :----: | :---: | :---------: | +| AGENTS.md | agentsmd | ✅ | | | 🎮 | 🎮 | 🎮 | | | +| AgentsSkills | agentsskills | | | | | | ✅ | | | +| Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | +| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ | ✅ 🌏 | | ✅ | +| Gemini CLI | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | 🎮 | ✅ 🌏 | ✅ 🌏 | | +| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | | +| Goose | goose | ✅ 🌏 | ✅ | | | | | | | +| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | | +| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | +| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | +| Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | +| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | +| Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | +| Qwen Code | qwencode | ✅ | ✅ | | | | | | | +| Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | +| Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | | +| JetBrains Junie | junie | ✅ | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | +| AugmentCode | augmentcode | ✅ | ✅ | | | | | | | +| Windsurf | windsurf | ✅ | ✅ | | | | | | | +| Warp | warp | ✅ | | | | | | | | +| Replit | replit | ✅ | | | | | ✅ | | | +| Zed | zed | | ✅ | | | | | | | - ✅: Supports project mode - 🌏: Supports global mode diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index eb6d86c70..fb9b4ac42 100644 --- a/scripts/generate-json-schema.ts +++ b/scripts/generate-json-schema.ts @@ -9,8 +9,10 @@ import { ConfigFileSchema } from "../src/config/config.js"; import { RULESYNC_CONFIG_SCHEMA_URL, RULESYNC_MCP_SCHEMA_URL, + RULESYNC_PERMISSIONS_SCHEMA_URL, } from "../src/constants/rulesync-paths.js"; import { RulesyncMcpFileSchema } from "../src/features/mcp/rulesync-mcp.js"; +import { RulesyncPermissionsFileSchema } from "../src/features/permissions/rulesync-permissions.js"; type SchemaMeta = { $id: string; @@ -56,5 +58,16 @@ generateSchema( mcpOutputPath, ); +const permissionsOutputPath = join(process.cwd(), "permissions-schema.json"); +generateSchema( + RulesyncPermissionsFileSchema, + { + $id: RULESYNC_PERMISSIONS_SCHEMA_URL, + title: "Rulesync Permissions Configuration", + description: "Permissions configuration file for Rulesync CLI tool", + }, + permissionsOutputPath, +); + // Format generated schema files with oxfmt for consistent formatting -execFileSync("npx", ["oxfmt", outputPath, mcpOutputPath]); +execFileSync("npx", ["oxfmt", outputPath, mcpOutputPath, permissionsOutputPath]); diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 51c36a8c6..e1d6eab5d 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -254,6 +254,51 @@ You can control which individual tools from an MCP server are enabled or disable - `enabledTools`: An array of tool names that should be explicitly enabled for this server. - `disabledTools`: An array of tool names that should be explicitly disabled for this server. +## `.rulesync/permissions.json` + +Permissions define tool-level access controls (allow/ask/deny) for AI coding tools. Each entry specifies a tool, a pattern, and an action. + +- **Supported targets:** Claude Code (`claudecode`), OpenCode (`opencode`), Codex CLI (`codexcli`) +- Codex CLI only supports `bash` tool permissions; non-bash entries are skipped with a warning. + +Example: + +```json +{ + "$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json", + "permissions": [ + { "tool": "bash", "pattern": ["npm", "*"], "action": "allow" }, + { "tool": "bash", "pattern": ["rm", "-rf", "*"], "action": "deny" }, + { "tool": "read", "pattern": ["*", ".env"], "action": "ask" }, + { "tool": "read", "pattern": ["src", "**"], "action": "allow" }, + { "tool": "edit", "pattern": ["src", "**"], "action": "allow" } + ] +} +``` + +**Fields:** + +- `tool`: Canonical tool name (`bash`, `read`, `edit`, `write`, `webfetch`, `grep`, `glob`, or MCP tool names with `mcp__` prefix) +- `pattern`: Array of pattern segments. For `bash`, segments are space-separated (e.g., `["npm", "*"]` → `npm *`). For file path tools, segments are `/`-separated (e.g., `["src", "**"]` → `src/**`). +- `action`: One of `allow`, `ask`, or `deny` + +**Tool-specific output:** + +- **Claude Code** (`.claude/settings.json`): Stored as `{ "permissions": { "allow": ["Bash(npm *)"], "deny": ["Bash(rm -rf *)"] } }` with PascalCase tool names +- **OpenCode** (`opencode.json`): Stored as `{ "permission": { "bash": { "npm *": "allow" } } }` with lowercase tool names +- **Codex CLI** (`.codex/config.toml`): Stored as TOML `rules.prefix_rules` with action mappings (`allow` → `allow`, `ask` → `prompt`, `deny` → `forbidden`) + +#### JSON Schema Support + +Rulesync provides a JSON Schema for editor validation and autocompletion. Add the `$schema` property to your `.rulesync/permissions.json`: + +```json +{ + "$schema": "https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json", + "permissions": [] +} +``` + ## `.rulesync/.aiignore` or `.rulesyncignore` Rulesync supports a single ignore list that can live in either location below: diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 4331a3433..21454d6fa 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -2,30 +2,30 @@ Rulesync supports both **generation** and **import** for All of the major AI coding tools: -| Tool | --targets | rules | ignore | mcp | commands | subagents | skills | hooks | -| ------------------ | ------------ | :---: | :----: | :------: | :------: | :-------: | :----: | :---: | -| AGENTS.md | agentsmd | ✅ | | | 🎮 | 🎮 | 🎮 | | -| AgentsSkills | agentsskills | | | | | | ✅ | | -| Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ | ✅ 🌏 | | -| Gemini CLI | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | 🎮 | ✅ 🌏 | ✅ 🌏 | -| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | -| Goose | goose | ✅ 🌏 | ✅ | | | | | | -| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | -| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | -| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | -| Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | -| Qwen Code | qwencode | ✅ | ✅ | | | | | | -| Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | -| Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | -| JetBrains Junie | junie | ✅ | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | -| AugmentCode | augmentcode | ✅ | ✅ | | | | | | -| Windsurf | windsurf | ✅ | ✅ | | | | | | -| Warp | warp | ✅ | | | | | | | -| Replit | replit | ✅ | | | | | ✅ | | -| Zed | zed | | ✅ | | | | | | +| Tool | --targets | rules | ignore | mcp | commands | subagents | skills | hooks | permissions | +| ------------------ | ------------ | :---: | :----: | :------: | :------: | :-------: | :----: | :---: | :---------: | +| AGENTS.md | agentsmd | ✅ | | | 🎮 | 🎮 | 🎮 | | | +| AgentsSkills | agentsskills | | | | | | ✅ | | | +| Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | +| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ | ✅ 🌏 | | ✅ | +| Gemini CLI | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | 🎮 | ✅ 🌏 | ✅ 🌏 | | +| GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | | +| Goose | goose | ✅ 🌏 | ✅ | | | | | | | +| Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | | +| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | +| OpenCode | opencode | ✅ 🌏 | | ✅ 🌏 🔧 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | +| Cline | cline | ✅ | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | +| Kilo Code | kilo | ✅ 🌏 | ✅ | ✅ | ✅ 🌏 | | ✅ 🌏 | | | +| Roo Code | roo | ✅ | ✅ | ✅ | ✅ | 🎮 | ✅ 🌏 | | | +| Qwen Code | qwencode | ✅ | ✅ | | | | | | | +| Kiro | kiro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | +| Google Antigravity | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | | +| JetBrains Junie | junie | ✅ | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | +| AugmentCode | augmentcode | ✅ | ✅ | | | | | | | +| Windsurf | windsurf | ✅ | ✅ | | | | | | | +| Warp | warp | ✅ | | | | | | | | +| Replit | replit | ✅ | | | | | ✅ | | | +| Zed | zed | | ✅ | | | | | | | - ✅: Supports project mode - 🌏: Supports global mode diff --git a/src/cli/commands/generate.ts b/src/cli/commands/generate.ts index 4b481cca6..b82006861 100644 --- a/src/cli/commands/generate.ts +++ b/src/cli/commands/generate.ts @@ -71,6 +71,9 @@ export async function generateCommand(logger: Logger, options: GenerateOptions): if (features.includes("hooks")) { logger.debug("Generating hooks..."); } + if (features.includes("permissions")) { + logger.debug("Generating permissions..."); + } if (features.includes("rules")) { logger.debug("Generating rule files..."); } @@ -87,6 +90,7 @@ export async function generateCommand(logger: Logger, options: GenerateOptions): subagents: { count: result.subagentsCount, paths: result.subagentsPaths }, skills: { count: result.skillsCount, paths: result.skillsPaths }, hooks: { count: result.hooksCount, paths: result.hooksPaths }, + permissions: { count: result.permissionsCount, paths: result.permissionsPaths }, rules: { count: result.rulesCount, paths: result.rulesPaths }, }; @@ -99,6 +103,7 @@ export async function generateCommand(logger: Logger, options: GenerateOptions): subagents: (count) => `${count === 1 ? "subagent" : "subagents"}`, skills: (count) => `${count === 1 ? "skill" : "skills"}`, hooks: (count) => `${count === 1 ? "hooks file" : "hooks files"}`, + permissions: (count) => `${count === 1 ? "permission file" : "permission files"}`, }; for (const [feature, data] of Object.entries(featureResults)) { @@ -133,6 +138,7 @@ export async function generateCommand(logger: Logger, options: GenerateOptions): if (result.subagentsCount > 0) parts.push(`${result.subagentsCount} subagents`); if (result.skillsCount > 0) parts.push(`${result.skillsCount} skills`); if (result.hooksCount > 0) parts.push(`${result.hooksCount} hooks`); + if (result.permissionsCount > 0) parts.push(`${result.permissionsCount} permissions`); if (isPreview) { logger.info(`${modePrefix} Would write ${totalGenerated} file(s) total (${parts.join(" + ")})`); diff --git a/src/cli/commands/import.ts b/src/cli/commands/import.ts index 570769a76..6e7f023a3 100644 --- a/src/cli/commands/import.ts +++ b/src/cli/commands/import.ts @@ -43,6 +43,7 @@ export async function importCommand(logger: Logger, options: ImportOptions): Pro subagents: { count: result.subagentsCount }, skills: { count: result.skillsCount }, hooks: { count: result.hooksCount }, + permissions: { count: result.permissionsCount }, }); logger.captureData("totalFiles", totalImported); } @@ -55,6 +56,7 @@ export async function importCommand(logger: Logger, options: ImportOptions): Pro if (result.subagentsCount > 0) parts.push(`${result.subagentsCount} subagents`); if (result.skillsCount > 0) parts.push(`${result.skillsCount} skills`); if (result.hooksCount > 0) parts.push(`${result.hooksCount} hooks`); + if (result.permissionsCount > 0) parts.push(`${result.permissionsCount} permissions`); logger.success(`Imported ${totalImported} file(s) total (${parts.join(" + ")})`); } diff --git a/src/constants/rulesync-paths.ts b/src/constants/rulesync-paths.ts index aacd6bf17..d34683307 100644 --- a/src/constants/rulesync-paths.ts +++ b/src/constants/rulesync-paths.ts @@ -19,15 +19,23 @@ export const RULESYNC_CURATED_SKILLS_RELATIVE_DIR_PATH = join( ); export const RULESYNC_SOURCES_LOCK_RELATIVE_FILE_PATH = "rulesync.lock"; +export const RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH = join( + RULESYNC_RELATIVE_DIR_PATH, + "permissions.json", +); + // File names (without path) export const RULESYNC_MCP_FILE_NAME = "mcp.json"; export const RULESYNC_HOOKS_FILE_NAME = "hooks.json"; +export const RULESYNC_PERMISSIONS_FILE_NAME = "permissions.json"; // JSON Schema URLs (published as GitHub release assets) export const RULESYNC_CONFIG_SCHEMA_URL = "https://github.com/dyoshikawa/rulesync/releases/latest/download/config-schema.json"; export const RULESYNC_MCP_SCHEMA_URL = "https://github.com/dyoshikawa/rulesync/releases/latest/download/mcp-schema.json"; +export const RULESYNC_PERMISSIONS_SCHEMA_URL = + "https://github.com/dyoshikawa/rulesync/releases/latest/download/permissions-schema.json"; // Size limits export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB diff --git a/src/features/permissions/claudecode-permissions.test.ts b/src/features/permissions/claudecode-permissions.test.ts new file mode 100644 index 000000000..e3a326498 --- /dev/null +++ b/src/features/permissions/claudecode-permissions.test.ts @@ -0,0 +1,251 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { 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 { ClaudecodePermissions } from "./claudecode-permissions.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; + +describe("ClaudecodePermissions", () => { + 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("should return .claude and settings.json", () => { + const paths = ClaudecodePermissions.getSettablePaths(); + expect(paths).toEqual({ + relativeDirPath: ".claude", + relativeFilePath: "settings.json", + }); + }); + }); + + describe("isDeletable", () => { + it("should return false", () => { + const instance = new ClaudecodePermissions({ + baseDir: testDir, + relativeDirPath: ".claude", + relativeFilePath: "settings.json", + fileContent: "{}", + validate: false, + }); + expect(instance.isDeletable()).toBe(false); + }); + }); + + describe("fromRulesyncPermissions", () => { + it("should convert canonical format to Claude Code format", async () => { + await ensureDir(join(testDir, ".claude")); + await writeFileContent(join(testDir, ".claude", "settings.json"), "{}"); + + const config = { + permissions: [ + { tool: "bash", pattern: ["npm", "*"], action: "allow" }, + { tool: "bash", pattern: ["rm", "-rf", "*"], action: "deny" }, + { tool: "read", pattern: ["src", "**"], action: "allow" }, + { tool: "read", pattern: ["*", ".env"], action: "ask" }, + { tool: "edit", pattern: ["src", "**"], action: "allow" }, + ], + }; + + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const result = await ClaudecodePermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + + const parsed = JSON.parse(result.getFileContent()); + expect(parsed.permissions.allow).toEqual( + expect.arrayContaining(["Bash(npm *)", "Edit(src/**)", "Read(src/**)"]), + ); + expect(parsed.permissions.ask).toEqual(["Read(*/.env)"]); + expect(parsed.permissions.deny).toEqual(["Bash(rm -rf *)"]); + }); + + it("should preserve existing non-permissions keys in settings.json", async () => { + await ensureDir(join(testDir, ".claude")); + await writeFileContent( + join(testDir, ".claude", "settings.json"), + JSON.stringify({ hooks: { PreToolUse: [] }, customKey: "value" }), + ); + + const config = { + permissions: [{ tool: "bash", pattern: ["npm", "*"], action: "allow" }], + }; + + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const result = await ClaudecodePermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + + const parsed = JSON.parse(result.getFileContent()); + expect(parsed.hooks).toEqual({ PreToolUse: [] }); + expect(parsed.customKey).toBe("value"); + expect(parsed.permissions.allow).toEqual(["Bash(npm *)"]); + }); + + it("should preserve Read() entries from ignore feature in deny", async () => { + await ensureDir(join(testDir, ".claude")); + await writeFileContent( + join(testDir, ".claude", "settings.json"), + JSON.stringify({ + permissions: { + deny: ["Read(node_modules/**)", "Read(.env)"], + }, + }), + ); + + const config = { + permissions: [{ tool: "bash", pattern: ["rm", "-rf", "*"], action: "deny" }], + }; + + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const result = await ClaudecodePermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + + const parsed = JSON.parse(result.getFileContent()); + // Should preserve the Read() entries from ignore AND add the new deny + expect(parsed.permissions.deny).toEqual( + expect.arrayContaining(["Bash(rm -rf *)", "Read(.env)", "Read(node_modules/**)"]), + ); + }); + + it("should create settings.json if it does not exist", async () => { + const config = { + permissions: [{ tool: "bash", pattern: ["npm", "*"], action: "allow" }], + }; + + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const result = await ClaudecodePermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + + const parsed = JSON.parse(result.getFileContent()); + expect(parsed.permissions.allow).toEqual(["Bash(npm *)"]); + }); + + it("should handle MCP tool names (mcp__ prefix)", async () => { + await ensureDir(join(testDir, ".claude")); + await writeFileContent(join(testDir, ".claude", "settings.json"), "{}"); + + const config = { + permissions: [{ tool: "mcp__serena__search", pattern: ["**"], action: "allow" }], + }; + + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const result = await ClaudecodePermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + + const parsed = JSON.parse(result.getFileContent()); + expect(parsed.permissions.allow).toEqual(["mcp__serena__search(**)"]); + }); + }); + + describe("toRulesyncPermissions", () => { + it("should convert Claude Code format back to canonical", () => { + const settings = { + permissions: { + allow: ["Bash(npm *)", "Read(src/**)"], + ask: ["Read(*.env)"], + deny: ["Bash(rm -rf *)"], + }, + }; + + const instance = new ClaudecodePermissions({ + baseDir: testDir, + relativeDirPath: ".claude", + relativeFilePath: "settings.json", + fileContent: JSON.stringify(settings), + validate: false, + }); + + const rulesync = instance.toRulesyncPermissions(); + const json = rulesync.getJson(); + + expect(json.permissions).toEqual( + expect.arrayContaining([ + { tool: "bash", pattern: ["npm", "*"], action: "allow" }, + { tool: "read", pattern: ["src", "**"], action: "allow" }, + { tool: "read", pattern: ["*.env"], action: "ask" }, + { tool: "bash", pattern: ["rm", "-rf", "*"], action: "deny" }, + ]), + ); + }); + }); + + describe("fromFile", () => { + it("should load from existing settings.json", async () => { + await ensureDir(join(testDir, ".claude")); + await writeFileContent( + join(testDir, ".claude", "settings.json"), + JSON.stringify({ + permissions: { + allow: ["Bash(npm *)"], + }, + }), + ); + + const instance = await ClaudecodePermissions.fromFile({ baseDir: testDir }); + const content = JSON.parse(instance.getFileContent()); + expect(content.permissions.allow).toEqual(["Bash(npm *)"]); + }); + + it("should return empty object if file does not exist", async () => { + const instance = await ClaudecodePermissions.fromFile({ baseDir: testDir }); + expect(instance.getFileContent()).toBe("{}"); + }); + }); +}); diff --git a/src/features/permissions/claudecode-permissions.ts b/src/features/permissions/claudecode-permissions.ts new file mode 100644 index 000000000..24bd6f99b --- /dev/null +++ b/src/features/permissions/claudecode-permissions.ts @@ -0,0 +1,252 @@ +import { join } from "node:path"; + +import { uniq } from "es-toolkit"; + +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import type { PermissionAction, PermissionEntry } from "../../types/permissions.js"; +import { + CANONICAL_TO_CLAUDE_TOOL_NAMES, + CLAUDE_TO_CANONICAL_TOOL_NAMES, + joinPattern, + splitPattern, +} from "../../types/permissions.js"; +import { formatError } from "../../utils/error.js"; +import { fileExists, readFileContent, readOrInitializeFileContent } from "../../utils/file.js"; +import type { RulesyncPermissions } from "./rulesync-permissions.js"; +import { + ToolPermissions, + type ToolPermissionsForDeletionParams, + type ToolPermissionsFromFileParams, + type ToolPermissionsFromRulesyncPermissionsParams, + type ToolPermissionsSettablePaths, +} from "./tool-permissions.js"; + +type ClaudeSettingsPermissions = { + allow?: string[]; + ask?: string[]; + deny?: string[]; +}; + +type ClaudeSettingsJson = Record & { + permissions?: ClaudeSettingsPermissions; +}; + +/** + * Convert a canonical tool name to Claude Code PascalCase name. + * If the tool name starts with "mcp__", it's kept as-is (MCP tool). + */ +function toClaudeToolName(canonicalTool: string): string { + if (canonicalTool.startsWith("mcp__")) { + return canonicalTool; + } + return CANONICAL_TO_CLAUDE_TOOL_NAMES[canonicalTool] ?? canonicalTool; +} + +/** + * Convert a Claude Code tool name back to canonical. + */ +function fromClaudeToolName(claudeTool: string): string { + if (claudeTool.startsWith("mcp__")) { + return claudeTool; + } + return CLAUDE_TO_CANONICAL_TOOL_NAMES[claudeTool] ?? claudeTool.toLowerCase(); +} + +/** + * Format a permission entry for Claude Code: "ToolName(pattern)" + */ +function formatClaudePermission(entry: PermissionEntry): string { + const toolName = toClaudeToolName(entry.tool); + const joined = joinPattern(entry.tool, entry.pattern); + return `${toolName}(${joined})`; +} + +/** + * Parse a Claude permission string like "Bash(npm *)" back to canonical entry. + */ +function parseClaudePermission( + permission: string, + action: PermissionAction, +): PermissionEntry | null { + const match = permission.match(/^([^(]+)\((.+)\)$/); + if (!match) return null; + + // eslint-disable-next-line no-type-assertion/no-type-assertion + const claudeTool = match[1]!; + // eslint-disable-next-line no-type-assertion/no-type-assertion + const patternStr = match[2]!; + const canonicalTool = fromClaudeToolName(claudeTool); + + return { + tool: canonicalTool, + pattern: splitPattern(canonicalTool, patternStr), + action, + }; +} + +/** + * Check if a permission string is a Read() pattern (from the ignore feature). + */ +function isReadPattern(permission: string): boolean { + return permission.startsWith("Read(") && permission.endsWith(")"); +} + +export class ClaudecodePermissions extends ToolPermissions { + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? "{}", + }); + } + + override isDeletable(): boolean { + return false; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolPermissionsSettablePaths { + return { relativeDirPath: ".claude", relativeFilePath: "settings.json" }; + } + + static async fromFile({ + baseDir = process.cwd(), + validate = true, + }: ToolPermissionsFromFileParams): Promise { + const paths = ClaudecodePermissions.getSettablePaths(); + const filePath = join(baseDir, paths.relativeDirPath, paths.relativeFilePath); + + let fileContent = "{}"; + if (await fileExists(filePath)) { + fileContent = await readFileContent(filePath); + } + + return new ClaudecodePermissions({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + static async fromRulesyncPermissions({ + baseDir = process.cwd(), + rulesyncPermissions, + validate = true, + }: ToolPermissionsFromRulesyncPermissionsParams): Promise { + const paths = ClaudecodePermissions.getSettablePaths(); + const filePath = join(baseDir, paths.relativeDirPath, paths.relativeFilePath); + const existingContent = await readOrInitializeFileContent( + filePath, + JSON.stringify({}, null, 2), + ); + let settings: ClaudeSettingsJson; + try { + settings = JSON.parse(existingContent); + } catch (error) { + throw new Error( + `Failed to parse existing Claude settings at ${filePath}: ${formatError(error)}`, + { cause: error }, + ); + } + + const config = rulesyncPermissions.getJson(); + const entries = config.permissions; + + // Group entries by action + const grouped: Record = { + allow: [], + ask: [], + deny: [], + }; + + for (const entry of entries) { + const formatted = formatClaudePermission(entry); + grouped[entry.action].push(formatted); + } + + // Build new permissions, preserving Read() entries from the ignore feature in deny + const existingPermissions = settings.permissions ?? {}; + const existingDenies = existingPermissions.deny ?? []; + + // Preserve Read() patterns in deny that came from the ignore feature + // (i.e., Read() patterns that are NOT generated by permissions) + const permissionsDenyReadPatterns = new Set(grouped.deny.filter((p) => isReadPattern(p))); + const preservedIgnoreReads = existingDenies.filter( + (deny) => isReadPattern(deny) && !permissionsDenyReadPatterns.has(deny), + ); + + const newPermissions: ClaudeSettingsPermissions = {}; + if (grouped.allow.length > 0) { + newPermissions.allow = uniq(grouped.allow.toSorted()); + } + if (grouped.ask.length > 0) { + newPermissions.ask = uniq(grouped.ask.toSorted()); + } + + const allDenies = uniq([...preservedIgnoreReads, ...grouped.deny].toSorted()); + if (allDenies.length > 0) { + newPermissions.deny = allDenies; + } + + const merged: ClaudeSettingsJson = { + ...settings, + permissions: newPermissions, + }; + const fileContent = JSON.stringify(merged, null, 2); + + return new ClaudecodePermissions({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + toRulesyncPermissions(): RulesyncPermissions { + let settings: ClaudeSettingsJson; + try { + settings = JSON.parse(this.getFileContent()); + } catch (error) { + throw new Error( + `Failed to parse Claude permissions content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, + { cause: error }, + ); + } + + const permissions = settings.permissions ?? {}; + const entries: PermissionEntry[] = []; + + for (const action of ["allow", "ask", "deny"] as const) { + const items = permissions[action] ?? []; + for (const item of items) { + const entry = parseClaudePermission(item, action); + if (entry) { + entries.push(entry); + } + } + } + + return this.toRulesyncPermissionsDefault({ + fileContent: JSON.stringify({ permissions: entries }, null, 2), + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolPermissionsForDeletionParams): ClaudecodePermissions { + return new ClaudecodePermissions({ + baseDir, + relativeDirPath, + relativeFilePath, + fileContent: JSON.stringify({}, null, 2), + validate: false, + }); + } +} diff --git a/src/features/permissions/codexcli-permissions.test.ts b/src/features/permissions/codexcli-permissions.test.ts new file mode 100644 index 000000000..5941f3154 --- /dev/null +++ b/src/features/permissions/codexcli-permissions.test.ts @@ -0,0 +1,216 @@ +import { join } from "node:path"; + +import * as smolToml from "smol-toml"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { 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 { CodexcliPermissions } from "./codexcli-permissions.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; + +describe("CodexcliPermissions", () => { + 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("should return .codex and config.toml", () => { + const paths = CodexcliPermissions.getSettablePaths(); + expect(paths).toEqual({ + relativeDirPath: ".codex", + relativeFilePath: "config.toml", + }); + }); + }); + + describe("isDeletable", () => { + it("should return false", () => { + const instance = new CodexcliPermissions({ + baseDir: testDir, + relativeDirPath: ".codex", + relativeFilePath: "config.toml", + fileContent: "", + validate: false, + }); + expect(instance.isDeletable()).toBe(false); + }); + }); + + describe("fromRulesyncPermissions", () => { + it("should convert canonical bash entries to Codex prefix_rules format", async () => { + await ensureDir(join(testDir, ".codex")); + await writeFileContent(join(testDir, ".codex", "config.toml"), ""); + + const config = { + permissions: [ + { tool: "bash", pattern: ["rm", "-rf", "*"], action: "deny" }, + { tool: "bash", pattern: ["npm", "*"], action: "allow" }, + { tool: "bash", pattern: ["git", "push"], action: "ask" }, + ], + }; + + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const result = await CodexcliPermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + + const content = result.getFileContent(); + // Verify TOML output contains prefix_rules + expect(content).toContain("prefix_rules"); + + // Parse to verify structure + const parsed = smolToml.parse(content); + const rules = parsed.rules as { + prefix_rules: Array<{ pattern: Array<{ token: string }>; decision: string }>; + }; + expect(rules.prefix_rules).toHaveLength(3); + + // Check that trailing "*" is omitted (prefix matching) + const rmRule = rules.prefix_rules.find((r) => r.decision === "forbidden"); + expect(rmRule).toBeDefined(); + expect(rmRule!.pattern).toEqual([{ token: "rm" }, { token: "-rf" }]); + + const npmRule = rules.prefix_rules.find((r) => r.pattern[0]?.token === "npm"); + expect(npmRule).toBeDefined(); + expect(npmRule!.decision).toBe("allow"); + expect(npmRule!.pattern).toEqual([{ token: "npm" }]); + + // ask → prompt + const gitRule = rules.prefix_rules.find((r) => r.pattern[0]?.token === "git"); + expect(gitRule).toBeDefined(); + expect(gitRule!.decision).toBe("prompt"); + }); + + it("should skip non-bash entries and warn", async () => { + await ensureDir(join(testDir, ".codex")); + await writeFileContent(join(testDir, ".codex", "config.toml"), ""); + + const config = { + permissions: [ + { tool: "bash", pattern: ["npm", "*"], action: "allow" }, + { tool: "read", pattern: ["src", "**"], action: "allow" }, + { tool: "edit", pattern: ["src", "**"], action: "allow" }, + ], + }; + + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const result = await CodexcliPermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + + const parsed = smolToml.parse(result.getFileContent()); + const rules = parsed.rules as { + prefix_rules: Array<{ pattern: Array<{ token: string }>; decision: string }>; + }; + // Only bash entry should be present + expect(rules.prefix_rules).toHaveLength(1); + }); + + it("should preserve existing TOML keys", async () => { + await ensureDir(join(testDir, ".codex")); + await writeFileContent( + join(testDir, ".codex", "config.toml"), + '[mcp_servers.myserver]\ncommand = "npx"\nargs = ["-y", "my-mcp"]\n', + ); + + const config = { + permissions: [{ tool: "bash", pattern: ["npm", "*"], action: "allow" }], + }; + + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const result = await CodexcliPermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + + const parsed = smolToml.parse(result.getFileContent()); + expect(parsed.mcp_servers).toBeDefined(); + expect(parsed.rules).toBeDefined(); + }); + }); + + describe("toRulesyncPermissions", () => { + it("should convert Codex prefix_rules back to canonical", async () => { + const toml = smolToml.stringify({ + rules: { + prefix_rules: [ + { pattern: [{ token: "rm" }, { token: "-rf" }], decision: "forbidden" }, + { pattern: [{ token: "npm" }], decision: "allow" }, + { pattern: [{ token: "git" }, { token: "push" }], decision: "prompt" }, + ], + }, + }); + + const instance = new CodexcliPermissions({ + baseDir: testDir, + relativeDirPath: ".codex", + relativeFilePath: "config.toml", + fileContent: toml, + validate: false, + }); + + const rulesync = instance.toRulesyncPermissions(); + const json = rulesync.getJson(); + + expect(json.permissions).toEqual( + expect.arrayContaining([ + { tool: "bash", pattern: ["rm", "-rf", "*"], action: "deny" }, + { tool: "bash", pattern: ["npm", "*"], action: "allow" }, + { tool: "bash", pattern: ["git", "push", "*"], action: "ask" }, + ]), + ); + }); + }); + + describe("fromFile", () => { + it("should load from existing config.toml", async () => { + await ensureDir(join(testDir, ".codex")); + await writeFileContent( + join(testDir, ".codex", "config.toml"), + '[[rules.prefix_rules]]\ndecision = "allow"\n\n[[rules.prefix_rules.pattern]]\ntoken = "npm"\n', + ); + + const instance = await CodexcliPermissions.fromFile({ baseDir: testDir }); + expect(instance.getFileContent()).toContain("prefix_rules"); + }); + + it("should create empty config if file does not exist", async () => { + const instance = await CodexcliPermissions.fromFile({ baseDir: testDir }); + // Should not throw + expect(instance).toBeDefined(); + }); + }); +}); diff --git a/src/features/permissions/codexcli-permissions.ts b/src/features/permissions/codexcli-permissions.ts new file mode 100644 index 000000000..4c8143a9a --- /dev/null +++ b/src/features/permissions/codexcli-permissions.ts @@ -0,0 +1,192 @@ +import { join } from "node:path"; + +import * as smolToml from "smol-toml"; + +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import type { PermissionAction, PermissionEntry } from "../../types/permissions.js"; +import { readOrInitializeFileContent } from "../../utils/file.js"; +import { logger } from "../../utils/logger.js"; +import type { RulesyncPermissions } from "./rulesync-permissions.js"; +import { + ToolPermissions, + type ToolPermissionsForDeletionParams, + type ToolPermissionsFromFileParams, + type ToolPermissionsFromRulesyncPermissionsParams, + type ToolPermissionsSettablePaths, +} from "./tool-permissions.js"; + +// Codex CLI action mapping: canonical → codex +const CANONICAL_TO_CODEX_ACTION: Record = { + allow: "allow", + ask: "prompt", + deny: "forbidden", +}; + +// Codex CLI action mapping: codex → canonical +const CODEX_TO_CANONICAL_ACTION: Record = { + allow: "allow", + prompt: "ask", + forbidden: "deny", +}; + +type CodexPrefixRule = { + pattern: Array<{ token: string }>; + decision: string; +}; + +export class CodexcliPermissions extends ToolPermissions { + private readonly toml: smolToml.TomlTable; + + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? "", + validate: false, + }); + + this.toml = this.fileContent ? smolToml.parse(this.fileContent) : {}; + + if (params.validate) { + const result = this.validate(); + if (!result.success) { + throw result.error; + } + } + } + + override isDeletable(): boolean { + return false; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolPermissionsSettablePaths { + return { relativeDirPath: ".codex", relativeFilePath: "config.toml" }; + } + + static async fromFile({ + baseDir = process.cwd(), + validate = true, + }: ToolPermissionsFromFileParams): Promise { + const paths = this.getSettablePaths(); + const filePath = join(baseDir, paths.relativeDirPath, paths.relativeFilePath); + const fileContent = await readOrInitializeFileContent(filePath, smolToml.stringify({})); + + return new CodexcliPermissions({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + static async fromRulesyncPermissions({ + baseDir = process.cwd(), + rulesyncPermissions, + validate = true, + }: ToolPermissionsFromRulesyncPermissionsParams): Promise { + const paths = this.getSettablePaths(); + const filePath = join(baseDir, paths.relativeDirPath, paths.relativeFilePath); + const existingContent = await readOrInitializeFileContent(filePath, smolToml.stringify({})); + const configToml = smolToml.parse(existingContent); + + const config = rulesyncPermissions.getJson(); + + // Filter to bash-only entries; warn about skipped non-bash entries + const bashEntries: PermissionEntry[] = []; + const skippedTools = new Set(); + + for (const entry of config.permissions) { + if (entry.tool === "bash") { + bashEntries.push(entry); + } else { + skippedTools.add(entry.tool); + } + } + + if (skippedTools.size > 0) { + logger.warn( + `Codex CLI only supports bash permissions. Skipped tool(s): ${[...skippedTools].join(", ")}`, + ); + } + + // Convert to Codex prefix_rules format + const prefixRules: CodexPrefixRule[] = bashEntries.map((entry) => { + // Omit trailing "*" tokens (prefix matching in Codex) + const tokens = [...entry.pattern]; + while (tokens.length > 0 && tokens[tokens.length - 1] === "*") { + tokens.pop(); + } + + return { + pattern: tokens.map((t) => ({ token: t })), + decision: CANONICAL_TO_CODEX_ACTION[entry.action], + }; + }); + + // Preserve existing config, replace rules.prefix_rules + const existingRules = + typeof configToml.rules === "object" && configToml.rules !== null + ? // eslint-disable-next-line no-type-assertion/no-type-assertion + (configToml.rules as Record) + : {}; + + // eslint-disable-next-line no-type-assertion/no-type-assertion + configToml.rules = { + ...existingRules, + prefix_rules: prefixRules, + } as smolToml.TomlTable; + + return new CodexcliPermissions({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent: smolToml.stringify(configToml), + validate, + }); + } + + toRulesyncPermissions(): RulesyncPermissions { + // eslint-disable-next-line no-type-assertion/no-type-assertion + const rules = this.toml.rules as Record | undefined; + // eslint-disable-next-line no-type-assertion/no-type-assertion + const prefixRules = (rules?.prefix_rules ?? []) as CodexPrefixRule[]; + const entries: PermissionEntry[] = []; + + for (const rule of prefixRules) { + const tokens = rule.pattern.map((p) => p.token); + const action = CODEX_TO_CANONICAL_ACTION[rule.decision]; + if (!action) continue; + + // Reconstruct the pattern - add trailing "*" for prefix matching + const pattern = tokens.length > 0 ? [...tokens, "*"] : ["*"]; + + entries.push({ + tool: "bash", + pattern, + action, + }); + } + + return this.toRulesyncPermissionsDefault({ + fileContent: JSON.stringify({ permissions: entries }, null, 2), + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolPermissionsForDeletionParams): CodexcliPermissions { + return new CodexcliPermissions({ + baseDir, + relativeDirPath, + relativeFilePath, + fileContent: "", + validate: false, + }); + } +} diff --git a/src/features/permissions/opencode-permissions.test.ts b/src/features/permissions/opencode-permissions.test.ts new file mode 100644 index 000000000..b433e65be --- /dev/null +++ b/src/features/permissions/opencode-permissions.test.ts @@ -0,0 +1,186 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { 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; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return . and opencode.json", () => { + const paths = OpencodePermissions.getSettablePaths(); + expect(paths).toEqual({ + relativeDirPath: ".", + relativeFilePath: "opencode.json", + }); + }); + }); + + describe("isDeletable", () => { + it("should return false", () => { + const instance = new OpencodePermissions({ + baseDir: testDir, + relativeDirPath: ".", + relativeFilePath: "opencode.json", + fileContent: "{}", + validate: false, + }); + expect(instance.isDeletable()).toBe(false); + }); + }); + + describe("fromRulesyncPermissions", () => { + it("should convert canonical format to OpenCode format", async () => { + const config = { + permissions: [ + { tool: "bash", pattern: ["npm", "*"], action: "allow" }, + { tool: "bash", pattern: ["rm", "-rf", "*"], action: "deny" }, + { tool: "read", pattern: ["*", ".env"], action: "ask" }, + { tool: "read", pattern: ["src", "**"], action: "allow" }, + { tool: "edit", pattern: ["src", "**"], action: "allow" }, + ], + }; + + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const result = await OpencodePermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + + const parsed = JSON.parse(result.getFileContent()); + expect(parsed.permission).toEqual({ + bash: { "npm *": "allow", "rm -rf *": "deny" }, + read: { "*/.env": "ask", "src/**": "allow" }, + edit: { "src/**": "allow" }, + }); + }); + + it("should preserve existing non-permission keys", async () => { + await writeFileContent( + join(testDir, "opencode.json"), + JSON.stringify({ mcp: { server1: {} }, model: "claude" }), + ); + + const config = { + permissions: [{ tool: "bash", pattern: ["npm", "*"], action: "allow" }], + }; + + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const result = await OpencodePermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + + const parsed = JSON.parse(result.getFileContent()); + expect(parsed.mcp).toEqual({ server1: {} }); + expect(parsed.model).toBe("claude"); + expect(parsed.permission.bash).toEqual({ "npm *": "allow" }); + }); + + it("should prefer jsonc file when both exist", async () => { + await writeFileContent( + join(testDir, "opencode.jsonc"), + JSON.stringify({ existing: "jsonc" }), + ); + await writeFileContent(join(testDir, "opencode.json"), JSON.stringify({ existing: "json" })); + + const config = { + permissions: [{ tool: "bash", pattern: ["npm", "*"], action: "allow" }], + }; + + const rulesyncPermissions = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const result = await OpencodePermissions.fromRulesyncPermissions({ + baseDir: testDir, + rulesyncPermissions, + }); + + const parsed = JSON.parse(result.getFileContent()); + expect(parsed.existing).toBe("jsonc"); + }); + }); + + describe("toRulesyncPermissions", () => { + it("should convert OpenCode format back to canonical", () => { + const opencodeConfig = { + permission: { + bash: { "npm *": "allow", "rm -rf *": "deny" }, + read: { "*.env": "ask", "src/**": "allow" }, + }, + }; + + const instance = new OpencodePermissions({ + baseDir: testDir, + relativeDirPath: ".", + relativeFilePath: "opencode.json", + fileContent: JSON.stringify(opencodeConfig), + validate: false, + }); + + const rulesync = instance.toRulesyncPermissions(); + const json = rulesync.getJson(); + + expect(json.permissions).toEqual( + expect.arrayContaining([ + { tool: "bash", pattern: ["npm", "*"], action: "allow" }, + { tool: "bash", pattern: ["rm", "-rf", "*"], action: "deny" }, + { tool: "read", pattern: ["*.env"], action: "ask" }, + { tool: "read", pattern: ["src", "**"], action: "allow" }, + ]), + ); + }); + }); + + describe("fromFile", () => { + it("should load from existing opencode.json", async () => { + await writeFileContent( + join(testDir, "opencode.json"), + JSON.stringify({ permission: { bash: { "npm *": "allow" } } }), + ); + + const instance = await OpencodePermissions.fromFile({ baseDir: testDir }); + expect(instance.getFileContent()).toContain("permission"); + }); + + it("should return empty object if file does not exist", async () => { + const instance = await OpencodePermissions.fromFile({ baseDir: testDir }); + expect(instance.getFileContent()).toBe("{}"); + }); + }); +}); diff --git a/src/features/permissions/opencode-permissions.ts b/src/features/permissions/opencode-permissions.ts new file mode 100644 index 000000000..83c342f14 --- /dev/null +++ b/src/features/permissions/opencode-permissions.ts @@ -0,0 +1,164 @@ +import { join } from "node:path"; + +import { parse as parseJsonc } from "jsonc-parser"; + +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import type { PermissionAction, PermissionEntry } from "../../types/permissions.js"; +import { joinPattern, splitPattern } from "../../types/permissions.js"; +import { readFileContentOrNull } from "../../utils/file.js"; +import type { RulesyncPermissions } from "./rulesync-permissions.js"; +import { + ToolPermissions, + type ToolPermissionsForDeletionParams, + type ToolPermissionsFromFileParams, + type ToolPermissionsFromRulesyncPermissionsParams, + type ToolPermissionsSettablePaths, +} from "./tool-permissions.js"; + +// OpenCode permission format: { tool: { pattern: action } } +type OpencodePermission = Record>; + +type OpencodeConfig = Record & { + permission?: OpencodePermission; +}; + +// OpenCode uses lowercase tool names identical to canonical names. +// No mapping needed; canonical names are used directly. + +/** + * Resolve the OpenCode config file, preferring .jsonc over .json. + * Returns the file content and the relative file path that was found. + */ +async function resolveOpencodeConfigFile( + baseDir: string, + basePaths: ToolPermissionsSettablePaths, +): Promise<{ fileContent: string | null; relativeFilePath: string }> { + const jsonDir = join(baseDir, basePaths.relativeDirPath); + const jsoncPath = join(jsonDir, "opencode.jsonc"); + const jsonPath = join(jsonDir, "opencode.json"); + + const jsoncContent = await readFileContentOrNull(jsoncPath); + if (jsoncContent) { + return { fileContent: jsoncContent, relativeFilePath: "opencode.jsonc" }; + } + + const jsonContent = await readFileContentOrNull(jsonPath); + if (jsonContent) { + return { fileContent: jsonContent, relativeFilePath: "opencode.json" }; + } + + return { fileContent: null, relativeFilePath: "opencode.jsonc" }; +} + +export class OpencodePermissions extends ToolPermissions { + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? "{}", + }); + } + + override isDeletable(): boolean { + return false; + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolPermissionsSettablePaths { + return { relativeDirPath: ".", relativeFilePath: "opencode.json" }; + } + + static async fromFile({ + baseDir = process.cwd(), + validate = true, + }: ToolPermissionsFromFileParams): Promise { + const basePaths = this.getSettablePaths(); + const { fileContent, relativeFilePath } = await resolveOpencodeConfigFile(baseDir, basePaths); + + return new OpencodePermissions({ + baseDir, + relativeDirPath: basePaths.relativeDirPath, + relativeFilePath, + fileContent: fileContent ?? "{}", + validate, + }); + } + + static async fromRulesyncPermissions({ + baseDir = process.cwd(), + rulesyncPermissions, + validate = true, + }: ToolPermissionsFromRulesyncPermissionsParams): Promise { + const basePaths = this.getSettablePaths(); + const { fileContent: existingContent, relativeFilePath } = await resolveOpencodeConfigFile( + baseDir, + basePaths, + ); + + const fileContent = existingContent ?? JSON.stringify({}, null, 2); + + const json: OpencodeConfig = parseJsonc(fileContent) ?? {}; + + // Convert canonical → OpenCode format + const config = rulesyncPermissions.getJson(); + const permission: OpencodePermission = {}; + + for (const entry of config.permissions) { + const toolName = entry.tool; + if (!permission[toolName]) { + permission[toolName] = {}; + } + const joined = joinPattern(entry.tool, entry.pattern); + permission[toolName][joined] = entry.action; + } + + const newJson = { + ...json, + permission, + }; + + return new OpencodePermissions({ + baseDir, + relativeDirPath: basePaths.relativeDirPath, + relativeFilePath, + fileContent: JSON.stringify(newJson, null, 2), + validate, + }); + } + + toRulesyncPermissions(): RulesyncPermissions { + const json: OpencodeConfig = parseJsonc(this.getFileContent()) ?? {}; + const permission = json.permission ?? {}; + const entries: PermissionEntry[] = []; + + for (const [toolName, patterns] of Object.entries(permission)) { + for (const [pattern, action] of Object.entries(patterns)) { + entries.push({ + tool: toolName, + pattern: splitPattern(toolName, pattern), + action, + }); + } + } + + return this.toRulesyncPermissionsDefault({ + fileContent: JSON.stringify({ permissions: entries }, null, 2), + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolPermissionsForDeletionParams): OpencodePermissions { + return new OpencodePermissions({ + baseDir, + relativeDirPath, + relativeFilePath, + fileContent: "{}", + validate: false, + }); + } +} diff --git a/src/features/permissions/permissions-processor.test.ts b/src/features/permissions/permissions-processor.test.ts new file mode 100644 index 000000000..6dfe306a9 --- /dev/null +++ b/src/features/permissions/permissions-processor.test.ts @@ -0,0 +1,160 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { 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 { PermissionsProcessor } from "./permissions-processor.js"; + +describe("PermissionsProcessor", () => { + 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("constructor", () => { + it("should accept valid tool targets", () => { + expect( + () => new PermissionsProcessor({ baseDir: testDir, toolTarget: "claudecode" }), + ).not.toThrow(); + expect( + () => new PermissionsProcessor({ baseDir: testDir, toolTarget: "opencode" }), + ).not.toThrow(); + expect( + () => new PermissionsProcessor({ baseDir: testDir, toolTarget: "codexcli" }), + ).not.toThrow(); + }); + + it("should reject invalid tool targets", () => { + expect(() => new PermissionsProcessor({ baseDir: testDir, toolTarget: "cursor" })).toThrow( + "Invalid tool target for PermissionsProcessor", + ); + }); + }); + + describe("getToolTargets", () => { + it("should return supported targets for project mode", () => { + const targets = PermissionsProcessor.getToolTargets(); + expect(targets).toEqual(expect.arrayContaining(["claudecode", "opencode", "codexcli"])); + }); + + it("should return empty for global mode", () => { + const targets = PermissionsProcessor.getToolTargets({ global: true }); + expect(targets).toEqual([]); + }); + + it("should return importable targets when importOnly is true", () => { + const targets = PermissionsProcessor.getToolTargets({ importOnly: true }); + expect(targets).toContain("claudecode"); + expect(targets).toContain("opencode"); + expect(targets).not.toContain("codexcli"); + }); + }); + + describe("loadRulesyncFiles", () => { + it("should load permissions.json from .rulesync", async () => { + await ensureDir(join(testDir, RULESYNC_RELATIVE_DIR_PATH)); + await writeFileContent( + join(testDir, RULESYNC_RELATIVE_DIR_PATH, "permissions.json"), + JSON.stringify({ + permissions: [{ tool: "bash", pattern: ["npm", "*"], action: "allow" }], + }), + ); + + const processor = new PermissionsProcessor({ + baseDir: testDir, + toolTarget: "claudecode", + }); + + const files = await processor.loadRulesyncFiles(); + expect(files).toHaveLength(1); + }); + + it("should return empty array if permissions.json does not exist", async () => { + const processor = new PermissionsProcessor({ + baseDir: testDir, + toolTarget: "claudecode", + }); + + const files = await processor.loadRulesyncFiles(); + expect(files).toHaveLength(0); + }); + }); + + describe("convertRulesyncFilesToToolFiles", () => { + it("should convert for claudecode target", async () => { + await ensureDir(join(testDir, RULESYNC_RELATIVE_DIR_PATH)); + await writeFileContent( + join(testDir, RULESYNC_RELATIVE_DIR_PATH, "permissions.json"), + JSON.stringify({ + permissions: [{ tool: "bash", pattern: ["npm", "*"], action: "allow" }], + }), + ); + + const processor = new PermissionsProcessor({ + baseDir: testDir, + toolTarget: "claudecode", + }); + + const rulesyncFiles = await processor.loadRulesyncFiles(); + const toolFiles = await processor.convertRulesyncFilesToToolFiles(rulesyncFiles); + expect(toolFiles).toHaveLength(1); + + const content = JSON.parse(toolFiles[0]!.getFileContent()); + expect(content.permissions.allow).toEqual(["Bash(npm *)"]); + }); + + it("should convert for opencode target", async () => { + await ensureDir(join(testDir, RULESYNC_RELATIVE_DIR_PATH)); + await writeFileContent( + join(testDir, RULESYNC_RELATIVE_DIR_PATH, "permissions.json"), + JSON.stringify({ + permissions: [{ tool: "bash", pattern: ["npm", "*"], action: "allow" }], + }), + ); + + const processor = new PermissionsProcessor({ + baseDir: testDir, + toolTarget: "opencode", + }); + + const rulesyncFiles = await processor.loadRulesyncFiles(); + const toolFiles = await processor.convertRulesyncFilesToToolFiles(rulesyncFiles); + expect(toolFiles).toHaveLength(1); + + const content = JSON.parse(toolFiles[0]!.getFileContent()); + expect(content.permission.bash).toEqual({ "npm *": "allow" }); + }); + + it("should convert for codexcli target (bash only)", async () => { + await ensureDir(join(testDir, RULESYNC_RELATIVE_DIR_PATH)); + await writeFileContent( + join(testDir, RULESYNC_RELATIVE_DIR_PATH, "permissions.json"), + JSON.stringify({ + permissions: [ + { tool: "bash", pattern: ["npm", "*"], action: "allow" }, + { tool: "read", pattern: ["src", "**"], action: "allow" }, + ], + }), + ); + + const processor = new PermissionsProcessor({ + baseDir: testDir, + toolTarget: "codexcli", + }); + + const rulesyncFiles = await processor.loadRulesyncFiles(); + const toolFiles = await processor.convertRulesyncFilesToToolFiles(rulesyncFiles); + expect(toolFiles).toHaveLength(1); + }); + }); +}); diff --git a/src/features/permissions/permissions-processor.ts b/src/features/permissions/permissions-processor.ts new file mode 100644 index 000000000..53a04f059 --- /dev/null +++ b/src/features/permissions/permissions-processor.ts @@ -0,0 +1,197 @@ +import { z } from "zod/mini"; + +import { RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH } from "../../constants/rulesync-paths.js"; +import { FeatureProcessor } from "../../types/feature-processor.js"; +import type { RulesyncFile } from "../../types/rulesync-file.js"; +import type { ToolFile } from "../../types/tool-file.js"; +import type { ToolTarget } from "../../types/tool-targets.js"; +import { formatError } from "../../utils/error.js"; +import { logger } from "../../utils/logger.js"; +import { ClaudecodePermissions } from "./claudecode-permissions.js"; +import { CodexcliPermissions } from "./codexcli-permissions.js"; +import { OpencodePermissions } from "./opencode-permissions.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; +import type { + ToolPermissionsForDeletionParams, + ToolPermissionsFromFileParams, + ToolPermissionsFromRulesyncPermissionsParams, + ToolPermissionsSettablePaths, +} from "./tool-permissions.js"; +import { ToolPermissions } from "./tool-permissions.js"; + +const permissionsProcessorToolTargetTuple = ["claudecode", "opencode", "codexcli"] as const; + +export type PermissionsProcessorToolTarget = (typeof permissionsProcessorToolTargetTuple)[number]; + +export const PermissionsProcessorToolTargetSchema = z.enum(permissionsProcessorToolTargetTuple); + +type ToolPermissionsFactory = { + class: { + fromRulesyncPermissions( + params: ToolPermissionsFromRulesyncPermissionsParams, + ): ToolPermissions | Promise; + fromFile(params: ToolPermissionsFromFileParams): Promise; + forDeletion(params: ToolPermissionsForDeletionParams): ToolPermissions; + getSettablePaths(options?: { global?: boolean }): ToolPermissionsSettablePaths; + }; + meta: { + supportsProject: boolean; + supportsGlobal: boolean; + supportsImport: boolean; + }; +}; + +const toolPermissionsFactories = new Map([ + [ + "claudecode", + { + class: ClaudecodePermissions, + meta: { + supportsProject: true, + supportsGlobal: false, + supportsImport: true, + }, + }, + ], + [ + "opencode", + { + class: OpencodePermissions, + meta: { + supportsProject: true, + supportsGlobal: false, + supportsImport: true, + }, + }, + ], + [ + "codexcli", + { + class: CodexcliPermissions, + meta: { + supportsProject: true, + supportsGlobal: false, + supportsImport: false, + }, + }, + ], +]); + +const permissionsProcessorToolTargets: ToolTarget[] = [...toolPermissionsFactories.keys()]; +const permissionsProcessorToolTargetsImportable: ToolTarget[] = [ + ...toolPermissionsFactories.entries(), +] + .filter(([, f]) => f.meta.supportsImport) + .map(([t]) => t); + +export class PermissionsProcessor extends FeatureProcessor { + private readonly toolTarget: PermissionsProcessorToolTarget; + + constructor({ + baseDir = process.cwd(), + toolTarget, + dryRun = false, + }: { + baseDir?: string; + toolTarget: ToolTarget; + dryRun?: boolean; + }) { + super({ baseDir, dryRun }); + const result = PermissionsProcessorToolTargetSchema.safeParse(toolTarget); + if (!result.success) { + throw new Error( + `Invalid tool target for PermissionsProcessor: ${toolTarget}. ${formatError(result.error)}`, + ); + } + this.toolTarget = result.data; + } + + async loadRulesyncFiles(): Promise { + try { + return [ + await RulesyncPermissions.fromFile({ + baseDir: this.baseDir, + validate: true, + }), + ]; + } catch (error) { + logger.error( + `Failed to load Rulesync permissions file (${RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH}): ${formatError(error)}`, + ); + return []; + } + } + + async loadToolFiles({ forDeletion = false }: { forDeletion?: boolean } = {}): Promise< + ToolFile[] + > { + try { + const factory = toolPermissionsFactories.get(this.toolTarget); + if (!factory) throw new Error(`Unsupported tool target: ${this.toolTarget}`); + const paths = factory.class.getSettablePaths(); + + if (forDeletion) { + const toolPermissions = factory.class.forDeletion({ + baseDir: this.baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + }); + const list = toolPermissions.isDeletable?.() !== false ? [toolPermissions] : []; + logger.debug( + `Successfully loaded ${list.length} ${this.toolTarget} permissions files for deletion`, + ); + return list; + } + + const toolPermissions = await factory.class.fromFile({ + baseDir: this.baseDir, + validate: true, + }); + logger.debug(`Successfully loaded 1 ${this.toolTarget} permissions file`); + return [toolPermissions]; + } catch (error) { + const msg = `Failed to load permissions files for tool target: ${this.toolTarget}: ${formatError(error)}`; + if (error instanceof Error && error.message.includes("no such file or directory")) { + logger.debug(msg); + } else { + logger.error(msg); + } + return []; + } + } + + async convertRulesyncFilesToToolFiles(rulesyncFiles: RulesyncFile[]): Promise { + const rulesyncPermissions = rulesyncFiles.find( + (f): f is RulesyncPermissions => f instanceof RulesyncPermissions, + ); + if (!rulesyncPermissions) { + throw new Error(`No ${RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH} found.`); + } + + const factory = toolPermissionsFactories.get(this.toolTarget); + if (!factory) throw new Error(`Unsupported tool target: ${this.toolTarget}`); + + const toolPermissions = await factory.class.fromRulesyncPermissions({ + baseDir: this.baseDir, + rulesyncPermissions, + validate: true, + }); + return [toolPermissions]; + } + + async convertToolFilesToRulesyncFiles(toolFiles: ToolFile[]): Promise { + const permissions = toolFiles.filter((f): f is ToolPermissions => f instanceof ToolPermissions); + return permissions.map((p) => p.toRulesyncPermissions()); + } + + static getToolTargets({ + global = false, + importOnly = false, + }: { global?: boolean; importOnly?: boolean } = {}): ToolTarget[] { + if (global) { + // Permissions does not support global mode for any target currently + return []; + } + return importOnly ? permissionsProcessorToolTargetsImportable : permissionsProcessorToolTargets; + } +} diff --git a/src/features/permissions/rulesync-permissions.test.ts b/src/features/permissions/rulesync-permissions.test.ts new file mode 100644 index 000000000..3d3d5e990 --- /dev/null +++ b/src/features/permissions/rulesync-permissions.test.ts @@ -0,0 +1,120 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { 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 { RulesyncPermissions } from "./rulesync-permissions.js"; + +describe("RulesyncPermissions", () => { + 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("should return .rulesync and permissions.json", () => { + const paths = RulesyncPermissions.getSettablePaths(); + expect(paths).toEqual({ + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + }); + }); + }); + + describe("fromFile", () => { + it("should load and parse a valid permissions file", async () => { + const config = { + permissions: [ + { tool: "bash", pattern: ["npm", "*"], action: "allow" }, + { tool: "read", pattern: ["src", "**"], action: "allow" }, + ], + }; + + await ensureDir(join(testDir, RULESYNC_RELATIVE_DIR_PATH)); + await writeFileContent( + join(testDir, RULESYNC_RELATIVE_DIR_PATH, "permissions.json"), + JSON.stringify(config), + ); + + const rulesyncPermissions = await RulesyncPermissions.fromFile({ + baseDir: testDir, + }); + + expect(rulesyncPermissions.getJson()).toEqual(config); + }); + + it("should throw if file does not exist", async () => { + await expect(RulesyncPermissions.fromFile({ baseDir: testDir })).rejects.toThrow( + "No .rulesync/permissions.json found.", + ); + }); + }); + + describe("validate", () => { + it("should validate a correct permissions config", () => { + const config = { + permissions: [{ tool: "bash", pattern: ["npm", "*"], action: "allow" }], + }; + + const instance = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: true, + }); + + expect(instance.getJson()).toEqual(config); + }); + + it("should reject invalid action values", () => { + const config = { + permissions: [{ tool: "bash", pattern: ["npm"], action: "invalid" }], + }; + + expect( + () => + new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: true, + }), + ).toThrow(); + }); + }); + + describe("getJson", () => { + it("should return the parsed JSON content", () => { + const config = { + permissions: [ + { tool: "bash", pattern: ["rm", "-rf", "*"], action: "deny" }, + { tool: "read", pattern: ["*", ".env"], action: "ask" }, + ], + }; + + const instance = new RulesyncPermissions({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + expect(instance.getJson().permissions).toHaveLength(2); + expect(instance.getJson().permissions[0]!.tool).toBe("bash"); + expect(instance.getJson().permissions[1]!.action).toBe("ask"); + }); + }); +}); diff --git a/src/features/permissions/rulesync-permissions.ts b/src/features/permissions/rulesync-permissions.ts new file mode 100644 index 000000000..abae394ff --- /dev/null +++ b/src/features/permissions/rulesync-permissions.ts @@ -0,0 +1,82 @@ +import { join } from "node:path"; + +import { + RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH, + RULESYNC_RELATIVE_DIR_PATH, +} from "../../constants/rulesync-paths.js"; +import type { ValidationResult } from "../../types/ai-file.js"; +import { type PermissionsConfig, PermissionsConfigSchema } from "../../types/permissions.js"; +import type { RulesyncFileFromFileParams, RulesyncFileParams } from "../../types/rulesync-file.js"; +import { RulesyncFile } from "../../types/rulesync-file.js"; +import { fileExists, readFileContent } from "../../utils/file.js"; + +export type RulesyncPermissionsParams = RulesyncFileParams; + +export type RulesyncPermissionsFromFileParams = Pick< + RulesyncFileFromFileParams, + "baseDir" | "validate" +>; + +export type RulesyncPermissionsSettablePaths = { + relativeDirPath: string; + relativeFilePath: string; +}; + +// Re-export for JSON schema generation +export { PermissionsConfigSchema as RulesyncPermissionsFileSchema }; + +export class RulesyncPermissions extends RulesyncFile { + private readonly json: PermissionsConfig; + + constructor(params: RulesyncPermissionsParams) { + super({ ...params }); + + this.json = JSON.parse(this.fileContent); + if (params.validate) { + const result = this.validate(); + if (!result.success) { + throw result.error; + } + } + } + + static getSettablePaths(): RulesyncPermissionsSettablePaths { + return { + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + }; + } + + validate(): ValidationResult { + const result = PermissionsConfigSchema.safeParse(this.json); + if (!result.success) { + return { success: false, error: result.error }; + } + return { success: true, error: null }; + } + + static async fromFile({ + baseDir = process.cwd(), + validate = true, + }: RulesyncPermissionsFromFileParams): Promise { + const paths = RulesyncPermissions.getSettablePaths(); + const filePath = join(baseDir, paths.relativeDirPath, paths.relativeFilePath); + + if (!(await fileExists(filePath))) { + throw new Error(`No ${RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH} found.`); + } + + const fileContent = await readFileContent(filePath); + return new RulesyncPermissions({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + getJson(): PermissionsConfig { + return this.json; + } +} diff --git a/src/features/permissions/tool-permissions.ts b/src/features/permissions/tool-permissions.ts new file mode 100644 index 000000000..3cb9e931a --- /dev/null +++ b/src/features/permissions/tool-permissions.ts @@ -0,0 +1,73 @@ +import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import type { AiFileFromFileParams, AiFileParams } from "../../types/ai-file.js"; +import { ToolFile } from "../../types/tool-file.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; + +export type ToolPermissionsParams = AiFileParams; + +export type ToolPermissionsFromRulesyncPermissionsParams = Omit< + AiFileParams, + "fileContent" | "relativeFilePath" | "relativeDirPath" +> & { + rulesyncPermissions: RulesyncPermissions; +}; + +export type ToolPermissionsFromFileParams = Pick< + AiFileFromFileParams, + "baseDir" | "validate" | "global" +>; + +export type ToolPermissionsForDeletionParams = { + baseDir?: string; + relativeDirPath: string; + relativeFilePath: string; + global?: boolean; +}; + +export type ToolPermissionsSettablePaths = { + relativeDirPath: string; + relativeFilePath: string; +}; + +export abstract class ToolPermissions extends ToolFile { + constructor(params: ToolPermissionsParams) { + super({ + ...params, + validate: true, + }); + + if (params.validate) { + const result = this.validate(); + if (!result.success) { + throw result.error; + } + } + } + + static getSettablePaths(_options?: { global?: boolean }): ToolPermissionsSettablePaths { + throw new Error("Please implement this method in the subclass."); + } + + abstract toRulesyncPermissions(): RulesyncPermissions; + + protected toRulesyncPermissionsDefault({ + fileContent = undefined, + }: { + fileContent?: string; + } = {}): RulesyncPermissions { + return new RulesyncPermissions({ + baseDir: this.baseDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "permissions.json", + fileContent: fileContent ?? this.fileContent, + }); + } + + static async fromFile(_params: ToolPermissionsFromFileParams): Promise { + throw new Error("Please implement this method in the subclass."); + } + + static forDeletion(_params: ToolPermissionsForDeletionParams): ToolPermissions { + throw new Error("Please implement this method in the subclass."); + } +} diff --git a/src/index.test.ts b/src/index.test.ts index c8bd402a9..eee0d2a63 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -41,6 +41,8 @@ const mockGenerateResult: GenerateResult = { skillsPaths: [], hooksCount: 0, hooksPaths: [], + permissionsCount: 0, + permissionsPaths: [], skills: [], hasDiff: false, }; @@ -53,6 +55,7 @@ const mockImportResult: ImportResult = { subagentsCount: 0, skillsCount: 0, hooksCount: 0, + permissionsCount: 0, }; beforeEach(() => { diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index f448402d5..523e8f4e5 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -8,6 +8,7 @@ import { RULESYNC_AIIGNORE_FILE_NAME, RULESYNC_HOOKS_FILE_NAME, RULESYNC_MCP_FILE_NAME, + RULESYNC_PERMISSIONS_FILE_NAME, RULESYNC_RELATIVE_DIR_PATH, } from "../constants/rulesync-paths.js"; import { CommandsProcessor } from "../features/commands/commands-processor.js"; @@ -52,6 +53,7 @@ const FEATURE_PATHS: Record = { ignore: [RULESYNC_AIIGNORE_FILE_NAME], mcp: [RULESYNC_MCP_FILE_NAME], hooks: [RULESYNC_HOOKS_FILE_NAME], + permissions: [RULESYNC_PERMISSIONS_FILE_NAME], }; /** diff --git a/src/lib/generate.ts b/src/lib/generate.ts index 6b9198b18..9215d9379 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -8,6 +8,7 @@ import { CommandsProcessor } from "../features/commands/commands-processor.js"; import { HooksProcessor } from "../features/hooks/hooks-processor.js"; import { IgnoreProcessor } from "../features/ignore/ignore-processor.js"; import { McpProcessor } from "../features/mcp/mcp-processor.js"; +import { PermissionsProcessor } from "../features/permissions/permissions-processor.js"; import { RulesProcessor } from "../features/rules/rules-processor.js"; import { RulesyncSkill } from "../features/skills/rulesync-skill.js"; import { SkillsProcessor } from "../features/skills/skills-processor.js"; @@ -38,6 +39,8 @@ export type GenerateResult = { skillsPaths: string[]; hooksCount: number; hooksPaths: string[]; + permissionsCount: number; + permissionsPaths: string[]; skills: RulesyncSkill[]; hasDiff: boolean; }; @@ -138,12 +141,17 @@ export async function checkRulesyncDirExists(params: { baseDir: string }): Promi export async function generate(params: { config: Config }): Promise { const { config } = params; + // generateIgnoreCore must run before generatePermissionsCore. + // Both features write to .claude/settings.json's permissions.deny key. + // The permissions feature preserves Read() entries written by the ignore feature. + // This dependency is enforced by requiring ignoreResult as a parameter. const ignoreResult = await generateIgnoreCore({ config }); const mcpResult = await generateMcpCore({ config }); const commandsResult = await generateCommandsCore({ config }); const subagentsResult = await generateSubagentsCore({ config }); const skillsResult = await generateSkillsCore({ config }); const hooksResult = await generateHooksCore({ config }); + const permissionsResult = await generatePermissionsCore({ config, ignoreResult }); const rulesResult = await generateRulesCore({ config, skills: skillsResult.skills }); const hasDiff = @@ -153,6 +161,7 @@ export async function generate(params: { config: Config }): Promise { + const { config } = params; + + let totalCount = 0; + const allPaths: string[] = []; + let hasDiff = false; + + const supportedPermissionsTargets = PermissionsProcessor.getToolTargets({ + global: config.getGlobal(), + }); + const toolTargets = intersection(config.getTargets(), supportedPermissionsTargets); + warnUnsupportedTargets({ + config, + supportedTargets: supportedPermissionsTargets, + featureName: "permissions", + }); + + for (const baseDir of config.getBaseDirs()) { + for (const toolTarget of toolTargets) { + if (!config.getFeatures(toolTarget).includes("permissions")) { + continue; + } + + const processor = new PermissionsProcessor({ + baseDir, + toolTarget, + dryRun: config.isPreviewMode(), + }); + + const rulesyncFiles = await processor.loadRulesyncFiles(); + let result; + + if (rulesyncFiles.length === 0) { + result = await processEmptyFeatureGeneration({ + config, + processor, + }); + } else { + const toolFiles = await processor.convertRulesyncFilesToToolFiles(rulesyncFiles); + result = await processFeatureGeneration({ + config, + processor, + toolFiles, + }); + } + + totalCount += result.count; + allPaths.push(...result.paths); + if (result.hasDiff) hasDiff = true; + } + } + + return { count: totalCount, paths: allPaths, hasDiff }; +} diff --git a/src/lib/import.ts b/src/lib/import.ts index c00314381..dae87d7b2 100644 --- a/src/lib/import.ts +++ b/src/lib/import.ts @@ -3,6 +3,7 @@ import { CommandsProcessor } from "../features/commands/commands-processor.js"; import { HooksProcessor } from "../features/hooks/hooks-processor.js"; import { IgnoreProcessor } from "../features/ignore/ignore-processor.js"; import { McpProcessor } from "../features/mcp/mcp-processor.js"; +import { PermissionsProcessor } from "../features/permissions/permissions-processor.js"; import { RulesProcessor } from "../features/rules/rules-processor.js"; import { SkillsProcessor } from "../features/skills/skills-processor.js"; import { SubagentsProcessor } from "../features/subagents/subagents-processor.js"; @@ -17,6 +18,7 @@ export type ImportResult = { subagentsCount: number; skillsCount: number; hooksCount: number; + permissionsCount: number; }; /** @@ -35,6 +37,7 @@ export async function importFromTool(params: { const subagentsCount = await importSubagentsCore({ config, tool }); const skillsCount = await importSkillsCore({ config, tool }); const hooksCount = await importHooksCore({ config, tool }); + const permissionsCount = await importPermissionsCore({ config, tool }); return { rulesCount, @@ -44,6 +47,7 @@ export async function importFromTool(params: { subagentsCount, skillsCount, hooksCount, + permissionsCount, }; } @@ -313,3 +317,46 @@ async function importHooksCore(params: { config: Config; tool: ToolTarget }): Pr return writtenCount; } + +async function importPermissionsCore(params: { + config: Config; + tool: ToolTarget; +}): Promise { + const { config, tool } = params; + + if (!config.getFeatures(tool).includes("permissions")) { + return 0; + } + + const allTargets = PermissionsProcessor.getToolTargets(); + const importableTargets = PermissionsProcessor.getToolTargets({ importOnly: true }); + + if (!allTargets.includes(tool)) { + return 0; + } + + if (!importableTargets.includes(tool)) { + logger.warn(`Import is not supported for ${tool} permissions. Skipping.`); + return 0; + } + + const permissionsProcessor = new PermissionsProcessor({ + baseDir: config.getBaseDirs()[0] ?? ".", + toolTarget: tool, + }); + + const toolFiles = await permissionsProcessor.loadToolFiles(); + if (toolFiles.length === 0) { + logger.warn(`No permissions files found for ${tool}. Skipping import.`); + return 0; + } + + const rulesyncFiles = await permissionsProcessor.convertToolFilesToRulesyncFiles(toolFiles); + const { count: writtenCount } = await permissionsProcessor.writeAiFiles(rulesyncFiles); + + if (config.getVerbose() && writtenCount > 0) { + logger.success(`Created ${writtenCount} permissions file(s)`); + } + + return writtenCount; +} diff --git a/src/mcp/generate.ts b/src/mcp/generate.ts index cf6edff96..79f3e7eb2 100644 --- a/src/mcp/generate.ts +++ b/src/mcp/generate.ts @@ -108,6 +108,7 @@ function buildSuccessResponse(params: { subagentsCount: generateResult.subagentsCount, skillsCount: generateResult.skillsCount, hooksCount: generateResult.hooksCount, + permissionsCount: generateResult.permissionsCount, totalCount, }, config: { diff --git a/src/mcp/import.ts b/src/mcp/import.ts index 1947eb244..baf4c719c 100644 --- a/src/mcp/import.ts +++ b/src/mcp/import.ts @@ -101,6 +101,7 @@ function buildSuccessResponse(params: { subagentsCount: importResult.subagentsCount, skillsCount: importResult.skillsCount, hooksCount: importResult.hooksCount, + permissionsCount: importResult.permissionsCount, totalCount, }, config: { diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 1f5330d9b..93cf45570 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -14,5 +14,6 @@ export type McpResultCounts = { subagentsCount: number; skillsCount: number; hooksCount: number; + permissionsCount: number; totalCount: number; }; diff --git a/src/types/features.ts b/src/types/features.ts index fb19a48a7..4b3de5e67 100644 --- a/src/types/features.ts +++ b/src/types/features.ts @@ -10,6 +10,7 @@ export const ALL_FEATURES = [ "commands", "skills", "hooks", + "permissions", ] as const; export const ALL_FEATURES_WITH_WILDCARD = [...ALL_FEATURES, "*"] as const; diff --git a/src/types/permissions.ts b/src/types/permissions.ts new file mode 100644 index 000000000..4ccff10df --- /dev/null +++ b/src/types/permissions.ts @@ -0,0 +1,84 @@ +import { z } from "zod/mini"; + +export const PermissionActionSchema = z.enum(["allow", "ask", "deny"]); +export type PermissionAction = z.infer; + +export const PermissionEntrySchema = z.looseObject({ + tool: z.string().check(z.regex(/^[a-zA-Z0-9_]+$/)), + pattern: z.array(z.string().check(z.regex(/^[^()]*$/))), + action: PermissionActionSchema, +}); +export type PermissionEntry = z.infer; + +export const PermissionsConfigSchema = z.looseObject({ + $schema: z.optional(z.string()), + permissions: z.array(PermissionEntrySchema), +}); +export type PermissionsConfig = z.infer; + +// Canonical tool name → Claude Code PascalCase name +export const CANONICAL_TO_CLAUDE_TOOL_NAMES: Record = { + bash: "Bash", + read: "Read", + edit: "Edit", + write: "Write", + webfetch: "WebFetch", + grep: "Grep", + glob: "Glob", +}; + +// Claude Code PascalCase name → canonical tool name +export const CLAUDE_TO_CANONICAL_TOOL_NAMES: Record = Object.fromEntries( + Object.entries(CANONICAL_TO_CLAUDE_TOOL_NAMES).map(([k, v]) => [v, k]), +); + +// OpenCode uses lowercase tool names identical to canonical names. +// No mapping is needed; the canonical name is used directly. + +/** + * Join pattern segments for bash tool (space-separated) + */ +export function joinPatternForBash(pattern: string[]): string { + return pattern.join(" "); +} + +/** + * Join pattern segments for file path tools (/-separated) + */ +export function joinPatternForPath(pattern: string[]): string { + return pattern.join("/"); +} + +/** + * Split a joined pattern back to segments for bash tool (space-separated) + */ +export function splitPatternForBash(joined: string): string[] { + return joined.split(" ").filter((s) => s !== ""); +} + +/** + * Split a joined pattern back to segments for file path tools (/-separated) + */ +export function splitPatternForPath(joined: string): string[] { + return joined.split("/").filter((s) => s !== ""); +} + +/** + * Join pattern segments based on tool type + */ +export function joinPattern(tool: string, pattern: string[]): string { + if (tool === "bash") { + return joinPatternForBash(pattern); + } + return joinPatternForPath(pattern); +} + +/** + * Split a joined pattern back to segments based on tool type + */ +export function splitPattern(tool: string, joined: string): string[] { + if (tool === "bash") { + return splitPatternForBash(joined); + } + return splitPatternForPath(joined); +} diff --git a/src/utils/result.ts b/src/utils/result.ts index f837f9425..a1993f980 100644 --- a/src/utils/result.ts +++ b/src/utils/result.ts @@ -22,6 +22,7 @@ export type CountableResult = { subagentsCount: number; skillsCount: number; hooksCount: number; + permissionsCount: number; }; /** @@ -35,6 +36,7 @@ export function calculateTotalCount(result: CountableResult): number { result.commandsCount + result.subagentsCount + result.skillsCount + - result.hooksCount + result.hooksCount + + result.permissionsCount ); }