@stigmer/runner 3.12.0 → 3.12.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.
Files changed (41) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +23 -8
  3. package/dist/activities/execute-deep-agent/attachment-injector.js +104 -105
  4. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  5. package/dist/activities/execute-deep-agent/prompt-builder.js +11 -1
  6. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  7. package/dist/activities/execute-deep-agent/setup.js +7 -3
  8. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  9. package/dist/middleware/path-normalization.d.ts +15 -2
  10. package/dist/middleware/path-normalization.js +39 -5
  11. package/dist/middleware/path-normalization.js.map +1 -1
  12. package/dist/shared/mcp-enabled-tools.d.ts +6 -2
  13. package/dist/shared/mcp-enabled-tools.js +6 -2
  14. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  15. package/dist/shared/plan-mode-permissions.d.ts +46 -10
  16. package/dist/shared/plan-mode-permissions.js +56 -12
  17. package/dist/shared/plan-mode-permissions.js.map +1 -1
  18. package/dist/shared/zip-extract.d.ts +24 -6
  19. package/dist/shared/zip-extract.js +31 -90
  20. package/dist/shared/zip-extract.js.map +1 -1
  21. package/dist/shared/zip-structure.d.ts +61 -0
  22. package/dist/shared/zip-structure.js +128 -0
  23. package/dist/shared/zip-structure.js.map +1 -0
  24. package/package.json +2 -2
  25. package/src/__test-utils__/zip-fixtures.ts +206 -0
  26. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -42
  27. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +165 -126
  28. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
  29. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +7 -2
  30. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
  31. package/src/activities/execute-deep-agent/attachment-injector.ts +146 -142
  32. package/src/activities/execute-deep-agent/prompt-builder.ts +11 -1
  33. package/src/activities/execute-deep-agent/setup.ts +7 -3
  34. package/src/middleware/__tests__/path-normalization.test.ts +29 -3
  35. package/src/middleware/path-normalization.ts +42 -5
  36. package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
  37. package/src/shared/__tests__/zip-extract.test.ts +106 -89
  38. package/src/shared/mcp-enabled-tools.ts +6 -2
  39. package/src/shared/plan-mode-permissions.ts +59 -12
  40. package/src/shared/zip-extract.ts +35 -117
  41. package/src/shared/zip-structure.ts +181 -0
@@ -1,5 +1,4 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { deflateRawSync } from "node:zlib";
3
2
  import { writeFile, mkdir } from "node:fs/promises";
4
3
  import { join } from "node:path";
5
4
  import { tmpdir } from "node:os";
@@ -15,130 +14,42 @@ import {
15
14
  } from "../attachment-injector.js";
16
15
  import { mockWorkspaceBackend } from "../../../__test-utils__/mock-workspace.js";
17
16
  import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
17
+ import { buildZip, type ZipFixtureFile } from "../../../__test-utils__/zip-fixtures.js";
18
18
  import {
19
19
  DEEP_AGENT_VISION_PROFILE,
20
20
  VisionBudget,
21
21
  } from "../../../shared/attachment-vision.js";
22
22
 
23
23
  // ── ZIP Construction Helpers ─────────────────────────────────────────
24
+ //
25
+ // All archives come from the shared real-shape builder (zip-fixtures.ts):
26
+ // local headers, payloads, central directory, EOCD — the only shape real
27
+ // ZIP writers produce and the shape central-directory parsing requires.
28
+ // The record-form helpers below keep ordinary call sites terse; tests that
29
+ // need streaming or declared-size shapes call buildZip directly.
24
30
 
