@andares/pi-toolkits 0.9.6 → 0.9.7

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.
@@ -0,0 +1,324 @@
1
+ /**
2
+ * ask feature — scratch directory for planning notes.
3
+ *
4
+ * Ask mode used to confiscate every write tool, including writes to a temp
5
+ * file. That is stricter than the promise it makes ("do not change the
6
+ * project"): a model that wants to hold an outline, a checklist or a draft
7
+ * analysis had to either dump it into the reply or work around the sandbox. So
8
+ * the mode now hands out exactly one place to write — a per-session directory
9
+ * under the OS temp dir, outside the project:
10
+ *
11
+ * - the root is `<os.tmpdir()>/pi-ask/<session>`, so nothing here can touch the
12
+ * repository and no path arrives from the model (names are relative);
13
+ * - names are validated against a narrow allowlist (letters, digits, dot,
14
+ * dash, underscore; at most two levels; no `..`, no absolute path, no
15
+ * backslash), and the joined path is re-checked for containment as defence
16
+ * in depth;
17
+ * - existing symlinked components are refused, so a pre-existing link in the
18
+ * temp dir cannot turn a note into a write somewhere else;
19
+ * - files are capped at {@link SCRATCH_MAX_BYTES}: "notes" are not a dump of
20
+ * the repository, and the cap also keeps a tool result from flooding the
21
+ * context when the model reads its own note back;
22
+ * - `createScratchStore` throws when the temp dir cannot be created or written.
23
+ * The caller then degrades to the old fully read-only mode instead of
24
+ * running half-open.
25
+ *
26
+ * Nothing written here is ever executed: ask mode's bash sandbox stays
27
+ * read-only, so a script dropped into the scratch directory is just text.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ // Creation and sweeping are synchronous on purpose: ask mode can be entered from
31
+ // `session_start` (the autostart path), and the first run starts before an async
32
+ // directory creation would resolve. Those are a handful of syscalls; the actual
33
+ // note operations stay async because they run inside tool calls.
34
+ import {
35
+ mkdirSync,
36
+ realpathSync,
37
+ readdirSync,
38
+ rmSync,
39
+ statSync,
40
+ unlinkSync,
41
+ writeFileSync,
42
+ } from "node:fs";
43
+ import {
44
+ appendFile,
45
+ lstat,
46
+ mkdir,
47
+ readFile,
48
+ readdir,
49
+ stat,
50
+ unlink,
51
+ writeFile,
52
+ } from "node:fs/promises";
53
+ import { tmpdir } from "node:os";
54
+ import { isAbsolute, join, resolve, sep } from "node:path";
55
+
56
+ /** Directory under the OS temp dir that holds every session's scratch space. */
57
+ export const SCRATCH_DIR_NAME = "pi-ask";
58
+ /** Largest single note. Notes are planning aids, not a copy of the repository. */
59
+ export const SCRATCH_MAX_BYTES = 256 * 1024;
60
+ /** Scratch directories older than this are removed when a session enters ask mode. */
61
+ export const SCRATCH_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
62
+
63
+ /** A note name segment: no leading dot, no separators, no shell metacharacters. */
64
+ const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
65
+ /** A session id that is safe as a directory name (pi uses UUIDs; be defensive). */
66
+ const SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
67
+ /** Deepest allowed note name, e.g. `notes/api.md`. */
68
+ const MAX_SEGMENTS = 2;
69
+
70
+ /** One note as reported by `list`. */
71
+ export interface ScratchFile {
72
+ readonly name: string;
73
+ readonly bytes: number;
74
+ }
75
+
76
+ /** Result of writing or appending one note. */
77
+ export interface ScratchWrite {
78
+ readonly name: string;
79
+ readonly path: string;
80
+ readonly bytes: number;
81
+ readonly lines: number;
82
+ }
83
+
84
+ /** The scratch space of one session. Every operation takes a relative note name. */
85
+ export interface ScratchStore {
86
+ readonly root: string;
87
+ write(name: string, content: string): Promise<ScratchWrite>;
88
+ append(name: string, content: string): Promise<ScratchWrite>;
89
+ read(name: string): Promise<string>;
90
+ list(): Promise<ScratchFile[]>;
91
+ remove(name: string): Promise<void>;
92
+ }
93
+
94
+ /** Why `name` is not a valid note name, or undefined when it is. */
95
+ export function nameError(name: string): string | undefined {
96
+ const trimmed = name.trim();
97
+ if (trimmed === "") {
98
+ return 'The note name is empty. Use a relative name such as "plan.md".';
99
+ }
100
+ if (isAbsolute(name) || /^[A-Za-z]:/.test(name) || name.includes("\\")) {
101
+ return `"${name}" is not a relative name. Scratch names are relative to the scratch directory (for example "plan.md"); absolute paths and backslashes are rejected.`;
102
+ }
103
+ const segments = name.split("/");
104
+ if (segments.length > MAX_SEGMENTS) {
105
+ return `"${name}" is nested too deeply. At most ${MAX_SEGMENTS} levels are allowed ("notes/api.md").`;
106
+ }
107
+ const invalid = segments.find((segment) => !SEGMENT.test(segment));
108
+ if (invalid !== undefined) {
109
+ return `"${name}" has an invalid segment "${invalid}". A segment is 1-64 characters of letters, digits, dot, dash or underscore and may not start with a dot.`;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ /** Case-folded containment check (Windows paths are case-insensitive). */
115
+ function inside(root: string, target: string): boolean {
116
+ const fold = (value: string): string =>
117
+ process.platform === "win32" ? value.toLowerCase() : value;
118
+ const base = fold(root);
119
+ const candidate = fold(target);
120
+ return (
121
+ candidate === base ||
122
+ candidate.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)
123
+ );
124
+ }
125
+
126
+ /** Absolute path of a validated note name, re-checked for containment. */
127
+ function targetFor(root: string, name: string): string {
128
+ const problem = nameError(name);
129
+ if (problem !== undefined) throw new Error(problem);
130
+ const target = resolve(root, name);
131
+ if (!inside(root, target)) {
132
+ throw new Error(`"${name}" resolves outside the scratch directory.`);
133
+ }
134
+ return target;
135
+ }
136
+
137
+ /**
138
+ * Refuse a target whose existing components go through a symlink.
139
+ *
140
+ * The store never creates links itself, so this only matters when something
141
+ * else (the operator, another process) put one in the temp directory first.
142
+ * Missing components are fine: they are about to be created inside the root.
143
+ */
144
+ async function assertNoSymlink(root: string, target: string): Promise<void> {
145
+ const parts = target.slice(root.length).split(sep).filter(Boolean);
146
+ let current = root;
147
+ for (const part of parts) {
148
+ current = join(current, part);
149
+ try {
150
+ const info = await lstat(current);
151
+ if (info.isSymbolicLink()) {
152
+ throw new Error(
153
+ `${part} in the scratch directory is a symbolic link; scratch notes must be regular files.`,
154
+ );
155
+ }
156
+ } catch (error) {
157
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
158
+ throw error;
159
+ }
160
+ }
161
+ }
162
+
163
+ function tooBig(bytes: number): string {
164
+ const kib = Math.ceil(SCRATCH_MAX_BYTES / 1024);
165
+ return `Scratch notes are capped at ${kib} KiB; this note would be ${Math.ceil(bytes / 1024)} KiB. Keep it to the planning essentials — the reply, not the note, is the deliverable.`;
166
+ }
167
+
168
+ function lineCount(text: string): number {
169
+ if (text === "") return 0;
170
+ const breaks = text.split("\n").length;
171
+ return text.endsWith("\n") ? breaks - 1 : breaks;
172
+ }
173
+
174
+ async function sizeOf(path: string): Promise<number | undefined> {
175
+ try {
176
+ const info = await stat(path);
177
+ return info.size;
178
+ } catch {
179
+ return undefined;
180
+ }
181
+ }
182
+
183
+ /** Create a {@link ScratchStore} rooted in `options.baseDir` (default: OS temp). */
184
+ export function createScratchStore(options: {
185
+ readonly sessionId: string;
186
+ readonly baseDir?: string;
187
+ readonly now?: number;
188
+ }): ScratchStore {
189
+ const base = join(resolve(options.baseDir ?? tmpdir()), SCRATCH_DIR_NAME);
190
+ const leaf = SESSION.test(options.sessionId)
191
+ ? options.sessionId
192
+ : createHash("sha256").update(options.sessionId).digest("hex").slice(0, 16);
193
+ const created = join(base, leaf);
194
+ mkdirSync(created, { recursive: true });
195
+ // macOS resolves /var (and therefore the default tmpdir) through a symlink.
196
+ const root = realpathSync(created);
197
+ // Prove the directory is writable: on a read-only TMPDIR `mkdir -p` on an
198
+ // existing directory succeeds without proving anything. Ask mode degrades
199
+ // when this throws.
200
+ const probe = join(root, `.probe-${process.pid}-${Date.now()}`);
201
+ writeFileSync(probe, "");
202
+ unlinkSync(probe);
203
+ sweepScratchDirs(base, SCRATCH_MAX_AGE_MS, options.now);
204
+
205
+ const store: ScratchStore = {
206
+ root,
207
+
208
+ async write(name, content) {
209
+ const target = targetFor(root, name);
210
+ const bytes = Buffer.byteLength(content, "utf8");
211
+ if (bytes > SCRATCH_MAX_BYTES) throw new Error(tooBig(bytes));
212
+ await assertNoSymlink(root, target);
213
+ await mkdir(resolve(target, ".."), { recursive: true });
214
+ await writeFile(target, content, "utf8");
215
+ return { name, path: target, bytes, lines: lineCount(content) };
216
+ },
217
+
218
+ async append(name, content) {
219
+ const target = targetFor(root, name);
220
+ const existing = (await sizeOf(target)) ?? 0;
221
+ const bytes = Buffer.byteLength(content, "utf8");
222
+ if (existing + bytes > SCRATCH_MAX_BYTES) {
223
+ throw new Error(tooBig(existing + bytes));
224
+ }
225
+ await assertNoSymlink(root, target);
226
+ await mkdir(resolve(target, ".."), { recursive: true });
227
+ await appendFile(target, content, "utf8");
228
+ const total = existing + bytes;
229
+ return {
230
+ name,
231
+ path: target,
232
+ bytes: total,
233
+ // The appended text is what the caller just wrote; the file's own
234
+ // line count is not needed by the model.
235
+ lines: lineCount(content),
236
+ };
237
+ },
238
+
239
+ async read(name) {
240
+ const target = targetFor(root, name);
241
+ await assertNoSymlink(root, target);
242
+ let data: Buffer;
243
+ try {
244
+ data = await readFile(target);
245
+ } catch (error) {
246
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
247
+ throw new Error(
248
+ `There is no scratch note named "${name}". Use op "list" to see the notes that exist.`,
249
+ );
250
+ }
251
+ throw error;
252
+ }
253
+ if (data.length > SCRATCH_MAX_BYTES) {
254
+ return `${data.subarray(0, SCRATCH_MAX_BYTES).toString("utf8")}\n…(truncated at ${Math.floor(SCRATCH_MAX_BYTES / 1024)} KiB)`;
255
+ }
256
+ return data.toString("utf8");
257
+ },
258
+
259
+ async list() {
260
+ const files: ScratchFile[] = [];
261
+ const walk = async (dir: string, prefix: string): Promise<void> => {
262
+ const entries = await readdir(dir, { withFileTypes: true });
263
+ for (const entry of entries) {
264
+ if (entry.name.startsWith(".")) continue;
265
+ const name = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
266
+ if (entry.isDirectory()) {
267
+ // One level only: names cannot nest deeper than MAX_SEGMENTS.
268
+ if (prefix === "") await walk(join(dir, entry.name), name);
269
+ continue;
270
+ }
271
+ // Symlinks and other non-files are deliberately not listed.
272
+ if (!entry.isFile()) continue;
273
+ const size = await sizeOf(join(dir, entry.name));
274
+ files.push({ name, bytes: size ?? 0 });
275
+ }
276
+ };
277
+ await walk(root, "");
278
+ return files.sort((a, b) => a.name.localeCompare(b.name));
279
+ },
280
+
281
+ async remove(name) {
282
+ const target = targetFor(root, name);
283
+ try {
284
+ await unlink(target);
285
+ } catch (error) {
286
+ // Deleting a note that is already gone is not an error: the model
287
+ // asked for the note to be gone and it is.
288
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
289
+ }
290
+ },
291
+ };
292
+ return store;
293
+ }
294
+
295
+ /**
296
+ * Remove scratch directories older than `maxAgeMs`. Never throws: cleanup is
297
+ * housekeeping, and a stale directory must not keep ask mode from starting.
298
+ */
299
+ export function sweepScratchDirs(
300
+ base: string,
301
+ maxAgeMs: number,
302
+ now = Date.now(),
303
+ ): string[] {
304
+ const removed: string[] = [];
305
+ let entries;
306
+ try {
307
+ entries = readdirSync(base, { withFileTypes: true });
308
+ } catch {
309
+ return removed;
310
+ }
311
+ for (const entry of entries) {
312
+ if (!entry.isDirectory()) continue;
313
+ const dir = join(base, entry.name);
314
+ try {
315
+ const info = statSync(dir);
316
+ if (now - info.mtimeMs <= maxAgeMs) continue;
317
+ rmSync(dir, { recursive: true, force: true });
318
+ removed.push(entry.name);
319
+ } catch {
320
+ // Best effort; a directory in use elsewhere stays.
321
+ }
322
+ }
323
+ return removed;
324
+ }
@@ -8,13 +8,18 @@
8
8
  import { mkdtempSync, realpathSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
- import { describe, expect, it } from "vitest";
11
+ import { beforeEach, describe, expect, it } from "vitest";
12
12
  import type {
13
13
  ExtensionAPI,
14
14
  ExtensionContext,
15
15
  ToolCallEvent,
16
16
  } from "@earendil-works/pi-coding-agent";
17
17
  import type { AskDecision, GateHub, ToolGate } from "../../lib/gates.js";
18
+ import {
19
+ clearJailedTool,
20
+ markJailedTool,
21
+ resetJailedTools,
22
+ } from "../../lib/tools.js";
18
23
  import { markStartupCommand } from "../../lib/startup.js";
19
24
  import { registerPermission } from "./index.js";
20
25
 
@@ -391,8 +396,7 @@ describe("model-facing banner", () => {
391
396
  });
392
397
  });
