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: 2 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -1395,6 +1395,8 @@ For Cline CLI, this generates `.cline/command-permissions.json` (project mode on

For Zed, this generates the `agent.tool_permissions` object in `.zed/settings.json` (project mode) or `~/.config/zed/settings.json` (global mode — `%APPDATA%\Zed\settings.json` on Windows). Each canonical category becomes a key under `agent.tool_permissions.tools.<tool>` (tool-name mapping: `bash` → `terminal`, `edit` → `edit_file`, `write` → `write_file`, `webfetch` → `fetch`, `websearch` → `search_web`; unknown categories pass through unchanged). Per-tool MCP categories are translated: canonical `mcp__<server>__<tool>` becomes Zed's `mcp:<server>:<tool>`, and imports back into the canonical spelling, so a category authored once reaches Zed and the other targets alike (a key already written in Zed's spelling is emitted unchanged but still normalizes to the canonical form on import). Only the first separator is split, so a tool whose own name contains `__` survives the round-trip. Inside an MCP category only the catch-all `*` rule is emitted, as the tool's `default`; pattern-scoped rules are dropped with a warning, because Zed dispatches every MCP tool with a single empty input ("MCP tools are gated only by tool id (no per-input pattern matching)"), so a pattern would be matched against `""` rather than against anything meaningful. A category that omits or wildcards either half — `mcp__<server>`, `mcp__<server>__*`, `mcp__*__<tool>` — is dropped with a warning too, since Zed looks the tool up by exact key on the full triple with no glob or prefix matching. Any canonical-spelled `mcp__<server>__<tool>` entry an earlier Rulesync version left in `settings.json` is swept on the next generate, whether or not the current config still names that category: it is not a Zed tool name, so it can only be Rulesync's own output, and leaving it would resurrect stale rules on the next import. Read-only categories are not written at all: Zed's [gated tool list](https://zed.dev/docs/ai/tool-permissions#supported-tools) does not include `read_file`, `grep`, `find_path` or `list_directory` — they are in Zed's own `EXCLUDED_TOOLS` and never consult the permission settings, so neither a per-tool entry nor the global `default` reaches them. Canonical `read`, `grep` and `glob` (and a category naming one of those Zed tools directly) are therefore **dropped**, with a warning when the category carried a `deny` or `ask` rule, rather than written as entries Zed ignores. Zed's read-denial surface is `private_files`, which the ignore feature writes from `.rulesync/.aiignore`. An inert entry an earlier Rulesync version wrote is left in place rather than deleted — Rulesync cannot tell it from one you wrote, and Zed ignores it either way — and it still imports back as the canonical category, so remove it by hand if you want it gone. The canonical `*` category is the exception: its catch-all `*` rule sets the top-level `agent.tool_permissions.default` — rung 6 of Zed's precedence ladder, and the mechanism Zed documents for MCP tools — rather than an inert `tools["*"]` entry (`*` is not a Zed tool name; a stale `tools["*"]` entry written by an earlier version is cleaned up when the canonical config carries a `*` category, and the `default` imports back as `*: { "*": <action> }`). Pattern-scoped rules in the `*` category have no Zed counterpart and are dropped with a warning. Within every other category, the catch-all `*` pattern sets the per-tool `default`, while specific patterns become `always_allow` / `always_deny` / `always_confirm` entries of the form `{ "pattern": <regex>, "case_sensitive": false }`. Action mapping: rulesync `ask` ⇄ Zed `confirm` (`allow`/`deny` are shared). Because Zed matches with regular expressions, patterns are emitted verbatim — author canonical patterns as regexes when targeting Zed. The settings file is shared with the MCP (`context_servers`) and ignore (`private_files`) features, so writes merge non-destructively: unrelated settings, a user-set `agent.tool_permissions.default` (when the canonical config has no `*` category), and any `tools.<tool>` entries NOT managed by rulesync are preserved on round-trip. The canonical model has no slot for per-pattern case sensitivity, so rulesync always emits `case_sensitive: false`; a hand-authored `case_sensitive: true` on a rulesync-managed tool is overwritten on the next generate.

