@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +23 -8
- package/dist/activities/execute-deep-agent/attachment-injector.js +104 -105
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.js +11 -1
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +7 -3
- package/dist/activities/execute-deep-agent/setup.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/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/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/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__/zip-fixtures.ts +206 -0
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -42
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +165 -126
- 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 +7 -2
- package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
- package/src/activities/execute-deep-agent/attachment-injector.ts +146 -142
- package/src/activities/execute-deep-agent/prompt-builder.ts +11 -1
- package/src/activities/execute-deep-agent/setup.ts +7 -3
- package/src/middleware/__tests__/path-normalization.test.ts +29 -3
- package/src/middleware/path-normalization.ts +42 -5
- package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
- package/src/shared/__tests__/zip-extract.test.ts +106 -89
- package/src/shared/mcp-enabled-tools.ts +6 -2
- package/src/shared/plan-mode-permissions.ts +59 -12
- package/src/shared/zip-extract.ts +35 -117
- 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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
+
});
|
|
@@ -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.
|
|
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", () => {
|