@agentforge-ai/sandbox 0.6.0 → 0.7.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.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,75 @@
1
1
  export { DockerSandbox } from './docker-sandbox.js';
2
2
  export { ContainerPool } from './container-pool.js';
3
3
  export { SandboxManager, isDockerAvailable } from './sandbox-manager.js';
4
+ import { SandboxProfile, SandboxProvider, NativeSandboxConfig, ExecOptions, ExecResult } from './types.js';
5
+ export { DockerSandboxConfig, PoolConfig, PoolEntry, ResourceLimits, SandboxManagerConfig } from './types.js';
4
6
  export { BLOCKED_BIND_PREFIXES, DEFAULT_CAP_DROP, SecurityError, validateBind, validateBinds, validateCommand, validateImageName } from './security.js';
5
- export { DockerSandboxConfig, ExecOptions, ExecResult, PoolConfig, PoolEntry, ResourceLimits, SandboxManagerConfig, SandboxProvider } from './types.js';
6
7
  import 'dockerode';
8
+
9
+ /**
10
+ * @module native-sandbox
11
+ *
12
+ * NativeSandbox — a lightweight process-level sandbox using platform-native
13
+ * isolation mechanisms as an alternative to Docker.
14
+ *
15
+ * Platform support:
16
+ * - macOS: `sandbox-exec` with Apple Seatbelt profiles (.sb)
17
+ * - Linux: `bwrap` (Bubblewrap) if available
18
+ * - Fallback: plain child_process with timeout + env filtering (no isolation)
19
+ *
20
+ * Security note: spawn() is used intentionally here because this module is the
21
+ * sandbox itself. Commands are passed through isolation wrappers (sandbox-exec /
22
+ * bwrap) that enforce the security policy. The env is sanitized before use.
23
+ */
24
+
25
+ /**
26
+ * Conservative default profile for native sandboxes.
27
+ * Network access is disabled; only /tmp is accessible.
28
+ */
29
+ declare const DEFAULT_SANDBOX_PROFILE: SandboxProfile;
30
+ /**
31
+ * Detects which native isolation method is available on the current platform.
32
+ *
33
+ * @returns An object indicating availability and the method found.
34
+ */
35
+ declare function isNativeSandboxAvailable(): Promise<{
36
+ available: boolean;
37
+ method: 'sandbox-exec' | 'bwrap' | 'none';
38
+ }>;
39
+ /**
40
+ * A sandbox provider that uses platform-native process isolation instead of Docker.
41
+ *
42
+ * Isolation strategy (in priority order):
43
+ * 1. macOS: `sandbox-exec` with Apple Seatbelt profiles
44
+ * 2. Linux: `bwrap` (Bubblewrap)
45
+ * 3. Fallback: plain child_process with timeout + env filtering (no isolation)
46
+ */
47
+ declare class NativeSandbox implements SandboxProvider {
48
+ private readonly config;
49
+ private readonly profile;
50
+ private readonly id;
51
+ private running;
52
+ private isolationMethod;
53
+ constructor(config: NativeSandboxConfig);
54
+ start(): Promise<void>;
55
+ stop(): Promise<void>;
56
+ destroy(): Promise<void>;
57
+ exec(command: string, options?: ExecOptions): Promise<ExecResult>;
58
+ readFile(filePath: string): Promise<string>;
59
+ writeFile(filePath: string, content: string): Promise<void>;
60
+ isRunning(): Promise<boolean>;
61
+ getContainerId(): string | null;
62
+ /**
63
+ * Validates that a file path is within the allowed filesystem paths defined
64
+ * in the sandbox profile. Throws if the path is not allowed.
65
+ */
66
+ private _validatePath;
67
+ /**
68
+ * Builds a sanitized environment for child processes.
69
+ * Removes sensitive variables (secrets, tokens, credentials) and merges
70
+ * any additional environment variables provided by the caller.
71
+ */
72
+ private _buildSafeEnv;
73
+ }
74
+
75
+ export { DEFAULT_SANDBOX_PROFILE, ExecOptions, ExecResult, NativeSandbox, NativeSandboxConfig, SandboxProfile, SandboxProvider, isNativeSandboxAvailable };
package/dist/index.js CHANGED
@@ -280,11 +280,11 @@ var DockerSandbox = class {
280
280
  *
281
281
  * @param path - Absolute path inside the container.
282
282
  */
283
- async readFile(path) {
284
- const result = await this.exec(`cat "${path.replace(/"/g, '\\"')}"`);
283
+ async readFile(path2) {
284
+ const result = await this.exec(`cat "${path2.replace(/"/g, '\\"')}"`);
285
285
  if (result.exitCode !== 0) {
286
286
  throw new Error(
287
- `DockerSandbox.readFile: failed to read "${path}" (exit ${result.exitCode}): ${result.stderr}`
287
+ `DockerSandbox.readFile: failed to read "${path2}" (exit ${result.exitCode}): ${result.stderr}`
288
288
  );
289
289
  }
290
290
  return result.stdout;
@@ -296,13 +296,13 @@ var DockerSandbox = class {
296
296
  * @param path - Absolute path inside the container.
297
297
  * @param content - UTF-8 string content.
298
298
  */
299
- async writeFile(path, content) {
299
+ async writeFile(path2, content) {
300
300
  const b64 = Buffer.from(content, "utf8").toString("base64");
301
- const cmd = `printf '%s' "${b64}" | base64 -d > "${path.replace(/"/g, '\\"')}"`;
301
+ const cmd = `printf '%s' "${b64}" | base64 -d > "${path2.replace(/"/g, '\\"')}"`;
302
302
  const result = await this.exec(cmd);
303
303
  if (result.exitCode !== 0) {
304
304
  throw new Error(
305
- `DockerSandbox.writeFile: failed to write "${path}" (exit ${result.exitCode}): ${result.stderr}`
305
+ `DockerSandbox.writeFile: failed to write "${path2}" (exit ${result.exitCode}): ${result.stderr}`
306
306
  );
307
307
  }
308
308
  }
@@ -536,7 +536,254 @@ var ContainerPool = class {
536
536
 
537
537
  // src/sandbox-manager.ts
538
538
  import Dockerode2 from "dockerode";
539
+ import { randomUUID as randomUUID4 } from "crypto";
540
+
541
+ // src/native-sandbox.ts
542
+ import { execFile, spawn } from "child_process";
543
+ import { promisify } from "util";
544
+ import fs from "fs/promises";
545
+ import path from "path";
546
+ import os from "os";
539
547
  import { randomUUID as randomUUID3 } from "crypto";
548
+ var execFileAsync = promisify(execFile);
549
+ var DEFAULT_SANDBOX_PROFILE = {
550
+ allowNetwork: false,
551
+ allowFS: ["/tmp"],
552
+ timeout: 30,
553
+ memory: 512
554
+ };
555
+ async function isNativeSandboxAvailable() {
556
+ if (process.platform === "darwin") {
557
+ try {
558
+ await execFileAsync("which", ["sandbox-exec"], { timeout: 5e3 });
559
+ return { available: true, method: "sandbox-exec" };
560
+ } catch {
561
+ }
562
+ }
563
+ if (process.platform === "linux") {
564
+ try {
565
+ await execFileAsync("which", ["bwrap"], { timeout: 5e3 });
566
+ return { available: true, method: "bwrap" };
567
+ } catch {
568
+ }
569
+ }
570
+ return { available: false, method: "none" };
571
+ }
572
+ function generateSeatbeltProfile(profile) {
573
+ const lines = [
574
+ "(version 1)",
575
+ "(deny default)",
576
+ // Always allow process execution so /bin/sh -c works
577
+ "(allow process-exec)",
578
+ "(allow process-fork)",
579
+ // Allow reading system libraries and frameworks
580
+ '(allow file-read* (subpath "/usr/lib"))',
581
+ '(allow file-read* (subpath "/usr/libexec"))',
582
+ '(allow file-read* (subpath "/System/Library"))',
583
+ '(allow file-read* (subpath "/Library/Preferences"))',
584
+ '(allow file-read* (literal "/dev/null"))',
585
+ '(allow file-read* (literal "/dev/urandom"))',
586
+ '(allow file-read* (literal "/dev/random"))',
587
+ // Allow sysctl reads (needed by many programs)
588
+ "(allow sysctl-read)",
589
+ // Allow signal sending to self
590
+ "(allow signal (target self))",
591
+ // Allow mach operations needed for basic process functionality
592
+ "(allow mach-lookup)"
593
+ ];
594
+ for (const allowedPath of profile.allowFS) {
595
+ lines.push(`(allow file-read* (subpath "${allowedPath}"))`);
596
+ lines.push(`(allow file-write* (subpath "${allowedPath}"))`);
597
+ }
598
+ if (profile.allowNetwork) {
599
+ lines.push("(allow network*)");
600
+ }
601
+ return lines.join("\n");
602
+ }
603
+ function buildBwrapArgs(profile, command, cwd) {
604
+ const args = [
605
+ "--ro-bind",
606
+ "/",
607
+ "/",
608
+ "--dev",
609
+ "/dev",
610
+ "--proc",
611
+ "/proc",
612
+ "--tmpfs",
613
+ "/tmp"
614
+ ];
615
+ for (const allowedPath of profile.allowFS) {
616
+ if (allowedPath === "/tmp") continue;
617
+ args.push("--bind", allowedPath, allowedPath);
618
+ }
619
+ if (!profile.allowNetwork) {
620
+ args.push("--unshare-net");
621
+ }
622
+ args.push("--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts");
623
+ if (cwd) {
624
+ args.push("--chdir", cwd);
625
+ }
626
+ args.push("--", "/bin/sh", "-c", command);
627
+ return args;
628
+ }
629
+ var NativeSandbox = class {
630
+ config;
631
+ profile;
632
+ id;
633
+ running = false;
634
+ isolationMethod = "none";
635
+ constructor(config) {
636
+ this.config = config;
637
+ this.profile = config.profile ?? { ...DEFAULT_SANDBOX_PROFILE };
638
+ this.id = `native-${randomUUID3()}`;
639
+ }
640
+ // ---------------------------------------------------------------------------
641
+ // Lifecycle
642
+ // ---------------------------------------------------------------------------
643
+ async start() {
644
+ const { method } = await isNativeSandboxAvailable();
645
+ this.isolationMethod = method;
646
+ if (method === "none") {
647
+ console.warn(
648
+ "[NativeSandbox] No native isolation available (sandbox-exec / bwrap not found). Running with limited isolation (timeout + env filtering only). For stronger isolation, install Docker or use bwrap on Linux."
649
+ );
650
+ } else {
651
+ console.info(`[NativeSandbox] Using isolation method: ${method}`);
652
+ }
653
+ if (this.config.workingDirectory) {
654
+ await fs.mkdir(this.config.workingDirectory, { recursive: true });
655
+ }
656
+ this.running = true;
657
+ }
658
+ async stop() {
659
+ this.running = false;
660
+ }
661
+ async destroy() {
662
+ this.running = false;
663
+ }
664
+ // ---------------------------------------------------------------------------
665
+ // Execution
666
+ // ---------------------------------------------------------------------------
667
+ async exec(command, options) {
668
+ const timeoutMs = options?.timeout ?? this.profile.timeout * 1e3;
669
+ const cwd = options?.cwd ?? this.config.workingDirectory ?? os.tmpdir();
670
+ const extraEnv = options?.env ?? {};
671
+ return new Promise((resolve, reject) => {
672
+ let stdout = "";
673
+ let stderr = "";
674
+ const env = this._buildSafeEnv(extraEnv);
675
+ let proc;
676
+ if (this.isolationMethod === "sandbox-exec") {
677
+ const sbProfile = generateSeatbeltProfile(this.profile);
678
+ proc = spawn("sandbox-exec", ["-p", sbProfile, "/bin/sh", "-c", command], {
679
+ cwd,
680
+ env,
681
+ timeout: timeoutMs
682
+ });
683
+ } else if (this.isolationMethod === "bwrap") {
684
+ const bwrapArgs = buildBwrapArgs(this.profile, command, cwd);
685
+ proc = spawn("bwrap", bwrapArgs, {
686
+ env,
687
+ timeout: timeoutMs
688
+ });
689
+ } else {
690
+ proc = spawn("/bin/sh", ["-c", command], {
691
+ cwd,
692
+ env,
693
+ timeout: timeoutMs
694
+ });
695
+ }
696
+ proc.stdout?.on("data", (chunk) => {
697
+ stdout += chunk.toString();
698
+ });
699
+ proc.stderr?.on("data", (chunk) => {
700
+ stderr += chunk.toString();
701
+ });
702
+ proc.on("error", (err) => {
703
+ reject(new Error(`[NativeSandbox] exec error: ${err.message}`));
704
+ });
705
+ proc.on("close", (code) => {
706
+ resolve({
707
+ stdout,
708
+ stderr,
709
+ exitCode: code ?? 1
710
+ });
711
+ });
712
+ });
713
+ }
714
+ // ---------------------------------------------------------------------------
715
+ // File I/O (with path validation)
716
+ // ---------------------------------------------------------------------------
717
+ async readFile(filePath) {
718
+ this._validatePath(filePath);
719
+ return fs.readFile(filePath, "utf-8");
720
+ }
721
+ async writeFile(filePath, content) {
722
+ this._validatePath(filePath);
723
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
724
+ await fs.writeFile(filePath, content, "utf-8");
725
+ }
726
+ // ---------------------------------------------------------------------------
727
+ // Health
728
+ // ---------------------------------------------------------------------------
729
+ async isRunning() {
730
+ return this.running;
731
+ }
732
+ getContainerId() {
733
+ return this.running ? this.id : null;
734
+ }
735
+ // ---------------------------------------------------------------------------
736
+ // Private helpers
737
+ // ---------------------------------------------------------------------------
738
+ /**
739
+ * Validates that a file path is within the allowed filesystem paths defined
740
+ * in the sandbox profile. Throws if the path is not allowed.
741
+ */
742
+ _validatePath(filePath) {
743
+ const resolved = path.resolve(filePath);
744
+ const allowed = this.profile.allowFS.some((allowedPath) => {
745
+ const resolvedAllowed = path.resolve(allowedPath);
746
+ return resolved.startsWith(resolvedAllowed + path.sep) || resolved === resolvedAllowed;
747
+ });
748
+ if (!allowed) {
749
+ throw new Error(
750
+ `[NativeSandbox] Access denied: path "${filePath}" is not within allowed filesystem paths: ` + JSON.stringify(this.profile.allowFS)
751
+ );
752
+ }
753
+ }
754
+ /**
755
+ * Builds a sanitized environment for child processes.
756
+ * Removes sensitive variables (secrets, tokens, credentials) and merges
757
+ * any additional environment variables provided by the caller.
758
+ */
759
+ _buildSafeEnv(extra) {
760
+ const SENSITIVE_PATTERNS = [
761
+ /SECRET/i,
762
+ /TOKEN/i,
763
+ /PASSWORD/i,
764
+ /PASSWD/i,
765
+ /API_KEY/i,
766
+ /PRIVATE_KEY/i,
767
+ /CREDENTIAL/i,
768
+ /AUTH/i,
769
+ /AWS_/i,
770
+ /GCP_/i,
771
+ /AZURE_/i
772
+ ];
773
+ const safeEnv = {};
774
+ for (const [key, value] of Object.entries(process.env)) {
775
+ if (value === void 0) continue;
776
+ const isSensitive = SENSITIVE_PATTERNS.some((pattern) => pattern.test(key));
777
+ if (!isSensitive) {
778
+ safeEnv[key] = value;
779
+ }
780
+ }
781
+ Object.assign(safeEnv, extra);
782
+ return safeEnv;
783
+ }
784
+ };
785
+
786
+ // src/sandbox-manager.ts
540
787
  var E2BProviderStub = class {
541
788
  async start() {
542
789
  throw new Error(
@@ -609,6 +856,16 @@ var SandboxManager = class {
609
856
  );
610
857
  }
611
858
  }
859
+ if (this.config.provider === "native") {
860
+ const { available, method } = await isNativeSandboxAvailable();
861
+ if (!available) {
862
+ console.warn(
863
+ "[SandboxManager] No native isolation method found (sandbox-exec / bwrap). Sandboxes will run with limited isolation (timeout + env filtering only)."
864
+ );
865
+ } else {
866
+ console.info(`[SandboxManager] Native isolation available: ${method}`);
867
+ }
868
+ }
612
869
  this._registerShutdownHandlers();
613
870
  }
614
871
  /**
@@ -626,6 +883,18 @@ var SandboxManager = class {
626
883
  this.active.set(id2, stub);
627
884
  return stub;
628
885
  }
886
+ if (this.config.provider === "native") {
887
+ const nativeConfig = {
888
+ scope: overrides.scope,
889
+ profile: this.config.nativeConfig?.profile,
890
+ workingDirectory: this.config.nativeConfig?.workingDirectory
891
+ };
892
+ const sandbox2 = new NativeSandbox(nativeConfig);
893
+ await sandbox2.start();
894
+ const id2 = this._generateId(overrides.scope);
895
+ this.active.set(id2, sandbox2);
896
+ return sandbox2;
897
+ }
629
898
  const mergedConfig = {
630
899
  image: "node:22-slim",
631
900
  ...this.config.dockerConfig,
@@ -672,7 +941,7 @@ var SandboxManager = class {
672
941
  // Private helpers
673
942
  // ---------------------------------------------------------------------------
674
943
  _generateId(scope) {
675
- return `agentforge-${scope}-${randomUUID3().slice(0, 8)}`;
944
+ return `agentforge-${scope}-${randomUUID4().slice(0, 8)}`;
676
945
  }
677
946
  _registerShutdownHandlers() {
678
947
  if (this.shutdownRegistered) return;
@@ -689,10 +958,13 @@ export {
689
958
  BLOCKED_BIND_PREFIXES,
690
959
  ContainerPool,
691
960
  DEFAULT_CAP_DROP,
961
+ DEFAULT_SANDBOX_PROFILE,
692
962
  DockerSandbox,
963
+ NativeSandbox,
693
964
  SandboxManager,
694
965
  SecurityError,
695
966
  isDockerAvailable,
967
+ isNativeSandboxAvailable,
696
968
  validateBind,
697
969
  validateBinds,
698
970
  validateCommand,