@stigmer/runner 3.11.1-dev.20260812192248 → 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 (108) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +11 -0
  3. package/dist/activities/execute-cursor/attachment-resolver.js +8 -0
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  6. package/dist/activities/execute-cursor/blueprint-resolver.js +0 -1
  7. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  8. package/dist/activities/execute-cursor/index.d.ts +6 -2
  9. package/dist/activities/execute-cursor/index.js +16 -32
  10. package/dist/activities/execute-cursor/index.js.map +1 -1
  11. package/dist/activities/execute-cursor/prompt-builder.d.ts +15 -11
  12. package/dist/activities/execute-cursor/prompt-builder.js +20 -10
  13. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  14. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +34 -8
  15. package/dist/activities/execute-deep-agent/attachment-injector.js +111 -106
  16. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  17. package/dist/activities/execute-deep-agent/index.js +0 -15
  18. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +0 -2
  20. package/dist/activities/execute-deep-agent/mcp-gate.js +0 -1
  21. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
  22. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -8
  23. package/dist/activities/execute-deep-agent/prompt-builder.js +25 -8
  24. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  25. package/dist/activities/execute-deep-agent/setup.d.ts +0 -10
  26. package/dist/activities/execute-deep-agent/setup.js +13 -45
  27. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  28. package/dist/config.d.ts +6 -5
  29. package/dist/config.js.map +1 -1
  30. package/dist/middleware/path-normalization.d.ts +15 -2
  31. package/dist/middleware/path-normalization.js +39 -5
  32. package/dist/middleware/path-normalization.js.map +1 -1
  33. package/dist/shared/artifact-storage.d.ts +11 -0
  34. package/dist/shared/artifact-storage.js +2 -0
  35. package/dist/shared/artifact-storage.js.map +1 -1
  36. package/dist/shared/attachment-download-urls.d.ts +62 -0
  37. package/dist/shared/attachment-download-urls.js +79 -0
  38. package/dist/shared/attachment-download-urls.js.map +1 -0
  39. package/dist/shared/channel-attachment.d.ts +1 -1
  40. package/dist/shared/channel-attachment.js +1 -1
  41. package/dist/shared/conversation-attachment.d.ts +2 -3
  42. package/dist/shared/conversation-attachment.js +2 -3
  43. package/dist/shared/conversation-attachment.js.map +1 -1
  44. package/dist/shared/mcp-enabled-tools.d.ts +6 -2
  45. package/dist/shared/mcp-enabled-tools.js +6 -2
  46. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  47. package/dist/shared/mcp-transport-guard.d.ts +2 -2
  48. package/dist/shared/mcp-transport-guard.js +2 -2
  49. package/dist/shared/plan-mode-permissions.d.ts +46 -10
  50. package/dist/shared/plan-mode-permissions.js +56 -12
  51. package/dist/shared/plan-mode-permissions.js.map +1 -1
  52. package/dist/shared/synthesized-attachment.d.ts +4 -4
  53. package/dist/shared/synthesized-attachment.js +4 -4
  54. package/dist/shared/zip-extract.d.ts +24 -6
  55. package/dist/shared/zip-extract.js +31 -90
  56. package/dist/shared/zip-extract.js.map +1 -1
  57. package/dist/shared/zip-structure.d.ts +61 -0
  58. package/dist/shared/zip-structure.js +128 -0
  59. package/dist/shared/zip-structure.js.map +1 -0
  60. package/package.json +2 -2
  61. package/src/__test-utils__/fake-artifact-storage.ts +10 -1
  62. package/src/__test-utils__/zip-fixtures.ts +206 -0
  63. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +63 -3
  64. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +62 -29
  65. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -42
  66. package/src/activities/execute-cursor/attachment-resolver.ts +24 -0
  67. package/src/activities/execute-cursor/blueprint-resolver.ts +1 -10
  68. package/src/activities/execute-cursor/index.ts +22 -37
  69. package/src/activities/execute-cursor/prompt-builder.ts +38 -22
  70. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +270 -126
  71. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +0 -2
  72. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +0 -1
  73. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +0 -1
  74. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +2 -7
  75. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
  76. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +78 -2
  77. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +0 -1
  78. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
  79. package/src/activities/execute-deep-agent/attachment-injector.ts +166 -143
  80. package/src/activities/execute-deep-agent/index.ts +0 -19
  81. package/src/activities/execute-deep-agent/mcp-gate.ts +0 -3
  82. package/src/activities/execute-deep-agent/prompt-builder.ts +38 -16
  83. package/src/activities/execute-deep-agent/setup.ts +13 -68
  84. package/src/config.ts +6 -5
  85. package/src/middleware/__tests__/path-normalization.test.ts +29 -3
  86. package/src/middleware/path-normalization.ts +42 -5
  87. package/src/shared/__tests__/artifact-storage.test.ts +9 -0
  88. package/src/shared/__tests__/attachment-download-urls.test.ts +70 -0
  89. package/src/shared/__tests__/channel-attachment.test.ts +2 -2
  90. package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
  91. package/src/shared/__tests__/synthesized-attachment.test.ts +19 -41
  92. package/src/shared/__tests__/zip-extract.test.ts +106 -89
  93. package/src/shared/artifact-storage.ts +11 -0
  94. package/src/shared/attachment-download-urls.ts +98 -0
  95. package/src/shared/channel-attachment.ts +1 -1
  96. package/src/shared/conversation-attachment.ts +2 -3
  97. package/src/shared/mcp-enabled-tools.ts +6 -2
  98. package/src/shared/mcp-transport-guard.ts +2 -2
  99. package/src/shared/plan-mode-permissions.ts +59 -12
  100. package/src/shared/synthesized-attachment.ts +4 -4
  101. package/src/shared/zip-extract.ts +35 -117
  102. package/src/shared/zip-structure.ts +181 -0
  103. package/dist/shared/datastore-attachment.d.ts +0 -99
  104. package/dist/shared/datastore-attachment.js +0 -184
  105. package/dist/shared/datastore-attachment.js.map +0 -1
  106. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +0 -104
  107. package/src/shared/__tests__/datastore-attachment.test.ts +0 -293
  108. package/src/shared/datastore-attachment.ts +0 -209
