@zenera/neo 1.1.20 → 1.1.22
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/README.md +1 -1
- package/dist/fork.js +4 -3
- package/dist/inspect/client.d.ts +1 -1
- package/dist/inspect/client.js +95 -14
- package/dist/inspect/css.d.ts +1 -1
- package/dist/inspect/css.js +28 -3
- package/dist/kernel.js +22 -1
- package/dist/memory/files.d.ts +2 -0
- package/dist/memory/files.js +10 -9
- package/dist/memory/index.d.ts +1 -0
- package/dist/memory/index.js +48 -16
- package/dist/memory/tools.js +66 -16
- package/dist/project/config.d.ts +12 -0
- package/dist/project/config.js +14 -0
- package/dist/project/load.js +61 -0
- package/dist/tools/sandbox.d.ts +25 -0
- package/dist/tools/sandbox.js +58 -8
- package/dist/tools/workspace.js +2 -2
- package/dist/types.js +25 -10
- package/package.json +1 -1
package/dist/tools/sandbox.js
CHANGED
|
@@ -39,6 +39,17 @@ const MAX_TIMEOUT_MS = 3_600_000;
|
|
|
39
39
|
const DEFAULT_PIDS = 1024;
|
|
40
40
|
/** How long a container gets to stop itself before it is killed. */
|
|
41
41
|
const STOP_GRACE_S = 2;
|
|
42
|
+
// Standard mode's resource ceilings. Even without the strict profile, an
|
|
43
|
+
// unbounded container can exhaust host cores or memory during runaway builds.
|
|
44
|
+
const DEFAULT_CPUS = 4;
|
|
45
|
+
const DEFAULT_MEMORY = 4096;
|
|
46
|
+
// Strict mode's own defaults. A limit that is only there when someone
|
|
47
|
+
// remembered to write it down is not a limit, so the profile supplies one for
|
|
48
|
+
// each of the four exhaustible things: cores, memory, processes and disk.
|
|
49
|
+
const STRICT_CPUS = 2;
|
|
50
|
+
const STRICT_MEMORY = 2048;
|
|
51
|
+
const STRICT_TMPFS = 'rw,noexec,nosuid,nodev';
|
|
52
|
+
const STRICT_TMP_SIZE = '64m';
|
|
42
53
|
/** The family name config selects the whole set by: `tools: [sandbox:*]`. */
|
|
43
54
|
export const SANDBOX_GROUP = 'sandbox';
|
|
44
55
|
/** A failure the caller can do something about — a missing engine, usually. */
|
|
@@ -198,14 +209,36 @@ export class Sandbox {
|
|
|
198
209
|
}
|
|
199
210
|
args.push('--pids-limit', String(DEFAULT_PIDS));
|
|
200
211
|
// `no-new-privileges` costs nothing here and closes setuid escalation.
|
|
201
|
-
//
|
|
202
|
-
// package is the ordinary use of this tool, and
|
|
203
|
-
// every package manager there is. The container
|
|
212
|
+
// Under `standard` the capabilities are deliberately *not* dropped
|
|
213
|
+
// wholesale: installing a package is the ordinary use of this tool, and
|
|
214
|
+
// `--cap-drop=ALL` breaks every package manager there is. The container
|
|
215
|
+
// is the boundary. `strict` makes the opposite trade, below.
|
|
204
216
|
args.push('--security-opt', 'no-new-privileges');
|
|
205
217
|
// Background jobs orphan processes; without an init they accumulate as
|
|
206
218
|
// zombies until the pids limit stops the sandbox dead.
|
|
207
219
|
args.push('--init');
|
|
208
220
|
args.push('--restart', 'no');
|
|
221
|
+
if (s.hardening === 'strict') {
|
|
222
|
+
args.push('--cap-drop', 'ALL');
|
|
223
|
+
// The image's own files become reference material: a command can
|
|
224
|
+
// read a binary and run it, and can write nowhere but the mounts
|
|
225
|
+
// and the tmpfs below.
|
|
226
|
+
args.push('--read-only');
|
|
227
|
+
// `noexec` is safe for the job launcher because a job script is
|
|
228
|
+
// *interpreted* (`/bin/sh <file>`), never executed as a program.
|
|
229
|
+
// `/run` is left to podman's own read-only tmpfs: `--init` mounts
|
|
230
|
+
// catatonit under it and has to be able to exec it.
|
|
231
|
+
args.push('--tmpfs', `/tmp:${STRICT_TMPFS},size=${STRICT_TMP_SIZE}`);
|
|
232
|
+
if (!s.user) {
|
|
233
|
+
// Rootless podman maps container uid 0 to the host account, so
|
|
234
|
+
// the default container root is already unprivileged on the
|
|
235
|
+
// host — but it is uid 0 *inside*, where the setuid binaries
|
|
236
|
+
// are. `keep-id` runs the process as the host's own uid, which
|
|
237
|
+
// is both non-zero and the owner of every bind mount, so the
|
|
238
|
+
// workspace stays writable without a chown.
|
|
239
|
+
args.push('--userns', 'keep-id');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
209
242
|
if (s.user) {
|
|
210
243
|
args.push('--user', s.user);
|
|
211
244
|
}
|
|
@@ -418,15 +451,20 @@ function resolveSpec(opts) {
|
|
|
418
451
|
if (!posix.isAbsolute(workdir)) {
|
|
419
452
|
throw new SandboxError(`sandbox workdir must be absolute: ${workdir}`);
|
|
420
453
|
}
|
|
454
|
+
const hardening = opts.hardening ?? 'standard';
|
|
455
|
+
const strict = hardening === 'strict';
|
|
421
456
|
return {
|
|
422
457
|
root: opts.root,
|
|
423
458
|
key: opts.key,
|
|
424
459
|
image: opts.image ?? DEFAULT_IMAGE,
|
|
425
|
-
cpus: opts.cpus ??
|
|
426
|
-
memory: opts.memory ??
|
|
427
|
-
|
|
460
|
+
cpus: opts.cpus ?? (strict ? STRICT_CPUS : DEFAULT_CPUS),
|
|
461
|
+
memory: opts.memory ?? (strict ? STRICT_MEMORY : DEFAULT_MEMORY),
|
|
462
|
+
explicitCpus: opts.cpus !== undefined,
|
|
463
|
+
explicitMemory: opts.memory !== undefined,
|
|
464
|
+
network: opts.network ?? (strict ? 'none' : 'bridge'),
|
|
428
465
|
workdir,
|
|
429
466
|
timeout: opts.timeout ?? DEFAULT_TIMEOUT_MS / 1000,
|
|
467
|
+
hardening,
|
|
430
468
|
user: opts.user,
|
|
431
469
|
persist: opts.persist ?? false,
|
|
432
470
|
env: opts.env ?? {},
|
|
@@ -447,12 +485,21 @@ function resolveSpec(opts) {
|
|
|
447
485
|
* every time a key was rotated.
|
|
448
486
|
*/
|
|
449
487
|
function containerName(spec) {
|
|
488
|
+
// Unconfigured cpus and memory hash 0 under standard and strict's own
|
|
489
|
+
// defaults under strict, so introducing defaults did not rename every
|
|
490
|
+
// existing container.
|
|
491
|
+
const hashCpus = spec.explicitCpus ? spec.cpus : spec.hardening === 'strict' ? STRICT_CPUS : 0;
|
|
492
|
+
const hashMemory = spec.explicitMemory
|
|
493
|
+
? spec.memory
|
|
494
|
+
: spec.hardening === 'strict'
|
|
495
|
+
? STRICT_MEMORY
|
|
496
|
+
: 0;
|
|
450
497
|
const digest = createHash('sha256')
|
|
451
498
|
.update(JSON.stringify([
|
|
452
499
|
spec.root,
|
|
453
500
|
spec.image,
|
|
454
|
-
|
|
455
|
-
|
|
501
|
+
hashCpus,
|
|
502
|
+
hashMemory,
|
|
456
503
|
spec.network,
|
|
457
504
|
spec.workdir,
|
|
458
505
|
spec.user ?? null,
|
|
@@ -460,6 +507,9 @@ function containerName(spec) {
|
|
|
460
507
|
spec.mounts,
|
|
461
508
|
Object.entries(spec.env).sort(),
|
|
462
509
|
[...spec.secrets].sort(),
|
|
510
|
+
// Appended only when it is set, so adding the profile did not
|
|
511
|
+
// rename every container that existed before it.
|
|
512
|
+
...(spec.hardening === 'strict' ? ['strict'] : []),
|
|
463
513
|
]))
|
|
464
514
|
.digest('hex')
|
|
465
515
|
.slice(0, 10);
|
package/dist/tools/workspace.js
CHANGED
|
@@ -760,8 +760,8 @@ function patchLines(lines, chunks, file) {
|
|
|
760
760
|
// ---------------------------------------------------------------------------
|
|
761
761
|
// The tools
|
|
762
762
|
// ---------------------------------------------------------------------------
|
|
763
|
-
/** The family name config selects the whole set by: `tools: [
|
|
764
|
-
const GROUP = '
|
|
763
|
+
/** The family name config selects the whole set by: `tools: [files:*]` (or legacy `workspace:*`). */
|
|
764
|
+
const GROUP = 'files';
|
|
765
765
|
export function workspaceTools(opts) {
|
|
766
766
|
const ws = new Workspace(opts);
|
|
767
767
|
// What the model is told about naming a file. The long form goes in
|
package/dist/types.js
CHANGED
|
@@ -57,6 +57,16 @@ export function isToolReturn(v) {
|
|
|
57
57
|
export function tool(def) {
|
|
58
58
|
return def;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Aliases for tool groups, so renaming a group breaks no existing configuration.
|
|
62
|
+
* `workspace` is the legacy name for the file tools (`files`).
|
|
63
|
+
*/
|
|
64
|
+
const GROUP_ALIASES = {
|
|
65
|
+
workspace: 'files',
|
|
66
|
+
};
|
|
67
|
+
function canonicalGroup(group) {
|
|
68
|
+
return group ? (GROUP_ALIASES[group] ?? group) : undefined;
|
|
69
|
+
}
|
|
60
70
|
/**
|
|
61
71
|
* Resolves selectors against the tools a host provided, in the order written
|
|
62
72
|
* and without duplicates. A selector is a tool name, `<group>:<name>` for the
|
|
@@ -88,11 +98,14 @@ function matchTools(available, selector, opts) {
|
|
|
88
98
|
return available;
|
|
89
99
|
}
|
|
90
100
|
if (selector.endsWith(':*')) {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
101
|
+
const rawGroup = selector.slice(0, -2);
|
|
102
|
+
const group = canonicalGroup(rawGroup) ?? rawGroup;
|
|
103
|
+
const hits = available.filter((t) => (canonicalGroup(t.group) ?? t.group) === group);
|
|
93
104
|
if (hits.length === 0) {
|
|
94
|
-
const groups = [
|
|
95
|
-
|
|
105
|
+
const groups = [
|
|
106
|
+
...new Set(available.map((t) => canonicalGroup(t.group) ?? t.group).filter(Boolean)),
|
|
107
|
+
];
|
|
108
|
+
throw new Error(`${opts.where}: no tools in group "${rawGroup}" ` +
|
|
96
109
|
`(known groups: ${groups.join(', ') || 'none'})`);
|
|
97
110
|
}
|
|
98
111
|
return hits;
|
|
@@ -101,21 +114,23 @@ function matchTools(available, selector, opts) {
|
|
|
101
114
|
if (exact) {
|
|
102
115
|
return [exact];
|
|
103
116
|
}
|
|
104
|
-
// `workspace:read_file` is the same thing as `read_file`:
|
|
105
|
-
// writes `
|
|
106
|
-
// is a typo on the next.
|
|
117
|
+
// `workspace:read_file` or `files:read_file` is the same thing as `read_file`:
|
|
118
|
+
// an author who writes `files:*` on one line should not be told the qualified
|
|
119
|
+
// form is a typo on the next.
|
|
107
120
|
const colon = selector.lastIndexOf(':');
|
|
108
121
|
if (colon > 0) {
|
|
109
|
-
const
|
|
122
|
+
const rawGroup = selector.slice(0, colon);
|
|
123
|
+
const group = canonicalGroup(rawGroup) ?? rawGroup;
|
|
110
124
|
const name = selector.slice(colon + 1);
|
|
111
|
-
const qualified = available.find((t) => t.group === group && t.name === name);
|
|
125
|
+
const qualified = available.find((t) => (canonicalGroup(t.group) ?? t.group) === group && t.name === name);
|
|
112
126
|
if (qualified) {
|
|
113
127
|
return [qualified];
|
|
114
128
|
}
|
|
115
129
|
const elsewhere = available.find((t) => t.name === name);
|
|
116
130
|
if (elsewhere) {
|
|
131
|
+
const elsewhereGroup = canonicalGroup(elsewhere.group) ?? elsewhere.group ?? 'none';
|
|
117
132
|
throw new Error(`${opts.where}: unknown tool "${selector}" ` +
|
|
118
|
-
`("${name}" is in group "${
|
|
133
|
+
`("${name}" is in group "${elsewhereGroup}", not "${rawGroup}")`);
|
|
119
134
|
}
|
|
120
135
|
}
|
|
121
136
|
const known = available.map((t) => t.name).join(', ') || 'none';
|