@zenera/neo 1.1.21 → 1.1.23

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.
@@ -0,0 +1,379 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { hostPath } from "./files.js";
5
+ import { DUPLICATE_SCORE, flatten } from "./index.js";
6
+ import { MANIFEST_FILE, MemoryStore, lockHolder } from "./store.js";
7
+ import { MemoryError } from "./types.js";
8
+ // ---------------------------------------------------------------------------
9
+ // Folding several memories into one
10
+ //
11
+ // Warming a memory is embarrassingly parallel — N runs, N questions, N answers
12
+ // — but the lock is per directory, so N runs cannot share one. They each write
13
+ // their own, and this puts the results back together.
14
+ //
15
+ // Ids are ULIDs and are preserved, which makes the interesting case the one
16
+ // where the same id appears twice: the runs started from a copy of the same
17
+ // memory, so most of what they hold is a shared ancestor rather than a
18
+ // collision. That case reconciles to nothing more than counters, and the
19
+ // counters take the MAX rather than the sum, because merging the same source
20
+ // twice has to be a no-op. Anything that is not a shared ancestor is a real
21
+ // divergence and refuses the whole merge; `force` is the escape hatch, not the
22
+ // default, because picking a winner silently loses a memory.
23
+ //
24
+ // Nothing here embeds. A merge is offline and fast, and the price of that is
25
+ // that every side must already agree on the embedder.
26
+ // ---------------------------------------------------------------------------
27
+ /** How much of a diverged memory's text a conflict report carries. */
28
+ const CLIP = 80;
29
+ /**
30
+ * Refusal carrying what to look at. Thrown before anything is written, so a
31
+ * conflicted merge leaves the target exactly as it was.
32
+ */
33
+ export class MergeConflicts extends MemoryError {
34
+ conflicts;
35
+ constructor(conflicts) {
36
+ super(`${conflicts.length} ${conflicts.length === 1 ? 'memory' : 'memories'} changed in more than one place`, 'compare them with `zen memory show <id>`, or take the newest with --force');
37
+ this.name = 'MergeConflicts';
38
+ this.conflicts = conflicts;
39
+ }
40
+ }
41
+ /**
42
+ * Fold `sources` into `target`, left to right.
43
+ *
44
+ * Order is visible: a memory folded onto a twin keeps the id of whichever copy
45
+ * arrived first, so `merge a b` and `merge b a` can differ in which id
46
+ * survives. They never differ in what is remembered.
47
+ *
48
+ * On `dryRun` the target is left with the merge applied in memory and nothing
49
+ * on disk. Report the result and discard the store; do not commit it.
50
+ */
51
+ export async function mergeMemories(target, sources, opts = {}) {
52
+ const dedupe = opts.dedupe !== false;
53
+ const opened = await openSources(target, sources, opts.force === true);
54
+ const reports = new Map(opened.map((src) => [
55
+ src,
56
+ {
57
+ dir: src.dir,
58
+ nodes: src.graph.order,
59
+ edges: src.graph.size,
60
+ added: 0,
61
+ shared: 0,
62
+ twins: 0,
63
+ files: 0,
64
+ },
65
+ ]));
66
+ const embedding = agree(target, opened);
67
+ if (embedding) {
68
+ target.adopt(embedding);
69
+ }
70
+ // --- plan: everything refusable is refused here, before a single write ---
71
+ const plan = new Map();
72
+ for (const node of target.graph.nodes()) {
73
+ plan.set(node.id, { node, fresh: false });
74
+ }
75
+ const conflicts = [];
76
+ for (const src of opened) {
77
+ const report = reports.get(src);
78
+ for (const node of src.graph.nodes()) {
79
+ const held = plan.get(node.id);
80
+ if (!held) {
81
+ plan.set(node.id, { node, from: src, fresh: true });
82
+ continue;
83
+ }
84
+ if (held.node.revision === node.revision && sameContent(held.node, node)) {
85
+ plan.set(node.id, { ...held, node: reconcile(held.node, node) });
86
+ report.shared++;
87
+ continue;
88
+ }
89
+ if (!opts.force) {
90
+ conflicts.push({
91
+ id: node.id,
92
+ dir: src.dir,
93
+ mine: held.node.revision,
94
+ theirs: node.revision,
95
+ text: clip(node.text),
96
+ });
97
+ continue;
98
+ }
99
+ report.shared++;
100
+ plan.set(node.id, newer(node, held.node)
101
+ ? { node: reconcile(node, held.node), from: src, fresh: held.fresh }
102
+ : { ...held, node: reconcile(held.node, node) });
103
+ }
104
+ }
105
+ if (conflicts.length) {
106
+ throw new MergeConflicts(conflicts);
107
+ }
108
+ await checkFiles(plan);
109
+ // --- fold, in memory ---
110
+ const before = target.graph.size;
111
+ const moved = new Map();
112
+ for (const planned of plan.values()) {
113
+ if (planned.fresh) {
114
+ continue;
115
+ }
116
+ target.graph.adopt(planned.node);
117
+ if (planned.from) {
118
+ carryVector(planned.from, target, planned.node.id);
119
+ }
120
+ }
121
+ const lexical = new Map();
122
+ for (const node of target.graph.nodes()) {
123
+ lexical.set(twinKey(node), node.id);
124
+ }
125
+ for (const src of opened) {
126
+ const report = reports.get(src);
127
+ const stale = target.graph.superseded();
128
+ for (const node of src.graph.nodes()) {
129
+ const planned = plan.get(node.id);
130
+ if (!planned.fresh || planned.from !== src || moved.has(node.id)) {
131
+ continue;
132
+ }
133
+ const twin = dedupe ? twinOf(target, src, planned.node, lexical, stale) : undefined;
134
+ if (twin) {
135
+ moved.set(node.id, twin);
136
+ report.twins++;
137
+ continue;
138
+ }
139
+ target.graph.adopt(planned.node);
140
+ carryVector(src, target, node.id);
141
+ lexical.set(twinKey(planned.node), node.id);
142
+ moved.set(node.id, node.id);
143
+ report.added++;
144
+ }
145
+ for (const link of src.graph.links()) {
146
+ const from = moved.get(link.source) ?? link.source;
147
+ const to = moved.get(link.target) ?? link.target;
148
+ // A self-edge is what a fold leaves behind when both ends collapsed
149
+ // onto the same memory; it says nothing and is dropped.
150
+ if (from === to || !target.graph.has(from) || !target.graph.has(to)) {
151
+ continue;
152
+ }
153
+ target.graph.adoptEdge(from, to, link.attrs);
154
+ }
155
+ }
156
+ const report = {
157
+ dir: target.dir,
158
+ sources: opened.map((src) => reports.get(src)),
159
+ added: 0,
160
+ shared: 0,
161
+ twins: 0,
162
+ edges: target.graph.size - before,
163
+ files: 0,
164
+ ...(target.embedding ? { embedding: target.embedding } : {}),
165
+ dryRun: opts.dryRun === true,
166
+ };
167
+ if (opts.dryRun) {
168
+ for (const [id, planned] of plan) {
169
+ if (copies(id, planned, moved)) {
170
+ reports.get(planned.from).files++;
171
+ }
172
+ }
173
+ return total(report);
174
+ }
175
+ await copyFiles(target, plan, moved, reports);
176
+ await target.commit();
177
+ return total(report);
178
+ }
179
+ async function openSources(target, sources, force) {
180
+ if (!sources.length) {
181
+ throw new MemoryError('no memories to merge', 'name at least one memory directory');
182
+ }
183
+ const here = resolve(target.dir);
184
+ const seen = new Set();
185
+ const out = [];
186
+ for (const dir of sources) {
187
+ const path = resolve(dir);
188
+ if (path === here) {
189
+ throw new MemoryError(`${dir} is the memory being merged into`, 'name only the memories to fold in');
190
+ }
191
+ if (seen.has(path)) {
192
+ throw new MemoryError(`${dir} was named twice`, 'name each memory once');
193
+ }
194
+ seen.add(path);
195
+ // `MemoryStore.open` creates what is missing, so a mistyped path would
196
+ // otherwise merge an empty memory it had just brought into existence.
197
+ try {
198
+ await stat(join(path, MANIFEST_FILE));
199
+ }
200
+ catch {
201
+ throw new MemoryError(`${dir} is not a memory`, `no ${MANIFEST_FILE} in it`);
202
+ }
203
+ const held = lockHolder(path);
204
+ if (held && !force) {
205
+ throw new MemoryError(`${dir} is in use (pid ${held.pid}, since ${held.startedAt})`, 'wait for that run to finish, or merge with --force');
206
+ }
207
+ out.push(await MemoryStore.open(path, { lock: false }));
208
+ }
209
+ return out;
210
+ }
211
+ /**
212
+ * The one embedder everything must already share. Sources that hold nothing are
213
+ * not asked: a warmup run that found nothing worth keeping never adopted one.
214
+ */
215
+ function agree(target, sources) {
216
+ const holding = sources.filter((src) => src.graph.order > 0);
217
+ const chosen = target.embedding ?? holding.find((src) => src.embedding)?.embedding;
218
+ for (const src of holding) {
219
+ const theirs = src.embedding;
220
+ if (chosen && theirs) {
221
+ if (theirs.model !== chosen.model || theirs.dimensions !== chosen.dimensions) {
222
+ throw new MemoryError(`${src.dir} was embedded with ${theirs.model} (${theirs.dimensions}d), ` +
223
+ `but this merge is ${chosen.model} (${chosen.dimensions}d)`, 'merge memories warmed with the same embedder');
224
+ }
225
+ }
226
+ else if (chosen) {
227
+ throw new MemoryError(`${src.dir} has no embedder, but this merge is ${chosen.model} (${chosen.dimensions}d)`, 'its memories would be invisible to search; re-warm it with that embedder');
228
+ }
229
+ }
230
+ return chosen;
231
+ }
232
+ /**
233
+ * The existing twin rule, applied across memories: same kind, same audience, no
234
+ * file, not superseded, and either near-identical by cosine or identical once
235
+ * whitespace and case stop counting.
236
+ */
237
+ function twinOf(target, src, node, lexical, stale) {
238
+ if (node.file) {
239
+ return undefined;
240
+ }
241
+ const vector = src.vectors?.get(node.id);
242
+ const block = target.vectors;
243
+ if (vector && block && block.dims === vector.length && block.rows) {
244
+ const allow = (id) => {
245
+ const other = target.graph.get(id);
246
+ return !stale.has(id) && !!other && comparable(other, node);
247
+ };
248
+ const hit = block.topK(vector, 1, allow)[0];
249
+ return hit && hit.score >= DUPLICATE_SCORE ? hit.id : undefined;
250
+ }
251
+ const id = lexical.get(twinKey(node));
252
+ return id && !stale.has(id) ? id : undefined;
253
+ }
254
+ function comparable(a, b) {
255
+ return a.id !== b.id && a.kind === b.kind && !a.file && audienceKey(a) === audienceKey(b);
256
+ }
257
+ function twinKey(node) {
258
+ return [node.kind, audienceKey(node), flatten(node.text)].join('\u0000');
259
+ }
260
+ function audienceKey(node) {
261
+ return [...node.audience].sort().join('|');
262
+ }
263
+ function carryVector(from, to, id) {
264
+ const vector = from.vectors?.get(id);
265
+ const block = to.vectors;
266
+ if (vector && block && block.dims === vector.length) {
267
+ block.set(id, vector);
268
+ }
269
+ }
270
+ /**
271
+ * Use is not change: `touch` moves these and deliberately leaves `revision`
272
+ * alone, which is what lets two runs of the same memory reconcile instead of
273
+ * colliding. MAX and not sum, so merging a source twice changes nothing.
274
+ */
275
+ function reconcile(keep, other) {
276
+ return {
277
+ ...keep,
278
+ // ISO-8601 in UTC is fixed width, so string order is time order.
279
+ createdAt: keep.createdAt <= other.createdAt ? keep.createdAt : other.createdAt,
280
+ lastUsedAt: keep.lastUsedAt >= other.lastUsedAt ? keep.lastUsedAt : other.lastUsedAt,
281
+ useCount: Math.max(keep.useCount, other.useCount),
282
+ };
283
+ }
284
+ function newer(a, b) {
285
+ return a.revision === b.revision ? a.updatedAt > b.updatedAt : a.revision > b.revision;
286
+ }
287
+ function sameContent(a, b) {
288
+ return (a.kind === b.kind &&
289
+ a.text === b.text &&
290
+ audienceKey(a) === audienceKey(b) &&
291
+ a.file?.sha256 === b.file?.sha256 &&
292
+ deep(a.metadata, b.metadata));
293
+ }
294
+ function deep(a, b) {
295
+ if (a === b) {
296
+ return true;
297
+ }
298
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
299
+ return false;
300
+ }
301
+ if (Array.isArray(a) !== Array.isArray(b)) {
302
+ return false;
303
+ }
304
+ const left = a;
305
+ const right = b;
306
+ const keys = Object.keys(left);
307
+ return keys.length === Object.keys(right).length && keys.every((k) => deep(left[k], right[k]));
308
+ }
309
+ /** Stat every file the merge would copy before it copies any of them. */
310
+ async function checkFiles(plan) {
311
+ for (const planned of plan.values()) {
312
+ const file = planned.node.file;
313
+ if (!planned.from || !file) {
314
+ continue;
315
+ }
316
+ const source = hostPath(planned.from.dir, file);
317
+ const info = await stat(source).catch(() => undefined);
318
+ if (!info?.isFile()) {
319
+ throw new MemoryError(`${file.path} is missing from ${planned.from.dir}`, 'that memory is incomplete; re-run it, or forget the node that names the file');
320
+ }
321
+ }
322
+ }
323
+ /**
324
+ * Bytes last, and all or nothing: the digest is checked against the one the
325
+ * node records, and anything written before a failure is taken back, so a
326
+ * refused merge cannot leave files no graph mentions.
327
+ */
328
+ async function copyFiles(target, plan, moved, reports) {
329
+ const written = [];
330
+ try {
331
+ for (const [id, planned] of plan) {
332
+ const file = planned.node.file;
333
+ if (!copies(id, planned, moved)) {
334
+ continue;
335
+ }
336
+ const source = hostPath(planned.from.dir, file);
337
+ const destination = hostPath(target.dir, file);
338
+ if (source === destination) {
339
+ continue;
340
+ }
341
+ const bytes = await readFile(source);
342
+ const sha = createHash('sha256').update(bytes).digest('hex');
343
+ if (sha !== file.sha256) {
344
+ throw new MemoryError(`${file.path} in ${planned.from.dir} does not match the digest its memory records`, 'that memory was modified outside zen; merge it after re-running it');
345
+ }
346
+ const existed = await stat(destination).then(() => true, () => false);
347
+ await writeFile(destination, bytes);
348
+ if (!existed) {
349
+ written.push(destination);
350
+ }
351
+ reports.get(planned.from).files++;
352
+ }
353
+ }
354
+ catch (err) {
355
+ await Promise.all(written.map((path) => rm(path, { force: true })));
356
+ throw err;
357
+ }
358
+ }
359
+ /** A file comes across when its node came from a source and survived the fold. */
360
+ function copies(id, planned, moved) {
361
+ if (!planned.from || !planned.node.file) {
362
+ return false;
363
+ }
364
+ return !planned.fresh || moved.get(id) === id;
365
+ }
366
+ function total(report) {
367
+ for (const src of report.sources) {
368
+ report.added += src.added;
369
+ report.shared += src.shared;
370
+ report.twins += src.twins;
371
+ report.files += src.files;
372
+ }
373
+ return report;
374
+ }
375
+ function clip(text) {
376
+ const flat = flatten(text);
377
+ return flat.length > CLIP ? `${flat.slice(0, CLIP - 1)}…` : flat;
378
+ }
379
+ //# sourceMappingURL=merge.js.map
@@ -19,6 +19,11 @@ export interface MemoryManifest {
19
19
  edges: number;
20
20
  updatedAt: string;
21
21
  }
