@dieulc/workflow 0.5.2 → 0.6.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.
package/edit-policy.ts ADDED
@@ -0,0 +1,955 @@
1
+ /**
2
+ * Workflow Extension — Write Policy
3
+ *
4
+ * User-controllable permission for file modification in Pi's DEFAULT mode:
5
+ *
6
+ * "allow" (default) — the agent may write files freely (today's behaviour).
7
+ * "ask" — every file write pops a confirmation dialog. The
8
+ * user can allow once, allow that path/command permanently, or
9
+ * deny. Persistent grants live in the allowlists below.
10
+ * "never" — the agent may not write files at all. The user (or
11
+ * /build, the deliberate execution mode) does the writing.
12
+ *
13
+ * WHY THIS EXISTS: the Workflow extension's three modes are Plan → Build →
14
+ * Default. Plan mode is read-only and Build mode is unrestricted, but
15
+ * Default mode is ALSO unrestricted — `resolveSessionRole()` returns
16
+ * undefined there, so the tool_call gate in index.ts has no branch to take
17
+ * and the agent edits files without asking. This module supplies the missing
18
+ * permission knob, enforced deterministically (a soft prompt is not enough —
19
+ * the same lesson as the commit policy).
20
+ *
21
+ * This module holds the pure, I/O-light parts (config, coercion, merge,
22
+ * read, write, path matching, command classification, the decision table) so
23
+ * they can be unit-tested without a Pi runtime. Nothing here imports the Pi
24
+ * SDK at module scope — same discipline as commit-policy.ts and permissions.ts.
25
+ *
26
+ * Settings live under `workflow.writePolicy` in `~/.pi/agent/settings.json`
27
+ * (global) or `<cwd>/.pi/settings.json` (project). Project wins for `mode`;
28
+ * the allowlists are additive (unioned) across scopes, because an allowlist
29
+ * is a grant of trust, not a restriction. Writes preserve every other key in
30
+ * the file (Pi owns settings.json — we must not clobber it).
31
+ */
32
+
33
+ import * as fs from "node:fs";
34
+ import * as path from "node:path";
35
+ import type { ArgumentCompletion } from "./utils.ts";
36
+ import { joinArgumentValue, suppressExactArgumentMatch } from "./utils.ts";
37
+ import {
38
+ COMMIT_CONFIG_DIR,
39
+ WORKFLOW_SETTINGS_KEY,
40
+ type JsonObject,
41
+ } from "./commit-policy.ts";
42
+ import {
43
+ commandClassOf,
44
+ forbiddenSyntaxReason,
45
+ gitCommitPolicyHit,
46
+ splitCommandSegments,
47
+ stripHarmlessRedirects,
48
+ DENIED_FLAG_RE,
49
+ } from "./permissions.ts";
50
+
51
+ /** Nested key under WORKFLOW_SETTINGS_KEY. */
52
+ export const WRITE_POLICY_KEY = "writePolicy";
53
+
54
+ /** How the agent may modify files in Default mode. */
55
+ export type WritePolicyMode = "allow" | "ask" | "never";
56
+
57
+ export interface WritePolicyConfig {
58
+ mode: WritePolicyMode;
59
+ /** Absolute paths the agent may write without being asked again. */
60
+ allowedPaths: string[];
61
+ /** Command keys the agent may run without being asked again. */
62
+ allowedCommands: string[];
63
+ /** Extra tool names treated as file writes (e.g. "ast_grep_replace"). */
64
+ mutatingTools: string[];
65
+ }
66
+
67
+ export const DEFAULT_WRITE_POLICY_CONFIG: Readonly<WritePolicyConfig> = {
68
+ mode: "allow",
69
+ allowedPaths: [],
70
+ allowedCommands: [],
71
+ mutatingTools: [],
72
+ };
73
+
74
+ const WRITE_POLICY_MODES: readonly WritePolicyMode[] = ["allow", "ask", "never"];
75
+
76
+ /**
77
+ * A raw `workflow.writePolicy` fragment. A bare mode string ("allow" |
78
+ * "ask" | "never") is accepted as shorthand for `{ mode: <string> }` so a
79
+ * minimal settings file stays readable.
80
+ */
81
+ export type RawWritePolicySetting = JsonObject | string;
82
+
83
+ /** Coerce an unknown value into a WritePolicyMode, or undefined. */
84
+ function coerceMode(value: unknown): WritePolicyMode | undefined {
85
+ if (typeof value !== "string") return undefined;
86
+ const normalized = value.trim().toLowerCase();
87
+ return (WRITE_POLICY_MODES as readonly string[]).includes(normalized)
88
+ ? (normalized as WritePolicyMode)
89
+ : undefined;
90
+ }
91
+
92
+ /**
93
+ * Coerce an unknown value into a list of non-empty strings. Non-arrays and
94
+ * non-string members are dropped. Never throws.
95
+ */
96
+ function coerceStringList(value: unknown): string[] | undefined {
97
+ if (!Array.isArray(value)) return undefined;
98
+ const out: string[] = [];
99
+ for (const item of value) {
100
+ if (typeof item !== "string") continue;
101
+ const trimmed = item.trim();
102
+ if (!trimmed) continue;
103
+ if (out.includes(trimmed)) continue;
104
+ out.push(trimmed);
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** Union two string lists, preserving order and dropping duplicates. */
110
+ function unionStrings(a: readonly string[], b: readonly string[]): string[] {
111
+ const out = [...a];
112
+ for (const item of b) if (!out.includes(item)) out.push(item);
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Coerce an untrusted settings fragment into a complete WritePolicyConfig.
118
+ * Accepts an object (`{ mode: "ask" }`) or a bare mode string shorthand.
119
+ * Booleans, numbers, garbage and wrong types all fall back to `base`
120
+ * (defaults) — there is no boolean shorthand for the write policy.
121
+ * Never throws.
122
+ */
123
+ export function coerceWritePolicyConfig(
124
+ raw: unknown,
125
+ base: WritePolicyConfig = DEFAULT_WRITE_POLICY_CONFIG as WritePolicyConfig,
126
+ ): WritePolicyConfig {
127
+ if (typeof raw === "string") {
128
+ const mode = coerceMode(raw);
129
+ return { ...base, mode: mode ?? base.mode };
130
+ }
131
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
132
+ return { ...base };
133
+ }
134
+ const obj = raw as JsonObject;
135
+ const mode = coerceMode(obj.mode);
136
+ return {
137
+ mode: mode ?? base.mode,
138
+ allowedPaths:
139
+ coerceStringList(obj.allowedPaths) ?? [...base.allowedPaths],
140
+ allowedCommands:
141
+ coerceStringList(obj.allowedCommands) ?? [...base.allowedCommands],
142
+ mutatingTools:
143
+ coerceStringList(obj.mutatingTools) ?? [...base.mutatingTools],
144
+ };
145
+ }
146
+
147
+ /** Extract the `workflow.writePolicy` fragment from a raw settings object. */
148
+ export function extractWritePolicyRaw(
149
+ settings: unknown,
150
+ ): RawWritePolicySetting | undefined {
151
+ if (!settings || typeof settings !== "object" || Array.isArray(settings))
152
+ return undefined;
153
+ const wf = (settings as JsonObject)[WORKFLOW_SETTINGS_KEY];
154
+ if (!wf || typeof wf !== "object" || Array.isArray(wf)) return undefined;
155
+ const raw = (wf as JsonObject)[WRITE_POLICY_KEY];
156
+ if (typeof raw === "string") return raw;
157
+ if (raw && typeof raw === "object" && !Array.isArray(raw))
158
+ return raw as JsonObject;
159
+ return undefined;
160
+ }
161
+
162
+ /**
163
+ * Merge global then project settings fragments, then coerce onto defaults.
164
+ * `mode` is project-over-global (field-by-field); the allowlists and the
165
+ * mutating-tool list are UNIONED, because an allowlist is additive trust.
166
+ */
167
+ export function mergeWritePolicyConfig(
168
+ globalRaw: unknown,
169
+ projectRaw: unknown,
170
+ ): WritePolicyConfig {
171
+ const fromGlobal = coerceWritePolicyConfig(globalRaw);
172
+ const fromProject = coerceWritePolicyConfig(projectRaw, fromGlobal);
173
+ return {
174
+ mode: fromProject.mode,
175
+ allowedPaths: unionStrings(
176
+ fromGlobal.allowedPaths,
177
+ fromProject.allowedPaths,
178
+ ),
179
+ allowedCommands: unionStrings(
180
+ fromGlobal.allowedCommands,
181
+ fromProject.allowedCommands,
182
+ ),
183
+ mutatingTools: unionStrings(
184
+ fromGlobal.mutatingTools,
185
+ fromProject.mutatingTools,
186
+ ),
187
+ };
188
+ }
189
+
190
+ export interface WritePolicyConfigPaths {
191
+ global: string;
192
+ project: string;
193
+ }
194
+
195
+ /**
196
+ * Settings file locations. `agentDir` is passed in rather than read from the
197
+ * Pi SDK so this stays pure and testable.
198
+ */
199
+ export function writePolicyConfigPaths(
200
+ cwd: string,
201
+ agentDir: string,
202
+ ): WritePolicyConfigPaths {
203
+ return {
204
+ global: path.join(agentDir, "settings.json"),
205
+ project: path.join(cwd, COMMIT_CONFIG_DIR, "settings.json"),
206
+ };
207
+ }
208
+
209
+ function readJsonSafe(fp: string): JsonObject | undefined {
210
+ try {
211
+ if (!fs.existsSync(fp)) return undefined;
212
+ const parsed: unknown = JSON.parse(fs.readFileSync(fp, "utf8"));
213
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
214
+ return undefined;
215
+ return parsed as JsonObject;
216
+ } catch {
217
+ return undefined;
218
+ }
219
+ }
220
+
221
+ /** Which settings scope supplied a field's effective value. */
222
+ export type WritePolicyScopeSource = "default" | "global" | "project";
223
+
224
+ /**
225
+ * Which raw fragment supplied the effective `mode`: the project fragment when
226
+ * it carries a valid mode, else the global fragment, else the built-in
227
+ * default. Pure and total.
228
+ */
229
+ export function writePolicyModeSourceOf(
230
+ globalRaw: RawWritePolicySetting | undefined,
231
+ projectRaw: RawWritePolicySetting | undefined,
232
+ ): WritePolicyScopeSource {
233
+ const hasModeKey = (raw: unknown): boolean =>
234
+ typeof raw === "string"
235
+ ? coerceMode(raw) !== undefined
236
+ : Boolean(raw) &&
237
+ typeof raw === "object" &&
238
+ !Array.isArray(raw) &&
239
+ coerceMode((raw as JsonObject).mode) !== undefined;
240
+ if (hasModeKey(projectRaw)) return "project";
241
+ if (hasModeKey(globalRaw)) return "global";
242
+ return "default";
243
+ }
244
+
245
+ export interface ResolvedWritePolicy {
246
+ config: WritePolicyConfig;
247
+ /** Which files contributed (for /write-policy status and debugging). */
248
+ sources: {
249
+ global: boolean;
250
+ project: boolean;
251
+ /** Which scope supplied the effective `mode` value. */
252
+ mode: WritePolicyScopeSource;
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Resolve the effective Write Policy config from disk.
258
+ * Never throws — a corrupt or missing file falls back to defaults.
259
+ */
260
+ export function readWritePolicyConfig(
261
+ cwd: string,
262
+ agentDir: string,
263
+ ): ResolvedWritePolicy {
264
+ const paths = writePolicyConfigPaths(cwd, agentDir);
265
+ const globalSettings = readJsonSafe(paths.global);
266
+ const projectSettings = readJsonSafe(paths.project);
267
+ const globalRaw = extractWritePolicyRaw(globalSettings);
268
+ const projectRaw = extractWritePolicyRaw(projectSettings);
269
+ const config = mergeWritePolicyConfig(globalRaw, projectRaw);
270
+ return {
271
+ config,
272
+ sources: {
273
+ global: globalRaw !== undefined,
274
+ project: projectRaw !== undefined,
275
+ mode: writePolicyModeSourceOf(globalRaw, projectRaw),
276
+ },
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Read-modify-write `workflow.writePolicy` into a settings file, preserving
282
+ * every other key in that file (Pi owns settings.json — we must not clobber
283
+ * it).
284
+ *
285
+ * `mode` is REPLACED by the patch. The three list fields are ADDITIVE: the
286
+ * patch's entries are appended to the stored ones (deduplicated, order
287
+ * preserved) and never remove what is already there. An allowlist is a grant
288
+ * of trust — removing an entry is done by editing settings.json directly.
289
+ */
290
+ export function writeWritePolicyConfig(
291
+ scope: "global" | "project",
292
+ cwd: string,
293
+ agentDir: string,
294
+ patch: Partial<WritePolicyConfig>,
295
+ ): { path: string; ok: boolean; error?: string } {
296
+ const paths = writePolicyConfigPaths(cwd, agentDir);
297
+ const fp = scope === "project" ? paths.project : paths.global;
298
+ try {
299
+ const existing = readJsonSafe(fp);
300
+ const root: JsonObject = existing ? { ...existing } : {};
301
+ const wfExisting = root[WORKFLOW_SETTINGS_KEY];
302
+ const wf: JsonObject =
303
+ wfExisting && typeof wfExisting === "object" && !Array.isArray(wfExisting)
304
+ ? { ...(wfExisting as JsonObject) }
305
+ : {};
306
+ const current = coerceWritePolicyConfig(wf[WRITE_POLICY_KEY]);
307
+ // `mode` is replaced by the patch; the three list fields are ADDITIVE —
308
+ // a plain spread would REPLACE them, silently dropping the entries that
309
+ // are already stored (an allowlist is a grant of trust, so a grant must
310
+ // never be lost by writing another one).
311
+ const next: WritePolicyConfig = {
312
+ mode: coerceWritePolicyConfig({ ...current, ...patch }, current).mode,
313
+ allowedPaths: unionStrings(
314
+ current.allowedPaths,
315
+ coerceStringList(patch.allowedPaths) ?? [],
316
+ ),
317
+ allowedCommands: unionStrings(
318
+ current.allowedCommands,
319
+ coerceStringList(patch.allowedCommands) ?? [],
320
+ ),
321
+ mutatingTools: unionStrings(
322
+ current.mutatingTools,
323
+ coerceStringList(patch.mutatingTools) ?? [],
324
+ ),
325
+ };
326
+ wf[WRITE_POLICY_KEY] = next;
327
+ root[WORKFLOW_SETTINGS_KEY] = wf;
328
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
329
+ fs.writeFileSync(fp, JSON.stringify(root, null, 2) + "\n", "utf8");
330
+ return { path: fp, ok: true };
331
+ } catch (e: unknown) {
332
+ return {
333
+ path: fp,
334
+ ok: false,
335
+ error: e instanceof Error ? e.message : String(e),
336
+ };
337
+ }
338
+ }
339
+
340
+ // ── Path matching ───────────────────────────────────────────────────
341
+
342
+ /**
343
+ * Canonical form of a path for allowlist comparison: absolute, forward
344
+ * slashes, lower-cased, no trailing separator.
345
+ *
346
+ * Lower-casing matches the existing `isPlanWritePath` convention in utils.ts
347
+ * (Windows paths are case-insensitive) and keeps the comparison
348
+ * platform-independent. A trailing separator is stripped so a directory and
349
+ * its own path compare equal; the filesystem root keeps its slash.
350
+ */
351
+ export function normalizeWritePath(p: unknown, cwd: string): string {
352
+ if (typeof p !== "string") return "";
353
+ const raw = p.trim();
354
+ if (!raw) return "";
355
+ const base = cwd && cwd.trim() ? cwd : process.cwd();
356
+ let resolved: string;
357
+ try {
358
+ resolved = path.resolve(base, raw);
359
+ } catch {
360
+ resolved = raw;
361
+ }
362
+ let normalized = resolved.replace(/\\/g, "/").toLowerCase();
363
+ while (
364
+ normalized.length > 1 &&
365
+ normalized.endsWith("/") &&
366
+ !/^[a-z]:\/$/.test(normalized)
367
+ ) {
368
+ normalized = normalized.slice(0, -1);
369
+ }
370
+ return normalized;
371
+ }
372
+
373
+ /**
374
+ * True when `target` is covered by an allowlist entry.
375
+ *
376
+ * An entry matches when it EQUALS the target, or when the target sits
377
+ * strictly inside it on a directory boundary — so `…/src` covers
378
+ * `…/src/a.ts` but never `…/src-old/a.ts`.
379
+ */
380
+ export function isPathAllowed(
381
+ target: unknown,
382
+ allowedPaths: readonly string[],
383
+ cwd: string,
384
+ ): boolean {
385
+ const normalizedTarget = normalizeWritePath(target, cwd);
386
+ if (!normalizedTarget) return false;
387
+ for (const entry of allowedPaths) {
388
+ const normalizedEntry = normalizeWritePath(entry, cwd);
389
+ if (!normalizedEntry) continue;
390
+ if (normalizedTarget === normalizedEntry) return true;
391
+ if (normalizedTarget.startsWith(`${normalizedEntry}/`)) return true;
392
+ }
393
+ return false;
394
+ }
395
+
396
+ // ── Command classification ──────────────────────────────────────────
397
+
398
+ /** The outcome of classifying one tool call. */
399
+ export interface WriteIntent {
400
+ /** True when the call may modify the filesystem. */
401
+ mutating: boolean;
402
+ /** Short human-readable cause, present when `mutating`. */
403
+ reason?: string;
404
+ /** The shell segment that triggered the classification. */
405
+ segment?: string;
406
+ }
407
+
408
+ /**
409
+ * Classify a shell command as mutating or not.
410
+ *
411
+ * Per segment, in this order:
412
+ * 1. forbidden shell syntax (file redirect, heredoc, command/process
413
+ * substitution, background `&`) → mutating. Checked FIRST so a
414
+ * redirect riding along with a git command (`git commit -m x > log`)
415
+ * is still caught as a write.
416
+ * 2. `git add` / `git commit` / `git push` → NOT mutating: they touch the
417
+ * index and the object store, not the user's working-tree files, and
418
+ * the independent commit policy already governs them.
419
+ * 3. an unrestricted command class (`commandClassOf` → "full") → mutating.
420
+ * This is the fail-closed default: anything not provably read-only
421
+ * (sed/awk programs, interpreters, unknown binaries) may write.
422
+ * 4. a write flag (`--fix`, `--write`, `--in-place`, `--update-snapshots`,
423
+ * `--output`, …) → mutating. Catches verify-class commands that still
424
+ * rewrite files (`eslint --fix`).
425
+ * 5. otherwise → not mutating (read-only and verify-class segments pass:
426
+ * `ls`, `cat`, `rg`, `git status`, `npm test`, `tsc --noEmit`).
427
+ */
428
+ export function commandMayWrite(command: unknown): WriteIntent {
429
+ if (typeof command !== "string" || !command.trim()) {
430
+ return { mutating: false };
431
+ }
432
+ for (const segment of splitCommandSegments(command)) {
433
+ const trimmed = segment.trim();
434
+ if (!trimmed) continue;
435
+
436
+ // 1. Forbidden syntax — a file redirect is a write whatever else runs.
437
+ const syntax = forbiddenSyntaxReason(trimmed);
438
+ if (syntax) {
439
+ return { mutating: true, reason: syntax, segment: trimmed };
440
+ }
441
+
442
+ // Classify the command with harmless redirections (`2>&1`, `>/dev/null`)
443
+ // removed, so `2>/dev/null ls` is judged as `ls` (read-only) rather than
444
+ // as an unknown binary. A segment that is nothing but a redirection
445
+ // writes no file.
446
+ const scrubbed = stripHarmlessRedirects(trimmed).trim();
447
+ if (!scrubbed) continue;
448
+
449
+ // 2. Git history operations — governed by the commit policy, not here.
450
+ if (gitCommitPolicyHit(scrubbed)) continue;
451
+
452
+ // 3. Not provably read-only → may write.
453
+ if (commandClassOf(scrubbed) === "full") {
454
+ return {
455
+ mutating: true,
456
+ reason: "runs a command that may modify files",
457
+ segment: trimmed,
458
+ };
459
+ }
460
+
461
+ // 4. A write flag on an otherwise-safe command.
462
+ const flag = scrubbed.match(DENIED_FLAG_RE);
463
+ if (flag) {
464
+ return {
465
+ mutating: true,
466
+ reason: `uses the write flag ${flag[2] ?? flag[0].trim()}`,
467
+ segment: trimmed,
468
+ };
469
+ }
470
+ }
471
+ return { mutating: false };
472
+ }
473
+
474
+ /** Normalize a shell segment into the key used for allowlist matching. */
475
+ export function commandAllowKey(segment: unknown): string {
476
+ if (typeof segment !== "string") return "";
477
+ return segment.trim().replace(/\s+/g, " ").toLowerCase();
478
+ }
479
+
480
+ /**
481
+ * True when `key` is covered by an allowlist entry.
482
+ *
483
+ * An entry matches when it EQUALS the key, or when the key continues the
484
+ * entry on a word boundary — so "npm run build" covers
485
+ * "npm run build --watch" but never "npm run buildx".
486
+ */
487
+ export function isCommandAllowed(
488
+ key: unknown,
489
+ allowedCommands: readonly string[],
490
+ ): boolean {
491
+ const normalizedKey = commandAllowKey(key);
492
+ if (!normalizedKey) return false;
493
+ for (const entry of allowedCommands) {
494
+ const normalizedEntry = commandAllowKey(entry);
495
+ if (!normalizedEntry) continue;
496
+ if (normalizedKey === normalizedEntry) return true;
497
+ if (normalizedKey.startsWith(`${normalizedEntry} `)) return true;
498
+ }
499
+ return false;
500
+ }
501
+
502
+ // ── Tool classification ─────────────────────────────────────────────
503
+
504
+ /** Built-in tools that write files. */
505
+ export const FILE_WRITE_TOOLS: readonly string[] = ["edit", "write"];
506
+
507
+ /** Built-in tools that run shell commands. */
508
+ export const SHELL_TOOLS: readonly string[] = ["bash", "powershell"];
509
+
510
+ /**
511
+ * The key under which a path-less mutating tool is allowlisted. The
512
+ * namespace prefix keeps a tool name from ever colliding with a shell
513
+ * command key.
514
+ */
515
+ export function toolAllowKey(toolName: string): string {
516
+ return `tool:${toolName.toLowerCase()}`;
517
+ }
518
+
519
+ /**
520
+ * Candidate target paths of a tool call. Handles the built-in file tools
521
+ * (`path` / `file_path`) and the array form used by extension tools
522
+ * (`paths`, e.g. ast_grep_replace).
523
+ */
524
+ export function writeTargetPaths(input: unknown): string[] {
525
+ if (!input || typeof input !== "object" || Array.isArray(input))
526
+ return [];
527
+ const obj = input as JsonObject;
528
+ const out: string[] = [];
529
+ const push = (value: unknown): void => {
530
+ if (typeof value !== "string") return;
531
+ const trimmed = value.trim();
532
+ if (trimmed && !out.includes(trimmed)) out.push(trimmed);
533
+ };
534
+ push(obj.path);
535
+ push(obj.file_path);
536
+ if (Array.isArray(obj.paths)) for (const p of obj.paths) push(p);
537
+ return out;
538
+ }
539
+
540
+ /** How one tool call should be treated by the write gate. */
541
+ export interface WriteCallClassification {
542
+ /** The call may modify the filesystem. */
543
+ mutating: boolean;
544
+ /** Short human-readable cause. */
545
+ reason?: string;
546
+ /** Candidate target paths (file tools and path-aware extension tools). */
547
+ paths: string[];
548
+ /**
549
+ * Key under which a persistent grant is stored: the normalized mutating
550
+ * shell segment, or `tool:<name>` for a path-less mutating tool. Absent
551
+ * when the call is gated by path instead.
552
+ */
553
+ key?: string;
554
+ }
555
+
556
+ /**
557
+ * Classify a tool call against the write policy's scope.
558
+ *
559
+ * `edit` / `write` always write. `bash` is classified by
560
+ * `commandMayWrite`. `powershell` always writes — it cannot be classified
561
+ * by the POSIX allowlist, so it fails closed (same stance the extension
562
+ * already takes for restricted roles). Any tool named in
563
+ * `config.mutatingTools` is treated as a write (the escape hatch for
564
+ * third-party write tools such as ast_grep_replace or the office tools).
565
+ */
566
+ export function classifyWriteCall(
567
+ toolName: string,
568
+ input: unknown,
569
+ config: WritePolicyConfig,
570
+ ): WriteCallClassification {
571
+ const name = String(toolName ?? "");
572
+ if (FILE_WRITE_TOOLS.includes(name)) {
573
+ const paths = writeTargetPaths(input);
574
+ return {
575
+ mutating: true,
576
+ reason: paths.length
577
+ ? `${name}s ${paths.join(", ")}`
578
+ : `${name}s a file`,
579
+ paths,
580
+ };
581
+ }
582
+ if (SHELL_TOOLS.includes(name)) {
583
+ const command = String(
584
+ (input && typeof input === "object" && !Array.isArray(input)
585
+ ? (input as JsonObject).command
586
+ : "") ?? "",
587
+ );
588
+ if (name === "powershell") {
589
+ // Not classifiable by the POSIX allowlist — fail closed.
590
+ return {
591
+ mutating: true,
592
+ reason: "runs a powershell command (not statically classifiable)",
593
+ paths: [],
594
+ key: commandAllowKey(command),
595
+ };
596
+ }
597
+ const intent = commandMayWrite(command);
598
+ return {
599
+ mutating: intent.mutating,
600
+ reason: intent.reason,
601
+ paths: [],
602
+ key: intent.segment ? commandAllowKey(intent.segment) : undefined,
603
+ };
604
+ }
605
+ if (config.mutatingTools.includes(name)) {
606
+ const paths = writeTargetPaths(input);
607
+ return {
608
+ mutating: true,
609
+ reason: `the ${name} tool modifies files`,
610
+ paths,
611
+ key: paths.length ? undefined : toolAllowKey(name),
612
+ };
613
+ }
614
+ return { mutating: false, paths: [] };
615
+ }
616
+
617
+ /**
618
+ * True when the call is already covered by a persistent grant: every target
619
+ * path is allow-listed, or (for a path-less call) its command key is.
620
+ */
621
+ export function isWriteCallAllowed(
622
+ classification: WriteCallClassification,
623
+ config: WritePolicyConfig,
624
+ cwd: string,
625
+ ): boolean {
626
+ if (classification.paths.length > 0) {
627
+ return classification.paths.every((p) =>
628
+ isPathAllowed(p, config.allowedPaths, cwd),
629
+ );
630
+ }
631
+ if (classification.key) {
632
+ return isCommandAllowed(classification.key, config.allowedCommands);
633
+ }
634
+ return false;
635
+ }
636
+
637
+ // ── Decision table ──────────────────────────────────────────────────
638
+
639
+ export type WriteGateDecision = "allow" | "ask" | "block";
640
+
641
+ export interface WriteGateInput {
642
+ mode: WritePolicyMode;
643
+ /** The call may modify the filesystem. */
644
+ mutating: boolean;
645
+ /** The call is already covered by a persistent grant. */
646
+ allowed: boolean;
647
+ }
648
+
649
+ /**
650
+ * The pure decision table.
651
+ *
652
+ * not mutating → allow
653
+ * mode "allow" → allow
654
+ * mode "never" → block (an allowlist entry does NOT soften
655
+ * "never" — the strongest statement of intent
656
+ * must win, and a stale grant must not
657
+ * silently re-open writes)
658
+ * mode "ask", allowed → allow (no prompt for an already-granted path)
659
+ * mode "ask", not allowed → ask
660
+ */
661
+ export function decideWriteGate(input: WriteGateInput): WriteGateDecision {
662
+ if (!input.mutating) return "allow";
663
+ if (input.mode === "allow") return "allow";
664
+ if (input.mode === "never") return "block";
665
+ return input.allowed ? "allow" : "ask";
666
+ }
667
+
668
+ /** One-line human-readable summary of a policy mode (for status output). */
669
+ export function describeWritePolicyMode(mode: WritePolicyMode): string {
670
+ switch (mode) {
671
+ case "allow":
672
+ return "the agent may write files freely";
673
+ case "ask":
674
+ return "every file write asks first; granted paths/commands are remembered";
675
+ case "never":
676
+ return "the agent may not write files — you write, or switch to /build";
677
+ default:
678
+ return "the agent may write files freely";
679
+ }
680
+ }
681
+
682
+ // ── Guided denial ("Deny with instructions") ─────────────────────────
683
+ //
684
+ // The plain "Deny" answer tells the model only that the write was refused.
685
+ // It says nothing about what the user actually wants, so the model either
686
+ // stops cold or re-proposes the same class of action. "Deny with
687
+ // instructions" lets the user say WHY in one sentence ("only explore and
688
+ // propose a plan, do not edit files yet") and have that sentence steer the
689
+ // model.
690
+ //
691
+ // Industry practice (Claude Code's "No, and tell Claude what to do
692
+ // differently", Cursor's "Reject? Tell what to do differently") attaches
693
+ // the note to the rejection. Anthropic then shipped a bug with exactly that
694
+ // design — feedback carried ONLY by the tool result was repeatedly ignored
695
+ // by the model (anthropics/claude-code#89231, #52824). A tool result is a
696
+ // fact the model may reinterpret; a user-role turn is an instruction it
697
+ // must obey. So the instruction travels through BOTH channels: the block
698
+ // reason (tool result) and a user message (see index.ts).
699
+ //
700
+ // Everything in this section is pure so it can be unit-tested without a Pi
701
+ // runtime, and so the option labels and the parsing of them live in one
702
+ // place instead of being string-matched inline in the gate.
703
+
704
+ /** Longest user instruction kept from the denial dialog. */
705
+ export const WRITE_INSTRUCTION_MAX_LEN = 2000;
706
+
707
+ /**
708
+ * Prefix of the guided-denial choice. The parser matches on this prefix, so
709
+ * it must stay a prefix of the label below.
710
+ */
711
+ export const WRITE_DENY_WITH_INSTRUCTIONS_PREFIX = "Deny with";
712
+
713
+ /** Label of the guided-denial choice in the ask dialog. */
714
+ export const WRITE_DENY_WITH_INSTRUCTIONS_LABEL = `${WRITE_DENY_WITH_INSTRUCTIONS_PREFIX} instructions…`;
715
+
716
+ /** Title of the free-text dialog opened after the guided-denial choice. */
717
+ export const WRITE_INSTRUCTION_PROMPT_TITLE =
718
+ "Write denied — tell the agent how to proceed";
719
+
720
+ /** Placeholder of the free-text dialog (an example, not a requirement). */
721
+ export const WRITE_INSTRUCTION_PROMPT_PLACEHOLDER =
722
+ "e.g. only explore and propose a plan, do not edit files yet";
723
+
724
+ /**
725
+ * Normalize the text the user typed in the denial dialog.
726
+ *
727
+ * - normalises CRLF/CR to LF,
728
+ * - strips C0 control characters and DEL (but keeps tab and newline),
729
+ * - trims,
730
+ * - hard-caps at WRITE_INSTRUCTION_MAX_LEN (appending an ellipsis).
731
+ *
732
+ * Returns "" for empty / whitespace-only input — the caller then treats the
733
+ * answer as a plain denial (fail closed, never an implicit allow).
734
+ */
735
+ export function sanitizeWriteInstruction(raw: unknown): string {
736
+ const text = String(raw ?? "");
737
+ if (!text) return "";
738
+ const out = text
739
+ .replace(/\r\n?/g, "\n")
740
+ // eslint-disable-next-line no-control-regex
741
+ .replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, "")
742
+ .trim();
743
+ if (!out) return "";
744
+ if (out.length > WRITE_INSTRUCTION_MAX_LEN) {
745
+ return `${out.slice(0, WRITE_INSTRUCTION_MAX_LEN)}…`;
746
+ }
747
+ return out;
748
+ }
749
+
750
+ /**
751
+ * The choices the ask dialog offers — the single source of the labels.
752
+ *
753
+ * The gate consumes this and `parseWriteGateChoice` below, so a renamed
754
+ * label can never silently change the gate's behaviour (the unit test maps
755
+ * every real option through the parser).
756
+ */
757
+ export function writeGateOptions(
758
+ kind: "path" | "command",
759
+ ): readonly string[] {
760
+ return kind === "path"
761
+ ? [
762
+ "Allow once",
763
+ "Always allow this path",
764
+ "Deny",
765
+ WRITE_DENY_WITH_INSTRUCTIONS_LABEL,
766
+ ]
767
+ : [
768
+ "Allow once",
769
+ "Always allow this command",
770
+ "Deny",
771
+ WRITE_DENY_WITH_INSTRUCTIONS_LABEL,
772
+ ];
773
+ }
774
+
775
+ /** What the ask dialog's answer means. */
776
+ export type WriteGateChoice =
777
+ | "allow-once"
778
+ | "always"
779
+ | "deny-with-instructions"
780
+ | "deny";
781
+
782
+ /**
783
+ * Parse the answer of the ask dialog.
784
+ *
785
+ * Fail closed: anything unrecognised — a cancelled dialog, a blank answer,
786
+ * a label this build does not know — is a plain "deny". "Deny with…" is
787
+ * matched BEFORE the bare "Deny" prefix, otherwise the guided choice would
788
+ * be swallowed by it.
789
+ */
790
+ export function parseWriteGateChoice(choice: unknown): WriteGateChoice {
791
+ const text = String(choice ?? "").trim();
792
+ if (text.startsWith("Allow once")) return "allow-once";
793
+ if (text.startsWith("Always allow")) return "always";
794
+ if (text.startsWith(WRITE_DENY_WITH_INSTRUCTIONS_PREFIX)) {
795
+ return "deny-with-instructions";
796
+ }
797
+ return "deny";
798
+ }
799
+
800
+ export interface WriteDenyReasonInput {
801
+ /** The write the gate was about (paths, or the command key). */
802
+ target: string;
803
+ /** Why the call was classified as a write (e.g. "edits src/a.ts"). */
804
+ why?: string;
805
+ /** The user's instruction; sanitized here. Empty → a plain denial. */
806
+ instruction?: string;
807
+ }
808
+
809
+ /**
810
+ * The reason the model reads when the user denies a write.
811
+ *
812
+ * Plain denial: the classic "declined, do not retry, propose the change
813
+ * instead". Guided denial: the user's sentence is quoted verbatim in the
814
+ * middle of the reason, followed by the disposition ("follow the
815
+ * instruction; if it cannot be followed, say so and stop") — the
816
+ * "failing loudly" shape, where a refusal states what, why, and what to do
817
+ * next instead of a bare "no".
818
+ */
819
+ export function buildWriteDenyReason(input: WriteDenyReasonInput): string {
820
+ const target = String(input.target ?? "").trim() || "the requested write";
821
+ const why = input.why ? ` (${input.why})` : "";
822
+ const instruction = sanitizeWriteInstruction(input.instruction);
823
+ if (!instruction) {
824
+ return (
825
+ `Write policy 'ask': the user declined this write${why}. Do not retry it. ` +
826
+ "Propose the change (a diff or the exact edit) and let the user apply it, or ask whether to continue another way. " +
827
+ `Target: ${target}`
828
+ );
829
+ }
830
+ return (
831
+ `Write policy 'ask': the user denied this write${why} and gave this instruction:\n\n` +
832
+ `${instruction}\n\n` +
833
+ "Do not retry this write. Follow the instruction above. If it cannot be followed, say so and stop. " +
834
+ `Target: ${target}`
835
+ );
836
+ }
837
+
838
+ /**
839
+ * The user-role message that carries the instruction to the model.
840
+ *
841
+ * This is the second channel: a steer message is delivered as a real user
842
+ * turn after the current tool batch, so the instruction is something the
843
+ * model must obey rather than a tool result it may reinterpret.
844
+ */
845
+ export function buildWriteGuidanceMessage(input: {
846
+ target: string;
847
+ instruction: string;
848
+ }): string {
849
+ const target = String(input.target ?? "").trim() || "the requested write";
850
+ const instruction = sanitizeWriteInstruction(input.instruction);
851
+ return (
852
+ "[write denied — the user asked you to do something different]\n" +
853
+ `The user denied the write to ${target}. Their instruction:\n\n${instruction}\n\n` +
854
+ "Follow the instruction above instead of retrying that write."
855
+ );
856
+ }
857
+
858
+ // ── Argument completions (/write-policy) ────────────────────────────
859
+
860
+ /** One /write-policy subcommand with its usage hint. */
861
+ const WRITE_POLICY_SUBS: readonly {
862
+ name: string;
863
+ label: string;
864
+ description: string;
865
+ }[] = [
866
+ {
867
+ name: "allow",
868
+ label: "allow",
869
+ description: "the agent may write files freely",
870
+ },
871
+ {
872
+ name: "ask",
873
+ label: "ask",
874
+ description: "every file write asks first",
875
+ },
876
+ {
877
+ name: "never",
878
+ label: "never",
879
+ description: "the agent may not write files (use /build to execute)",
880
+ },
881
+ ];
882
+
883
+ const WRITE_POLICY_SCOPES: readonly {
884
+ token: string;
885
+ description: string;
886
+ }[] = [
887
+ { token: "global", description: "write to ~/.pi/agent/settings.json" },
888
+ { token: "project", description: "write to <cwd>/.pi/settings.json" },
889
+ ];
890
+
891
+ /** write-policy subcommands take an optional scope → trailing-space values. */
892
+ const WRITE_POLICY_MODE_TOKENS: ReadonlySet<string> = new Set([
893
+ "allow",
894
+ "ask",
895
+ "never",
896
+ ]);
897
+
898
+ /**
899
+ * Argument completions for `/write-policy` (pure; unit-tested in
900
+ * edit-policy.test.ts).
901
+ *
902
+ * Follows the pi-tui full-argument contract (see `joinArgumentValue` and the
903
+ * /mcp extension): the first token completes a mode, the second token
904
+ * completes the write scope, and every returned `value` is the complete
905
+ * replacement for the typed argument text (`"ask project"`, never
906
+ * `"project"`). Returns null when nothing useful can be offered, including
907
+ * when the typed token is already an exact unique match (exact-match
908
+ * suppression — the dropdown closes so the first Enter submits).
909
+ */
910
+ export function writePolicyArgumentCompletions(
911
+ argumentPrefix: string,
912
+ ): ArgumentCompletion[] | null {
913
+ const raw = String(argumentPrefix ?? "");
914
+ const join = (token: string) => joinArgumentValue(raw, token);
915
+
916
+ // ── Subcommand position: no whitespace typed yet ────────────────────
917
+ if (!raw.trim() || !/\s/.test(raw)) {
918
+ const lower = raw.trim().toLowerCase();
919
+ const matching = WRITE_POLICY_SUBS.filter((s) => s.name.startsWith(lower));
920
+ if (matching.length === 0) return null;
921
+ return matching.map((s) => ({
922
+ value: `${s.name} `,
923
+ label: s.label,
924
+ description: s.description,
925
+ }));
926
+ }
927
+
928
+ // ── Argument position: mode already committed → complete the scope ──
929
+ const tokens = raw.trim().split(/\s+/).filter(Boolean);
930
+ const mode = (tokens[0] ?? "").toLowerCase();
931
+ if (!WRITE_POLICY_MODE_TOKENS.has(mode)) return null;
932
+ const endsWithWs = /\s$/.test(raw);
933
+ const partial = endsWithWs ? "" : (tokens[tokens.length - 1] ?? "");
934
+ const items: ArgumentCompletion[] = WRITE_POLICY_SCOPES.flatMap((s) =>
935
+ s.token.startsWith(partial.toLowerCase())
936
+ ? [
937
+ {
938
+ value: join(s.token),
939
+ label: s.token,
940
+ description: s.description,
941
+ },
942
+ ]
943
+ : [],
944
+ );
945
+ if (items.length === 0) return null;
946
+ if (
947
+ suppressExactArgumentMatch(
948
+ items.map((i) => i.label),
949
+ partial,
950
+ )
951
+ ) {
952
+ return null;
953
+ }
954
+ return items;
955
+ }