@stigmer/runner 3.11.1-dev.20260812192248 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +11 -0
  3. package/dist/activities/execute-cursor/attachment-resolver.js +8 -0
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  6. package/dist/activities/execute-cursor/blueprint-resolver.js +0 -1
  7. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  8. package/dist/activities/execute-cursor/index.d.ts +6 -2
  9. package/dist/activities/execute-cursor/index.js +16 -32
  10. package/dist/activities/execute-cursor/index.js.map +1 -1
  11. package/dist/activities/execute-cursor/prompt-builder.d.ts +15 -11
  12. package/dist/activities/execute-cursor/prompt-builder.js +20 -10
  13. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  14. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +34 -8
  15. package/dist/activities/execute-deep-agent/attachment-injector.js +111 -106
  16. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  17. package/dist/activities/execute-deep-agent/index.js +0 -15
  18. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +0 -2
  20. package/dist/activities/execute-deep-agent/mcp-gate.js +0 -1
  21. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -1
  22. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -8
  23. package/dist/activities/execute-deep-agent/prompt-builder.js +25 -8
  24. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  25. package/dist/activities/execute-deep-agent/setup.d.ts +0 -10
  26. package/dist/activities/execute-deep-agent/setup.js +13 -45
  27. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  28. package/dist/config.d.ts +6 -5
  29. package/dist/config.js.map +1 -1
  30. package/dist/middleware/path-normalization.d.ts +15 -2
  31. package/dist/middleware/path-normalization.js +39 -5
  32. package/dist/middleware/path-normalization.js.map +1 -1
  33. package/dist/shared/artifact-storage.d.ts +11 -0
  34. package/dist/shared/artifact-storage.js +2 -0
  35. package/dist/shared/artifact-storage.js.map +1 -1
  36. package/dist/shared/attachment-download-urls.d.ts +62 -0
  37. package/dist/shared/attachment-download-urls.js +79 -0
  38. package/dist/shared/attachment-download-urls.js.map +1 -0
  39. package/dist/shared/channel-attachment.d.ts +1 -1
  40. package/dist/shared/channel-attachment.js +1 -1
  41. package/dist/shared/conversation-attachment.d.ts +2 -3
  42. package/dist/shared/conversation-attachment.js +2 -3
  43. package/dist/shared/conversation-attachment.js.map +1 -1
  44. package/dist/shared/mcp-enabled-tools.d.ts +6 -2
  45. package/dist/shared/mcp-enabled-tools.js +6 -2
  46. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  47. package/dist/shared/mcp-transport-guard.d.ts +2 -2
  48. package/dist/shared/mcp-transport-guard.js +2 -2
  49. package/dist/shared/plan-mode-permissions.d.ts +46 -10
  50. package/dist/shared/plan-mode-permissions.js +56 -12
  51. package/dist/shared/plan-mode-permissions.js.map +1 -1
  52. package/dist/shared/synthesized-attachment.d.ts +4 -4
  53. package/dist/shared/synthesized-attachment.js +4 -4
  54. package/dist/shared/zip-extract.d.ts +24 -6
  55. package/dist/shared/zip-extract.js +31 -90
  56. package/dist/shared/zip-extract.js.map +1 -1
  57. package/dist/shared/zip-structure.d.ts +61 -0
  58. package/dist/shared/zip-structure.js +128 -0
  59. package/dist/shared/zip-structure.js.map +1 -0
  60. package/package.json +2 -2
  61. package/src/__test-utils__/fake-artifact-storage.ts +10 -1
  62. package/src/__test-utils__/zip-fixtures.ts +206 -0
  63. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +63 -3
  64. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +62 -29
  65. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -42
  66. package/src/activities/execute-cursor/attachment-resolver.ts +24 -0
  67. package/src/activities/execute-cursor/blueprint-resolver.ts +1 -10
  68. package/src/activities/execute-cursor/index.ts +22 -37
  69. package/src/activities/execute-cursor/prompt-builder.ts +38 -22
  70. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +270 -126
  71. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +0 -2
  72. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +0 -1
  73. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +0 -1
  74. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +2 -7
  75. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
  76. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +78 -2
  77. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +0 -1
  78. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
  79. package/src/activities/execute-deep-agent/attachment-injector.ts +166 -143
  80. package/src/activities/execute-deep-agent/index.ts +0 -19
  81. package/src/activities/execute-deep-agent/mcp-gate.ts +0 -3
  82. package/src/activities/execute-deep-agent/prompt-builder.ts +38 -16
  83. package/src/activities/execute-deep-agent/setup.ts +13 -68
  84. package/src/config.ts +6 -5
  85. package/src/middleware/__tests__/path-normalization.test.ts +29 -3
  86. package/src/middleware/path-normalization.ts +42 -5
  87. package/src/shared/__tests__/artifact-storage.test.ts +9 -0
  88. package/src/shared/__tests__/attachment-download-urls.test.ts +70 -0
  89. package/src/shared/__tests__/channel-attachment.test.ts +2 -2
  90. package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
  91. package/src/shared/__tests__/synthesized-attachment.test.ts +19 -41
  92. package/src/shared/__tests__/zip-extract.test.ts +106 -89
  93. package/src/shared/artifact-storage.ts +11 -0
  94. package/src/shared/attachment-download-urls.ts +98 -0
  95. package/src/shared/channel-attachment.ts +1 -1
  96. package/src/shared/conversation-attachment.ts +2 -3
  97. package/src/shared/mcp-enabled-tools.ts +6 -2
  98. package/src/shared/mcp-transport-guard.ts +2 -2
  99. package/src/shared/plan-mode-permissions.ts +59 -12
  100. package/src/shared/synthesized-attachment.ts +4 -4
  101. package/src/shared/zip-extract.ts +35 -117
  102. package/src/shared/zip-structure.ts +181 -0
  103. package/dist/shared/datastore-attachment.d.ts +0 -99
  104. package/dist/shared/datastore-attachment.js +0 -184
  105. package/dist/shared/datastore-attachment.js.map +0 -1
  106. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +0 -104
  107. package/src/shared/__tests__/datastore-attachment.test.ts +0 -293
  108. package/src/shared/datastore-attachment.ts +0 -209
