@dieulc/workflow 0.11.0 → 0.11.1

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.
@@ -1,319 +1,359 @@
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
- }
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 {
22
+ ExclusionRescueRoots,
23
+ FolderPolicyConfig,
24
+ } from "./folder-policy.ts";
25
+ import {
26
+ matchSensitivePath,
27
+ matchUnrescuedExclusion,
28
+ toPosixPath,
29
+ } from "./folder-policy.ts";
30
+
31
+ /**
32
+ * Roots that can re-open an excluded path for this result. Without them an
33
+ * exclusion match is withheld, so a caller that forgets to pass roots fails
34
+ * closed rather than leaking the excluded tree.
35
+ */
36
+ export type RedactRoots = ExclusionRescueRoots;
37
+
38
+ export interface RedactableBlock {
39
+ type: string;
40
+ text?: string;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ export interface RedactableToolResult {
45
+ toolName: string;
46
+ input: Record<string, unknown>;
47
+ content: RedactableBlock[];
48
+ details?: unknown;
49
+ structuredContent?: unknown;
50
+ }
51
+
52
+ export interface RedactedToolResult {
53
+ content?: RedactableBlock[];
54
+ details?: unknown;
55
+ structuredContent?: undefined;
56
+ }
57
+
58
+ export interface RedactTextResult {
59
+ text: string;
60
+ redacted: boolean;
61
+ }
62
+
63
+ function searchBase(input: Record<string, unknown>, cwd: string): string {
64
+ const raw = input.path;
65
+ if (typeof raw !== "string" || !raw.trim()) return cwd;
66
+ const resolved = path.resolve(cwd, raw);
67
+ try {
68
+ return fs.statSync(resolved).isDirectory() ? resolved : path.dirname(resolved);
69
+ } catch {
70
+ return resolved;
71
+ }
72
+ }
73
+
74
+ function isSensitive(
75
+ pathValue: string,
76
+ config: FolderPolicyConfig,
77
+ cwd: string,
78
+ ): boolean {
79
+ return matchSensitivePath(pathValue, config, cwd).sensitive;
80
+ }
81
+
82
+ /**
83
+ * Sensitive always wins; an excluded path is withheld unless an approved root
84
+ * (see `matchUnrescuedExclusion`) re-opens it.
85
+ */
86
+ function isProtected(
87
+ pathValue: string,
88
+ config: FolderPolicyConfig,
89
+ cwd: string,
90
+ roots: RedactRoots | undefined,
91
+ ): boolean {
92
+ if (isSensitive(pathValue, config, cwd)) return true;
93
+ if (config.excludePaths.length === 0) return false;
94
+ return matchUnrescuedExclusion(pathValue, config, cwd, roots ?? {}).excluded;
95
+ }
96
+
97
+ function resolveOutputPath(
98
+ raw: string,
99
+ base: string,
100
+ cwd: string,
101
+ ): string {
102
+ if (path.isAbsolute(raw)) return raw;
103
+ const joined = path.resolve(base, raw);
104
+ // `grep` relativises against the search root; if the joined path does not
105
+ // exist, the raw path may already be cwd-relative.
106
+ try {
107
+ if (fs.existsSync(joined)) return joined;
108
+ } catch {
109
+ /* ignore */
110
+ }
111
+ return path.resolve(cwd, raw);
112
+ }
113
+
114
+ function redactGrep(
115
+ text: string,
116
+ input: Record<string, unknown>,
117
+ cwd: string,
118
+ config: FolderPolicyConfig,
119
+ roots?: RedactRoots,
120
+ ): RedactTextResult {
121
+ const base = searchBase(input, cwd);
122
+ const out: string[] = [];
123
+ let redacted = false;
124
+ for (const line of text.split("\n")) {
125
+ const match = /^(.+?)(?::(\d+):|-(\d+)-)(.*)$/.exec(line);
126
+ if (!match) {
127
+ out.push(line);
128
+ continue;
129
+ }
130
+ const rawPath = match[1];
131
+ if (rawPath.includes("://")) {
132
+ out.push(line);
133
+ continue;
134
+ }
135
+ const resolved = resolveOutputPath(rawPath, base, cwd);
136
+ if (isProtected(resolved, config, cwd, roots)) {
137
+ redacted = true;
138
+ continue;
139
+ }
140
+ out.push(line);
141
+ }
142
+ if (redacted && out.every((line) => !line.trim())) {
143
+ return {
144
+ text:
145
+ "[matches in protected files were withheld by read policy]",
146
+ redacted: true,
147
+ };
148
+ }
149
+ return { text: out.join("\n"), redacted };
150
+ }
151
+
152
+ function redactFind(
153
+ text: string,
154
+ input: Record<string, unknown>,
155
+ cwd: string,
156
+ config: FolderPolicyConfig,
157
+ roots?: RedactRoots,
158
+ ): RedactTextResult {
159
+ const base = searchBase(input, cwd);
160
+ const out: string[] = [];
161
+ let redacted = false;
162
+ for (const line of text.split("\n")) {
163
+ const trimmed = line.trim();
164
+ if (!trimmed) {
165
+ out.push(line);
166
+ continue;
167
+ }
168
+ const resolved = resolveOutputPath(trimmed.replace(/\/$/, ""), base, cwd);
169
+ if (isProtected(resolved, config, cwd, roots)) {
170
+ redacted = true;
171
+ continue;
172
+ }
173
+ out.push(line);
174
+ }
175
+ if (redacted && out.every((line) => !line.trim())) {
176
+ return { text: "[entries withheld by read policy]", redacted: true };
177
+ }
178
+ return { text: out.join("\n"), redacted };
179
+ }
180
+
181
+ function redactLs(
182
+ text: string,
183
+ input: Record<string, unknown>,
184
+ cwd: string,
185
+ config: FolderPolicyConfig,
186
+ roots?: RedactRoots,
187
+ ): RedactTextResult {
188
+ const raw = input.path;
189
+ const base =
190
+ typeof raw === "string" && raw.trim()
191
+ ? path.resolve(cwd, raw)
192
+ : cwd;
193
+ const out: string[] = [];
194
+ let redacted = false;
195
+ for (const line of text.split("\n")) {
196
+ const entry = line.trim().replace(/\/$/, "");
197
+ if (!entry) {
198
+ out.push(line);
199
+ continue;
200
+ }
201
+ if (isProtected(path.join(base, entry), config, cwd, roots)) {
202
+ redacted = true;
203
+ continue;
204
+ }
205
+ out.push(line);
206
+ }
207
+ if (redacted && out.every((line) => !line.trim())) {
208
+ return { text: "[entries withheld by read policy]", redacted: true };
209
+ }
210
+ return { text: out.join("\n"), redacted };
211
+ }
212
+
213
+ function redactBash(
214
+ text: string,
215
+ cwd: string,
216
+ config: FolderPolicyConfig,
217
+ roots?: RedactRoots,
218
+ ): RedactTextResult {
219
+ const lines = text.split("\n");
220
+ const out: string[] = [];
221
+ let redacted = false;
222
+ let inSensitiveDiff = false;
223
+ for (const line of lines) {
224
+ const diffHeader = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
225
+ if (diffHeader) {
226
+ const targetA = resolveOutputPath(diffHeader[1], cwd, cwd);
227
+ const targetB = resolveOutputPath(diffHeader[2], cwd, cwd);
228
+ inSensitiveDiff =
229
+ isProtected(targetA, config, cwd, roots) ||
230
+ isProtected(targetB, config, cwd, roots);
231
+ if (inSensitiveDiff) {
232
+ redacted = true;
233
+ out.push("[diff section withheld by read policy]");
234
+ } else {
235
+ out.push(line);
236
+ }
237
+ continue;
238
+ }
239
+ if (inSensitiveDiff) {
240
+ redacted = true;
241
+ continue;
242
+ }
243
+ if (/^Full output: /.test(line)) {
244
+ // Remove the temp-file escape hatch only when something was redacted;
245
+ // the caller also drops details.fullOutputPath.
246
+ if (redacted) continue;
247
+ }
248
+ const prefixed = /^(.+?)(?::(\d+):|-(\d+)-)(.*)$/.exec(line);
249
+ if (prefixed && !prefixed[1].includes("://")) {
250
+ const resolved = resolveOutputPath(prefixed[1], cwd, cwd);
251
+ if (isProtected(resolved, config, cwd, roots)) {
252
+ redacted = true;
253
+ continue;
254
+ }
255
+ }
256
+ out.push(line);
257
+ }
258
+ if (redacted && out.every((line) => !line.trim())) {
259
+ return { text: "[output withheld by read policy]", redacted: true };
260
+ }
261
+ return { text: out.join("\n"), redacted };
262
+ }
263
+
264
+ /**
265
+ * Redact one text payload. `kind` is the tool name (grep/find/ls/bash/read).
266
+ */
267
+ export function redactSensitiveText(
268
+ text: string,
269
+ kind: string,
270
+ input: Record<string, unknown>,
271
+ cwd: string,
272
+ config: FolderPolicyConfig,
273
+ roots?: RedactRoots,
274
+ ): RedactTextResult {
275
+ if (!text) return { text, redacted: false };
276
+ if (kind === "grep") return redactGrep(text, input, cwd, config, roots);
277
+ if (kind === "find") return redactFind(text, input, cwd, config, roots);
278
+ if (kind === "ls") return redactLs(text, input, cwd, config, roots);
279
+ if (kind === "bash" || kind === "powershell")
280
+ return redactBash(text, cwd, config, roots);
281
+ return { text, redacted: false };
282
+ }
283
+
284
+ function inputPath(input: Record<string, unknown>): string {
285
+ const raw =
286
+ input.path ?? input.file ?? input.filePath ?? input.file_path ?? undefined;
287
+ return typeof raw === "string" ? raw.trim() : "";
288
+ }
289
+
290
+ /**
291
+ * Redact a tool result. Returns `undefined` when nothing changed (the caller
292
+ * leaves the result untouched). Any change drops `structuredContent`, per the
293
+ * Pi tool_result contract.
294
+ */
295
+ export function redactToolResult(
296
+ event: RedactableToolResult,
297
+ config: FolderPolicyConfig,
298
+ cwd: string,
299
+ roots?: RedactRoots,
300
+ ): RedactedToolResult | undefined {
301
+ const tool = String(event.toolName ?? "");
302
+ const readTools = new Set([
303
+ "read",
304
+ "read_symbol",
305
+ "read_enclosing",
306
+ "module_report",
307
+ "effective_config",
308
+ ]);
309
+ if (readTools.has(tool)) {
310
+ const target = inputPath(event.input);
311
+ if (target && isProtected(path.resolve(cwd, target), config, cwd, roots)) {
312
+ return {
313
+ content: [
314
+ {
315
+ type: "text",
316
+ text: "[blocked by read policy: protected file]",
317
+ },
318
+ ],
319
+ structuredContent: undefined,
320
+ };
321
+ }
322
+ return undefined;
323
+ }
324
+
325
+ if (!["grep", "find", "ls", "bash", "powershell"].includes(tool))
326
+ return undefined;
327
+ let changed = false;
328
+ const content = (event.content ?? []).map((block) => {
329
+ if (block?.type !== "text" || typeof block.text !== "string") return block;
330
+ const result = redactSensitiveText(
331
+ block.text,
332
+ tool,
333
+ event.input ?? {},
334
+ cwd,
335
+ config,
336
+ roots,
337
+ );
338
+ if (!result.redacted) return block;
339
+ changed = true;
340
+ return { ...block, text: result.text };
341
+ });
342
+ if (!changed) return undefined;
343
+
344
+ const result: RedactedToolResult = { content, structuredContent: undefined };
345
+ if (event.details && typeof event.details === "object") {
346
+ const details = { ...(event.details as Record<string, unknown>) };
347
+ if (tool === "bash" || tool === "powershell") {
348
+ delete details.fullOutputPath;
349
+ delete details.full_output_path;
350
+ }
351
+ result.details = details;
352
+ }
353
+ return result;
354
+ }
355
+
356
+ /** A short display path for refusals/diagnostics. */
357
+ export function displayRedactPath(value: string, cwd: string): string {
358
+ return toPosixPath(path.relative(cwd, value) || value);
359
+ }