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
23 changes: 6 additions & 17 deletions src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
} from "./auto-shell-policy.js";
import { commandReferencesSensitivePath } from "../plugins/secret-guard-plugin.js";
import {
looksLikePath,
normalizePathArguments,
pathEscapeBlockReason,
} from "../plugins/path-escape-plugin.js";
import { runShellAuthzBlockReason } from "../shell/run-shell-authz.js";
Expand All @@ -38,10 +38,7 @@ import {
tokenize,
stripCommentLines,
} from "./command.js";
import {
createPathRestriction,
resolveWorkspacePath,
} from "./path-restriction.js";
import { createPathRestriction } from "./path-restriction.js";
import {
createWorktreeRootsProvider,
type RootsProvider,
Expand Down Expand Up @@ -475,23 +472,15 @@ function canSafelyMintPerSegment(pattern: string): boolean {
}

// posix pathEscapePlugin rewrites path-like args to resolveWorkspacePath before
// gateToolCall. Cache identity must use that same resolution so an authorizeCall
// allow is not treated as a different call (and re-decided) at execution.
// gateToolCall. Cache identity must use that same resolution — deep, like the
// plugin's escapeValue walk — so an authorizeCall allow is not treated as a
// different call (and re-decided) at execution.
function identityArguments(
args: ToolCall["arguments"],
cwd: string,
rootsProvider: RootsProvider,
): string {
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string" && looksLikePath(key)) {
normalized[key] =
resolveWorkspacePath(cwd, value, rootsProvider) ?? value;
} else {
normalized[key] = value;
}
}
return JSON.stringify(normalized);
return JSON.stringify(normalizePathArguments(args, cwd, rootsProvider));
}

export function createPermissionGate(
Expand Down
41 changes: 41 additions & 0 deletions src/permission/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,47 @@ describe("gate denies path tools path-escape will reject", () => {
});
});

describe("gate cache identity matches the plugin rewrite for nested paths", () => {
// authorizeCall caches by identityArguments; executionVerdict must hit that
// cache when execution hands it the plugin-rewritten (workspace-absolute)
// arguments. A grant seeded after authorize changes what a fresh decide
// would say, so a miss visibly flips to allow while a hit reuses the ask.
test("nested in-bounds call authorizes once and executes without re-decide", async () => {
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "corbits-identity-")));
const gate = createPermissionGate({
approvals: [],
cwd,
requestApproval: async () => ({ allow: false }),
interactive: true,
skipPermissions: false,
reactorGated: true,
});
const call: ToolCall = {
id: "c",
name: "write_file",
arguments: {
path: "notes.txt",
options: { path: "notes.txt" },
content: "x",
},
};
const authorized = await gate.authorizeCall(call);
expect(authorized.effect).toBe("ask");
gate.setSeededApprovals([
{ tool: "write_file", pattern: join(cwd, "notes.txt") },
]);
const executed = await gate.executionVerdict({
...call,
arguments: {
path: join(cwd, "notes.txt"),
options: { path: join(cwd, "notes.txt") },
content: "x",
},
});
expect(executed.effect).toBe("ask");
});
});

