@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
|
@@ -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
|
|
@@ -27,19 +37,24 @@ import { posix } from "node:path";
|
|
|
27
37
|
import type { Attachment } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
|
|
28
38
|
import type { WorkspaceBackend } from "../../shared/workspace/types.js";
|
|
29
39
|
import type { ArtifactStorage } from "../../shared/artifact-storage.js";
|
|
40
|
+
import { mintAttachmentDownloadUrl } from "../../shared/attachment-download-urls.js";
|
|
30
41
|
import { allocateUniqueName } from "../../shared/attachment-naming.js";
|
|
31
42
|
import type {
|
|
32
43
|
VisionBudget,
|
|
33
44
|
VisionDegradedReason,
|
|
34
45
|
VisionImage,
|
|
35
46
|
} from "../../shared/attachment-vision.js";
|
|
47
|
+
import {
|
|
48
|
+
EOCD_MIN_SIZE,
|
|
49
|
+
parseZipStructure,
|
|
50
|
+
type ZipStructuralEntry,
|
|
51
|
+
} from "../../shared/zip-structure.js";
|
|
36
52
|
|
|
37
53
|
// ── Constants ────────────────────────────────────────────────────────
|
|
38
54
|
|
|
39
55
|
const MAX_ZIP_FILES = 1000;
|
|
40
56
|
const MAX_ZIP_EXTRACTED_SIZE = 100 * 1024 * 1024; // 100 MB
|
|
41
57
|
const DEFAULT_INPUTS_PREFIX = ".stigmer/inputs";
|
|
42
|
-
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
|
43
58
|
|
|
44
59
|
export { MAX_ZIP_FILES, MAX_ZIP_EXTRACTED_SIZE };
|
|
45
60
|
|
|
@@ -66,6 +81,17 @@ export interface InjectedFile {
|
|
|
66
81
|
* Attachments that were never image-shaped carry neither field.
|
|
67
82
|
*/
|
|
68
83
|
readonly visionDegraded?: VisionDegradedReason;
|
|
84
|
+
/**
|
|
85
|
+
* A download URL for the attachment's stored object, listed in the system
|
|
86
|
+
* prompt's Input Files section so the agent can hand the file to a tool
|
|
87
|
+
* whose backend cannot read the sandbox filesystem (issue #532). Minted
|
|
88
|
+
* whenever the attachment has a `storageKey` and a usable storage; never
|
|
89
|
+
* present on extracted ZIP entries (no attachment-level object). Absent
|
|
90
|
+
* when there is nothing to mint or the mint failed (non-fatal — policy,
|
|
91
|
+
* degrade log, and prompt wording live in
|
|
92
|
+
* shared/attachment-download-urls.ts).
|
|
93
|
+
*/
|
|
94
|
+
readonly downloadUrl?: string;
|
|
69
95
|
}
|
|
70
96
|
|
|
71
97
|
export interface InjectAttachmentsOptions {
|
|
@@ -120,126 +146,117 @@ export class AttachmentValidationError extends Error {
|
|
|
120
146
|
}
|
|
121
147
|
}
|
|
122
148
|
|
|
123
|
-
// ── ZIP Validation (pure
|
|
149
|
+
// ── ZIP Validation (pure functions) ──────────────────────────────────
|
|
124
150
|
|
|
125
151
|
/**
|
|
126
152
|
* Validate a ZIP archive for safe extraction. Returns the manifest of
|
|
127
153
|
* file entries (directories excluded) if the archive passes all checks.
|
|
128
154
|
*
|
|
129
155
|
* Security checks enforced:
|
|
130
|
-
* 1. Valid ZIP format (
|
|
156
|
+
* 1. Valid ZIP format (readable central directory; fail-hard, see module doc)
|
|
131
157
|
* 2. No absolute paths (entries starting with / or \)
|
|
132
158
|
* 3. No path traversal (.. components)
|
|
133
159
|
* 4. No null bytes in filenames
|
|
134
|
-
* 5.
|
|
135
|
-
* 6.
|
|
136
|
-
*
|
|
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
|
|
137
168
|
*/
|
|
138
169
|
export function validateZipForExtraction(
|
|
139
170
|
zipData: Buffer,
|
|
140
171
|
sourceFilename: string,
|
|
141
172
|
): ZipEntryInfo[] {
|
|
142
|
-
|
|
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) {
|
|
143
194
|
throw new AttachmentValidationError(
|
|
144
195
|
sourceFilename,
|
|
145
196
|
"not a valid ZIP archive (file too small)",
|
|
146
197
|
);
|
|
147
198
|
}
|
|
148
199
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
200
|
+
let structural: ZipStructuralEntry[];
|
|
201
|
+
try {
|
|
202
|
+
structural = parseZipStructure(zipData);
|
|
203
|
+
} catch (err) {
|
|
153
204
|
throw new AttachmentValidationError(
|
|
154
205
|
sourceFilename,
|
|
155
|
-
|
|
206
|
+
`not a valid ZIP archive (${err instanceof Error ? err.message : String(err)})`,
|
|
156
207
|
);
|
|
157
208
|
}
|
|
158
209
|
|
|
159
|
-
const entries:
|
|
210
|
+
const entries: ValidatedZipEntry[] = [];
|
|
211
|
+
const seenPaths = new Set<string>();
|
|
160
212
|
let totalUncompressed = 0;
|
|
161
|
-
let offset = 0;
|
|
162
|
-
|
|
163
|
-
while (offset < zipData.length - 4) {
|
|
164
|
-
const signature = view.getUint32(offset, true);
|
|
165
|
-
if (signature !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) break;
|
|
166
213
|
|
|
167
|
-
|
|
214
|
+
for (const entry of structural) {
|
|
215
|
+
if (entry.name.includes("\u0000")) {
|
|
168
216
|
throw new AttachmentValidationError(
|
|
169
217
|
sourceFilename,
|
|
170
|
-
"
|
|
218
|
+
"contains a filename with null bytes and cannot be safely extracted",
|
|
171
219
|
);
|
|
172
220
|
}
|
|
173
221
|
|
|
174
|
-
|
|
175
|
-
const compressedSize = view.getUint32(offset + 18, true);
|
|
176
|
-
const uncompressedSize = view.getUint32(offset + 22, true);
|
|
177
|
-
const fileNameLength = view.getUint16(offset + 26, true);
|
|
178
|
-
const extraFieldLength = view.getUint16(offset + 28, true);
|
|
222
|
+
if (entry.isDirectory) continue;
|
|
179
223
|
|
|
180
|
-
|
|
181
|
-
const fileNameEnd = fileNameStart + fileNameLength;
|
|
182
|
-
|
|
183
|
-
if (fileNameEnd > zipData.length) {
|
|
224
|
+
if (entry.name.startsWith("/") || entry.name.startsWith("\\")) {
|
|
184
225
|
throw new AttachmentValidationError(
|
|
185
226
|
sourceFilename,
|
|
186
|
-
|
|
227
|
+
`contains an absolute path entry and cannot be safely extracted: ${entry.name}`,
|
|
187
228
|
);
|
|
188
229
|
}
|
|
189
230
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
sourceFilename,
|
|
196
|
-
"contains a filename with null bytes and cannot be safely extracted",
|
|
197
|
-
);
|
|
198
|
-
}
|
|
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
|
+
);
|
|
199
236
|
}
|
|
200
237
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (fileName.startsWith("/") || fileName.startsWith("\\")) {
|
|
207
|
-
throw new AttachmentValidationError(
|
|
208
|
-
sourceFilename,
|
|
209
|
-
`contains an absolute path entry and cannot be safely extracted: ${fileName}`,
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (hasPathTraversal(fileName)) {
|
|
214
|
-
throw new AttachmentValidationError(
|
|
215
|
-
sourceFilename,
|
|
216
|
-
`contains a path traversal entry and cannot be safely extracted: ${fileName}`,
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
entries.push({ relativePath: fileName, uncompressedSize });
|
|
221
|
-
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
|
+
);
|
|
222
243
|
}
|
|
244
|
+
seenPaths.add(entry.name);
|
|
223
245
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if ((generalFlags & 0x08) !== 0 && compressedSize === 0) {
|
|
231
|
-
// Data descriptor follows compressed data — scan for next header
|
|
232
|
-
// This is a simplified approach; for untrusted inputs we require
|
|
233
|
-
// sizes in the local header. Reject archives that use streaming.
|
|
234
|
-
if (!isDirectory && compressionMethod !== 0) {
|
|
235
|
-
throw new AttachmentValidationError(
|
|
236
|
-
sourceFilename,
|
|
237
|
-
"uses streaming (data descriptors without sizes) which is not supported for security validation",
|
|
238
|
-
);
|
|
239
|
-
}
|
|
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
|
+
);
|
|
240
251
|
}
|
|
241
252
|
|
|
242
|
-
|
|
253
|
+
entries.push({
|
|
254
|
+
relativePath: entry.name,
|
|
255
|
+
uncompressedSize: entry.uncompressedSize,
|
|
256
|
+
compressionMethod: entry.compressionMethod,
|
|
257
|
+
compressedData: entry.compressedData,
|
|
258
|
+
});
|
|
259
|
+
totalUncompressed += entry.uncompressedSize;
|
|
243
260
|
}
|
|
244
261
|
|
|
245
262
|
if (entries.length === 0) {
|
|
@@ -294,13 +311,16 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
|
|
|
294
311
|
const { path: mountPath, renamedFrom } = mountPaths.get(attachment)!;
|
|
295
312
|
|
|
296
313
|
if (attachment.extract) {
|
|
297
|
-
const entries =
|
|
314
|
+
const entries = parseAndValidateZip(content, attachment.filename);
|
|
298
315
|
const extracted = await extractZipToWorkspace(
|
|
299
|
-
|
|
316
|
+
entries, mountPath, backend, attachment.filename,
|
|
300
317
|
);
|
|
301
318
|
// A renamed mount DIR is visible through every extracted path; the
|
|
302
319
|
// entries themselves were not renamed, so they carry no renamedFrom
|
|
303
|
-
// (per-file disclosure would misattribute the rename).
|
|
320
|
+
// (per-file disclosure would misattribute the rename). Extracted
|
|
321
|
+
// entries also carry no downloadUrl: the stored object is the ZIP, not
|
|
322
|
+
// any listed file, and a URL to bytes that differ from the listing
|
|
323
|
+
// would be a false promise.
|
|
304
324
|
injectedFiles.push(...extracted);
|
|
305
325
|
} else {
|
|
306
326
|
await backend.writeFileBuffer(mountPath, content);
|
|
@@ -313,6 +333,9 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
|
|
|
313
333
|
// the vision budget before they go out of scope (the sniff decides
|
|
314
334
|
// eligibility; the budget owns every size/count rule).
|
|
315
335
|
const vision = visionBudget?.offer(filename, attachment.contentType, content);
|
|
336
|
+
const downloadUrl = await mintAttachmentDownloadUrl(
|
|
337
|
+
storage, attachment.storageKey, filename,
|
|
338
|
+
);
|
|
316
339
|
injectedFiles.push({
|
|
317
340
|
filename,
|
|
318
341
|
path: mountPath,
|
|
@@ -320,6 +343,7 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
|
|
|
320
343
|
...(renamedFrom !== undefined ? { renamedFrom } : {}),
|
|
321
344
|
...(vision?.kind === "accepted" ? { vision: vision.image } : {}),
|
|
322
345
|
...(vision?.kind === "degraded" ? { visionDegraded: vision.reason } : {}),
|
|
346
|
+
...(downloadUrl !== undefined ? { downloadUrl } : {}),
|
|
323
347
|
});
|
|
324
348
|
}
|
|
325
349
|
}
|
|
@@ -505,20 +529,16 @@ async function downloadAttachment(
|
|
|
505
529
|
}
|
|
506
530
|
|
|
507
531
|
async function extractZipToWorkspace(
|
|
508
|
-
|
|
509
|
-
entries: readonly ZipEntryInfo[],
|
|
532
|
+
entries: readonly ValidatedZipEntry[],
|
|
510
533
|
mountDir: string,
|
|
511
534
|
backend: WorkspaceBackend,
|
|
535
|
+
sourceFilename: string,
|
|
512
536
|
): Promise<InjectedFile[]> {
|
|
513
537
|
const cleanMountDir = mountDir.replace(/\/+$/, "");
|
|
514
538
|
const injected: InjectedFile[] = [];
|
|
515
|
-
const parsedEntries = parseZipFileData(zipData);
|
|
516
539
|
|
|
517
540
|
for (const entry of entries) {
|
|
518
|
-
const
|
|
519
|
-
if (!fileData) continue;
|
|
520
|
-
|
|
521
|
-
const content = await decompressEntry(fileData);
|
|
541
|
+
const content = await decompressEntry(entry, sourceFilename);
|
|
522
542
|
const targetPath = `${cleanMountDir}/${entry.relativePath}`;
|
|
523
543
|
|
|
524
544
|
await backend.writeFileBuffer(targetPath, content);
|
|
@@ -532,63 +552,66 @@ async function extractZipToWorkspace(
|
|
|
532
552
|
return injected;
|
|
533
553
|
}
|
|
534
554
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const fileNameLength = view.getUint16(offset + 26, true);
|
|
552
|
-
const extraFieldLength = view.getUint16(offset + 28, true);
|
|
553
|
-
|
|
554
|
-
const fileNameStart = offset + 30;
|
|
555
|
-
const fileName = new TextDecoder().decode(
|
|
556
|
-
zipData.subarray(fileNameStart, fileNameStart + fileNameLength),
|
|
557
|
-
);
|
|
558
|
-
|
|
559
|
-
const dataStart = fileNameStart + fileNameLength + extraFieldLength;
|
|
560
|
-
const compressedData = zipData.subarray(dataStart, dataStart + compressedSize);
|
|
561
|
-
|
|
562
|
-
if (!fileName.endsWith("/")) {
|
|
563
|
-
result.set(fileName, {
|
|
564
|
-
compressedData: Buffer.from(compressedData),
|
|
565
|
-
compressionMethod,
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
offset = dataStart + compressedSize;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
return result;
|
|
573
|
-
}
|
|
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;
|
|
574
571
|
|
|
575
|
-
async function decompressEntry(entry: RawZipEntry): Promise<Buffer> {
|
|
576
572
|
if (entry.compressionMethod === 0) {
|
|
577
|
-
|
|
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);
|
|
578
581
|
}
|
|
579
582
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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);
|
|
588
600
|
});
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
+
});
|
|
592
615
|
}
|
|
593
616
|
|
|
594
617
|
function hasPathTraversal(filePath: string): boolean {
|
|
@@ -32,7 +32,6 @@ import {
|
|
|
32
32
|
} from "../../shared/workspace/stigmer-link.js";
|
|
33
33
|
import type { ToolOutputOffloadContext } from "../../shared/status-offload.js";
|
|
34
34
|
import { publishPlanArtifact } from "../../shared/plan-artifact.js";
|
|
35
|
-
import { formatDatastoreDegradationNotice } from "../../shared/datastore-attachment.js";
|
|
36
35
|
import { classifyTool } from "../../shared/tool-kind.js";
|
|
37
36
|
import {
|
|
38
37
|
POLICY_ENGINE_VERSION,
|
|
@@ -153,24 +152,6 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
153
152
|
? seedStatusFromExecution(setup.execution)
|
|
154
153
|
: create(AgentExecutionStatusSchema, {});
|
|
155
154
|
|
|
156
|
-
// Datastore degradation disclosure, operator half (issue #325): the
|
|
157
|
-
// prompt already tells the agent to disclose the outage; this makes
|
|
158
|
-
// it visible on the execution record without reading a transcript.
|
|
159
|
-
// Appended to initialStatus BEFORE streaming (not via a status
|
|
160
|
-
// builder): both the v2 and v3 stream paths construct their own
|
|
161
|
-
// builder around this same proto and only ever append messages, so
|
|
162
|
-
// this row survives whichever path runs and rides every persist.
|
|
163
|
-
if (setup.datastoreToolsMissing.length > 0) {
|
|
164
|
-
initialStatus.messages.push(create(AgentMessageSchema, {
|
|
165
|
-
type: MessageType.MESSAGE_SYSTEM,
|
|
166
|
-
content: formatDatastoreDegradationNotice(
|
|
167
|
-
setup.agent.spec?.datastoreUsages?.length ?? 0,
|
|
168
|
-
setup.datastoreToolsMissing,
|
|
169
|
-
),
|
|
170
|
-
timestamp: utcTimestamp(),
|
|
171
|
-
}));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
155
|
const statusBuilder = new StatusBuilder(executionId, initialStatus);
|
|
175
156
|
|
|
176
157
|
statusBuilder.setApprovalProvider({
|
|
@@ -17,8 +17,6 @@
|
|
|
17
17
|
export interface McpToolSources {
|
|
18
18
|
/** Declared MCP server usages (agent spec + session spec). */
|
|
19
19
|
readonly mcpServerUsageCount: number;
|
|
20
|
-
/** Declared datastore usages (the records attachment, T05). */
|
|
21
|
-
readonly datastoreUsageCount: number;
|
|
22
20
|
/** Serving proactive-messaging channels (the channels attachment, DD-006). */
|
|
23
21
|
readonly channelMessagingCount: number;
|
|
24
22
|
/** The serving channel id when this session IS a live channel
|
|
@@ -30,7 +28,6 @@ export interface McpToolSources {
|
|
|
30
28
|
export function shouldConnectMcp(sources: McpToolSources): boolean {
|
|
31
29
|
return (
|
|
32
30
|
sources.mcpServerUsageCount > 0 ||
|
|
33
|
-
sources.datastoreUsageCount > 0 ||
|
|
34
31
|
sources.channelMessagingCount > 0 ||
|
|
35
32
|
sources.conversationChannelId !== undefined
|
|
36
33
|
);
|
|
@@ -21,6 +21,10 @@ import {
|
|
|
21
21
|
visionDisclosureLines,
|
|
22
22
|
type NotViewableEntry,
|
|
23
23
|
} from "../../shared/attachment-vision.js";
|
|
24
|
+
import {
|
|
25
|
+
downloadUrlDisclosureLine,
|
|
26
|
+
type DownloadUrlKind,
|
|
27
|
+
} from "../../shared/attachment-download-urls.js";
|
|
24
28
|
import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
|
|
25
29
|
import {
|
|
26
30
|
buildImplementPlanDirective,
|
|
@@ -99,14 +103,6 @@ export interface PromptBuilderInput {
|
|
|
99
103
|
provisionResults: ProvisionResult[];
|
|
100
104
|
containerRoot: string;
|
|
101
105
|
skillsPromptSection: string;
|
|
102
|
-
/**
|
|
103
|
-
* The datastores section (shared/datastore-attachment.ts
|
|
104
|
-
* formatDatastoresSection): `<available_datastores>` when the connected
|
|
105
|
-
* record-tool roster is complete, `<unavailable_datastores>` when setup's
|
|
106
|
-
* reconciliation found record tools missing (issue #325). Empty when the
|
|
107
|
-
* agent uses no datastores.
|
|
108
|
-
*/
|
|
109
|
-
datastoresPromptSection?: string;
|
|
110
106
|
/**
|
|
111
107
|
* The `<available_channel_templates>` section
|
|
112
108
|
* (shared/channel-attachment.ts formatChannelTemplatesSection); absent
|
|
@@ -122,6 +118,12 @@ export interface PromptBuilderInput {
|
|
|
122
118
|
* Rendered inside the Input Files section.
|
|
123
119
|
*/
|
|
124
120
|
vision?: VisionPromptInfo;
|
|
121
|
+
/**
|
|
122
|
+
* What kind of URL the turn's storage backend mints (issue #532) — keys
|
|
123
|
+
* the Input Files section's hand-off wording (attachment-download-urls.ts).
|
|
124
|
+
* One turn-level fact: all attachments ride the one configured storage.
|
|
125
|
+
*/
|
|
126
|
+
downloadUrlKind?: DownloadUrlKind;
|
|
125
127
|
/**
|
|
126
128
|
* The execution's interaction mode. PLAN appends the shared plan-mode
|
|
127
129
|
* directive so the model knows the turn's deliverable is a plan document.
|
|
@@ -201,10 +203,6 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
|
|
|
201
203
|
prompt += input.skillsPromptSection;
|
|
202
204
|
}
|
|
203
205
|
|
|
204
|
-
if (input.datastoresPromptSection) {
|
|
205
|
-
prompt += "\n\n" + input.datastoresPromptSection;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
206
|
if (input.channelTemplatesPromptSection) {
|
|
209
207
|
prompt += "\n\n" + input.channelTemplatesPromptSection;
|
|
210
208
|
}
|
|
@@ -220,7 +218,9 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
|
|
|
220
218
|
}
|
|
221
219
|
|
|
222
220
|
if (input.injectedFiles.length > 0) {
|
|
223
|
-
prompt += buildInjectedFilesSection(
|
|
221
|
+
prompt += buildInjectedFilesSection(
|
|
222
|
+
input.injectedFiles, input.vision, input.downloadUrlKind,
|
|
223
|
+
);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
if (input.senderIdentity) {
|
|
@@ -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) {
|
|
@@ -401,6 +411,7 @@ function buildReferencedFilesSection(
|
|
|
401
411
|
function buildInjectedFilesSection(
|
|
402
412
|
files: readonly InjectedFile[],
|
|
403
413
|
vision?: VisionPromptInfo,
|
|
414
|
+
downloadUrlKind?: DownloadUrlKind,
|
|
404
415
|
): string {
|
|
405
416
|
let section = "\n\n## Input Files\n\n";
|
|
406
417
|
section +=
|
|
@@ -416,12 +427,23 @@ function buildInjectedFilesSection(
|
|
|
416
427
|
const sizeInfo = ` (${f.sizeBytes} bytes)`;
|
|
417
428
|
// A duplicate-renamed file (attachment-naming.ts) discloses its original
|
|
418
429
|
// name so the agent can connect "the two report.pdfs" in the user's
|
|
419
|
-
// message to distinct files on disk.
|
|
430
|
+
// message to distinct files on disk. A file with a minted download URL
|
|
431
|
+
// (attachment-download-urls.ts) lists it beside the path for the remote
|
|
432
|
+
// hand-off story.
|
|
420
433
|
const renameInfo =
|
|
421
434
|
f.renamedFrom !== undefined
|
|
422
435
|
? ` (renamed from duplicate '${f.renamedFrom}')`
|
|
423
436
|
: "";
|
|
424
|
-
|
|
437
|
+
const urlInfo =
|
|
438
|
+
f.downloadUrl !== undefined ? ` — download URL: ${f.downloadUrl}` : "";
|
|
439
|
+
section += `- \`${f.path}\`${sizeInfo}${renameInfo}${urlInfo}\n`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// The URL hand-off line (shared wording, attachment-download-urls.ts)
|
|
443
|
+
// renders only when some listed file actually carries a URL — its wording
|
|
444
|
+
// keys on what kind of URL the storage backend mints.
|
|
445
|
+
if (downloadUrlKind !== undefined && files.some((f) => f.downloadUrl !== undefined)) {
|
|
446
|
+
section += "\n" + downloadUrlDisclosureLine(downloadUrlKind) + "\n";
|
|
425
447
|
}
|
|
426
448
|
|
|
427
449
|
// The vision lines (shared wording, attachment-vision.ts) tell the model
|