@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
|
@@ -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
|
});
|
|
@@ -5,13 +5,23 @@
|
|
|
5
5
|
* validates ZIP archives with security guards, and writes files into the
|
|
6
6
|
* workspace via the platform mount namespace (.stigmer/inputs/).
|
|
7
7
|
*
|
|
8
|
-
* Security model: attachments are untrusted user uploads.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Security model: attachments are untrusted user uploads. ZIP archives are
|
|
9
|
+
* parsed from the central directory — the format's authoritative index —
|
|
10
|
+
* via the shared structural layer (shared/zip-structure.ts; issue #567,
|
|
11
|
+
* which killed this module's local-header walk: it silently truncated
|
|
12
|
+
* stored streaming entries and rejected Go-default archives outright).
|
|
13
|
+
* Every entry is validated for path traversal, zip bombs, and format
|
|
14
|
+
* integrity BEFORE any extraction occurs, and because the central
|
|
15
|
+
* directory's sizes are declarations an attacker controls, decompression
|
|
16
|
+
* re-enforces them: an entry whose actual output disagrees with its
|
|
17
|
+
* declared size aborts the injection.
|
|
11
18
|
*
|
|
12
19
|
* Error model: fail-hard. Any attachment failure aborts the entire injection
|
|
13
20
|
* and propagates a descriptive error. Attachments are explicit user inputs —
|
|
14
|
-
* running with partial inputs produces silently incorrect results.
|
|
21
|
+
* running with partial inputs produces silently incorrect results. This is
|
|
22
|
+
* deliberately the OPPOSITE policy from the skill-artifact reader
|
|
23
|
+
* (shared/zip-extract.ts, non-fatal empty return): skill artifacts were
|
|
24
|
+
* structurally vouched for by the push gates, attachments never are.
|
|
15
25
|
*
|
|
16
26
|
* Duplicate names are NOT a failure (issue #364): a default-derived mount
|
|
17
27
|
* path that collides is renamed with the platform's `stem-2.ext` semantics
|
|
@@ -34,13 +44,17 @@ import type {
|
|
|
34
44
|
VisionDegradedReason,
|
|
35
45
|
VisionImage,
|
|
36
46
|
} from "../../shared/attachment-vision.js";
|
|
47
|
+
import {
|
|
48
|
+
EOCD_MIN_SIZE,
|
|
49
|
+
parseZipStructure,
|
|
50
|
+
type ZipStructuralEntry,
|
|
51
|
+
} from "../../shared/zip-structure.js";
|
|
37
52
|
|
|
38
53
|
// ── Constants ────────────────────────────────────────────────────────
|
|
39
54
|
|
|
40
55
|
const MAX_ZIP_FILES = 1000;
|
|
41
56
|
const MAX_ZIP_EXTRACTED_SIZE = 100 * 1024 * 1024; // 100 MB
|
|
42
57
|
const DEFAULT_INPUTS_PREFIX = ".stigmer/inputs";
|
|
43
|
-
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
|
44
58
|
|
|
45
59
|
export { MAX_ZIP_FILES, MAX_ZIP_EXTRACTED_SIZE };
|
|
46
60
|
|
|
@@ -132,126 +146,117 @@ export class AttachmentValidationError extends Error {
|
|
|
132
146
|
}
|
|
133
147
|
}
|
|
134
148
|
|
|
135
|
-
// ── ZIP Validation (pure
|
|
149
|
+
// ── ZIP Validation (pure functions) ──────────────────────────────────
|
|
136
150
|
|
|
137
151
|
/**
|
|
138
152
|
* Validate a ZIP archive for safe extraction. Returns the manifest of
|
|
139
153
|
* file entries (directories excluded) if the archive passes all checks.
|
|
140
154
|
*
|
|
141
155
|
* Security checks enforced:
|
|
142
|
-
* 1. Valid ZIP format (
|
|
156
|
+
* 1. Valid ZIP format (readable central directory; fail-hard, see module doc)
|
|
143
157
|
* 2. No absolute paths (entries starting with / or \)
|
|
144
158
|
* 3. No path traversal (.. components)
|
|
145
159
|
* 4. No null bytes in filenames
|
|
146
|
-
* 5.
|
|
147
|
-
* 6.
|
|
148
|
-
*
|
|
160
|
+
* 5. No duplicate entry paths (a contradictory manifest)
|
|
161
|
+
* 6. Only supported compression methods (stored, deflate) — checked here
|
|
162
|
+
* so an unsupported entry can never abort extraction after earlier
|
|
163
|
+
* entries were already written
|
|
164
|
+
* 7. Non-empty archive (at least one file entry)
|
|
165
|
+
* 8. File count within limits (max 1000)
|
|
166
|
+
* 9. Total declared uncompressed size within limits (max 100 MB) —
|
|
167
|
+
* declarations are re-enforced against actual output at decompression
|
|
149
168
|
*/
|
|
150
169
|
export function validateZipForExtraction(
|
|
151
170
|
zipData: Buffer,
|
|
152
171
|
sourceFilename: string,
|
|
153
172
|
): ZipEntryInfo[] {
|
|
154
|
-
|
|
173
|
+
return parseAndValidateZip(zipData, sourceFilename).map(
|
|
174
|
+
({ relativePath, uncompressedSize }) => ({ relativePath, uncompressedSize }),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A validated file entry, still carrying its payload slice. Validation and
|
|
180
|
+
* extraction share these records — parsing happens exactly once, so "the
|
|
181
|
+
* manifest promised a file extraction cannot find" is unrepresentable
|
|
182
|
+
* (the old two-walker design silently skipped such entries).
|
|
183
|
+
*/
|
|
184
|
+
interface ValidatedZipEntry extends ZipEntryInfo {
|
|
185
|
+
readonly compressionMethod: number;
|
|
186
|
+
readonly compressedData: Uint8Array;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseAndValidateZip(
|
|
190
|
+
zipData: Buffer,
|
|
191
|
+
sourceFilename: string,
|
|
192
|
+
): ValidatedZipEntry[] {
|
|
193
|
+
if (zipData.length < EOCD_MIN_SIZE) {
|
|
155
194
|
throw new AttachmentValidationError(
|
|
156
195
|
sourceFilename,
|
|
157
196
|
"not a valid ZIP archive (file too small)",
|
|
158
197
|
);
|
|
159
198
|
}
|
|
160
199
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
200
|
+
let structural: ZipStructuralEntry[];
|
|
201
|
+
try {
|
|
202
|
+
structural = parseZipStructure(zipData);
|
|
203
|
+
} catch (err) {
|
|
165
204
|
throw new AttachmentValidationError(
|
|
166
205
|
sourceFilename,
|
|
167
|
-
|
|
206
|
+
`not a valid ZIP archive (${err instanceof Error ? err.message : String(err)})`,
|
|
168
207
|
);
|
|
169
208
|
}
|
|
170
209
|
|
|
171
|
-
const entries:
|
|
210
|
+
const entries: ValidatedZipEntry[] = [];
|
|
211
|
+
const seenPaths = new Set<string>();
|
|
172
212
|
let totalUncompressed = 0;
|
|
173
|
-
let offset = 0;
|
|
174
|
-
|
|
175
|
-
while (offset < zipData.length - 4) {
|
|
176
|
-
const signature = view.getUint32(offset, true);
|
|
177
|
-
if (signature !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) break;
|
|
178
213
|
|
|
179
|
-
|
|
214
|
+
for (const entry of structural) {
|
|
215
|
+
if (entry.name.includes("\u0000")) {
|
|
180
216
|
throw new AttachmentValidationError(
|
|
181
217
|
sourceFilename,
|
|
182
|
-
"
|
|
218
|
+
"contains a filename with null bytes and cannot be safely extracted",
|
|
183
219
|
);
|
|
184
220
|
}
|
|
185
221
|
|
|
186
|
-
|
|
187
|
-
const compressedSize = view.getUint32(offset + 18, true);
|
|
188
|
-
const uncompressedSize = view.getUint32(offset + 22, true);
|
|
189
|
-
const fileNameLength = view.getUint16(offset + 26, true);
|
|
190
|
-
const extraFieldLength = view.getUint16(offset + 28, true);
|
|
222
|
+
if (entry.isDirectory) continue;
|
|
191
223
|
|
|
192
|
-
|
|
193
|
-
const fileNameEnd = fileNameStart + fileNameLength;
|
|
194
|
-
|
|
195
|
-
if (fileNameEnd > zipData.length) {
|
|
224
|
+
if (entry.name.startsWith("/") || entry.name.startsWith("\\")) {
|
|
196
225
|
throw new AttachmentValidationError(
|
|
197
226
|
sourceFilename,
|
|
198
|
-
|
|
227
|
+
`contains an absolute path entry and cannot be safely extracted: ${entry.name}`,
|
|
199
228
|
);
|
|
200
229
|
}
|
|
201
230
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
sourceFilename,
|
|
208
|
-
"contains a filename with null bytes and cannot be safely extracted",
|
|
209
|
-
);
|
|
210
|
-
}
|
|
231
|
+
if (hasPathTraversal(entry.name)) {
|
|
232
|
+
throw new AttachmentValidationError(
|
|
233
|
+
sourceFilename,
|
|
234
|
+
`contains a path traversal entry and cannot be safely extracted: ${entry.name}`,
|
|
235
|
+
);
|
|
211
236
|
}
|
|
212
237
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (fileName.startsWith("/") || fileName.startsWith("\\")) {
|
|
219
|
-
throw new AttachmentValidationError(
|
|
220
|
-
sourceFilename,
|
|
221
|
-
`contains an absolute path entry and cannot be safely extracted: ${fileName}`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (hasPathTraversal(fileName)) {
|
|
226
|
-
throw new AttachmentValidationError(
|
|
227
|
-
sourceFilename,
|
|
228
|
-
`contains a path traversal entry and cannot be safely extracted: ${fileName}`,
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
entries.push({ relativePath: fileName, uncompressedSize });
|
|
233
|
-
totalUncompressed += uncompressedSize;
|
|
238
|
+
if (seenPaths.has(entry.name)) {
|
|
239
|
+
throw new AttachmentValidationError(
|
|
240
|
+
sourceFilename,
|
|
241
|
+
`contains duplicate entry '${entry.name}' and cannot be safely extracted`,
|
|
242
|
+
);
|
|
234
243
|
}
|
|
244
|
+
seenPaths.add(entry.name);
|
|
235
245
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if ((generalFlags & 0x08) !== 0 && compressedSize === 0) {
|
|
243
|
-
// Data descriptor follows compressed data — scan for next header
|
|
244
|
-
// This is a simplified approach; for untrusted inputs we require
|
|
245
|
-
// sizes in the local header. Reject archives that use streaming.
|
|
246
|
-
if (!isDirectory && compressionMethod !== 0) {
|
|
247
|
-
throw new AttachmentValidationError(
|
|
248
|
-
sourceFilename,
|
|
249
|
-
"uses streaming (data descriptors without sizes) which is not supported for security validation",
|
|
250
|
-
);
|
|
251
|
-
}
|
|
246
|
+
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
|
|
247
|
+
throw new AttachmentValidationError(
|
|
248
|
+
sourceFilename,
|
|
249
|
+
`entry '${entry.name}' uses unsupported compression method ${entry.compressionMethod}`,
|
|
250
|
+
);
|
|
252
251
|
}
|
|
253
252
|
|
|
254
|
-
|
|
253
|
+
entries.push({
|
|
254
|
+
relativePath: entry.name,
|
|
255
|
+
uncompressedSize: entry.uncompressedSize,
|
|
256
|
+
compressionMethod: entry.compressionMethod,
|
|
257
|
+
compressedData: entry.compressedData,
|
|
258
|
+
});
|
|
259
|
+
totalUncompressed += entry.uncompressedSize;
|
|
255
260
|
}
|
|
256
261
|
|
|
257
262
|
if (entries.length === 0) {
|
|
@@ -306,9 +311,9 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
|
|
|
306
311
|
const { path: mountPath, renamedFrom } = mountPaths.get(attachment)!;
|
|
307
312
|
|
|
308
313
|
if (attachment.extract) {
|
|
309
|
-
const entries =
|
|
314
|
+
const entries = parseAndValidateZip(content, attachment.filename);
|
|
310
315
|
const extracted = await extractZipToWorkspace(
|
|
311
|
-
|
|
316
|
+
entries, mountPath, backend, attachment.filename,
|
|
312
317
|
);
|
|
313
318
|
// A renamed mount DIR is visible through every extracted path; the
|
|
314
319
|
// entries themselves were not renamed, so they carry no renamedFrom
|
|
@@ -524,20 +529,16 @@ async function downloadAttachment(
|
|
|
524
529
|
}
|
|
525
530
|
|
|
526
531
|
async function extractZipToWorkspace(
|
|
527
|
-
|
|
528
|
-
entries: readonly ZipEntryInfo[],
|
|
532
|
+
entries: readonly ValidatedZipEntry[],
|
|
529
533
|
mountDir: string,
|
|
530
534
|
backend: WorkspaceBackend,
|
|
535
|
+
sourceFilename: string,
|
|
531
536
|
): Promise<InjectedFile[]> {
|
|
532
537
|
const cleanMountDir = mountDir.replace(/\/+$/, "");
|
|
533
538
|
const injected: InjectedFile[] = [];
|
|
534
|
-
const parsedEntries = parseZipFileData(zipData);
|
|
535
539
|
|
|
536
540
|
for (const entry of entries) {
|
|
537
|
-
const
|
|
538
|
-
if (!fileData) continue;
|
|
539
|
-
|
|
540
|
-
const content = await decompressEntry(fileData);
|
|
541
|
+
const content = await decompressEntry(entry, sourceFilename);
|
|
541
542
|
const targetPath = `${cleanMountDir}/${entry.relativePath}`;
|
|
542
543
|
|
|
543
544
|
await backend.writeFileBuffer(targetPath, content);
|
|
@@ -551,63 +552,66 @@ async function extractZipToWorkspace(
|
|
|
551
552
|
return injected;
|
|
552
553
|
}
|
|
553
554
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const fileNameLength = view.getUint16(offset + 26, true);
|
|
571
|
-
const extraFieldLength = view.getUint16(offset + 28, true);
|
|
572
|
-
|
|
573
|
-
const fileNameStart = offset + 30;
|
|
574
|
-
const fileName = new TextDecoder().decode(
|
|
575
|
-
zipData.subarray(fileNameStart, fileNameStart + fileNameLength),
|
|
576
|
-
);
|
|
577
|
-
|
|
578
|
-
const dataStart = fileNameStart + fileNameLength + extraFieldLength;
|
|
579
|
-
const compressedData = zipData.subarray(dataStart, dataStart + compressedSize);
|
|
580
|
-
|
|
581
|
-
if (!fileName.endsWith("/")) {
|
|
582
|
-
result.set(fileName, {
|
|
583
|
-
compressedData: Buffer.from(compressedData),
|
|
584
|
-
compressionMethod,
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
offset = dataStart + compressedSize;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
return result;
|
|
592
|
-
}
|
|
555
|
+
/**
|
|
556
|
+
* Decompress a validated entry, enforcing its declared uncompressed size.
|
|
557
|
+
*
|
|
558
|
+
* The size cap in parseAndValidateZip budgets *declared* sizes, which the
|
|
559
|
+
* archive author controls — a crafted archive can declare 1 KB and inflate
|
|
560
|
+
* to gigabytes. Enforcement is therefore two-sided and fail-hard: inflation
|
|
561
|
+
* aborts the moment output exceeds the declaration, and an undershoot (or a
|
|
562
|
+
* stored payload whose length disagrees) fails too, because a manifest that
|
|
563
|
+
* misdescribes its own contents is exactly the corrupt-input class this
|
|
564
|
+
* module must never extract from.
|
|
565
|
+
*/
|
|
566
|
+
async function decompressEntry(
|
|
567
|
+
entry: ValidatedZipEntry,
|
|
568
|
+
sourceFilename: string,
|
|
569
|
+
): Promise<Buffer> {
|
|
570
|
+
const declared = entry.uncompressedSize;
|
|
593
571
|
|
|
594
|
-
async function decompressEntry(entry: RawZipEntry): Promise<Buffer> {
|
|
595
572
|
if (entry.compressionMethod === 0) {
|
|
596
|
-
|
|
573
|
+
if (entry.compressedData.length !== declared) {
|
|
574
|
+
throw new AttachmentValidationError(
|
|
575
|
+
sourceFilename,
|
|
576
|
+
`entry '${entry.relativePath}' payload is ${entry.compressedData.length} bytes ` +
|
|
577
|
+
`but the archive declares ${declared} — corrupt or crafted archive`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
return Buffer.from(entry.compressedData);
|
|
597
581
|
}
|
|
598
582
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
583
|
+
// Deflate — the only other method parseAndValidateZip admits.
|
|
584
|
+
return new Promise<Buffer>((resolve, reject) => {
|
|
585
|
+
const inflate = createInflateRaw();
|
|
586
|
+
const chunks: Buffer[] = [];
|
|
587
|
+
let produced = 0;
|
|
588
|
+
inflate.on("data", (chunk: Buffer) => {
|
|
589
|
+
produced += chunk.length;
|
|
590
|
+
if (produced > declared) {
|
|
591
|
+
inflate.destroy();
|
|
592
|
+
reject(new AttachmentValidationError(
|
|
593
|
+
sourceFilename,
|
|
594
|
+
`entry '${entry.relativePath}' decompresses past its declared size of ` +
|
|
595
|
+
`${declared} bytes — corrupt or crafted archive`,
|
|
596
|
+
));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
chunks.push(chunk);
|
|
607
600
|
});
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
601
|
+
inflate.on("end", () => {
|
|
602
|
+
if (produced !== declared) {
|
|
603
|
+
reject(new AttachmentValidationError(
|
|
604
|
+
sourceFilename,
|
|
605
|
+
`entry '${entry.relativePath}' decompressed to ${produced} bytes ` +
|
|
606
|
+
`but the archive declares ${declared} — corrupt or crafted archive`,
|
|
607
|
+
));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
resolve(Buffer.concat(chunks));
|
|
611
|
+
});
|
|
612
|
+
inflate.on("error", reject);
|
|
613
|
+
inflate.end(Buffer.from(entry.compressedData));
|
|
614
|
+
});
|
|
611
615
|
}
|
|
612
616
|
|
|
613
617
|
function hasPathTraversal(filePath: string): boolean {
|
|
@@ -251,7 +251,17 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
|
|
|
251
251
|
// reads. (PLAN and build_from_plan are mutually exclusive in practice —
|
|
252
252
|
// the build turn is always an Agent-mode execution.)
|
|
253
253
|
if (input.interactionMode === InteractionMode.PLAN) {
|
|
254
|
-
|
|
254
|
+
// The read-boundary sentence is native-harness-only, appended OUTSIDE the
|
|
255
|
+
// shared directive: PLAN_MODE_DIRECTIVE also serves the Cursor harness,
|
|
256
|
+
// which has no tool-level read boundary — there the sentence would be
|
|
257
|
+
// false. Here it is enforced fact (shared/plan-mode-permissions.ts,
|
|
258
|
+
// issue #528), stated so the model doesn't burn rounds probing paths the
|
|
259
|
+
// rules will refuse.
|
|
260
|
+
prompt +=
|
|
261
|
+
"\n\n## Plan mode\n\n" +
|
|
262
|
+
PLAN_MODE_DIRECTIVE +
|
|
263
|
+
"\n- File reads are limited to your workspace (including its " +
|
|
264
|
+
"`.stigmer/` directory); paths outside it are refused.";
|
|
255
265
|
}
|
|
256
266
|
|
|
257
267
|
if (input.buildFromPlan) {
|
|
@@ -52,7 +52,7 @@ import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
|
|
|
52
52
|
import type { WorkspaceBackend, ProvisionResult } from "../../shared/workspace/types.js";
|
|
53
53
|
import { createCasCaptureBackend } from "./cas-capture-backend.js";
|
|
54
54
|
import { buildShellEnv } from "./shell-env.js";
|
|
55
|
-
import {
|
|
55
|
+
import { buildPlanModePermissions } from "../../shared/plan-mode-permissions.js";
|
|
56
56
|
import { CasCaptureObserver } from "./cas-capture-observer.js";
|
|
57
57
|
import { isGitWorkTree, isPathCapturable } from "../../shared/filereview/git-substrate.js";
|
|
58
58
|
import { deriveCaptureMode } from "../../shared/filereview/capture.js";
|
|
@@ -598,8 +598,12 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
598
598
|
// every sub-agent graph, AND the path-normalization middleware (issue #429)
|
|
599
599
|
// all derive from this single value, so a rule-bearing graph can never miss
|
|
600
600
|
// the shim that keeps prompt-compliant relative paths from dying in rule
|
|
601
|
-
// validation (`path must be absolute`).
|
|
602
|
-
|
|
601
|
+
// validation (`path must be absolute`). Built from the same rootDir the
|
|
602
|
+
// backends and the shim resolve against, so the workspace read boundary
|
|
603
|
+
// (issue #528) and the paths it must admit agree by construction.
|
|
604
|
+
const planModePermissions = isPlanMode
|
|
605
|
+
? buildPlanModePermissions(workspaceBackend.rootDir)
|
|
606
|
+
: undefined;
|
|
603
607
|
|
|
604
608
|
const toolServerMap = new Map<string, string>();
|
|
605
609
|
if (mcpConnection) {
|
|
@@ -104,9 +104,35 @@ describe("createPathNormalizationMiddleware", () => {
|
|
|
104
104
|
expect(args.file_path).toBe("/etc/hosts");
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
it("
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
it("supplies the workspace root when the base path is omitted on ls/glob/grep", async () => {
|
|
108
|
+
// Deliberate reversal of the original #429 pin ("leaves an omitted base
|
|
109
|
+
// path alone"): the tools' schema default is "/" — the OS ROOT under the
|
|
110
|
+
// legacy backend — applied inside the tool AFTER this seam, so under the
|
|
111
|
+
// #528 workspace read boundary an untouched omission would deny the bare
|
|
112
|
+
// first listing. The middleware fills the omission with the root it
|
|
113
|
+
// already knows.
|
|
114
|
+
for (const tool of ["ls", "glob", "grep"]) {
|
|
115
|
+
const args = await argsSeenByHandler(tool, { pattern: "TOKEN" });
|
|
116
|
+
expect(args.path).toBe(ROOT);
|
|
117
|
+
expect(args.pattern).toBe("TOKEN");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("does not fill an omitted file_path on the file tools", async () => {
|
|
122
|
+
// An absent file_path is a genuine model error — the tool's own input
|
|
123
|
+
// validation gives the better message than any path this seam could invent.
|
|
124
|
+
const args = await argsSeenByHandler("read_file", {});
|
|
125
|
+
expect(args.file_path).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("leaves an explicit '/' untouched on the dir tools — the rules answer it honestly", async () => {
|
|
129
|
+
// The model asked for the OS root; under plan mode's read boundary the
|
|
130
|
+
// honest answer is the rules' denial, not silently substituted workspace
|
|
131
|
+
// contents (the #429 never-convert-a-refusal-into-an-allowance doctrine).
|
|
132
|
+
for (const tool of ["ls", "glob", "grep"]) {
|
|
133
|
+
const args = await argsSeenByHandler(tool, { path: "/" });
|
|
134
|
+
expect(args.path).toBe("/");
|
|
135
|
+
}
|
|
110
136
|
});
|
|
111
137
|
|
|
112
138
|
it("never touches tools outside the built-in filesystem set", async () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Workspace-relative path normalization for permission-rule-bearing graphs
|
|
3
|
-
* (
|
|
3
|
+
* (issues #429, #528).
|
|
4
4
|
*
|
|
5
5
|
* deepagents' permission enforcement canonicalizes tool-call paths BEFORE any
|
|
6
6
|
* rule or backend runs, and its validation refuses non-absolute paths — on
|
|
@@ -20,13 +20,26 @@
|
|
|
20
20
|
* resolves relative paths under the workspace root, so act mode keeps a
|
|
21
21
|
* byte-zero delta.
|
|
22
22
|
*
|
|
23
|
+
* It also supplies the workspace root when `ls`/`glob`/`grep` are called
|
|
24
|
+
* with NO path argument (issue #528). Those tools' schema default is "/" —
|
|
25
|
+
* the OS ROOT under the legacy backend — and the default is applied inside
|
|
26
|
+
* the tool, after this seam, so under the workspace read boundary a bare
|
|
27
|
+
* first `ls` would otherwise die with `permission denied for read on /`
|
|
28
|
+
* (the same first-turn degradation class #429 fixed). An EXPLICIT "/" is
|
|
29
|
+
* deliberately NOT rewritten: the model asked for the OS root, and the
|
|
30
|
+
* honest answer under plan mode's read boundary is the rules' denial —
|
|
31
|
+
* silently substituting workspace contents would be an answer to a
|
|
32
|
+
* different question.
|
|
33
|
+
*
|
|
23
34
|
* Invariant — nothing becomes newly reachable: only paths whose resolution
|
|
24
35
|
* stays INSIDE the workspace root are rewritten. Escaping relatives (`../x`)
|
|
25
36
|
* and `~`-carrying paths are left raw so today's validation refusal keeps
|
|
26
37
|
* speaking (a naive join would resolve `..` away and smuggle an out-of-root
|
|
27
38
|
* read past validation), and absolute paths pass through byte-untouched.
|
|
28
39
|
* The middleware converts false errors into correct behavior — never a
|
|
29
|
-
* refusal into an allowance the rules didn't decide.
|
|
40
|
+
* refusal into an allowance the rules didn't decide. (The absent-path
|
|
41
|
+
* injection honors the same line: it narrows the tool's own OS-root default
|
|
42
|
+
* to the workspace, granting nothing the rules would refuse.)
|
|
30
43
|
*
|
|
31
44
|
* Tool matching is by bare built-in name, the house doctrine (an MCP server
|
|
32
45
|
* is not expected to shadow a built-in name — see shared/tool-kind.ts); the
|
|
@@ -41,9 +54,8 @@ import type { StigmerMiddleware } from "./types.js";
|
|
|
41
54
|
|
|
42
55
|
/**
|
|
43
56
|
* The path-bearing argument of each deepagents built-in filesystem tool.
|
|
44
|
-
* `ls`/`glob`/`grep` take a base directory `path
|
|
45
|
-
*
|
|
46
|
-
* Confirmed against the installed deepagents' tool definitions.
|
|
57
|
+
* `ls`/`glob`/`grep` take a base directory `path`; the file tools take
|
|
58
|
+
* `file_path`. Confirmed against the installed deepagents' tool definitions.
|
|
47
59
|
*/
|
|
48
60
|
const PATH_ARG_BY_TOOL: ReadonlyMap<string, string> = new Map([
|
|
49
61
|
["ls", "path"],
|
|
@@ -54,6 +66,15 @@ const PATH_ARG_BY_TOOL: ReadonlyMap<string, string> = new Map([
|
|
|
54
66
|
["edit_file", "file_path"],
|
|
55
67
|
]);
|
|
56
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The tools whose path argument is optional with an OS-root ("/") schema
|
|
71
|
+
* default. When the model omits it, this middleware supplies the workspace
|
|
72
|
+
* root instead (issue #528). The file tools are deliberately excluded: an
|
|
73
|
+
* absent `file_path` is a genuine model error, and the tool's own input
|
|
74
|
+
* validation gives the better message.
|
|
75
|
+
*/
|
|
76
|
+
const DIR_DEFAULTING_TOOLS: ReadonlySet<string> = new Set(["ls", "glob", "grep"]);
|
|
77
|
+
|
|
57
78
|
export interface PathNormalizationConfig {
|
|
58
79
|
/** The workspace root the graph's filesystem backend resolves against. */
|
|
59
80
|
readonly rootDir: string;
|
|
@@ -108,6 +129,22 @@ export function createPathNormalizationMiddleware(
|
|
|
108
129
|
if (!argKey) return handler(request);
|
|
109
130
|
|
|
110
131
|
const raw = request.toolCall.args[argKey];
|
|
132
|
+
|
|
133
|
+
// Absent base directory on ls/glob/grep: the middleware sees the
|
|
134
|
+
// model's raw args, BEFORE the tool's zod parse applies the "/"
|
|
135
|
+
// (OS root) schema default — so the omission must be filled here,
|
|
136
|
+
// where the workspace root is known. An explicit "/" is not this
|
|
137
|
+
// case and flows through to an honest rule denial (header doctrine).
|
|
138
|
+
if (raw == null && DIR_DEFAULTING_TOOLS.has(request.toolCall.name)) {
|
|
139
|
+
return handler({
|
|
140
|
+
...request,
|
|
141
|
+
toolCall: {
|
|
142
|
+
...request.toolCall,
|
|
143
|
+
args: { ...request.toolCall.args, [argKey]: rootDir },
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
111
148
|
if (typeof raw !== "string") return handler(request);
|
|
112
149
|
|
|
113
150
|
const normalized = normalizeWorkspacePathArg(raw, rootDir);
|