393
398
 
394
- describe("session grants cover repeat asks", () => {
395
- it("stops asking for a destructive in-project command once allowed", async () => {
399
+ describe("session grants cover repeat asks", () => { it("stops asking for a destructive in-project command once allowed", async () => {
396
400
  const h = makeHarness();
397
401
  await h.command("allow allow allow ask");
398
402
  const first = asAsk(await h.evaluate(bashEvent("rm -rf .")));
@@ -411,3 +415,23 @@ describe("session grants cover repeat asks", () => {
411
415
  expect(await h.evaluate(bashEvent(command))).toBeUndefined();
412
416
  });
413
417
  });
418
+
419
+ describe("self-jailed tools", () => {
420
+ beforeEach(() => {
421
+ resetJailedTools();
422
+ });
423
+
424
+ it("skips the boundary check only while the owning feature marks the tool", async () => {
425
+ const h = makeHarness();
426
+ // Make the judgement observable: with in-project writes set to ask, an
427
+ // unmarked pathless tool would raise a dialog.
428
+ await h.command("ask ask ask ask");
429
+ expect(asAsk(await h.evaluate(customEvent("scratchpad"))).kind).toBe("ask");
430
+
431
+ markJailedTool("scratchpad");
432
+ expect(await h.evaluate(customEvent("scratchpad"))).toBeUndefined();
433
+
434
+ clearJailedTool("scratchpad");
435
+ expect(asAsk(await h.evaluate(customEvent("scratchpad"))).kind).toBe("ask");
436
+ });
437
+ });
@@ -23,6 +23,7 @@ import type {
23
23
  } from "@earendil-works/pi-coding-agent";
24
24
  import { Text } from "@earendil-works/pi-tui";
25
25
  import type { AskRequest, GateHub } from "../../lib/gates.js";
26
+ import { isJailedTool } from "../../lib/tools.js";
26
27
  import { consumeStartupCommand } from "../../lib/startup.js";
27
28
  import {
28
29
  DEFAULT_MODES,
@@ -356,6 +357,11 @@ export function registerPermission(pi: ExtensionAPI, gates: GateHub): void {
356
357
  order: 20,
357
358
  evaluate(event, ctx) {
358
359
  if (!enabled) return undefined;
360
+ // A self-jailed tool (ask's `scratchpad`) can only reach the temp directory
361
+ // it owns: there is no boundary to judge, and the operator sanctioned that
362
+ // space by entering the mode that activates the tool. The owning feature
363
+ // marks it only while active, so an inactive tool is judged normally.
364
+ if (isJailedTool(event.toolName)) return undefined;
359
365
  const units = unitsFor(event, ctx.cwd);
360
366
  if (units.length === 0) return undefined;
361
367
  const decision = decideProgram(units, {
package/src/lib/tools.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  /**
2
2
  * Shared tool-set management helpers (feature-agnostic).
3
+ *
4
+ * Two concerns live here because both are about tool identity rather than any
5
+ * one feature: snapshotting/restoring the active set, and the registry of
6
+ * self-jailed tools that the boundary gate consults.
3
7
  */
4
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
9
 
@@ -23,3 +27,47 @@ export function withoutTools(
23
27
  ): string[] {
24
28
  return active.filter((name) => !removed.has(name));
25
29
  }
30
+
31
+ /**
32
+ * Self-jailed tools — a registry the boundary gate consults.
33
+ *
34
+ * A tool can be *jailed*: its own implementation only reaches a place it
35
+ * created and owns, so there is no boundary for `permission` to judge and no
36
+ * dialog to raise. Example: ask mode's `scratchpad` writes into the session's
37
+ * temp directory through a relative, validated name; running it through the
38
+ * boundary analysis would ask "outside the project, write?" on every note even
39
+ * though the operator already sanctioned that space by entering ask mode.
40
+ *
41
+ * This is intentionally a registry rather than a bypass baked into
42
+ * `permission`: the owning feature marks the tool only while it is active, and
43
+ * `permission` does not need to know which feature that is. A tool that is not
44
+ * marked is judged normally, so forgetting to clear the mark would widen the
45
+ * boundary — hence the owning feature clears it on exit.
46
+ *
47
+ * Narrow on purpose: the registry covers tool *names*, not paths. A tool only
48
+ * belongs here when its caller cannot point it at a path.
49
+ */
50
+ const jailed = new Set<string>();
51
+
52
+ /** Mark a tool as self-jailed (call while it is active). */
53
+ export function markJailedTool(name: string): void {
54
+ jailed.add(name);
55
+ }
56
+
57
+ /** Remove the mark (call when the tool is deactivated). */
58
+ export function clearJailedTool(name: string): void {
59
+ jailed.delete(name);
60
+ }
61
+
62
+ /** Whether `name` is a self-jailed tool that is currently active. */
63
+ export function isJailedTool(name: string): boolean {
64
+ return jailed.has(name);
65
+ }
66
+
67
+ /**
68
+ * Drop every mark. Tests call this between cases; production code clears one
69
+ * tool at a time when the owning feature exits.
70
+ */
71
+ export function resetJailedTools(): void {
72
+ jailed.clear();
73
+ }