describe("createPermissionGate", () => {
test("allow-tier tools pass without asking", async () => {
let asked = 0;
Expand Down
167 changes: 166 additions & 1 deletion src/plugins/path-escape-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import { existsSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { pathEscapePlugin } from "./path-escape-plugin.js";
import {
normalizePathArguments,
pathEscapeBlockReason,
pathEscapePlugin,
} from "./path-escape-plugin.js";
import type { ToolCall, ToolResult } from "@intx/types/runtime";

function makeCall(name: string, args: Record<string, unknown>): ToolCall {
Expand Down Expand Up @@ -264,4 +268,165 @@ describe("pathEscapePlugin", () => {
await rm(outside, { recursive: true, force: true });
});
});

describe("nested and alternate path keys (CL-6730)", () => {
const captureNext = () => {
let seen: Record<string, unknown> = {};
const next = async (call: ToolCall): Promise<ToolResult> => {
seen = call.arguments as Record<string, unknown>;
return {
callId: call.id,
content: JSON.stringify(call.arguments),
};
};
return { next, seen: () => seen };
};

test("blocks escape in a nested object under a path-like key", async () => {
const plugin = pathEscapePlugin("/project");
const handler = plugin.middleware
? plugin.middleware(nextHandler)
: nextHandler;
const result = await handler(
makeCall("read_file", { options: { path: "../secret.txt" } }),
new AbortController().signal,
);
expect(result.isError).toBe(true);
expect(result.content).toMatch(/escapes working directory/);
});

test("resolves nested in-bounds paths instead of passing them through", async () => {
const plugin = pathEscapePlugin("/project");
const { next, seen } = captureNext();
const handler = plugin.middleware ? plugin.middleware(next) : next;
const result = await handler(
makeCall("read_file", { options: { path: "src/index.ts" } }),
new AbortController().signal,
);
expect(result.isError).not.toBe(true);
expect(seen()).toEqual({
options: { path: "/project/src/index.ts" },
});
});

test("blocks escape via the filepath spelling", async () => {
const plugin = pathEscapePlugin("/project");
const handler = plugin.middleware
? plugin.middleware(nextHandler)
: nextHandler;
const result = await handler(
makeCall("read_file", { filepath: "../secret.txt" }),
new AbortController().signal,
);
expect(result.isError).toBe(true);
expect(result.content).toMatch(/escapes working directory/);
});

test("blocks escape in a string array under a path-like key", async () => {
const plugin = pathEscapePlugin("/project");
const handler = plugin.middleware
? plugin.middleware(nextHandler)
: nextHandler;
const result = await handler(
makeCall("read_file", {
paths: ["src/index.ts", "../secret.txt"],
}),
new AbortController().signal,
);
expect(result.isError).toBe(true);
expect(result.content).toMatch(/escapes working directory/);
});

test("pathEscapeBlockReason agrees with execution time on nested escapes", async () => {
expect(
pathEscapeBlockReason(
{ options: { path: "../secret.txt" } },
"/project",
),
).toMatch(/escapes working directory/);
expect(
pathEscapeBlockReason({ filepath: "../secret.txt" }, "/project"),
).toMatch(/escapes working directory/);
expect(
pathEscapeBlockReason(
{ paths: ["src/index.ts", "../secret.txt"] },
"/project",
),
).toMatch(/escapes working directory/);
});

test("nested non-path keys pass through untouched (allowlist policy)", async () => {
const plugin = pathEscapePlugin("/project");
const { next, seen } = captureNext();
const handler = plugin.middleware ? plugin.middleware(next) : next;
const result = await handler(
makeCall("custom_tool", { options: { command: "../secret.txt" } }),
new AbortController().signal,
);
expect(result.isError).not.toBe(true);
expect(seen()).toEqual({ options: { command: "../secret.txt" } });
expect(
pathEscapeBlockReason(
{ options: { command: "../secret.txt" } },
"/project",
),
).toBeUndefined();
});

test("FILE_PATH and file-path match case- and separator-insensitively", async () => {
const plugin = pathEscapePlugin("/project");
const handler = plugin.middleware
? plugin.middleware(nextHandler)
: nextHandler;
for (const key of ["FILE_PATH", "file-path"]) {
const result = await handler(
makeCall("read_file", { [key]: "../secret.txt" }),
new AbortController().signal,
);
expect(result.isError).toBe(true);
expect(result.content).toMatch(/escapes working directory/);
expect(
pathEscapeBlockReason({ [key]: "../secret.txt" }, "/project"),
).toMatch(/escapes working directory/);
}
});

test("xpath, jsonpath, and classpath keys pass through untouched", async () => {
const plugin = pathEscapePlugin("/project");
const { next, seen } = captureNext();
const handler = plugin.middleware ? plugin.middleware(next) : next;
const args = {
xpath: "../../title",
jsonpath: "$.store.book",
classpath: "src/Main",
};
const result = await handler(
makeCall("custom_tool", args),
new AbortController().signal,
);
expect(result.isError).not.toBe(true);
expect(seen()).toEqual(args);
expect(pathEscapeBlockReason(args, "/project")).toBeUndefined();
});

test("normalizePathArguments shares the plugin rewrite identity", () => {
expect(
normalizePathArguments(
{ options: { path: "src/index.ts" } },
"/project",
() => [],
),
).toEqual({ options: { path: "/project/src/index.ts" } });
expect(
normalizePathArguments(
{ options: { command: "../secret.txt" } },
"/project",
() => [],
),
).toEqual({ options: { command: "../secret.txt" } });
expect(
normalizePathArguments({ xpath: "src/index.ts" }, "/project", () => []),
).toEqual({ xpath: "src/index.ts" });
});
});
});
Loading
Loading