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
10 changes: 7 additions & 3 deletions extensions/rules-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
43 changes: 43 additions & 0 deletions extensions/rules-guard/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import rulesGuard, {
bashMatcher,
buildPolicy,
candidateAbsPaths,
claudeFiles,
compileGlob,
decide,
EMBEDDED_ALLOW,
Expand Down Expand Up @@ -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();
Expand Down
34 changes: 25 additions & 9 deletions extensions/rules-guard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* - <cwd>/.claude/settings.json → permissions.deny + permissions.allow
* - <cwd>/.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
Expand Down Expand Up @@ -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<string, true> = {
Expand Down