@stigmer/runner 3.12.0 → 3.12.2

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 (97) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/capture-flow.d.ts +5 -4
  3. package/dist/activities/execute-cursor/capture-flow.js +5 -4
  4. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  5. package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
  6. package/dist/activities/execute-cursor/cas-observations.js +10 -4
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +39 -13
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/skill-resolver.d.ts +8 -0
  11. package/dist/activities/execute-cursor/skill-resolver.js +77 -26
  12. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +23 -8
  14. package/dist/activities/execute-deep-agent/attachment-injector.js +104 -105
  15. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
  17. package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
  18. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/prompt-builder.js +11 -1
  20. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/setup.js +15 -3
  22. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/subagent-wiring.js +16 -9
  24. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  25. package/dist/activities/generate-session-subject.d.ts +80 -0
  26. package/dist/activities/generate-session-subject.js +283 -0
  27. package/dist/activities/generate-session-subject.js.map +1 -0
  28. package/dist/client/stigmer-client.d.ts +8 -0
  29. package/dist/client/stigmer-client.js +10 -0
  30. package/dist/client/stigmer-client.js.map +1 -1
  31. package/dist/middleware/approval-gate.d.ts +25 -10
  32. package/dist/middleware/approval-gate.js +33 -14
  33. package/dist/middleware/approval-gate.js.map +1 -1
  34. package/dist/middleware/otel-spans.d.ts +2 -1
  35. package/dist/middleware/otel-spans.js +2 -1
  36. package/dist/middleware/otel-spans.js.map +1 -1
  37. package/dist/middleware/path-normalization.d.ts +15 -2
  38. package/dist/middleware/path-normalization.js +39 -5
  39. package/dist/middleware/path-normalization.js.map +1 -1
  40. package/dist/runner-manager.js +3 -1
  41. package/dist/runner-manager.js.map +1 -1
  42. package/dist/runner.js +3 -1
  43. package/dist/runner.js.map +1 -1
  44. package/dist/shared/attachment-vision.js +9 -0
  45. package/dist/shared/attachment-vision.js.map +1 -1
  46. package/dist/shared/mcp-enabled-tools.d.ts +6 -2
  47. package/dist/shared/mcp-enabled-tools.js +6 -2
  48. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  49. package/dist/shared/mcp-manager.js +8 -0
  50. package/dist/shared/mcp-manager.js.map +1 -1
  51. package/dist/shared/plan-mode-permissions.d.ts +46 -10
  52. package/dist/shared/plan-mode-permissions.js +56 -12
  53. package/dist/shared/plan-mode-permissions.js.map +1 -1
  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__/zip-fixtures.ts +206 -0
  62. package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
  63. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
  64. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +166 -49
  65. package/src/activities/execute-cursor/capture-flow.ts +5 -4
  66. package/src/activities/execute-cursor/cas-observations.ts +10 -4
  67. package/src/activities/execute-cursor/hook-script.ts +39 -13
  68. package/src/activities/execute-cursor/skill-resolver.ts +98 -31
  69. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +165 -126
  70. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
  71. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +7 -2
  72. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
  73. package/src/activities/execute-deep-agent/attachment-injector.ts +146 -142
  74. package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
  75. package/src/activities/execute-deep-agent/prompt-builder.ts +11 -1
  76. package/src/activities/execute-deep-agent/setup.ts +15 -3
  77. package/src/activities/execute-deep-agent/subagent-wiring.ts +16 -9
  78. package/src/activities/generate-session-subject.ts +370 -0
  79. package/src/client/stigmer-client.ts +11 -0
  80. package/src/middleware/__tests__/approval-gate.test.ts +130 -1
  81. package/src/middleware/__tests__/path-normalization.test.ts +29 -3
  82. package/src/middleware/approval-gate.ts +58 -24
  83. package/src/middleware/otel-spans.ts +2 -1
  84. package/src/middleware/path-normalization.ts +42 -5
  85. package/src/runner-manager.ts +3 -0
  86. package/src/runner.ts +3 -0
  87. package/src/shared/__tests__/attachment-vision.test.ts +4 -0
  88. package/src/shared/__tests__/mcp-manager.test.ts +7 -2
  89. package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
  90. package/src/shared/__tests__/zip-extract.test.ts +106 -89
  91. package/src/shared/attachment-vision.ts +9 -0
  92. package/src/shared/filereview/__tests__/capture.test.ts +48 -0
  93. package/src/shared/mcp-enabled-tools.ts +6 -2
  94. package/src/shared/mcp-manager.ts +8 -0
  95. package/src/shared/plan-mode-permissions.ts +59 -12
  96. package/src/shared/zip-extract.ts +35 -117
  97. 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 rule under test is the PRODUCTION constant from