25
31
  function makeZip(entries: Record<string, string | Buffer>): Buffer {
26
- const parts: Buffer[] = [];
27
- const centralDir: Buffer[] = [];
28
- let offset = 0;
29
-
30
- for (const [name, content] of Object.entries(entries)) {
31
- const nameBytes = Buffer.from(name, "utf-8");
32
- const contentBytes = typeof content === "string" ? Buffer.from(content, "utf-8") : content;
33
- const compressed = deflateRawSync(contentBytes);
34
-
35
- // Local file header
36
- const header = Buffer.alloc(30);
37
- header.writeUInt32LE(0x04034b50, 0); // signature
38
- header.writeUInt16LE(20, 4); // version needed
39
- header.writeUInt16LE(0, 6); // flags
40
- header.writeUInt16LE(8, 8); // compression: deflate
41
- header.writeUInt16LE(0, 10); // mod time
42
- header.writeUInt16LE(0, 12); // mod date
43
- header.writeUInt32LE(0, 14); // crc32 (skip for tests)
44
- header.writeUInt32LE(compressed.length, 18); // compressed size
45
- header.writeUInt32LE(contentBytes.length, 22); // uncompressed size
46
- header.writeUInt16LE(nameBytes.length, 26); // filename length
47
- header.writeUInt16LE(0, 28); // extra field length
48
-
49
- parts.push(header, nameBytes, compressed);
50
-
51
- // Central directory entry
52
- const cdEntry = Buffer.alloc(46);
53
- cdEntry.writeUInt32LE(0x02014b50, 0);
54
- cdEntry.writeUInt16LE(20, 4);
55
- cdEntry.writeUInt16LE(20, 6);
56
- cdEntry.writeUInt16LE(0, 8);
57
- cdEntry.writeUInt16LE(8, 10);
58
- cdEntry.writeUInt16LE(0, 12);
59
- cdEntry.writeUInt16LE(0, 14);
60
- cdEntry.writeUInt32LE(0, 16);
61
- cdEntry.writeUInt32LE(compressed.length, 20);
62
- cdEntry.writeUInt32LE(contentBytes.length, 24);
63
- cdEntry.writeUInt16LE(nameBytes.length, 28);
64
- cdEntry.writeUInt16LE(0, 30);
65
- cdEntry.writeUInt16LE(0, 32);
66
- cdEntry.writeUInt16LE(0, 34);
67
- cdEntry.writeUInt16LE(0, 36);
68
- cdEntry.writeUInt32LE(0, 38);
69
- cdEntry.writeUInt32LE(offset, 42);
70
- centralDir.push(cdEntry, nameBytes);
71
-
72
- offset += header.length + nameBytes.length + compressed.length;
73
- }
74
-
75
- // End of central directory
76
- const eocd = Buffer.alloc(22);
77
- const cdSize = centralDir.reduce((s, b) => s + b.length, 0);
78
- eocd.writeUInt32LE(0x06054b50, 0);
79
- eocd.writeUInt16LE(0, 4);
80
- eocd.writeUInt16LE(0, 6);
81
- eocd.writeUInt16LE(Object.keys(entries).length, 8);
82
- eocd.writeUInt16LE(Object.keys(entries).length, 10);
83
- eocd.writeUInt32LE(cdSize, 12);
84
- eocd.writeUInt32LE(offset, 16);
85
- eocd.writeUInt16LE(0, 20);
86
-
87
- return Buffer.concat([...parts, ...centralDir, eocd]);
32
+ return makeZipWith(entries, { method: "deflated" });
88
33
  }
89
34
 
90
35
  function makeStoredZip(entries: Record<string, Buffer>): Buffer {
91
- const parts: Buffer[] = [];
92
- let offset = 0;
93
-
94
- for (const [name, content] of Object.entries(entries)) {
95
- const nameBytes = Buffer.from(name, "utf-8");
96
-
97
- const header = Buffer.alloc(30);
98
- header.writeUInt32LE(0x04034b50, 0);
99
- header.writeUInt16LE(20, 4);
100
- header.writeUInt16LE(0, 6);
101
- header.writeUInt16LE(0, 8); // stored (no compression)
102
- header.writeUInt32LE(content.length, 18);
103
- header.writeUInt32LE(content.length, 22);
104
- header.writeUInt16LE(nameBytes.length, 26);
105
- header.writeUInt16LE(0, 28);
106
-
107
- parts.push(header, nameBytes, content);
108
- offset += header.length + nameBytes.length + content.length;
109
- }
110
-
111
- // Minimal EOCD
112
- const eocd = Buffer.alloc(22);
113
- eocd.writeUInt32LE(0x06054b50, 0);
114
- eocd.writeUInt16LE(Object.keys(entries).length, 8);
115
- eocd.writeUInt16LE(Object.keys(entries).length, 10);
116
- eocd.writeUInt32LE(0, 12);
117
- eocd.writeUInt32LE(offset, 16);
36
+ return makeZipWith(entries, { method: "stored" });
37
+ }
118
38
 
119
- return Buffer.concat([...parts, eocd]);
39
+ function makeZipWith(
40
+ entries: Record<string, string | Buffer>,
41
+ shape: Pick<ZipFixtureFile, "method" | "streaming">,
42
+ ): Buffer {
43
+ const files: ZipFixtureFile[] = Object.entries(entries).map(([name, content]) => ({
44
+ name,
45
+ content: typeof content === "string" ? content : new Uint8Array(content),
46
+ ...shape,
47
+ }));
48
+ return Buffer.from(buildZip(files));
120
49
  }