> **Zed-only override (`zed` key):** two Zed surfaces sit outside the canonical allow/ask/deny model and are authored verbatim through a tool-scoped `zed` override. `zed.sandbox_permissions` is written into `agent.sandbox_permissions`: Zed's OS-level agent sandbox, which since [Zed 1.14.2](https://zed.dev/releases/stable) (2026-08-05) is **on by default** for the `terminal` and `fetch` tools and by default forbids network access, writing outside the project directories, and writing to `.git`. Most real setups therefore need to relax one of `network_hosts` (exact hostnames or leading `*.` wildcards), `allow_all_hosts`, `write_paths`, `allow_fs_write_all` or `allow_unsandboxed` — none of which the canonical categories can express, since this is process containment rather than tool gating. `zed.profiles` is written into `agent.profiles`, Zed's tool-availability layer: a separate enforcement stage from `tool_permissions`, because a tool absent from the active profile cannot be used no matter what the permission rules allow (per-profile keys `name`, `tools`, `enable_all_context_servers`, `context_servers`, `default_model`). Example: `{ "permission": { … }, "zed": { "sandbox_permissions": { "network_hosts": ["*.github.com"], "write_paths": ["/tmp"] }, "profiles": { "review": { "name": "Review", "tools": { "terminal": false } } } } }`. Both blocks pass through untouched — Rulesync canonicalizes neither, and validates only the documented `profiles` keys (`sandbox_permissions` is unvalidated, since Zed adds to it release over release) — and each is **replaced wholesale** when the override supplies it, since Zed reads each as a single policy unit; omit the key and whatever is already in `settings.json` is left alone rather than deleted, so removing a block is a manual edit. On **import**, both are lifted back into the `zed` override so a hand-written sandbox policy or profile set round-trips — including approvals you did not write by hand, since Zed saves an always-allow you clicked in the sandbox prompt into `agent.sandbox_permissions` itself. Read the imported block before committing it: an ad-hoc `allow_unsandboxed` picked up from your own machine would otherwise be regenerated into the project file and shipped to everyone. The same wholesale replace works the other way too — regenerating from an authored override discards approvals Zed had recorded since. The `zed` block authors these two keys and nothing else: `agent.tool_permissions` belongs to the canonical `permission` block, and any other key — a misspelling, or a blunt instrument such as Zed's `agent.always_allow_tool_actions` — is ignored with a warning, so nothing reachable from the override can weaken a reviewed deny. Both scopes are written, like the sibling `agent.tool_permissions`: Zed layers user settings under project settings, and a project's `.zed/settings.json` is applied once the worktree is trusted. That trust prompt is the thing to watch when you clone a repository — a project-scoped `allow_unsandboxed` or `allow_all_hosts` is a real grant, not an inert one, so review a `.zed/settings.json` you did not write before trusting the worktree. See the [Zed sandboxing](https://zed.dev/docs/ai/sandboxing) and [agent profiles](https://zed.dev/docs/ai/agent-profiles) docs.

For Qwen Code, this generates `permissions.allow`, `permissions.ask`, and `permissions.deny` arrays in `.qwen/settings.json` (project mode) or `~/.qwen/settings.json` (global mode). The format mirrors Claude Code's: entries are `Bash(<pattern>)`, `Read(<pattern>)`, `Edit(<pattern>)`, `Write(<pattern>)`, `WebFetch(<pattern>)`, `WebSearch(<pattern>)`, `Grep(<pattern>)`, `Glob(<pattern>)`, `Agent(<pattern>)`, etc. Other top-level keys in `settings.json` are preserved on round-trip. Patterns may contain nested parentheses (e.g. `Bash(echo (a))`); Rulesync uses the **last** `)` as the closing delimiter when parsing, so inner parens round-trip. Malformed entries (missing closing paren, trailing characters) emit a warning; for **`deny`** they fall back to the catch-all pattern `*` (fail-closed: broadening a deny is the safer direction), but for **`allow` / `ask`** they are **dropped** rather than broadened — silently turning a narrow user rule into `*` would be a fail-open round-trip. Generation does not create the `.qwen/` directory until `writeAiFiles` runs, so dry-run is side-effect-free.

For Kimi Code, permissions are global-only and generate `[[permission.rules]]` entries in `~/.kimi-code/config.toml`. Canonical categories map to Kimi tool patterns (`bash` → `Bash`, `read` → `Read`, `write` → `Write`, `edit` → `Edit`, `grep` → `Grep`, `glob` → `Glob`, `websearch` → `WebSearch`, `webfetch` → `FetchURL`, `agent` → `Agent`, and `mcp__…` passes through as the MCP tool name); a `*` canonical pattern emits the bare tool name and a specific pattern emits `Tool(pattern)`. Actions map 1:1 to Kimi's `allow` / `ask` / `deny`, and generated rules use `scope = "user"`. Kimi evaluates rules first-match-wins, so Rulesync sorts canonical output fail-closed: all `deny` rules precede `ask`, all `ask` rules precede `allow`, and more-specific patterns precede broader patterns within each action. Kimi does not match MCP tool arguments; an argument-specific MCP `allow`/`ask` is skipped with a warning rather than broadened, while an argument-specific `deny` becomes a whole-tool deny with a warning. The optional `kimi-code.defaultPermissionMode` override writes Kimi's top-level `default_permission_mode` (`manual` / `yolo` / `auto`), while `kimi-code.rules` accepts native rules that canonical categories cannot express and emits them first in their authored order. On import, Rulesync preserves the complete ordered rule list under `kimi-code.rules`, including rules that could otherwise fit the shared permission model, so regeneration cannot change Kimi's first-match behavior. A `kimi-code.tools` override writes Kimi's `[tools] enabled` / `disabled` lists — a separate enforcement layer from `[[permission.rules]]`, since a rule prompts while these remove the tool from every agent in every session. Entries pass through verbatim because the section uses agent-file tool syntax (exact built-in names, `mcp__server__*` globs) rather than the canonical category/pattern shape. Note that Kimi registers `[tools]` in its v2 engine, so today it applies under `kimi web` and experimental `kimi -p` rather than the interactive TUI. Like the MCP defaults, the section merges per key: authoring only `enabled` leaves a hand-written `disabled` list alone, and dropping the override leaves the section as it stands. Values are carried through exactly as written, empty lists included — `enabled = []` is an allowlist admitting _nothing_, the strictest setting there is, while an absent `enabled` means no allowlist at all, so the two are never interchanged. The TOML file is shared with hooks, the MCP timeout defaults and other Kimi settings, so updates merge in place and never delete the file. See the [Kimi Code permission docs](https://moonshotai.github.io/kimi-code/en/configuration/config-files.html).
Expand Down
32 changes: 32 additions & 0 deletions src/e2e/e2e-permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,38 @@ describe("E2E: permissions", () => {
expect(tools.read_file).toBeUndefined();
});

it("should generate the zed sandbox_permissions and profiles override into .zed/settings.json", async () => {
const testDir = getTestDir();

await writeFileContent(
join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH),
JSON.stringify(
{
permission: { bash: { "rm *": "deny" } },
zed: {
sandbox_permissions: { network_hosts: ["*.github.com"], write_paths: ["/tmp/build"] },
profiles: { review: { name: "Review", tools: { terminal: false } } },
},
},
null,
2,
),
);

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

const agent = JSON.parse(await readFileContent(join(testDir, ".zed", "settings.json"))).agent;
expect(agent.sandbox_permissions).toEqual({
network_hosts: ["*.github.com"],
write_paths: ["/tmp/build"],
});
expect(agent.profiles).toEqual({ review: { name: "Review", tools: { terminal: false } } });
// The canonical block still owns tool_permissions.
expect(agent.tool_permissions.tools.terminal.always_deny).toEqual([
{ pattern: "rm *", case_sensitive: false },
]);
});

it("should generate amp permissions into .amp/settings.json", async () => {
const testDir = getTestDir();

Expand Down
174 changes: 174 additions & 0 deletions src/features/permissions/zed-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ function createRulesyncPermissions(permission: Record<string, Record<string, str
});
}

function createRulesyncPermissionsWithConfig(config: Record<string, unknown>) {
return new RulesyncPermissions({
relativeDirPath: ".rulesync",
relativeFilePath: "permissions.json",
fileContent: JSON.stringify(config),
validate: true,
});
}

describe("ZedPermissions", () => {
let testDir: string;
let cleanup: () => Promise<void>;
Expand Down Expand Up @@ -678,4 +687,169 @@ describe("ZedPermissions", () => {
expect(json.permission.mcp__context7__get_docs).toEqual({ "*": "allow" });
});
});

describe("zed override (sandbox_permissions / profiles)", () => {
const sandboxPermissions = {
network_hosts: ["*.github.com", "registry.npmjs.org"],
write_paths: ["/tmp/build"],
allow_fs_write_all: false,
};
const profiles = {
review: {
name: "Review",
tools: { terminal: false, edit_file: true },
enable_all_context_servers: false,
},
};

it("should write both blocks verbatim into agent alongside tool_permissions", async () => {
const rulesyncPermissions = createRulesyncPermissionsWithConfig({
permission: { bash: { "*": "ask" } },
zed: { sandbox_permissions: sandboxPermissions, profiles },
});

const generated = await ZedPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});
const agent = JSON.parse(generated.getFileContent()).agent;

expect(agent.sandbox_permissions).toEqual(sandboxPermissions);
expect(agent.profiles).toEqual(profiles);
expect(agent.tool_permissions.tools.terminal).toEqual({ default: "confirm" });
});

it("should round-trip both blocks through generate → import in project scope", async () => {
const rulesyncPermissions = createRulesyncPermissionsWithConfig({
permission: { bash: { "*": "deny" } },
zed: { sandbox_permissions: sandboxPermissions, profiles },
});

const generated = await ZedPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});
await writeFileContent(join(testDir, ".zed", "settings.json"), generated.getFileContent());

const imported = await ZedPermissions.fromFile({ outputRoot: testDir });
const json = JSON.parse(imported.toRulesyncPermissions().getFileContent());

expect(json.permission.bash).toEqual({ "*": "deny" });
expect(json.zed).toEqual({ sandbox_permissions: sandboxPermissions, profiles });
});

