diff --git a/extensions/rules-guard/README.md b/extensions/rules-guard/README.md index eac2e72..4892fb8 100644 --- a/extensions/rules-guard/README.md +++ b/extensions/rules-guard/README.md @@ -13,17 +13,21 @@ which closes that gap. ## Policy sources -At startup the extension reads both Claude settings files, pulls their `permissions.deny` +At startup the extension reads the Claude settings files, pulls their `permissions.deny` and `permissions.allow` arrays, and merges them with the opinionated defaults: | Source | What it contributes | | --- | --- | | `~/.claude/settings.json` | Your personal `permissions.deny` and `permissions.allow`. | | `~/.claude/remote-settings.json` | Organization policy. On Team and Enterprise plans this file is managed by org admins, so central rules apply automatically with no local opt-in. | +| `.claude/settings.json` | Project-shared rules, committed to source control so the whole team inherits them. Resolved under the working directory. | +| `.claude/settings.local.json` | Project-scoped personal rules, resolved under the working directory. Git-ignored by convention, so it carries machine-local rules that are not shared with the team. | | Opinionated defaults (`index.ts`) | An opinionated default deny/allow list baked into the source, so the guard still works standalone when a file is missing or invalid. | -All three merge into one policy. A missing or unparseable file is skipped without error, -and the guard falls back to the remaining file plus the opinionated defaults. Rules compile +They all merge into one policy — the project-scoped files add rules, they do not take +precedence by being project-level; conflicts resolve by specificity like every other source +(see below). A missing or unparseable file is skipped without error, and the guard falls +back to the remaining files plus the opinionated defaults. Rules compile once when the extension loads. The active counts appear in a `session_start` notification, for example `20 deny / 2 allow rules across all tools`. diff --git a/extensions/rules-guard/index.test.ts b/extensions/rules-guard/index.test.ts index 302445d..284b620 100644 --- a/extensions/rules-guard/index.test.ts +++ b/extensions/rules-guard/index.test.ts @@ -11,6 +11,7 @@ import rulesGuard, { bashMatcher, buildPolicy, candidateAbsPaths, + claudeFiles, compileGlob, decide, EMBEDDED_ALLOW, @@ -566,6 +567,48 @@ describe("loadPolicyEntries (settings file merge)", () => { }); }); +describe("claudeFiles (default settings file locations)", () => { + test("resolves user + org files under home and both project files under cwd", () => { + expect(claudeFiles("/home/test", "/proj")).toEqual([ + nodePath.join("/home/test", ".claude", "settings.json"), + nodePath.join("/home/test", ".claude", "remote-settings.json"), + nodePath.join("/proj", ".claude", "settings.json"), + nodePath.join("/proj", ".claude", "settings.local.json"), + ]); + }); + test("project-scoped settings.json + settings.local.json load and merge via claudeFiles", () => { + inTempDir((dir) => { + const home = nodePath.join(dir, "home"); + const proj = nodePath.join(dir, "proj"); + const projClaude = nodePath.join(proj, ".claude"); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(projClaude, { recursive: true }); + fs.writeFileSync( + nodePath.join(projClaude, "settings.json"), + JSON.stringify({ + permissions: { deny: ["Read(/proj/shared-secret)"] }, + }), + ); + fs.writeFileSync( + nodePath.join(projClaude, "settings.local.json"), + JSON.stringify({ + permissions: { + deny: ["Read(/proj/secret)"], + allow: ["Write(/proj/tmp/**)"], + }, + }), + ); + // home has no .claude files, so only the project sources contribute. + const { deny, allow } = loadPolicyEntries(claudeFiles(home, proj)); + expect(deny).toContain("Read(/proj/shared-secret)"); // project settings.json + expect(deny).toContain("Read(/proj/secret)"); // project settings.local.json + expect(deny).toContain("Read(**/.env*)"); // defaults still present + expect(allow).toContain("Write(/proj/tmp/**)"); + expect(allow).toEqual(expect.arrayContaining(EMBEDDED_ALLOW)); + }); + }); +}); + describe("rulesGuard wiring — registration & session_start", () => { test("sets a label of the expected shape and registers three hooks", () => { const { pi, handlers, state } = makeMockPi(); diff --git a/extensions/rules-guard/index.ts b/extensions/rules-guard/index.ts index e9f371a..c84c81a 100644 --- a/extensions/rules-guard/index.ts +++ b/extensions/rules-guard/index.ts @@ -8,10 +8,12 @@ * denied path, and enforces the denied bash command patterns too. A `tool_result` * pass redacts secret-shaped output as defense in depth. * - * Policy source (read at load from BOTH Claude settings files): - * - ~/.claude/settings.json → permissions.deny + permissions.allow - * - ~/.claude/remote-settings.json → permissions.deny + permissions.allow - * Both files plus the opinionated defaults below are merged, so the guard still + * Policy source (read at load from ALL Claude settings files): + * - ~/.claude/settings.json → permissions.deny + permissions.allow + * - ~/.claude/remote-settings.json → permissions.deny + permissions.allow + * - /.claude/settings.json → permissions.deny + permissions.allow + * - /.claude/settings.local.json → permissions.deny + permissions.allow + * All files plus the opinionated defaults below are merged, so the guard still * works standalone when a file is missing/invalid. * * Precedence (unlike Claude, where deny always wins): the MORE SPECIFIC rule @@ -71,11 +73,25 @@ export const EMBEDDED_ALLOW: string[] = [ "Read(**/.env.default)", ]; -// Import `~/.claude/settings.json` and `~/.claude/remote-settings.json` to -// construct additional rules -const CLAUDE_FILES = ["settings.json", "remote-settings.json"].map((f) => - nodePath.join(os.homedir(), ".claude", f), -); +// Default Claude settings files that contribute policy rules: the user + org +// files under `~/.claude`, plus the project-scoped `.claude/settings.json` +// (shared, committed) and `.claude/settings.local.json` (local, git-ignored) +// under `cwd`. Order is irrelevant to the final policy (precedence is by +// specificity, not position); a missing file is skipped. Pure + injectable for +// tests, matching `compileGlob`/`globSpecificity`. +export function claudeFiles( + home: string = os.homedir(), + cwd: string = process.cwd(), +): string[] { + return [ + nodePath.join(home, ".claude", "settings.json"), + nodePath.join(home, ".claude", "remote-settings.json"), + nodePath.join(cwd, ".claude", "settings.json"), + nodePath.join(cwd, ".claude", "settings.local.json"), + ]; +} + +const CLAUDE_FILES = claudeFiles(); // Claude permission "tool" names mapped onto omp tool classes. const CLAUDE_READ_TOOLS: Record = {