121
50
 
122
51
  function makeDirectoryOnlyZip(): Buffer {
123
- const name = "empty_dir/";
124
- const nameBytes = Buffer.from(name, "utf-8");
125
-
126
- const header = Buffer.alloc(30);
127
- header.writeUInt32LE(0x04034b50, 0);
128
- header.writeUInt16LE(20, 4);
129
- header.writeUInt16LE(0, 6);
130
- header.writeUInt16LE(0, 8);
131
- header.writeUInt32LE(0, 18);
132
- header.writeUInt32LE(0, 22);
133
- header.writeUInt16LE(nameBytes.length, 26);
134
- header.writeUInt16LE(0, 28);
135
-
136
- const eocd = Buffer.alloc(22);
137
- eocd.writeUInt32LE(0x06054b50, 0);
138
- eocd.writeUInt16LE(1, 8);
139
- eocd.writeUInt16LE(1, 10);
140
-
141
- return Buffer.concat([header, nameBytes, eocd]);
52
+ return Buffer.from(buildZip([{ name: "empty_dir/", content: "" }]));
142
53
  }
143
54
 
144
55
  function makeAttachment(overrides: Partial<{
@@ -251,22 +162,7 @@ describe("validateZipForExtraction", () => {
251
162
  });
252
163
 
253
164
  it("rejects null bytes in filenames", () => {
254
- const nameWithNull = "file\x00.txt";
255
- const nameBytes = Buffer.from(nameWithNull, "utf-8");
256
- const content = Buffer.from("test");
257
- const compressed = deflateRawSync(content);
258
-
259
- const header = Buffer.alloc(30);
260
- header.writeUInt32LE(0x04034b50, 0);
261
- header.writeUInt16LE(20, 4);
262
- header.writeUInt16LE(0, 6);
263
- header.writeUInt16LE(8, 8);
264
- header.writeUInt32LE(compressed.length, 18);
265
- header.writeUInt32LE(content.length, 22);
266
- header.writeUInt16LE(nameBytes.length, 26);
267
- header.writeUInt16LE(0, 28);
268
-
269
- const zip = Buffer.concat([header, nameBytes, compressed]);
165
+ const zip = makeZip({ "file\u0000.txt": "test" });
270
166
  expect(() => validateZipForExtraction(zip, "null.zip"))
271
167
  .toThrow(/null bytes/);
272
168
  });
@@ -305,6 +201,149 @@ describe("validateZipForExtraction", () => {
305
201
  });
306
202
  });
307
203
 
