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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
75 changes: 74 additions & 1 deletion __tests__/hooks/block-read-outside-cwd.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,6 +18,19 @@ function makeCtx(overrides: Partial<PolicyContext>): 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);
Expand Down Expand Up @@ -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");
});
});
4 changes: 2 additions & 2 deletions docs/built-in-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down
4 changes: 3 additions & 1 deletion src/hooks/builtin-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]);
Expand Down
Loading