@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.
@@ -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
- // Capabilities are deliberately *not* dropped wholesale: installing a
202
- // package is the ordinary use of this tool, and `--cap-drop=ALL` breaks
203
- // every package manager there is. The container is the boundary.
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 ?? 0,
426
- memory: opts.memory ?? 0,
427
- network: opts.network ?? 'bridge',
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
- spec.cpus,
455
- spec.memory,
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);
@@ -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: [workspace:*]`. */
764
- const GROUP = 'workspace';
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 group = selector.slice(0, -2);
92
- const hits = available.filter((t) => t.group === group);
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 = [...new Set(available.map((t) => t.group).filter(Boolean))];
95
- throw new Error(`${opts.where}: no tools in group "${group}" ` +
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`: an author who
105
- // writes `workspace:*` on one line should not be told the qualified form
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 group = selector.slice(0, colon);
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 "${elsewhere.group ?? 'none'}", not "${group}")`);
133
+ `("${name}" is in group "${elsewhereGroup}", not "${rawGroup}")`);
119
134
  }
120
135
  }
121
136
  const known = available.map((t) => t.name).join(', ') || 'none';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/neo",
3
- "version": "1.1.20",
3
+ "version": "1.1.22",
4
4
  "description": "A multi-agent runtime for Node.js: agents, models, tools, skills, memory and an append-only trajectory.",
5
5
  "keywords": [
6
6
  "agents",