204
+ // ═══════════════════════════════════════════════════════════════════════
205
+ // Central-directory parsing (issue #567)
206
+ // ═══════════════════════════════════════════════════════════════════════
207
+ // The archive shapes the old local-header walk corrupted or rejected:
208
+ // stored streaming entries (silent manifest truncation) and Go-default
209
+ // deflated streaming entries (rejected outright). Sizes come from the
210
+ // central directory — the format's authoritative index — and structural
211
+ // failures are fail-hard, unlike the skill-artifact reader's non-fatal
212
+ // empty return: nothing upstream vouches for an attachment.
213
+
214
+ describe("validateZipForExtraction — central-directory parsing (issue #567)", () => {
215
+ it("validates every entry of a stored streaming archive (no silent truncation)", () => {
216
+ // Method 0 + flag bit 3 + zeroed local sizes: the old walk admitted the
217
+ // first entry with size 0, landed mid-payload, and quietly dropped the
218
+ // rest of the manifest.
219
+ const zip = Buffer.from(buildZip([
220
+ { name: "first.txt", content: "first file content", streaming: true },
221
+ { name: "second.txt", content: "second file content", streaming: true },
222
+ ]));
223
+
224
+ const result = validateZipForExtraction(zip, "streamed.zip");
225
+ expect(result.map((e) => e.relativePath)).toEqual(["first.txt", "second.txt"]);
226
+ expect(result.map((e) => e.uncompressedSize)).toEqual([
227
+ "first file content".length,
228
+ "second file content".length,
229
+ ]);
230
+ });
231
+
232
+ it("accepts a Go-default archive (deflated streaming entries)", () => {
233
+ const zip = Buffer.from(buildZip([
234
+ { name: "main.go", content: "package main", method: "deflated", streaming: true },
235
+ { name: "go.mod", content: "module example", method: "deflated", streaming: true },
236
+ ]));
237
+
238
+ const result = validateZipForExtraction(zip, "go-built.zip");
239
+ expect(result.map((e) => e.relativePath)).toEqual(["go.mod", "main.go"]);
240
+ });
241
+
242
+ it("rejects an archive with no central directory (fail-hard, unlike skill extraction)", () => {
243
+ const zip = Buffer.from(buildZip(
244
+ [{ name: "a.txt", content: "aaa" }],
245
+ { omitCentralDirectory: true },
246
+ ));
247
+
248
+ expect(() => validateZipForExtraction(zip, "truncated.zip"))
249
+ .toThrow(AttachmentValidationError);
250
+ expect(() => validateZipForExtraction(zip, "truncated.zip"))
251
+ .toThrow(/not a valid ZIP archive/);
252
+ });
253
+
254
+ it("rejects duplicate entry paths (a contradictory manifest)", () => {
255
+ const zip = Buffer.from(buildZip([
256
+ { name: "dup.txt", content: "one" },
257
+ { name: "dup.txt", content: "two" },
258
+ ]));
259
+
260
+ expect(() => validateZipForExtraction(zip, "dup.zip"))
261
+ .toThrow(/duplicate entry/);
262
+ });
263
+
264
+ it("locates the central directory behind a trailing archive comment", () => {
265
+ const zip = Buffer.from(buildZip(
266
+ [{ name: "a.txt", content: "aaa" }],
267
+ { comment: "release archive — built by tooling" },
268
+ ));
269
+
270
+ const result = validateZipForExtraction(zip, "commented.zip");
271
+ expect(result.map((e) => e.relativePath)).toEqual(["a.txt"]);
272
+ });
273
+ });
274
+
275
+ describe("injectAttachments — central-directory extraction (issue #567)", () => {
276
+ let tempDir: string;
277
+
278
+ beforeEach(async () => {
279
+ tempDir = await mkdtemp(join(tmpdir(), "attachment-cd-"));
280
+ });
281
+
282
+ async function extractArchive(zip: Uint8Array) {
283
+ const localFile = join(tempDir, "archive.zip");
284
+ await writeFile(localFile, zip);
285
+ const backend = mockWorkspaceBackend();
286
+ const result = await injectAttachments({
287
+ backend,
288
+ attachments: [makeAttachment({
289
+ filename: "archive.zip",
290
+ mountPath: ".stigmer/inputs/archive",
291
+ extract: true,
292
+ localPath: localFile,
293
+ })],
294
+ storage: makeMockStorage(),
295
+ isLocalMode: true,
296
+ });
297
+ return { backend, result };
298
+ }
299
+
300
+ it("extracts a stored streaming archive completely, contents intact", async () => {
301
+ const { backend, result } = await extractArchive(buildZip([
302
+ { name: "first.txt", content: "first file content", streaming: true },
303
+ { name: "second.txt", content: "second file content", streaming: true },
304
+ ]));
305
+
306
+ expect(result.map((f) => f.path).sort()).toEqual([
307
+ ".stigmer/inputs/archive/first.txt",
308
+ ".stigmer/inputs/archive/second.txt",
309
+ ]);
310
+ expect(backend.writeFileBuffer).toHaveBeenCalledWith(
311
+ ".stigmer/inputs/archive/first.txt", Buffer.from("first file content"),
312
+ );
313
+ expect(backend.writeFileBuffer).toHaveBeenCalledWith(
314
+ ".stigmer/inputs/archive/second.txt", Buffer.from("second file content"),
315
+ );
316
+ });
317
+
318
+ it("extracts a Go-default deflated streaming archive", async () => {
319
+ const { backend, result } = await extractArchive(buildZip([
320
+ { name: "src/main.go", content: "package main\n", method: "deflated", streaming: true },
321
+ ]));
322
+
323
+ expect(result).toHaveLength(1);
324
+ expect(backend.writeFileBuffer).toHaveBeenCalledWith(
325
+ ".stigmer/inputs/archive/src/main.go", Buffer.from("package main\n"),
326
+ );
327
+ });
328
+
329
+ it("aborts when an entry inflates past its declared size (crafted archive)", async () => {
330
+ await expect(extractArchive(buildZip([
331
+ {
332
+ name: "bomb.txt",
333
+ content: "x".repeat(4096),
334
+ method: "deflated",
335
+ declaredUncompressedSize: 16,
336
+ },
337
+ ]))).rejects.toThrow(AttachmentValidationError);
338
+ });
339
+
340
+ it("aborts when a stored entry's payload disagrees with its declared size", async () => {
341
+ await expect(extractArchive(buildZip([
342
+ { name: "short.txt", content: "eleven byte", declaredUncompressedSize: 4096 },
343
+ ]))).rejects.toThrow(/declare/);
344
+ });
345
+ });
346
+
308
347
  // ═══════════════════════════════════════════════════════════════════════
