@evomap/evolver-core 2.0.0-beta.14 → 2.0.0-beta.15

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,274 @@
1
+ // Agent runtime trace instrumentation (Learning Ops slice 1).
2
+ // Aligns with evomap-hub Learning Ops data plane contract (schemaVersion `trace_event.v0` /
3
+ // `learning_packet.v0`): the runtime emits ordered TraceEvents over the agent-run lifecycle and
4
+ // assembles a LearningPacket DRAFT locally. Hub delivery goes through the LearningPacketSink port —
5
+ // core never hardcodes a hub endpoint (file/console/memory sinks let a run trace without hub access).
6
+ import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
7
+ import { dirname } from 'node:path';
8
+ export const TRACE_EVENT_SCHEMA = 'trace_event.v0';
9
+ export const LEARNING_PACKET_SCHEMA = 'learning_packet.v0';
10
+ /** Lifecycle vocabulary for slice 1. Kept flat + closed so hub-side eventType stays queryable. */
11
+ export const TRACE_EVENT_TYPES = [
12
+ 'run.started',
13
+ 'run.completed',
14
+ 'model.called',
15
+ 'tool.called',
16
+ 'tool.failed',
17
+ 'retry.attempted',
18
+ 'reflection.recorded',
19
+ 'intervention.received',
20
+ ];
21
+ export class InMemoryTraceSink {
22
+ events = [];
23
+ emit(event) { this.events.push(event); }
24
+ }
25
+ /** JSONL append sink for local inspection / offline replay. */
26
+ export class FileTraceSink {
27
+ path;
28
+ constructor(path) {
29
+ this.path = path;
30
+ }
31
+ emit(event) {
32
+ mkdirSync(dirname(this.path), { recursive: true });
33
+ appendFileSync(this.path, `${JSON.stringify(event)}\n`, 'utf8');
34
+ }
35
+ }
36
+ export class ConsoleTraceSink {
37
+ log;
38
+ constructor(log = (line) => console.error(line)) {
39
+ this.log = log;
40
+ }
41
+ emit(event) { this.log(`[trace] ${event.sequence} ${event.eventType} ${JSON.stringify(event.payload)}`); }
42
+ }
43
+ /**
44
+ * Per-run trace recorder: the single hook surface the runtime calls at lifecycle points.
45
+ * Sequence numbers are assigned here (monotonic per run), so downstream ordering never depends on
46
+ * sink latency or clock resolution.
47
+ */
48
+ export class AgentRunTraceRecorder {
49
+ opts;
50
+ sequence = 0;
51
+ recorded = [];
52
+ constructor(opts) {
53
+ this.opts = opts;
54
+ }
55
+ get events() { return this.recorded; }
56
+ runStarted(input = {}) {
57
+ return this.record('run.started', {
58
+ ...(input.taskSummary !== undefined ? { taskSummary: input.taskSummary } : {}),
59
+ ...(input.signals !== undefined ? { signals: [...input.signals] } : {}),
60
+ ...(input.geneId !== undefined ? { geneId: input.geneId } : {}),
61
+ }, input.metadata);
62
+ }
63
+ modelCalled(input = {}) {
64
+ return this.record('model.called', {
65
+ ...(input.provider !== undefined ? { provider: input.provider } : {}),
66
+ ...(input.model !== undefined ? { model: input.model } : {}),
67
+ ...(input.requestId !== undefined ? { requestId: input.requestId } : {}),
68
+ ...(input.latencyMs !== undefined ? { latencyMs: input.latencyMs } : {}),
69
+ ...(input.usage !== undefined ? { usage: input.usage } : {}),
70
+ ...(input.stopReason !== undefined ? { stopReason: input.stopReason } : {}),
71
+ });
72
+ }
73
+ toolCalled(input) {
74
+ return this.record('tool.called', {
75
+ toolName: input.toolName,
76
+ ...(input.callId !== undefined ? { callId: input.callId } : {}),
77
+ ...(input.durationMs !== undefined ? { durationMs: input.durationMs } : {}),
78
+ }, input.metadata);
79
+ }
80
+ toolFailed(input) {
81
+ return this.record('tool.failed', {
82
+ toolName: input.toolName,
83
+ ...(input.callId !== undefined ? { callId: input.callId } : {}),
84
+ error: input.error,
85
+ });
86
+ }
87
+ retryAttempted(input) {
88
+ return this.record('retry.attempted', {
89
+ attempt: input.attempt,
90
+ ...(input.reason !== undefined ? { reason: input.reason } : {}),
91
+ ...(input.target !== undefined ? { target: input.target } : {}),
92
+ });
93
+ }
94
+ reflectionRecorded(input) {
95
+ return this.record('reflection.recorded', {
96
+ outcome: input.outcome,
97
+ ...(input.action !== undefined ? { action: input.action } : {}),
98
+ ...(input.summary !== undefined ? { summary: input.summary } : {}),
99
+ });
100
+ }
101
+ interventionReceived(input) {
102
+ return this.record('intervention.received', {
103
+ kind: input.kind,
104
+ ...(input.actorId !== undefined ? { actorId: input.actorId } : {}),
105
+ ...(input.detail !== undefined ? { detail: input.detail } : {}),
106
+ });
107
+ }
108
+ runCompleted(input) {
109
+ return this.record('run.completed', {
110
+ status: input.status,
111
+ ...(input.score !== undefined ? { score: input.score } : {}),
112
+ ...(input.reason !== undefined ? { reason: input.reason } : {}),
113
+ ...(input.producedValue !== undefined ? { producedValue: input.producedValue } : {}),
114
+ ...(input.failureKind !== undefined ? { failureKind: input.failureKind } : {}),
115
+ });
116
+ }
117
+ /** Fold one normalized llm_turn (trace/trajectory.ts) into model.called + tool.called/tool.failed events. */
118
+ recordLlmTurn(turn) {
119
+ const out = [this.modelCalled({
120
+ ...(turn.provider !== null ? { provider: turn.provider } : {}),
121
+ ...(turn.chosen_model !== null ? { model: turn.chosen_model } : {}),
122
+ ...(turn.request_id !== null ? { requestId: turn.request_id } : {}),
123
+ ...(turn.latency_ms !== null ? { latencyMs: turn.latency_ms } : {}),
124
+ ...(turn.usage !== undefined ? { usage: { ...turn.usage } } : {}),
125
+ ...(typeof turn.stop_reason === 'string' ? { stopReason: turn.stop_reason } : {}),
126
+ })];
127
+ if (Array.isArray(turn.tool_calls)) {
128
+ for (const call of turn.tool_calls) {
129
+ const rec = call;
130
+ const toolName = typeof rec.name === 'string' && rec.name.length > 0 ? rec.name : 'unknown_tool';
131
+ const callId = typeof rec.id === 'string' ? rec.id : undefined;
132
+ out.push(typeof rec.error === 'string' && rec.error.length > 0
133
+ ? this.toolFailed({ toolName, ...(callId !== undefined ? { callId } : {}), error: rec.error })
134
+ : this.toolCalled({ toolName, ...(callId !== undefined ? { callId } : {}) }));
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+ record(eventType, payload, metadata) {
140
+ this.sequence += 1;
141
+ const at = (this.opts.now ?? Date.now)();
142
+ const event = {
143
+ schemaVersion: TRACE_EVENT_SCHEMA,
144
+ eventId: (this.opts.eventIdFactory ?? ((seq) => `${this.opts.runId}-${String(seq).padStart(4, '0')}`))(this.sequence),
145
+ eventType,
146
+ traceId: this.opts.runId,
147
+ ...(this.opts.sessionId !== undefined ? { sessionId: this.opts.sessionId } : {}),
148
+ ...(this.opts.taskId !== undefined ? { taskId: this.opts.taskId } : {}),
149
+ sequence: this.sequence,
150
+ occurredAt: new Date(at).toISOString(),
151
+ payload,
152
+ metadata: metadata ?? {},
153
+ };
154
+ this.recorded.push(event);
155
+ this.opts.sink?.emit(event);
156
+ return event;
157
+ }
158
+ }
159
+ /* ── Root-event bridge: existing cycle lifecycle → trace events, via the sanctioned ObserverBus ── */
160
+ const BRIDGED_EVENT_TYPES = [
161
+ 'cycle.started', 'cycle.solidified', 'cycle.failed',
162
+ 'reflection.recorded',
163
+ 'actor.human.nudge', 'actor.human.intervene', 'actor.human.teach',
164
+ 'actor.human.review.approve', 'actor.human.review.reject',
165
+ ];
166
+ function asRecord(value) {
167
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
168
+ }
169
+ /**
170
+ * Bypass-side bridge (never blocks the write path): maps the engine's existing root events onto the
171
+ * run's TraceEvent stream, so CycleEngine needs no code change for run start/completion, reflection,
172
+ * or human intervention coverage.
173
+ */
174
+ export function learningTraceObserver(deps) {
175
+ const meta = {
176
+ name: 'learning_trace',
177
+ eventTypes: BRIDGED_EVENT_TYPES,
178
+ idempotent: true,
179
+ timeoutMs: deps.timeoutMs ?? 2_000,
180
+ };
181
+ return {
182
+ meta,
183
+ handle(event) {
184
+ const payload = asRecord(event.payload) ?? {};
185
+ if (event.type === 'cycle.started') {
186
+ deps.recorder.runStarted({ metadata: { cycleId: payload['cycleId'] ?? null } });
187
+ return;
188
+ }
189
+ if (event.type === 'cycle.solidified' || event.type === 'cycle.failed') {
190
+ const outcome = asRecord(payload['outcome']);
191
+ const status = event.type === 'cycle.solidified' ? 'success' : 'failed';
192
+ const score = typeof outcome?.['score'] === 'number' ? outcome['score'] : undefined;
193
+ const reason = typeof payload['error'] === 'string'
194
+ ? payload['error']
195
+ : typeof payload['reason'] === 'string' ? payload['reason'] : undefined;
196
+ deps.recorder.runCompleted({
197
+ status,
198
+ ...(score !== undefined ? { score } : {}),
199
+ ...(reason !== undefined ? { reason } : {}),
200
+ ...(typeof payload['producedValue'] === 'boolean' ? { producedValue: payload['producedValue'] } : {}),
201
+ ...(typeof payload['failureKind'] === 'string' ? { failureKind: payload['failureKind'] } : {}),
202
+ });
203
+ return;
204
+ }
205
+ if (event.type === 'reflection.recorded') {
206
+ deps.recorder.reflectionRecorded({
207
+ outcome: typeof payload['outcome'] === 'string' ? payload['outcome'] : 'unknown',
208
+ ...(typeof payload['action'] === 'string' ? { action: payload['action'] } : {}),
209
+ ...(typeof payload['summary'] === 'string' ? { summary: payload['summary'] } : {}),
210
+ });
211
+ return;
212
+ }
213
+ // actor.human.*: host/human intervention audit trail.
214
+ deps.recorder.interventionReceived({
215
+ kind: event.type.replace(/^actor\.human\./, ''),
216
+ ...(event.actor.id !== undefined ? { actorId: event.actor.id } : {}),
217
+ ...(typeof payload['detail'] === 'string' ? { detail: payload['detail'] } : {}),
218
+ });
219
+ },
220
+ };
221
+ }
222
+ export function buildLearningPacketDraft(recorder, input) {
223
+ const events = [...recorder.events].sort((a, b) => a.sequence - b.sequence);
224
+ const first = events[0];
225
+ const completed = [...events].reverse().find((e) => e.eventType === 'run.completed');
226
+ const completedStatus = completed?.payload['status'];
227
+ const failureKind = completed?.payload['failureKind'];
228
+ const started = events.find((e) => e.eventType === 'run.started');
229
+ const startedSummary = started?.payload['taskSummary'];
230
+ return {
231
+ schemaVersion: LEARNING_PACKET_SCHEMA,
232
+ status: 'draft',
233
+ source: { repo: input.sourceRepo, run: first?.traceId ?? 'unknown-run', type: 'agent_run', id: first?.traceId ?? 'unknown-run' },
234
+ task: {
235
+ taskId: first?.taskId ?? null,
236
+ summary: input.taskSummary ?? (typeof startedSummary === 'string' ? startedSummary : null),
237
+ signals: [...(input.signals ?? [])],
238
+ },
239
+ context: {
240
+ sessionId: first?.sessionId ?? null,
241
+ traceId: first?.traceId ?? 'unknown-run',
242
+ environment: input.environment ?? {},
243
+ },
244
+ trajectory: events,
245
+ artifacts: { placeholder: true, items: [] },
246
+ evaluation: {
247
+ placeholder: true,
248
+ outcomeStatus: completedStatus === 'success' ? 'success' : completedStatus === 'failed' ? 'failed' : 'unknown',
249
+ verifier: null,
250
+ failureCategory: typeof failureKind === 'string' ? failureKind : null,
251
+ },
252
+ governance: { placeholder: true, redactionStatus: 'metadata_only', consentStatus: 'unknown', trainingEligible: false, retentionPolicy: 'standard' },
253
+ };
254
+ }
255
+ export class InMemoryLearningPacketSink {
256
+ drafts = [];
257
+ async submit(draft) {
258
+ this.drafts.push(draft);
259
+ return { accepted: true };
260
+ }
261
+ }
262
+ /** Writes one JSON file per run — the offline/no-hub path (and a manual-inspection artifact). */
263
+ export class FileLearningPacketSink {
264
+ dir;
265
+ constructor(dir) {
266
+ this.dir = dir;
267
+ }
268
+ async submit(draft) {
269
+ mkdirSync(this.dir, { recursive: true });
270
+ const path = `${this.dir}/${draft.source.run}.learning-packet.json`;
271
+ writeFileSync(path, `${JSON.stringify(draft, null, 2)}\n`, 'utf8');
272
+ return { accepted: true, reason: path };
273
+ }
274
+ }
@@ -19,13 +19,41 @@ export interface SandboxOptions {
19
19
  * unprivileged mount namespaces are unavailable. Binaries live outside these dirs, so the command still runs.
20
20
  */
21
21
  hideHomeSecrets?: boolean;
22
+ /** Remount the inherited filesystem read-only inside the mount namespace, leaving only an isolated /tmp writable. */
23
+ readOnlyFilesystem?: boolean;
24
+ /** Host scratch directory exposed as the sandbox's only writable filesystem. */
25
+ writableTmpDir?: string;
26
+ /** Read-only checkout root remounted at its original absolute path after HOME and /tmp are hidden. */
27
+ readOnlyRoot?: string;
28
+ /** Put the full command tree in a bounded cgroup v2. Required by verified Skill execution. */
29
+ resourceLimits?: boolean;
22
30
  /** Injected unshare-availability probe (test seam). Default: a real `unshare -r -m -n true` check (cached). */
23
31
  unshareCheck?: () => boolean;
32
+ /** Injected cgroup allocator (test seam). */
33
+ resourceGroupFactory?: () => SandboxResourceGroup | null;
24
34
  }
35
+ export interface SandboxResourceGroup {
36
+ procsFile: string;
37
+ cleanup: () => void;
38
+ }
39
+ /** Configure an already-created cgroup. Kept separate so limit values are testable without resource exhaustion. */
40
+ export declare function configureSandboxResourceGroup(path: string): boolean;
41
+ /** Allocate a delegated cgroup v2 for one validation command. Returns null unless every limit is enforceable. */
42
+ export declare function createSandboxResourceGroup(): SandboxResourceGroup | null;
43
+ /** Join the cgroup before exec, so attacker-controlled code never runs outside the aggregate resource budget. */
44
+ export declare function resourceLimitedCommand(cmd: string, args: readonly string[], procsFile: string): {
45
+ cmd: string;
46
+ args: string[];
47
+ };
48
+ export declare function sandboxResourceLimitsAvailable(): boolean;
25
49
  /** Wrap an (executable,args) in unprivileged namespaces per the requested isolation (pure — testable). */
26
50
  export declare function isolationCommand(bin: string, args: readonly string[], opts: {
27
51
  noNetwork?: boolean;
28
52
  hideHomeSecrets?: boolean;
53
+ readOnlyFilesystem?: boolean;
54
+ writableTmpDir?: string;
55
+ readOnlyRoot?: string;
56
+ cwd?: string;
29
57
  }): {
30
58
  cmd: string;
31
59
  args: string[];
@@ -5,41 +5,205 @@
5
5
  // module hardens EXECUTION: blocked node eval-flags, shell-metachar rejection, a fresh wiped temp cwd,
6
6
  // a scrubbed env (no secrets leak in), and a SIGKILL timeout. Output is folded + truncated.
7
7
  import { spawn, spawnSync } from 'node:child_process';
8
- import { mkdtempSync, rmSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
- import { join } from 'node:path';
10
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
11
11
  import { isNodeExecutable, nodeFlagViolation, SHELL_METACHARS } from './validation.js';
12
12
  const DEFAULT_TIMEOUT_MS = 60_000;
13
13
  const MAX_TIMEOUT_MS = 120_000;
14
14
  const MAX_OUTPUT_CHARS = 4000;
15
+ const CGROUP_ROOT = '/sys/fs/cgroup';
16
+ const TRUSTED_SYSTEM_PATH = '/usr/sbin:/usr/bin:/sbin:/bin';
17
+ const SYSTEM_SH = '/bin/sh';
18
+ const SYSTEM_UNSHARE = ['/usr/bin/unshare', '/bin/unshare'].find((path) => existsSync(path)) ?? '/usr/bin/unshare';
19
+ const RESOURCE_LIMITS = {
20
+ memoryBytes: 1024 * 1024 * 1024,
21
+ processes: 64,
22
+ cpuQuota: '100000 100000',
23
+ scratchBytes: 256 * 1024 * 1024,
24
+ };
15
25
  // Env scrub: only these keys reach the child, so node-secrets/tokens in the parent env cannot leak into validation.
16
26
  const DEFAULT_ENV_ALLOW = ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'SYSTEMROOT'];
17
27
  /** Credential dirs hidden under $HOME when hideHomeSecrets is set (dirs only — never where binaries live). */
18
28
  const SECRET_HOME_DIRS = ['.evomap', '.ssh', '.aws', '.gnupg', '.docker', '.kube'];
19
29
  // Fixed setup run inside the mount ns: tmpfs over each existing secret dir, then exec the command. The command
20
30
  // is passed as POSITIONAL args (`exec "$@"`), never interpolated into the script — no shell-injection vector.
21
- const HIDE_SECRETS_SCRIPT = `for d in ${SECRET_HOME_DIRS.map((d) => `"$HOME/${d}"`).join(' ')}; do [ -d "$d" ] && mount -t tmpfs none "$d" 2>/dev/null; done; exec "$@"`;
31
+ const HIDE_SECRETS_SETUP = `PATH=${TRUSTED_SYSTEM_PATH}; export PATH; for d in ${SECRET_HOME_DIRS.map((d) => `"$HOME/${d}"`).join(' ')}; do if [ -d "$d" ]; then mount -t tmpfs none "$d" 2>/dev/null || exit 126; fi; done`;
32
+ const READ_ONLY_FILESYSTEM_SETUP = [
33
+ `PATH=${TRUSTED_SYSTEM_PATH}; export PATH`,
34
+ 'session_tmp="$1"; shift',
35
+ 'validation_root="$1"; shift',
36
+ 'validation_cwd="$1"; shift',
37
+ 'host_bin="$1"; shift',
38
+ '[ -n "$HOME" ] && [ "$HOME" != / ] && [ -d "$HOME" ] || exit 126',
39
+ 'case "$validation_cwd/" in "$validation_root/"*) ;; *) exit 126 ;; esac',
40
+ 'case "$HOME/" in "$validation_root/"*) exit 126 ;; esac',
41
+ `mount -t tmpfs -o size=${RESOURCE_LIMITS.scratchBytes},nr_inodes=65536 none "$session_tmp" 2>/dev/null || exit 126`,
42
+ 'mkdir -p "$session_tmp/source" "$session_tmp/home" || exit 126',
43
+ 'mkdir -p "$session_tmp/dev" || exit 126',
44
+ ': > "$session_tmp/node" || exit 126',
45
+ 'mount --bind "$validation_root" "$session_tmp/source" 2>/dev/null || exit 126',
46
+ 'mount --bind "$host_bin" "$session_tmp/node" 2>/dev/null || exit 126',
47
+ 'for device in null zero random urandom; do : > "$session_tmp/dev/$device" || exit 126; mount --bind "/dev/$device" "$session_tmp/dev/$device" 2>/dev/null || exit 126; done',
48
+ 'mount -t tmpfs none "$HOME" 2>/dev/null || exit 126',
49
+ '[ ! -d /run ] || mount -t tmpfs none /run 2>/dev/null || exit 126',
50
+ 'mount -t tmpfs none /tmp 2>/dev/null || exit 126',
51
+ 'mkdir -p "$validation_root" || exit 126',
52
+ 'mount --bind "$session_tmp/source" "$validation_root" 2>/dev/null || exit 126',
53
+ 'mounts="$(findmnt -rn -o TARGET -R /)" || exit 126',
54
+ '[ -n "$mounts" ] || exit 126',
55
+ 'printf "%s\\n" "$mounts" | while IFS= read -r target; do mount -o remount,bind,ro "$target" 2>/dev/null || exit 126; done || exit 126',
56
+ 'mount -o remount,bind,rw,exec "$session_tmp" 2>/dev/null || exit 126',
57
+ 'mount -t tmpfs -o mode=755,size=65536,nr_inodes=64 none /dev 2>/dev/null || exit 126',
58
+ 'for device in null zero random urandom; do : > "/dev/$device" || exit 126; mount --bind "$session_tmp/dev/$device" "/dev/$device" 2>/dev/null || exit 126; done',
59
+ 'mount -o remount,ro /dev 2>/dev/null || exit 126',
60
+ 'mount --rbind "$session_tmp" /tmp 2>/dev/null || exit 126',
61
+ 'cd "$validation_cwd" || exit 126',
62
+ 'export HOME=/tmp/home TMPDIR=/tmp TMP=/tmp TEMP=/tmp',
63
+ ].join('; ');
64
+ const DROP_NAMESPACE_PRIVILEGES = [
65
+ 'supervisor_pid=$$',
66
+ 'exec 3<&0',
67
+ 'setpriv --no-new-privs --securebits=+noroot,+noroot_locked --bounding-set=-all --inh-caps=-all --ambient-caps=-all -- /tmp/node "$@" </dev/null 3<&- & workload_pid=$!',
68
+ // The pipe is owned by the parent Node process. Kernel EOF is therefore a reliable parent-death signal,
69
+ // including SIGKILL, while unshare --kill-child propagates supervisor death through the PID namespace.
70
+ '(IFS= read -r _ <&3 || kill -KILL "$supervisor_pid") & watchdog_pid=$!',
71
+ 'exec 3<&-',
72
+ 'wait "$workload_pid"; status=$?',
73
+ 'kill "$watchdog_pid" 2>/dev/null || true',
74
+ 'wait "$watchdog_pid" 2>/dev/null || true',
75
+ 'exit "$status"',
76
+ ].join('; ');
77
+ function pathIsWithin(root, target) {
78
+ const rel = relative(root, target);
79
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
80
+ }
81
+ function currentCgroupPath() {
82
+ const entry = readFileSync('/proc/self/cgroup', 'utf8')
83
+ .split(/\r?\n/)
84
+ .find((line) => line.startsWith('0::'));
85
+ if (!entry)
86
+ return null;
87
+ const relativePath = entry.slice(3).replace(/^\/+/, '');
88
+ const path = resolve(CGROUP_ROOT, relativePath);
89
+ return pathIsWithin(CGROUP_ROOT, path) ? path : null;
90
+ }
91
+ /** Configure an already-created cgroup. Kept separate so limit values are testable without resource exhaustion. */
92
+ export function configureSandboxResourceGroup(path) {
93
+ try {
94
+ const required = ['cgroup.procs', 'cgroup.kill', 'memory.max', 'memory.swap.max', 'memory.oom.group', 'pids.max', 'cpu.max'];
95
+ if (!required.every((name) => existsSync(join(path, name))))
96
+ return false;
97
+ writeFileSync(join(path, 'memory.max'), String(RESOURCE_LIMITS.memoryBytes));
98
+ writeFileSync(join(path, 'memory.swap.max'), '0');
99
+ writeFileSync(join(path, 'memory.oom.group'), '1');
100
+ writeFileSync(join(path, 'pids.max'), String(RESOURCE_LIMITS.processes));
101
+ writeFileSync(join(path, 'cpu.max'), RESOURCE_LIMITS.cpuQuota);
102
+ return true;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ /** Allocate a delegated cgroup v2 for one validation command. Returns null unless every limit is enforceable. */
109
+ export function createSandboxResourceGroup() {
110
+ if (process.platform !== 'linux' || !existsSync(join(CGROUP_ROOT, 'cgroup.controllers')))
111
+ return null;
112
+ let path = null;
113
+ try {
114
+ const parent = currentCgroupPath();
115
+ if (!parent)
116
+ return null;
117
+ path = join(parent, `evolver-validation-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
118
+ mkdirSync(path, { mode: 0o700 });
119
+ if (!configureSandboxResourceGroup(path))
120
+ throw new Error('required cgroup v2 controllers are not delegated');
121
+ let cleaned = false;
122
+ return {
123
+ procsFile: join(path, 'cgroup.procs'),
124
+ cleanup: () => {
125
+ if (cleaned)
126
+ return;
127
+ cleaned = true;
128
+ try {
129
+ writeFileSync(join(path, 'cgroup.kill'), '1');
130
+ }
131
+ catch { /* already empty or removed */ }
132
+ try {
133
+ rmdirSync(path);
134
+ }
135
+ catch { /* kernel may release the empty group asynchronously */ }
136
+ },
137
+ };
138
+ }
139
+ catch {
140
+ if (path) {
141
+ try {
142
+ writeFileSync(join(path, 'cgroup.kill'), '1');
143
+ }
144
+ catch { /* best effort */ }
145
+ try {
146
+ rmdirSync(path);
147
+ }
148
+ catch { /* best effort */ }
149
+ }
150
+ return null;
151
+ }
152
+ }
153
+ /** Join the cgroup before exec, so attacker-controlled code never runs outside the aggregate resource budget. */
154
+ export function resourceLimitedCommand(cmd, args, procsFile) {
155
+ const setup = 'printf "%s" "$$" > "$1" || exit 126; shift; exec "$@"';
156
+ return { cmd: SYSTEM_SH, args: ['-c', setup, 'sh', procsFile, cmd, ...args] };
157
+ }
158
+ export function sandboxResourceLimitsAvailable() {
159
+ const group = createSandboxResourceGroup();
160
+ if (!group)
161
+ return false;
162
+ group.cleanup();
163
+ return true;
164
+ }
22
165
  /** Wrap an (executable,args) in unprivileged namespaces per the requested isolation (pure — testable). */
23
166
  export function isolationCommand(bin, args, opts) {
167
+ if (opts.readOnlyFilesystem && !opts.writableTmpDir) {
168
+ throw new Error('read-only filesystem isolation requires writableTmpDir');
169
+ }
170
+ if (opts.readOnlyFilesystem && (!opts.readOnlyRoot || !opts.cwd)) {
171
+ throw new Error('read-only filesystem isolation requires readOnlyRoot and cwd');
172
+ }
24
173
  const flags = ['-r'];
25
- if (opts.hideHomeSecrets)
174
+ if (opts.hideHomeSecrets || opts.readOnlyFilesystem)
26
175
  flags.push('-m');
27
176
  if (opts.noNetwork)
28
177
  flags.push('-n');
29
- if (!opts.noNetwork && !opts.hideHomeSecrets)
178
+ if (opts.readOnlyFilesystem)
179
+ flags.push('-p', '-i', '-u');
180
+ if (!opts.noNetwork && !opts.hideHomeSecrets && !opts.readOnlyFilesystem)
30
181
  return { cmd: bin, args: [...args] };
31
- const base = [...flags, '--fork', '--kill-child', '--'];
182
+ const base = [...flags, '--fork', '--kill-child', ...(opts.readOnlyFilesystem ? ['--mount-proc'] : []), '--'];
183
+ const setup = [
184
+ ...(opts.hideHomeSecrets && !opts.readOnlyFilesystem ? [HIDE_SECRETS_SETUP] : []),
185
+ ...(opts.readOnlyFilesystem ? [READ_ONLY_FILESYSTEM_SETUP] : []),
186
+ opts.readOnlyFilesystem ? DROP_NAMESPACE_PRIVILEGES : 'exec "$@"',
187
+ ].join('; ');
32
188
  // Mount setup needs a launcher (sh runs the FIXED script then exec "$@"); net-only needs no shell.
33
- return opts.hideHomeSecrets
34
- ? { cmd: 'unshare', args: [...base, 'sh', '-c', HIDE_SECRETS_SCRIPT, 'sh', bin, ...args] }
35
- : { cmd: 'unshare', args: [...base, bin, ...args] };
189
+ return opts.hideHomeSecrets || opts.readOnlyFilesystem
190
+ ? {
191
+ cmd: SYSTEM_UNSHARE,
192
+ args: [...base, SYSTEM_SH, '-c', setup, 'sh', ...(opts.readOnlyFilesystem
193
+ ? [opts.writableTmpDir, opts.readOnlyRoot, opts.cwd]
194
+ : []), bin, ...args],
195
+ }
196
+ : { cmd: SYSTEM_UNSHARE, args: [...base, bin, ...args] };
36
197
  }
37
198
  let unshareCache;
38
199
  /** Whether unprivileged user+mount+net namespaces (`unshare -r -m -n`) work here (cached) — covers both isolation modes. */
39
200
  export function unshareNetAvailable() {
40
201
  if (unshareCache === undefined) {
41
202
  try {
42
- unshareCache = spawnSync('unshare', ['-r', '-m', '-n', 'true'], { timeout: 5000 }).status === 0;
203
+ unshareCache = spawnSync(SYSTEM_UNSHARE, ['-r', '-m', '-n', 'true'], {
204
+ env: { PATH: TRUSTED_SYSTEM_PATH },
205
+ timeout: 5000,
206
+ }).status === 0;
43
207
  }
44
208
  catch {
45
209
  unshareCache = false;
@@ -78,28 +242,63 @@ export function makeSandboxRunner(opts = {}) {
78
242
  if (badFlag)
79
243
  return deny(`rejected: blocked node flag ${badFlag}`);
80
244
  // Isolation (#26): fail-safe — if we can't actually create the namespaces, refuse rather than run un-isolated.
81
- if ((opts.noNetwork || opts.hideHomeSecrets) && !(opts.unshareCheck ?? unshareNetAvailable)()) {
82
- return deny('rejected: isolation (noNetwork/hideHomeSecrets) requested but unprivileged namespaces (unshare -r -m -n) are unavailable here');
245
+ if ((opts.noNetwork || opts.hideHomeSecrets || opts.readOnlyFilesystem) && !(opts.unshareCheck ?? unshareNetAvailable)()) {
246
+ return deny('rejected: requested namespace isolation is unavailable');
83
247
  }
248
+ const resourceGroup = opts.resourceLimits
249
+ ? (opts.resourceGroupFactory ?? createSandboxResourceGroup)()
250
+ : null;
251
+ if (opts.resourceLimits && !resourceGroup)
252
+ return deny('rejected: cgroup v2 resource limits are unavailable');
84
253
  const ownTemp = !opts.cwd;
85
254
  let cwd;
86
255
  try {
87
256
  cwd = opts.cwd ?? mkdtempSync(join(tmpdir(), 'evo-sbx-'));
88
257
  }
89
258
  catch (e) {
259
+ resourceGroup?.cleanup();
90
260
  return deny(`temp dir failed: ${e instanceof Error ? e.message : 'err'}`, 1);
91
261
  }
92
- const cleanup = () => { if (ownTemp) {
93
- try {
94
- rmSync(cwd, { recursive: true, force: true });
262
+ let cleaned = false;
263
+ const cleanup = () => {
264
+ if (cleaned)
265
+ return;
266
+ cleaned = true;
267
+ resourceGroup?.cleanup();
268
+ if (ownTemp) {
269
+ try {
270
+ rmSync(cwd, { recursive: true, force: true });
271
+ }
272
+ catch { /* best effort */ }
95
273
  }
96
- catch { /* best effort */ }
97
- } };
274
+ };
98
275
  // Resolve 'node' to the running binary so spawn(shell:false) works on Windows too (where bare 'node' would ENOENT).
99
276
  const bin = isNodeExecutable(executable) ? process.execPath : executable;
100
277
  try {
101
- const { cmd: spawnCmd, args: spawnArgs } = isolationCommand(bin, args, { noNetwork: opts.noNetwork, hideHomeSecrets: opts.hideHomeSecrets });
102
- const child = spawn(spawnCmd, spawnArgs, { shell: false, cwd, env: scrubEnv(envAllow), timeout, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'pipe'] });
278
+ const { cmd: spawnCmd, args: spawnArgs } = isolationCommand(bin, args, {
279
+ noNetwork: opts.noNetwork,
280
+ hideHomeSecrets: opts.hideHomeSecrets,
281
+ readOnlyFilesystem: opts.readOnlyFilesystem,
282
+ writableTmpDir: opts.writableTmpDir,
283
+ readOnlyRoot: opts.readOnlyRoot ?? cwd,
284
+ cwd,
285
+ });
286
+ const env = scrubEnv(envAllow);
287
+ if (opts.noNetwork || opts.hideHomeSecrets || opts.readOnlyFilesystem || opts.resourceLimits) {
288
+ env['PATH'] = TRUSTED_SYSTEM_PATH;
289
+ }
290
+ if (opts.readOnlyFilesystem) {
291
+ Object.assign(env, { TMPDIR: '/tmp', TMP: '/tmp', TEMP: '/tmp' });
292
+ }
293
+ const limited = resourceGroup ? resourceLimitedCommand(spawnCmd, spawnArgs, resourceGroup.procsFile) : { cmd: spawnCmd, args: spawnArgs };
294
+ const child = spawn(limited.cmd, limited.args, {
295
+ shell: false,
296
+ cwd,
297
+ env,
298
+ timeout,
299
+ killSignal: 'SIGKILL',
300
+ stdio: [opts.readOnlyFilesystem ? 'pipe' : 'ignore', 'pipe', 'pipe'],
301
+ });
103
302
  let out = '';
104
303
  const cap = (d) => { if (out.length < MAX_OUTPUT_CHARS)
105
304
  out += String(d); };
@@ -1,3 +1,4 @@
1
+ import { type SandboxResourceGroup } from './sandboxRunner.js';
1
2
  import { type ValidationResult } from './validation.js';
2
3
  export interface SandboxedValidationSkippedCommand {
3
4
  cmd: string;
@@ -19,11 +20,19 @@ export interface SandboxedValidationResult {
19
20
  */
20
21
  isolated: boolean;
21
22
  }
23
+ export declare function readOnlyFilesystemIsolationAvailable(): boolean;
24
+ export declare function readOnlyIsolationAvailable(): boolean;
22
25
  export interface SandboxedValidationOptions {
23
26
  /** Per-command timeout (ms), forwarded to the sandbox runner. */
24
27
  timeoutMs?: number;
25
28
  /** Test seam: override the unprivileged-namespace availability probe. */
26
29
  unshareCheck?: () => boolean;
30
+ /** Refuse before spawning when network/home namespace isolation is unavailable. */
31
+ requireIsolation?: boolean;
32
+ /** Checkout root to preserve read-only at its original absolute path. */
33
+ readOnlyRoot?: string;
34
+ /** Injected cgroup allocator (test seam). */
35
+ resourceGroupFactory?: () => SandboxResourceGroup | null;
27
36
  }
28
37
  /**
29
38
  * Run validation commands in the hardened sandbox. Isolation (no-network + hidden home secrets) is requested only