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
7 changes: 6 additions & 1 deletion docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -1162,16 +1162,21 @@ Rulesync provides a JSON Schema for editor validation and autocompletion. Add th

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

Claude Code's file permission checks match only `Edit(path)` and `Read(path)` rules: a `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted but never matched by those checks, so Claude Code warns at startup for each allow, deny, or ask rule in one of these unmatched forms" ([permissions docs](https://code.claude.com/docs/en/permissions), v2.1.210+). Rulesync therefore writes a canonical `write` or `notebookedit` rule that carries a pattern as `Edit(pattern)`, and a `glob` rule as `Read(pattern)`. A rule whose pattern is `*` is a tool-name rule with no path — it matches the tool everywhere and produces no warning — so it is still written as the bare `Write` / `NotebookEdit` / `Glob`. Entries an earlier Rulesync wrote in the warned form are replaced on the next generate, and so is a rewritten entry whose action changed, so flipping a rule from deny to allow never leaves the old deny behind to win. Rewriting a rule does **not** make Rulesync claim the `Edit` or `Read` namespace as a whole: a `Read(...)` deny the [ignore feature](#rulesyncignore) wrote, or an `Edit(...)` rule you added to `settings.json` by hand, is left alone unless the canonical config manages that category itself. Import stays tolerant of both forms, so an existing `settings.json` still round-trips; a rewritten rule comes back under `edit` or `read` rather than the category it was authored in, since that is the rule Claude Code actually applies. Note that this widens a `glob` **allow** rule: `Read(pattern)` permits reading the files' contents, not just listing their names — the docs prescribe the substitution, but author `glob` allow rules with that in mind. When two categories resolve to the same entry with different actions (`edit` allowing what `write` denies, say) both are written and Rulesync warns — Claude Code applies deny first, then ask, then allow.

