@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +5 -4
- package/dist/activities/execute-cursor/capture-flow.js +5 -4
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
- package/dist/activities/execute-cursor/cas-observations.js +10 -4
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +39 -13
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +8 -0
- package/dist/activities/execute-cursor/skill-resolver.js +77 -26
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +23 -8
- package/dist/activities/execute-deep-agent/attachment-injector.js +104 -105
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.js +11 -1
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +15 -3
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.js +16 -9
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/generate-session-subject.d.ts +80 -0
- package/dist/activities/generate-session-subject.js +283 -0
- package/dist/activities/generate-session-subject.js.map +1 -0
- package/dist/client/stigmer-client.d.ts +8 -0
- package/dist/client/stigmer-client.js +10 -0
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +25 -10
- package/dist/middleware/approval-gate.js +33 -14
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/middleware/otel-spans.d.ts +2 -1
- package/dist/middleware/otel-spans.js +2 -1
- package/dist/middleware/otel-spans.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/runner-manager.js +3 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +3 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/attachment-vision.js +9 -0
- package/dist/shared/attachment-vision.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-manager.js +8 -0
- package/dist/shared/mcp-manager.js.map +1 -1
- package/dist/shared/plan-mode-permissions.d.ts +46 -10
- package/dist/shared/plan-mode-permissions.js +56 -12
- package/dist/shared/plan-mode-permissions.js.map +1 -1
- package/dist/shared/zip-extract.d.ts +24 -6
- package/dist/shared/zip-extract.js +31 -90
- package/dist/shared/zip-extract.js.map +1 -1
- package/dist/shared/zip-structure.d.ts +61 -0
- package/dist/shared/zip-structure.js +128 -0
- package/dist/shared/zip-structure.js.map +1 -0
- package/package.json +2 -2
- package/src/__test-utils__/zip-fixtures.ts +206 -0
- package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +166 -49
- package/src/activities/execute-cursor/capture-flow.ts +5 -4
- package/src/activities/execute-cursor/cas-observations.ts +10 -4
- package/src/activities/execute-cursor/hook-script.ts +39 -13
- package/src/activities/execute-cursor/skill-resolver.ts +98 -31
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +165 -126
- package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +7 -2
- package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
- package/src/activities/execute-deep-agent/attachment-injector.ts +146 -142
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
- package/src/activities/execute-deep-agent/prompt-builder.ts +11 -1
- package/src/activities/execute-deep-agent/setup.ts +15 -3
- package/src/activities/execute-deep-agent/subagent-wiring.ts +16 -9
- package/src/activities/generate-session-subject.ts +370 -0
- package/src/client/stigmer-client.ts +11 -0
- package/src/middleware/__tests__/approval-gate.test.ts +130 -1
- package/src/middleware/__tests__/path-normalization.test.ts +29 -3
- package/src/middleware/approval-gate.ts +58 -24
- package/src/middleware/otel-spans.ts +2 -1
- package/src/middleware/path-normalization.ts +42 -5
- package/src/runner-manager.ts +3 -0
- package/src/runner.ts +3 -0
- package/src/shared/__tests__/attachment-vision.test.ts +4 -0
- package/src/shared/__tests__/mcp-manager.test.ts +7 -2
- package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
- package/src/shared/__tests__/zip-extract.test.ts +106 -89
- package/src/shared/attachment-vision.ts +9 -0
- package/src/shared/filereview/__tests__/capture.test.ts +48 -0
- package/src/shared/mcp-enabled-tools.ts +6 -2
- package/src/shared/mcp-manager.ts +8 -0
- package/src/shared/plan-mode-permissions.ts +59 -12
- package/src/shared/zip-extract.ts +35 -117
- package/src/shared/zip-structure.ts +181 -0
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* rules (act mode) the same write flows — this change is a plan-mode-only
|
|
14
14
|
* delta.
|
|
15
15
|
*
|
|
16
|
-
* The
|
|
17
|
-
* shared/plan-mode-permissions.ts (the same
|
|
16
|
+
* The rules under test are the PRODUCTION buildPlanModePermissions output
|
|
17
|
+
* from shared/plan-mode-permissions.ts (the same setup.ts applies to both
|
|
18
18
|
* graph sites), not a test copy, so a change to the plan-mode permission set
|
|
19
19
|
* re-proves sub-agent coverage automatically.
|
|
20
20
|
*/
|
|
@@ -25,7 +25,7 @@ import { tmpdir } from "node:os";
|
|
|
25
25
|
import { join } from "node:path";
|
|
26
26
|
import { HumanMessage, ToolMessage, type BaseMessage } from "@langchain/core/messages";
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import { buildPlanModePermissions } from "../../../shared/plan-mode-permissions.js";
|
|
29
29
|
import { CasCaptureObserver } from "../cas-capture-observer.js";
|
|
30
30
|
import { compileSubagents } from "../subagent-transformer.js";
|
|
31
31
|
import { ScriptedModel, type ScriptSelector } from "../__test-utils__/scripted-model.js";
|
|
@@ -64,7 +64,7 @@ describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
|
|
|
64
64
|
workspaceRootDir: root,
|
|
65
65
|
casObserver: observer,
|
|
66
66
|
modelFactory: async () => new ScriptedModel(script),
|
|
67
|
-
...(planMode ? { permissions:
|
|
67
|
+
...(planMode ? { permissions: buildPlanModePermissions(root) } : {}),
|
|
68
68
|
},
|
|
69
69
|
);
|
|
70
70
|
expect(compiled).toHaveLength(1);
|
|
@@ -175,4 +175,35 @@ describe("plan-mode sub-agent filesystem permissions (issue #255)", () => {
|
|
|
175
175
|
expect(readResult).toContain("PLAN_MODE_README_TOKEN");
|
|
176
176
|
expect(readResult).not.toMatch(/path must be absolute/i);
|
|
177
177
|
});
|
|
178
|
+
|
|
179
|
+
it("scopes sub-agent reads to the workspace and fills the bare-ls default (issue #528)", async () => {
|
|
180
|
+
// The read boundary must hold on SUB-AGENT graphs too — they carry their
|
|
181
|
+
// own rules (issue #255) and their own normalization shim (#429), so a
|
|
182
|
+
// parent-only fix would leave sub-agents reading anywhere. One turn,
|
|
183
|
+
// three calls: out-of-root read denied, in-root read flows, bare ls
|
|
184
|
+
// (whose schema default is the OS root) lists the workspace.
|
|
185
|
+
const script: ScriptSelector = () => ({
|
|
186
|
+
toolCalls: [
|
|
187
|
+
{ name: "read_file", args: { file_path: "/etc/hosts" }, id: "c_read_out" },
|
|
188
|
+
{ name: "read_file", args: { file_path: join(root, "notes.md") }, id: "c_read_in" },
|
|
189
|
+
{ name: "ls", args: {}, id: "c_ls_bare" },
|
|
190
|
+
],
|
|
191
|
+
done: "worker done",
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const worker = await compileWorker(script, true);
|
|
195
|
+
const result = (await worker.runnable.invoke(
|
|
196
|
+
{ messages: [new HumanMessage({ content: "go" })] },
|
|
197
|
+
{ configurable: { thread_id: "t4" }, recursionLimit: 50 },
|
|
198
|
+
)) as { messages: BaseMessage[] };
|
|
199
|
+
|
|
200
|
+
const outResult = toolResultById(result.messages, "c_read_out");
|
|
201
|
+
expect(outResult).toMatch(/permission denied for read/i);
|
|
202
|
+
|
|
203
|
+
expect(toolResultById(result.messages, "c_read_in")).toContain("PLAN_MODE_README_TOKEN");
|
|
204
|
+
|
|
205
|
+
const lsResult = toolResultById(result.messages, "c_ls_bare");
|
|
206
|
+
expect(lsResult).toContain("notes.md");
|
|
207
|
+
expect(lsResult).not.toMatch(/permission denied/i);
|
|
208
|
+
});
|
|
178
209
|
});
|
|
@@ -5,13 +5,23 @@
|
|
|
5
5
|
* validates ZIP archives with security guards, and writes files into the
|
|
6
6
|
* workspace via the platform mount namespace (.stigmer/inputs/).
|
|
7
7
|
*
|
|
8
|
-
* Security model: attachments are untrusted user uploads.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Security model: attachments are untrusted user uploads. ZIP archives are
|
|
9
|
+
* parsed from the central directory — the format's authoritative index —
|
|
10
|
+
* via the shared structural layer (shared/zip-structure.ts; issue #567,
|
|
11
|
+
* which killed this module's local-header walk: it silently truncated
|
|
12
|
+
* stored streaming entries and rejected Go-default archives outright).
|
|
13
|
+
* Every entry is validated for path traversal, zip bombs, and format
|
|
14
|
+
* integrity BEFORE any extraction occurs, and because the central
|
|
15
|
+
* directory's sizes are declarations an attacker controls, decompression
|
|
16
|
+
* re-enforces them: an entry whose actual output disagrees with its
|
|
17
|
+
* declared size aborts the injection.
|
|
11
18
|
*
|
|
12
19
|
* Error model: fail-hard. Any attachment failure aborts the entire injection
|
|
13
20
|
* and propagates a descriptive error. Attachments are explicit user inputs —
|
|
14
|
-
* running with partial inputs produces silently incorrect results.
|
|
21
|
+
* running with partial inputs produces silently incorrect results. This is
|
|
22
|
+
* deliberately the OPPOSITE policy from the skill-artifact reader
|
|
23
|
+
* (shared/zip-extract.ts, non-fatal empty return): skill artifacts were
|
|
24
|
+
* structurally vouched for by the push gates, attachments never are.
|
|
15
25
|
*
|
|
16
26
|
* Duplicate names are NOT a failure (issue #364): a default-derived mount
|
|
17
27
|
* path that collides is renamed with the platform's `stem-2.ext` semantics
|
|
@@ -34,13 +44,17 @@ import type {
|
|
|
34
44
|
VisionDegradedReason,
|
|
35
45
|
VisionImage,
|
|
36
46
|
} from "../../shared/attachment-vision.js";
|
|
47
|
+
import {
|
|
48
|
+
EOCD_MIN_SIZE,
|
|
49
|
+
parseZipStructure,
|
|
50
|
+
type ZipStructuralEntry,
|
|
51
|
+
} from "../../shared/zip-structure.js";
|
|
37
52
|
|
|
38
53
|
// ── Constants ────────────────────────────────────────────────────────
|
|
39
54
|
|
|
40
55
|
const MAX_ZIP_FILES = 1000;
|
|
41
56
|
const MAX_ZIP_EXTRACTED_SIZE = 100 * 1024 * 1024; // 100 MB
|
|
42
57
|
const DEFAULT_INPUTS_PREFIX = ".stigmer/inputs";
|
|
43
|
-
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
|
44
58
|
|
|
45
59
|
export { MAX_ZIP_FILES, MAX_ZIP_EXTRACTED_SIZE };
|
|
46
60
|
|
|
@@ -132,126 +146,117 @@ export class AttachmentValidationError extends Error {
|
|
|
132
146
|
}
|
|
133
147
|
}
|
|
134
148
|
|
|
135
|
-
// ── ZIP Validation (pure
|
|
149
|
+
// ── ZIP Validation (pure functions) ──────────────────────────────────
|
|
136
150
|
|
|
137
151
|
/**
|
|
138
152
|
* Validate a ZIP archive for safe extraction. Returns the manifest of
|
|
139
153
|
* file entries (directories excluded) if the archive passes all checks.
|
|
140
154
|
*
|
|
141
155
|
* Security checks enforced:
|
|
142
|
-
* 1. Valid ZIP format (
|
|
156
|
+
* 1. Valid ZIP format (readable central directory; fail-hard, see module doc)
|
|
143
157
|
* 2. No absolute paths (entries starting with / or \)
|
|
144
158
|
* 3. No path traversal (.. components)
|
|
145
159
|
* 4. No null bytes in filenames
|
|
146
|
-
* 5.
|
|
147
|
-
* 6.
|
|
148
|
-
*
|
|
160
|
+
* 5. No duplicate entry paths (a contradictory manifest)
|
|
161
|
+
* 6. Only supported compression methods (stored, deflate) — checked here
|
|
162
|
+
* so an unsupported entry can never abort extraction after earlier
|
|
163
|
+
* entries were already written
|
|
164
|
+
* 7. Non-empty archive (at least one file entry)
|
|
165
|
+
* 8. File count within limits (max 1000)
|
|
166
|
+
* 9. Total declared uncompressed size within limits (max 100 MB) —
|
|
167
|
+
* declarations are re-enforced against actual output at decompression
|
|
149
168
|
*/
|
|
150
169
|
export function validateZipForExtraction(
|
|
151
170
|
zipData: Buffer,
|
|
152
171
|
sourceFilename: string,
|
|
153
172
|
): ZipEntryInfo[] {
|
|
154
|
-
|
|
173
|
+
return parseAndValidateZip(zipData, sourceFilename).map(
|
|
174
|
+
({ relativePath, uncompressedSize }) => ({ relativePath, uncompressedSize }),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A validated file entry, still carrying its payload slice. Validation and
|
|
180
|
+
* extraction share these records — parsing happens exactly once, so "the
|
|
181
|
+
* manifest promised a file extraction cannot find" is unrepresentable
|
|
182
|
+
* (the old two-walker design silently skipped such entries).
|
|
183
|
+
*/
|
|
184
|
+
interface ValidatedZipEntry extends ZipEntryInfo {
|
|
185
|
+
readonly compressionMethod: number;
|
|
186
|
+
readonly compressedData: Uint8Array;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseAndValidateZip(
|
|
190
|
+
zipData: Buffer,
|
|
191
|
+
sourceFilename: string,
|
|
192
|
+
): ValidatedZipEntry[] {
|
|
193
|
+
if (zipData.length < EOCD_MIN_SIZE) {
|
|
155
194
|
throw new AttachmentValidationError(
|
|
156
195
|
sourceFilename,
|
|
157
196
|
"not a valid ZIP archive (file too small)",
|
|
158
197
|
);
|
|
159
198
|
}
|
|
160
199
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
200
|
+
let structural: ZipStructuralEntry[];
|
|
201
|
+
try {
|
|
202
|
+
structural = parseZipStructure(zipData);
|
|
203
|
+
} catch (err) {
|
|
165
204
|
throw new AttachmentValidationError(
|
|
166
205
|
sourceFilename,
|
|
167
|
-
|
|
206
|
+
`not a valid ZIP archive (${err instanceof Error ? err.message : String(err)})`,
|
|
168
207
|
);
|
|
169
208
|
}
|
|
170
209
|
|
|
171
|
-
const entries:
|
|
210
|
+
const entries: ValidatedZipEntry[] = [];
|
|
211
|
+
const seenPaths = new Set<string>();
|
|
172
212
|
let totalUncompressed = 0;
|
|
173
|
-
let offset = 0;
|
|
174
|
-
|
|
175
|
-
while (offset < zipData.length - 4) {
|
|
176
|
-
const signature = view.getUint32(offset, true);
|
|
177
|
-
if (signature !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) break;
|
|
178
213
|
|
|
179
|
-
|
|
214
|
+
for (const entry of structural) {
|
|
215
|
+
if (entry.name.includes("\u0000")) {
|
|
180
216
|
throw new AttachmentValidationError(
|
|
181
217
|
sourceFilename,
|
|
182
|
-
"
|
|
218
|
+
"contains a filename with null bytes and cannot be safely extracted",
|
|
183
219
|
);
|
|
184
220
|
}
|
|
185
221
|
|
|
186
|
-
|
|
187
|
-
const compressedSize = view.getUint32(offset + 18, true);
|
|
188
|
-
const uncompressedSize = view.getUint32(offset + 22, true);
|
|
189
|
-
const fileNameLength = view.getUint16(offset + 26, true);
|
|
190
|
-
const extraFieldLength = view.getUint16(offset + 28, true);
|
|
222
|
+
if (entry.isDirectory) continue;
|
|
191
223
|
|
|
192
|
-
|
|
193
|
-
const fileNameEnd = fileNameStart + fileNameLength;
|
|
194
|
-
|
|
195
|
-
if (fileNameEnd > zipData.length) {
|
|
224
|
+
if (entry.name.startsWith("/") || entry.name.startsWith("\\")) {
|
|
196
225
|
throw new AttachmentValidationError(
|
|
197
226
|
sourceFilename,
|
|
198
|
-
|
|
227
|
+
`contains an absolute path entry and cannot be safely extracted: ${entry.name}`,
|
|
199
228
|
);
|
|
200
229
|
}
|
|
201
230
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
sourceFilename,
|
|
208
|
-
"contains a filename with null bytes and cannot be safely extracted",
|
|
209
|
-
);
|
|
210
|
-
}
|
|
231
|
+
if (hasPathTraversal(entry.name)) {
|
|
232
|
+
throw new AttachmentValidationError(
|
|
233
|
+
sourceFilename,
|
|
234
|
+
`contains a path traversal entry and cannot be safely extracted: ${entry.name}`,
|
|
235
|
+
);
|
|
211
236
|
}
|
|
212
237
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (fileName.startsWith("/") || fileName.startsWith("\\")) {
|
|
219
|
-
throw new AttachmentValidationError(
|
|
220
|
-
sourceFilename,
|
|
221
|
-
`contains an absolute path entry and cannot be safely extracted: ${fileName}`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (hasPathTraversal(fileName)) {
|
|
226
|
-
throw new AttachmentValidationError(
|
|
227
|
-
sourceFilename,
|
|
228
|
-
`contains a path traversal entry and cannot be safely extracted: ${fileName}`,
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
entries.push({ relativePath: fileName, uncompressedSize });
|
|
233
|
-
totalUncompressed += uncompressedSize;
|
|
238
|
+
if (seenPaths.has(entry.name)) {
|
|
239
|
+
throw new AttachmentValidationError(
|
|
240
|
+
sourceFilename,
|
|
241
|
+
`contains duplicate entry '${entry.name}' and cannot be safely extracted`,
|
|
242
|
+
);
|
|
234
243
|
}
|
|
244
|
+
seenPaths.add(entry.name);
|
|
235
245
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if ((generalFlags & 0x08) !== 0 && compressedSize === 0) {
|
|
243
|
-
// Data descriptor follows compressed data — scan for next header
|
|
244
|
-
// This is a simplified approach; for untrusted inputs we require
|
|
245
|
-
// sizes in the local header. Reject archives that use streaming.
|
|
246
|
-
if (!isDirectory && compressionMethod !== 0) {
|
|
247
|
-
throw new AttachmentValidationError(
|
|
248
|
-
sourceFilename,
|
|
249
|
-
"uses streaming (data descriptors without sizes) which is not supported for security validation",
|
|
250
|
-
);
|
|
251
|
-
}
|
|
246
|
+
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
|
|
247
|
+
throw new AttachmentValidationError(
|
|
248
|
+
sourceFilename,
|
|
249
|
+
`entry '${entry.name}' uses unsupported compression method ${entry.compressionMethod}`,
|
|
250
|
+
);
|
|
252
251
|
}
|
|
253
252
|
|
|
254
|
-
|
|
253
|
+
entries.push({
|
|
254
|
+
relativePath: entry.name,
|
|
255
|
+
uncompressedSize: entry.uncompressedSize,
|
|
256
|
+
compressionMethod: entry.compressionMethod,
|
|
257
|
+
compressedData: entry.compressedData,
|
|
258
|
+
});
|
|
259
|
+
totalUncompressed += entry.uncompressedSize;
|
|
255
260
|
}
|
|
256
261
|
|
|
257
262
|
if (entries.length === 0) {
|
|
@@ -306,9 +311,9 @@ export async function injectAttachments(opts: InjectAttachmentsOptions): Promise
|
|
|
306
311
|
const { path: mountPath, renamedFrom } = mountPaths.get(attachment)!;
|
|
307
312
|
|
|
308
313
|
if (attachment.extract) {
|
|
309
|
-
const entries =
|
|
314
|
+
const entries = parseAndValidateZip(content, attachment.filename);
|
|
310
315
|
const extracted = await extractZipToWorkspace(
|
|
311
|
-
|
|
316
|
+
entries, mountPath, backend, attachment.filename,
|
|
312
317
|
);
|
|
313
318
|
// A renamed mount DIR is visible through every extracted path; the
|
|
314
319
|
// entries themselves were not renamed, so they carry no renamedFrom
|
|
@@ -524,20 +529,16 @@ async function downloadAttachment(
|
|
|
524
529
|
}
|
|
525
530
|
|
|
526
531
|
async function extractZipToWorkspace(
|
|
527
|
-
|
|
528
|
-
entries: readonly ZipEntryInfo[],
|
|
532
|
+
entries: readonly ValidatedZipEntry[],
|
|
529
533
|
mountDir: string,
|
|
530
534
|
backend: WorkspaceBackend,
|
|
535
|
+
sourceFilename: string,
|
|
531
536
|
): Promise<InjectedFile[]> {
|
|
532
537
|
const cleanMountDir = mountDir.replace(/\/+$/, "");
|
|
533
538
|
const injected: InjectedFile[] = [];
|
|
534
|
-
const parsedEntries = parseZipFileData(zipData);
|
|
535
539
|
|
|
536
540
|
for (const entry of entries) {
|
|
537
|
-
const
|
|
538
|
-
if (!fileData) continue;
|
|
539
|
-
|
|
540
|
-
const content = await decompressEntry(fileData);
|
|
541
|
+
const content = await decompressEntry(entry, sourceFilename);
|
|
541
542
|
const targetPath = `${cleanMountDir}/${entry.relativePath}`;
|
|
542
543
|
|
|
543
544
|
await backend.writeFileBuffer(targetPath, content);
|
|
@@ -551,63 +552,66 @@ async function extractZipToWorkspace(
|
|
|
551
552
|
return injected;
|
|
552
553
|
}
|
|
553
554
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
const fileNameLength = view.getUint16(offset + 26, true);
|
|
571
|
-
const extraFieldLength = view.getUint16(offset + 28, true);
|
|
572
|
-
|
|
573
|
-
const fileNameStart = offset + 30;
|
|
574
|
-
const fileName = new TextDecoder().decode(
|
|
575
|
-
zipData.subarray(fileNameStart, fileNameStart + fileNameLength),
|
|
576
|
-
);
|
|
577
|
-
|
|
578
|
-
const dataStart = fileNameStart + fileNameLength + extraFieldLength;
|
|
579
|
-
const compressedData = zipData.subarray(dataStart, dataStart + compressedSize);
|
|
580
|
-
|
|
581
|
-
if (!fileName.endsWith("/")) {
|
|
582
|
-
result.set(fileName, {
|
|
583
|
-
compressedData: Buffer.from(compressedData),
|
|
584
|
-
compressionMethod,
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
offset = dataStart + compressedSize;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
return result;
|
|
592
|
-
}
|
|
555
|
+
/**
|
|
556
|
+
* Decompress a validated entry, enforcing its declared uncompressed size.
|
|
557
|
+
*
|
|
558
|
+
* The size cap in parseAndValidateZip budgets *declared* sizes, which the
|
|
559
|
+
* archive author controls — a crafted archive can declare 1 KB and inflate
|
|
560
|
+
* to gigabytes. Enforcement is therefore two-sided and fail-hard: inflation
|
|
561
|
+
* aborts the moment output exceeds the declaration, and an undershoot (or a
|
|
562
|
+
* stored payload whose length disagrees) fails too, because a manifest that
|
|
563
|
+
* misdescribes its own contents is exactly the corrupt-input class this
|
|
564
|
+
* module must never extract from.
|
|
565
|
+
*/
|
|
566
|
+
async function decompressEntry(
|
|
567
|
+
entry: ValidatedZipEntry,
|
|
568
|
+
sourceFilename: string,
|
|
569
|
+
): Promise<Buffer> {
|
|
570
|
+
const declared = entry.uncompressedSize;
|
|
593
571
|
|
|
594
|
-
async function decompressEntry(entry: RawZipEntry): Promise<Buffer> {
|
|
595
572
|
if (entry.compressionMethod === 0) {
|
|
596
|
-
|
|
573
|
+
if (entry.compressedData.length !== declared) {
|
|
574
|
+
throw new AttachmentValidationError(
|
|
575
|
+
sourceFilename,
|
|
576
|
+
`entry '${entry.relativePath}' payload is ${entry.compressedData.length} bytes ` +
|
|
577
|
+
`but the archive declares ${declared} — corrupt or crafted archive`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
return Buffer.from(entry.compressedData);
|
|
597
581
|
}
|
|
598
582
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
583
|
+
// Deflate — the only other method parseAndValidateZip admits.
|
|
584
|
+
return new Promise<Buffer>((resolve, reject) => {
|
|
585
|
+
const inflate = createInflateRaw();
|
|
586
|
+
const chunks: Buffer[] = [];
|
|
587
|
+
let produced = 0;
|
|
588
|
+
inflate.on("data", (chunk: Buffer) => {
|
|
589
|
+
produced += chunk.length;
|
|
590
|
+
if (produced > declared) {
|
|
591
|
+
inflate.destroy();
|
|
592
|
+
reject(new AttachmentValidationError(
|
|
593
|
+
sourceFilename,
|
|
594
|
+
`entry '${entry.relativePath}' decompresses past its declared size of ` +
|
|
595
|
+
`${declared} bytes — corrupt or crafted archive`,
|
|
596
|
+
));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
chunks.push(chunk);
|
|
607
600
|
});
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
601
|
+
inflate.on("end", () => {
|
|
602
|
+
if (produced !== declared) {
|
|
603
|
+
reject(new AttachmentValidationError(
|
|
604
|
+
sourceFilename,
|
|
605
|
+
`entry '${entry.relativePath}' decompressed to ${produced} bytes ` +
|
|
606
|
+
`but the archive declares ${declared} — corrupt or crafted archive`,
|
|
607
|
+
));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
resolve(Buffer.concat(chunks));
|
|
611
|
+
});
|
|
612
|
+
inflate.on("error", reject);
|
|
613
|
+
inflate.end(Buffer.from(entry.compressedData));
|
|
614
|
+
});
|
|
611
615
|
}
|
|
612
616
|
|
|
613
617
|
function hasPathTraversal(filePath: string): boolean {
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
254
|
+
// The read-boundary sentence is native-harness-only, appended OUTSIDE the
|
|
255
|
+
// shared directive: PLAN_MODE_DIRECTIVE also serves the Cursor harness,
|
|
256
|
+
// which has no tool-level read boundary — there the sentence would be
|
|
257
|
+
// false. Here it is enforced fact (shared/plan-mode-permissions.ts,
|
|
258
|
+
// issue #528), stated so the model doesn't burn rounds probing paths the
|
|
259
|
+
// rules will refuse.
|
|
260
|
+
prompt +=
|
|
261
|
+
"\n\n## Plan mode\n\n" +
|
|
262
|
+
PLAN_MODE_DIRECTIVE +
|
|
263
|
+
"\n- File reads are limited to your workspace (including its " +
|
|
264
|
+
"`.stigmer/` directory); paths outside it are refused.";
|
|
255
265
|
}
|
|
256
266
|
|
|
257
267
|
if (input.buildFromPlan) {
|
|
@@ -52,7 +52,7 @@ import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
|
|
|
52
52
|
import type { WorkspaceBackend, ProvisionResult } from "../../shared/workspace/types.js";
|
|
53
53
|
import { createCasCaptureBackend } from "./cas-capture-backend.js";
|
|
54
54
|
import { buildShellEnv } from "./shell-env.js";
|
|
55
|
-
import {
|
|
55
|
+
import { buildPlanModePermissions } from "../../shared/plan-mode-permissions.js";
|
|
56
56
|
import { CasCaptureObserver } from "./cas-capture-observer.js";
|
|
57
57
|
import { isGitWorkTree, isPathCapturable } from "../../shared/filereview/git-substrate.js";
|
|
58
58
|
import { deriveCaptureMode } from "../../shared/filereview/capture.js";
|
|
@@ -598,8 +598,12 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
598
598
|
// every sub-agent graph, AND the path-normalization middleware (issue #429)
|
|
599
599
|
// all derive from this single value, so a rule-bearing graph can never miss
|
|
600
600
|
// the shim that keeps prompt-compliant relative paths from dying in rule
|
|
601
|
-
// validation (`path must be absolute`).
|
|
602
|
-
|
|
601
|
+
// validation (`path must be absolute`). Built from the same rootDir the
|
|
602
|
+
// backends and the shim resolve against, so the workspace read boundary
|
|
603
|
+
// (issue #528) and the paths it must admit agree by construction.
|
|
604
|
+
const planModePermissions = isPlanMode
|
|
605
|
+
? buildPlanModePermissions(workspaceBackend.rootDir)
|
|
606
|
+
: undefined;
|
|
603
607
|
|
|
604
608
|
const toolServerMap = new Map<string, string>();
|
|
605
609
|
if (mcpConnection) {
|
|
@@ -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
|
|
114
|
-
// CAS
|
|
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
|
|
117
|
-
// backs the sub-agent's writes. When false (default;
|
|
118
|
-
// observer), force CAS routing off and drop
|
|
119
|
-
// gitignored paths stay on the interrupt gate — a flowed
|
|
120
|
-
// unobserved backend would apply unobserved,
|
|
121
|
-
// git-tracked edits are always captured by
|
|
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
|
-
: {
|
|
127
|
+
: {
|
|
128
|
+
...options.approvalGate,
|
|
129
|
+
captureIgnored: false,
|
|
130
|
+
recordBlockedSecret: undefined,
|
|
131
|
+
captureDeleteBefore: undefined,
|
|
132
|
+
},
|
|
126
133
|
));
|
|
127
134
|
}
|
|
128
135
|
|