it("should round-trip both blocks through generate → import in global scope", async () => {
const rulesyncPermissions = createRulesyncPermissionsWithConfig({
permission: { bash: { "*": "deny" } },
zed: { sandbox_permissions: sandboxPermissions, profiles },
});

const generated = await ZedPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
global: true,
});
await writeFileContent(
join(testDir, expectedZedGlobalDir, "settings.json"),
generated.getFileContent(),
);

const imported = await ZedPermissions.fromFile({ outputRoot: testDir, global: true });
const json = JSON.parse(imported.toRulesyncPermissions().getFileContent());

expect(json.zed).toEqual({ sandbox_permissions: sandboxPermissions, profiles });
});

it("should omit the zed override on import when the settings carry neither block", async () => {
await writeFileContent(
join(testDir, ".zed", "settings.json"),
JSON.stringify({
agent: { tool_permissions: { tools: { terminal: { default: "deny" } } } },
}),
);

const imported = await ZedPermissions.fromFile({ outputRoot: testDir });
const json = JSON.parse(imported.toRulesyncPermissions().getFileContent());

expect(json.zed).toBeUndefined();
});

it("should replace an existing block wholesale but preserve one the override omits", async () => {
await writeFileContent(
join(testDir, ".zed", "settings.json"),
JSON.stringify({
agent: {
sandbox_permissions: { network_hosts: ["old.example.com"], allow_all_hosts: true },
profiles: { legacy: { name: "Legacy" } },
},
}),
);

const rulesyncPermissions = createRulesyncPermissionsWithConfig({
permission: { bash: { "*": "ask" } },
zed: { sandbox_permissions: { network_hosts: ["new.example.com"] } },
});

const generated = await ZedPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});
const agent = JSON.parse(generated.getFileContent()).agent;

