@sema-agent/connectors 0.5.0 → 0.7.0
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/CHANGELOG.md +75 -0
- package/INTEGRATION.md +2 -1
- package/dist/cli-kit.d.ts +1 -1
- package/dist/cli-kit.js +18 -10
- package/dist/host-url.d.ts +8 -0
- package/dist/host-url.js +19 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/launcher.js +3 -3
- package/dist/pinned-upstream.d.ts +2 -0
- package/dist/pinned-upstream.js +346 -85
- package/dist/principal.d.ts +1 -1
- package/dist/principal.js +5 -2
- package/dist/release-archive.d.ts +22 -0
- package/dist/release-archive.js +462 -10
- package/dist/secrets.d.ts +9 -0
- package/dist/secrets.js +66 -4
- package/dist/write-all.d.ts +2 -0
- package/dist/write-all.js +36 -6
- package/package.json +1 -1
package/dist/release-archive.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { chmodSync, closeSync, createReadStream, fchmodSync, fstatSync, mkdirSync, openSync, readSync, symlinkSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { Transform } from 'node:stream';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { createInflateRaw, gunzipSync, inflateRawSync } from 'node:zlib';
|
|
6
|
+
import { writeAll, writeAllStream } from './write-all.js';
|
|
2
7
|
const BLOCK = 512;
|
|
3
|
-
const UNPACKED_LIMIT =
|
|
8
|
+
export const UNPACKED_LIMIT = 1024 * 1024 * 1024;
|
|
9
|
+
const LINK_LIMIT = 4096;
|
|
10
|
+
const FILE_MODE = 0o644;
|
|
11
|
+
const EXEC_MODE = 0o755;
|
|
12
|
+
const DIR_MODE = 0o755;
|
|
13
|
+
export const formatOf = (file) => (/\.(?:tar\.gz|tgz)$/.test(file) ? 'tar.gz' : /\.zip$/.test(file) ? 'zip' : null);
|
|
4
14
|
function text(h, at, length) {
|
|
5
15
|
const raw = h.subarray(at, at + length);
|
|
6
16
|
const end = raw.indexOf(0);
|
|
@@ -25,6 +35,8 @@ function pax(body) {
|
|
|
25
35
|
const value = record.slice(eq + 1);
|
|
26
36
|
if (key === 'path')
|
|
27
37
|
out.path = value;
|
|
38
|
+
if (key === 'linkpath')
|
|
39
|
+
out.link = value;
|
|
28
40
|
if (key === 'size') {
|
|
29
41
|
if (!/^[0-9]+$/.test(value))
|
|
30
42
|
return null;
|
|
@@ -34,7 +46,7 @@ function pax(body) {
|
|
|
34
46
|
}
|
|
35
47
|
return out;
|
|
36
48
|
}
|
|
37
|
-
|
|
49
|
+
function tarEntries(archive) {
|
|
38
50
|
const refuse = (problem) => ({ ok: false, problem });
|
|
39
51
|
let tar;
|
|
40
52
|
try {
|
|
@@ -43,7 +55,7 @@ export function entryOf(archive, wanted) {
|
|
|
43
55
|
catch {
|
|
44
56
|
return refuse('is not a gzip stream within the size limit');
|
|
45
57
|
}
|
|
46
|
-
const
|
|
58
|
+
const entries = [];
|
|
47
59
|
let next = {};
|
|
48
60
|
for (let at = 0;;) {
|
|
49
61
|
if (at === tar.length)
|
|
@@ -59,7 +71,7 @@ export function entryOf(archive, wanted) {
|
|
|
59
71
|
if (octal(h, 148, 8) !== sum)
|
|
60
72
|
return refuse('has a damaged header');
|
|
61
73
|
const type = h[156] === 0 ? '0' : String.fromCharCode(h[156] ?? 0);
|
|
62
|
-
const meta = type === 'x' || type === 'g' || type === 'L';
|
|
74
|
+
const meta = type === 'x' || type === 'g' || type === 'L' || type === 'K';
|
|
63
75
|
const own = octal(h, 124, 12);
|
|
64
76
|
const size = meta ? own : (next.size ?? own);
|
|
65
77
|
if (size === null)
|
|
@@ -80,17 +92,457 @@ export function entryOf(archive, wanted) {
|
|
|
80
92
|
next = { ...next, path: body.toString('utf8').replace(/\0+$/, '') };
|
|
81
93
|
continue;
|
|
82
94
|
}
|
|
95
|
+
if (type === 'K') {
|
|
96
|
+
next = { ...next, link: body.toString('utf8').replace(/\0+$/, '') };
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
83
99
|
if (type === 'g')
|
|
84
100
|
continue;
|
|
85
101
|
const prefix = text(h, 257, 6).startsWith('ustar') ? text(h, 345, 155) : '';
|
|
86
102
|
const name = next.path ?? (prefix === '' ? text(h, 0, 100) : `${prefix}/${text(h, 0, 100)}`);
|
|
103
|
+
const link = next.link ?? text(h, 157, 100);
|
|
87
104
|
next = {};
|
|
88
|
-
|
|
89
|
-
found.push(Buffer.from(body));
|
|
105
|
+
entries.push({ name, type: type === '7' ? '0' : type, mode: octal(h, 100, 8) ?? 0, body, link });
|
|
90
106
|
}
|
|
107
|
+
return { ok: true, entries };
|
|
108
|
+
}
|
|
109
|
+
export function entryOf(archive, wanted) {
|
|
110
|
+
const read = tarEntries(archive);
|
|
111
|
+
if (!read.ok)
|
|
112
|
+
return read;
|
|
113
|
+
const found = read.entries.filter((e) => e.type === '0' && e.name.replace(/^\.\//, '') === wanted);
|
|
91
114
|
if (found.length === 0)
|
|
92
|
-
return
|
|
115
|
+
return { ok: false, problem: `holds no ${wanted}` };
|
|
93
116
|
if (found.length > 1)
|
|
94
|
-
return
|
|
95
|
-
return { ok: true, bytes: found[0] };
|
|
117
|
+
return { ok: false, problem: `holds ${wanted} more than once` };
|
|
118
|
+
return { ok: true, bytes: Buffer.from(found[0].body) };
|
|
119
|
+
}
|
|
120
|
+
const EOCD = 0x06054b50;
|
|
121
|
+
const CENTRAL = 0x02014b50;
|
|
122
|
+
const LOCAL = 0x04034b50;
|
|
123
|
+
const ZIP64_LOCATOR = 0x07064b50;
|
|
124
|
+
const UNIX = 3;
|
|
125
|
+
const S_IFMT = 0o170000;
|
|
126
|
+
function readAt(fd, position, length) {
|
|
127
|
+
const b = Buffer.alloc(length);
|
|
128
|
+
let got = 0;
|
|
129
|
+
while (got < length) {
|
|
130
|
+
const n = readSync(fd, b, got, length - got, position + got);
|
|
131
|
+
if (n === 0)
|
|
132
|
+
break;
|
|
133
|
+
got += n;
|
|
134
|
+
}
|
|
135
|
+
return got === length ? b : b.subarray(0, got);
|
|
136
|
+
}
|
|
137
|
+
function zipEntries(fd) {
|
|
138
|
+
const refuse = (problem) => ({ ok: false, problem });
|
|
139
|
+
const size = fstatSync(fd).size;
|
|
140
|
+
if (size < 22)
|
|
141
|
+
return refuse('is not a zip archive');
|
|
142
|
+
const tailStart = Math.max(0, size - 22 - 65535);
|
|
143
|
+
const tail = readAt(fd, tailStart, size - tailStart);
|
|
144
|
+
const ends = [];
|
|
145
|
+
for (let i = tail.length - 22; i >= 0; i--)
|
|
146
|
+
if (tail.readUInt32LE(i) === EOCD && i + 22 + tail.readUInt16LE(i + 20) === tail.length)
|
|
147
|
+
ends.push(i);
|
|
148
|
+
if (ends.length !== 1)
|
|
149
|
+
return refuse(ends.length === 0 ? 'is not a zip archive' : 'has more than one end record');
|
|
150
|
+
const e = ends[0];
|
|
151
|
+
const eocdAt = tailStart + e;
|
|
152
|
+
if (tail.readUInt16LE(e + 4) !== 0 || tail.readUInt16LE(e + 6) !== 0)
|
|
153
|
+
return refuse('is a split archive');
|
|
154
|
+
const count = tail.readUInt16LE(e + 10);
|
|
155
|
+
const cdSize = tail.readUInt32LE(e + 12);
|
|
156
|
+
const cdAt = tail.readUInt32LE(e + 16);
|
|
157
|
+
if (tail.readUInt16LE(e + 8) !== count || count === 0xffff || cdSize === 0xffffffff || cdAt === 0xffffffff)
|
|
158
|
+
return refuse('is a ZIP64 archive, which is not taken');
|
|
159
|
+
if (eocdAt >= 20 && readAt(fd, eocdAt - 20, 4).readUInt32LE(0) === ZIP64_LOCATOR)
|
|
160
|
+
return refuse('is a ZIP64 archive, which is not taken');
|
|
161
|
+
if (cdAt + cdSize !== eocdAt)
|
|
162
|
+
return refuse('has a central directory that does not end where its end record starts');
|
|
163
|
+
const cd = readAt(fd, cdAt, cdSize);
|
|
164
|
+
const entries = [];
|
|
165
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
166
|
+
let total = 0;
|
|
167
|
+
let at = 0;
|
|
168
|
+
for (let i = 0; i < count; i++) {
|
|
169
|
+
if (at + 46 > cd.length || cd.readUInt32LE(at) !== CENTRAL)
|
|
170
|
+
return refuse('has a damaged central directory');
|
|
171
|
+
const host = cd.readUInt8(at + 5);
|
|
172
|
+
const flags = cd.readUInt16LE(at + 8);
|
|
173
|
+
const method = cd.readUInt16LE(at + 10);
|
|
174
|
+
const crc = cd.readUInt32LE(at + 16);
|
|
175
|
+
const compressed = cd.readUInt32LE(at + 20);
|
|
176
|
+
const unpacked = cd.readUInt32LE(at + 24);
|
|
177
|
+
const nameLength = cd.readUInt16LE(at + 28);
|
|
178
|
+
const extraLength = cd.readUInt16LE(at + 30);
|
|
179
|
+
const commentLength = cd.readUInt16LE(at + 32);
|
|
180
|
+
const disk = cd.readUInt16LE(at + 34);
|
|
181
|
+
const external = cd.readUInt32LE(at + 38);
|
|
182
|
+
const localAt = cd.readUInt32LE(at + 42);
|
|
183
|
+
const raw = cd.subarray(at + 46, at + 46 + nameLength);
|
|
184
|
+
at += 46 + nameLength + extraLength + commentLength;
|
|
185
|
+
if (at > cd.length)
|
|
186
|
+
return refuse('has a damaged central directory');
|
|
187
|
+
if (compressed === 0xffffffff || unpacked === 0xffffffff || localAt === 0xffffffff || disk !== 0)
|
|
188
|
+
return refuse('is a ZIP64 archive, which is not taken');
|
|
189
|
+
if ((flags & 0x0001) !== 0 || (flags & 0x0040) !== 0 || (flags & 0x2000) !== 0)
|
|
190
|
+
return refuse('holds an encrypted entry');
|
|
191
|
+
if (method !== 0 && method !== 8)
|
|
192
|
+
return refuse('holds an entry compressed with a method other than stored or deflate');
|
|
193
|
+
if (method === 0 && compressed !== unpacked)
|
|
194
|
+
return refuse('holds a stored entry whose sizes disagree');
|
|
195
|
+
if ((flags & 0x0800) === 0 && raw.some((b) => b >= 0x80))
|
|
196
|
+
return refuse('holds a name that is not flagged as UTF-8');
|
|
197
|
+
let name;
|
|
198
|
+
try {
|
|
199
|
+
name = utf8.decode(raw);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return refuse('holds a name that is not UTF-8');
|
|
203
|
+
}
|
|
204
|
+
const unixMode = host === UNIX ? external >>> 16 : 0;
|
|
205
|
+
const type = unixMode & S_IFMT;
|
|
206
|
+
const slash = name.endsWith('/');
|
|
207
|
+
let kind;
|
|
208
|
+
if (type === 0o120000)
|
|
209
|
+
kind = 'link';
|
|
210
|
+
else if (type === 0o040000 || (type === 0 && slash))
|
|
211
|
+
kind = 'directory';
|
|
212
|
+
else if (type === 0o100000 || type === 0)
|
|
213
|
+
kind = 'file';
|
|
214
|
+
else
|
|
215
|
+
return refuse('holds an entry of a kind that is not taken (a device, a pipe or a socket)');
|
|
216
|
+
if ((kind === 'directory') !== slash)
|
|
217
|
+
return refuse('holds an entry whose kind and name disagree');
|
|
218
|
+
if (kind === 'link' && unpacked > LINK_LIMIT)
|
|
219
|
+
return refuse('holds a symbolic link with a target that is too long');
|
|
220
|
+
total += unpacked;
|
|
221
|
+
if (total > UNPACKED_LIMIT)
|
|
222
|
+
return refuse(`unpacks to more than ${UNPACKED_LIMIT} bytes`);
|
|
223
|
+
const local = readAt(fd, localAt, 30);
|
|
224
|
+
if (local.length !== 30 || local.readUInt32LE(0) !== LOCAL)
|
|
225
|
+
return refuse('has a damaged local header');
|
|
226
|
+
const localName = readAt(fd, localAt + 30, local.readUInt16LE(26));
|
|
227
|
+
if (!localName.equals(raw) || local.readUInt16LE(8) !== method || (local.readUInt16LE(6) & 0x0001) !== 0)
|
|
228
|
+
return refuse('has a local header that disagrees with its central directory');
|
|
229
|
+
const data = localAt + 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
|
|
230
|
+
entries.push({ name, kind, mode: unixMode & 0o7777, method, crc, compressed, size: unpacked, local: localAt, data });
|
|
231
|
+
}
|
|
232
|
+
if (at !== cd.length)
|
|
233
|
+
return refuse('has a damaged central directory');
|
|
234
|
+
const byPlace = [...entries].sort((a, b) => a.local - b.local);
|
|
235
|
+
for (let i = 0; i < byPlace.length; i++) {
|
|
236
|
+
const x = byPlace[i];
|
|
237
|
+
const limit = i + 1 < byPlace.length ? byPlace[i + 1].local : cdAt;
|
|
238
|
+
if (x.local === limit || x.data + x.compressed > limit)
|
|
239
|
+
return refuse('holds entries whose data overlap');
|
|
240
|
+
}
|
|
241
|
+
return { ok: true, entries };
|
|
242
|
+
}
|
|
243
|
+
const CRC_TABLE = (() => {
|
|
244
|
+
const t = new Int32Array(256);
|
|
245
|
+
for (let n = 0; n < 256; n++) {
|
|
246
|
+
let c = n;
|
|
247
|
+
for (let k = 0; k < 8; k++)
|
|
248
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
249
|
+
t[n] = c;
|
|
250
|
+
}
|
|
251
|
+
return t;
|
|
252
|
+
})();
|
|
253
|
+
function crc32(crc, bytes) {
|
|
254
|
+
let c = ~crc;
|
|
255
|
+
for (let i = 0; i < bytes.length; i++)
|
|
256
|
+
c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
|
|
257
|
+
return ~c >>> 0;
|
|
258
|
+
}
|
|
259
|
+
function zipBytes(fd, e) {
|
|
260
|
+
const packed = readAt(fd, e.data, e.compressed);
|
|
261
|
+
if (packed.length !== e.compressed)
|
|
262
|
+
return null;
|
|
263
|
+
let bytes;
|
|
264
|
+
try {
|
|
265
|
+
bytes = e.method === 8 ? inflateRawSync(packed, { maxOutputLength: Math.max(e.size, 1) }) : packed;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
return bytes.length === e.size && crc32(0, bytes) === e.crc ? bytes : null;
|
|
271
|
+
}
|
|
272
|
+
function withFile(path, fn) {
|
|
273
|
+
let fd;
|
|
274
|
+
try {
|
|
275
|
+
fd = openSync(path, 'r');
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
return { value: fn(fd) };
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
closeSync(fd);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const wholeFile = (path) => withFile(path, (fd) => readAt(fd, 0, fstatSync(fd).size))?.value ?? null;
|
|
288
|
+
export function archiveEntry(path, format, wanted) {
|
|
289
|
+
if (format === 'tar.gz') {
|
|
290
|
+
const bytes = wholeFile(path);
|
|
291
|
+
return bytes === null ? { ok: false, problem: 'cannot be read' } : entryOf(bytes, wanted);
|
|
292
|
+
}
|
|
293
|
+
const r = withFile(path, (fd) => {
|
|
294
|
+
const read = zipEntries(fd);
|
|
295
|
+
if (!read.ok)
|
|
296
|
+
return read;
|
|
297
|
+
const found = read.entries.filter((e) => e.kind === 'file' && e.name === wanted);
|
|
298
|
+
if (found.length === 0)
|
|
299
|
+
return { ok: false, problem: `holds no ${wanted}` };
|
|
300
|
+
if (found.length > 1)
|
|
301
|
+
return { ok: false, problem: `holds ${wanted} more than once` };
|
|
302
|
+
const bytes = zipBytes(fd, found[0]);
|
|
303
|
+
return bytes === null ? { ok: false, problem: `holds a damaged ${wanted}` } : { ok: true, bytes };
|
|
304
|
+
});
|
|
305
|
+
return r?.value ?? { ok: false, problem: 'cannot be read' };
|
|
306
|
+
}
|
|
307
|
+
const SEGMENT_BAD = /[\x00-\x1f\x7f\\]/;
|
|
308
|
+
const segmentProblem = (s) => s === '' || s === '.' || SEGMENT_BAD.test(s);
|
|
309
|
+
function below(name, top) {
|
|
310
|
+
const bare = name.replace(/^\.\//, '');
|
|
311
|
+
if (bare.startsWith('/'))
|
|
312
|
+
return { problem: 'holds an entry with an absolute path' };
|
|
313
|
+
if (bare === top || bare === `${top}/`)
|
|
314
|
+
return { segments: [] };
|
|
315
|
+
if (!bare.startsWith(`${top}/`))
|
|
316
|
+
return { problem: `holds an entry outside ${top}/` };
|
|
317
|
+
const rest = bare.slice(top.length + 1).replace(/\/$/, '');
|
|
318
|
+
const segments = rest.split('/');
|
|
319
|
+
if (segments.some((s) => s === '..'))
|
|
320
|
+
return { problem: 'holds an entry whose path climbs out of its directory (..)' };
|
|
321
|
+
if (segments.some(segmentProblem))
|
|
322
|
+
return { problem: 'holds an entry whose path has an empty or "." segment, a backslash or a control character' };
|
|
323
|
+
return { segments };
|
|
324
|
+
}
|
|
325
|
+
const linkTargetTaken = (target) => target !== '' && !target.startsWith('/') && target.split('/').every((s) => s !== '..' && !segmentProblem(s));
|
|
326
|
+
function plannedProblem(planned) {
|
|
327
|
+
const kinds = new Map();
|
|
328
|
+
for (const p of planned) {
|
|
329
|
+
const key = p.segments.join('/');
|
|
330
|
+
if (kinds.has(key))
|
|
331
|
+
return 'holds a name more than once';
|
|
332
|
+
kinds.set(key, p.kind);
|
|
333
|
+
}
|
|
334
|
+
for (const p of planned) {
|
|
335
|
+
for (let i = 1; i < p.segments.length; i++) {
|
|
336
|
+
const parent = kinds.get(p.segments.slice(0, i).join('/'));
|
|
337
|
+
if (parent !== undefined && parent !== 'directory')
|
|
338
|
+
return `holds an entry below a ${parent === 'link' ? 'symbolic link' : 'file'} of the archive`;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
async function writePlanned(planned, dest) {
|
|
344
|
+
const pathOf = (p) => join(dest, ...p.segments);
|
|
345
|
+
const directory = (path) => {
|
|
346
|
+
mkdirSync(path, { recursive: true, mode: DIR_MODE });
|
|
347
|
+
chmodSync(path, DIR_MODE);
|
|
348
|
+
};
|
|
349
|
+
for (const p of planned)
|
|
350
|
+
if (p.kind === 'directory')
|
|
351
|
+
directory(pathOf(p));
|
|
352
|
+
for (const p of planned) {
|
|
353
|
+
if (p.kind !== 'file')
|
|
354
|
+
continue;
|
|
355
|
+
mkdirSync(dirname(pathOf(p)), { recursive: true, mode: DIR_MODE });
|
|
356
|
+
const problem = await p.write(pathOf(p));
|
|
357
|
+
if (problem !== null)
|
|
358
|
+
return problem;
|
|
359
|
+
}
|
|
360
|
+
for (const p of planned) {
|
|
361
|
+
if (p.kind !== 'link')
|
|
362
|
+
continue;
|
|
363
|
+
mkdirSync(dirname(pathOf(p)), { recursive: true, mode: DIR_MODE });
|
|
364
|
+
symlinkSync(p.link, pathOf(p));
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
const modeOf = (mode) => ((mode & 0o111) !== 0 ? EXEC_MODE : FILE_MODE);
|
|
369
|
+
function writeBytes(target, bytes, mode) {
|
|
370
|
+
const fd = openSync(target, 'wx', mode);
|
|
371
|
+
try {
|
|
372
|
+
fchmodSync(fd, mode);
|
|
373
|
+
writeAll(fd, bytes);
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
closeSync(fd);
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
function planTar(archive, top) {
|
|
381
|
+
const read = tarEntries(archive);
|
|
382
|
+
if (!read.ok)
|
|
383
|
+
return { problem: read.problem };
|
|
384
|
+
const planned = [];
|
|
385
|
+
for (const e of read.entries) {
|
|
386
|
+
const where = below(e.name, top);
|
|
387
|
+
if ('problem' in where)
|
|
388
|
+
return where;
|
|
389
|
+
if (where.segments.length === 0)
|
|
390
|
+
continue;
|
|
391
|
+
if (e.type === '0') {
|
|
392
|
+
const mode = modeOf(e.mode);
|
|
393
|
+
planned.push({ segments: where.segments, kind: 'file', mode, size: e.body.length, write: (target) => writeBytes(target, e.body, mode) });
|
|
394
|
+
}
|
|
395
|
+
else if (e.type === '5')
|
|
396
|
+
planned.push({ segments: where.segments, kind: 'directory', mode: DIR_MODE, size: 0, write: () => null });
|
|
397
|
+
else if (e.type === '2') {
|
|
398
|
+
if (!linkTargetTaken(e.link))
|
|
399
|
+
return { problem: 'holds a symbolic link whose target is not a relative path below its own directory' };
|
|
400
|
+
planned.push({ segments: where.segments, kind: 'link', mode: 0, size: 0, link: e.link, write: () => null });
|
|
401
|
+
}
|
|
402
|
+
else if (e.type === '1')
|
|
403
|
+
return { problem: 'holds a hard link, which is not taken' };
|
|
404
|
+
else
|
|
405
|
+
return { problem: 'holds an entry of a kind that is not taken (a device, a pipe or a socket)' };
|
|
406
|
+
}
|
|
407
|
+
return { planned };
|
|
408
|
+
}
|
|
409
|
+
class Checked extends Transform {
|
|
410
|
+
limit;
|
|
411
|
+
size = 0;
|
|
412
|
+
crc = 0;
|
|
413
|
+
constructor(limit) {
|
|
414
|
+
super();
|
|
415
|
+
this.limit = limit;
|
|
416
|
+
}
|
|
417
|
+
_transform(chunk, _encoding, done) {
|
|
418
|
+
this.size += chunk.length;
|
|
419
|
+
if (this.size > this.limit)
|
|
420
|
+
return done(new Error('the entry is larger than the archive says'));
|
|
421
|
+
this.crc = crc32(this.crc, chunk);
|
|
422
|
+
done(null, chunk);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function planZip(fd, path, top) {
|
|
426
|
+
const read = zipEntries(fd);
|
|
427
|
+
if (!read.ok)
|
|
428
|
+
return { problem: read.problem };
|
|
429
|
+
const planned = [];
|
|
430
|
+
for (const e of read.entries) {
|
|
431
|
+
const where = below(e.name, top);
|
|
432
|
+
if ('problem' in where)
|
|
433
|
+
return where;
|
|
434
|
+
if (where.segments.length === 0)
|
|
435
|
+
continue;
|
|
436
|
+
if (e.kind === 'directory')
|
|
437
|
+
planned.push({ segments: where.segments, kind: 'directory', mode: DIR_MODE, size: 0, write: () => null });
|
|
438
|
+
else if (e.kind === 'link') {
|
|
439
|
+
const target = zipBytes(fd, e);
|
|
440
|
+
if (target === null)
|
|
441
|
+
return { problem: 'holds a damaged symbolic link' };
|
|
442
|
+
const link = target.toString('utf8');
|
|
443
|
+
if (!linkTargetTaken(link))
|
|
444
|
+
return { problem: 'holds a symbolic link whose target is not a relative path below its own directory' };
|
|
445
|
+
planned.push({ segments: where.segments, kind: 'link', mode: 0, size: 0, link, write: () => null });
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
const mode = modeOf(e.mode);
|
|
449
|
+
planned.push({
|
|
450
|
+
segments: where.segments,
|
|
451
|
+
kind: 'file',
|
|
452
|
+
mode,
|
|
453
|
+
size: e.size,
|
|
454
|
+
write: async (target) => {
|
|
455
|
+
if (e.compressed === 0)
|
|
456
|
+
return e.size === 0 && e.crc === 0 ? writeBytes(target, Buffer.alloc(0), mode) : 'holds a damaged entry';
|
|
457
|
+
const checked = new Checked(e.size);
|
|
458
|
+
const source = createReadStream(path, { start: e.data, end: e.data + e.compressed - 1 });
|
|
459
|
+
let fd = null;
|
|
460
|
+
try {
|
|
461
|
+
fd = openSync(target, 'wx', mode);
|
|
462
|
+
fchmodSync(fd, mode);
|
|
463
|
+
if (e.method === 8)
|
|
464
|
+
await pipeline(source, createInflateRaw(), checked, writeAllStream(fd));
|
|
465
|
+
else
|
|
466
|
+
await pipeline(source, checked, writeAllStream(fd));
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
source.destroy();
|
|
470
|
+
return 'holds a damaged entry';
|
|
471
|
+
}
|
|
472
|
+
finally {
|
|
473
|
+
if (fd !== null)
|
|
474
|
+
closeSync(fd);
|
|
475
|
+
}
|
|
476
|
+
return checked.size === e.size && checked.crc === e.crc ? null : 'holds an entry whose size or CRC-32 does not match the archive';
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return { planned };
|
|
482
|
+
}
|
|
483
|
+
export async function unpackTree(path, format, top, dest) {
|
|
484
|
+
if (format === 'tar.gz') {
|
|
485
|
+
const bytes = wholeFile(path);
|
|
486
|
+
if (bytes === null)
|
|
487
|
+
return 'cannot be read';
|
|
488
|
+
const plan = planTar(bytes, top);
|
|
489
|
+
if ('problem' in plan)
|
|
490
|
+
return plan.problem;
|
|
491
|
+
return plannedProblem(plan.planned) ?? (await writePlanned(plan.planned, dest));
|
|
492
|
+
}
|
|
493
|
+
let fd;
|
|
494
|
+
try {
|
|
495
|
+
fd = openSync(path, 'r');
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return 'cannot be read';
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
const plan = planZip(fd, path, top);
|
|
502
|
+
if ('problem' in plan)
|
|
503
|
+
return plan.problem;
|
|
504
|
+
return plannedProblem(plan.planned) ?? (await writePlanned(plan.planned, dest));
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
closeSync(fd);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
export function treeProblem(path, format, top) {
|
|
511
|
+
if (format === 'tar.gz') {
|
|
512
|
+
const bytes = wholeFile(path);
|
|
513
|
+
if (bytes === null)
|
|
514
|
+
return 'cannot be read';
|
|
515
|
+
const plan = planTar(bytes, top);
|
|
516
|
+
return 'problem' in plan ? plan.problem : plannedProblem(plan.planned);
|
|
517
|
+
}
|
|
518
|
+
const r = withFile(path, (fd) => {
|
|
519
|
+
const plan = planZip(fd, path, top);
|
|
520
|
+
return 'problem' in plan ? plan.problem : plannedProblem(plan.planned);
|
|
521
|
+
});
|
|
522
|
+
return r === null ? 'cannot be read' : r.value;
|
|
523
|
+
}
|
|
524
|
+
export function treeListing(path, format, top) {
|
|
525
|
+
const listed = (plan) => {
|
|
526
|
+
if ('problem' in plan)
|
|
527
|
+
return { ok: false, problem: plan.problem };
|
|
528
|
+
const problem = plannedProblem(plan.planned);
|
|
529
|
+
if (problem !== null)
|
|
530
|
+
return { ok: false, problem };
|
|
531
|
+
const paths = new Map();
|
|
532
|
+
for (const p of plan.planned) {
|
|
533
|
+
for (let i = 1; i < p.segments.length; i++) {
|
|
534
|
+
const parent = p.segments.slice(0, i).join('/');
|
|
535
|
+
if (!paths.has(parent))
|
|
536
|
+
paths.set(parent, { kind: 'directory', mode: DIR_MODE, size: 0 });
|
|
537
|
+
}
|
|
538
|
+
paths.set(p.segments.join('/'), { kind: p.kind, mode: p.kind === 'link' ? 0 : p.mode, size: p.size, ...(p.link !== undefined ? { link: p.link } : {}) });
|
|
539
|
+
}
|
|
540
|
+
return { ok: true, paths };
|
|
541
|
+
};
|
|
542
|
+
if (format === 'tar.gz') {
|
|
543
|
+
const bytes = wholeFile(path);
|
|
544
|
+
return bytes === null ? { ok: false, problem: 'cannot be read' } : listed(planTar(bytes, top));
|
|
545
|
+
}
|
|
546
|
+
const r = withFile(path, (fd) => listed(planZip(fd, path, top)));
|
|
547
|
+
return r === null ? { ok: false, problem: 'cannot be read' } : r.value;
|
|
96
548
|
}
|
package/dist/secrets.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface LaunchSecrets {
|
|
|
34
34
|
readonly handles: readonly SecretHandle[];
|
|
35
35
|
readonly files: ReadonlyMap<string, string>;
|
|
36
36
|
readonly values: readonly string[];
|
|
37
|
+
readonly policy: readonly string[] | null;
|
|
37
38
|
remove(): void;
|
|
38
39
|
}
|
|
39
40
|
export type LaunchCopy = ({
|
|
@@ -44,6 +45,14 @@ export type LaunchCopy = ({
|
|
|
44
45
|
};
|
|
45
46
|
export declare const NO_HANDLES: LaunchSecrets;
|
|
46
47
|
export declare function copyForLaunch(configDir: string, connector: string): LaunchCopy;
|
|
48
|
+
export declare const POLICY_FILE = "tools.json";
|
|
49
|
+
export type PolicyReading = {
|
|
50
|
+
readonly ok: true;
|
|
51
|
+
readonly policy: readonly string[] | null;
|
|
52
|
+
} | {
|
|
53
|
+
readonly ok: false;
|
|
54
|
+
readonly problem: string;
|
|
55
|
+
};
|
|
47
56
|
export declare function openFileSecrets(options: {
|
|
48
57
|
readonly configDir: string;
|
|
49
58
|
readonly connector: string;
|
package/dist/secrets.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { closeSync, constants, fchmodSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
3
3
|
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
4
4
|
import { CONNECTOR_NAME } from './connector-name.js';
|
|
5
5
|
import { hasRepeatedKey } from './json-text.js';
|
|
@@ -56,7 +56,7 @@ function locked(dir, connector, fn) {
|
|
|
56
56
|
release();
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
-
export const NO_HANDLES = { handles: [], files: new Map(), values: [], remove: () => undefined };
|
|
59
|
+
export const NO_HANDLES = { handles: [], files: new Map(), values: [], policy: null, remove: () => undefined };
|
|
60
60
|
export function copyForLaunch(configDir, connector) {
|
|
61
61
|
const dir = handleDirectory(configDir, connector);
|
|
62
62
|
try {
|
|
@@ -74,11 +74,15 @@ export function copyForLaunch(configDir, connector) {
|
|
|
74
74
|
return r.ok ? r.value : r;
|
|
75
75
|
}
|
|
76
76
|
function copyHandles(configDir, dir, connector) {
|
|
77
|
+
const policyReading = readToolPolicy(dir, connector);
|
|
78
|
+
if (!policyReading.ok)
|
|
79
|
+
return policyReading;
|
|
80
|
+
const { policy } = policyReading;
|
|
77
81
|
const listing = listHandles(dir, connector);
|
|
78
82
|
if (!listing.ok)
|
|
79
83
|
return listing;
|
|
80
84
|
if (listing.handles.length === 0)
|
|
81
|
-
return { ok: true, ...NO_HANDLES };
|
|
85
|
+
return { ok: true, ...NO_HANDLES, policy };
|
|
82
86
|
const copies = mkdtempSync(join(dir, '.launch-'));
|
|
83
87
|
const remove = () => rmSync(copies, { recursive: true, force: true });
|
|
84
88
|
try {
|
|
@@ -98,7 +102,7 @@ function copyHandles(configDir, dir, connector) {
|
|
|
98
102
|
if (isSecret(handle))
|
|
99
103
|
values.push(bytes.toString('utf8'));
|
|
100
104
|
}
|
|
101
|
-
return { ok: true, handles: listing.handles, files, values, remove };
|
|
105
|
+
return { ok: true, handles: listing.handles, files, values, policy, remove };
|
|
102
106
|
}
|
|
103
107
|
catch (e) {
|
|
104
108
|
remove();
|
|
@@ -142,6 +146,64 @@ function entryProblem(path, kind, label) {
|
|
|
142
146
|
return `${label} has permissions wider than ${kind === 'directory' ? '0700' : '0600'}`;
|
|
143
147
|
return null;
|
|
144
148
|
}
|
|
149
|
+
export const POLICY_FILE = 'tools.json';
|
|
150
|
+
const ALLOW_NAME = /^[^\s\x00-\x1F\x7F]+$/;
|
|
151
|
+
function validAllowList(v) {
|
|
152
|
+
if (!Array.isArray(v))
|
|
153
|
+
return false;
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (const name of v) {
|
|
156
|
+
if (typeof name !== 'string' || name === '*' || !ALLOW_NAME.test(name) || seen.has(name))
|
|
157
|
+
return false;
|
|
158
|
+
seen.add(name);
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
function readToolPolicy(dir, connector) {
|
|
163
|
+
const relative = `connectors/${connector}/${POLICY_FILE}`;
|
|
164
|
+
const refuse = (problem) => ({ ok: false, problem: `connector ${connector}: ${problem}` });
|
|
165
|
+
const malformed = () => refuse(`${relative} does not hold a JSON object whose only key is "allow", a list of non-empty tool names with no whitespace, no control character and no repeat`);
|
|
166
|
+
let fd;
|
|
167
|
+
try {
|
|
168
|
+
fd = openSync(join(dir, POLICY_FILE), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
const code = e.code;
|
|
172
|
+
if (code === 'ENOENT')
|
|
173
|
+
return { ok: true, policy: null };
|
|
174
|
+
if (code === 'ELOOP')
|
|
175
|
+
return refuse(`${relative} is a symbolic link`);
|
|
176
|
+
return refuse(`${relative} cannot be read`);
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
if (!fstatSync(fd).isFile())
|
|
180
|
+
return refuse(`${relative} is not a regular file`);
|
|
181
|
+
let text;
|
|
182
|
+
try {
|
|
183
|
+
text = readFileSync(fd, 'utf8');
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return refuse(`${relative} cannot be read`);
|
|
187
|
+
}
|
|
188
|
+
let value;
|
|
189
|
+
try {
|
|
190
|
+
value = JSON.parse(text);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return malformed();
|
|
194
|
+
}
|
|
195
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) || hasRepeatedKey(text))
|
|
196
|
+
return malformed();
|
|
197
|
+
const keys = Object.keys(value);
|
|
198
|
+
if (keys.length !== 1 || keys[0] !== 'allow')
|
|
199
|
+
return malformed();
|
|
200
|
+
const allow = value.allow;
|
|
201
|
+
return validAllowList(allow) ? { ok: true, policy: allow } : malformed();
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
closeSync(fd);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
145
207
|
function listHandles(dir, connector) {
|
|
146
208
|
const refuse = (problem) => ({ ok: false, problem: `connector ${connector}: ${problem}` });
|
|
147
209
|
try {
|
package/dist/write-all.d.ts
CHANGED