@dieulc/workflow 0.9.2 → 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 +104 -20
- package/commit-policy.ts +74 -10
- package/edit-policy.ts +111 -44
- package/folder-policy.ts +1159 -0
- package/index.ts +2868 -496
- package/package.json +16 -6
- package/provider-retarget.ts +258 -0
- package/read-policy-redact.ts +319 -0
- package/read-policy-tools.ts +310 -0
- package/read-policy.ts +654 -0
- package/review-handoff.ts +126 -0
- package/review-recovery.ts +67 -0
- package/review-runtime.ts +454 -94
- package/review-tools.ts +472 -7
- package/review.ts +451 -33
- 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 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-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",
|
|
@@ -44,6 +51,9 @@
|
|
|
44
51
|
"shell-text.ts",
|
|
45
52
|
"review.ts",
|
|
46
53
|
"review-providers.ts",
|
|
54
|
+
"review-recovery.ts",
|
|
55
|
+
"review-handoff.ts",
|
|
56
|
+
"provider-retarget.ts",
|
|
47
57
|
"review-tools.ts",
|
|
48
58
|
"review-runtime.ts",
|
|
49
59
|
"review-ui.ts",
|
|
@@ -65,10 +75,10 @@
|
|
|
65
75
|
"typebox": "*"
|
|
66
76
|
},
|
|
67
77
|
"devDependencies": {
|
|
68
|
-
"@earendil-works/pi-agent-core": "
|
|
69
|
-
"@earendil-works/pi-ai": "
|
|
70
|
-
"@earendil-works/pi-coding-agent": "
|
|
71
|
-
"@earendil-works/pi-tui": "
|
|
78
|
+
"@earendil-works/pi-agent-core": "1.5.0",
|
|
79
|
+
"@earendil-works/pi-ai": "1.5.0",
|
|
80
|
+
"@earendil-works/pi-coding-agent": "1.5.0",
|
|
81
|
+
"@earendil-works/pi-tui": "1.5.0",
|
|
72
82
|
"typebox": "1.3.7",
|
|
73
83
|
"@types/node": "^20.19.43",
|
|
74
84
|
"typescript": "5.5.4"
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — provider retarget for profiles.json / roles.json.
|
|
3
|
+
*
|
|
4
|
+
* The maintained Command Code provider registers as `commandcode`; the
|
|
5
|
+
* hand-written workaround often registers as `command-code`. Migrating a user
|
|
6
|
+
* should not mean hand-editing two JSON files and hoping every other key
|
|
7
|
+
* survives. This module rewrites only `{ provider, id }` model refs, preserves
|
|
8
|
+
* every other field, and backs up the original before writing.
|
|
9
|
+
*
|
|
10
|
+
* Pure parsing/formatting is separated from the filesystem so the tests can
|
|
11
|
+
* exercise both a dry run and an apply without touching a real profile.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
export type RetargetFileStatus =
|
|
18
|
+
"changed" | "would-change" | "unchanged" | "missing" | "invalid" | "blocked";
|
|
19
|
+
|
|
20
|
+
export interface RetargetFileResult {
|
|
21
|
+
path: string;
|
|
22
|
+
status: RetargetFileStatus;
|
|
23
|
+
changes: number;
|
|
24
|
+
backup?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RetargetFilesResult {
|
|
29
|
+
oldProvider: string;
|
|
30
|
+
newProvider: string;
|
|
31
|
+
dryRun: boolean;
|
|
32
|
+
profiles: RetargetFileResult;
|
|
33
|
+
roles: RetargetFileResult;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RetargetFilesInput {
|
|
37
|
+
profilesPath: string;
|
|
38
|
+
rolesPath: string;
|
|
39
|
+
oldProvider: string;
|
|
40
|
+
newProvider: string;
|
|
41
|
+
/** When true, compute only; never write. */
|
|
42
|
+
dryRun?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RetargetDocumentResult =
|
|
46
|
+
{ ok: true; text: string; changes: number } | { ok: false; error: string };
|
|
47
|
+
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rewrite every `{ provider: oldProvider, id: <string> }` object in a parsed
|
|
54
|
+
* profile/role document. Unknown keys and their order are preserved because the
|
|
55
|
+
* input is mutated in place and serialized with the same 2-space layout.
|
|
56
|
+
*/
|
|
57
|
+
function retargetValue(
|
|
58
|
+
value: unknown,
|
|
59
|
+
oldProvider: string,
|
|
60
|
+
newProvider: string,
|
|
61
|
+
counter: { changes: number },
|
|
62
|
+
depth: number,
|
|
63
|
+
): void {
|
|
64
|
+
if (depth > 64) return;
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
for (const entry of value) {
|
|
67
|
+
retargetValue(entry, oldProvider, newProvider, counter, depth + 1);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!isRecord(value)) return;
|
|
72
|
+
if (value.provider === oldProvider && typeof value.id === "string") {
|
|
73
|
+
value.provider = newProvider;
|
|
74
|
+
counter.changes += 1;
|
|
75
|
+
}
|
|
76
|
+
for (const entry of Object.values(value)) {
|
|
77
|
+
retargetValue(entry, oldProvider, newProvider, counter, depth + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Retarget one JSON document. Malformed JSON or a non-object root is an error;
|
|
83
|
+
* the caller must not write in that case. A same-provider request is a no-op.
|
|
84
|
+
*/
|
|
85
|
+
export function retargetProviderDocument(
|
|
86
|
+
text: string,
|
|
87
|
+
oldProvider: string,
|
|
88
|
+
newProvider: string,
|
|
89
|
+
): RetargetDocumentResult {
|
|
90
|
+
let parsed: unknown;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(text);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (!isRecord(parsed)) {
|
|
100
|
+
return { ok: false, error: "the document root is not a JSON object" };
|
|
101
|
+
}
|
|
102
|
+
if (oldProvider === newProvider) {
|
|
103
|
+
return { ok: true, text, changes: 0 };
|
|
104
|
+
}
|
|
105
|
+
const counter = { changes: 0 };
|
|
106
|
+
retargetValue(parsed, oldProvider, newProvider, counter, 0);
|
|
107
|
+
if (counter.changes === 0) return { ok: true, text, changes: 0 };
|
|
108
|
+
const trailing = text.endsWith("\n") ? "\n" : "";
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
text: `${JSON.stringify(parsed, null, 2)}${trailing}`,
|
|
112
|
+
changes: counter.changes,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface FilePlan {
|
|
117
|
+
result: RetargetFileResult;
|
|
118
|
+
nextText?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readPlan(
|
|
122
|
+
filePath: string,
|
|
123
|
+
oldProvider: string,
|
|
124
|
+
newProvider: string,
|
|
125
|
+
): FilePlan {
|
|
126
|
+
if (!fs.existsSync(filePath)) {
|
|
127
|
+
return { result: { path: filePath, status: "missing", changes: 0 } };
|
|
128
|
+
}
|
|
129
|
+
let text: string;
|
|
130
|
+
try {
|
|
131
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return {
|
|
134
|
+
result: {
|
|
135
|
+
path: filePath,
|
|
136
|
+
status: "invalid",
|
|
137
|
+
changes: 0,
|
|
138
|
+
error: error instanceof Error ? error.message : String(error),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const rewritten = retargetProviderDocument(text, oldProvider, newProvider);
|
|
143
|
+
if (!rewritten.ok) {
|
|
144
|
+
return {
|
|
145
|
+
result: {
|
|
146
|
+
path: filePath,
|
|
147
|
+
status: "invalid",
|
|
148
|
+
changes: 0,
|
|
149
|
+
error: rewritten.error,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (rewritten.changes === 0) {
|
|
154
|
+
return { result: { path: filePath, status: "unchanged", changes: 0 } };
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
result: {
|
|
158
|
+
path: filePath,
|
|
159
|
+
status: "would-change",
|
|
160
|
+
changes: rewritten.changes,
|
|
161
|
+
},
|
|
162
|
+
nextText: rewritten.text,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function applyPlan(plan: FilePlan): RetargetFileResult {
|
|
167
|
+
if (plan.result.status !== "would-change" || plan.nextText === undefined) {
|
|
168
|
+
return plan.result;
|
|
169
|
+
}
|
|
170
|
+
const filePath = plan.result.path;
|
|
171
|
+
const backup = `${filePath}.bak`;
|
|
172
|
+
const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
173
|
+
try {
|
|
174
|
+
fs.copyFileSync(filePath, backup);
|
|
175
|
+
fs.writeFileSync(temporary, plan.nextText, "utf8");
|
|
176
|
+
fs.renameSync(temporary, filePath);
|
|
177
|
+
return { ...plan.result, status: "changed", backup };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
try {
|
|
180
|
+
fs.rmSync(temporary, { force: true });
|
|
181
|
+
} catch {
|
|
182
|
+
// Best-effort cleanup must not replace the write error.
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
...plan.result,
|
|
186
|
+
status: "invalid",
|
|
187
|
+
error: error instanceof Error ? error.message : String(error),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Retarget both files. Two-phase: if either document is invalid, neither file
|
|
194
|
+
* is written. A dry run never writes or creates a backup.
|
|
195
|
+
*/
|
|
196
|
+
export function retargetProviderFiles(
|
|
197
|
+
input: RetargetFilesInput,
|
|
198
|
+
): RetargetFilesResult {
|
|
199
|
+
const oldProvider = String(input.oldProvider ?? "").trim();
|
|
200
|
+
const newProvider = String(input.newProvider ?? "").trim();
|
|
201
|
+
const dryRun = input.dryRun === true;
|
|
202
|
+
const profiles = readPlan(input.profilesPath, oldProvider, newProvider);
|
|
203
|
+
const roles = readPlan(input.rolesPath, oldProvider, newProvider);
|
|
204
|
+
const invalid =
|
|
205
|
+
profiles.result.status === "invalid" || roles.result.status === "invalid";
|
|
206
|
+
const finish = (plan: FilePlan): RetargetFileResult => {
|
|
207
|
+
if (invalid) {
|
|
208
|
+
return plan.result.status === "invalid"
|
|
209
|
+
? plan.result
|
|
210
|
+
: plan.result.status === "would-change"
|
|
211
|
+
? {
|
|
212
|
+
...plan.result,
|
|
213
|
+
status: "blocked",
|
|
214
|
+
error: "not written because the other document is invalid",
|
|
215
|
+
}
|
|
216
|
+
: plan.result;
|
|
217
|
+
}
|
|
218
|
+
if (dryRun) return plan.result;
|
|
219
|
+
return applyPlan(plan);
|
|
220
|
+
};
|
|
221
|
+
return {
|
|
222
|
+
oldProvider,
|
|
223
|
+
newProvider,
|
|
224
|
+
dryRun,
|
|
225
|
+
profiles: finish(profiles),
|
|
226
|
+
roles: finish(roles),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function statusLabel(result: RetargetFileResult): string {
|
|
231
|
+
switch (result.status) {
|
|
232
|
+
case "changed":
|
|
233
|
+
return `changed ${result.changes} model ref(s)${result.backup ? `; backup ${path.basename(result.backup)}` : ""}`;
|
|
234
|
+
case "would-change":
|
|
235
|
+
return `would change ${result.changes} model ref(s)`;
|
|
236
|
+
case "unchanged":
|
|
237
|
+
return "no model refs to change";
|
|
238
|
+
case "missing":
|
|
239
|
+
return "not present";
|
|
240
|
+
case "invalid":
|
|
241
|
+
return `not changed — ${result.error ?? "invalid document"}`;
|
|
242
|
+
case "blocked":
|
|
243
|
+
return `not written — ${result.error ?? "blocked by another invalid file"}`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Human-readable command result; safe for `ctx.ui.notify`. */
|
|
248
|
+
export function formatRetargetResult(result: RetargetFilesResult): string {
|
|
249
|
+
const verb = result.dryRun ? "Dry run" : "Retarget";
|
|
250
|
+
return [
|
|
251
|
+
`${verb}: provider ${result.oldProvider} → ${result.newProvider}`,
|
|
252
|
+
` profiles.json ${statusLabel(result.profiles)}`,
|
|
253
|
+
` roles.json ${statusLabel(result.roles)}`,
|
|
254
|
+
...(result.dryRun
|
|
255
|
+
? ["Add --apply to write; each changed file keeps a .bak."]
|
|
256
|
+
: []),
|
|
257
|
+
].join("\n");
|
|
258
|
+
}
|
|
@@ -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
|
+
}
|