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
41 changes: 39 additions & 2 deletions src/permission/authz-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { evaluateGrants, type GrantRule } from "@intx/authz";

import type { Approval } from "./types.js";
import { matchesPattern } from "./matcher.js";
import { realpathOr } from "./worktree-roots.js";

// Exact-escaped patterns (backslash before metacharacters) cannot round-trip
// through @intx/authz matchPattern, so those grants are filtered out of the
Expand All @@ -25,12 +26,48 @@ export function approvalToGrantRule(approval: Approval, index: number): GrantRul
};
}

// The gate's own project boundary: the session root it was constructed with,
// plus every git worktree registered against that root (which, per CL-4929,
// may live outside the root entirely — a sibling directory, not a
// subdirectory). Built once per gate from its closed-over resolvedCwd and
// rootsProvider and threaded through — never accept one built anywhere else,
// or "same project" quietly stops meaning "same gate's project."
export type GrantWorkspace = { resolvedCwd: string; roots: readonly string[] };

// A project-scoped grant (Approval.cwd set) is confined to the session that
// minted it: it may replay only for a request whose cwd is that same session
// root, or one of the root's registered worktrees. A worktree cwd never
// equals the session root by string identity (that's the bug this closes),
// so membership is resolved through `workspace` instead of a bare `===`.
//
// `grantCwd !== workspace.resolvedCwd` is the boundary: a grant stamped with
// some OTHER project's root is rejected before roots are ever consulted, so
// a request cwd that happens to coincide with a different project's worktree
// can never match. Membership within a matching project is exact equality
// against the resolved roots, never a path-prefix — a prefix check would let
// a maliciously named sibling directory (`/repo/wt-1-evil`) match a
// legitimate root (`/repo/wt-1`). `workspace.roots` already comes back
// realpath-resolved (see worktree-roots.ts); `requestCwd` is realpath'd here
// so a symlinked checkout (macOS /tmp vs /private/tmp) still compares equal.
export function cwdMatchesGrant(
grantCwd: string | undefined,
requestCwd: string | undefined,
workspace: GrantWorkspace,
): boolean {
if (grantCwd === undefined) return true;
if (requestCwd === undefined) return false;
if (grantCwd === requestCwd) return true;
if (grantCwd !== workspace.resolvedCwd) return false;
return workspace.roots.includes(realpathOr(requestCwd));
}

export type EvaluateApprovalsInput = {
tool: string;
subject: string;
approvals: readonly Approval[];
activeProviderModel?: string | undefined;
requestCwd?: string | undefined;
workspace: GrantWorkspace;
};

// Grant-store evaluation via @intx/authz. Filters provider-model and cwd the
Expand All @@ -39,12 +76,12 @@ export type EvaluateApprovalsInput = {
// matchesPattern (equality after unescape) first so a stored exact command is
// never lost.
export async function evaluateApprovals(input: EvaluateApprovalsInput): Promise<boolean> {
const { tool, subject, approvals, activeProviderModel, requestCwd } = input;
const { tool, subject, approvals, activeProviderModel, requestCwd, workspace } = input;
const scoped = approvals.filter(
(a) =>
a.tool === tool &&
(a.providerModel === undefined || a.providerModel === activeProviderModel) &&
(a.cwd === undefined || a.cwd === requestCwd),
cwdMatchesGrant(a.cwd, requestCwd, workspace),
);
if (scoped.length === 0) return false;

Expand Down
22 changes: 18 additions & 4 deletions src/permission/gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ describe("preGrantGuardReason / isRequestCoveredByGrant guard parity", () => {
cwd,
};
const grant: Approval = { tool: "run_shell", pattern: command };
expect(isRequestCoveredByGrant(request, grant, undefined, isRestricted)).toBe(false);
expect(
isRequestCoveredByGrant(request, grant, undefined, isRestricted, { resolvedCwd: cwd, roots: [] }),
).toBe(false);
});

test(`${name}: evaluate() never allows outright`, async () => {
Expand All @@ -85,7 +87,9 @@ describe("preGrantGuardReason / isRequestCoveredByGrant guard parity", () => {
};
expect(preGrantGuardReason(request, isRestricted)).toBeUndefined();
const grant: Approval = { tool: "run_shell", pattern: "npm test" };
expect(isRequestCoveredByGrant(request, grant, undefined, isRestricted)).toBe(true);
expect(
isRequestCoveredByGrant(request, grant, undefined, isRestricted, { resolvedCwd: cwd, roots: [] }),
).toBe(true);
});
});

Expand Down Expand Up @@ -122,7 +126,12 @@ describe("grant coverage rebinds relative paths to the request process cwd", ()
cwd: agentCwd,
};
const grant: Approval = { tool: "run_shell", pattern: "cat *" };
expect(isRequestCoveredByGrant(request, grant, undefined, sessionRestricted)).toBe(false);
expect(
isRequestCoveredByGrant(request, grant, undefined, sessionRestricted, {
resolvedCwd: sessionCwd,
roots: [],
}),
).toBe(false);
});