309
348
  // injectAttachments
310
349
  // ═══════════════════════════════════════════════════════════════════════
@@ -1,19 +1,23 @@
1
1
  /**
2
2
  * End-to-end proof (real deepagents + LangGraph runtime, no LLM/network) that
3
- * a plan-mode PARENT graph accepts workspace-relative paths (issue #429).
3
+ * a plan-mode PARENT graph accepts workspace-relative paths (issue #429) and
4
+ * scopes reads to the workspace (issue #528).
4
5
  *
5
- * Before the fix, deepagents' permission enforcement canonicalized every
6
+ * Before the #429 fix, deepagents' permission enforcement canonicalized every
6
7
  * filesystem tool-call path BEFORE any rule ran and refused non-absolute
7
8
  * shapes, so on a rule-bearing graph a workspace-relative call — reads
8
9
  * included — died with `path must be absolute` instead of just working. The
9
10
  * path-normalization middleware (middleware/path-normalization.ts) rewrites
10
11
  * relative paths to workspace-absolute at our seam, before enforcement sees
11
- * them.
12
+ * them. #528 then made the workspace the READ boundary (owner ruling): the
13
+ * rules deny out-of-root reads, the middleware fills the ls/glob/grep
14
+ * omitted-path case (whose schema default is the OS root), and the
15
+ * `.stigmer` symlink keeps platform-dir reads in-root as path strings.
12
16
  *
13
17
  * The graph here is composed exactly the way setup.ts composes the parent:
14
18
  * the PRODUCTION buildMiddlewareStack (pathNormalization present, the
15
19
  * rule-bearing shape) + the CAS capture backend + the PRODUCTION
16
- * PLAN_MODE_PERMISSIONS constant. These tests are also the empirical proof
20
+ * buildPlanModePermissions rules. These tests are also the empirical proof
17
21
  * that langchain's wrapToolCall seam delivers rewritten args to the tool —
18
22
  * if it did not, the relative read below could never succeed.
19
23
  *
@@ -22,20 +26,56 @@
22
26
  */
23
27
 
24
28
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
25
- import { mkdtemp, mkdir, rm, readFile, writeFile, access } from "node:fs/promises";
29
+ import { mkdtemp, mkdir, rm, readFile, writeFile, access, symlink } from "node:fs/promises";
26
30
  import { tmpdir } from "node:os";
27
31
  import { join } from "node:path";
28
32
  import { HumanMessage, ToolMessage, type BaseMessage } from "@langchain/core/messages";
29
33
  import { MemorySaver } from "@langchain/langgraph";
30
34
  import { createDeepAgent } from "deepagents";
31
35
 
32
- import { PLAN_MODE_PERMISSIONS } from "../../../shared/plan-mode-permissions.js";
36
+ import { buildPlanModePermissions } from "../../../shared/plan-mode-permissions.js";
33
37
  import { buildMiddlewareStack } from "../../../middleware/index.js";
34
38
  import { createCasCaptureBackend } from "../cas-capture-backend.js";
35
39
  import { CasCaptureObserver } from "../cas-capture-observer.js";
36
40
  import { ScriptedModel, type ScriptSelector } from "../__test-utils__/scripted-model.js";
37
41
 
38
42
  const SEEDED_CONTENT = "PLAN_MODE_README_TOKEN: hello from the seeded file";
43
+ const OUTSIDE_CONTENT = "OUT_OF_ROOT_SECRET_TOKEN: must never cross the boundary";
44
+ const PLATFORM_CONTENT = "PLATFORM_SKILL_TOKEN: reached through the .stigmer symlink";
45
+
46
+ /**
47
+ * Build a plan-mode parent graph the way setup.ts does: the production
48
+ * middleware stack with pathNormalization set (derived, like the graph's
49
+ * permissions, from the plan-mode rules), a filesystem-only CAS capture
50
+ * backend (no shellEnv — plan mode clears it), and the production
51
+ * buildPlanModePermissions rules on the graph.
52
+ */
53
+ async function buildPlanModeParent(
54
+ root: string,
55
+ observer: CasCaptureObserver,
56
+ script: ScriptSelector,
57
+ ) {
58
+ const { middleware } = buildMiddlewareStack({
59
+ approvalGate: null,
60
+ pathNormalization: { rootDir: root },
61
+ });
62
+ const backend = await createCasCaptureBackend({ rootDir: root, observer });
63
+ return createDeepAgent({
64
+ model: new ScriptedModel(script),
65
+ checkpointer: new MemorySaver() as never,
66
+ backend,
67
+ middleware: middleware as never[],
68
+ permissions: buildPlanModePermissions(root),
69
+ } as Parameters<typeof createDeepAgent>[0]);
70
+ }
71
+
72
+ function toolResultById(messages: BaseMessage[], toolCallId: string): string {
73
+ const match = messages.find(
74
+ (m): m is ToolMessage => m instanceof ToolMessage && m.tool_call_id === toolCallId,
75
+ );
76
+ expect(match, `expected a tool result for call '${toolCallId}'`).toBeDefined();
77
+ return typeof match!.content === "string" ? match!.content : JSON.stringify(match!.content);
78
+ }
39
79
 
