@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +11 -0
- package/dist/activities/execute-cursor/attachment-resolver.js +8 -0
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
- package/dist/activities/execute-cursor/blueprint-resolver.js +0 -1
- package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +6 -2
- package/dist/activities/execute-cursor/index.js +16 -32
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +15 -11
- package/dist/activities/execute-cursor/prompt-builder.js +20 -10
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +34 -8
- package/dist/activities/execute-deep-agent/attachment-injector.js +111 -106
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +0 -15
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/mcp-gate.d.ts +0 -2
- package/dist/activities/execute-deep-agent/mcp-gate.js +0 -1
- package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -8
- package/dist/activities/execute-deep-agent/prompt-builder.js +25 -8
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +0 -10
- package/dist/activities/execute-deep-agent/setup.js +13 -45
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/config.d.ts +6 -5
- package/dist/config.js.map +1 -1
- package/dist/middleware/path-normalization.d.ts +15 -2
- package/dist/middleware/path-normalization.js +39 -5
- package/dist/middleware/path-normalization.js.map +1 -1
- package/dist/shared/artifact-storage.d.ts +11 -0
- package/dist/shared/artifact-storage.js +2 -0
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/attachment-download-urls.d.ts +62 -0
- package/dist/shared/attachment-download-urls.js +79 -0
- package/dist/shared/attachment-download-urls.js.map +1 -0
- package/dist/shared/channel-attachment.d.ts +1 -1
- package/dist/shared/channel-attachment.js +1 -1
- package/dist/shared/conversation-attachment.d.ts +2 -3
- package/dist/shared/conversation-attachment.js +2 -3
- package/dist/shared/conversation-attachment.js.map +1 -1
- package/dist/shared/mcp-enabled-tools.d.ts +6 -2
- package/dist/shared/mcp-enabled-tools.js +6 -2
- package/dist/shared/mcp-enabled-tools.js.map +1 -1
- package/dist/shared/mcp-transport-guard.d.ts +2 -2
- package/dist/shared/mcp-transport-guard.js +2 -2
- package/dist/shared/plan-mode-permissions.d.ts +46 -10
- package/dist/shared/plan-mode-permissions.js +56 -12
- package/dist/shared/plan-mode-permissions.js.map +1 -1
- package/dist/shared/synthesized-attachment.d.ts +4 -4
- package/dist/shared/synthesized-attachment.js +4 -4
- package/dist/shared/zip-extract.d.ts +24 -6
- package/dist/shared/zip-extract.js +31 -90
- package/dist/shared/zip-extract.js.map +1 -1
- package/dist/shared/zip-structure.d.ts +61 -0
- package/dist/shared/zip-structure.js +128 -0
- package/dist/shared/zip-structure.js.map +1 -0
- package/package.json +2 -2
- package/src/__test-utils__/fake-artifact-storage.ts +10 -1
- package/src/__test-utils__/zip-fixtures.ts +206 -0
- package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +63 -3
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +62 -29
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -42
- package/src/activities/execute-cursor/attachment-resolver.ts +24 -0
- package/src/activities/execute-cursor/blueprint-resolver.ts +1 -10
- package/src/activities/execute-cursor/index.ts +22 -37
- package/src/activities/execute-cursor/prompt-builder.ts +38 -22
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +270 -126
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +0 -2
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +2 -7
- package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +78 -2
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +0 -1
- package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
- package/src/activities/execute-deep-agent/attachment-injector.ts +166 -143
- package/src/activities/execute-deep-agent/index.ts +0 -19
- package/src/activities/execute-deep-agent/mcp-gate.ts +0 -3
- package/src/activities/execute-deep-agent/prompt-builder.ts +38 -16
- package/src/activities/execute-deep-agent/setup.ts +13 -68
- package/src/config.ts +6 -5
- package/src/middleware/__tests__/path-normalization.test.ts +29 -3
- package/src/middleware/path-normalization.ts +42 -5
- package/src/shared/__tests__/artifact-storage.test.ts +9 -0
- package/src/shared/__tests__/attachment-download-urls.test.ts +70 -0
- package/src/shared/__tests__/channel-attachment.test.ts +2 -2
- package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
- package/src/shared/__tests__/synthesized-attachment.test.ts +19 -41
- package/src/shared/__tests__/zip-extract.test.ts +106 -89
- package/src/shared/artifact-storage.ts +11 -0
- package/src/shared/attachment-download-urls.ts +98 -0
- package/src/shared/channel-attachment.ts +1 -1
- package/src/shared/conversation-attachment.ts +2 -3
- package/src/shared/mcp-enabled-tools.ts +6 -2
- package/src/shared/mcp-transport-guard.ts +2 -2
- package/src/shared/plan-mode-permissions.ts +59 -12
- package/src/shared/synthesized-attachment.ts +4 -4
- package/src/shared/zip-extract.ts +35 -117
- package/src/shared/zip-structure.ts +181 -0
- package/dist/shared/datastore-attachment.d.ts +0 -99
- package/dist/shared/datastore-attachment.js +0 -184
- package/dist/shared/datastore-attachment.js.map +0 -1
- package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +0 -104
- package/src/shared/__tests__/datastore-attachment.test.ts +0 -293
- 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
|
-
*
|
|
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 {
|
|
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.
|
|
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", () => {
|
|
@@ -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
|
|
17
|
-
* shared/plan-mode-permissions.ts (the same
|
|
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 {
|
|
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:
|
|
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
|
});
|