test("a relative path inside the registered worktree is not forced-restricted", () => {
Expand All @@ -135,6 +144,11 @@ describe("grant coverage rebinds relative paths to the request process cwd", ()
cwd: agentCwd,
};
const grant: Approval = { tool: "run_shell", pattern: "cat *" };
expect(isRequestCoveredByGrant(request, grant, undefined, sessionRestricted)).toBe(true);
expect(
isRequestCoveredByGrant(request, grant, undefined, sessionRestricted, {
resolvedCwd: sessionCwd,
roots: [],
}),
).toBe(true);
});
});
33 changes: 24 additions & 9 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { autoShellRuleForCall } from "./auto-shell-policy.js";
import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js";
import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js";
import { matchesPattern, escapeGlobLiteral } from "./matcher.js";
import { evaluateApprovals } from "./authz-grants.js";
import { evaluateApprovals, cwdMatchesGrant, type GrantWorkspace } from "./authz-grants.js";
import { splitChainedCommand, tokenize, isShellCommentOnly, stripCommentLines } from "./command.js";
import { createPathRestriction } from "./path-restriction.js";
import { createWorktreeRootsProvider, type RootsProvider } from "./worktree-roots.js";
Expand Down Expand Up @@ -51,6 +51,7 @@ function hasExactFullCommandGrant(
approvals: readonly Approval[],
activeProviderModel: string | undefined,
requestCwd: string | undefined,
workspace: GrantWorkspace,
): boolean {
// Comment-insensitive: a model-authored "# why" line prepended to an
// otherwise-identical command must still replay against a grant minted
Expand All @@ -62,7 +63,7 @@ function hasExactFullCommandGrant(
a.tool === tool &&
a.pattern === normalized &&
(a.providerModel === undefined || a.providerModel === activeProviderModel) &&
(a.cwd === undefined || a.cwd === requestCwd),
cwdMatchesGrant(a.cwd, requestCwd, workspace),
);
}

Expand Down Expand Up @@ -142,9 +143,10 @@ export function isRequestCoveredByGrant(
approval: Approval,
activeProviderModel: string | undefined,
isRestricted: (path: string, isWrite: boolean) => boolean,
workspace: GrantWorkspace,
): boolean {
if (request.tool !== approval.tool) return false;
if (approval.cwd !== undefined && approval.cwd !== request.cwd) return false;
if (!cwdMatchesGrant(approval.cwd, request.cwd, workspace)) return false;
if (
approval.providerModel !== undefined &&
approval.providerModel !== activeProviderModel
Expand Down Expand Up @@ -268,11 +270,15 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
const { requestApproval, persist, interactive, skipPermissions, providerName, model, cwd } = options;
const mcpTiers = options.mcpTiers ?? createMcpToolPermissionRegistry();
const resolvedCwd = cwd ?? process.cwd();
const pathRestriction = createPathRestriction(
resolvedCwd,
options.rootsProvider ?? createWorktreeRootsProvider(resolvedCwd),
);
const rootsProvider = options.rootsProvider ?? createWorktreeRootsProvider(resolvedCwd);
const pathRestriction = createPathRestriction(resolvedCwd, rootsProvider);
const isRestricted = pathRestriction.isRestricted;
// This gate's project boundary for grant matching (see cwdMatchesGrant):
// this session's root plus its currently-known registered worktrees. Built
// fresh per read from the same rootsProvider the gate already uses for
// path containment, so "same project" for a grant and "inside the
// workspace" for a path share one authority.
const grantWorkspace = (): GrantWorkspace => ({ resolvedCwd, roots: rootsProvider() });
let auto = options.auto;
// Own a private copy so evaluating a grant never mutates the caller's array.
const approvals: Approval[] = [...options.approvals];
Expand Down Expand Up @@ -309,7 +315,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
persist?.(approval, grant);
}
options.onGrant?.(approval, (request) =>
isRequestCoveredByGrant(request, approval, activeProviderModel, isRestricted),
isRequestCoveredByGrant(request, approval, activeProviderModel, isRestricted, grantWorkspace()),
);
};

Expand Down Expand Up @@ -405,7 +411,14 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
!fullReferencesSecret &&
!commandTargetsRestricted(fullCommand, isRestrictedHere) &&
segments.length > 1 &&
hasExactFullCommandGrant(request.tool, fullCommand, approvals, activeProviderModel, effectiveCwd)
hasExactFullCommandGrant(
request.tool,
fullCommand,
approvals,
activeProviderModel,
effectiveCwd,
grantWorkspace(),
)
) {
continue;
}
Expand All @@ -432,6 +445,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
approvals,
activeProviderModel,
requestCwd: effectiveCwd,
workspace: grantWorkspace(),
})
) {
continue;
Expand Down Expand Up @@ -502,6 +516,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
approvals,
activeProviderModel,
requestCwd: effectiveCwd,
workspace: grantWorkspace(),
});
if (alreadyApproved) {
continue;
Expand Down
Loading
Loading