@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/read-policy.ts
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — Read Policy
|
|
3
|
+
*
|
|
4
|
+
* Read-specific settings and the pure decision/dialog surface. The shared
|
|
5
|
+
* folder scope, outside action and sensitive list live in `folder-policy.ts`;
|
|
6
|
+
* this module only owns:
|
|
7
|
+
*
|
|
8
|
+
* - read grants (`readPolicy.allowedPaths`), persisted at the same project
|
|
9
|
+
* scope as write/commit grants,
|
|
10
|
+
* - `shell: "paths" | "strict"` for the balanced/strict shell analysis,
|
|
11
|
+
* - `guardedTools`: extra tool names treated as read tools.
|
|
12
|
+
*
|
|
13
|
+
* The decision is pure: sensitive always blocks; outside asks/blocks per the
|
|
14
|
+
* shared folder policy; a granted root allows; `shell: "strict"` turns an
|
|
15
|
+
* unverifiable command into a question. The runtime in index.ts owns the UI.
|
|
16
|
+
*
|
|
17
|
+
* No Pi SDK imports at module scope — same discipline as edit-policy.ts.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
|
|
23
|
+
import type { ArgumentCompletion } from "./utils.ts";
|
|
24
|
+
import {
|
|
25
|
+
joinArgumentValue,
|
|
26
|
+
suppressExactArgumentMatch,
|
|
27
|
+
translateForeignPath,
|
|
28
|
+
} from "./utils.ts";
|
|
29
|
+
import {
|
|
30
|
+
COMMIT_CONFIG_DIR,
|
|
31
|
+
WORKFLOW_SETTINGS_KEY,
|
|
32
|
+
type JsonObject,
|
|
33
|
+
} from "./commit-policy.ts";
|
|
34
|
+
import type {
|
|
35
|
+
FolderPolicyConfig,
|
|
36
|
+
FolderTarget,
|
|
37
|
+
FolderOutsideAction,
|
|
38
|
+
} from "./folder-policy.ts";
|
|
39
|
+
import {
|
|
40
|
+
grantRootForTarget,
|
|
41
|
+
outsideProjectRootForTarget,
|
|
42
|
+
} from "./folder-policy.ts";
|
|
43
|
+
|
|
44
|
+
/** Nested key under WORKFLOW_SETTINGS_KEY. */
|
|
45
|
+
export const READ_POLICY_KEY = "readPolicy";
|
|
46
|
+
|
|
47
|
+
export type ReadShellMode = "paths" | "strict";
|
|
48
|
+
|
|
49
|
+
export interface ReadPolicyConfig {
|
|
50
|
+
/** Extra read-only roots, per capability. */
|
|
51
|
+
allowedPaths: string[];
|
|
52
|
+
shell: ReadShellMode;
|
|
53
|
+
/** Extra tool names treated as read tools. */
|
|
54
|
+
guardedTools: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_READ_POLICY_CONFIG: Readonly<ReadPolicyConfig> = {
|
|
58
|
+
allowedPaths: [],
|
|
59
|
+
shell: "paths",
|
|
60
|
+
guardedTools: [],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const SHELL_MODES: readonly ReadShellMode[] = ["paths", "strict"];
|
|
64
|
+
|
|
65
|
+
export type RawReadPolicySetting = JsonObject | string;
|
|
66
|
+
|
|
67
|
+
function coerceStringList(value: unknown): string[] | undefined {
|
|
68
|
+
if (!Array.isArray(value)) return undefined;
|
|
69
|
+
const out: string[] = [];
|
|
70
|
+
for (const item of value) {
|
|
71
|
+
if (typeof item !== "string") continue;
|
|
72
|
+
const trimmed = item.trim();
|
|
73
|
+
if (!trimmed || out.includes(trimmed)) continue;
|
|
74
|
+
out.push(trimmed);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function unionStrings(a: readonly string[], b: readonly string[]): string[] {
|
|
80
|
+
const out = [...a];
|
|
81
|
+
for (const item of b) if (!out.includes(item)) out.push(item);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function coerceShell(value: unknown): ReadShellMode | undefined {
|
|
86
|
+
if (typeof value !== "string") return undefined;
|
|
87
|
+
const normalized = value.trim().toLowerCase();
|
|
88
|
+
return (SHELL_MODES as readonly string[]).includes(normalized)
|
|
89
|
+
? (normalized as ReadShellMode)
|
|
90
|
+
: undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Coerce an untrusted `workflow.readPolicy` fragment. A bare string is accepted
|
|
95
|
+
* as `{ shell: <string> }` for a minimal settings file.
|
|
96
|
+
*/
|
|
97
|
+
export function coerceReadPolicyConfig(
|
|
98
|
+
raw: unknown,
|
|
99
|
+
base: ReadPolicyConfig = DEFAULT_READ_POLICY_CONFIG as ReadPolicyConfig,
|
|
100
|
+
): ReadPolicyConfig {
|
|
101
|
+
if (typeof raw === "string") {
|
|
102
|
+
return { ...base, shell: coerceShell(raw) ?? base.shell };
|
|
103
|
+
}
|
|
104
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
105
|
+
return { ...base };
|
|
106
|
+
}
|
|
107
|
+
const obj = raw as JsonObject;
|
|
108
|
+
return {
|
|
109
|
+
allowedPaths:
|
|
110
|
+
coerceStringList(obj.allowedPaths) ?? [...base.allowedPaths],
|
|
111
|
+
shell: coerceShell(obj.shell) ?? base.shell,
|
|
112
|
+
guardedTools:
|
|
113
|
+
coerceStringList(obj.guardedTools) ?? [...base.guardedTools],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function extractReadPolicyRaw(
|
|
118
|
+
settings: unknown,
|
|
119
|
+
): RawReadPolicySetting | undefined {
|
|
120
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
121
|
+
return undefined;
|
|
122
|
+
const wf = (settings as JsonObject)[WORKFLOW_SETTINGS_KEY];
|
|
123
|
+
if (!wf || typeof wf !== "object" || Array.isArray(wf)) return undefined;
|
|
124
|
+
const raw = (wf as JsonObject)[READ_POLICY_KEY];
|
|
125
|
+
if (typeof raw === "string") return raw;
|
|
126
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw))
|
|
127
|
+
return raw as JsonObject;
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Merge global then project; shell is project-over-global, lists union. */
|
|
132
|
+
export function mergeReadPolicyConfig(
|
|
133
|
+
globalRaw: unknown,
|
|
134
|
+
projectRaw: unknown,
|
|
135
|
+
): ReadPolicyConfig {
|
|
136
|
+
const fromGlobal = coerceReadPolicyConfig(globalRaw);
|
|
137
|
+
const fromProject = coerceReadPolicyConfig(projectRaw, fromGlobal);
|
|
138
|
+
return {
|
|
139
|
+
allowedPaths: unionStrings(
|
|
140
|
+
fromGlobal.allowedPaths,
|
|
141
|
+
fromProject.allowedPaths,
|
|
142
|
+
),
|
|
143
|
+
shell: fromProject.shell,
|
|
144
|
+
guardedTools: unionStrings(
|
|
145
|
+
fromGlobal.guardedTools,
|
|
146
|
+
fromProject.guardedTools,
|
|
147
|
+
),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ReadPolicyConfigPaths {
|
|
152
|
+
global: string;
|
|
153
|
+
project: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function readPolicyConfigPaths(
|
|
157
|
+
cwd: string,
|
|
158
|
+
agentDir: string,
|
|
159
|
+
): ReadPolicyConfigPaths {
|
|
160
|
+
const nativeCwd = translateForeignPath(cwd).path || cwd;
|
|
161
|
+
const nativeAgentDir = translateForeignPath(agentDir).path || agentDir;
|
|
162
|
+
return {
|
|
163
|
+
global: path.join(nativeAgentDir, "settings.json"),
|
|
164
|
+
project: path.join(nativeCwd, COMMIT_CONFIG_DIR, "settings.json"),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readJsonSafe(fp: string): JsonObject | undefined {
|
|
169
|
+
try {
|
|
170
|
+
if (!fs.existsSync(fp)) return undefined;
|
|
171
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
172
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
173
|
+
return undefined;
|
|
174
|
+
return parsed as JsonObject;
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type ReadPolicyScopeSource = "default" | "global" | "project";
|
|
181
|
+
|
|
182
|
+
export interface ResolvedReadPolicy {
|
|
183
|
+
config: ReadPolicyConfig;
|
|
184
|
+
sources: {
|
|
185
|
+
global: boolean;
|
|
186
|
+
project: boolean;
|
|
187
|
+
projectIgnored: boolean;
|
|
188
|
+
shell: ReadPolicyScopeSource;
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Resolve read settings; project settings ignored unless trusted. */
|
|
193
|
+
export function readReadPolicyConfig(
|
|
194
|
+
cwd: string,
|
|
195
|
+
agentDir: string,
|
|
196
|
+
options: { trusted?: boolean } = {},
|
|
197
|
+
): ResolvedReadPolicy {
|
|
198
|
+
const paths = readPolicyConfigPaths(cwd, agentDir);
|
|
199
|
+
const globalRaw = extractReadPolicyRaw(readJsonSafe(paths.global));
|
|
200
|
+
const projectRaw = extractReadPolicyRaw(readJsonSafe(paths.project));
|
|
201
|
+
const trusted = options.trusted === true;
|
|
202
|
+
const effectiveProjectRaw = trusted ? projectRaw : undefined;
|
|
203
|
+
const shellSource: ReadPolicyScopeSource =
|
|
204
|
+
typeof effectiveProjectRaw === "object" &&
|
|
205
|
+
effectiveProjectRaw &&
|
|
206
|
+
coerceShell((effectiveProjectRaw as JsonObject).shell)
|
|
207
|
+
? "project"
|
|
208
|
+
: typeof globalRaw === "object" &&
|
|
209
|
+
globalRaw &&
|
|
210
|
+
coerceShell((globalRaw as JsonObject).shell)
|
|
211
|
+
? "global"
|
|
212
|
+
: "default";
|
|
213
|
+
return {
|
|
214
|
+
config: mergeReadPolicyConfig(globalRaw, effectiveProjectRaw),
|
|
215
|
+
sources: {
|
|
216
|
+
global: globalRaw !== undefined,
|
|
217
|
+
project: projectRaw !== undefined,
|
|
218
|
+
projectIgnored: projectRaw !== undefined && !trusted,
|
|
219
|
+
shell: shellSource,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Read-modify-write `workflow.readPolicy`. Preserves every other key. The
|
|
226
|
+
* lists are additive; `shell` is replaced.
|
|
227
|
+
*/
|
|
228
|
+
export function writeReadPolicyConfig(
|
|
229
|
+
scope: "global" | "project",
|
|
230
|
+
cwd: string,
|
|
231
|
+
agentDir: string,
|
|
232
|
+
patch: Partial<ReadPolicyConfig>,
|
|
233
|
+
): { path: string; ok: boolean; error?: string } {
|
|
234
|
+
const paths = readPolicyConfigPaths(cwd, agentDir);
|
|
235
|
+
const fp = scope === "project" ? paths.project : paths.global;
|
|
236
|
+
if (process.env.PI_FOLDER_POLICY_LOCKED === "1") {
|
|
237
|
+
return {
|
|
238
|
+
path: fp,
|
|
239
|
+
ok: false,
|
|
240
|
+
error: "read policy is locked for this subagent",
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const existing = readJsonSafe(fp);
|
|
245
|
+
const root: JsonObject = existing ? { ...existing } : {};
|
|
246
|
+
const wfExisting = root[WORKFLOW_SETTINGS_KEY];
|
|
247
|
+
const wf: JsonObject =
|
|
248
|
+
wfExisting && typeof wfExisting === "object" && !Array.isArray(wfExisting)
|
|
249
|
+
? { ...(wfExisting as JsonObject) }
|
|
250
|
+
: {};
|
|
251
|
+
const current = coerceReadPolicyConfig(wf[READ_POLICY_KEY]);
|
|
252
|
+
const next: ReadPolicyConfig = {
|
|
253
|
+
allowedPaths: unionStrings(
|
|
254
|
+
current.allowedPaths,
|
|
255
|
+
coerceStringList(patch.allowedPaths) ?? [],
|
|
256
|
+
),
|
|
257
|
+
shell: coerceShell(patch.shell) ?? current.shell,
|
|
258
|
+
guardedTools: unionStrings(
|
|
259
|
+
current.guardedTools,
|
|
260
|
+
coerceStringList(patch.guardedTools) ?? [],
|
|
261
|
+
),
|
|
262
|
+
};
|
|
263
|
+
wf[READ_POLICY_KEY] = next;
|
|
264
|
+
root[WORKFLOW_SETTINGS_KEY] = wf;
|
|
265
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
266
|
+
fs.writeFileSync(fp, `${JSON.stringify(root, null, 2)}\n`, "utf8");
|
|
267
|
+
return { path: fp, ok: true };
|
|
268
|
+
} catch (error: unknown) {
|
|
269
|
+
return {
|
|
270
|
+
path: fp,
|
|
271
|
+
ok: false,
|
|
272
|
+
error: error instanceof Error ? error.message : String(error),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Remove one exact read grant. Lists are otherwise additive. */
|
|
278
|
+
export function revokeReadPolicyPath(
|
|
279
|
+
scope: "global" | "project",
|
|
280
|
+
cwd: string,
|
|
281
|
+
agentDir: string,
|
|
282
|
+
grant: string,
|
|
283
|
+
): { path: string; ok: boolean; removed: boolean; error?: string } {
|
|
284
|
+
const paths = readPolicyConfigPaths(cwd, agentDir);
|
|
285
|
+
const fp = scope === "project" ? paths.project : paths.global;
|
|
286
|
+
if (process.env.PI_FOLDER_POLICY_LOCKED === "1") {
|
|
287
|
+
return {
|
|
288
|
+
path: fp,
|
|
289
|
+
ok: false,
|
|
290
|
+
removed: false,
|
|
291
|
+
error: "read policy is locked for this subagent",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const existing = readJsonSafe(fp);
|
|
296
|
+
const root: JsonObject = existing ? { ...existing } : {};
|
|
297
|
+
const wfExisting = root[WORKFLOW_SETTINGS_KEY];
|
|
298
|
+
const wf: JsonObject =
|
|
299
|
+
wfExisting && typeof wfExisting === "object" && !Array.isArray(wfExisting)
|
|
300
|
+
? { ...(wfExisting as JsonObject) }
|
|
301
|
+
: {};
|
|
302
|
+
const current = coerceReadPolicyConfig(wf[READ_POLICY_KEY]);
|
|
303
|
+
const target = grant.trim();
|
|
304
|
+
const nextPaths = current.allowedPaths.filter(
|
|
305
|
+
(entry) => entry.trim() !== target,
|
|
306
|
+
);
|
|
307
|
+
const removed = nextPaths.length !== current.allowedPaths.length;
|
|
308
|
+
wf[READ_POLICY_KEY] = { ...current, allowedPaths: nextPaths };
|
|
309
|
+
root[WORKFLOW_SETTINGS_KEY] = wf;
|
|
310
|
+
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
311
|
+
fs.writeFileSync(fp, `${JSON.stringify(root, null, 2)}\n`, "utf8");
|
|
312
|
+
return { path: fp, ok: true, removed };
|
|
313
|
+
} catch (error: unknown) {
|
|
314
|
+
return {
|
|
315
|
+
path: fp,
|
|
316
|
+
ok: false,
|
|
317
|
+
removed: false,
|
|
318
|
+
error: error instanceof Error ? error.message : String(error),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Decision table ───────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
export type ReadGateDecision = "allow" | "ask" | "block";
|
|
326
|
+
|
|
327
|
+
export interface ReadGateInput {
|
|
328
|
+
/** A target matches the sensitive deny list. */
|
|
329
|
+
sensitive: boolean;
|
|
330
|
+
/** A target is outside the folder roots. */
|
|
331
|
+
outside: boolean;
|
|
332
|
+
outsideAction: FolderOutsideAction;
|
|
333
|
+
/** A turn grant / allowedPaths entry already covers every outside target. */
|
|
334
|
+
granted?: boolean;
|
|
335
|
+
/** The shell analyzer could not verify the command. */
|
|
336
|
+
uncertain?: boolean;
|
|
337
|
+
shell: ReadShellMode;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* sensitive → block (never softened by a grant)
|
|
342
|
+
* outside + block → block
|
|
343
|
+
* outside + ask → ask unless already granted
|
|
344
|
+
* uncertain + strict → ask
|
|
345
|
+
* otherwise → allow
|
|
346
|
+
*/
|
|
347
|
+
export function decideReadGate(input: ReadGateInput): ReadGateDecision {
|
|
348
|
+
if (input.sensitive) return "block";
|
|
349
|
+
if (input.outside) {
|
|
350
|
+
if (input.outsideAction === "block") return "block";
|
|
351
|
+
if (!input.granted) return "ask";
|
|
352
|
+
}
|
|
353
|
+
if (input.uncertain && input.shell === "strict") return "ask";
|
|
354
|
+
return "allow";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Human-readable summary of the read shell mode. */
|
|
358
|
+
export function describeReadShellMode(mode: ReadShellMode): string {
|
|
359
|
+
return mode === "strict"
|
|
360
|
+
? "recognized reads are path-gated; unverifiable commands ask first"
|
|
361
|
+
: "recognized reads are path-gated; unverifiable commands are documented residual";
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Dialog / denial helpers ─────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
export const READ_INSTRUCTION_MAX_LEN = 2000;
|
|
367
|
+
export const READ_DENY_WITH_INSTRUCTIONS_PREFIX = "Deny with";
|
|
368
|
+
export const READ_DENY_WITH_INSTRUCTIONS_LABEL = `${READ_DENY_WITH_INSTRUCTIONS_PREFIX} instructions…`;
|
|
369
|
+
export const READ_INSTRUCTION_PROMPT_TITLE =
|
|
370
|
+
"Read denied — tell the agent how to proceed";
|
|
371
|
+
export const READ_INSTRUCTION_PROMPT_PLACEHOLDER =
|
|
372
|
+
"e.g. only use that folder for reference, do not copy secrets";
|
|
373
|
+
|
|
374
|
+
export type ReadGateItemKind = "folder" | "folders" | "command" | "commands" | "mixed";
|
|
375
|
+
|
|
376
|
+
export interface ReadGateItem {
|
|
377
|
+
tool: string;
|
|
378
|
+
target: FolderTarget;
|
|
379
|
+
/** Folder root a persistent grant would add ("" for uncertain commands). */
|
|
380
|
+
grantRoot: string;
|
|
381
|
+
/** Why the item is gated. */
|
|
382
|
+
why: string;
|
|
383
|
+
key: string;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export type ReadGateChoice =
|
|
387
|
+
| "allow-once"
|
|
388
|
+
| "always"
|
|
389
|
+
| "deny-with-instructions"
|
|
390
|
+
| "deny";
|
|
391
|
+
|
|
392
|
+
/** Labels for the ask dialog; the parser and the UI share this table. */
|
|
393
|
+
export function readGateOptions(kind: ReadGateItemKind): readonly string[] {
|
|
394
|
+
const always =
|
|
395
|
+
kind === "folder"
|
|
396
|
+
? "Always allow this folder"
|
|
397
|
+
: kind === "folders"
|
|
398
|
+
? "Always allow these folders"
|
|
399
|
+
: kind === "mixed"
|
|
400
|
+
? "Always allow these folders"
|
|
401
|
+
: "";
|
|
402
|
+
return [
|
|
403
|
+
"Allow once (this turn)",
|
|
404
|
+
...(always ? [always] : []),
|
|
405
|
+
"Deny",
|
|
406
|
+
READ_DENY_WITH_INSTRUCTIONS_LABEL,
|
|
407
|
+
];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The “Always allow…” label for a kind (empty for command-only). */
|
|
411
|
+
export function readGateAlwaysLabel(kind: ReadGateItemKind): string {
|
|
412
|
+
if (kind === "command" || kind === "commands") return "";
|
|
413
|
+
return readGateOptions(kind)[1] ?? "";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Parse a dialog answer; unknown/cancel/empty fail closed to deny. */
|
|
417
|
+
export function parseReadGateChoice(choice: unknown): ReadGateChoice {
|
|
418
|
+
const text = String(choice ?? "").trim();
|
|
419
|
+
if (text.startsWith("Allow once")) return "allow-once";
|
|
420
|
+
if (text.startsWith("Always allow")) return "always";
|
|
421
|
+
if (text.startsWith(READ_DENY_WITH_INSTRUCTIONS_PREFIX))
|
|
422
|
+
return "deny-with-instructions";
|
|
423
|
+
return "deny";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Normalise a free-text denial instruction. */
|
|
427
|
+
export function sanitizeReadInstruction(raw: unknown): string {
|
|
428
|
+
let text = String(raw ?? "")
|
|
429
|
+
.replace(/\r\n?/g, "\n")
|
|
430
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
|
|
431
|
+
.trim();
|
|
432
|
+
if (text.length > READ_INSTRUCTION_MAX_LEN)
|
|
433
|
+
text = `${text.slice(0, READ_INSTRUCTION_MAX_LEN)}…`;
|
|
434
|
+
return text;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export interface ReadDenyReasonInput {
|
|
438
|
+
kind: "sensitive" | "outside" | "uncertain" | "blocked";
|
|
439
|
+
target: string;
|
|
440
|
+
pattern?: string;
|
|
441
|
+
outsideAction?: FolderOutsideAction;
|
|
442
|
+
instruction?: string;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Build the tool-result reason for a read refusal. */
|
|
446
|
+
export function buildReadDenyReason(input: ReadDenyReasonInput): string {
|
|
447
|
+
const instruction = input.instruction?.trim();
|
|
448
|
+
const guidance = instruction
|
|
449
|
+
? ` The user's instruction:\n\n${instruction}\n\nDo not retry this read. Follow the instruction above. If it cannot be followed, say so and stop.`
|
|
450
|
+
: "";
|
|
451
|
+
switch (input.kind) {
|
|
452
|
+
case "sensitive":
|
|
453
|
+
return (
|
|
454
|
+
`Read policy: \`${input.target}\` is a sensitive file` +
|
|
455
|
+
(input.pattern ? ` (${input.pattern})` : "") +
|
|
456
|
+
". This cannot be overridden by a folder grant. Do not retry; ask the user if they want to add a manual `sensitiveExemptions` entry." +
|
|
457
|
+
guidance
|
|
458
|
+
);
|
|
459
|
+
case "blocked":
|
|
460
|
+
return (
|
|
461
|
+
`Read policy: \`${input.target}\` is outside the permitted folders and the folder policy is 'block'. Do not retry. Ask the user to open the project at that folder or change /folder-policy.` +
|
|
462
|
+
guidance
|
|
463
|
+
);
|
|
464
|
+
case "uncertain":
|
|
465
|
+
return (
|
|
466
|
+
`Read policy 'strict': the command's file access could not be verified (\`${input.target}\`). Do not retry the same form; use a recognized read tool/command, or ask the user to run it.` +
|
|
467
|
+
guidance
|
|
468
|
+
);
|
|
469
|
+
case "outside":
|
|
470
|
+
default:
|
|
471
|
+
return (
|
|
472
|
+
`Read policy: \`${input.target}\` is outside the permitted folders. Do not retry. Ask the user for access (allow once, always allow the folder, or /folder-policy), or use the current repository folder.` +
|
|
473
|
+
guidance
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Build the user-role message for deny-with-instructions. */
|
|
479
|
+
export function buildReadGuidanceMessage(input: {
|
|
480
|
+
target: string;
|
|
481
|
+
instruction: string;
|
|
482
|
+
}): string {
|
|
483
|
+
return (
|
|
484
|
+
`[read denied — the user asked you to do something different]\n` +
|
|
485
|
+
`The user denied the read of ${input.target}. Their instruction:\n\n` +
|
|
486
|
+
`${input.instruction.trim()}`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Stable key for a gated read target (turn grants / run denials). */
|
|
491
|
+
export function readGateTargetKey(target: FolderTarget): string {
|
|
492
|
+
return `path:${target.path}`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Build a gate item from a target; primary for the dialog and grants. */
|
|
496
|
+
export function readGateItemFromTarget(
|
|
497
|
+
tool: string,
|
|
498
|
+
target: FolderTarget,
|
|
499
|
+
why: string,
|
|
500
|
+
baseRoot?: string,
|
|
501
|
+
): ReadGateItem {
|
|
502
|
+
const grantRoot =
|
|
503
|
+
(baseRoot && outsideProjectRootForTarget(target, baseRoot)) ||
|
|
504
|
+
grantRootForTarget(target);
|
|
505
|
+
return {
|
|
506
|
+
tool,
|
|
507
|
+
target,
|
|
508
|
+
grantRoot,
|
|
509
|
+
why,
|
|
510
|
+
key: readGateTargetKey(target),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function readGateItemsKind(items: readonly ReadGateItem[]): ReadGateItemKind {
|
|
515
|
+
const folders = items.filter((item) => item.grantRoot);
|
|
516
|
+
const commands = items.filter((item) => !item.grantRoot);
|
|
517
|
+
if (folders.length > 0 && commands.length > 0) return "mixed";
|
|
518
|
+
if (folders.length === 1) return "folder";
|
|
519
|
+
if (folders.length > 1) return "folders";
|
|
520
|
+
if (commands.length === 1) return "command";
|
|
521
|
+
return "commands";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function describeReadGateItems(items: readonly ReadGateItem[]): string {
|
|
525
|
+
const folders = [...new Set(items.map((item) => item.grantRoot).filter(Boolean))];
|
|
526
|
+
const commands = items.filter((item) => !item.grantRoot).length;
|
|
527
|
+
const parts: string[] = [];
|
|
528
|
+
if (folders.length > 0) parts.push(`${folders.length} folder(s)`);
|
|
529
|
+
if (commands > 0) parts.push(`${commands} unverified command(s)`);
|
|
530
|
+
return parts.join(" + ") || "no targets";
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function readGateTargetsLabel(items: readonly ReadGateItem[]): string {
|
|
534
|
+
const labels = items.map((item) => item.target.path || item.grantRoot);
|
|
535
|
+
const unique = [...new Set(labels)];
|
|
536
|
+
const shown = unique.slice(0, 4).join(", ");
|
|
537
|
+
return unique.length > 4 ? `${shown}, …` : shown;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Bounded one-per-line target detail for the permission dialogs. */
|
|
541
|
+
export function readGateDetail(
|
|
542
|
+
items: readonly ReadGateItem[],
|
|
543
|
+
max = 5,
|
|
544
|
+
): string {
|
|
545
|
+
const seen = new Set<string>();
|
|
546
|
+
const lines: string[] = [];
|
|
547
|
+
for (const item of items) {
|
|
548
|
+
const key = `${item.target.path}\0${item.grantRoot}`;
|
|
549
|
+
if (seen.has(key)) continue;
|
|
550
|
+
seen.add(key);
|
|
551
|
+
if (lines.length >= max) break;
|
|
552
|
+
const tool = item.tool ? `${item.tool} → ` : "";
|
|
553
|
+
const target = item.target.path || item.why;
|
|
554
|
+
const grant =
|
|
555
|
+
item.grantRoot && item.grantRoot !== item.target.path
|
|
556
|
+
? ` (folder: ${item.grantRoot})`
|
|
557
|
+
: "";
|
|
558
|
+
lines.push(` • ${tool}${target}${grant}`);
|
|
559
|
+
}
|
|
560
|
+
const total = new Set(items.map((item) => item.target.path)).size;
|
|
561
|
+
if (total > lines.length) lines.push(` … and ${total - lines.length} more`);
|
|
562
|
+
return lines.join("\n");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function readGateTitle(
|
|
566
|
+
items: readonly ReadGateItem[],
|
|
567
|
+
base?: string,
|
|
568
|
+
): string {
|
|
569
|
+
const detail = readGateDetail(items);
|
|
570
|
+
return (
|
|
571
|
+
`Read policy: allow ${describeReadGateItems(items)}?` +
|
|
572
|
+
(base ? `\nOutside: ${base}` : "") +
|
|
573
|
+
(detail ? `\n${detail}` : "")
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Grant root for a dialog target; exported from folder-policy for callers. */
|
|
578
|
+
export { grantRootForTarget, outsideProjectRootForTarget };
|
|
579
|
+
|
|
580
|
+
// ── /read-policy completions ────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
const READ_POLICY_SUBS: readonly {
|
|
583
|
+
name: string;
|
|
584
|
+
description: string;
|
|
585
|
+
}[] = [
|
|
586
|
+
{ name: "status", description: "show read grants, shell mode and the shared folder scope" },
|
|
587
|
+
{ name: "allow", description: "add a read-only folder/file grant" },
|
|
588
|
+
{ name: "revoke", description: "remove a read grant" },
|
|
589
|
+
{ name: "shell", description: "paths (balanced) or strict" },
|
|
590
|
+
{ name: "guard", description: "add a tool name that must be read-guarded" },
|
|
591
|
+
];
|
|
592
|
+
|
|
593
|
+
const READ_SCOPES: readonly string[] = ["global", "project"];
|
|
594
|
+
|
|
595
|
+
/** Full-argument completions for `/read-policy`. */
|
|
596
|
+
export function readPolicyArgumentCompletions(
|
|
597
|
+
prefix: string,
|
|
598
|
+
): ArgumentCompletion[] | null {
|
|
599
|
+
const text = String(prefix ?? "");
|
|
600
|
+
const trailing = /\s$/.test(text);
|
|
601
|
+
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
|
602
|
+
const committed = trailing ? tokens : tokens.slice(0, -1);
|
|
603
|
+
const partial = trailing ? "" : (tokens[tokens.length - 1] ?? "");
|
|
604
|
+
const first = committed[0] ?? "";
|
|
605
|
+
const candidates: { value: string; description?: string }[] = [];
|
|
606
|
+
const add = (value: string, description?: string) =>
|
|
607
|
+
candidates.push({ value, description });
|
|
608
|
+
|
|
609
|
+
if (committed.length === 0) {
|
|
610
|
+
for (const sub of READ_POLICY_SUBS) add(sub.name, sub.description);
|
|
611
|
+
} else if (committed.length === 1) {
|
|
612
|
+
if (first === "shell") {
|
|
613
|
+
add("paths", "gate recognized reads; unknown commands are residual");
|
|
614
|
+
add("strict", "also ask for interpreter/inline/unverifiable commands");
|
|
615
|
+
} else if (first === "allow" || first === "revoke" || first === "guard") {
|
|
616
|
+
// Path/tool value is free text; scope follows.
|
|
617
|
+
}
|
|
618
|
+
} else if (
|
|
619
|
+
committed.length === 2 &&
|
|
620
|
+
["allow", "revoke", "shell", "guard"].includes(first)
|
|
621
|
+
) {
|
|
622
|
+
for (const scope of READ_SCOPES) add(scope);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const filtered = candidates.filter((candidate) =>
|
|
626
|
+
candidate.value.toLowerCase().startsWith(partial.toLowerCase()),
|
|
627
|
+
);
|
|
628
|
+
if (filtered.length === 0) return null;
|
|
629
|
+
if (
|
|
630
|
+
suppressExactArgumentMatch(
|
|
631
|
+
filtered.map((candidate) => candidate.value),
|
|
632
|
+
partial,
|
|
633
|
+
)
|
|
634
|
+
)
|
|
635
|
+
return null;
|
|
636
|
+
return filtered.map((candidate) => ({
|
|
637
|
+
value: joinArgumentValue(text, candidate.value),
|
|
638
|
+
label: candidate.value,
|
|
639
|
+
...(candidate.description ? { description: candidate.description } : {}),
|
|
640
|
+
}));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** A concise status line for /read-policy (folder mode comes from folderPolicy). */
|
|
644
|
+
export function describeReadPolicy(
|
|
645
|
+
config: ReadPolicyConfig,
|
|
646
|
+
folder: FolderPolicyConfig,
|
|
647
|
+
): string {
|
|
648
|
+
const grants =
|
|
649
|
+
config.allowedPaths.length > 0 ? config.allowedPaths.join(", ") : "none";
|
|
650
|
+
return (
|
|
651
|
+
`Read policy: shared folder scope '${folder.mode}' (${folder.outside}); ` +
|
|
652
|
+
`shell '${config.shell}'; grants: ${grants}`
|
|
653
|
+
);
|
|
654
|
+
}
|