@dieulc/workflow 0.5.2 → 0.6.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 +97 -2
- package/edit-policy.ts +955 -0
- package/index.ts +389 -6
- package/package.json +3 -2
- package/permissions.ts +25 -5
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
|
+
}
|