nawabari 0.2.0 → 0.4.1

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 (50) hide show
  1. package/README.md +230 -15
  2. package/dist/cli.d.ts +5 -0
  3. package/dist/cli.js +704 -115
  4. package/dist/cli.js.map +1 -1
  5. package/dist/contract.js +135 -2
  6. package/dist/contract.js.map +1 -1
  7. package/dist/domain/errors.d.ts +1 -1
  8. package/dist/domain/errors.js +4 -1
  9. package/dist/domain/errors.js.map +1 -1
  10. package/dist/domain/sandbox-launcher.d.ts +35 -0
  11. package/dist/domain/sandbox-launcher.js +679 -0
  12. package/dist/domain/sandbox-launcher.js.map +1 -0
  13. package/dist/domain/sandbox.d.ts +190 -0
  14. package/dist/domain/sandbox.js +363 -0
  15. package/dist/domain/sandbox.js.map +1 -0
  16. package/dist/domain/session-backend.d.ts +5 -3
  17. package/dist/domain/session-backend.js +102 -4
  18. package/dist/domain/session-backend.js.map +1 -1
  19. package/dist/domain/session.d.ts +116 -2
  20. package/dist/domain/session.js +36 -0
  21. package/dist/domain/session.js.map +1 -1
  22. package/dist/errors.d.ts +1 -1
  23. package/dist/errors.js.map +1 -1
  24. package/dist/git.d.ts +62 -0
  25. package/dist/git.js +299 -0
  26. package/dist/git.js.map +1 -1
  27. package/dist/operation-authorization.d.ts +5 -2
  28. package/dist/operation-authorization.js +6 -7
  29. package/dist/operation-authorization.js.map +1 -1
  30. package/dist/output-budget.d.ts +16 -0
  31. package/dist/output-budget.js +58 -0
  32. package/dist/output-budget.js.map +1 -0
  33. package/dist/presentation.js +28 -4
  34. package/dist/presentation.js.map +1 -1
  35. package/dist/registry/atomic.d.ts +24 -0
  36. package/dist/registry/atomic.js +35 -2
  37. package/dist/registry/atomic.js.map +1 -1
  38. package/dist/registry/errors.d.ts +1 -1
  39. package/dist/registry/errors.js +1 -0
  40. package/dist/registry/errors.js.map +1 -1
  41. package/dist/repository-evidence.d.ts +74 -0
  42. package/dist/repository-evidence.js +7 -0
  43. package/dist/repository-evidence.js.map +1 -0
  44. package/dist/resource-claims.d.ts +25 -3
  45. package/dist/resource-claims.js +57 -5
  46. package/dist/resource-claims.js.map +1 -1
  47. package/dist/session-registry.d.ts +68 -7
  48. package/dist/session-registry.js +422 -149
  49. package/dist/session-registry.js.map +1 -1
  50. package/package.json +5 -3
