diff --git a/CHANGELOG.md b/CHANGELOG.md index e05df1d3..fc318070 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixes - Stop stderr leakage from workflow policies (`require-push-before-stop`, `require-pr-before-stop`, `require-ci-green-before-stop`, etc.): git probes that are expected to sometimes fail no longer leak "fatal: Needed a single revision" or similar messages to the user's terminal (#132) +- `block-read-outside-cwd` now uses `CLAUDE_PROJECT_DIR` (the stable project root) instead of the live hook `cwd`, which drifts when Claude `cd`s into a subdirectory. Reads at the project root are no longer wrongly denied after a `cd`. Falls back to `ctx.session.cwd` when that variable is unset (#134) ## 0.0.6-beta.2 — 2026-04-21 diff --git a/__tests__/hooks/block-read-outside-cwd.test.ts b/__tests__/hooks/block-read-outside-cwd.test.ts index 9deea6f5..ef20884c 100644 --- a/__tests__/hooks/block-read-outside-cwd.test.ts +++ b/__tests__/hooks/block-read-outside-cwd.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import type { PolicyContext } from "../../src/hooks/policy-types"; // Import the builtin policies array to get the policy function @@ -18,6 +18,19 @@ function makeCtx(overrides: Partial): PolicyContext { } describe("block-read-outside-cwd policy", () => { + // Ensure CLAUDE_PROJECT_DIR does not leak in from the outer env (the test + // runner may itself be launched under Claude Code). The env-var precedence + // block below sets it explicitly where relevant. + const originalProjectDir = process.env.CLAUDE_PROJECT_DIR; + beforeEach(() => { + delete process.env.CLAUDE_PROJECT_DIR; + }); + afterEach(() => { + if (originalProjectDir === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = originalProjectDir; + }); + + it("exists in BUILTIN_POLICIES", () => { expect(policy).toBeDefined(); expect(policy.defaultEnabled).toBe(false); @@ -482,4 +495,64 @@ describe("block-read-outside-cwd policy", () => { const result = await policy.fn(ctx); expect(result.decision).toBe("deny"); }); + + // -- $CLAUDE_PROJECT_DIR precedence tests -- + // Claude Code's hook JSON `cwd` follows live shell CWD (it drifts on `cd`), + // but $CLAUDE_PROJECT_DIR is the stable project root. The policy should + // prefer the env var so reads at the project root aren't wrongly blocked + // after Claude cd's into a subdirectory. + + it("uses $CLAUDE_PROJECT_DIR to allow a sibling-dir read after Claude cd'd into a subdir", async () => { + process.env.CLAUDE_PROJECT_DIR = "/home/user/project"; + const ctx = makeCtx({ + toolInput: { file_path: "/home/user/project/README.md" }, + session: { cwd: "/home/user/project/server" }, + }); + const result = await policy.fn(ctx); + expect(result.decision).toBe("allow"); + }); + + it("denies reads outside $CLAUDE_PROJECT_DIR even when session.cwd is deeper inside it", async () => { + process.env.CLAUDE_PROJECT_DIR = "/home/user/project"; + const ctx = makeCtx({ + toolInput: { file_path: "/etc/passwd" }, + session: { cwd: "/home/user/project/server" }, + }); + const result = await policy.fn(ctx); + expect(result.decision).toBe("deny"); + expect(result.reason).toContain("/etc/passwd"); + }); + + it("$CLAUDE_PROJECT_DIR takes precedence when both env var and session.cwd are set", async () => { + // Boundary = env var /home/user/project. session.cwd points elsewhere but + // should be ignored. The target is inside the env-var boundary → allow. + process.env.CLAUDE_PROJECT_DIR = "/home/user/project"; + const ctx = makeCtx({ + toolInput: { file_path: "/home/user/project/src/index.ts" }, + session: { cwd: "/somewhere/else" }, + }); + const result = await policy.fn(ctx); + expect(result.decision).toBe("allow"); + }); + + it("falls back to session.cwd when $CLAUDE_PROJECT_DIR is unset", async () => { + // beforeEach already deletes the env var, so this just documents the fallback. + const ctx = makeCtx({ + toolInput: { file_path: "/home/user/project/src/index.ts" }, + session: { cwd: "/home/user/project" }, + }); + const result = await policy.fn(ctx); + expect(result.decision).toBe("allow"); + }); + + it("applies Bash read-path checks against $CLAUDE_PROJECT_DIR rather than session.cwd", async () => { + process.env.CLAUDE_PROJECT_DIR = "/home/user/project"; + const ctx = makeCtx({ + toolName: "Bash", + toolInput: { command: "cat /home/user/project/CHANGELOG.md" }, + session: { cwd: "/home/user/project/deeply/nested" }, + }); + const result = await policy.fn(ctx); + expect(result.decision).toBe("allow"); + }); }); diff --git a/docs/built-in-policies.mdx b/docs/built-in-policies.mdx index 5ace66e3..361aa4bd 100644 --- a/docs/built-in-policies.mdx +++ b/docs/built-in-policies.mdx @@ -216,13 +216,13 @@ Keep agents working inside project boundaries and away from sensitive files. ### `block-read-outside-cwd` **Event:** PreToolUse (Read, Bash) -**Default:** Denies reading files outside the current working directory (the project root). +**Default:** Denies reading files outside the project root. The boundary is `CLAUDE_PROJECT_DIR` (set once per session by Claude Code), with a fallback to the session's current working directory when that variable is unset. Using the project root rather than the live `cwd` means the boundary stays stable even after Claude `cd`s into a subdirectory. **Parameters:** | Param | Type | Default | Description | |-------|------|---------|-------------| -| `allowPaths` | `string[]` | `[]` | Absolute path prefixes that are permitted even if outside cwd. | +| `allowPaths` | `string[]` | `[]` | Absolute path prefixes that are permitted even if outside the project root. | **Example:** diff --git a/src/hooks/builtin-policies.ts b/src/hooks/builtin-policies.ts index 6ab6eda4..0555220c 100644 --- a/src/hooks/builtin-policies.ts +++ b/src/hooks/builtin-policies.ts @@ -676,7 +676,9 @@ function extractAbsolutePaths(command: string): string[] { } function blockReadOutsideCwd(ctx: PolicyContext): PolicyResult { - const cwd = ctx.session?.cwd; + // Prefer $CLAUDE_PROJECT_DIR (stable project root) over ctx.session.cwd, + // which tracks the live shell CWD and drifts when Claude `cd`s into a subdir. + const cwd = process.env.CLAUDE_PROJECT_DIR || ctx.session?.cwd; if (!cwd) return allow(); // Can't enforce without cwd const allowPaths = ((ctx.params?.allowPaths ?? []) as string[]);