// Authored: replaced as one unit, so the stale allow_all_hosts is gone.
expect(agent.sandbox_permissions).toEqual({ network_hosts: ["new.example.com"] });
// Not authored: left exactly as the user wrote it.
expect(agent.profiles).toEqual({ legacy: { name: "Legacy" } });
});

it("should ignore a tool_permissions key in the override with a warning", async () => {
const logger = { warn: vi.fn() } as unknown as Logger;
const rulesyncPermissions = createRulesyncPermissionsWithConfig({
permission: { bash: { "rm *": "deny" } },
zed: {
tool_permissions: { default: "allow", tools: { terminal: { default: "allow" } } },
},
});

const generated = await ZedPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
logger,
});
const toolPermissions = JSON.parse(generated.getFileContent()).agent.tool_permissions;

// The canonical deny stands; the override could not relax it.
expect(toolPermissions.default).toBeUndefined();
expect(toolPermissions.tools.terminal).toEqual({
always_deny: [{ pattern: "rm *", case_sensitive: false }],
});
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("tool_permissions"));
});

it("should ignore an unsupported override key with a warning rather than silently", async () => {
const logger = { warn: vi.fn() } as unknown as Logger;
const rulesyncPermissions = createRulesyncPermissionsWithConfig({
permission: { bash: { "*": "ask" } },
zed: {
// A misspelling of sandbox_permissions, plus a real Zed `agent` key
// that would disarm every permission rule if the block were spread.
sandboxPermissions: { allow_all_hosts: true },
always_allow_tool_actions: true,
},
});

const generated = await ZedPermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
logger,
});
const agent = JSON.parse(generated.getFileContent()).agent;

expect(agent.sandboxPermissions).toBeUndefined();
expect(agent.always_allow_tool_actions).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("'zed.sandboxPermissions', 'zed.always_allow_tool_actions'"),
);
});
});
});
Loading
Loading