@dieulc/workflow 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -14
- package/commit-policy.ts +74 -10
- package/edit-policy.ts +111 -44
- package/folder-policy.ts +1159 -0
- package/index.ts +1958 -258
- package/package.json +9 -2
- package/read-policy-redact.ts +319 -0
- package/read-policy-tools.ts +310 -0
- package/read-policy.ts +654 -0
- package/review-handoff.ts +3 -2
- package/review-recovery.ts +4 -2
- package/review-runtime.ts +142 -52
- package/review-tools.ts +169 -1
- package/review.ts +209 -31
- package/shell-paths.ts +1045 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +68 -0
- package/workflow-status.ts +22 -8
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dieulc/workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"keywords": ["pi-package", "workflow", "plan-mode", "review-mode", "questionnaire", "rewind"],
|
|
7
7
|
"description": "Plan↔Build mode with an independent Review Mode reviewer, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "node --test utils.todo.test.ts permissions.test.ts shell-text.test.ts roles.test.ts profiles.test.ts provider-retarget.test.ts thinking-levels.test.ts workflow-status.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-handoff.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts",
|
|
10
|
+
"test": "node --test utils.todo.test.ts todo-panel.test.ts permissions.test.ts shell-text.test.ts roles.test.ts profiles.test.ts provider-retarget.test.ts thinking-levels.test.ts workflow-status.test.ts picker.test.ts questionnaire-layout.test.ts questionnaire-view.test.ts model-default.test.ts review.test.ts review-handoff.test.ts review-ui.test.ts extension.test.ts commit-policy.test.ts edit-policy.test.ts tab-policy.test.ts tab-editor.test.ts folder-policy.test.ts shell-paths.test.ts read-policy.test.ts read-policy-tools.test.ts read-policy-redact.test.ts read-policy-gate.test.ts folder-floor-write.test.ts folder-floor-commit.test.ts",
|
|
11
11
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
12
12
|
"check:policy": "node --experimental-transform-types scripts/verify-write-policy.mjs",
|
|
13
|
+
"check:read-policy": "node --experimental-transform-types scripts/verify-read-policy.mjs",
|
|
13
14
|
"prepublishOnly": "node ../../../scripts/verify-packages.mjs --pack-only --packages workflow"
|
|
14
15
|
},
|
|
15
16
|
"repository": {
|
|
@@ -29,6 +30,11 @@
|
|
|
29
30
|
"utils.ts",
|
|
30
31
|
"checkpoint.ts",
|
|
31
32
|
"commit-policy.ts",
|
|
33
|
+
"folder-policy.ts",
|
|
34
|
+
"shell-paths.ts",
|
|
35
|
+
"read-policy.ts",
|
|
36
|
+
"read-policy-tools.ts",
|
|
37
|
+
"read-policy-redact.ts",
|
|
32
38
|
"edit-policy.ts",
|
|
33
39
|
"roles.ts",
|
|
34
40
|
"profiles.ts",
|
|
@@ -37,6 +43,7 @@
|
|
|
37
43
|
"tab-editor.ts",
|
|
38
44
|
"thinking-levels.ts",
|
|
39
45
|
"workflow-status.ts",
|
|
46
|
+
"todo-panel.ts",
|
|
40
47
|
"questionnaire-layout.ts",
|
|
41
48
|
"questionnaire-view.ts",
|
|
42
49
|
"model-default.ts",
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — Read-policy output redaction
|
|
3
|
+
*
|
|
4
|
+
* Defence in depth for channels the tool-call gate cannot fully see:
|
|
5
|
+
*
|
|
6
|
+
* - `grep` searches a directory with `rg --hidden`; a `.env` that is not
|
|
7
|
+
* gitignored would otherwise be returned in matched lines.
|
|
8
|
+
* - `find` / `ls` list sensitive names; listings omit them.
|
|
9
|
+
* - `bash` can print `git diff`/`git show` sections or `rg`-style lines.
|
|
10
|
+
* - Pi's truncated bash output writes the full text to a temp file and tells
|
|
11
|
+
* the model its path; when we redact, that escape hatch is removed.
|
|
12
|
+
* - a successful `read` of a sensitive path is replaced as a safety net.
|
|
13
|
+
*
|
|
14
|
+
* Textual redaction is best-effort (custom renderers/structured content could
|
|
15
|
+
* bypass); callers drop `structuredContent` whenever content changes.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
|
|
21
|
+
import type { FolderPolicyConfig } from "./folder-policy.ts";
|
|
22
|
+
import { matchSensitivePath, toPosixPath } from "./folder-policy.ts";
|
|
23
|
+
|
|
24
|
+
export interface RedactableBlock {
|
|
25
|
+
type: string;
|
|
26
|
+
text?: string;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RedactableToolResult {
|
|
31
|
+
toolName: string;
|
|
32
|
+
input: Record<string, unknown>;
|
|
33
|
+
content: RedactableBlock[];
|
|
34
|
+
details?: unknown;
|
|
35
|
+
structuredContent?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RedactedToolResult {
|
|
39
|
+
content?: RedactableBlock[];
|
|
40
|
+
details?: unknown;
|
|
41
|
+
structuredContent?: undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RedactTextResult {
|
|
45
|
+
text: string;
|
|
46
|
+
redacted: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function searchBase(input: Record<string, unknown>, cwd: string): string {
|
|
50
|
+
const raw = input.path;
|
|
51
|
+
if (typeof raw !== "string" || !raw.trim()) return cwd;
|
|
52
|
+
const resolved = path.resolve(cwd, raw);
|
|
53
|
+
try {
|
|
54
|
+
return fs.statSync(resolved).isDirectory() ? resolved : path.dirname(resolved);
|
|
55
|
+
} catch {
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isSensitive(pathValue: string, config: FolderPolicyConfig, cwd: string): boolean {
|
|
61
|
+
return matchSensitivePath(pathValue, config, cwd).sensitive;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveOutputPath(
|
|
65
|
+
raw: string,
|
|
66
|
+
base: string,
|
|
67
|
+
cwd: string,
|
|
68
|
+
): string {
|
|
69
|
+
if (path.isAbsolute(raw)) return raw;
|
|
70
|
+
const joined = path.resolve(base, raw);
|
|
71
|
+
// `grep` relativises against the search root; if the joined path does not
|
|
72
|
+
// exist, the raw path may already be cwd-relative.
|
|
73
|
+
try {
|
|
74
|
+
if (fs.existsSync(joined)) return joined;
|
|
75
|
+
} catch {
|
|
76
|
+
/* ignore */
|
|
77
|
+
}
|
|
78
|
+
return path.resolve(cwd, raw);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function redactGrep(
|
|
82
|
+
text: string,
|
|
83
|
+
input: Record<string, unknown>,
|
|
84
|
+
cwd: string,
|
|
85
|
+
config: FolderPolicyConfig,
|
|
86
|
+
): RedactTextResult {
|
|
87
|
+
const base = searchBase(input, cwd);
|
|
88
|
+
const out: string[] = [];
|
|
89
|
+
let redacted = false;
|
|
90
|
+
for (const line of text.split("\n")) {
|
|
91
|
+
const match = /^(.+?)(?::(\d+):|-(\d+)-)(.*)$/.exec(line);
|
|
92
|
+
if (!match) {
|
|
93
|
+
out.push(line);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const rawPath = match[1];
|
|
97
|
+
if (rawPath.includes("://")) {
|
|
98
|
+
out.push(line);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const resolved = resolveOutputPath(rawPath, base, cwd);
|
|
102
|
+
if (isSensitive(resolved, config, cwd)) {
|
|
103
|
+
redacted = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
out.push(line);
|
|
107
|
+
}
|
|
108
|
+
if (redacted && out.every((line) => !line.trim())) {
|
|
109
|
+
return {
|
|
110
|
+
text:
|
|
111
|
+
"[matches in sensitive files were withheld by read policy]",
|
|
112
|
+
redacted: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { text: out.join("\n"), redacted };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function redactFind(
|
|
119
|
+
text: string,
|
|
120
|
+
input: Record<string, unknown>,
|
|
121
|
+
cwd: string,
|
|
122
|
+
config: FolderPolicyConfig,
|
|
123
|
+
): RedactTextResult {
|
|
124
|
+
const base = searchBase(input, cwd);
|
|
125
|
+
const out: string[] = [];
|
|
126
|
+
let redacted = false;
|
|
127
|
+
for (const line of text.split("\n")) {
|
|
128
|
+
const trimmed = line.trim();
|
|
129
|
+
if (!trimmed) {
|
|
130
|
+
out.push(line);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const resolved = resolveOutputPath(trimmed.replace(/\/$/, ""), base, cwd);
|
|
134
|
+
if (isSensitive(resolved, config, cwd)) {
|
|
135
|
+
redacted = true;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
out.push(line);
|
|
139
|
+
}
|
|
140
|
+
if (redacted && out.every((line) => !line.trim())) {
|
|
141
|
+
return { text: "[entries withheld by read policy]", redacted: true };
|
|
142
|
+
}
|
|
143
|
+
return { text: out.join("\n"), redacted };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function redactLs(
|
|
147
|
+
text: string,
|
|
148
|
+
input: Record<string, unknown>,
|
|
149
|
+
cwd: string,
|
|
150
|
+
config: FolderPolicyConfig,
|
|
151
|
+
): RedactTextResult {
|
|
152
|
+
const raw = input.path;
|
|
153
|
+
const base =
|
|
154
|
+
typeof raw === "string" && raw.trim()
|
|
155
|
+
? path.resolve(cwd, raw)
|
|
156
|
+
: cwd;
|
|
157
|
+
const out: string[] = [];
|
|
158
|
+
let redacted = false;
|
|
159
|
+
for (const line of text.split("\n")) {
|
|
160
|
+
const entry = line.trim().replace(/\/$/, "");
|
|
161
|
+
if (!entry) {
|
|
162
|
+
out.push(line);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (isSensitive(path.join(base, entry), config, cwd)) {
|
|
166
|
+
redacted = true;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
out.push(line);
|
|
170
|
+
}
|
|
171
|
+
if (redacted && out.every((line) => !line.trim())) {
|
|
172
|
+
return { text: "[entries withheld by read policy]", redacted: true };
|
|
173
|
+
}
|
|
174
|
+
return { text: out.join("\n"), redacted };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function redactBash(
|
|
178
|
+
text: string,
|
|
179
|
+
cwd: string,
|
|
180
|
+
config: FolderPolicyConfig,
|
|
181
|
+
): RedactTextResult {
|
|
182
|
+
const lines = text.split("\n");
|
|
183
|
+
const out: string[] = [];
|
|
184
|
+
let redacted = false;
|
|
185
|
+
let inSensitiveDiff = false;
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
const diffHeader = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
|
|
188
|
+
if (diffHeader) {
|
|
189
|
+
const targetA = resolveOutputPath(diffHeader[1], cwd, cwd);
|
|
190
|
+
const targetB = resolveOutputPath(diffHeader[2], cwd, cwd);
|
|
191
|
+
inSensitiveDiff =
|
|
192
|
+
isSensitive(targetA, config, cwd) ||
|
|
193
|
+
isSensitive(targetB, config, cwd);
|
|
194
|
+
if (inSensitiveDiff) {
|
|
195
|
+
redacted = true;
|
|
196
|
+
out.push("[diff section withheld by read policy]");
|
|
197
|
+
} else {
|
|
198
|
+
out.push(line);
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (inSensitiveDiff) {
|
|
203
|
+
redacted = true;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (/^Full output: /.test(line)) {
|
|
207
|
+
// Remove the temp-file escape hatch only when something was redacted;
|
|
208
|
+
// the caller also drops details.fullOutputPath.
|
|
209
|
+
if (redacted) continue;
|
|
210
|
+
}
|
|
211
|
+
const prefixed = /^(.+?)(?::(\d+):|-(\d+)-)(.*)$/.exec(line);
|
|
212
|
+
if (prefixed && !prefixed[1].includes("://")) {
|
|
213
|
+
const resolved = resolveOutputPath(prefixed[1], cwd, cwd);
|
|
214
|
+
if (isSensitive(resolved, config, cwd)) {
|
|
215
|
+
redacted = true;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
out.push(line);
|
|
220
|
+
}
|
|
221
|
+
if (redacted && out.every((line) => !line.trim())) {
|
|
222
|
+
return { text: "[output withheld by read policy]", redacted: true };
|
|
223
|
+
}
|
|
224
|
+
return { text: out.join("\n"), redacted };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Redact one text payload. `kind` is the tool name (grep/find/ls/bash/read).
|
|
229
|
+
*/
|
|
230
|
+
export function redactSensitiveText(
|
|
231
|
+
text: string,
|
|
232
|
+
kind: string,
|
|
233
|
+
input: Record<string, unknown>,
|
|
234
|
+
cwd: string,
|
|
235
|
+
config: FolderPolicyConfig,
|
|
236
|
+
): RedactTextResult {
|
|
237
|
+
if (!text) return { text, redacted: false };
|
|
238
|
+
if (kind === "grep") return redactGrep(text, input, cwd, config);
|
|
239
|
+
if (kind === "find") return redactFind(text, input, cwd, config);
|
|
240
|
+
if (kind === "ls") return redactLs(text, input, cwd, config);
|
|
241
|
+
if (kind === "bash" || kind === "powershell")
|
|
242
|
+
return redactBash(text, cwd, config);
|
|
243
|
+
return { text, redacted: false };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function inputPath(input: Record<string, unknown>): string {
|
|
247
|
+
const raw =
|
|
248
|
+
input.path ?? input.file ?? input.filePath ?? input.file_path ?? undefined;
|
|
249
|
+
return typeof raw === "string" ? raw.trim() : "";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Redact a tool result. Returns `undefined` when nothing changed (the caller
|
|
254
|
+
* leaves the result untouched). Any change drops `structuredContent`, per the
|
|
255
|
+
* Pi tool_result contract.
|
|
256
|
+
*/
|
|
257
|
+
export function redactToolResult(
|
|
258
|
+
event: RedactableToolResult,
|
|
259
|
+
config: FolderPolicyConfig,
|
|
260
|
+
cwd: string,
|
|
261
|
+
): RedactedToolResult | undefined {
|
|
262
|
+
const tool = String(event.toolName ?? "");
|
|
263
|
+
const readTools = new Set([
|
|
264
|
+
"read",
|
|
265
|
+
"read_symbol",
|
|
266
|
+
"read_enclosing",
|
|
267
|
+
"module_report",
|
|
268
|
+
"effective_config",
|
|
269
|
+
]);
|
|
270
|
+
if (readTools.has(tool)) {
|
|
271
|
+
const target = inputPath(event.input);
|
|
272
|
+
if (target && isSensitive(path.resolve(cwd, target), config, cwd)) {
|
|
273
|
+
return {
|
|
274
|
+
content: [
|
|
275
|
+
{
|
|
276
|
+
type: "text",
|
|
277
|
+
text: "[blocked by read policy: sensitive file]",
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
structuredContent: undefined,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!["grep", "find", "ls", "bash", "powershell"].includes(tool))
|
|
287
|
+
return undefined;
|
|
288
|
+
let changed = false;
|
|
289
|
+
const content = (event.content ?? []).map((block) => {
|
|
290
|
+
if (block?.type !== "text" || typeof block.text !== "string") return block;
|
|
291
|
+
const result = redactSensitiveText(
|
|
292
|
+
block.text,
|
|
293
|
+
tool,
|
|
294
|
+
event.input ?? {},
|
|
295
|
+
cwd,
|
|
296
|
+
config,
|
|
297
|
+
);
|
|
298
|
+
if (!result.redacted) return block;
|
|
299
|
+
changed = true;
|
|
300
|
+
return { ...block, text: result.text };
|
|
301
|
+
});
|
|
302
|
+
if (!changed) return undefined;
|
|
303
|
+
|
|
304
|
+
const result: RedactedToolResult = { content, structuredContent: undefined };
|
|
305
|
+
if (event.details && typeof event.details === "object") {
|
|
306
|
+
const details = { ...(event.details as Record<string, unknown>) };
|
|
307
|
+
if (tool === "bash" || tool === "powershell") {
|
|
308
|
+
delete details.fullOutputPath;
|
|
309
|
+
delete details.full_output_path;
|
|
310
|
+
}
|
|
311
|
+
result.details = details;
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A short display path for refusals/diagnostics. */
|
|
317
|
+
export function displayRedactPath(value: string, cwd: string): string {
|
|
318
|
+
return toPosixPath(path.relative(cwd, value) || value);
|
|
319
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — Read tool-call classifier
|
|
3
|
+
*
|
|
4
|
+
* Turns a tool name + input into the filesystem targets the read gate must
|
|
5
|
+
* classify. Covers Pi's built-in read tools, the pi-lens read tools, `explore`
|
|
6
|
+
* (the child's working root), local `fetch_content` / `browser_*` file URLs,
|
|
7
|
+
* and a conservative generic path-key scan for extension/MCP tools.
|
|
8
|
+
*
|
|
9
|
+
* Known writers are skipped (the write policy owns them). An unknown tool with
|
|
10
|
+
* a path-like input is guarded; an unknown tool without one is allowed. This
|
|
11
|
+
* is deliberately fail-safe without pretending every MCP tool is a file read.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
import type { ReadPolicyConfig } from "./read-policy.ts";
|
|
17
|
+
import type { FolderTarget } from "./folder-policy.ts";
|
|
18
|
+
import { normalizePolicyPath, toPosixPath } from "./folder-policy.ts";
|
|
19
|
+
import { analyzeShellPaths } from "./shell-paths.ts";
|
|
20
|
+
|
|
21
|
+
export interface ReadCallClassification {
|
|
22
|
+
tool: string;
|
|
23
|
+
targets: FolderTarget[];
|
|
24
|
+
uncertain: string[];
|
|
25
|
+
exempt?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const KNOWN_WRITE_TOOLS = new Set([
|
|
29
|
+
"edit",
|
|
30
|
+
"write",
|
|
31
|
+
"ast_grep_replace",
|
|
32
|
+
"review_submit_plan",
|
|
33
|
+
"review_plan_begin",
|
|
34
|
+
"review_plan_chunk",
|
|
35
|
+
"review_plan_finalize",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const EXEMPT_TOOLS = new Set([
|
|
39
|
+
"memory_search",
|
|
40
|
+
"memory_read",
|
|
41
|
+
"memory_write",
|
|
42
|
+
"memory_forget",
|
|
43
|
+
"memory_restore",
|
|
44
|
+
"memory_status",
|
|
45
|
+
"scratchpad",
|
|
46
|
+
"workflow_todo",
|
|
47
|
+
"web_search",
|
|
48
|
+
"source_check",
|
|
49
|
+
"get_search_content",
|
|
50
|
+
"pi_lens_activate_tools",
|
|
51
|
+
"lens_diagnostic_mark",
|
|
52
|
+
"questionnaire",
|
|
53
|
+
"browser_screenshot",
|
|
54
|
+
"browser_console_logs",
|
|
55
|
+
"browser_console_errors",
|
|
56
|
+
"browser_network_requests",
|
|
57
|
+
"browser_close",
|
|
58
|
+
"browser_attach",
|
|
59
|
+
"browser_detach",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const FILE_PATH_TOOLS = new Set([
|
|
63
|
+
"read",
|
|
64
|
+
"read_symbol",
|
|
65
|
+
"read_enclosing",
|
|
66
|
+
"module_report",
|
|
67
|
+
"effective_config",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const DIR_OR_FILE_PATH_TOOLS = new Set(["ls", "grep", "find", "lsp_navigation"]);
|
|
71
|
+
|
|
72
|
+
const PATHS_TOOLS = new Set([
|
|
73
|
+
"symbol_search",
|
|
74
|
+
"ast_grep_search",
|
|
75
|
+
"ast_grep_outline",
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const GENERIC_PATH_KEYS = new Set([
|
|
79
|
+
"path",
|
|
80
|
+
"file",
|
|
81
|
+
"filePath",
|
|
82
|
+
"file_path",
|
|
83
|
+
"dir",
|
|
84
|
+
"dirs",
|
|
85
|
+
"directory",
|
|
86
|
+
"directories",
|
|
87
|
+
"cwd",
|
|
88
|
+
"root",
|
|
89
|
+
"roots",
|
|
90
|
+
"analysisRoot",
|
|
91
|
+
"paths",
|
|
92
|
+
"files",
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
function addTarget(
|
|
96
|
+
out: FolderTarget[],
|
|
97
|
+
value: unknown,
|
|
98
|
+
kind: FolderTarget["kind"],
|
|
99
|
+
cwd: string,
|
|
100
|
+
): void {
|
|
101
|
+
if (typeof value !== "string" || !value.trim()) return;
|
|
102
|
+
const path = normalizePolicyPath(value, cwd);
|
|
103
|
+
if (!path) return;
|
|
104
|
+
const target =
|
|
105
|
+
value.includes("*") || value.includes("?")
|
|
106
|
+
? { path: staticGlobPrefix(value, cwd), kind: "glob" as const }
|
|
107
|
+
: { path, kind };
|
|
108
|
+
if (!out.some((item) => item.path === target.path)) out.push(target);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Static, non-wildcard prefix of a glob, resolved to the narrowest directory
|
|
113
|
+
* that must be permitted. `../other/src/**\/*.ts` -> `../other/src`;
|
|
114
|
+
* `**\/*.ts` -> cwd.
|
|
115
|
+
*/
|
|
116
|
+
export function staticGlobPrefix(value: string, cwd: string): string {
|
|
117
|
+
const raw = value.trim();
|
|
118
|
+
const cut = raw.search(/[?*[]/);
|
|
119
|
+
const prefix = cut >= 0 ? raw.slice(0, cut) : raw;
|
|
120
|
+
const withoutTrailing = prefix.replace(/[\\/]+$/, "");
|
|
121
|
+
if (!withoutTrailing) return normalizePolicyPath(cwd, cwd);
|
|
122
|
+
const wildcardInLastSegment =
|
|
123
|
+
cut >= 0 && !/^[\\/]/.test(raw.slice(cut)) && !/[\\/]$/.test(prefix);
|
|
124
|
+
const base = wildcardInLastSegment
|
|
125
|
+
? withoutTrailing.replace(/[\\/][^\\/]*$/, "")
|
|
126
|
+
: withoutTrailing;
|
|
127
|
+
return normalizePolicyPath(base || cwd, cwd);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function collection(value: unknown): unknown[] {
|
|
131
|
+
return Array.isArray(value) ? value : [value];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function classifyFileUrls(
|
|
135
|
+
tool: string,
|
|
136
|
+
input: Record<string, unknown>,
|
|
137
|
+
cwd: string,
|
|
138
|
+
): ReadCallClassification {
|
|
139
|
+
const result: ReadCallClassification = { tool, targets: [], uncertain: [] };
|
|
140
|
+
const raw = input.url ?? input.urls;
|
|
141
|
+
for (const entry of collection(raw)) {
|
|
142
|
+
if (typeof entry !== "string" || !entry.trim()) continue;
|
|
143
|
+
const value = entry.trim();
|
|
144
|
+
if (/^https?:\/\//i.test(value) || /^(data|ftp|mailto):/i.test(value))
|
|
145
|
+
continue;
|
|
146
|
+
if (value.startsWith("file:")) {
|
|
147
|
+
try {
|
|
148
|
+
addTarget(result.targets, fileURLToPath(value), "file", cwd);
|
|
149
|
+
} catch {
|
|
150
|
+
result.uncertain.push(`${tool}: invalid file URL`);
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
// A bare local path is a local read (video/image/PDF fetchers). Windows
|
|
155
|
+
// drive paths are not URL schemes (`C:\...`).
|
|
156
|
+
if (
|
|
157
|
+
/^[A-Za-z]:[\\/]/.test(value) ||
|
|
158
|
+
value.startsWith("\\\\") ||
|
|
159
|
+
!/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)
|
|
160
|
+
) {
|
|
161
|
+
addTarget(result.targets, value, "file", cwd);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Classify a tool call. Total: malformed inputs never throw.
|
|
169
|
+
*/
|
|
170
|
+
export function classifyReadCall(
|
|
171
|
+
toolName: string,
|
|
172
|
+
input: unknown,
|
|
173
|
+
cwd: string,
|
|
174
|
+
config: ReadPolicyConfig,
|
|
175
|
+
): ReadCallClassification {
|
|
176
|
+
const tool = String(toolName ?? "");
|
|
177
|
+
const obj =
|
|
178
|
+
input && typeof input === "object" && !Array.isArray(input)
|
|
179
|
+
? (input as Record<string, unknown>)
|
|
180
|
+
: {};
|
|
181
|
+
const result: ReadCallClassification = { tool, targets: [], uncertain: [] };
|
|
182
|
+
|
|
183
|
+
if (tool === "bash" || tool === "powershell") {
|
|
184
|
+
const analysis = analyzeShellPaths(obj.command, cwd);
|
|
185
|
+
result.targets = analysis.reads;
|
|
186
|
+
result.uncertain = analysis.uncertain;
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (config.guardedTools.includes(tool)) {
|
|
191
|
+
collectGeneric(obj, cwd, result);
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (EXEMPT_TOOLS.has(tool)) {
|
|
196
|
+
result.exempt = "not a filesystem read tool";
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
if (KNOWN_WRITE_TOOLS.has(tool)) {
|
|
200
|
+
result.exempt = "write tool (write policy owns it)";
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (tool === "explore") {
|
|
205
|
+
if (obj.cwd !== undefined) addTarget(result.targets, obj.cwd, "dir", cwd);
|
|
206
|
+
const tasks = Array.isArray(obj.tasks) ? obj.tasks : [];
|
|
207
|
+
for (const task of tasks) {
|
|
208
|
+
if (!task || typeof task !== "object") continue;
|
|
209
|
+
const taskCwd = (task as Record<string, unknown>).cwd;
|
|
210
|
+
if (taskCwd !== undefined) addTarget(result.targets, taskCwd, "dir", cwd);
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (tool === "fetch_content" || tool === "browser_launch" || tool === "browser_navigate") {
|
|
216
|
+
return classifyFileUrls(tool, obj, cwd);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (tool === "lens_diagnostics") {
|
|
220
|
+
for (const key of ["path", "file", "filePath", "analysisRoot"]) {
|
|
221
|
+
if (obj[key] !== undefined) addTarget(result.targets, obj[key], "dir", cwd);
|
|
222
|
+
}
|
|
223
|
+
for (const entry of collection(obj.paths)) {
|
|
224
|
+
addTarget(result.targets, entry, "file", cwd);
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (PATHS_TOOLS.has(tool)) {
|
|
230
|
+
for (const entry of collection(obj.paths)) {
|
|
231
|
+
addTarget(result.targets, entry, "file", cwd);
|
|
232
|
+
}
|
|
233
|
+
if (obj.path !== undefined) addTarget(result.targets, obj.path, "dir", cwd);
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (FILE_PATH_TOOLS.has(tool)) {
|
|
238
|
+
const value =
|
|
239
|
+
obj.path ?? obj.file ?? obj.filePath ?? obj.file_path ?? undefined;
|
|
240
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
241
|
+
result.uncertain.push(`${tool}: missing path`);
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
addTarget(result.targets, value, "file", cwd);
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (DIR_OR_FILE_PATH_TOOLS.has(tool)) {
|
|
249
|
+
const value = obj.path ?? obj.file ?? obj.filePath ?? obj.file_path;
|
|
250
|
+
addTarget(result.targets, value ?? ".", "dir", cwd);
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Unknown/MCP tool: guard path-like inputs, otherwise allow.
|
|
255
|
+
collectGeneric(obj, cwd, result);
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function collectGeneric(
|
|
260
|
+
input: unknown,
|
|
261
|
+
cwd: string,
|
|
262
|
+
result: ReadCallClassification,
|
|
263
|
+
depth = 0,
|
|
264
|
+
): void {
|
|
265
|
+
if (!input || typeof input !== "object" || depth > 3) return;
|
|
266
|
+
if (Array.isArray(input)) {
|
|
267
|
+
for (const entry of input) {
|
|
268
|
+
if (typeof entry === "string") {
|
|
269
|
+
// Only reached for a direct array under a path key; handled below.
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
collectGeneric(entry, cwd, result, depth + 1);
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
|
277
|
+
if (GENERIC_PATH_KEYS.has(key)) {
|
|
278
|
+
if (Array.isArray(value)) {
|
|
279
|
+
for (const entry of value) {
|
|
280
|
+
if (typeof entry === "string") {
|
|
281
|
+
const kind = key === "paths" || key === "files" ? "file" : "dir";
|
|
282
|
+
addTarget(result.targets, entry, kind, cwd);
|
|
283
|
+
} else {
|
|
284
|
+
collectGeneric(entry, cwd, result, depth + 1);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} else if (typeof value === "string") {
|
|
288
|
+
const kind =
|
|
289
|
+
key === "path" || key === "file" || key === "filePath" || key === "file_path"
|
|
290
|
+
? "file"
|
|
291
|
+
: "dir";
|
|
292
|
+
addTarget(result.targets, value, kind, cwd);
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
// Nested containers may hold `cwd`/`path` fields (explore tasks, MCP args).
|
|
297
|
+
if (value && typeof value === "object") {
|
|
298
|
+
collectGeneric(value, cwd, result, depth + 1);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** A short display path for diagnostics. */
|
|
304
|
+
export function describeReadTargets(
|
|
305
|
+
targets: readonly FolderTarget[],
|
|
306
|
+
): string {
|
|
307
|
+
return targets
|
|
308
|
+
.map((target) => toPosixPath(target.path))
|
|
309
|
+
.join(", ");
|
|
310
|
+
}
|