lyra-core 0.1.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.
Files changed (131) hide show
  1. package/README.md +24 -0
  2. package/package.json +65 -0
  3. package/src/brain/compaction.test.ts +156 -0
  4. package/src/brain/compaction.ts +131 -0
  5. package/src/brain/demo-endpoint.ts +13 -0
  6. package/src/brain/frozen-prefix.test.ts +235 -0
  7. package/src/brain/frozen-prefix.ts +320 -0
  8. package/src/brain/history-persist.test.ts +129 -0
  9. package/src/brain/history-persist.ts +154 -0
  10. package/src/brain/index.ts +44 -0
  11. package/src/brain/openai-brain.test.ts +61 -0
  12. package/src/brain/openai-brain.ts +544 -0
  13. package/src/brain/sanitize.test.ts +27 -0
  14. package/src/brain/sanitize.ts +23 -0
  15. package/src/brain/stub.ts +20 -0
  16. package/src/brain/types.ts +129 -0
  17. package/src/chain.ts +35 -0
  18. package/src/claude-plugins/discovery.test.ts +71 -0
  19. package/src/claude-plugins/discovery.ts +152 -0
  20. package/src/claude-plugins/index.ts +6 -0
  21. package/src/claude-plugins/types.ts +38 -0
  22. package/src/commands/index.ts +16 -0
  23. package/src/commands/registry.test.ts +186 -0
  24. package/src/commands/registry.ts +255 -0
  25. package/src/config.ts +201 -0
  26. package/src/economy/index.ts +6 -0
  27. package/src/events/index.ts +4 -0
  28. package/src/events/listeners.ts +37 -0
  29. package/src/events/queue.test.ts +43 -0
  30. package/src/events/queue.ts +63 -0
  31. package/src/events/router.ts +42 -0
  32. package/src/events/types.ts +28 -0
  33. package/src/format.ts +13 -0
  34. package/src/index.test.ts +6 -0
  35. package/src/index.ts +314 -0
  36. package/src/locks.test.ts +259 -0
  37. package/src/locks.ts +233 -0
  38. package/src/mcp/discovery.test.ts +97 -0
  39. package/src/mcp/discovery.ts +150 -0
  40. package/src/mcp/index.ts +10 -0
  41. package/src/mcp/manager.test.ts +97 -0
  42. package/src/mcp/manager.ts +110 -0
  43. package/src/mcp/stdio-client.ts +154 -0
  44. package/src/mcp/types.ts +44 -0
  45. package/src/memory/edit.test.ts +41 -0
  46. package/src/memory/edit.ts +53 -0
  47. package/src/memory/encryption.test.ts +68 -0
  48. package/src/memory/encryption.ts +94 -0
  49. package/src/memory/fs-util.ts +15 -0
  50. package/src/memory/index-file.ts +74 -0
  51. package/src/memory/index-sync.test.ts +81 -0
  52. package/src/memory/index-sync.ts +99 -0
  53. package/src/memory/index.ts +58 -0
  54. package/src/memory/list-tool.ts +105 -0
  55. package/src/memory/pack-blob.test.ts +90 -0
  56. package/src/memory/pack-blob.ts +120 -0
  57. package/src/memory/pack-gather.test.ts +110 -0
  58. package/src/memory/pack-gather.ts +112 -0
  59. package/src/memory/parser.test.ts +29 -0
  60. package/src/memory/parser.ts +20 -0
  61. package/src/memory/path-drift.test.ts +71 -0
  62. package/src/memory/read-tool-fallback.test.ts +54 -0
  63. package/src/memory/read-tool.ts +198 -0
  64. package/src/memory/save-tool.test.ts +193 -0
  65. package/src/memory/save-tool.ts +189 -0
  66. package/src/memory/scan.test.ts +24 -0
  67. package/src/memory/scan.ts +63 -0
  68. package/src/memory/topic.ts +32 -0
  69. package/src/memory/types.ts +49 -0
  70. package/src/migration/index.ts +6 -0
  71. package/src/migration/option3-crypto.test.ts +106 -0
  72. package/src/migration/option3-crypto.ts +143 -0
  73. package/src/pairing.test.ts +212 -0
  74. package/src/pairing.ts +285 -0
  75. package/src/paths.ts +70 -0
  76. package/src/permission/dangerous.ts +105 -0
  77. package/src/permission/env-redact.ts +54 -0
  78. package/src/permission/index.ts +16 -0
  79. package/src/permission/path-guard.ts +114 -0
  80. package/src/permission/permission.test.ts +299 -0
  81. package/src/permission/service.ts +191 -0
  82. package/src/plugins/context.ts +226 -0
  83. package/src/plugins/hooks.ts +81 -0
  84. package/src/plugins/index.ts +24 -0
  85. package/src/plugins/plugins.test.ts +196 -0
  86. package/src/plugins/tool-search.ts +49 -0
  87. package/src/public/card.test.ts +70 -0
  88. package/src/public/card.ts +67 -0
  89. package/src/runtime/activity.ts +29 -0
  90. package/src/runtime/index.ts +7 -0
  91. package/src/runtime/runtime.test.ts +55 -0
  92. package/src/runtime/runtime.ts +126 -0
  93. package/src/sandbox/credentials.ts +25 -0
  94. package/src/sandbox/docker.ts +389 -0
  95. package/src/sandbox/factory.ts +99 -0
  96. package/src/sandbox/index.ts +15 -0
  97. package/src/sandbox/linux.ts +141 -0
  98. package/src/sandbox/local.ts +19 -0
  99. package/src/sandbox/macos.ts +71 -0
  100. package/src/sandbox/sandbox.test.ts +386 -0
  101. package/src/sandbox/seatbelt-profile.ts +139 -0
  102. package/src/sandbox/types.ts +129 -0
  103. package/src/skills/index.ts +8 -0
  104. package/src/skills/scanner.test.ts +137 -0
  105. package/src/skills/scanner.ts +257 -0
  106. package/src/skills/triggers.test.ts +77 -0
  107. package/src/skills/triggers.ts +78 -0
  108. package/src/skills/types.ts +37 -0
  109. package/src/storage/encryption.test.ts +30 -0
  110. package/src/storage/encryption.ts +87 -0
  111. package/src/storage/factory.ts +31 -0
  112. package/src/storage/index.ts +11 -0
  113. package/src/storage/local-stub.ts +70 -0
  114. package/src/storage/sqlite.test.ts +29 -0
  115. package/src/storage/sqlite.ts +95 -0
  116. package/src/storage/types.ts +21 -0
  117. package/src/tools/escalation.test.ts +348 -0
  118. package/src/tools/escalation.ts +200 -0
  119. package/src/tools/index.ts +11 -0
  120. package/src/tools/registry.test.ts +70 -0
  121. package/src/tools/registry.ts +152 -0
  122. package/src/tools/types.ts +65 -0
  123. package/src/tools/zod-helpers.test.ts +63 -0
  124. package/src/tools/zod-helpers.ts +36 -0
  125. package/src/tools/zod-schema.ts +99 -0
  126. package/src/wallet/drain.test.ts +41 -0
  127. package/src/wallet/drain.ts +76 -0
  128. package/src/wallet/eoa.ts +61 -0
  129. package/src/wallet/index.ts +14 -0
  130. package/src/wallet/keystore.test.ts +17 -0
  131. package/src/wallet/keystore.ts +50 -0
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Container sandbox backend (Tier 3, Phase 9.5 follow-up to sandbox-exec).
3
+ *
4
+ * Works with EITHER Docker Desktop OR Podman — they provide the same CLI.
5
+ * Auto-detects the runtime; same default config works for both. Operator
6
+ * can force a specific binary via `runtimePath` opt.
7
+ *
8
+ * Same isolation shape as hermes-agent's `TERMINAL_ENV=docker` mode (full Linux
9
+ * container). Differences from Tier 2 (sandbox-exec):
10
+ *
11
+ * - Container has its own filesystem (chroot-like). Host fs invisible to the
12
+ * sandboxed processes unless explicitly mounted via `mountWorkspace=true`.
13
+ * - Container has its own /tmp, /etc, /home — `rm -rf /tmp/*` only nukes the
14
+ * container's tmpdir, never the host's.
15
+ * - Network goes through the runtime's bridge by default (still allowed for
16
+ * lyra's RPC/storage/compute/WC traffic to escape the container).
17
+ * - Cold-start cost ~1s on the FIRST tool call after lyra boot (longer if
18
+ * the image is being pulled). Subsequent `exec` calls are ~50-100ms.
19
+ *
20
+ * Hybrid MVP: only shell.run / shell.process_start / code.execute go through
21
+ * the container. fs.* tools still run on host (gated by PathGuard). browser.*
22
+ * still runs on host. A future bundle would re-exec all of lyra inside the
23
+ * container; this is the lower-risk incremental step.
24
+ *
25
+ * Lifecycle:
26
+ * - `wrapSpawn` lazy-starts the container on first call.
27
+ * - Container runs `nikolaik/python-nodejs:python3.11-nodejs20` by default
28
+ * (matches hermes' default; has bash, python3, node, npm, git, curl on
29
+ * standard PATH).
30
+ * - Container is detached (`run -d`), idle-loops on `tail -f /dev/null` so it
31
+ * stays alive between exec calls.
32
+ * - `dispose()` kills the container. chat.tsx wires this to process exit
33
+ * handlers.
34
+ *
35
+ * Failure modes:
36
+ * - Runtime not installed → constructor throws clear error; factory falls
37
+ * back to LocalBackend with stderr warning.
38
+ * - Daemon/machine not running → first call surfaces "daemon unreachable"
39
+ * error from the runtime. With podman on macOS, requires `podman machine
40
+ * start` once.
41
+ * - Image pull on first run → 30-60s, surfaced as "starting container" log.
42
+ * - Container crash mid-session (external `podman kill`, OOM, daemon
43
+ * restart) → wrapSpawn detects the stale cache via a fast
44
+ * `podman inspect --format '{{.State.Running}}'` probe and self-heals by
45
+ * invalidating containerId + re-running startContainer. Cost: one extra
46
+ * inspect (~5-15ms on the warm Podman API socket) per shell-class call.
47
+ * Worth the latency vs. the alternative of leaving the brain stuck on
48
+ * "no such container" errors with no recovery path.
49
+ */
50
+
51
+ import { type SpawnOptions, execFile } from 'node:child_process'
52
+ import { existsSync } from 'node:fs'
53
+ import { tmpdir } from 'node:os'
54
+ import { promisify } from 'node:util'
55
+ import type {
56
+ SandboxBackend,
57
+ SandboxBackendOpts,
58
+ SandboxEnvHint,
59
+ SandboxSpawnRequest,
60
+ WrappedSpawn,
61
+ } from './types'
62
+
63
+ const exec = promisify(execFile)
64
+
65
+ /**
66
+ * Probe order for container runtime auto-detect. First existing path wins.
67
+ * macOS Homebrew Podman lives at /opt/homebrew/bin/podman; Docker Desktop
68
+ * symlinks /usr/local/bin/docker to its CLI (or to podman, on machines
69
+ * with both). Linux paths included for completeness.
70
+ */
71
+ const RUNTIME_CANDIDATES: ReadonlyArray<{ path: string; runtime: 'docker' | 'podman' }> = [
72
+ { path: '/usr/local/bin/docker', runtime: 'docker' },
73
+ { path: '/opt/homebrew/bin/docker', runtime: 'docker' },
74
+ { path: '/opt/homebrew/bin/podman', runtime: 'podman' },
75
+ { path: '/usr/bin/docker', runtime: 'docker' },
76
+ { path: '/usr/bin/podman', runtime: 'podman' },
77
+ ]
78
+
79
+ interface RuntimeInfo {
80
+ path: string
81
+ runtime: 'docker' | 'podman'
82
+ }
83
+
84
+ function detectRuntime(override?: string): RuntimeInfo {
85
+ if (override) {
86
+ if (!existsSync(override)) {
87
+ throw new Error(`container runtime override path does not exist: ${override}`)
88
+ }
89
+ const runtime = override.includes('podman') ? 'podman' : 'docker'
90
+ return { path: override, runtime }
91
+ }
92
+ for (const cand of RUNTIME_CANDIDATES) {
93
+ if (existsSync(cand.path)) return cand
94
+ }
95
+ throw new Error(
96
+ 'no container runtime found. Install Docker Desktop or Podman (`brew install podman`) and ensure the daemon/machine is running.',
97
+ )
98
+ }
99
+
100
+ export interface DockerBackendOpts extends SandboxBackendOpts {
101
+ /**
102
+ * Container image. Default: `nikolaik/python-nodejs:python3.11-nodejs20`
103
+ * (matches hermes-agent's TERMINAL_DOCKER_IMAGE default; has bash, python3,
104
+ * node, npm, git, curl on standard PATH so every code.execute language and
105
+ * shell tool works out of the box). Switch to `oven/bun:1` (~250MB vs 700MB)
106
+ * if you only need bun/ts and don't care about python.
107
+ */
108
+ image?: string
109
+ /**
110
+ * Mount the host's workspaceRoot into the container at /workspace. Default
111
+ * `false` for max isolation (container has no view of host fs). Set true
112
+ * when the operator wants the agent to read/edit host project files.
113
+ */
114
+ mountWorkspace?: boolean
115
+ /**
116
+ * Override container runtime binary path. Default: auto-detect (docker, then
117
+ * podman). Set this to force one or the other, e.g. `/opt/homebrew/bin/podman`.
118
+ */
119
+ runtimePath?: string
120
+ /** Override container start timeout in ms. Default 60000 (60s for image pull). */
121
+ startTimeoutMs?: number
122
+ /**
123
+ * CPU cores cap (passed to runtime as `--cpus`). Float (e.g. 0.5, 2). Unset =
124
+ * unlimited (runtime default). Hermes default is 1; lyra leaves UNSET so
125
+ * the container competes fairly with host work unless the operator opts in.
126
+ */
127
+ cpu?: number
128
+ /**
129
+ * Memory cap in MB (`--memory <N>m`). Unset = unlimited. Hermes default is
130
+ * 5120 (5GB). OOM kills happen at this cap, so prefer leaving it unset
131
+ * unless the operator wants a hard guard against runaway pip installs.
132
+ */
133
+ memoryMb?: number
134
+ /**
135
+ * Per-container disk cap in MB (`--storage-opt size=<N>m`). Linux + overlay2
136
+ * with pquota only — silently dropped on macOS (Docker Desktop / podman
137
+ * machine). Unset = unlimited.
138
+ */
139
+ diskMb?: number
140
+ /**
141
+ * Block all network access from inside the container (`--network=none`).
142
+ * Default false (container's bridge network reaches the internet). Useful
143
+ * for max-paranoia code.execute runs that should never reach out.
144
+ */
145
+ noNetwork?: boolean
146
+ }
147
+
148
+ /**
149
+ * Always-on hardening flags ported from hermes-agent's `_SECURITY_ARGS`. Drop
150
+ * every Linux capability then re-add the minimum needed by package managers
151
+ * (pip / npm / apt set ownership and override DAC), block setuid escalation,
152
+ * cap process count to stop fork bombs, and replace tmpfs /tmp /var/tmp /run
153
+ * with size-limited writable tmpfs that doesn't bleed into the host. `--init`
154
+ * gives the container a real PID 1 (tini) that reaps zombies — without it,
155
+ * background tools that orphan children leak file descriptors.
156
+ */
157
+ const HARDENING_ARGS: ReadonlyArray<string> = [
158
+ '--init',
159
+ '--cap-drop',
160
+ 'ALL',
161
+ '--cap-add',
162
+ 'DAC_OVERRIDE',
163
+ '--cap-add',
164
+ 'CHOWN',
165
+ '--cap-add',
166
+ 'FOWNER',
167
+ '--security-opt',
168
+ 'no-new-privileges',
169
+ '--pids-limit',
170
+ '256',
171
+ '--tmpfs',
172
+ '/tmp:rw,nosuid,size=512m',
173
+ '--tmpfs',
174
+ '/var/tmp:rw,noexec,nosuid,size=256m',
175
+ '--tmpfs',
176
+ '/run:rw,noexec,nosuid,size=64m',
177
+ ]
178
+
179
+ export class DockerBackend implements SandboxBackend {
180
+ readonly mode = 'docker' as const
181
+ readonly label: string
182
+ private readonly image: string
183
+ private readonly mountWorkspace: boolean
184
+ private readonly runtime: RuntimeInfo
185
+ private readonly startTimeoutMs: number
186
+ private readonly workspaceRoot: string
187
+ private readonly cpu?: number
188
+ private readonly memoryMb?: number
189
+ private readonly diskMb?: number
190
+ private readonly noNetwork: boolean
191
+ private containerId: string | null = null
192
+ private starting: Promise<string> | null = null
193
+ /**
194
+ * Last `Date.now()` at which `isContainerAlive` returned true. Used to
195
+ * cache the result and skip the ~30-70ms `inspect` probe when the container
196
+ * was confirmed alive recently. The window is narrow enough that a stale
197
+ * cache only delays self-heal by ALIVE_PROBE_TTL_MS in the rare case where
198
+ * the container died externally.
199
+ */
200
+ private lastAliveProbeMs = 0
201
+ private static readonly ALIVE_PROBE_TTL_MS = 30_000
202
+
203
+ constructor(opts: DockerBackendOpts) {
204
+ this.image = opts.image ?? 'nikolaik/python-nodejs:python3.11-nodejs20'
205
+ this.mountWorkspace = opts.mountWorkspace ?? false
206
+ this.runtime = detectRuntime(opts.runtimePath)
207
+ this.startTimeoutMs = opts.startTimeoutMs ?? 60_000
208
+ this.workspaceRoot = opts.workspaceRoot
209
+ this.cpu = opts.cpu
210
+ this.memoryMb = opts.memoryMb
211
+ this.diskMb = opts.diskMb
212
+ this.noNetwork = opts.noNetwork ?? false
213
+ this.label = `${this.runtime.runtime}:${this.image}${this.mountWorkspace ? '+workspace' : ''}`
214
+ }
215
+
216
+ /**
217
+ * Lazy-starts the container on first call. Reuses on subsequent calls.
218
+ * Synchronous assignment to `this.starting` BEFORE the first await ensures
219
+ * concurrent first-callers all wait on the same Promise (otherwise each
220
+ * read `this.starting === null`, each kicked off `startContainer`, and only
221
+ * the last wrote — leaking N-1 orphan containers).
222
+ */
223
+ private ensureContainer(): Promise<string> {
224
+ if (this.containerId) return Promise.resolve(this.containerId)
225
+ if (this.starting) return this.starting
226
+ const promise = this.startContainer().then(
227
+ id => {
228
+ this.containerId = id
229
+ return id
230
+ },
231
+ err => {
232
+ this.starting = null
233
+ throw err
234
+ },
235
+ )
236
+ this.starting = promise
237
+ return promise
238
+ }
239
+
240
+ private async startContainer(): Promise<string> {
241
+ // Verify the runtime daemon/machine is reachable. Fast-fail with a clear
242
+ // error if not. Podman on macOS needs `podman machine start` once before
243
+ // the API responds.
244
+ try {
245
+ await exec(this.runtime.path, ['version', '--format', '{{.Server.Version}}'], {
246
+ timeout: 5_000,
247
+ })
248
+ } catch (err) {
249
+ const hint =
250
+ this.runtime.runtime === 'podman'
251
+ ? "Run `podman machine start` if you haven't yet."
252
+ : 'Start Docker Desktop.'
253
+ throw new Error(
254
+ `${this.runtime.runtime} daemon unreachable (${(err as Error).message}). ${hint} Or set sandbox.mode='os' / 'none'.`,
255
+ )
256
+ }
257
+
258
+ const runArgs: string[] = ['run', '-d', '--rm', '--label', 'lyra-sandbox=1', ...HARDENING_ARGS]
259
+ // Run as host UID so files created in a mounted workspace are owned by
260
+ // the host user. Podman rootless on macOS handles this automatically; we
261
+ // only force --user on docker/podman where the default would be root.
262
+ if (this.runtime.runtime === 'docker') {
263
+ runArgs.push('--user', `${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`)
264
+ }
265
+ // Optional resource caps. `--cpus` and `--memory` work cross-platform.
266
+ // `--storage-opt size` only works on Linux + overlay2 with pquota; we
267
+ // skip it on darwin to match hermes' behavior (silently a no-op there).
268
+ if (this.cpu && this.cpu > 0) runArgs.push('--cpus', String(this.cpu))
269
+ if (this.memoryMb && this.memoryMb > 0) runArgs.push('--memory', `${this.memoryMb}m`)
270
+ if (this.diskMb && this.diskMb > 0 && process.platform !== 'darwin') {
271
+ runArgs.push('--storage-opt', `size=${this.diskMb}m`)
272
+ }
273
+ if (this.noNetwork) runArgs.push('--network=none')
274
+ if (this.mountWorkspace) {
275
+ runArgs.push('-v', `${this.workspaceRoot}:/workspace`)
276
+ runArgs.push('-w', '/workspace')
277
+ }
278
+ // Mount the host's tmpdir READ-ONLY at the same path inside the container
279
+ // so code.execute's host-written snippet (mkdtemp + writeFile happen on
280
+ // host, then `python3 <hostpath>` runs in container) is actually readable.
281
+ // RO so the container can't write back — the container's own /tmp stays
282
+ // isolated and `rm /var/folders/...` from inside fails with EROFS.
283
+ const hostTmp = tmpdir()
284
+ runArgs.push('-v', `${hostTmp}:${hostTmp}:ro`)
285
+ runArgs.push(this.image, 'tail', '-f', '/dev/null')
286
+
287
+ const { stdout } = await exec(this.runtime.path, runArgs, {
288
+ timeout: this.startTimeoutMs,
289
+ })
290
+ const containerId = stdout.toString().trim()
291
+ if (!containerId || containerId.length < 12) {
292
+ throw new Error(
293
+ `${this.runtime.runtime} run returned unexpected output: "${containerId.slice(0, 200)}"`,
294
+ )
295
+ }
296
+ return containerId
297
+ }
298
+
299
+ async wrapSpawn(req: SandboxSpawnRequest): Promise<WrappedSpawn> {
300
+ let containerId = await this.ensureContainer()
301
+ // Self-heal stale cache: container may have died since last call (external
302
+ // `podman kill`, OOM, daemon restart, --rm cleanup after host crash).
303
+ // Probe is rate-limited to ALIVE_PROBE_TTL_MS so the happy path doesn't
304
+ // pay the ~30-70ms inspect tax on every spawn — only the first call after
305
+ // the TTL window pays. Worst case after external kill: one failed exec
306
+ // before the next probe re-spawns.
307
+ const now = Date.now()
308
+ if (now - this.lastAliveProbeMs > DockerBackend.ALIVE_PROBE_TTL_MS) {
309
+ if (!(await this.isContainerAlive(containerId))) {
310
+ this.containerId = null
311
+ this.starting = null
312
+ this.lastAliveProbeMs = 0
313
+ containerId = await this.ensureContainer()
314
+ }
315
+ this.lastAliveProbeMs = Date.now()
316
+ }
317
+ // `exec -i` (interactive stdin), preserve env subset, run inside the
318
+ // container. We pass env explicitly via `-e` rather than relying on
319
+ // container env so redactedEnv from the tool layer actually reaches the
320
+ // inner process.
321
+ const envArgs: string[] = []
322
+ if (req.options.env) {
323
+ for (const [k, v] of Object.entries(req.options.env)) {
324
+ if (typeof v === 'string') envArgs.push('-e', `${k}=${v}`)
325
+ }
326
+ }
327
+ const cwdArg: string[] = []
328
+ if (this.mountWorkspace && req.options.cwd === this.workspaceRoot) {
329
+ cwdArg.push('-w', '/workspace')
330
+ }
331
+ // Strip cwd + env from passed-through options because we redirected both
332
+ // into exec flags. Keep stdio/etc.
333
+ const {
334
+ cwd: _cwd,
335
+ env: _env,
336
+ ...passOptions
337
+ } = req.options as SpawnOptions & {
338
+ cwd?: unknown
339
+ env?: unknown
340
+ }
341
+ return {
342
+ command: this.runtime.path,
343
+ args: ['exec', '-i', ...envArgs, ...cwdArg, containerId, req.command, ...req.args],
344
+ options: passOptions,
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Fast liveness probe. `inspect --format '{{.State.Running}}'` returns
350
+ * "true" / "false" when the container exists, fails non-zero when missing.
351
+ * 3s timeout prevents a wedged daemon from stalling every spawn.
352
+ */
353
+ private async isContainerAlive(id: string): Promise<boolean> {
354
+ try {
355
+ const { stdout } = await exec(
356
+ this.runtime.path,
357
+ ['inspect', '--format', '{{.State.Running}}', id],
358
+ { timeout: 3_000 },
359
+ )
360
+ return stdout.toString().trim() === 'true'
361
+ } catch {
362
+ return false
363
+ }
364
+ }
365
+
366
+ async dispose(): Promise<void> {
367
+ if (!this.containerId) return
368
+ const id = this.containerId
369
+ this.containerId = null
370
+ this.starting = null
371
+ this.lastAliveProbeMs = 0
372
+ try {
373
+ await exec(this.runtime.path, ['kill', id], { timeout: 5_000 })
374
+ } catch {
375
+ // Container may already be dead; --rm cleaned up. Best-effort.
376
+ }
377
+ }
378
+
379
+ envHint(): SandboxEnvHint {
380
+ return {
381
+ mode: 'docker',
382
+ label: this.label,
383
+ innerOs: 'linux',
384
+ workspaceMount: this.mountWorkspace ? '/workspace' : null,
385
+ scope:
386
+ 'shell.run, code.execute, shell.process_start run inside the container; fs.*, browser.*, memory.* run on the host',
387
+ }
388
+ }
389
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Factory: build the right backend for the configured mode + current platform.
3
+ *
4
+ * mode='none' → LocalBackend (passthrough)
5
+ * mode='os' on darwin → MacOSSandboxExecBackend (sandbox-exec wrapper)
6
+ * mode='os' on linux → LocalBackend + warning (bubblewrap impl pending)
7
+ * mode='os' elsewhere → LocalBackend + warning
8
+ * mode='docker' → throws "not yet implemented" (separate bundle)
9
+ *
10
+ * Failure mode: misconfiguration silently degrades to LocalBackend with a
11
+ * stderr warning rather than crashing init. Lyra MUST boot even on
12
+ * unsupported platforms; the sandbox is a defense-in-depth layer, not a hard
13
+ * requirement.
14
+ */
15
+
16
+ import { DockerBackend } from './docker'
17
+ import { LinuxBubblewrapBackend } from './linux'
18
+ import { LocalBackend } from './local'
19
+ import { MacOSSandboxExecBackend } from './macos'
20
+ import type { SandboxBackend, SandboxBackendOpts, SandboxMode } from './types'
21
+
22
+ export interface MakeSandboxOpts extends SandboxBackendOpts {
23
+ mode: SandboxMode
24
+ /** Override platform detection. Defaults to process.platform. Test hook. */
25
+ platform?: NodeJS.Platform
26
+ /** Sink for the platform-fallback warning. Defaults to process.stderr.write. */
27
+ warn?: (msg: string) => void
28
+ /** docker mode: container image override (default `nikolaik/python-nodejs:python3.11-nodejs20`). */
29
+ dockerImage?: string
30
+ /** docker mode: bind-mount workspaceRoot into container at /workspace (default false). */
31
+ dockerMountWorkspace?: boolean
32
+ /** docker mode: force a specific runtime binary path (auto-detect by default). */
33
+ dockerRuntimePath?: string
34
+ /** docker mode: CPU cores cap (`--cpus`). Unset = unlimited. */
35
+ dockerCpu?: number
36
+ /** docker mode: memory cap in MB (`--memory <N>m`). Unset = unlimited. */
37
+ dockerMemoryMb?: number
38
+ /** docker mode: per-container disk cap in MB. Linux+overlay2 only; ignored on darwin. */
39
+ dockerDiskMb?: number
40
+ /** docker mode: block all network from inside container (`--network=none`). Default false. */
41
+ dockerNoNetwork?: boolean
42
+ }
43
+
44
+ export function makeSandboxBackend(opts: MakeSandboxOpts): SandboxBackend {
45
+ const platform = opts.platform ?? process.platform
46
+ const warn = opts.warn ?? ((m: string) => process.stderr.write(m))
47
+
48
+ if (opts.mode === 'none') return new LocalBackend()
49
+
50
+ if (opts.mode === 'docker') {
51
+ try {
52
+ return new DockerBackend({
53
+ agentDir: opts.agentDir,
54
+ workspaceRoot: opts.workspaceRoot,
55
+ homedir: opts.homedir,
56
+ image: opts.dockerImage,
57
+ mountWorkspace: opts.dockerMountWorkspace,
58
+ runtimePath: opts.dockerRuntimePath,
59
+ cpu: opts.dockerCpu,
60
+ memoryMb: opts.dockerMemoryMb,
61
+ diskMb: opts.dockerDiskMb,
62
+ noNetwork: opts.dockerNoNetwork,
63
+ })
64
+ } catch (err) {
65
+ warn(
66
+ `lyra: docker sandbox failed to initialize, falling back to passthrough: ${(err as Error).message}\n`,
67
+ )
68
+ return new LocalBackend()
69
+ }
70
+ }
71
+
72
+ // mode === 'os'
73
+ if (platform === 'darwin') {
74
+ try {
75
+ return new MacOSSandboxExecBackend(opts)
76
+ } catch (err) {
77
+ warn(
78
+ `lyra: macOS sandbox-exec failed to initialize, falling back to passthrough: ${(err as Error).message}\n`,
79
+ )
80
+ return new LocalBackend()
81
+ }
82
+ }
83
+
84
+ if (platform === 'linux') {
85
+ try {
86
+ return new LinuxBubblewrapBackend(opts)
87
+ } catch (err) {
88
+ warn(
89
+ `lyra: linux bubblewrap sandbox failed to initialize, falling back to passthrough: ${(err as Error).message}\n`,
90
+ )
91
+ return new LocalBackend()
92
+ }
93
+ }
94
+
95
+ warn(
96
+ `lyra: sandbox.mode="os" not supported on platform "${platform}", falling back to passthrough\n`,
97
+ )
98
+ return new LocalBackend()
99
+ }
@@ -0,0 +1,15 @@
1
+ export { LocalBackend } from './local'
2
+ export { MacOSSandboxExecBackend } from './macos'
3
+ export { LinuxBubblewrapBackend, buildBwrapArgs } from './linux'
4
+ export { DockerBackend, type DockerBackendOpts } from './docker'
5
+ export { makeSandboxBackend, type MakeSandboxOpts } from './factory'
6
+ export { buildSeatbeltProfile, type SeatbeltProfileOpts } from './seatbelt-profile'
7
+ export type {
8
+ SandboxBackend,
9
+ SandboxBackendOpts,
10
+ SandboxEnvHint,
11
+ SandboxMode,
12
+ SandboxSpawnRequest,
13
+ WrappedSpawn,
14
+ } from './types'
15
+ export { credentialDirs, CREDENTIAL_DIR_RELATIVE_PATHS } from './credentials'
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Linux bubblewrap (`bwrap`) backend. Mirrors the macOS sandbox-exec backend
3
+ * for Linux operators. Wraps every tool spawn in:
4
+ *
5
+ * bwrap <profile-args...> <orig-command> <orig-args...>
6
+ *
7
+ * `bwrap` is unprivileged user-namespace sandboxing — the same primitive
8
+ * Flatpak / Bubblewrap / chromium use. No setuid required (kernel must have
9
+ * unprivileged user namespaces enabled, which is the default on every modern
10
+ * distro: Ubuntu 22+, Fedora, Arch, Debian 11+).
11
+ *
12
+ * Profile policy mirrors macOS seatbelt:
13
+ * - read-only bind of / (so commands like `cat`, `ls`, `find` work)
14
+ * - writable bind of agentDir + workspaceRoot
15
+ * - writable bind of /tmp (lyra-* dirs land there)
16
+ * - tmpfs overlay of credential dirs (~/.ssh, ~/.aws, ~/Library/Keychains
17
+ * [doesn't exist on Linux but cheap to include for portability],
18
+ * ~/.config/gcloud) — reads return empty
19
+ * - --unshare-all --share-net keeps network reachable so lyra can still
20
+ * hit Sui RPC, the indexer, etc.
21
+ * - --die-with-parent kills the child if lyra crashes, no zombies
22
+ * - --new-session puts the child in its own session (Ctrl-C from lyra
23
+ * doesn't propagate to the inner command)
24
+ */
25
+
26
+ import { existsSync } from 'node:fs'
27
+ import { credentialDirs } from './credentials'
28
+ import type {
29
+ SandboxBackend,
30
+ SandboxBackendOpts,
31
+ SandboxEnvHint,
32
+ SandboxSpawnRequest,
33
+ WrappedSpawn,
34
+ } from './types'
35
+
36
+ /**
37
+ * Probe order for bwrap binary. Most distros put it at /usr/bin/bwrap; some
38
+ * via pkg-managed systems at /usr/local/bin. First existing path wins.
39
+ */
40
+ const BWRAP_CANDIDATES: ReadonlyArray<string> = ['/usr/bin/bwrap', '/usr/local/bin/bwrap']
41
+
42
+ function findBwrap(): string | null {
43
+ for (const path of BWRAP_CANDIDATES) {
44
+ if (existsSync(path)) return path
45
+ }
46
+ return null
47
+ }
48
+
49
+ /**
50
+ * Build the bwrap argv prefix that wraps the user command. Returns a flat
51
+ * argv array; the actual command + args are appended after.
52
+ */
53
+ export function buildBwrapArgs(opts: SandboxBackendOpts): string[] {
54
+ const args: string[] = []
55
+
56
+ // Base filesystem: read-only bind of /. The container can read system tools
57
+ // (cat, ls, etc.) but cannot modify them. Override specific subdirs below.
58
+ args.push('--ro-bind', '/', '/')
59
+
60
+ // Writable subdirs: agentDir + workspaceRoot + /tmp (for lyra-* test dirs)
61
+ args.push('--bind', opts.agentDir, opts.agentDir)
62
+ args.push('--bind', opts.workspaceRoot, opts.workspaceRoot)
63
+ args.push('--bind', '/tmp', '/tmp')
64
+
65
+ // Optional extra-writable subpaths (test sandbox dirs, custom workspaces).
66
+ if (opts.extraWriteAllow) {
67
+ for (const path of opts.extraWriteAllow) {
68
+ args.push('--bind', path, path)
69
+ }
70
+ }
71
+
72
+ // Credential blackouts: empty tmpfs overlays so reads return ENOENT-equivalent.
73
+ // Shared list with seatbelt-profile.ts to prevent platform drift.
74
+ for (const dir of credentialDirs(opts.homedir)) {
75
+ args.push('--tmpfs', dir)
76
+ }
77
+
78
+ // Optional extra denies via tmpfs.
79
+ if (opts.extraWriteDeny) {
80
+ for (const path of opts.extraWriteDeny) {
81
+ args.push('--tmpfs', path)
82
+ }
83
+ }
84
+
85
+ // System pseudo-filesystems.
86
+ args.push('--proc', '/proc')
87
+ args.push('--dev', '/dev')
88
+
89
+ // Namespace isolation: unshare everything except network (lyra needs network
90
+ // for Sui RPC, indexer, brain inference). PID namespace isolates process tree
91
+ // from host. UTS namespace gives the sandbox its own hostname.
92
+ args.push('--unshare-all', '--share-net')
93
+
94
+ // Lifetime + signal handling.
95
+ args.push('--die-with-parent') // child dies if lyra dies
96
+ args.push('--new-session') // Ctrl-C from lyra doesn't kill the child directly
97
+
98
+ return args
99
+ }
100
+
101
+ export class LinuxBubblewrapBackend implements SandboxBackend {
102
+ readonly mode = 'os' as const
103
+ readonly label = 'os:linux'
104
+ private readonly bwrapPath: string
105
+ private readonly bwrapArgs: string[]
106
+
107
+ constructor(opts: SandboxBackendOpts) {
108
+ const path = findBwrap()
109
+ if (!path) {
110
+ throw new Error(
111
+ `bwrap not found in ${BWRAP_CANDIDATES.join(', ')}. Linux sandbox backend requires bubblewrap (apt install bubblewrap / dnf install bubblewrap).`,
112
+ )
113
+ }
114
+ this.bwrapPath = path
115
+ this.bwrapArgs = buildBwrapArgs(opts)
116
+ }
117
+
118
+ /** Test-only accessor for the bwrap argv prefix. */
119
+ getBwrapArgs(): readonly string[] {
120
+ return this.bwrapArgs
121
+ }
122
+
123
+ envHint(): SandboxEnvHint {
124
+ return {
125
+ mode: 'os',
126
+ label: this.label,
127
+ innerOs: 'linux',
128
+ workspaceMount: null,
129
+ scope:
130
+ 'shell.run, code.execute, shell.process_start are wrapped in a bubblewrap profile; writes outside agentDir + cwd + /tmp/lyra-* are denied',
131
+ }
132
+ }
133
+
134
+ async wrapSpawn(req: SandboxSpawnRequest): Promise<WrappedSpawn> {
135
+ return {
136
+ command: this.bwrapPath,
137
+ args: [...this.bwrapArgs, '--', req.command, ...req.args],
138
+ options: req.options,
139
+ }
140
+ }
141
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Passthrough backend. Used when `sandbox.mode = 'none'` (today's default for
3
+ * back-compat) or when the platform doesn't support `os` mode.
4
+ */
5
+
6
+ import type { SandboxBackend, SandboxSpawnRequest, WrappedSpawn } from './types'
7
+
8
+ export class LocalBackend implements SandboxBackend {
9
+ readonly mode = 'none' as const
10
+ readonly label = 'none'
11
+
12
+ async wrapSpawn(req: SandboxSpawnRequest): Promise<WrappedSpawn> {
13
+ return {
14
+ command: req.command,
15
+ args: req.args,
16
+ options: req.options,
17
+ }
18
+ }
19
+ }