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
14 changes: 7 additions & 7 deletions src/agent/tool-aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
withAuthzParityDefinitions,
WIRE_TO_ENGINE,
} from "./tool-aliases.js";
import { evaluateApprovals } from "../permission/authz-grants.js";
import { approvalCoversSubject } from "../permission/authz-grants.js";

const noWorkspace = { resolvedCwd: "/repo", roots: ["/repo"] };

Expand Down Expand Up @@ -55,7 +55,7 @@ describe("one advertised posix set", () => {
describe("grant aliases", () => {
test("a grant stored as run_shell covers bash", async () => {
expect(
await evaluateApprovals({
await approvalCoversSubject({
tool: "bash",
subject: "npm test",
approvals: [{ tool: "run_shell", pattern: "npm *" }],
Expand All @@ -66,7 +66,7 @@ describe("grant aliases", () => {

test("a grant stored as bash matches a run_shell request after canonicalize", async () => {
expect(
await evaluateApprovals({
await approvalCoversSubject({
tool: "run_shell",
subject: "npm test",
approvals: [{ tool: "bash", pattern: "npm *" }],
Expand All @@ -77,15 +77,15 @@ describe("grant aliases", () => {

test("pure renames stay bidirectional: read covers read_file and vice versa", async () => {
expect(
await evaluateApprovals({
await approvalCoversSubject({
tool: "read_file",
subject: "src/a.ts",
approvals: [{ tool: "read", pattern: "src/*" }],
workspace: noWorkspace,
}),
).toBe(true);
expect(
await evaluateApprovals({
await approvalCoversSubject({
tool: "read",
subject: "src/a.ts",
approvals: [{ tool: "read_file", pattern: "src/*" }],
Expand All @@ -102,7 +102,7 @@ describe("grant aliases", () => {
// update_plan keys, so that replay path is unreachable.
test("a stored update_plan grant does not cover manage_tasks", async () => {
expect(
await evaluateApprovals({
await approvalCoversSubject({
tool: "manage_tasks",
subject: "manage_tasks",
approvals: [{ tool: "update_plan", pattern: "*" }],
Expand All @@ -113,7 +113,7 @@ describe("grant aliases", () => {

test("a stored manage_tasks grant covers manage_tasks", async () => {
expect(
await evaluateApprovals({
await approvalCoversSubject({
tool: "manage_tasks",
subject: "manage_tasks",
approvals: [{ tool: "manage_tasks", pattern: "*" }],
Expand Down
2 changes: 1 addition & 1 deletion src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1527,7 +1527,7 @@ export async function createAgentToolset(
const failures: unknown[] = [];
// Kill live background process groups before the posix teardown so
// reload/interrupt cannot leave orphans behind.
backgroundShells.disposeAll("session closed");
backgroundShells.disposeAll();
try {
await posixTools.dispose();
} catch (err: unknown) {
Expand Down
15 changes: 4 additions & 11 deletions src/permission/authz-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "../agent/canonical-tool-name.js";
import type { Approval } from "./types.js";
import { directoryGrantAllows, matchesPattern } from "./matcher.js";
import { realpathOr } from "./worktree-roots.js";
import { realpathOrOriginal } from "./worktree-roots.js";

// Exact-escaped patterns (backslash before metacharacters) cannot round-trip
// through @intx/authz matchPattern; they skip the package call and fall to
Expand Down Expand Up @@ -65,7 +65,7 @@ export function cwdMatchesGrant(
if (requestCwd === undefined) return false;
if (grantCwd !== workspace.resolvedCwd) return false;
if (grantCwd === requestCwd) return true;
return workspace.roots.includes(realpathOr(requestCwd));
return workspace.roots.includes(realpathOrOriginal(requestCwd));
}

// Seeded grants enter in native key space: pure renames collapse onto the
Expand All @@ -84,8 +84,8 @@ export function normalizeSeededApprovals(
}

// Single owner for grant tool/providerModel/cwd scope coverage, independent
// of pattern matching. Both live call sites (evaluateApprovals,
// isRequestCoveredByGrant) delegate here so a scoping-dimension change lands
// of pattern matching. Both live call sites (approvalCoversSubject,
// isRequestCoveredByApprovals) delegate here so a scoping-dimension change lands
// in one place.
export function grantScopeMatches(
approval: Approval,
Expand Down Expand Up @@ -165,10 +165,3 @@ export async function approvalCoversSubject(
const decision = await evaluateGrants(grants, subject, action);
return decision.effect === "allow";
}

// Grant-store evaluation entry point; delegates to approvalCoversSubject.
export async function evaluateApprovals(
input: EvaluateApprovalsInput,
): Promise<boolean> {
return approvalCoversSubject(input);
}
18 changes: 9 additions & 9 deletions src/permission/gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { join } from "node:path";
import type { ToolCall } from "@intx/types/runtime";
import {
createPermissionGate,
isRequestCoveredByGrant,
isRequestCoveredByApproval,
preGrantGuardReason,
} from "./gate.js";
import { createPathRestriction } from "./path-restriction.js";
Expand All @@ -27,7 +27,7 @@ const shellCall = (command: string): ToolCall => ({
});

// Every pre-grant guard, keyed to a command that trips it.
// isRequestCoveredByGrant must refuse to cover each of these even when a
// isRequestCoveredByApproval must refuse to cover each of these even when a
// grant would otherwise match verbatim. The secret/restricted-path cases
// queue and could be auto-approved by reconciliation once a broad grant
// lands; the shell-authz hard-denies are unreachable today (evaluate()
Expand All @@ -44,7 +44,7 @@ const GUARD_CASES: { name: string; command: string }[] = [
{ name: "restricted path target", command: "cat /etc/passwd" },
];

describe("preGrantGuardReason / isRequestCoveredByGrant guard parity", () => {
describe("preGrantGuardReason / isRequestCoveredByApproval guard parity", () => {
const cwd = mkdtempSync(join(tmpdir(), "gate-guard-"));
const isRestricted = createPathRestriction(
cwd,
Expand All @@ -63,7 +63,7 @@ describe("preGrantGuardReason / isRequestCoveredByGrant guard parity", () => {
expect(preGrantGuardReason(request, isRestricted)).not.toBeUndefined();
});

test(`${name}: isRequestCoveredByGrant refuses an otherwise-matching grant`, () => {
test(`${name}: isRequestCoveredByApproval refuses an otherwise-matching grant`, () => {
const request: PermissionRequest = {
tool: "run_shell",
action: "Run",
Expand All @@ -73,7 +73,7 @@ describe("preGrantGuardReason / isRequestCoveredByGrant guard parity", () => {
};
const grant: Approval = { tool: "run_shell", pattern: command };
expect(
isRequestCoveredByGrant(request, grant, undefined, isRestricted, {
isRequestCoveredByApproval(request, grant, undefined, isRestricted, {
resolvedCwd: cwd,
roots: [],
}),
Expand Down Expand Up @@ -104,7 +104,7 @@ 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, {
isRequestCoveredByApproval(request, grant, undefined, isRestricted, {
resolvedCwd: cwd,
roots: [],
}),
Expand Down Expand Up @@ -265,7 +265,7 @@ describe("lone-& bypass at the gate (CL-7781)", () => {
const workspace = { resolvedCwd: cwd, roots: [] as string[] };
const grant: Approval = { tool: "run_shell", pattern: "bun test *" };
const covered = (subject: string): boolean =>
isRequestCoveredByGrant(
isRequestCoveredByApproval(
{ tool: "run_shell", action: "Run", subject, scopes: [], cwd },
grant,
undefined,
Expand Down Expand Up @@ -319,7 +319,7 @@ describe("grant coverage rebinds relative paths to the request process cwd", ()
};
const grant: Approval = { tool: "run_shell", pattern: "cat *" };
expect(
isRequestCoveredByGrant(request, grant, undefined, sessionRestricted, {
isRequestCoveredByApproval(request, grant, undefined, sessionRestricted, {
resolvedCwd: sessionCwd,
roots: [],
}),
Expand All @@ -337,7 +337,7 @@ describe("grant coverage rebinds relative paths to the request process cwd", ()
};
const grant: Approval = { tool: "run_shell", pattern: "cat *" };
expect(
isRequestCoveredByGrant(request, grant, undefined, sessionRestricted, {
isRequestCoveredByApproval(request, grant, undefined, sessionRestricted, {
resolvedCwd: sessionCwd,
roots: [],
}),
Expand Down
4 changes: 2 additions & 2 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ function bindRestrictedToProcessCwd(

// Hard-deny and forced-ask checks a run_shell request must clear before grant
// matching; no grant bypasses them. Shared by evaluate() and
// isRequestCoveredByGrant (via segmentGuard), so a guard added here applies to
// isRequestCoveredByApprovals (via segmentGuard), so a guard added here applies to
// fresh requests and reconciliation alike.
export function preGrantGuardReason(
request: PermissionRequest,
Expand Down Expand Up @@ -252,7 +252,7 @@ export function preGrantGuardReason(
// preGrantGuardReason sequence — so reconciliation never auto-approves
// something evaluate() would still ask for or hard-deny. run_shell coverage
// is per-segment; whole-string chain patterns never cover.
export function isRequestCoveredByGrant(
export function isRequestCoveredByApproval(
request: PermissionRequest,
approval: Approval,
activeProviderModel: string | undefined,
Expand Down
31 changes: 15 additions & 16 deletions src/permission/grant-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,19 @@ import type { ToolCall } from "@intx/types/runtime";
import type { Approval, PermissionRequest } from "./types.js";
import {
approvalCoversSubject,
evaluateApprovals,
grantScopeMatches,
normalizeSeededApprovals,
cwdMatchesGrant,
type GrantWorkspace,
} from "./authz-grants.js";
import { createPermissionGate, isRequestCoveredByGrant } from "./gate.js";
import { createPermissionGate, isRequestCoveredByApproval } from "./gate.js";
import { createPermissionRequestQueue } from "./queue.js";
import { buildRequests } from "./classify.js";

// approvalCoversSubject is the single grant-evaluation owner; evaluateApprovals
// delegates to it and isRequestCoveredByGrant must agree on scope. Driving
// the same grant+request pairs through all three catches a call site that
// reimplements the check with subtly different semantics.
// approvalCoversSubject is the single grant-evaluation owner;
// isRequestCoveredByApproval must agree on scope. Driving the same grant+request
// pairs through both catches a call site that reimplements the check with
// subtly different semantics.
describe("grant tool/providerModel/cwd scoping agrees across call sites", () => {
const workspace: GrantWorkspace = { resolvedCwd: "/proj", roots: ["/proj"] };
const noopRestricted = () => false;
Expand Down Expand Up @@ -70,7 +69,7 @@ describe("grant tool/providerModel/cwd scoping agrees across call sites", () =>
scopes: [],
...(req.cwd !== undefined ? { cwd: req.cwd } : {}),
};
const viaGate = isRequestCoveredByGrant(
const viaGate = isRequestCoveredByApproval(
request,
grant,
req.activeProviderModel,
Expand Down Expand Up @@ -151,7 +150,7 @@ describe("normalizeSeededApprovals", () => {

// mintGrant decomposes a chain scope into one grant per real segment; a grant
// patterned on the full chain is legacy shape and never replays, because
// evaluate() and isRequestCoveredByGrant both match per segment only.
// evaluate() and isRequestCoveredByApproval both match per segment only.
describe("a scope-mismatched grant never replays a multi-segment chain", () => {
const full = "npm i && curl x";
const shellCall = (command: string): ToolCall => ({
Expand Down Expand Up @@ -247,7 +246,7 @@ describe("legacy whole-string chain grants are explicitly rejected", () => {
expect(asked).toBe(1);
});

test("isRequestCoveredByGrant refuses a legacy full-chain pattern", () => {
test("isRequestCoveredByApproval refuses a legacy full-chain pattern", () => {
const request: PermissionRequest = {
tool: "run_shell",
action: "Run",
Expand All @@ -256,7 +255,7 @@ describe("legacy whole-string chain grants are explicitly rejected", () => {
cwd: "/proj",
};
expect(
isRequestCoveredByGrant(
isRequestCoveredByApproval(
request,
{ tool: "run_shell", pattern: full },
undefined,
Expand All @@ -275,7 +274,7 @@ describe("legacy whole-string chain grants are explicitly rejected", () => {
cwd: "/proj",
};
expect(
isRequestCoveredByGrant(
isRequestCoveredByApproval(
request,
{ tool: "run_shell", pattern: "npm i" },
undefined,
Expand Down Expand Up @@ -429,9 +428,9 @@ describe("foreign grant cwd matching request cwd under a different workspace is
});

test("both live call sites refuse a foreign grant replaying into /proj's workspace", async () => {
// evaluateApprovals
// approvalCoversSubject
expect(
await evaluateApprovals({
await approvalCoversSubject({
tool: "run_shell",
subject: "npm test",
approvals: [foreignGrant],
Expand All @@ -440,7 +439,7 @@ describe("foreign grant cwd matching request cwd under a different workspace is
}),
).toBe(false);

// isRequestCoveredByGrant (request cwd coincides with the foreign grant's cwd)
// isRequestCoveredByApproval (request cwd coincides with the foreign grant's cwd)
const request: PermissionRequest = {
tool: "run_shell",
action: "Run",
Expand All @@ -449,7 +448,7 @@ describe("foreign grant cwd matching request cwd under a different workspace is
cwd: "/foreign",
};
expect(
isRequestCoveredByGrant(
isRequestCoveredByApproval(
request,
foreignGrant,
undefined,
Expand All @@ -461,7 +460,7 @@ describe("foreign grant cwd matching request cwd under a different workspace is

test("the same grant with its own workspace still replays the session root", async () => {
expect(
await evaluateApprovals({
await approvalCoversSubject({
tool: "run_shell",
subject: "npm test",
approvals: [ownGrant],
Expand Down
27 changes: 9 additions & 18 deletions src/permission/path-restriction.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { lstatSync, readlinkSync, realpathSync } from "node:fs";
import { dirname, join, resolve, sep } from "node:path";
import { homedir } from "node:os";
import type { RootsProvider } from "./worktree-roots.js";
import { realpathOrOriginal, type RootsProvider } from "./worktree-roots.js";
import { projectSessionsRoot } from "../session/project-key.js";

// Paths the agent must not touch without explicit operator approval, even
Expand All @@ -26,14 +26,6 @@ export interface PathRestriction {

const LEGACY_STATE_DIR = ".agent-state";

function realpathOr(path: string): string {
try {
return realpathSync(path);
} catch {
return path;
}
}

// Sentinel from realpathNearestOr for a path that exists but cannot be
// resolved (dangling symlink or loop). Contains a NUL byte, which can never
// appear in a real path, so it cannot collide with a genuine result.
Expand Down Expand Up @@ -154,10 +146,9 @@ function isResolvedPathInWorkspace(
rootsProvider: RootsProvider,
): boolean {
if (real === UNRESOLVABLE) return false;
const realCwd = realpathOr(resolve(cwd));
const realCwd = realpathOrOriginal(resolve(cwd));
if (real === realCwd || real.startsWith(realCwd + sep)) return true;
if (inKnownRoots(real, rootsProvider())) return true;
if (inKnownRoots(real, rootsProvider(true))) return true;
return false;
}

Expand All @@ -180,14 +171,14 @@ export function isPermittedSiblingWorktreePath(
if (path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path)) return false;

const abs = resolve(cwd, path);
const realParent = realpathOr(dirname(abs));
const realHome = realpathOr(resolve(home));
const realParent = realpathOrOriginal(dirname(abs));
const realHome = realpathOrOriginal(resolve(home));
if (realParent === realHome) return false;

const knownRoots = [...rootsProvider(), ...rootsProvider(true)];
const knownRoots = rootsProvider();
const trustedParents = new Set<string>([
realpathOr(resolve(cwd, "..")),
...knownRoots.map((root) => realpathOr(dirname(root))),
realpathOrOriginal(resolve(cwd, "..")),
...knownRoots.map((root) => realpathOrOriginal(dirname(root))),
]);
return trustedParents.has(realParent);
}
Expand Down Expand Up @@ -226,8 +217,8 @@ export function createPathRestriction(
isRestricted: (path: string, isWrite: boolean): boolean => {
const abs = resolve(cwd, path);
const cacheKey = `${isWrite ? "w" : "r"}:${abs}`;
// Use realpathNearestOr rather than realpathOr: for a not-yet-created
// target, realpathOr returns the raw path unchanged — the same cache key
// Use realpathNearestOr rather than realpathOrOriginal: for a not-yet-created
// target, realpathOrOriginal returns the raw path unchanged — the same cache key
// before and after a symlink retarget. realpathNearestOr resolves the
// nearest existing ancestor, so the key changes when a symlink flips.
const currentRealpath = realpathNearestOr(abs);
Expand Down
Loading
Loading