40
80
  describe("plan-mode parent path normalization (issue #429)", () => {
41
81
  let root: string;
@@ -52,38 +92,8 @@ describe("plan-mode parent path normalization (issue #429)", () => {
52
92
  await rm(root, { recursive: true, force: true });
53
93
  });
54
94
 
55
- /**
56
- * Build a parent graph the way setup.ts does in plan mode: the production
57
- * middleware stack with pathNormalization set (derived, like the graph's
58
- * permissions, from the plan-mode rules), a filesystem-only CAS capture
59
- * backend (no shellEnv — plan mode clears it), and PLAN_MODE_PERMISSIONS
60
- * on the graph.
61
- */
62
- async function buildPlanModeParent(script: ScriptSelector) {
63
- const { middleware } = buildMiddlewareStack({
64
- approvalGate: null,
65
- pathNormalization: { rootDir: root },
66
- });
67
- const backend = await createCasCaptureBackend({ rootDir: root, observer });
68
- return createDeepAgent({
69
- model: new ScriptedModel(script),
70
- checkpointer: new MemorySaver() as never,
71
- backend,
72
- middleware: middleware as never[],
73
- permissions: PLAN_MODE_PERMISSIONS,
74
- } as Parameters<typeof createDeepAgent>[0]);
75
- }
76
-
77
- function toolResultById(messages: BaseMessage[], toolCallId: string): string {
78
- const match = messages.find(
79
- (m): m is ToolMessage => m instanceof ToolMessage && m.tool_call_id === toolCallId,
80
- );
81
- expect(match, `expected a tool result for call '${toolCallId}'`).toBeDefined();
82
- return typeof match!.content === "string" ? match!.content : JSON.stringify(match!.content);
83
- }
84
-
85
95
  async function invokeOnce(script: ScriptSelector, threadId: string) {
86
- const agent = await buildPlanModeParent(script);
96
+ const agent = await buildPlanModeParent(root, observer, script);
87
97
  return (await agent.invoke(
88
98
  { messages: [new HumanMessage({ content: "go" })] },
89
99
  { configurable: { thread_id: threadId }, recursionLimit: 50 },
@@ -172,3 +182,202 @@ describe("plan-mode parent path normalization (issue #429)", () => {
172
182
  expect(lsResult).not.toMatch(/path must be absolute/i);
173
183
  });
174
184
  });
185
+
186
+ describe("plan-mode workspace read boundary (issue #528)", () => {
187
+ let root: string;
188
+ let outside: string;
189
+ let platform: string;
190
+ let observer: CasCaptureObserver;
191
+
192
+ beforeEach(async () => {
193
+ root = await mkdtemp(join(tmpdir(), "plan-bound-"));
194
+ outside = await mkdtemp(join(tmpdir(), "plan-outside-"));
195
+ platform = await mkdtemp(join(tmpdir(), "plan-platform-"));
196
+
197
+ await mkdir(join(root, "src"), { recursive: true });
198
+ await writeFile(join(root, "src/notes.md"), SEEDED_CONTENT);
199
+ await writeFile(join(outside, "secret.txt"), OUTSIDE_CONTENT);
200
+
201
+ // The production shape from shared/workspace/stigmer-link.ts: the
202
+ // platform dir lives OUTSIDE the workspace, reached through an in-root
203
+ // symlink — its path STRINGS are in-root, which is what the rules match.
204
+ await mkdir(join(platform, "skills"), { recursive: true });
205
+ await writeFile(join(platform, "skills/guide.md"), PLATFORM_CONTENT);
206
+ await symlink(platform, join(root, ".stigmer"));
207
+
208
+ observer = new CasCaptureObserver({ rootDir: root, isIgnored: async () => true });
209
+ });
210
+
211
+ afterEach(async () => {
212
+ await rm(root, { recursive: true, force: true });
213
+ await rm(outside, { recursive: true, force: true });
214
+ await rm(platform, { recursive: true, force: true });
215
+ });
216
+
217
+ async function invokeOnce(script: ScriptSelector, threadId: string) {
218
+ const agent = await buildPlanModeParent(root, observer, script);
219
+ return (await agent.invoke(
220
+ { messages: [new HumanMessage({ content: "go" })] },
221
+ { configurable: { thread_id: threadId }, recursionLimit: 50 },
222
+ )) as { messages: BaseMessage[] };
223
+ }
224
+
225
+ it("denies an out-of-root absolute read — the exposure #429 deliberately preserved is closed", async () => {
226
+ const result = await invokeOnce(
227
+ () => ({
228
+ toolCalls: [
229
+ { name: "read_file", args: { file_path: join(outside, "secret.txt") }, id: "c_read_out" },
230
+ ],
231
+ done: "done",
232
+ }),
233
+ "t_read_out",
234
+ );
235
+
236
+ const readResult = toolResultById(result.messages, "c_read_out");
237
+ expect(readResult).toMatch(/permission denied for read/i);
238
+ expect(readResult).not.toContain("OUT_OF_ROOT_SECRET_TOKEN");
239
+ });
240
+
241
+ it("lists the workspace root itself — {root}/** admits the root, not just its subtree", async () => {
242
+ const result = await invokeOnce(
243
+ () => ({
244
+ toolCalls: [{ name: "ls", args: { path: root }, id: "c_ls_root" }],
245
+ done: "done",
246
+ }),
247
+ "t_ls_root",
248
+ );
249
+
250
+ const lsResult = toolResultById(result.messages, "c_ls_root");
251
+ expect(lsResult).toContain("src");
252
+ expect(lsResult).not.toMatch(/permission denied/i);
253
+ });
254
+
255
+ it("reads platform-dir material through the .stigmer symlink — in-root strings, out-of-root bytes", async () => {
256
+ const result = await invokeOnce(
257
+ () => ({
258
+ toolCalls: [
259
+ { name: "read_file", args: { file_path: join(root, ".stigmer/skills/guide.md") }, id: "c_read_skill" },
260
+ ],
261
+ done: "done",
262
+ }),
263
+ "t_read_skill",
264
+ );
265
+
266
+ const readResult = toolResultById(result.messages, "c_read_skill");
267
+ expect(readResult).toContain("PLATFORM_SKILL_TOKEN");
268
+ expect(readResult).not.toMatch(/permission denied/i);
269
+ });
270
+
271
+ it("a bare ls (no path argument) lists the workspace, not the OS root", async () => {
272
+ // The tool's schema default is "/" — the OS root — applied inside the
273
+ // tool, after the middleware seam. The middleware fills the omission
274
+ // with the workspace root, so the model's first listing just works.
275
+ const result = await invokeOnce(
276
+ () => ({
277
+ toolCalls: [{ name: "ls", args: {}, id: "c_ls_bare" }],
278
+ done: "done",
279
+ }),
280
+ "t_ls_bare",
281
+ );
282
+
283
+ const lsResult = toolResultById(result.messages, "c_ls_bare");
284
+ expect(lsResult).toContain("src");
285
+ expect(lsResult).not.toMatch(/permission denied/i);
286
+ });
287
+
288
+ it("a bare grep (no path argument) searches the workspace, not the whole filesystem", async () => {
289
+ // Pre-#528, a bare grep recursively scanned the ENTIRE OS filesystem
290
+ // (schema default "/" + the legacy backend's literal pass-through).
291
+ const result = await invokeOnce(
292
+ () => ({
293
+ toolCalls: [
294
+ { name: "grep", args: { pattern: "PLAN_MODE_README_TOKEN" }, id: "c_grep_bare" },
295
+ ],
296
+ done: "done",
297
+ }),
298
+ "t_grep_bare",
299
+ );
300
+
301
+ const grepResult = toolResultById(result.messages, "c_grep_bare");
302
+ expect(grepResult).toContain("notes.md");
303
+ expect(grepResult).not.toMatch(/permission denied/i);
304
+ });
305
+
306
+ it("an explicit ls of '/' is denied honestly — not silently redirected to the workspace", async () => {
307
+ const result = await invokeOnce(
308
+ () => ({
309
+ toolCalls: [{ name: "ls", args: { path: "/" }, id: "c_ls_slash" }],
310
+ done: "done",
311
+ }),
312
+ "t_ls_slash",
313
+ );
314
+
315
+ expect(toolResultById(result.messages, "c_ls_slash")).toMatch(
316
+ /permission denied for read on \//i,
317
+ );
318
+ });
319
+
320
+ it("writes stay denied everywhere — the read-allow rule admits reads only", async () => {
321
+ const result = await invokeOnce(
322
+ () => ({
323
+ toolCalls: [
324
+ { name: "write_file", args: { file_path: join(root, "src/out.txt"), content: "x" }, id: "c_write_in" },
325
+ ],
326
+ done: "done",
327
+ }),
328
+ "t_write_in",
329
+ );
330
+
331
+ expect(toolResultById(result.messages, "c_write_in")).toMatch(/permission denied for write/i);
332
+ await expect(access(join(root, "src/out.txt"))).rejects.toThrow();
333
+ });
334
+ });
335
+
336
+ describe("plan-mode read boundary with a glob-special workspace root (issue #528)", () => {
337
+ // Desktop localPath workspaces use the user's real project directory AS
338
+ // the root — names like "My (work) [v2]" are legal there. This suite pins
339
+ // escapeGlobLiteral against deepagents' real matcher end-to-end: without
340
+ // escaping, the read-allow rule would silently never match and every
341
+ // plan-mode read in such a workspace would be denied.
342
+ let base: string;
343
+ let root: string;
344
+ let observer: CasCaptureObserver;
345
+
346
+ beforeEach(async () => {
347
+ base = await mkdtemp(join(tmpdir(), "plan-glob-"));
348
+ root = join(base, "My (work) [v2]");
349
+ await mkdir(join(root, "src"), { recursive: true });
350
+ await writeFile(join(root, "src/notes.md"), SEEDED_CONTENT);
351
+ observer = new CasCaptureObserver({ rootDir: root, isIgnored: async () => true });
352
+ });
353
+
354
+ afterEach(async () => {
355
+ await rm(base, { recursive: true, force: true });
356
+ });
357
+
358
+ async function invokeOnce(script: ScriptSelector, threadId: string) {
359
+ const agent = await buildPlanModeParent(root, observer, script);
360
+ return (await agent.invoke(
361
+ { messages: [new HumanMessage({ content: "go" })] },
362
+ { configurable: { thread_id: threadId }, recursionLimit: 50 },
363
+ )) as { messages: BaseMessage[] };
364
+ }
365
+
366
+ it("in-root reads work, absolute and relative alike; out-of-root reads stay denied", async () => {
367
+ const result = await invokeOnce(
368
+ () => ({
369
+ toolCalls: [
370
+ { name: "read_file", args: { file_path: join(root, "src/notes.md") }, id: "c_abs" },
371
+ { name: "read_file", args: { file_path: "src/notes.md" }, id: "c_rel" },
372
+ { name: "read_file", args: { file_path: "/etc/hosts" }, id: "c_out" },
373
+ ],
374
+ done: "done",
375
+ }),
376
+ "t_glob_root",
377
+ );
378
+
379
+ expect(toolResultById(result.messages, "c_abs")).toContain("PLAN_MODE_README_TOKEN");
380
+ expect(toolResultById(result.messages, "c_rel")).toContain("PLAN_MODE_README_TOKEN");
381
+ expect(toolResultById(result.messages, "c_out")).toMatch(/permission denied for read/i);
382
+ });
383
+ });
@@ -422,14 +422,19 @@ describe("buildEnhancedSystemPrompt", () => {
422
422
  injectedFiles: [],
423
423
  };
424
424
 
425
- it("appends the shared plan-mode directive as the final section", () => {
425
+ it("appends the shared plan-mode directive plus the native-only read-boundary line as the final section", () => {
426
426
  const prompt = buildEnhancedSystemPrompt({
427
427
  ...base,
428
428
  interactionMode: InteractionMode.PLAN,
429
429
  });
430
430
 
431
431
  expect(prompt).toContain("## Plan mode");
432
- expect(prompt.endsWith(PLAN_MODE_DIRECTIVE)).toBe(true);
432
+ expect(prompt).toContain(PLAN_MODE_DIRECTIVE);
433
+ // The read boundary (issue #528) is enforced only on the native
434
+ // harness, so its sentence rides OUTSIDE the shared directive — after
435
+ // it, still in the plan-mode section.
436
+ expect(prompt.endsWith("paths outside it are refused.")).toBe(true);
437
+ expect(PLAN_MODE_DIRECTIVE).not.toContain("File reads are limited");
433
438
  });
434
439
 
435
440
  it("omits the directive for Agent mode", () => {