@stigmer/runner 3.12.0 → 3.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +23 -8
- package/dist/activities/execute-deep-agent/attachment-injector.js +104 -105
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.js +11 -1
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +7 -3
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/middleware/path-normalization.d.ts +15 -2
- package/dist/middleware/path-normalization.js +39 -5
- package/dist/middleware/path-normalization.js.map +1 -1
- package/dist/shared/mcp-enabled-tools.d.ts +6 -2
- package/dist/shared/mcp-enabled-tools.js +6 -2
- package/dist/shared/mcp-enabled-tools.js.map +1 -1
- package/dist/shared/plan-mode-permissions.d.ts +46 -10
- package/dist/shared/plan-mode-permissions.js +56 -12
- package/dist/shared/plan-mode-permissions.js.map +1 -1
- package/dist/shared/zip-extract.d.ts +24 -6
- package/dist/shared/zip-extract.js +31 -90
- package/dist/shared/zip-extract.js.map +1 -1
- package/dist/shared/zip-structure.d.ts +61 -0
- package/dist/shared/zip-structure.js +128 -0
- package/dist/shared/zip-structure.js.map +1 -0
- package/package.json +2 -2
- package/src/__test-utils__/zip-fixtures.ts +206 -0
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -42
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +165 -126
- package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +246 -37
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +7 -2
- package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +35 -4
- package/src/activities/execute-deep-agent/attachment-injector.ts +146 -142
- package/src/activities/execute-deep-agent/prompt-builder.ts +11 -1
- package/src/activities/execute-deep-agent/setup.ts +7 -3
- package/src/middleware/__tests__/path-normalization.test.ts +29 -3
- package/src/middleware/path-normalization.ts +42 -5
- package/src/shared/__tests__/plan-mode-permissions.test.ts +58 -0
- package/src/shared/__tests__/zip-extract.test.ts +106 -89
- package/src/shared/mcp-enabled-tools.ts +6 -2
- package/src/shared/plan-mode-permissions.ts +59 -12
- package/src/shared/zip-extract.ts +35 -117
- package/src/shared/zip-structure.ts +181 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit pins for the plan-mode rule builder (issue #528).
|
|
3
|
+
*
|
|
4
|
+
* The rule ORDER and SHAPE are the security boundary: deepagents evaluates
|
|
5
|
+
* first-match-wins with a permissive default, so the workspace read-allow
|
|
6
|
+
* must precede the read-deny, and the deny patterns must be the match-all
|
|
7
|
+
* "/**". These tests pin the built value; the boundary's end-to-end
|
|
8
|
+
* behavior (including matcher semantics for escaped roots and symlinked
|
|
9
|
+
* platform paths) is pinned against the real deepagents runtime in
|
|
10
|
+
* execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from "vitest";
|
|
14
|
+
import {
|
|
15
|
+
buildPlanModePermissions,
|
|
16
|
+
escapeGlobLiteral,
|
|
17
|
+
} from "../plan-mode-permissions.js";
|
|
18
|
+
|
|
19
|
+
describe("buildPlanModePermissions", () => {
|
|
20
|
+
it("builds the policy in enforcement order: workspace read-allow, then read-deny, then write-deny", () => {
|
|
21
|
+
expect(buildPlanModePermissions("/ws/session-1/repo")).toEqual([
|
|
22
|
+
{ operations: ["read"], paths: ["/ws/session-1/repo/**"] },
|
|
23
|
+
{ operations: ["read"], paths: ["/**"], mode: "deny" },
|
|
24
|
+
{ operations: ["write"], paths: ["/**"], mode: "deny" },
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("canonicalizes the root before building the pattern", () => {
|
|
29
|
+
// Enforcement canonicalizes incoming paths (no trailing separator,
|
|
30
|
+
// collapsed slashes) before matching — a pattern built from a raw
|
|
31
|
+
// trailing-slash root would silently match nothing.
|
|
32
|
+
const [allow] = buildPlanModePermissions("/ws//session-1/repo/");
|
|
33
|
+
expect(allow.paths).toEqual(["/ws/session-1/repo/**"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("glob-escapes the root so special characters match literally", () => {
|
|
37
|
+
const [allow] = buildPlanModePermissions("/Users/x/My (work) [v2]");
|
|
38
|
+
expect(allow.paths).toEqual(["/Users/x/My \\(work\\) \\[v2\\]/**"]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("escapeGlobLiteral", () => {
|
|
43
|
+
it("escapes every micromatch metacharacter", () => {
|
|
44
|
+
expect(escapeGlobLiteral("a*b?c(d)e[f]g{h}i!j+k@l")).toBe(
|
|
45
|
+
"a\\*b\\?c\\(d\\)e\\[f\\]g\\{h\\}i\\!j\\+k\\@l",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("escapes backslashes themselves", () => {
|
|
50
|
+
expect(escapeGlobLiteral("a\\b")).toBe("a\\\\b");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("leaves plain path characters alone", () => {
|
|
54
|
+
expect(escapeGlobLiteral("/ws/session-1/repo.dir")).toBe(
|
|
55
|
+
"/ws/session-1/repo.dir",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -1,89 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { deflateRawSync } from "node:zlib";
|
|
3
2
|
import { extractZipFileEntries } from "../zip-extract.js";
|
|
4
|
-
|
|
5
|
-
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Build a minimal valid ZIP archive from an array of { name, content } entries.
|
|
9
|
-
* Uses stored (method 0) compression for simplicity. Produces local file
|
|
10
|
-
* headers only (no central directory) — sufficient for our parser.
|
|
11
|
-
*/
|
|
12
|
-
function buildStoredZip(files: { name: string; content: string }[]): Uint8Array {
|
|
13
|
-
const parts: Uint8Array[] = [];
|
|
14
|
-
|
|
15
|
-
for (const file of files) {
|
|
16
|
-
const nameBytes = new TextEncoder().encode(file.name);
|
|
17
|
-
const contentBytes = new TextEncoder().encode(file.content);
|
|
18
|
-
const isDir = file.name.endsWith("/");
|
|
19
|
-
|
|
20
|
-
// Local file header: 30 bytes
|
|
21
|
-
const header = new ArrayBuffer(30);
|
|
22
|
-
const view = new DataView(header);
|
|
23
|
-
view.setUint32(0, 0x04034b50, true); // signature
|
|
24
|
-
view.setUint16(4, 20, true); // version needed
|
|
25
|
-
view.setUint16(6, 0, true); // general purpose flags
|
|
26
|
-
view.setUint16(8, 0, true); // compression method (stored)
|
|
27
|
-
view.setUint16(10, 0, true); // last mod time
|
|
28
|
-
view.setUint16(12, 0, true); // last mod date
|
|
29
|
-
view.setUint32(14, 0, true); // crc-32 (unused for our purposes)
|
|
30
|
-
view.setUint32(18, isDir ? 0 : contentBytes.length, true); // compressed size
|
|
31
|
-
view.setUint32(22, isDir ? 0 : contentBytes.length, true); // uncompressed size
|
|
32
|
-
view.setUint16(26, nameBytes.length, true); // file name length
|
|
33
|
-
view.setUint16(28, 0, true); // extra field length
|
|
34
|
-
|
|
35
|
-
parts.push(new Uint8Array(header));
|
|
36
|
-
parts.push(nameBytes);
|
|
37
|
-
if (!isDir) {
|
|
38
|
-
parts.push(contentBytes);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
|
|
43
|
-
const result = new Uint8Array(totalLength);
|
|
44
|
-
let offset = 0;
|
|
45
|
-
for (const part of parts) {
|
|
46
|
-
result.set(part, offset);
|
|
47
|
-
offset += part.length;
|
|
48
|
-
}
|
|
49
|
-
return result;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Build a ZIP archive with a single deflated (method 8) entry.
|
|
54
|
-
*/
|
|
55
|
-
function buildDeflatedZip(name: string, content: string): Uint8Array {
|
|
56
|
-
const nameBytes = new TextEncoder().encode(name);
|
|
57
|
-
const contentBytes = new TextEncoder().encode(content);
|
|
58
|
-
const compressed = deflateRawSync(contentBytes);
|
|
59
|
-
|
|
60
|
-
const header = new ArrayBuffer(30);
|
|
61
|
-
const view = new DataView(header);
|
|
62
|
-
view.setUint32(0, 0x04034b50, true);
|
|
63
|
-
view.setUint16(4, 20, true);
|
|
64
|
-
view.setUint16(6, 0, true);
|
|
65
|
-
view.setUint16(8, 8, true); // deflated
|
|
66
|
-
view.setUint16(10, 0, true);
|
|
67
|
-
view.setUint16(12, 0, true);
|
|
68
|
-
view.setUint32(14, 0, true);
|
|
69
|
-
view.setUint32(18, compressed.length, true);
|
|
70
|
-
view.setUint32(22, contentBytes.length, true);
|
|
71
|
-
view.setUint16(26, nameBytes.length, true);
|
|
72
|
-
view.setUint16(28, 0, true);
|
|
73
|
-
|
|
74
|
-
const totalLength = 30 + nameBytes.length + compressed.length;
|
|
75
|
-
const result = new Uint8Array(totalLength);
|
|
76
|
-
result.set(new Uint8Array(header), 0);
|
|
77
|
-
result.set(nameBytes, 30);
|
|
78
|
-
result.set(new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.length), 30 + nameBytes.length);
|
|
79
|
-
return result;
|
|
80
|
-
}
|
|
3
|
+
import { buildZip } from "../../__test-utils__/zip-fixtures.js";
|
|
81
4
|
|
|
82
5
|
// ─── extractZipFileEntries ───────────────────────────────────────────────
|
|
83
6
|
|
|
84
7
|
describe("extractZipFileEntries", () => {
|
|
85
8
|
it("extracts stored files from a ZIP archive", async () => {
|
|
86
|
-
const zip =
|
|
9
|
+
const zip = buildZip([
|
|
87
10
|
{ name: "SKILL.md", content: "# My Skill" },
|
|
88
11
|
{ name: "references/schema.md", content: "# Schema\n\nTable definitions." },
|
|
89
12
|
]);
|
|
@@ -96,7 +19,7 @@ describe("extractZipFileEntries", () => {
|
|
|
96
19
|
|
|
97
20
|
it("extracts deflated files", async () => {
|
|
98
21
|
const content = "This content is compressed with deflate.";
|
|
99
|
-
const zip =
|
|
22
|
+
const zip = buildZip([{ name: "notes.txt", content, method: "deflated" }]);
|
|
100
23
|
|
|
101
24
|
const entries = await extractZipFileEntries(zip);
|
|
102
25
|
expect(entries).toHaveLength(1);
|
|
@@ -104,7 +27,7 @@ describe("extractZipFileEntries", () => {
|
|
|
104
27
|
});
|
|
105
28
|
|
|
106
29
|
it("skips directory entries", async () => {
|
|
107
|
-
const zip =
|
|
30
|
+
const zip = buildZip([
|
|
108
31
|
{ name: "references/", content: "" },
|
|
109
32
|
{ name: "references/data.md", content: "data" },
|
|
110
33
|
]);
|
|
@@ -115,7 +38,7 @@ describe("extractZipFileEntries", () => {
|
|
|
115
38
|
});
|
|
116
39
|
|
|
117
40
|
it("excludes files by basename", async () => {
|
|
118
|
-
const zip =
|
|
41
|
+
const zip = buildZip([
|
|
119
42
|
{ name: "SKILL.md", content: "# Skill" },
|
|
120
43
|
{ name: "references/schema.md", content: "# Schema" },
|
|
121
44
|
{ name: "scripts/run.py", content: "print('hi')" },
|
|
@@ -127,7 +50,7 @@ describe("extractZipFileEntries", () => {
|
|
|
127
50
|
});
|
|
128
51
|
|
|
129
52
|
it("excludes files by full path", async () => {
|
|
130
|
-
const zip =
|
|
53
|
+
const zip = buildZip([
|
|
131
54
|
{ name: "a.md", content: "a" },
|
|
132
55
|
{ name: "nested/a.md", content: "nested" },
|
|
133
56
|
]);
|
|
@@ -138,7 +61,7 @@ describe("extractZipFileEntries", () => {
|
|
|
138
61
|
});
|
|
139
62
|
|
|
140
63
|
it("excludes entries matching basename even when nested", async () => {
|
|
141
|
-
const zip =
|
|
64
|
+
const zip = buildZip([
|
|
142
65
|
{ name: "SKILL.md", content: "root" },
|
|
143
66
|
{ name: "sub/SKILL.md", content: "sub" },
|
|
144
67
|
{ name: "data.md", content: "data" },
|
|
@@ -166,7 +89,7 @@ describe("extractZipFileEntries", () => {
|
|
|
166
89
|
});
|
|
167
90
|
|
|
168
91
|
it("handles multiple files with nested directories", async () => {
|
|
169
|
-
const zip =
|
|
92
|
+
const zip = buildZip([
|
|
170
93
|
{ name: "SKILL.md", content: "# Skill" },
|
|
171
94
|
{ name: "references/", content: "" },
|
|
172
95
|
{ name: "references/database-schema.md", content: "# Schema" },
|
|
@@ -184,7 +107,7 @@ describe("extractZipFileEntries", () => {
|
|
|
184
107
|
});
|
|
185
108
|
|
|
186
109
|
it("returns all entries when no exclude option is provided", async () => {
|
|
187
|
-
const zip =
|
|
110
|
+
const zip = buildZip([
|
|
188
111
|
{ name: "a.txt", content: "aaa" },
|
|
189
112
|
{ name: "b.txt", content: "bbb" },
|
|
190
113
|
]);
|
|
@@ -194,11 +117,105 @@ describe("extractZipFileEntries", () => {
|
|
|
194
117
|
});
|
|
195
118
|
|
|
196
119
|
it("returns all entries when exclude list is empty", async () => {
|
|
197
|
-
const zip =
|
|
198
|
-
{ name: "a.txt", content: "aaa" },
|
|
199
|
-
]);
|
|
120
|
+
const zip = buildZip([{ name: "a.txt", content: "aaa" }]);
|
|
200
121
|
|
|
201
122
|
const entries = await extractZipFileEntries(zip, { exclude: [] });
|
|
202
123
|
expect(entries).toHaveLength(1);
|
|
203
124
|
});
|
|
125
|
+
|
|
126
|
+
// ── Streaming entries (issue #450) ─────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
it("extracts Go-default streaming archives (deflated, data descriptors)", async () => {
|
|
129
|
+
const zip = buildZip([
|
|
130
|
+
{ name: "SKILL.md", content: "# Streamed Skill", method: "deflated", streaming: true },
|
|
131
|
+
{ name: "references/notes.md", content: "streamed notes", method: "deflated", streaming: true },
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
const entries = await extractZipFileEntries(zip);
|
|
135
|
+
expect(entries).toEqual([
|
|
136
|
+
{ path: "SKILL.md", content: "# Streamed Skill" },
|
|
137
|
+
{ path: "references/notes.md", content: "streamed notes" },
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("extracts a stored streaming entry whose payload embeds the data-descriptor signature", async () => {
|
|
142
|
+
// The four bytes of the descriptor signature (0x08074b50, little-endian
|
|
143
|
+
// "PK\x07\x08") planted mid-content, followed by twelve bytes a
|
|
144
|
+
// descriptor-scanning parser would misread as CRC and sizes. The old
|
|
145
|
+
// local-header walk truncated this entry at the planted signature and
|
|
146
|
+
// desynchronized everything after it — the exact defect of issue #450.
|
|
147
|
+
const poisoned = "before PK\u0007\u0008AAAABBBBCCCC after — full content survives";
|
|
148
|
+
const zip = buildZip([
|
|
149
|
+
{ name: "poison.md", content: poisoned, streaming: true },
|
|
150
|
+
{ name: "after.md", content: "the entry after the poisoned one" },
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
const entries = await extractZipFileEntries(zip);
|
|
154
|
+
expect(entries).toEqual([
|
|
155
|
+
{ path: "poison.md", content: poisoned },
|
|
156
|
+
{ path: "after.md", content: "the entry after the poisoned one" },
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("uses central-directory sizes when local header sizes are zeroed", async () => {
|
|
161
|
+
const content = "sizes live only in the central directory";
|
|
162
|
+
const zip = buildZip([{ name: "cd-sizes.txt", content, streaming: true }]);
|
|
163
|
+
|
|
164
|
+
const entries = await extractZipFileEntries(zip);
|
|
165
|
+
expect(entries).toEqual([{ path: "cd-sizes.txt", content }]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── Central directory edge cases ───────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
it("locates the EOCD behind a trailing archive comment", async () => {
|
|
171
|
+
const zip = buildZip([{ name: "a.txt", content: "aaa" }], {
|
|
172
|
+
comment: "release archive — built by tooling",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const entries = await extractZipFileEntries(zip);
|
|
176
|
+
expect(entries).toEqual([{ path: "a.txt", content: "aaa" }]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("is not fooled by EOCD signature bytes inside the archive comment", async () => {
|
|
180
|
+
// "PK\x05\x06" inside the comment is a decoy EOCD; validation must
|
|
181
|
+
// reject it (its "fields" are comment text) and keep scanning backward
|
|
182
|
+
// to the real record.
|
|
183
|
+
const zip = buildZip([{ name: "a.txt", content: "aaa" }], {
|
|
184
|
+
comment: "decoy: PK\u0005\u0006 not a real record",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const entries = await extractZipFileEntries(zip);
|
|
188
|
+
expect(entries).toEqual([{ path: "a.txt", content: "aaa" }]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("returns empty array when the central directory is missing", async () => {
|
|
192
|
+
// Local headers and payloads only — a download truncated before the
|
|
193
|
+
// archive's index. The parser must not fall back to guessing from
|
|
194
|
+
// local headers (design record 017).
|
|
195
|
+
const zip = buildZip(
|
|
196
|
+
[
|
|
197
|
+
{ name: "a.txt", content: "aaa" },
|
|
198
|
+
{ name: "b.txt", content: "bbb" },
|
|
199
|
+
],
|
|
200
|
+
{ omitCentralDirectory: true },
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const entries = await extractZipFileEntries(zip);
|
|
204
|
+
expect(entries).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("returns empty array for an archive with entries but a truncated tail", async () => {
|
|
208
|
+
const zip = buildZip([{ name: "a.txt", content: "aaa" }]);
|
|
209
|
+
const truncated = zip.subarray(0, zip.length - 10); // clips into the EOCD
|
|
210
|
+
|
|
211
|
+
const entries = await extractZipFileEntries(truncated);
|
|
212
|
+
expect(entries).toEqual([]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("extracts an empty archive (EOCD only) as no entries", async () => {
|
|
216
|
+
const zip = buildZip([]);
|
|
217
|
+
|
|
218
|
+
const entries = await extractZipFileEntries(zip);
|
|
219
|
+
expect(entries).toEqual([]);
|
|
220
|
+
});
|
|
204
221
|
});
|
|
@@ -21,8 +21,12 @@
|
|
|
21
21
|
* identity space as the sub-agent McpAccess filter and the approval-policy
|
|
22
22
|
* maps. An enabled name the server does not expose is warned and dropped
|
|
23
23
|
* (enforce the intersection): the restriction still holds and the run
|
|
24
|
-
* proceeds with the valid subset
|
|
25
|
-
*
|
|
24
|
+
* proceeds with the valid subset. The server-side half (issue #402) rejects
|
|
25
|
+
* such names at apply time once the referenced server has discovered
|
|
26
|
+
* capabilities (stigmer-server validateEnabledToolsStep /
|
|
27
|
+
* validateDefaultEnabledToolsStep); this runtime leniency remains the
|
|
28
|
+
* safety net for manifests applied before a server's first connect and for
|
|
29
|
+
* toolsets that changed since the last discovery.
|
|
26
30
|
*/
|
|
27
31
|
|
|
28
32
|
/**
|
|
@@ -2,12 +2,26 @@
|
|
|
2
2
|
* The Plan-mode filesystem permission rules — the enforcement twin of
|
|
3
3
|
* `plan-mode-prompt.ts` (which carries the instruction half of the contract).
|
|
4
4
|
*
|
|
5
|
-
* Plan mode is
|
|
6
|
-
* deny every filesystem write
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* Plan mode is contained BY CONSTRUCTION on the native harness: these rules
|
|
6
|
+
* deny every filesystem write everywhere AND scope reads to the session
|
|
7
|
+
* workspace (issue #528 — owner ruling: the workspace is plan mode's read
|
|
8
|
+
* boundary on cloud and desktop runners alike). Without the read boundary,
|
|
9
|
+
* model-provided absolute paths reached anywhere the process account could
|
|
10
|
+
* read — the pod filesystem (including /proc/self/environ) on cloud runners,
|
|
11
|
+
* the user's whole home directory on desktop — while plan mode still carries
|
|
12
|
+
* exfiltration-capable tools (web_fetch, MCP). Rules are first-match-wins
|
|
13
|
+
* with a permissive default (deepagents' decidePathAccess), so order is
|
|
14
|
+
* load-bearing: the workspace read-allow must precede the read-deny.
|
|
15
|
+
*
|
|
16
|
+
* The workspace-root pattern is matched as a STRING against the raw tool-call
|
|
17
|
+
* path (micromatch, dot:true), before the backend touches disk. That is
|
|
18
|
+
* exactly why the legitimate out-of-workspace reads keep working: skills,
|
|
19
|
+
* attachments, and the approved plan live in the platform dir but are
|
|
20
|
+
* addressed through the `{workspace}/.stigmer` symlink (see
|
|
21
|
+
* shared/workspace/stigmer-link.ts), so their path strings are in-root even
|
|
22
|
+
* though the bytes are not. The same holds for multi-workspace local entries
|
|
23
|
+
* (`{workspace}/{name}` symlinks). A realpath-based boundary would break
|
|
24
|
+
* both; do not "harden" this into one.
|
|
11
25
|
*
|
|
12
26
|
* Applied in execute-deep-agent/setup.ts to the parent graph AND threaded
|
|
13
27
|
* into every compiled sub-agent graph: deepagents' parent-permission
|
|
@@ -18,9 +32,9 @@
|
|
|
18
32
|
*
|
|
19
33
|
* Rules travel with a companion: every graph that carries them also installs
|
|
20
34
|
* the path-normalization middleware (middleware/path-normalization.ts,
|
|
21
|
-
*
|
|
22
|
-
* paths outright
|
|
23
|
-
*
|
|
35
|
+
* issues #429/#528), because deepagents' rule validation refuses
|
|
36
|
+
* workspace-relative paths outright and its `ls`/`glob`/`grep` schema default
|
|
37
|
+
* of "/" would deny the bare first listing. Both are derived from the same
|
|
24
38
|
* expression at each composition site so they cannot drift apart.
|
|
25
39
|
*
|
|
26
40
|
* Invariant: never combine these rules with a shell-capable (sandbox)
|
|
@@ -30,8 +44,41 @@
|
|
|
30
44
|
* for shell capability on both the parent and sub-agent backends.
|
|
31
45
|
*/
|
|
32
46
|
|
|
47
|
+
import { resolve } from "node:path";
|
|
33
48
|
import type { FilesystemPermission } from "deepagents";
|
|
34
49
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Backslash-escape every character micromatch/picomatch treats as glob
|
|
52
|
+
* syntax, so the result matches the input literally. micromatch exports no
|
|
53
|
+
* escape API of its own, and this is correctness, not caution: an unescaped
|
|
54
|
+
* `(` in a desktop project path would make the workspace read-allow rule
|
|
55
|
+
* silently never match — bricking every plan-mode read for that workspace.
|
|
56
|
+
* Semantics are pinned end-to-end through deepagents' own matcher by the
|
|
57
|
+
* special-character workspace suite in plan-mode-path-normalization.test.ts.
|
|
58
|
+
*/
|
|
59
|
+
export function escapeGlobLiteral(literal: string): string {
|
|
60
|
+
return literal.replace(/[\\*?()[\]{}!+@]/g, "\\$&");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the plan-mode rule set for a graph whose filesystem backend is
|
|
65
|
+
* rooted at `workspaceRootDir`. The three rules read as the policy:
|
|
66
|
+
* reads allowed in the workspace, reads denied everywhere else, writes
|
|
67
|
+
* denied everywhere.
|
|
68
|
+
*
|
|
69
|
+
* `{root}/**` matches the root itself as well as its subtree (verified
|
|
70
|
+
* against the installed micromatch), so one allow pattern suffices. The
|
|
71
|
+
* root is `path.resolve`d first because enforcement canonicalizes incoming
|
|
72
|
+
* paths (collapsed slashes, no trailing separator) before matching — a
|
|
73
|
+
* trailing slash in the pattern would silently match nothing.
|
|
74
|
+
*/
|
|
75
|
+
export function buildPlanModePermissions(
|
|
76
|
+
workspaceRootDir: string,
|
|
77
|
+
): FilesystemPermission[] {
|
|
78
|
+
const canonicalRoot = resolve(workspaceRootDir);
|
|
79
|
+
return [
|
|
80
|
+
{ operations: ["read"], paths: [`${escapeGlobLiteral(canonicalRoot)}/**`] },
|
|
81
|
+
{ operations: ["read"], paths: ["/**"], mode: "deny" },
|
|
82
|
+
{ operations: ["write"], paths: ["/**"], mode: "deny" },
|
|
83
|
+
];
|
|
84
|
+
}
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Skill-artifact ZIP extraction: text-decoded entries with non-fatal
|
|
3
|
+
* structural failure.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Structural parsing lives in zip-structure.ts (central-directory-based —
|
|
6
|
+
* see that module's doc for why local-header walks are never acceptable,
|
|
7
|
+
* issue #450). This module owns the skill-artifact *policy* on top of it:
|
|
8
|
+
*
|
|
9
|
+
* Input without a readable central directory is not given a second
|
|
10
|
+
* chance on purpose: every artifact reaching the runner was validated at
|
|
11
|
+
* push by a central-directory-based reader on both editions (OSS:
|
|
12
|
+
* `safearchive/zip` in stigmer-server's skill storage; cloud:
|
|
13
|
+
* commons-compress `ZipFile` in SkillArtifactExtractor), so a missing
|
|
14
|
+
* EOCD here can only mean a truncated or corrupted download — and the
|
|
15
|
+
* honest result for that is the documented empty return, not a guess.
|
|
16
|
+
* (The attachment injector consumes the same structural layer under the
|
|
17
|
+
* opposite policy — fail-hard — because its input is untrusted.)
|
|
18
|
+
*
|
|
19
|
+
* Handles stored (method 0) and deflated (method 8) entries. Returns
|
|
20
|
+
* parsed entries as path + content pairs — consumers bring their own
|
|
21
|
+
* write mechanism (WorkspaceBackend, node:fs, etc.).
|
|
8
22
|
*
|
|
9
23
|
* Extracted from skill-writer.ts so both the deep-agent and Cursor
|
|
10
24
|
* harnesses can share the same ZIP parsing logic without coupling to
|
|
@@ -12,6 +26,7 @@
|
|
|
12
26
|
*/
|
|
13
27
|
|
|
14
28
|
import { createInflateRaw } from "node:zlib";
|
|
29
|
+
import { EOCD_MIN_SIZE, parseZipStructure, type ZipStructuralEntry } from "./zip-structure.js";
|
|
15
30
|
|
|
16
31
|
// ─── Public API ──────────────────────────────────────────────────────────
|
|
17
32
|
|
|
@@ -29,18 +44,26 @@ export interface ZipFileEntry {
|
|
|
29
44
|
* entries whose filename (basename or full path) matches any excluded name.
|
|
30
45
|
*
|
|
31
46
|
* Returns an empty array for empty, truncated, or non-ZIP input rather
|
|
32
|
-
* than throwing — callers treat missing artifacts as non-fatal.
|
|
47
|
+
* than throwing — callers treat missing artifacts as non-fatal. Errors
|
|
48
|
+
* while *decompressing* a structurally valid entry (corrupt deflate
|
|
49
|
+
* stream, unsupported compression method) do propagate: at that point
|
|
50
|
+
* the archive's structure vouched for the entry, and silently dropping
|
|
51
|
+
* it would be the exact corruption this module exists to prevent.
|
|
33
52
|
*/
|
|
34
53
|
export async function extractZipFileEntries(
|
|
35
54
|
zipBytes: Uint8Array,
|
|
36
55
|
options?: { exclude?: readonly string[] },
|
|
37
56
|
): Promise<ZipFileEntry[]> {
|
|
38
|
-
if (zipBytes.length <
|
|
57
|
+
if (zipBytes.length < EOCD_MIN_SIZE) return [];
|
|
39
58
|
|
|
40
|
-
let entries:
|
|
59
|
+
let entries: ZipStructuralEntry[];
|
|
41
60
|
try {
|
|
42
|
-
entries =
|
|
43
|
-
} catch {
|
|
61
|
+
entries = parseZipStructure(zipBytes);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.warn(
|
|
64
|
+
"[zip-extract] archive has no readable central directory " +
|
|
65
|
+
`(truncated or corrupt download?): ${err instanceof Error ? err.message : String(err)}`,
|
|
66
|
+
);
|
|
44
67
|
return [];
|
|
45
68
|
}
|
|
46
69
|
|
|
@@ -58,16 +81,6 @@ export async function extractZipFileEntries(
|
|
|
58
81
|
return results;
|
|
59
82
|
}
|
|
60
83
|
|
|
61
|
-
// ─── Internals ───────────────────────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
interface ZipEntry {
|
|
64
|
-
name: string;
|
|
65
|
-
isDirectory: boolean;
|
|
66
|
-
compressedData: Uint8Array;
|
|
67
|
-
compressionMethod: number;
|
|
68
|
-
uncompressedSize: number;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
84
|
function isExcluded(name: string, excludeSet: ReadonlySet<string>): boolean {
|
|
72
85
|
if (excludeSet.size === 0) return false;
|
|
73
86
|
if (excludeSet.has(name)) return true;
|
|
@@ -76,104 +89,9 @@ function isExcluded(name: string, excludeSet: ReadonlySet<string>): boolean {
|
|
|
76
89
|
return excludeSet.has(basename);
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
|
|
80
|
-
const entries: ZipEntry[] = [];
|
|
81
|
-
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
82
|
-
let offset = 0;
|
|
83
|
-
|
|
84
|
-
while (offset < data.length - 4) {
|
|
85
|
-
const signature = view.getUint32(offset, true);
|
|
86
|
-
if (signature !== 0x04034b50) break; // Local file header signature
|
|
87
|
-
|
|
88
|
-
const generalFlags = view.getUint16(offset + 6, true);
|
|
89
|
-
const hasDataDescriptor = (generalFlags & 0x08) !== 0;
|
|
90
|
-
const compressionMethod = view.getUint16(offset + 8, true);
|
|
91
|
-
let compressedSize = view.getUint32(offset + 18, true);
|
|
92
|
-
let uncompressedSize = view.getUint32(offset + 22, true);
|
|
93
|
-
const fileNameLength = view.getUint16(offset + 26, true);
|
|
94
|
-
const extraFieldLength = view.getUint16(offset + 28, true);
|
|
95
|
-
|
|
96
|
-
const fileNameStart = offset + 30;
|
|
97
|
-
const fileName = new TextDecoder().decode(
|
|
98
|
-
data.subarray(fileNameStart, fileNameStart + fileNameLength),
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
const dataStart = fileNameStart + fileNameLength + extraFieldLength;
|
|
102
|
-
|
|
103
|
-
if (hasDataDescriptor && compressedSize === 0) {
|
|
104
|
-
const sizes = findDataDescriptor(data, view, dataStart, compressionMethod);
|
|
105
|
-
compressedSize = sizes.compressedSize;
|
|
106
|
-
uncompressedSize = sizes.uncompressedSize;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const compressedData = data.subarray(dataStart, dataStart + compressedSize);
|
|
110
|
-
|
|
111
|
-
entries.push({
|
|
112
|
-
name: fileName,
|
|
113
|
-
isDirectory: fileName.endsWith("/"),
|
|
114
|
-
compressedData,
|
|
115
|
-
compressionMethod,
|
|
116
|
-
uncompressedSize,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
let nextOffset = dataStart + compressedSize;
|
|
120
|
-
if (hasDataDescriptor) {
|
|
121
|
-
if (nextOffset + 4 <= data.length && view.getUint32(nextOffset, true) === 0x08074b50) {
|
|
122
|
-
nextOffset += 16; // signature(4) + crc(4) + compressedSize(4) + uncompressedSize(4)
|
|
123
|
-
} else {
|
|
124
|
-
nextOffset += 12; // crc(4) + compressedSize(4) + uncompressedSize(4)
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
offset = nextOffset;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return entries;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Scan forward from dataStart to find the data descriptor that contains
|
|
135
|
-
* the actual compressed and uncompressed sizes. Looks for either the
|
|
136
|
-
* optional signature 0x08074b50 or falls back to scanning the central
|
|
137
|
-
* directory for the matching entry.
|
|
138
|
-
*/
|
|
139
|
-
function findDataDescriptor(
|
|
140
|
-
data: Uint8Array,
|
|
141
|
-
view: DataView,
|
|
142
|
-
dataStart: number,
|
|
143
|
-
_compressionMethod: number,
|
|
144
|
-
): { compressedSize: number; uncompressedSize: number } {
|
|
145
|
-
for (let pos = dataStart; pos < data.length - 16; pos++) {
|
|
146
|
-
const sig = view.getUint32(pos, true);
|
|
147
|
-
if (sig === 0x08074b50) {
|
|
148
|
-
return {
|
|
149
|
-
compressedSize: view.getUint32(pos + 8, true),
|
|
150
|
-
uncompressedSize: view.getUint32(pos + 12, true),
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
if (sig === 0x04034b50 || sig === 0x02014b50) {
|
|
154
|
-
const descStart = pos - 12;
|
|
155
|
-
if (descStart >= dataStart) {
|
|
156
|
-
return {
|
|
157
|
-
compressedSize: view.getUint32(descStart + 4, true),
|
|
158
|
-
uncompressedSize: view.getUint32(descStart + 8, true),
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
break;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
for (let pos = dataStart; pos < data.length - 4; pos++) {
|
|
165
|
-
const sig = view.getUint32(pos, true);
|
|
166
|
-
if (sig === 0x04034b50 || sig === 0x02014b50 || sig === 0x08074b50) {
|
|
167
|
-
const compressedSize = sig === 0x08074b50
|
|
168
|
-
? view.getUint32(pos + 8, true)
|
|
169
|
-
: pos - dataStart;
|
|
170
|
-
return { compressedSize, uncompressedSize: 0 };
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return { compressedSize: data.length - dataStart, uncompressedSize: 0 };
|
|
174
|
-
}
|
|
92
|
+
// ─── Decompression ───────────────────────────────────────────────────────
|
|
175
93
|
|
|
176
|
-
async function decompressEntry(entry:
|
|
94
|
+
async function decompressEntry(entry: ZipStructuralEntry): Promise<string> {
|
|
177
95
|
if (entry.compressionMethod === 0) {
|
|
178
96
|
return new TextDecoder().decode(entry.compressedData);
|
|
179
97
|
}
|