> **Claude Code-only override (`claudecode` key):** Claude Code's `permissions` object also carries non-list fields with no canonical permission category — notably `defaultMode` (the session-start permission mode: `default` | `acceptEdits` | `plan` | `bypassPermissions`) and `additionalDirectories` (extra working directories). Add a tool-scoped `claudecode` override key alongside the shared block to author them: the fields under `claudecode.permissions` are merged into the settings `permissions` object and emitted **only** for Claude Code, while the shared `permission` block continues to drive the managed `allow`/`ask`/`deny` arrays. The block is a verbatim passthrough (so other/future `permissions` fields such as the org locks `disableBypassPermissionsMode`/`disableAutoMode` can be set too), but any `allow`/`ask`/`deny` placed inside it is ignored — rulesync owns those arrays. On import, the non-list `permissions` fields round-trip back into the `claudecode` override. Note that these fields are merged **additively** into the existing `settings.json` (so hand-added settings survive): removing a field from the `claudecode` override does not delete a value already written to `settings.json` — clear it there by hand.
>
> ```json
> {
> "permission": { "bash": { "git *": "allow" } },
> "claudecode": {
> "permissions": { "defaultMode": "acceptEdits", "additionalDirectories": ["../shared"] }
> "permissions": { "defaultMode": "acceptEdits", "additionalDirectories": ["../shared"] },
> "sandbox": { "network": { "allowedDomains": ["example.com"], "strictAllowlist": true } }
> }
> }
> ```
>
> The same override key also carries `sandbox`, the sibling top-level settings subtree governing the sandbox commands run in (`sandbox.network.*`, `sandbox.filesystem.*`, `sandbox.credentials`, `sandbox.allowAppleEvents`, ...). It has no canonical permission category either — it constrains _how_ a permitted command runs rather than which commands are permitted — so it is a verbatim passthrough on the same terms, merged into the top level of `settings.json` and round-tripped back on import. The merge is recursive, unlike the flat `permissions` fields above: `sandbox` subtrees carry restriction lists (`network.deniedDomains`, `filesystem.denyRead`), so setting one flag under `network` must not drop the denials beside it. A sibling key at any depth survives; a list you author replaces the existing list rather than being appended to. See the [sandboxing docs](https://code.claude.com/docs/en/sandboxing).

For OpenCode, this generates the `permission` object in `opencode.json` / `opencode.jsonc` (project mode) or `.config/opencode/opencode.json` / `.config/opencode/opencode.jsonc` (global mode), preserving other existing OpenCode config fields. OpenCode's `webfetch`, `websearch`, `todowrite`, `question`, and `doom_loop` keys accept only a single action string, so Rulesync emits their canonical `{ "*": "allow" }` form as `"allow"`. If one of these categories contains pattern-specific rules, Rulesync collapses them to the most restrictive action (`deny` > `ask` > `allow`) and logs a warning because OpenCode cannot represent those patterns; a map without `*` includes an implicit `ask` fallback so a narrow allowlist never becomes blanket `allow`, while an empty map becomes `deny` instead of falling through to OpenCode's default allow behavior.

Expand Down
7 changes: 6 additions & 1 deletion skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -1162,16 +1162,21 @@ Rulesync provides a JSON Schema for editor validation and autocompletion. Add th

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

Claude Code's file permission checks match only `Edit(path)` and `Read(path)` rules: a `Write(path)`, `NotebookEdit(path)` or `Glob(path)` rule "is accepted but never matched by those checks, so Claude Code warns at startup for each allow, deny, or ask rule in one of these unmatched forms" ([permissions docs](https://code.claude.com/docs/en/permissions), v2.1.210+). Rulesync therefore writes a canonical `write` or `notebookedit` rule that carries a pattern as `Edit(pattern)`, and a `glob` rule as `Read(pattern)`. A rule whose pattern is `*` is a tool-name rule with no path — it matches the tool everywhere and produces no warning — so it is still written as the bare `Write` / `NotebookEdit` / `Glob`. Entries an earlier Rulesync wrote in the warned form are replaced on the next generate, and so is a rewritten entry whose action changed, so flipping a rule from deny to allow never leaves the old deny behind to win. Rewriting a rule does **not** make Rulesync claim the `Edit` or `Read` namespace as a whole: a `Read(...)` deny the [ignore feature](#rulesyncignore) wrote, or an `Edit(...)` rule you added to `settings.json` by hand, is left alone unless the canonical config manages that category itself. Import stays tolerant of both forms, so an existing `settings.json` still round-trips; a rewritten rule comes back under `edit` or `read` rather than the category it was authored in, since that is the rule Claude Code actually applies. Note that this widens a `glob` **allow** rule: `Read(pattern)` permits reading the files' contents, not just listing their names — the docs prescribe the substitution, but author `glob` allow rules with that in mind. When two categories resolve to the same entry with different actions (`edit` allowing what `write` denies, say) both are written and Rulesync warns — Claude Code applies deny first, then ask, then allow.

> **Claude Code-only override (`claudecode` key):** Claude Code's `permissions` object also carries non-list fields with no canonical permission category — notably `defaultMode` (the session-start permission mode: `default` | `acceptEdits` | `plan` | `bypassPermissions`) and `additionalDirectories` (extra working directories). Add a tool-scoped `claudecode` override key alongside the shared block to author them: the fields under `claudecode.permissions` are merged into the settings `permissions` object and emitted **only** for Claude Code, while the shared `permission` block continues to drive the managed `allow`/`ask`/`deny` arrays. The block is a verbatim passthrough (so other/future `permissions` fields such as the org locks `disableBypassPermissionsMode`/`disableAutoMode` can be set too), but any `allow`/`ask`/`deny` placed inside it is ignored — rulesync owns those arrays. On import, the non-list `permissions` fields round-trip back into the `claudecode` override. Note that these fields are merged **additively** into the existing `settings.json` (so hand-added settings survive): removing a field from the `claudecode` override does not delete a value already written to `settings.json` — clear it there by hand.
>
> ```json
> {
> "permission": { "bash": { "git *": "allow" } },
> "claudecode": {
> "permissions": { "defaultMode": "acceptEdits", "additionalDirectories": ["../shared"] }
> "permissions": { "defaultMode": "acceptEdits", "additionalDirectories": ["../shared"] },
> "sandbox": { "network": { "allowedDomains": ["example.com"], "strictAllowlist": true } }
> }
> }
> ```
>
> The same override key also carries `sandbox`, the sibling top-level settings subtree governing the sandbox commands run in (`sandbox.network.*`, `sandbox.filesystem.*`, `sandbox.credentials`, `sandbox.allowAppleEvents`, ...). It has no canonical permission category either — it constrains _how_ a permitted command runs rather than which commands are permitted — so it is a verbatim passthrough on the same terms, merged into the top level of `settings.json` and round-tripped back on import. The merge is recursive, unlike the flat `permissions` fields above: `sandbox` subtrees carry restriction lists (`network.deniedDomains`, `filesystem.denyRead`), so setting one flag under `network` must not drop the denials beside it. A sibling key at any depth survives; a list you author replaces the existing list rather than being appended to. See the [sandboxing docs](https://code.claude.com/docs/en/sandboxing).

For OpenCode, this generates the `permission` object in `opencode.json` / `opencode.jsonc` (project mode) or `.config/opencode/opencode.json` / `.config/opencode/opencode.jsonc` (global mode), preserving other existing OpenCode config fields. OpenCode's `webfetch`, `websearch`, `todowrite`, `question`, and `doom_loop` keys accept only a single action string, so Rulesync emits their canonical `{ "*": "allow" }` form as `"allow"`. If one of these categories contains pattern-specific rules, Rulesync collapses them to the most restrictive action (`deny` > `ask` > `allow`) and logs a warning because OpenCode cannot represent those patterns; a map without `*` includes an implicit `ask` fallback so a narrow allowlist never becomes blanket `allow`, while an empty map becomes `deny` instead of falling through to OpenCode's default allow behavior.

Expand Down
195 changes: 195 additions & 0 deletions src/features/permissions/claudecode-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,181 @@ describe("ClaudecodePermissions", () => {
expect(content.permissions.deny).toContain("Bash(rm *)");
});

it("should emit path rules in the forms Claude Code actually matches", async () => {
// File permission checks match only Edit(path) and Read(path); a
// Write/NotebookEdit/Glob rule with a path is never matched and warns at
// startup. https://code.claude.com/docs/en/permissions
const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({
permission: {
write: { "docs/**": "deny", "*": "ask" },
notebookedit: { "notebooks/**": "deny" },
glob: { "secrets/**": "deny" },
},
}),
});

const instance = await ClaudecodePermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

const content = JSON.parse(instance.getFileContent());
expect(content.permissions.deny).toEqual([
"Edit(docs/**)",
"Edit(notebooks/**)",
"Read(secrets/**)",
]);
// A tool-name rule with no path matches everywhere and is left alone.
expect(content.permissions.ask).toEqual(["Write"]);
});