17
- * shared/plan-mode-permissions.ts (the same one setup.ts applies to both
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 { PLAN_MODE_PERMISSIONS } from "../../../shared/plan-mode-permissions.js";
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: PLAN_MODE_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. 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
@@ -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 function) ───────────────────────────────────
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 (local file header signature present)
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. Non-empty archive (at least one file entry)
147
- * 6. File count within limits (max 1000)
148
- * 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
149
168
  */
150
169
  export function validateZipForExtraction(
151
170
  zipData: Buffer,
152
171
  sourceFilename: string,
153
172
  ): ZipEntryInfo[] {
154
- 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) {
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
- const view = new DataView(zipData.buffer, zipData.byteOffset, zipData.byteLength);
162
- const firstSignature = view.getUint32(0, true);
163
-
164
- if (firstSignature !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
200
+ let structural: ZipStructuralEntry[];
201
+ try {
202
+ structural = parseZipStructure(zipData);
203
+ } catch (err) {
165
204
  throw new AttachmentValidationError(
166
205
  sourceFilename,
167
- "not a valid ZIP archive (invalid header signature)",
206
+ `not a valid ZIP archive (${err instanceof Error ? err.message : String(err)})`,
168
207
  );
169
208
  }
170
209
 
171
- const entries: ZipEntryInfo[] = [];
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
- if (offset + 30 > zipData.length) {
214
+ for (const entry of structural) {
215
+ if (entry.name.includes("\u0000")) {
180
216
  throw new AttachmentValidationError(
181
217
  sourceFilename,
182
- "not a valid ZIP archive (truncated local file header)",
218
+ "contains a filename with null bytes and cannot be safely extracted",
183
219
  );
184
220
  }
185
221
 
186
- const compressionMethod = view.getUint16(offset + 8, true);
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
- const fileNameStart = offset + 30;
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
- "not a valid ZIP archive (truncated filename)",
227
+ `contains an absolute path entry and cannot be safely extracted: ${entry.name}`,
199
228
  );
200
229
  }
201
230
 
202
- const fileNameBytes = zipData.subarray(fileNameStart, fileNameEnd);
203
-
204
- for (let i = 0; i < fileNameBytes.length; i++) {
205
- if (fileNameBytes[i] === 0x00) {
206
- throw new AttachmentValidationError(
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
- const fileName = new TextDecoder().decode(fileNameBytes);
214
-
215
- const isDirectory = fileName.endsWith("/");
216
-
217
- if (!isDirectory) {
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
- const dataStart = fileNameStart + fileNameLength + extraFieldLength;
237
-
238
- // Handle data descriptor (bit 3 of general purpose flag)
239
- const generalFlags = view.getUint16(offset + 6, true);
240
- let dataSize = compressedSize;
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
- 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;
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 = validateZipForExtraction(content, attachment.filename);
314
+ const entries = parseAndValidateZip(content, attachment.filename);
310
315
  const extracted = await extractZipToWorkspace(
311
- content, entries, mountPath, backend,
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
- zipData: Buffer,
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 fileData = parsedEntries.get(entry.relativePath);
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
- interface RawZipEntry {
555
- compressedData: Buffer;
556
- compressionMethod: number;
557
- }
558
-
559
- function parseZipFileData(zipData: Buffer): Map<string, RawZipEntry> {
560
- const result = new Map<string, RawZipEntry>();
561
- const view = new DataView(zipData.buffer, zipData.byteOffset, zipData.byteLength);
562
- let offset = 0;
563
-
564
- while (offset < zipData.length - 4) {
565
- const signature = view.getUint32(offset, true);
566
- if (signature !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) break;
567
-
568
- const compressionMethod = view.getUint16(offset + 8, true);
569
- const compressedSize = view.getUint32(offset + 18, true);
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
- 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);
597
581
  }
598
582
 
599
- if (entry.compressionMethod === 8) {
600
- return new Promise<Buffer>((resolve, reject) => {
601
- const inflate = createInflateRaw();
602
- const chunks: Buffer[] = [];
603
- inflate.on("data", (chunk: Buffer) => chunks.push(chunk));
604
- inflate.on("end", () => resolve(Buffer.concat(chunks)));
605
- inflate.on("error", reject);
606
- 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);
607
600
  });
608
- }
609
-
610
- 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
+ });
611
615
  }
612
616
 
613
617
  function hasPathTraversal(filePath: string): boolean {
@@ -21,7 +21,9 @@
21
21
  *
22
22
  * deepagents mutates only via `write` (create/overwrite) and `edit` (modify);
23
23
  * there is no backend delete/rename, so this observes CREATE and MODIFY. A
24
- * gitignored delete can only arrive via shell, which stays on the approval gate.
24
+ * delete-category tool consequently cannot be observed here the approval gate
25
+ * records its before-bytes at authorization time instead (`captureDeleteBefore`,
26
+ * issue #303). A delete via shell (`rm`) stays on the approval gate as always.
25
27
  *
26
28
  * @since File-Change HITL Redesign (Phase 3 — CAS deep-agent wiring); sub-agent
27
29
  * gitignored capture parity (Session 26, DD-19); shell restore (issue #248)
@@ -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) {
@@ -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 { PLAN_MODE_PERMISSIONS } from "../../shared/plan-mode-permissions.js";
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
- const planModePermissions = isPlanMode ? PLAN_MODE_PERMISSIONS : undefined;
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) {
@@ -672,6 +676,14 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
672
676
  isCapturablePath,
673
677
  captureIgnored: captureMode && !!artifactStorage,
674
678
  recordBlockedSecret: (rawPath: string) => casObserver.recordBlockedSecret(rawPath),
679
+ // Pre-delete byte capture (issue #303): deepagents has no backend
680
+ // delete method for the CAS backend to observe, so a flowing delete's
681
+ // before-bytes are recorded by the gate at authorization time, through
682
+ // the SAME shared observer — the turn boundary then reads after=null
683
+ // and authors a reviewable, restorable DELETE. recordBefore's own
684
+ // ownership predicate (gitignored-only in a git tree, everything in
685
+ // non-git) agrees with the gate's not-capturable condition.
686
+ captureDeleteBefore: (rawPath: string) => casObserver.recordBefore(rawPath),
675
687
  unattended,
676
688
  unattendedSkips,
677
689
  }
@@ -110,19 +110,26 @@ export function buildSubAgentMiddleware(
110
110
  stack.push(createToolTruncationMiddleware(options.toolTruncation));
111
111
 
112
112
  if (options.approvalGate) {
113
- // captureIgnored (DD-19): a sub-agent flows gitignored writes into CAS iff a
114
- // CAS observer backs its filesystem backend (compileSubagents passes this as
113
+ // captureIgnored (DD-19): a sub-agent flows gitignored writes and, since
114
+ // issue #303, non-secret CAS-owned deletes into CAS iff a CAS observer
115
+ // backs its filesystem backend (compileSubagents passes this as
115
116
  // `!!casObserver`). When true, inherit the parent gate verbatim so its
116
- // captureIgnored + recordBlockedSecret feed the SAME shared observer that
117
- // backs the sub-agent's writes. When false (default; non-capture mode, or no
118
- // observer), force CAS routing off and drop the blocked-secret sink so
119
- // gitignored paths stay on the interrupt gate — a flowed gitignored edit on an
120
- // unobserved backend would apply unobserved, unreviewable bytes. Sub-agent
121
- // git-tracked edits are always captured by the backend-agnostic boundary diff.
117
+ // captureIgnored + recordBlockedSecret + captureDeleteBefore feed the SAME
118
+ // shared observer that backs the sub-agent's writes. When false (default;
119
+ // non-capture mode, or no observer), force CAS routing off and drop both
120
+ // observer sinks so gitignored paths stay on the interrupt gate — a flowed
121
+ // gitignored edit on an unobserved backend would apply unobserved,
122
+ // unreviewable bytes. Sub-agent git-tracked edits are always captured by
123
+ // the backend-agnostic boundary diff.
122
124
  stack.push(createApprovalGateMiddleware(
123
125
  options.captureIgnored
124
126
  ? options.approvalGate
125
- : { ...options.approvalGate, captureIgnored: false, recordBlockedSecret: undefined },
127
+ : {
128
+ ...options.approvalGate,
129
+ captureIgnored: false,
130
+ recordBlockedSecret: undefined,
131
+ captureDeleteBefore: undefined,
132
+ },
126
133
  ));
127
134
  }
128
135