@@ -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. All ZIP content
9
- * is validated for path traversal, zip bombs, and format integrity before
10
- * any extraction occurs.
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 function) ───────────────────────────────────
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 (local file header signature present)
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. Non-empty archive (at least one file entry)
135
- * 6. File count within limits (max 1000)
136
- * 7. Total uncompressed size within limits (max 100 MB)
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
- if (zipData.length < 4) {
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
- const view = new DataView(zipData.buffer, zipData.byteOffset, zipData.byteLength);
150
- const firstSignature = view.getUint32(0, true);
151
-
152
- if (firstSignature !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
200
+ let structural: ZipStructuralEntry[];
201
+ try {
202
+ structural = parseZipStructure(zipData);
203
+ } catch (err) {
153
204
  throw new AttachmentValidationError(
154
205
  sourceFilename,
155
- "not a valid ZIP archive (invalid header signature)",
206
+ `not a valid ZIP archive (${err instanceof Error ? err.message : String(err)})`,
156
207
  );
157
208
  }
158
209
 
159
- const entries: ZipEntryInfo[] = [];
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
- if (offset + 30 > zipData.length) {
214
+ for (const entry of structural) {
215
+ if (entry.name.includes("\u0000")) {
168
216
  throw new AttachmentValidationError(
169
217
  sourceFilename,
170
- "not a valid ZIP archive (truncated local file header)",
218
+ "contains a filename with null bytes and cannot be safely extracted",
171
219
  );
172
220
  }
173
221
 
174
- const compressionMethod = view.getUint16(offset + 8, true);
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
- const fileNameStart = offset + 30;
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
- "not a valid ZIP archive (truncated filename)",
227
+ `contains an absolute path entry and cannot be safely extracted: ${entry.name}`,
187
228
  );
188
229
  }
189
230
 
190
- const fileNameBytes = zipData.subarray(fileNameStart, fileNameEnd);
191
-
192
- for (let i = 0; i < fileNameBytes.length; i++) {
193
- if (fileNameBytes[i] === 0x00) {
194
- throw new AttachmentValidationError(
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
- const fileName = new TextDecoder().decode(fileNameBytes);
202
-
203
- const isDirectory = fileName.endsWith("/");
204
-
205
- if (!isDirectory) {
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
- const dataStart = fileNameStart + fileNameLength + extraFieldLength;
225
-
226
- // Handle data descriptor (bit 3 of general purpose flag)
227
- const generalFlags = view.getUint16(offset + 6, true);
228
- let dataSize = compressedSize;
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
- offset = dataStart + dataSize;
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 = validateZipForExtraction(content, attachment.filename);
314
+ const entries = parseAndValidateZip(content, attachment.filename);
298
315
  const extracted = await extractZipToWorkspace(
299
- content, entries, mountPath, backend,
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
- zipData: Buffer,
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 fileData = parsedEntries.get(entry.relativePath);
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
- interface RawZipEntry {
536
- compressedData: Buffer;
537
- compressionMethod: number;
538
- }
539
-
540
- function parseZipFileData(zipData: Buffer): Map<string, RawZipEntry> {
541
- const result = new Map<string, RawZipEntry>();
542
- const view = new DataView(zipData.buffer, zipData.byteOffset, zipData.byteLength);
543
- let offset = 0;
544
-
545
- while (offset < zipData.length - 4) {
546
- const signature = view.getUint32(offset, true);
547
- if (signature !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) break;
548
-
549
- const compressionMethod = view.getUint16(offset + 8, true);
550
- const compressedSize = view.getUint32(offset + 18, true);
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
- return entry.compressedData;
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
- if (entry.compressionMethod === 8) {
581
- return new Promise<Buffer>((resolve, reject) => {
582
- const inflate = createInflateRaw();
583
- const chunks: Buffer[] = [];
584
- inflate.on("data", (chunk: Buffer) => chunks.push(chunk));
585
- inflate.on("end", () => resolve(Buffer.concat(chunks)));
586
- inflate.on("error", reject);
587
- inflate.end(entry.compressedData);
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
- throw new Error(`Unsupported ZIP compression method: ${entry.compressionMethod}`);
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(input.injectedFiles, input.vision);
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
- prompt += "\n\n## Plan mode\n\n" + PLAN_MODE_DIRECTIVE;
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
- section += `- \`${f.path}\`${sizeInfo}${renameInfo}\n`;
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