it("should replace the warned forms an earlier generate left behind", async () => {
await writeFileContent(
join(testDir, ".claude", "settings.json"),
JSON.stringify({ permissions: { deny: ["Write(docs/**)", "Glob(secrets/**)"] } }),
);

const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({
permission: {
write: { "docs/**": "deny" },
glob: { "secrets/**": "deny" },
},
}),
});

const instance = await ClaudecodePermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

expect(JSON.parse(instance.getFileContent()).permissions.deny).toEqual([
"Edit(docs/**)",
"Read(secrets/**)",
]);
});

it("should warn when two categories resolve to one entry with different actions", async () => {
const mockLogger = createMockLogger();
const warnSpy = vi.spyOn(mockLogger, "warn");
const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({
permission: {
edit: { "docs/**": "allow" },
write: { "docs/**": "deny" },
},
}),
});

await ClaudecodePermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
logger: mockLogger,
});

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('both resolve to "Edit(docs/**)"'),
);
});

it("should merge the claudecode sandbox override into the settings top level", async () => {
await writeFileContent(
join(testDir, ".claude", "settings.json"),
JSON.stringify({
model: "opus",
sandbox: { credentials: "keep", network: { deniedDomains: ["evil.test"] } },
}),
);

const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({
permission: { bash: { "npm *": "allow" } },
claudecode: { sandbox: { network: { strictAllowlist: true } } },
}),
});

