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
16 changes: 13 additions & 3 deletions src/permission/authz-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
grantToolCovers,
} from "../agent/canonical-tool-name.js";
import type { Approval } from "./types.js";
import { matchesPattern } from "./matcher.js";
import { directoryGrantAllows, matchesPattern } from "./matcher.js";
import { realpathOr } from "./worktree-roots.js";

// Exact-escaped patterns (backslash before metacharacters) cannot round-trip
Expand Down Expand Up @@ -147,7 +147,17 @@ export async function approvalCoversSubject(
);
if (scoped.length === 0) return false;

for (const a of scoped) {
// Directory Always grants (`<dir>/*`) cannot reach the package evaluator
// with a `..` walk-out subject: package `*` matches `..` lexically, so the
// same containment gate matchesPattern enforces applies here first. The
// gate only denies directory escapes; every other grant defers untouched.
const effectiveCwd = requestCwd ?? workspace.resolvedCwd;
const contained = scoped.filter((a) =>
directoryGrantAllows(a.pattern, subject, effectiveCwd),
);
if (contained.length === 0) return false;

for (const a of contained) {
if (
!isPackageCompatiblePattern(a.pattern) &&
matchesPattern(subject, a.pattern)
Expand All @@ -156,7 +166,7 @@ export async function approvalCoversSubject(
}
}

const grants = scoped
const grants = contained
.filter((a) => isPackageCompatiblePattern(a.pattern))
.map((a, i) => approvalToGrantRule(a, i));
if (grants.length === 0) return false;
Expand Down
3 changes: 2 additions & 1 deletion src/permission/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
runShellAuthzSegmentBlockReason,
} from "../shell/run-shell-authz.js";
import { resolveWorkspacePath } from "./path-restriction.js";
import { normalizeGrantPath } from "./matcher.js";
import type { RootsProvider } from "./worktree-roots.js";
import {
isProductMutationTool,
Expand Down Expand Up @@ -551,7 +552,7 @@ function fileScopes(path: string): ApprovalScope[] {
];
const slash = path.lastIndexOf("/");
if (slash > 0) {
const dir = path.slice(0, slash);
const dir = normalizeGrantPath(path.slice(0, slash));
scopes.push({
id: "dir",
label: `Allow Always (this directory)`,
Expand Down
11 changes: 9 additions & 2 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,13 @@ function isRequestCoveredByApprovals(
);
if (scoped.length === 0) return false;
if (request.tool !== "run_shell") {
return scoped.some((a) => matchesPattern(request.subject, a.pattern));
return scoped.some((a) =>
matchesPattern(
request.subject,
a.pattern,
request.cwd ?? workspace.resolvedCwd,
),
);
}
if (
preGrantGuardReason(request, isRestricted, rootsProvider, isExtraDenied) !==
Expand All @@ -340,7 +346,8 @@ function isRequestCoveredByApprovals(
if (segments.length === 0) return false;
const cwd = request.cwd ?? workspace.resolvedCwd;
return segments.every((segment) => {
if (scoped.some((a) => matchesPattern(segment, a.pattern))) return true;
if (scoped.some((a) => matchesPattern(segment, a.pattern, cwd)))
return true;
return isAutoAllowedShellSegment(
segment,
cwd,
Expand Down
50 changes: 49 additions & 1 deletion src/permission/matcher.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { posix } from "node:path";
import { matchPattern } from "@intx/authz";

// Exact-command grants (see escapeGlobLiteral) store a backslash before every
Expand Down Expand Up @@ -30,9 +31,56 @@ export function escapeGlobLiteral(text: string): string {
return text.replace(/[\\*?]/g, "\\$&");
}

export function matchesPattern(subject: string, pattern: string): boolean {
export function matchesPattern(
subject: string,
pattern: string,
cwd?: string,
): boolean {
if (isExactEscapedPattern(pattern)) {
return subject === unescapeExactPattern(pattern);
}
if (!directoryGrantAllows(pattern, subject, cwd)) return false;
return matchPattern(pattern, subject);
}

// Lexically normalize a grant path (POSIX, no fs I/O): collapse `.`, `..`,
// and duplicate slashes. Absolute paths normalize in place; relative paths
// resolve against cwd when one is supplied, else collapse in place. Shared by
// the matcher gate below and file-scope minting so both sides agree on what
// a directory grant covers.
export function normalizeGrantPath(path: string, cwd?: string): string {
if (posix.isAbsolute(path)) return posix.normalize(path);
if (cwd !== undefined) return posix.normalize(posix.resolve(cwd, path));
return posix.normalize(path);
}

// Containment gate for Directory Always grants (`<dir>/*`): the package
// `*` matches `..` lexically, so a subject like `/proj/sub/../evil` would
// otherwise match `/proj/sub/*` and escape the granted directory. A
// normalized subject must sit strictly under the anchor (`anchor/` prefix),
// or the grant does not cover it — the grant covers directory contents, so
// even the anchor itself (`/proj/sub/.` normalizes to `/proj/sub`) does not
// match. Returns true for every non-directory pattern (exact-escaped,
// non-`/*`, or non-literal anchors) so those defer to their existing matcher
// untouched — the gate only ever denies, never allows.
export function directoryGrantAllows(
pattern: string,
subject: string,
cwd?: string,
): boolean {
if (pattern.includes("\\")) return true;
if (!pattern.endsWith("/*")) return true;
const anchor = pattern.slice(0, -2);
if (anchor.includes("*") || anchor.includes("?")) return true;
const base = anchor === "" ? "/" : anchor;
const normalizedAnchor = stripTrailingSlash(normalizeGrantPath(base, cwd));
const normalizedSubject = stripTrailingSlash(
normalizeGrantPath(subject, cwd),
);
const prefix = normalizedAnchor === "/" ? "/" : `${normalizedAnchor}/`;
return normalizedSubject.startsWith(prefix);
}

function stripTrailingSlash(path: string): string {
return path.length > 1 ? path.replace(/\/+$/, "") : path;
}
77 changes: 77 additions & 0 deletions src/permission/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,51 @@ describe("matchesPattern (@intx/authz + exact escapes)", () => {
});
});

describe("matchesPattern directory-grant traversal (CL-8989)", () => {
// A Directory Always grant (`/proj/sub/*`) must never authorize a subject
// that lexically escapes the granted directory, even though `*` matches
// `/`, `.`, and `..` at the string level. Legit depth semantics (`*`
// crosses `/`) are locked in first so the fix cannot narrow real grants.
const grant = "/proj/sub/*";

test("* still crosses directories for legit paths", () => {
expect(matchesPattern("/proj/sub/file.txt", grant)).toBe(true);
expect(matchesPattern("/proj/sub/nested/file.txt", grant)).toBe(true);
});

test(".. walk-out subjects do not match the directory grant", () => {
expect(matchesPattern("/proj/sub/../evil", grant)).toBe(false);
expect(matchesPattern("/proj/sub/../../etc/passwd", grant)).toBe(false);
expect(matchesPattern("/proj/sub/.", grant)).toBe(false);
expect(matchesPattern("/proj/sub//../evil", grant)).toBe(false);
});

test("trailing-slash anchor spellings do not match the directory grant", () => {
expect(matchesPattern("/proj/sub/", grant)).toBe(false);
expect(matchesPattern("/proj/sub//", grant)).toBe(false);
});

test("relative subjects resolve against cwd through the gate", () => {
expect(matchesPattern("sub/file.txt", "sub/*", "/proj")).toBe(true);
expect(matchesPattern("sub/", "sub/*", "/proj")).toBe(false);
expect(matchesPattern("sub", "sub/*", "/proj")).toBe(false);
});

test("a .. subject that normalizes back inside still matches", () => {
expect(matchesPattern("/proj/sub/../sub/file.txt", grant)).toBe(true);
});

test("sibling-prefix paths do not match the directory grant", () => {
expect(matchesPattern("/proj/sub-evil/file.txt", grant)).toBe(false);
expect(matchesPattern("/proj/subfile.txt", grant)).toBe(false);
});

test("non-path wildcard grants keep their existing semantics", () => {
expect(matchesPattern("npm exec vite", "npm *")).toBe(true);
expect(matchesPattern("src/a.ts", "src/*")).toBe(true);
});
});

describe("evaluateApprovals (@intx/authz evaluateGrants)", () => {
const approvals: Approval[] = [
{ tool: "run_shell", pattern: "npm *" },
Expand Down Expand Up @@ -394,6 +439,38 @@ describe("evaluateApprovals (@intx/authz evaluateGrants)", () => {
).toBe(true);
});

test("a directory grant denies .. walk-out through the package path (CL-8989)", async () => {
// evaluateApprovals delegates package-compatible grants to @intx/authz
// evaluateGrants, whose `*` matches `..` lexically — the containment gate
// must hold on this path too, while legit nested paths still match.
const dirApprovals: Approval[] = [
{ tool: "write_file", pattern: "/proj/sub/*" },
];
const workspace = { resolvedCwd: "/proj", roots: [] as string[] };
expect(
await evaluateApprovals({
tool: "write_file",
subject: "/proj/sub/nested/file.txt",
approvals: dirApprovals,
workspace,
}),
).toBe(true);
for (const subject of [
"/proj/sub/../evil",
"/proj/sub/../../etc/passwd",
"/proj/sub//../evil",
]) {
expect(
await evaluateApprovals({
tool: "write_file",
subject,
approvals: dirApprovals,
workspace,
}),
).toBe(false);
}
});

test("a grant for read_file covers default.read_file", async () => {
expect(
await evaluateApprovals({
Expand Down
Loading