@@ -0,0 +1,679 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { DomainError, failure, success } from "./errors.js";
6
+ const SANDBOX_HOME = "/home/nawabari";
7
+ const SANDBOX_CONFIG_HOME = `${SANDBOX_HOME}/.config`;
8
+ const SANDBOX_LOCAL_HOME = `${SANDBOX_HOME}/.local`;
9
+ const SANDBOX_CACHE_HOME = `${SANDBOX_HOME}/.cache`;
10
+ const SANDBOX_SHARED_HOME = `${SANDBOX_HOME}/.nawabari`;
11
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1_000;
12
+ const DEFAULT_MAX_OUTPUT_BYTES = 1_024 * 1_024;
13
+ function topologyError(message, details) {
14
+ return failure(new DomainError("SANDBOX_TOPOLOGY_INVALID", message, details));
15
+ }
16
+ function isWithin(parent, candidate) {
17
+ const relative = path.relative(parent, candidate);
18
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
19
+ }
20
+ function canonicalPath(candidate, label) {
21
+ if (!path.isAbsolute(candidate) || candidate.includes("\0")) {
22
+ return topologyError(`${label} must be an absolute path without NUL bytes.`, { label, path: candidate });
23
+ }
24
+ try {
25
+ const normalized = path.normalize(candidate);
26
+ const resolved = fs.realpathSync.native(candidate);
27
+ if (resolved !== normalized) {
28
+ return topologyError(`${label} must not resolve through an unproven symlink.`, {
29
+ label,
30
+ path: candidate,
31
+ resolved,
32
+ });
33
+ }
34
+ return success(resolved);
35
+ }
36
+ catch (error) {
37
+ return topologyError(`${label} does not exist or cannot be canonicalized.`, {
38
+ label,
39
+ path: candidate,
40
+ reason: error instanceof Error ? error.message.slice(0, 200) : "unknown",
41
+ });
42
+ }
43
+ }
44
+ function preparePrivateDirectory(candidate, repository, label) {
45
+ if (!isWithin(repository, candidate) || candidate === repository) {
46
+ return topologyError(`${label} must be a private directory below the authoritative repository.`, {
47
+ label,
48
+ path: candidate,
49
+ repository,
50
+ });
51
+ }
52
+ let existingAncestor = candidate;
53
+ while (!fs.existsSync(existingAncestor)) {
54
+ const parent = path.dirname(existingAncestor);
55
+ if (parent === existingAncestor) {
56
+ return topologyError(`${label} has no canonicalizable parent.`, { label, path: candidate });
57
+ }
58
+ existingAncestor = parent;
59
+ }
60
+ try {
61
+ const resolvedAncestor = fs.realpathSync.native(existingAncestor);
62
+ if (!isWithin(repository, resolvedAncestor)) {
63
+ return topologyError(`${label} would traverse outside the authoritative repository.`, {
64
+ label,
65
+ path: candidate,
66
+ existing_ancestor: existingAncestor,
67
+ resolved_ancestor: resolvedAncestor,
68
+ repository,
69
+ });
70
+ }
71
+ }
72
+ catch (error) {
73
+ return topologyError(`${label} has an unresolvable parent.`, {
74
+ label,
75
+ path: candidate,
76
+ existing_ancestor: existingAncestor,
77
+ reason: error instanceof Error ? error.message.slice(0, 200) : "unknown",
78
+ });
79
+ }
80
+ try {
81
+ fs.mkdirSync(candidate, { recursive: true, mode: 0o700 });
82
+ }
83
+ catch (error) {
84
+ return topologyError(`${label} could not be prepared.`, {
85
+ label,
86
+ path: candidate,
87
+ reason: error instanceof Error ? error.message.slice(0, 200) : "unknown",
88
+ });
89
+ }
90
+ return canonicalPath(candidate, label);
91
+ }
92
+ function validateTopology(request) {
93
+ const repository = canonicalPath(request.repository, "repository");
94
+ if (!repository.ok)
95
+ return repository;
96
+ const worktree = canonicalPath(request.worktree, "worktree");
97
+ if (!worktree.ok)
98
+ return worktree;
99
+ if (request.filesystem.owned_worktree !== request.worktree) {
100
+ return topologyError("The filesystem topology worktree must equal the authoritative session worktree.", {
101
+ request_worktree: request.worktree,
102
+ topology_worktree: request.filesystem.owned_worktree,
103
+ });
104
+ }
105
+ const home = preparePrivateDirectory(request.filesystem.home, repository.value, "session home");
106
+ if (!home.ok)
107
+ return home;
108
+ const cache = preparePrivateDirectory(request.filesystem.cache, repository.value, "session cache");
109
+ if (!cache.ok)
110
+ return cache;
111
+ const persistentHome = preparePrivateDirectory(request.filesystem.persistent_home, repository.value, "persistent repository home");
112
+ if (!persistentHome.ok)
113
+ return persistentHome;
114
+ const gitMetadata = preparePrivateDirectory(request.filesystem.git_metadata, repository.value, "Git metadata");
115
+ if (!gitMetadata.ok)
116
+ return gitMetadata;
117
+ const gitObjects = canonicalPath(request.filesystem.git_objects, "Git object database");
118
+ if (!gitObjects.ok)
119
+ return gitObjects;
120
+ if (!isWithin(repository.value, gitObjects.value)) {
121
+ return topologyError("The Git object database escaped the authoritative repository.", {
122
+ repository: repository.value,
123
+ git_objects: gitObjects.value,
124
+ });
125
+ }
126
+ if (!isWithin(repository.value, home.value) || !isWithin(repository.value, cache.value)) {
127
+ return topologyError("Session-private state escaped the authoritative repository.", {
128
+ repository: repository.value,
129
+ home: home.value,
130
+ cache: cache.value,
131
+ });
132
+ }
133
+ return success({
134
+ repository: repository.value,
135
+ worktree: worktree.value,
136
+ home: home.value,
137
+ cache: cache.value,
138
+ persistent_home: persistentHome.value,
139
+ git_metadata: gitMetadata.value,
140
+ git_objects: gitObjects.value,
141
+ });
142
+ }
143
+ function pathMatches(candidate, root) {
144
+ return candidate === root || candidate.startsWith(`${root}${path.sep}`);
145
+ }
146
+ function allowedSystemPath(candidate) {
147
+ const normalized = path.normalize(candidate);
148
+ const roots = [
149
+ "/usr",
150
+ "/bin",
151
+ "/lib",
152
+ "/lib64",
153
+ "/nix/store",
154
+ "/run/current-system",
155
+ "/run/wrappers",
156
+ "/run/systemd/resolve",
157
+ "/run/NetworkManager",
158
+ "/mnt/wsl",
159
+ "/etc/profiles",
160
+ "/etc/alternatives",
161
+ "/etc/ssl",
162
+ "/etc/pki",
163
+ "/etc/ca-certificates",
164
+ ];
165
+ if (roots.some((root) => pathMatches(normalized, root)))
166
+ return true;
167
+ return new Set(["/etc/passwd", "/etc/group", "/etc/nsswitch.conf", "/etc/hosts", "/etc/resolv.conf"]).has(normalized);
168
+ }
169
+ function allowedRuntimePath(candidate) {
170
+ const normalized = path.normalize(candidate);
171
+ return (normalized === "/dev" ||
172
+ normalized === "/proc" ||
173
+ normalized === "/tmp" ||
174
+ pathMatches(normalized, "/nix/store") ||
175
+ pathMatches(normalized, "/run/current-system") ||
176
+ pathMatches(normalized, "/run/wrappers") ||
177
+ pathMatches(normalized, "/etc/profiles"));
178
+ }
179
+ function allowedUserToolPath(candidate, hostHome = process.env.HOME) {
180
+ const home = hostHome;
181
+ if (home === undefined || home === null)
182
+ return null;
183
+ if (!path.isAbsolute(home) || home.includes("\0"))
184
+ return null;
185
+ const normalized = path.normalize(candidate);
186
+ if (normalized === path.join(home, ".local", "bin"))
187
+ return "local_bin";
188
+ if (normalized === path.join(home, ".local", "lib"))
189
+ return "local_lib";
190
+ if (normalized === path.join(home, ".local", "share", "pnpm"))
191
+ return "pnpm_bin";
192
+ return null;
193
+ }
194
+ function ensureSourcePath(candidate, label) {
195
+ try {
196
+ const stat = fs.statSync(candidate);
197
+ if (!stat.isDirectory() && !stat.isFile()) {
198
+ return topologyError(`${label} is not a regular file or directory.`, { label, path: candidate });
199
+ }
200
+ return success(candidate);
201
+ }
202
+ catch (error) {
203
+ return topologyError(`${label} does not exist.`, {
204
+ label,
205
+ path: candidate,
206
+ reason: error instanceof Error ? error.message.slice(0, 200) : "unknown",
207
+ });
208
+ }
209
+ }
210
+ function destinationParents(destination) {
211
+ const parent = path.posix.dirname(destination);
212
+ if (parent === "/" || parent === ".")
213
+ return [];
214
+ const parts = parent.split("/").filter(Boolean);
215
+ const result = [];
216
+ let current = "";
217
+ for (const part of parts) {
218
+ current += `/${part}`;
219
+ result.push(current);
220
+ }
221
+ return result;
222
+ }
223
+ function addDirectory(args, destination, seen) {
224
+ for (const parent of destinationParents(destination)) {
225
+ if (seen.has(parent))
226
+ continue;
227
+ seen.add(parent);
228
+ args.push("--dir", parent);
229
+ }
230
+ }
231
+ function addReadOnlyBind(args, source, destination, seenDirectories) {
232
+ addDirectory(args, destination, seenDirectories);
233
+ args.push("--ro-bind", source, destination);
234
+ }
235
+ function addReadWriteBind(args, source, destination, seenDirectories) {
236
+ addDirectory(args, destination, seenDirectories);
237
+ args.push("--bind", source, destination);
238
+ }
239
+ function pathEntriesForEnvironment(request) {
240
+ const entries = [];
241
+ const sources = new Set(request.filesystem.runtime_paths);
242
+ const system = new Set(request.filesystem.system_paths);
243
+ const tools = new Set(request.filesystem.user_tool_paths);
244
+ const hostHome = request.filesystem.user_tool_home ?? process.env.HOME;
245
+ if (tools.has(path.join(hostHome ?? "", ".local", "bin")))
246
+ entries.push(`${SANDBOX_LOCAL_HOME}/bin`);
247
+ if (tools.has(path.join(hostHome ?? "", ".local", "share", "pnpm"))) {
248
+ entries.push(`${SANDBOX_LOCAL_HOME}/share/pnpm`);
249
+ }
250
+ if (sources.has("/run/current-system"))
251
+ entries.push("/run/current-system/sw/bin");
252
+ if (sources.has("/run/wrappers"))
253
+ entries.push("/run/wrappers/bin");
254
+ const profile = [...sources].find((source) => source.startsWith("/etc/profiles/per-user/"));
255
+ if (profile !== undefined)
256
+ entries.push(`${profile}/bin`);
257
+ if (system.has("/usr"))
258
+ entries.push("/usr/local/bin", "/usr/bin");
259
+ if (system.has("/bin"))
260
+ entries.push("/bin");
261
+ return [...new Set(entries)];
262
+ }
263
+ function validateExecutable(request) {
264
+ if (!request.enforce) {
265
+ return failure(new DomainError("SANDBOX_CAPABILITY_UNAVAILABLE", "A sandbox launcher cannot execute an advisory request without protected enforcement.", { enforce: false, session_id: request.session_id }));
266
+ }
267
+ if (request.sandbox_executable === null || !path.isAbsolute(request.sandbox_executable)) {
268
+ return failure(new DomainError("SANDBOX_CAPABILITY_UNAVAILABLE", "Bubblewrap was not resolved for the protected execution request.", { session_id: request.session_id }));
269
+ }
270
+ const checked = ensureSourcePath(request.sandbox_executable, "bubblewrap executable");
271
+ if (!checked.ok)
272
+ return checked;
273
+ try {
274
+ const stat = fs.statSync(request.sandbox_executable);
275
+ const resolved = fs.realpathSync.native(request.sandbox_executable);
276
+ if (!stat.isFile() || (stat.mode & 0o111) === 0) {
277
+ return failure(new DomainError("SANDBOX_CAPABILITY_UNAVAILABLE", "The resolved bubblewrap path is not executable.", {
278
+ path: request.sandbox_executable,
279
+ }));
280
+ }
281
+ if (!allowedSystemPath(resolved) &&
282
+ allowedUserToolPath(resolved, request.filesystem.user_tool_home) === null &&
283
+ !allowedRuntimePath(resolved)) {
284
+ return topologyError("Bubblewrap resolved outside the fixed runtime profile.", {
285
+ path: request.sandbox_executable,
286
+ resolved,
287
+ });
288
+ }
289
+ }
290
+ catch {
291
+ return failure(new DomainError("SANDBOX_CAPABILITY_UNAVAILABLE", "The resolved bubblewrap path cannot be canonicalized.", {
292
+ path: request.sandbox_executable,
293
+ }));
294
+ }
295
+ return checked;
296
+ }
297
+ function validateProfilePaths(request) {
298
+ for (const source of request.filesystem.runtime_paths) {
299
+ if (!allowedRuntimePath(source)) {
300
+ return topologyError("The runtime path is outside the canonical profile.", { path: source });
301
+ }
302
+ if (source === "/dev" || source === "/proc" || source === "/tmp")
303
+ continue;
304
+ const checked = ensureSourcePath(source, "runtime path");
305
+ if (!checked.ok)
306
+ return checked;
307
+ try {
308
+ const resolved = fs.realpathSync.native(source);
309
+ if (!allowedRuntimePath(resolved)) {
310
+ return topologyError("The runtime path symlink resolves outside the canonical profile.", {
311
+ path: source,
312
+ resolved,
313
+ });
314
+ }
315
+ }
316
+ catch {
317
+ return topologyError("The runtime path cannot be canonicalized.", { path: source });
318
+ }
319
+ }
320
+ for (const source of request.filesystem.system_paths) {
321
+ if (!allowedSystemPath(source)) {
322
+ return topologyError("The system path is outside the canonical profile.", { path: source });
323
+ }
324
+ const checked = ensureSourcePath(source, "system path");
325
+ if (!checked.ok)
326
+ return checked;
327
+ try {
328
+ const resolved = fs.realpathSync.native(source);
329
+ if (!allowedSystemPath(resolved)) {
330
+ return topologyError("The system path symlink resolves outside the canonical profile.", {
331
+ path: source,
332
+ resolved,
333
+ });
334
+ }
335
+ }
336
+ catch {
337
+ return topologyError("The system path cannot be canonicalized.", { path: source });
338
+ }
339
+ }
340
+ for (const source of request.filesystem.user_tool_paths) {
341
+ if (allowedUserToolPath(source, request.filesystem.user_tool_home) === null) {
342
+ return topologyError("The user-tool path is outside the selected local-tool profile.", { path: source });
343
+ }
344
+ const checked = ensureSourcePath(source, "user-tool path");
345
+ if (!checked.ok)
346
+ return checked;
347
+ try {
348
+ const resolved = fs.realpathSync.native(source);
349
+ if (allowedUserToolPath(resolved, request.filesystem.user_tool_home) === null) {
350
+ return topologyError("The user-tool path symlink resolves outside the selected profile.", {
351
+ path: source,
352
+ resolved,
353
+ });
354
+ }
355
+ }
356
+ catch {
357
+ return topologyError("The user-tool path cannot be canonicalized.", { path: source });
358
+ }
359
+ }
360
+ return success(null);
361
+ }
362
+ function worktreeGitDirectory(worktree) {
363
+ try {
364
+ const entry = path.join(worktree, ".git");
365
+ const stat = fs.lstatSync(entry);
366
+ if (stat.isSymbolicLink())
367
+ return null;
368
+ if (stat.isDirectory())
369
+ return entry;
370
+ const text = fs.readFileSync(entry, "utf8").trim();
371
+ if (!text.startsWith("gitdir:"))
372
+ return null;
373
+ return path.resolve(worktree, text.slice("gitdir:".length).trim());
374
+ }
375
+ catch {
376
+ return null;
377
+ }
378
+ }
379
+ function copyIfRegular(source, destination) {
380
+ try {
381
+ if (fs.lstatSync(source).isFile())
382
+ fs.copyFileSync(source, destination);
383
+ }
384
+ catch {
385
+ // Optional Git metadata is intentionally omitted when unavailable.
386
+ }
387
+ }
388
+ function prepareGitMetadata(request, topology) {
389
+ try {
390
+ fs.mkdirSync(topology.git_metadata, { recursive: true, mode: 0o700 });
391
+ fs.writeFileSync(path.join(topology.git_metadata, "config"), "[core]\n\trepositoryformatversion = 0\n\tbare = false\n", { mode: 0o600 });
392
+ const admin = worktreeGitDirectory(topology.worktree);
393
+ if (admin !== null) {
394
+ copyIfRegular(path.join(admin, "HEAD"), path.join(topology.git_metadata, "HEAD"));
395
+ copyIfRegular(path.join(admin, "index"), path.join(topology.git_metadata, "index"));
396
+ let head = "";
397
+ try {
398
+ head = fs.readFileSync(path.join(admin, "HEAD"), "utf8").trim();
399
+ }
400
+ catch {
401
+ head = "";
402
+ }
403
+ const reference = /^ref:\s+(refs\/heads\/[A-Za-z0-9._/-]+)$/u.exec(head)?.[1];
404
+ if (reference !== undefined) {
405
+ if (reference.split("/").some((part) => part === "." || part === "..")) {
406
+ return topologyError("The authoritative Git HEAD contains an unsafe ref path.", {
407
+ session_id: request.session_id,
408
+ reference,
409
+ });
410
+ }
411
+ const commonDirectory = path.dirname(topology.git_objects);
412
+ const source = path.join(commonDirectory, reference);
413
+ const destination = path.join(topology.git_metadata, reference);
414
+ fs.mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
415
+ copyIfRegular(source, destination);
416
+ }
417
+ const commonDirectory = path.dirname(topology.git_objects);
418
+ copyIfRegular(path.join(commonDirectory, "packed-refs"), path.join(topology.git_metadata, "packed-refs"));
419
+ }
420
+ if (!fs.existsSync(path.join(topology.git_metadata, "HEAD"))) {
421
+ fs.writeFileSync(path.join(topology.git_metadata, "HEAD"), "ref: refs/heads/main\n", { mode: 0o600 });
422
+ }
423
+ return success(null);
424
+ }
425
+ catch (error) {
426
+ return topologyError("Session-private Git metadata could not be prepared.", {
427
+ session_id: request.session_id,
428
+ reason: error instanceof Error ? error.message.slice(0, 200) : "unknown",
429
+ });
430
+ }
431
+ }
432
+ /**
433
+ * Compile one authoritative request into a deterministic bubblewrap argv.
434
+ * Mounts are derived from fixed topology fields; caller data is used only as
435
+ * the command and its argv, after the bwrap `--` terminator.
436
+ */
437
+ export function compileSandboxInvocation(request, command) {
438
+ const executable = validateExecutable(request);
439
+ if (!executable.ok)
440
+ return executable;
441
+ if (command.command.length === 0 || command.command.includes("\0")) {
442
+ return topologyError("The sandbox command must be non-empty and contain no NUL bytes.", {
443
+ command: command.command,
444
+ });
445
+ }
446
+ const commandArgs = [...(command.args ?? [])];
447
+ if (commandArgs.some((argument) => argument.includes("\0"))) {
448
+ return topologyError("Sandbox command arguments cannot contain NUL bytes.", { session_id: request.session_id });
449
+ }
450
+ const topology = validateTopology(request);
451
+ if (!topology.ok)
452
+ return topology;
453
+ const profilePaths = validateProfilePaths(request);
454
+ if (!profilePaths.ok)
455
+ return profilePaths;
456
+ const gitMetadata = prepareGitMetadata(request, topology.value);
457
+ if (!gitMetadata.ok)
458
+ return gitMetadata;
459
+ const pathValue = pathEntriesForEnvironment(request).join(":");
460
+ const args = [
461
+ "--die-with-parent",
462
+ "--new-session",
463
+ "--unshare-user",
464
+ "--unshare-pid",
465
+ "--unshare-ipc",
466
+ "--unshare-uts",
467
+ "--disable-userns",
468
+ "--uid",
469
+ "0",
470
+ "--gid",
471
+ "0",
472
+ "--cap-drop",
473
+ "ALL",
474
+ "--clearenv",
475
+ "--setenv",
476
+ "HOME",
477
+ SANDBOX_HOME,
478
+ "--setenv",
479
+ "TMPDIR",
480
+ "/tmp",
481
+ "--setenv",
482
+ "XDG_CONFIG_HOME",
483
+ SANDBOX_CONFIG_HOME,
484
+ "--setenv",
485
+ "XDG_CACHE_HOME",
486
+ SANDBOX_CACHE_HOME,
487
+ "--setenv",
488
+ "XDG_DATA_HOME",
489
+ `${SANDBOX_LOCAL_HOME}/share`,
490
+ "--setenv",
491
+ "USER",
492
+ "nawabari",
493
+ "--setenv",
494
+ "LOGNAME",
495
+ "nawabari",
496
+ "--setenv",
497
+ "NAWABARI_SESSION_ID",
498
+ request.session_id,
499
+ "--setenv",
500
+ "PATH",
501
+ pathValue,
502
+ "--setenv",
503
+ "GIT_DIR",
504
+ "/nawabari/git",
505
+ "--setenv",
506
+ "GIT_WORK_TREE",
507
+ topology.value.worktree,
508
+ "--setenv",
509
+ "GIT_CONFIG_NOSYSTEM",
510
+ "1",
511
+ "--setenv",
512
+ "GIT_CONFIG_GLOBAL",
513
+ "/dev/null",
514
+ "--tmpfs",
515
+ "/",
516
+ "--dev",
517
+ "/dev",
518
+ "--proc",
519
+ "/proc",
520
+ "--tmpfs",
521
+ "/tmp",
522
+ ];
523
+ const seenDirectories = new Set();
524
+ const worktreeDestination = topology.value.worktree;
525
+ addReadWriteBind(args, topology.value.worktree, worktreeDestination, seenDirectories);
526
+ addReadWriteBind(args, topology.value.home, SANDBOX_HOME, seenDirectories);
527
+ addReadWriteBind(args, topology.value.cache, SANDBOX_CACHE_HOME, seenDirectories);
528
+ addReadWriteBind(args, topology.value.persistent_home, SANDBOX_SHARED_HOME, seenDirectories);
529
+ addReadWriteBind(args, topology.value.git_metadata, "/nawabari/git", seenDirectories);
530
+ addReadOnlyBind(args, topology.value.git_objects, "/nawabari/git/objects", seenDirectories);
531
+ for (const source of request.filesystem.user_tool_paths) {
532
+ const kind = allowedUserToolPath(source, request.filesystem.user_tool_home);
533
+ if (kind === "local_bin")
534
+ addReadOnlyBind(args, source, `${SANDBOX_LOCAL_HOME}/bin`, seenDirectories);
535
+ if (kind === "local_lib")
536
+ addReadOnlyBind(args, source, `${SANDBOX_LOCAL_HOME}/lib`, seenDirectories);
537
+ if (kind === "pnpm_bin") {
538
+ addReadOnlyBind(args, source, `${SANDBOX_LOCAL_HOME}/share/pnpm`, seenDirectories);
539
+ }
540
+ }
541
+ for (const source of request.filesystem.runtime_paths) {
542
+ if (source === "/dev" || source === "/proc" || source === "/tmp")
543
+ continue;
544
+ addReadOnlyBind(args, source, source, seenDirectories);
545
+ }
546
+ for (const source of request.filesystem.system_paths)
547
+ addReadOnlyBind(args, source, source, seenDirectories);
548
+ for (const parent of destinationParents(worktreeDestination)) {
549
+ if (seenDirectories.has(parent))
550
+ continue;
551
+ seenDirectories.add(parent);
552
+ args.push("--dir", parent);
553
+ }
554
+ args.push("--chdir", worktreeDestination, "--", command.command, ...commandArgs);
555
+ const env = {
556
+ PATH: pathValue,
557
+ HOME: SANDBOX_HOME,
558
+ TMPDIR: "/tmp",
559
+ XDG_CONFIG_HOME: SANDBOX_CONFIG_HOME,
560
+ XDG_CACHE_HOME: SANDBOX_CACHE_HOME,
561
+ XDG_DATA_HOME: `${SANDBOX_LOCAL_HOME}/share`,
562
+ USER: "nawabari",
563
+ LOGNAME: "nawabari",
564
+ NAWABARI_SESSION_ID: request.session_id,
565
+ };
566
+ return success({ executable: executable.value, args, cwd: topology.value.worktree, env });
567
+ }
568
+ function boundedOutput(chunks, bytes, maxBytes, value) {
569
+ const text = typeof value === "string" ? value : value.toString("utf8");
570
+ const textBytes = Buffer.byteLength(text, "utf8");
571
+ const remaining = maxBytes - bytes;
572
+ if (remaining <= 0)
573
+ return { bytes, exceeded: textBytes > 0 };
574
+ if (textBytes <= remaining) {
575
+ chunks.push(text);
576
+ return { bytes: bytes + textBytes, exceeded: false };
577
+ }
578
+ const bounded = Buffer.from(text).subarray(0, remaining).toString("utf8");
579
+ chunks.push(bounded);
580
+ return { bytes: maxBytes, exceeded: true };
581
+ }
582
+ /** Execute the compiled invocation with shell execution disabled and no fallback path. */
583
+ export function runSandboxedCommand(request, command, options = {}) {
584
+ const invocation = compileSandboxInvocation(request, command);
585
+ if (!invocation.ok)
586
+ return Promise.resolve(invocation);
587
+ const timeoutMs = options.timeout_ms ?? DEFAULT_TIMEOUT_MS;
588
+ const maxOutputBytes = options.max_output_bytes ?? DEFAULT_MAX_OUTPUT_BYTES;
589
+ if (!Number.isSafeInteger(timeoutMs) ||
590
+ timeoutMs < 1 ||
591
+ !Number.isSafeInteger(maxOutputBytes) ||
592
+ maxOutputBytes < 1) {
593
+ return Promise.resolve(failure(new DomainError("INVALID_ARGUMENT", "Sandbox execution limits must be positive safe integers.", {
594
+ timeout_ms: timeoutMs,
595
+ max_output_bytes: maxOutputBytes,
596
+ })));
597
+ }
598
+ const started = Date.now();
599
+ let child;
600
+ try {
601
+ child = spawn(invocation.value.executable, [...invocation.value.args], {
602
+ cwd: invocation.value.cwd,
603
+ env: { ...invocation.value.env },
604
+ shell: false,
605
+ stdio: ["ignore", "pipe", "pipe"],
606
+ });
607
+ }
608
+ catch (error) {
609
+ return Promise.resolve(failure(new DomainError("SANDBOX_EXECUTION_FAILED", "Bubblewrap could not be started.", {
610
+ reason: error instanceof Error ? error.message.slice(0, 200) : "unknown",
611
+ })));
612
+ }
613
+ return new Promise((resolve) => {
614
+ const stdout = [];
615
+ const stderr = [];
616
+ let stdoutBytes = 0;
617
+ let stderrBytes = 0;
618
+ let timedOut = false;
619
+ let outputExceeded = false;
620
+ let spawnError = null;
621
+ let settled = false;
622
+ const timer = setTimeout(() => {
623
+ timedOut = true;
624
+ child.kill("SIGKILL");
625
+ }, timeoutMs);
626
+ const finish = (code, signal) => {
627
+ if (settled)
628
+ return;
629
+ settled = true;
630
+ clearTimeout(timer);
631
+ if (spawnError !== null) {
632
+ resolve(failure(new DomainError("SANDBOX_EXECUTION_FAILED", "Bubblewrap failed before the command could run.", {
633
+ reason: spawnError.message.slice(0, 200),
634
+ })));
635
+ return;
636
+ }
637
+ if (timedOut) {
638
+ resolve(failure(new DomainError("SANDBOX_EXECUTION_TIMEOUT", "The sandboxed command exceeded its execution timeout.", {
639
+ timeout_ms: timeoutMs,
640
+ })));
641
+ return;
642
+ }
643
+ if (outputExceeded) {
644
+ resolve(failure(new DomainError("SANDBOX_OUTPUT_LIMIT", "The sandboxed command exceeded its output limit.", {
645
+ max_output_bytes: maxOutputBytes,
646
+ })));
647
+ return;
648
+ }
649
+ resolve(success({
650
+ exit_code: code,
651
+ signal,
652
+ stdout: stdout.join(""),
653
+ stderr: stderr.join(""),
654
+ duration_ms: Date.now() - started,
655
+ }));
656
+ };
657
+ child.stdout?.on("data", (value) => {
658
+ const collected = boundedOutput(stdout, stdoutBytes, maxOutputBytes, value);
659
+ stdoutBytes = collected.bytes;
660
+ if (collected.exceeded) {
661
+ outputExceeded = true;
662
+ child.kill("SIGKILL");
663
+ }
664
+ });
665
+ child.stderr?.on("data", (value) => {
666
+ const collected = boundedOutput(stderr, stderrBytes, maxOutputBytes, value);
667
+ stderrBytes = collected.bytes;
668
+ if (collected.exceeded) {
669
+ outputExceeded = true;
670
+ child.kill("SIGKILL");
671
+ }
672
+ });
673
+ child.on("error", (error) => {
674
+ spawnError = error;
675
+ });
676
+ child.on("close", (code, signal) => finish(code, signal));
677
+ });
678
+ }
679
+ //# sourceMappingURL=sandbox-launcher.js.map