@@ -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
+ });
@@ -190,6 +190,77 @@ describe("buildEnhancedSystemPrompt", () => {
190
190
  expect(prompt).toContain("`.stigmer/inputs/report.pdf` (10 bytes)\n");
191
191
  });
192
192
 
193
+ it("lists a minted download URL beside its file with the presigned hand-off line (issue #532)", () => {
194
+ const prompt = buildEnhancedSystemPrompt({
195
+ instructions: "Test",
196
+ provisionResults: [],
197
+ containerRoot: "",
198
+ skillsPromptSection: "",
199
+ workspaceFileRefs: [],
200
+ workspaceRoot: "/workspace",
201
+ injectedFiles: [
202
+ {
203
+ filename: "lease.pdf",
204
+ path: ".stigmer/inputs/lease.pdf",
205
+ sizeBytes: 2048,
206
+ downloadUrl: "https://r2.example/lease?sig=abc",
207
+ },
208
+ { filename: "notes.md", path: ".stigmer/inputs/notes.md", sizeBytes: 12 },
209
+ ],
210
+ downloadUrlKind: "presigned",
211
+ });
212
+
213
+ expect(prompt).toContain(
214
+ "`.stigmer/inputs/lease.pdf` (2048 bytes) — download URL: https://r2.example/lease?sig=abc",
215
+ );
216
+ // The URL-less file keeps a clean entry.
217
+ expect(prompt).toContain("`.stigmer/inputs/notes.md` (12 bytes)\n");
218
+ expect(prompt).toContain("These URLs are time-limited");
219
+ });
220
+
221
+ it("words a local-serve URL honestly — reachable only from this machine", () => {
222
+ const prompt = buildEnhancedSystemPrompt({
223
+ instructions: "Test",
224
+ provisionResults: [],
225
+ containerRoot: "",
226
+ skillsPromptSection: "",
227
+ workspaceFileRefs: [],
228
+ workspaceRoot: "/workspace",
229
+ injectedFiles: [
230
+ {
231
+ filename: "lease.pdf",
232
+ path: ".stigmer/inputs/lease.pdf",
233
+ sizeBytes: 2048,
234
+ downloadUrl: "http://localhost:7235/attachments/01A/lease.pdf",
235
+ },
236
+ ],
237
+ downloadUrlKind: "local-serve",
238
+ });
239
+
240
+ expect(prompt).toContain("download URL: http://localhost:7235/attachments/01A/lease.pdf");
241
+ expect(prompt).toContain("reachable only from this machine");
242
+ expect(prompt).not.toContain("time-limited");
243
+ });
244
+
245
+ it("renders no hand-off line when no listed file carries a URL (kind alone is not enough)", () => {
246
+ const prompt = buildEnhancedSystemPrompt({
247
+ instructions: "Test",
248
+ provisionResults: [],
249
+ containerRoot: "",
250
+ skillsPromptSection: "",
251
+ workspaceFileRefs: [],
252
+ workspaceRoot: "/workspace",
253
+ injectedFiles: [
254
+ { filename: "local.csv", path: ".stigmer/inputs/local.csv", sizeBytes: 5 },
255
+ ],
256
+ downloadUrlKind: "presigned",
257
+ });
258
+
259
+ expect(prompt).toContain("## Input Files");
260
+ expect(prompt).not.toContain("download URL");
261
+ expect(prompt).not.toContain("time-limited");
262
+ });
263
+
193
264
  it("produces a git repo description for git workspace entries", () => {
194
265
  const prompt = buildEnhancedSystemPrompt({
195
266
  instructions: "Test",
@@ -351,14 +422,19 @@ describe("buildEnhancedSystemPrompt", () => {
351
422
  injectedFiles: [],
352
423
  };
353
424
 
354
- 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", () => {
355
426
  const prompt = buildEnhancedSystemPrompt({
356
427
  ...base,
357
428
  interactionMode: InteractionMode.PLAN,
358
429
  });
359
430
 
360
431
  expect(prompt).toContain("## Plan mode");
361
- 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");
362
438
  });
363
439
 
364
440
  it("omits the directive for Agent mode", () => {
@@ -196,7 +196,6 @@ function fakeMemoryGateSetup(opts: {
196
196
  readFile: vi.fn(async () => ""),
197
197
  },
198
198
  mcpConnection: null,
199
- datastoreToolsMissing: [],
200
199
  mergedEnvVars: {},
201
200
  secretKeys: new Set<string>(),
202
201
  modelName: "claude-sonnet",
@@ -13,8 +13,8 @@
13
13
  * rules (act mode) the same write flows — this change is a plan-mode-only
14
14
  * delta.
15
15
  *
16
- * The rule under test is the PRODUCTION constant from
17
- * shared/plan-mode-permissions.ts (the same one setup.ts applies to both
16
+ * The rules under test are the PRODUCTION buildPlanModePermissions output
17
+ * from shared/plan-mode-permissions.ts (the same setup.ts applies to both
18
18
  * graph sites), not a test copy, so a change to the plan-mode permission set
19
19
  * re-proves sub-agent coverage automatically.
20
20
  */
@@ -25,7 +25,7 @@ import { tmpdir } from "node:os";
25
25
  import { join } from "node:path";
26
26
  import { HumanMessage, ToolMessage, type BaseMessage } from "@langchain/core/messages";
27
27
 
28
- import { PLAN_MODE_PERMISSIONS } from "../../../shared/plan-mode-permissions.js";
28
+ import { buildPlanModePermissions } from "../../../shared/plan-mode-permissions.js";
29
29
  import { CasCaptureObserver } from "../cas-capture-observer.js";
30
30
  import { compileSubagents } from "../subagent-transformer.js";
31
31
  import { ScriptedModel, type ScriptSelector } from "../__test-utils__/scripted-model.js";
@@ -64,7 +64,7 @@ describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
64
64
  workspaceRootDir: root,
65
65
  casObserver: observer,
66
66
  modelFactory: async () => new ScriptedModel(script),
67
- ...(planMode ? { permissions: PLAN_MODE_PERMISSIONS } : {}),
67
+ ...(planMode ? { permissions: buildPlanModePermissions(root) } : {}),
68
68
  },
69
69
  );
70
70
  expect(compiled).toHaveLength(1);
@@ -175,4 +175,35 @@ describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
175
175
  expect(readResult).toContain("PLAN_MODE_README_TOKEN");
176
176
  expect(readResult).not.toMatch(/path must be absolute/i);
177
177
  });
178
+
179
+ it("scopes sub-agent reads to the workspace and fills the bare-ls default (issue #528)", async () => {
180
+ // The read boundary must hold on SUB-AGENT graphs too — they carry their
181
+ // own rules (issue #255) and their own normalization shim (#429), so a
182
+ // parent-only fix would leave sub-agents reading anywhere. One turn,
183
+ // three calls: out-of-root read denied, in-root read flows, bare ls
184
+ // (whose schema default is the OS root) lists the workspace.
185
+ const script: ScriptSelector = () => ({
186
+ toolCalls: [
187
+ { name: "read_file", args: { file_path: "/etc/hosts" }, id: "c_read_out" },
188
+ { name: "read_file", args: { file_path: join(root, "notes.md") }, id: "c_read_in" },
189
+ { name: "ls", args: {}, id: "c_ls_bare" },
190
+ ],
191
+ done: "worker done",
192
+ });
193
+
194
+ const worker = await compileWorker(script, true);
195
+ const result = (await worker.runnable.invoke(
196
+ { messages: [new HumanMessage({ content: "go" })] },
197
+ { configurable: { thread_id: "t4" }, recursionLimit: 50 },
198
+ )) as { messages: BaseMessage[] };
199
+
200
+ const outResult = toolResultById(result.messages, "c_read_out");
201
+ expect(outResult).toMatch(/permission denied for read/i);
202
+
203
+ expect(toolResultById(result.messages, "c_read_in")).toContain("PLAN_MODE_README_TOKEN");
204
+
205
+ const lsResult = toolResultById(result.messages, "c_ls_bare");
206
+ expect(lsResult).toContain("notes.md");
207
+ expect(lsResult).not.toMatch(/permission denied/i);
208
+ });
178
209
  });