const instance = await ClaudecodePermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

const content = JSON.parse(instance.getFileContent());
// Deep-merged: the restriction beside the authored flag survives, and so
// does the sibling settings key.
expect(content.model).toBe("opus");
expect(content.sandbox).toEqual({
credentials: "keep",
network: { deniedDomains: ["evil.test"], strictAllowlist: true },
});
});

it("should keep Edit and Read entries it does not manage", async () => {
// The ignore feature writes Read(...) denies into the same file, and a
// user may hand-write an Edit(...) rule. Rewriting a `write` rule to
// `Edit(...)` must not make rulesync claim those namespaces wholesale.
await writeFileContent(
join(testDir, ".claude", "settings.json"),
JSON.stringify({
permissions: {
allow: ["Edit(vendor/**)"],
deny: ["Read(.env)", "Write(docs/**)"],
},
}),
);

const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({
permission: {
write: { "docs/**": "deny" },
glob: { "secrets/**": "deny" },
},
}),
});

const instance = await ClaudecodePermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

const content = JSON.parse(instance.getFileContent());
expect(content.permissions.allow).toEqual(["Edit(vendor/**)"]);
expect(content.permissions.deny).toEqual(["Edit(docs/**)", "Read(.env)", "Read(secrets/**)"]);
});

it("should move a rewritten entry when its action changes", async () => {
await writeFileContent(
join(testDir, ".claude", "settings.json"),
JSON.stringify({ permissions: { deny: ["Edit(docs/**)"] } }),
);

const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: RULESYNC_PERMISSIONS_FILE_NAME,
fileContent: JSON.stringify({ permission: { write: { "docs/**": "allow" } } }),
});

const instance = await ClaudecodePermissions.fromRulesyncPermissions({
outputRoot: testDir,
rulesyncPermissions,
});

const content = JSON.parse(instance.getFileContent());
// The stale deny must not survive to win over the new allow.
expect(content.permissions.allow).toEqual(["Edit(docs/**)"]);
expect(content.permissions.deny ?? []).toEqual([]);
});

it("should handle multiple tool categories", async () => {
const rulesyncPermissions = new RulesyncPermissions({
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
Expand Down Expand Up @@ -544,6 +719,26 @@ describe("ClaudecodePermissions", () => {
});

describe("toRulesyncPermissions", () => {
it("should route the sandbox subtree back into the claudecode override", () => {
const instance = new ClaudecodePermissions({
outputRoot: testDir,
relativeDirPath: ".claude",
relativeFilePath: "settings.json",
fileContent: JSON.stringify({
permissions: { deny: ["Edit(docs/**)"], defaultMode: "acceptEdits" },
sandbox: { network: { strictAllowlist: true } },
model: "opus",
}),
});

const config = JSON.parse(instance.toRulesyncPermissions().getFileContent());
expect(config.claudecode.sandbox).toEqual({ network: { strictAllowlist: true } });
// The sibling override field still round-trips alongside it.
expect(config.claudecode.permissions).toEqual({ defaultMode: "acceptEdits" });
// A settings key this feature does not own is not swept into the override.
expect(config.claudecode.model).toBeUndefined();
});

it("routes non-list permissions fields into the claudecode override on import", () => {
const instance = new ClaudecodePermissions({
relativeDirPath: ".claude",
Expand Down
Loading
Loading