22
+ export interface Lock {
23
+ pid: number;
24
+ host: string;
25
+ startedAt: string;
26
+ }
22
27
  export interface OpenOptions {
23
28
  /** the embedder the caller intends to use; a mismatch with the manifest is fatal */
24
29
  embedding?: MemoryEmbedding;
@@ -46,4 +51,10 @@ export declare class MemoryStore {
46
51
  commit(): Promise<void>;
47
52
  release(): void;
48
53
  }
54
+ /**
55
+ * Who is writing a memory right now, if anyone. Read-only on purpose: it never
56
+ * claims the lock and never clears a stale one, so it is safe to ask about a
57
+ * directory this process has no intention of opening for writing.
58
+ */
59
+ export declare function lockHolder(dir: string): Lock | undefined;
49
60
  //# sourceMappingURL=store.d.ts.map
@@ -202,6 +202,15 @@ async function atomic(path, body) {
202
202
  throw err;
203
203
  }
204
204
  }
205
+ /**
206
+ * Who is writing a memory right now, if anyone. Read-only on purpose: it never
207
+ * claims the lock and never clears a stale one, so it is safe to ask about a
208
+ * directory this process has no intention of opening for writing.
209
+ */
210
+ export function lockHolder(dir) {
211
+ const held = readLock(join(dir, LOCK_FILE));
212
+ return held && held.host === hostname() && alive(held.pid) ? held : undefined;
213
+ }
205
214
  function readLock(path) {
206
215
  try {
207
216
  return JSON.parse(readFileSync(path, 'utf8'));
@@ -161,11 +161,11 @@ export function memoryTools(opts) {
161
161
  properties: {
162
162
  ref: {
163
163
  type: 'string',
164
- description: 'a short name for a new node, used by edges in this call',
164
+ description: 'a short local name for a new node (e.g. "task_1", "file_1"), used by edges in this call. Use ref (not id) when creating new nodes.',
165
165
  },
166
166
  id: {
167
167
  type: 'string',
168
- description: 'to change an existing node instead of adding one',
168
+ description: 'the permanent id of an existing node to update in place (from search/load). Do not use for new nodes; use ref instead.',
169
169
  },
170
170
  kind: { type: 'string', enum: kinds, description: kindHelp },
171
171
  text: {
@@ -173,8 +173,25 @@ export function memoryTools(opts) {
173
173
  description: 'what to remember, in your own words',
174
174
  },
175
175
  file: {
176
- type: 'string',
177
- description: 'path of a file to keep a copy of, for kind "file"',
176
+ oneOf: [
177
+ {
178
+ type: 'string',
179
+ description: 'path of an existing file in the workspace to keep a copy of (e.g. "report.py" or "/workspace/report.py"), for kind "file"',
180
+ },
181
+ {
182
+ type: 'object',
183
+ properties: {
184
+ path: {
185
+ type: 'string',
186
+ description: 'path of an existing file in the workspace',
187
+ },
188
+ },
189
+ required: ['path'],
190
+ additionalProperties: false,
191
+ description: 'file object with path',
192
+ },
193
+ ],
194
+ description: 'path of an existing file in the workspace to keep a copy of (e.g. "report.py" or "/workspace/report.py"), for kind "file"',
178
195
  },
179
196
  ...audience,
180
197
  expected_revision: {
@@ -192,11 +209,11 @@ export function memoryTools(opts) {
192
209
  properties: {
193
210
  from: {
194
211
  type: 'string',
195
- description: 'a ref from this call, or an id',
212
+ description: 'a ref from this call, or an existing node id',
196
213
  },
197
214
  to: {
198
215
  type: 'string',
199
- description: 'a ref from this call, or an id',
216
+ description: 'a ref from this call, or an existing node id',
200
217
  },
201
218
  relation: {
202
219
  type: 'string',
@@ -216,15 +233,28 @@ export function memoryTools(opts) {
216
233
  const opId = memoryOpId(tc.state.runId, tc.callId);
217
234
  let nodes;
218
235
  try {
219
- nodes = args.nodes.map((n) => ({
220
- ref: n.ref,
221
- id: n.id,
222
- kind: n.kind,
223
- text: n.text,
224
- audience: n.audience,
225
- expectedRevision: n.expected_revision,
226
- file: n.file === undefined ? undefined : { source: source(tc, n.file) },
227
- }));
236
+ nodes = args.nodes.map((n) => {
237
+ let fileSpec;
238
+ if (n.file !== undefined) {
239
+ const rawPath = extractFilePath(n.file);
240
+ if (!rawPath.trim()) {
241
+ throw new MemoryError('file path is required', 'pass the path of the file in the workspace to remember, e.g. "report.py"');
242
+ }
243
+ fileSpec = {
244
+ source: source(tc, rawPath),
245
+ path: rawPath,
246
+ };
247
+ }
248
+ return {
249
+ ref: n.ref,
250
+ id: n.id,
251
+ kind: n.kind,
252
+ text: n.text,
253
+ audience: n.audience,
254
+ expectedRevision: n.expected_revision,
255
+ file: fileSpec,
256
+ };
257
+ });
228
258
  }
229
259
  catch (err) {
230
260
  return refusal(err);
@@ -322,11 +352,31 @@ function refusal(err) {
322
352
  }
323
353
  throw err;
324
354
  }
355
+ function extractFilePath(raw) {
356
+ if (typeof raw === 'string') {
357
+ return raw;
358
+ }
359
+ if (raw &&
360
+ typeof raw === 'object' &&
361
+ 'path' in raw &&
362
+ typeof raw.path === 'string') {
363
+ return raw.path;
364
+ }
365
+ return '';
366
+ }
325
367
  function source(tc, path) {
326
368
  if (!tc.services.resolveFile) {
327
369
  throw new MemoryError('this agent cannot remember files', 'commit the node without `file`, or give the agent a workspace');
328
370
  }
329
- return tc.services.resolveFile(path);
371
+ try {
372
+ return tc.services.resolveFile(path);
373
+ }
374
+ catch (err) {
375
+ if (err instanceof MemoryError) {
376
+ throw err;
377
+ }
378
+ throw new MemoryError(err instanceof Error ? err.message : String(err), 'remember a file the run actually wrote, by the path the file tools use');
379
+ }
330
380
  }
331
381
  function changed(index, ids) {
332
382
  return Object.values(ids).flatMap((id) => {
@@ -9,6 +9,8 @@ export declare class VectorBlock {
9
9
  get rows(): number;
10
10
  get ids(): readonly string[];
11
11
  has(id: string): boolean;
12
+ /** A copy, not a view: the row it came from moves the next time something is deleted. */
13
+ get(id: string): Float32Array | undefined;
12
14
  set(id: string, vector: ArrayLike<number>): void;
13
15
  remove(id: string): boolean;
14
16
  /** Exact kNN over the rows `allow` accepts. Ties break toward the earlier row. */
@@ -39,6 +39,13 @@ export class VectorBlock {
39
39
  has(id) {
40
40
  return this.#index.has(id);
41
41
  }
42
+ /** A copy, not a view: the row it came from moves the next time something is deleted. */
43
+ get(id) {
44
+ const row = this.#index.get(id);
45
+ return row === undefined
46
+ ? undefined
47
+ : this.#data.slice(row * this.#dims, (row + 1) * this.#dims);
48
+ }
42
49
  set(id, vector) {
43
50
  if (vector.length !== this.#dims) {
44
51
  throw new Error(`expected a ${this.#dims}-dimension vector, got ${vector.length}`);
@@ -169,6 +169,10 @@ declare const sandbox: z.ZodObject<{
169
169
  }>>;
170
170
  workdir: z.ZodOptional<z.ZodString>;
171
171
  timeout: z.ZodOptional<z.ZodInt>;
172
+ hardening: z.ZodOptional<z.ZodEnum<{
173
+ strict: "strict";
174
+ standard: "standard";
175
+ }>>;
172
176
  user: z.ZodOptional<z.ZodString>;
173
177
  persist: z.ZodOptional<z.ZodBoolean>;
174
178
  env: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -295,6 +299,10 @@ export declare const projectSchema: z.ZodObject<{
295
299
  }>>;
296
300
  workdir: z.ZodOptional<z.ZodString>;
297
301
  timeout: z.ZodOptional<z.ZodInt>;
302
+ hardening: z.ZodOptional<z.ZodEnum<{
303
+ strict: "strict";
304
+ standard: "standard";
305
+ }>>;
298
306
  user: z.ZodOptional<z.ZodString>;
299
307
  persist: z.ZodOptional<z.ZodBoolean>;
300
308
  env: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -355,6 +363,10 @@ export declare const projectSchema: z.ZodObject<{
355
363
  }>>;
356
364
  workdir: z.ZodOptional<z.ZodString>;
357
365
  timeout: z.ZodOptional<z.ZodInt>;
366
+ hardening: z.ZodOptional<z.ZodEnum<{
367
+ strict: "strict";
368
+ standard: "standard";
369
+ }>>;
358
370
  user: z.ZodOptional<z.ZodString>;
359
371
  persist: z.ZodOptional<z.ZodBoolean>;
360
372
  env: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -263,6 +263,20 @@ const sandbox = z
263
263
  workdir: z.string().min(1).optional(),
264
264
  /** seconds one command may take before it is killed */
265
265
  timeout: z.int().positive().optional(),
266
+ /**
267
+ * How much of the host the container is allowed to be.
268
+ *
269
+ * `standard` (the default) keeps the kernel capabilities and a
270
+ * writable root filesystem, so `apt` and `pip` work — which is what a
271
+ * project whose agents install their own dependencies needs.
272
+ *
273
+ * `strict` drops every capability, mounts the root filesystem
274
+ * read-only with an in-memory `noexec` `/tmp`, runs as an
275
+ * unprivileged uid and turns the network off unless this block asks
276
+ * for one. Nothing can be installed at run time, so the image has to
277
+ * carry everything the agent will need. It requires rootless podman.
278
+ */
279
+ hardening: z.enum(['standard', 'strict']).optional(),
266
280
  /** uid, name or `uid:gid`; unset means the image's own user */
267
281
  user: z.string().min(1).optional(),
268
282
  /** keep the container between sessions instead of removing it */