From 820f218fce1cba37e5b6e5ae58b466505f7043ed Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Wed, 1 Jul 2026 16:49:59 +0200 Subject: [PATCH 1/2] Load project-scoped settings.local.json rules `rules-guard` now reads `/.claude/settings.local.json` in addition to the two `~/.claude` files, so machine-local project rules apply. The inline settings-file list becomes a pure, injectable `claudeFiles(home, cwd)` helper, matching `compileGlob`/`globSpecificity`, so resolution is unit-testable. The project-scoped file is an additional policy source, not an order-based override: `buildPolicy` merges every entry and resolves path conflicts by glob specificity (ties deny), so a local rule adds to the policy rather than winning by being local. A missing or unparseable file is skipped, as with the other sources. Glory to the Omnissiah --- extensions/rules-guard/README.md | 9 +++++--- extensions/rules-guard/index.test.ts | 32 ++++++++++++++++++++++++++++ extensions/rules-guard/index.ts | 31 +++++++++++++++++++-------- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/extensions/rules-guard/README.md b/extensions/rules-guard/README.md index eac2e72..cdb3c01 100644 --- a/extensions/rules-guard/README.md +++ b/extensions/rules-guard/README.md @@ -13,17 +13,20 @@ 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.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 — this project-scoped file adds rules, it does not take +precedence by being local; 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..179d8ad 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,37 @@ describe("loadPolicyEntries (settings file merge)", () => { }); }); +describe("claudeFiles (default settings file locations)", () => { + test("resolves user + org files under home and the project-local file 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.local.json"), + ]); + }); + test("project-scoped settings.local.json is loaded and merged via claudeFiles", () => { + inTempDir((dir) => { + const dotClaude = nodePath.join(dir, ".claude"); + fs.mkdirSync(dotClaude, { recursive: true }); + fs.writeFileSync( + nodePath.join(dotClaude, "settings.local.json"), + JSON.stringify({ + permissions: { + deny: ["Read(/proj/secret)"], + allow: ["Write(/proj/tmp/**)"], + }, + }), + ); + // home has no .claude files here, so only the project-local source contributes. + const { deny, allow } = loadPolicyEntries(claudeFiles(dir, dir)); + expect(deny).toContain("Read(/proj/secret)"); + 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..1bf2a3c 100644 --- a/extensions/rules-guard/index.ts +++ b/extensions/rules-guard/index.ts @@ -8,10 +8,11 @@ * 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.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 +72,23 @@ 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.local.json` +// 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.local.json"), + ]; +} + +const CLAUDE_FILES = claudeFiles(); // Claude permission "tool" names mapped onto omp tool classes. const CLAUDE_READ_TOOLS: Record = { From da77cdcdcbf7ae5593da342d6b6f29b809d1ec18 Mon Sep 17 00:00:00 2001 From: Robbie Blaine Date: Wed, 1 Jul 2026 16:58:51 +0200 Subject: [PATCH 2/2] Also load project-shared .claude/settings.json `rules-guard` now reads `/.claude/settings.json` in addition to the project-local `settings.local.json`, so team-shared project rules that are committed to source control are enforced too, not only the git-ignored local overrides. Both project files are resolved under `cwd`, matching Claude Code, which looks for project settings in the working directory without traversing parent directories. Order stays policy-irrelevant: `buildPolicy` merges every entry and resolves conflicts by glob specificity (ties deny), so the shared file adds rules rather than winning by position. By the will of the Machine God --- extensions/rules-guard/README.md | 5 +++-- extensions/rules-guard/index.test.ts | 27 +++++++++++++++++++-------- extensions/rules-guard/index.ts | 5 ++++- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/extensions/rules-guard/README.md b/extensions/rules-guard/README.md index cdb3c01..4892fb8 100644 --- a/extensions/rules-guard/README.md +++ b/extensions/rules-guard/README.md @@ -20,11 +20,12 @@ and `permissions.allow` arrays, and merges them with the opinionated defaults: | --- | --- | | `~/.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. | -They all merge into one policy — this project-scoped file adds rules, it does not take -precedence by being local; conflicts resolve by specificity like every other source +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, diff --git a/extensions/rules-guard/index.test.ts b/extensions/rules-guard/index.test.ts index 179d8ad..284b620 100644 --- a/extensions/rules-guard/index.test.ts +++ b/extensions/rules-guard/index.test.ts @@ -568,19 +568,29 @@ describe("loadPolicyEntries (settings file merge)", () => { }); describe("claudeFiles (default settings file locations)", () => { - test("resolves user + org files under home and the project-local file under cwd", () => { + 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.local.json is loaded and merged via claudeFiles", () => { + test("project-scoped settings.json + settings.local.json load and merge via claudeFiles", () => { inTempDir((dir) => { - const dotClaude = nodePath.join(dir, ".claude"); - fs.mkdirSync(dotClaude, { recursive: true }); + 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(dotClaude, "settings.local.json"), + 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)"], @@ -588,9 +598,10 @@ describe("claudeFiles (default settings file locations)", () => { }, }), ); - // home has no .claude files here, so only the project-local source contributes. - const { deny, allow } = loadPolicyEntries(claudeFiles(dir, dir)); - expect(deny).toContain("Read(/proj/secret)"); + // 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)); diff --git a/extensions/rules-guard/index.ts b/extensions/rules-guard/index.ts index 1bf2a3c..c84c81a 100644 --- a/extensions/rules-guard/index.ts +++ b/extensions/rules-guard/index.ts @@ -11,6 +11,7 @@ * 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. @@ -73,7 +74,8 @@ export const EMBEDDED_ALLOW: string[] = [ ]; // Default Claude settings files that contribute policy rules: the user + org -// files under `~/.claude`, plus the project-scoped `.claude/settings.local.json` +// 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`. @@ -84,6 +86,7 @@ export function claudeFiles( 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"), ]; }