-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathpath-escape-plugin.ts
More file actions
95 lines (90 loc) · 2.86 KB
/
Copy pathpath-escape-plugin.ts
File metadata and controls
95 lines (90 loc) · 2.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import { resolve } from "node:path";
import type { ToolPlugin } from "@intx/tools-posix";
import type { ToolCall, ToolResult } from "@intx/types/runtime";
import { isToolOutputLike } from "../util/tool-output-uri.js";
import { resolveWorkspacePath } from "../permission/path-restriction.js";
import type { RootsProvider } from "../permission/worktree-roots.js";
export type PathEscapeOptions = {
// When true (yolo / --dangerously-skip-permissions), paths outside the
// workspace still resolve to absolute form and pass through. Secret-guard and
// authz remain the hard-deny layers; the permission gate already auto-allows.
allowOutside?: boolean;
};
export function pathEscapePlugin(
cwd: string,
rootsProvider: RootsProvider = () => [],
options: PathEscapeOptions = {},
): ToolPlugin {
const allowOutside = options.allowOutside === true;
return {
middleware: (next) => async (call, signal) => {
if ("_raw" in call.arguments) {
return {
callId: call.id,
content: "Tool call arguments were malformed JSON (likely truncated). Retry with a smaller payload.",
isError: true,
};
}
let escaped: Record<string, unknown>;
try {
escaped = escapeArgs(call.arguments, cwd, rootsProvider, allowOutside);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { callId: call.id, content: message, isError: true };
}
return next({ ...call, arguments: escaped }, signal);
},
};
}
function escapeArgs(
args: Record<string, unknown>,
cwd: string,
rootsProvider: RootsProvider,
allowOutside: boolean,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
if (typeof value === "string" && looksLikePath(key)) {
out[key] = sanitizePath(value, cwd, rootsProvider, allowOutside);
} else {
out[key] = value;
}
}
return out;
}
export function looksLikePath(key: string): boolean {
return (
key === "path" ||
key === "file_path" ||
key === "target" ||
key === "cwd" ||
key === "directory" ||
key === "dir" ||
key === "dest" ||
key === "source" ||
key === "from" ||
key === "to" ||
key === "filename" ||
key.endsWith("Path")
);
}
function sanitizePath(
value: string,
cwd: string,
rootsProvider: RootsProvider,
allowOutside: boolean,
): string {
if (isToolOutputLike(value)) {
return value;
}
const resolved = resolveWorkspacePath(cwd, value, rootsProvider);
if (resolved !== undefined) {
return resolved;
}
if (allowOutside) {
// Same lexical resolve as resolveWorkspacePath's in-bounds branch — absolute
// so later plugins see a stable path, not a relative escape fragment.
return resolve(cwd, value);
}
throw new Error(`Path escapes working directory: ${value}`);
}