diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 316aa331a..dc9dbc007 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -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. diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 2068ca4b2..48773e69f 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -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. diff --git a/src/features/permissions/claudecode-permissions.test.ts b/src/features/permissions/claudecode-permissions.test.ts index b1d742775..483cfa71f 100644 --- a/src/features/permissions/claudecode-permissions.test.ts +++ b/src/features/permissions/claudecode-permissions.test.ts @@ -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, @@ -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", diff --git a/src/features/permissions/claudecode-permissions.ts b/src/features/permissions/claudecode-permissions.ts index 5700afe75..46201d29e 100644 --- a/src/features/permissions/claudecode-permissions.ts +++ b/src/features/permissions/claudecode-permissions.ts @@ -6,6 +6,8 @@ import type { ClaudeSettingsJson } from "../../types/claude-settings.js"; import type { PermissionAction, PermissionsConfig } from "../../types/permissions.js"; import { formatError } from "../../utils/error.js"; import { readFileContentOrNull } from "../../utils/file.js"; +import type { Logger } from "../../utils/logger.js"; +import { PROTOTYPE_POLLUTION_KEYS } from "../../utils/prototype-pollution.js"; import { applyPermissions } from "../shared/shared-config-gateway.js"; import { RulesyncPermissions } from "./rulesync-permissions.js"; import { @@ -66,6 +68,45 @@ function parseClaudePermissionEntry(entry: string): { toolName: string; pattern: return { toolName, pattern: pattern || "*" }; } +/** + * 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" — so a canonical + * `write`/`notebookedit`/`glob` rule with a pattern is emitted in the form the + * docs prescribe instead. A tool-name rule with no path is unaffected: it + * matches the tool everywhere and produces no warning. + * @see https://code.claude.com/docs/en/permissions + */ +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Merge `patch` into `base`, recursing into plain objects so a sibling key at + * any depth survives. Arrays and scalars are replaced, since a list the author + * states is the list they mean. + */ +function deepMergeRecords( + base: Record, + patch: Record, +): Record { + const merged: Record = { ...base }; + for (const [key, value] of Object.entries(patch)) { + if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue; + const existing = merged[key]; + merged[key] = + isPlainRecord(existing) && isPlainRecord(value) ? deepMergeRecords(existing, value) : value; + } + return merged; +} + +const CLAUDE_PATH_RULE_ALIASES: Record = { + Write: "Edit", + NotebookEdit: "Edit", + Glob: "Read", +}; + /** * Build a Claude Code permission entry like "Bash(npm run *)". * If the pattern is "*", returns just the tool name. @@ -74,7 +115,21 @@ function buildClaudePermissionEntry(toolName: string, pattern: string): string { if (pattern === "*") { return toolName; } - return `${toolName}(${pattern})`; + return `${CLAUDE_PATH_RULE_ALIASES[toolName] ?? toolName}(${pattern})`; +} + +/** + * The Claude tool names the canonical config manages. Deliberately the tool + * names the categories map to and *not* the aliases a path rule is rewritten + * to: claiming `Edit` because a `write` rule exists would sweep away the + * `Read`/`Edit` entries the ignore feature and the user wrote in the same file. + * The rewritten entries are still rulesync's to place — `applyPermissions` + * replaces an entry this run emits wherever it currently sits — and the + * original name stays claimed so an entry an older rulesync wrote in the warned + * form is cleaned up on the next generate. + */ +function managedClaudeToolNames(config: PermissionsConfig): Set { + return new Set(Object.keys(config.permission).map((category) => toClaudeToolName(category))); } export class ClaudecodePermissions extends ToolPermissions { @@ -131,7 +186,7 @@ export class ClaudecodePermissions extends ToolPermissions { } const config = rulesyncPermissions.getJson(); - const { allow, ask, deny } = convertRulesyncToClaudePermissions(config); + const { allow, ask, deny } = convertRulesyncToClaudePermissions({ config, logger }); // Merge the Claude Code-scoped override's non-list `permissions` fields // (e.g. `defaultMode`, `additionalDirectories`) into the settings @@ -143,9 +198,19 @@ export class ClaudecodePermissions extends ToolPermissions { settings.permissions = { ...settings.permissions, ...nonListFields }; } - const managedToolNames = new Set( - Object.keys(config.permission).map((category) => toClaudeToolName(category)), - ); + // `sandbox` sits next to `permissions` at the top level of settings.json. + // Deep-merged rather than shallow: its subtrees hold deny lists + // (`network.deniedDomains`, `filesystem.denyRead`), so replacing `network` + // wholesale to set one flag would drop the restrictions beside it. + const overrideSandbox = config.claudecode?.sandbox; + if (isPlainRecord(overrideSandbox)) { + settings.sandbox = deepMergeRecords( + isPlainRecord(settings.sandbox) ? settings.sandbox : {}, + overrideSandbox, + ); + } + + const managedToolNames = managedClaudeToolNames(config); // The gateway owns the shared `permissions` merge and the cross-feature // ownership rule; here we only state the intent (managed tools + arrays). @@ -195,6 +260,12 @@ export class ClaudecodePermissions extends ToolPermissions { config.claudecode = { permissions: nonListFields }; } + // The sibling `sandbox` subtree round-trips through the same override block. + const { sandbox } = settings; + if (isPlainRecord(sandbox) && Object.keys(sandbox).length > 0) { + config.claudecode = { ...config.claudecode, sandbox }; + } + return this.toRulesyncPermissionsDefault({ fileContent: JSON.stringify(config, null, 2), }); @@ -222,7 +293,13 @@ export class ClaudecodePermissions extends ToolPermissions { /** * Convert rulesync permissions config to Claude Code allow/ask/deny arrays. */ -function convertRulesyncToClaudePermissions(config: PermissionsConfig): { +function convertRulesyncToClaudePermissions({ + config, + logger, +}: { + config: PermissionsConfig; + logger?: Logger; +}): { allow: string[]; ask: string[]; deny: string[]; @@ -230,11 +307,25 @@ function convertRulesyncToClaudePermissions(config: PermissionsConfig): { const allow: string[] = []; const ask: string[] = []; const deny: string[] = []; + // Two categories can now produce the same entry — `write` and `edit` both map + // to `Edit(path)` — so a disagreement between them becomes a config that says + // two things at once. Claude Code resolves deny first, but the author should + // hear about it rather than discover it later. + const actionByEntry = new Map(); for (const [category, rules] of Object.entries(config.permission)) { const claudeToolName = toClaudeToolName(category); for (const [pattern, action] of Object.entries(rules)) { const entry = buildClaudePermissionEntry(claudeToolName, pattern); + const previous = actionByEntry.get(entry); + if (previous !== undefined && previous !== action) { + logger?.warn( + `Claude Code permissions: rules from different categories both resolve to "${entry}" ` + + `with conflicting actions (${previous} and ${action}). Both are written; Claude Code ` + + `applies deny first, then ask, then allow.`, + ); + } + actionByEntry.set(entry, action); switch (action) { case "allow": allow.push(entry); diff --git a/src/features/shared/shared-config-gateway.ts b/src/features/shared/shared-config-gateway.ts index 99c5f39d6..5962d9125 100644 --- a/src/features/shared/shared-config-gateway.ts +++ b/src/features/shared/shared-config-gateway.ts @@ -812,8 +812,14 @@ export const applyPermissions = (params: { const { settings, managedToolNames, toolNameOf, allow, ask, deny, logger } = params; const current = parsePermissionsBlock(settings); + // An entry this run emits is this run's to place, whatever list it currently + // sits in — otherwise flipping a rule from deny to allow would leave the old + // deny behind and win. Narrower than claiming its whole tool name, which + // would also sweep up entries another feature or the user wrote: a tool name + // is only claimed when the caller says the canonical config manages it. + const emitted = new Set([...allow, ...ask, ...deny]); const keepUnmanaged = (entries: string[]): string[] => - entries.filter((entry) => !managedToolNames.has(toolNameOf(entry))); + entries.filter((entry) => !managedToolNames.has(toolNameOf(entry)) && !emitted.has(entry)); if (logger && managedToolNames.has(READ_TOOL_NAME)) { const overwrittenReadDenies = current.deny.filter( diff --git a/src/types/claude-settings.ts b/src/types/claude-settings.ts index 92e87beaf..158de9de7 100644 --- a/src/types/claude-settings.ts +++ b/src/types/claude-settings.ts @@ -8,5 +8,11 @@ export type ClaudeSettingsJson = { ask?: string[] | null; deny?: string[] | null; } | null; + /** + * The sandbox commands run in (`network`, `filesystem`, `credentials`, ...). + * Authorable through the `claudecode.sandbox` permissions override. + * @see https://code.claude.com/docs/en/sandboxing + */ + sandbox?: Record | null; [key: string]: unknown; }; diff --git a/src/types/permissions.ts b/src/types/permissions.ts index edd8bac87..5735b63e5 100644 --- a/src/types/permissions.ts +++ b/src/types/permissions.ts @@ -165,12 +165,23 @@ export type KiloPermissionsOverride = z.infer;