@anvia/sandbox 1.1.1 → 1.1.3

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/README.md CHANGED
@@ -32,11 +32,27 @@ Networking is explicit. Use `{ mode: "none" }` or `{ mode: "bridge", ports: [...
32
32
  bind only to `127.0.0.1`. Runtime methods use object arguments, propagate abort signals, and expose
33
33
  command and process output as bytes. Tool wrappers decode UTF-8 strictly and return structured values.
34
34
 
35
+ With `exec.commands.mode: "allow"`, both `exec_command` and `start_process` reject known shell
36
+ executables by default, including path-qualified names such as `/bin/sh`. To permit an allowlisted
37
+ shell, set `exec.commands.allowShellInterpreters` to the boolean `true`. Omitted or `false` keeps the
38
+ guard enabled; non-boolean values such as `"false"` are rejected when creating tools. This guard
39
+ checks executable names only: allowlisted runtimes such as Node.js or Python can still launch other
40
+ commands, so the command policy does not restrict what those programs can execute.
41
+
35
42
  `resources.sharedMemoryMb` maps to a private Docker `/dev/shm` size. A security configuration may use
36
43
  explicit `dropCapabilities` and `addCapabilities` arrays, plus
37
44
  `seccompProfile: { type: "path", path }` with an absolute host path. These options are used by
38
45
  `@anvia/browser` to keep Chromium's own process sandbox enabled.
39
46
 
47
+ `createSandbox()` accepts `containerRuntime` — the runtime name registered in the Docker daemon (for
48
+ example `runsc` for gVisor). The runtime must already be registered (`runsc install` followed by a
49
+ daemon restart); creation fails with a `runtime_not_found` error otherwise. `resumeSandbox()` keeps
50
+ the container's original runtime. Under gVisor, workspace files persist through
51
+ `stop()`/`resumeSandbox()` because they live on a Docker volume, but changes to the container's
52
+ writable rootfs outside the workspace do not survive a resume, and `security.seccompProfile` is not
53
+ enforced the way it is on the default runtime because the gVisor sentry mediates application
54
+ syscalls itself.
55
+
40
56
  Studio does not discover sandboxes through tool metadata. Register a read-only inspector explicitly:
41
57
 
42
58
  ```ts
@@ -131,4 +131,4 @@ export {
131
131
  assertDockerCli,
132
132
  decodeUtf8
133
133
  };
134
- //# sourceMappingURL=chunk-4D2GWLEH.js.map
134
+ //# sourceMappingURL=chunk-D5XZQ2ES.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/docker-cli.ts"],"sourcesContent":["export type DockerSandboxErrorCode =\n | \"docker_unavailable\"\n | \"docker_command_failed\"\n | \"image_not_found\"\n | \"runtime_not_found\"\n | \"volume_not_found\"\n | \"sandbox_not_found\"\n | \"invalid_state\"\n | \"invalid_path\"\n | \"timeout\"\n | \"file_too_large\"\n | \"tool_policy\"\n | \"port\"\n | \"process\";\n\nexport class DockerSandboxError extends Error {\n constructor(\n message: string,\n readonly code: DockerSandboxErrorCode,\n readonly details?: unknown,\n options?: ErrorOptions,\n ) {\n super(message, options);\n this.name = \"DockerSandboxError\";\n }\n}\n\nexport function dockerSandboxError(\n message: string,\n code: DockerSandboxErrorCode,\n cause?: unknown,\n details?: unknown,\n): DockerSandboxError {\n return new DockerSandboxError(\n message,\n code,\n details,\n cause === undefined ? undefined : { cause },\n );\n}\n","import { spawn } from \"node:child_process\";\nimport { DockerSandboxError } from \"./errors\";\n\nexport interface DockerCliResult {\n stdout: Uint8Array;\n stderr: Uint8Array;\n exitCode: number;\n durationMs: number;\n timedOut: boolean;\n stdoutTruncated: boolean;\n stderrTruncated: boolean;\n}\n\nexport interface DockerCliOptions {\n dockerPath: string;\n timeoutMs?: number | undefined;\n maxOutputBytes?: number | undefined;\n input?: string | Uint8Array | undefined;\n signal?: AbortSignal | undefined;\n onStdout?: (chunk: Uint8Array) => void;\n onStderr?: (chunk: Uint8Array) => void;\n}\n\nconst defaultMaxOutputBytes = 1024 * 1024;\n\nexport async function runDockerCli(\n args: string[],\n options: DockerCliOptions,\n): Promise<DockerCliResult> {\n const startedAt = Date.now();\n const maxOutputBytes = options.maxOutputBytes ?? defaultMaxOutputBytes;\n const stdout = createOutputCollector(maxOutputBytes, options.onStdout);\n const stderr = createOutputCollector(maxOutputBytes, options.onStderr);\n\n return new Promise((resolve, reject) => {\n const child = spawn(options.dockerPath, args, {\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n\n let timedOut = false;\n let settled = false;\n\n const timeout =\n options.timeoutMs === undefined\n ? undefined\n : setTimeout(() => {\n timedOut = true;\n child.kill(\"SIGKILL\");\n }, options.timeoutMs);\n\n const abort = () => {\n child.kill(\"SIGKILL\");\n };\n\n if (options.signal?.aborted === true) {\n abort();\n } else {\n options.signal?.addEventListener(\"abort\", abort, { once: true });\n }\n\n child.stdout.on(\"data\", (chunk: Buffer) => stdout.accept(chunk));\n child.stderr.on(\"data\", (chunk: Buffer) => stderr.accept(chunk));\n\n child.on(\"error\", (error) => {\n if (settled) {\n return;\n }\n settled = true;\n clearTimeout(timeout);\n options.signal?.removeEventListener(\"abort\", abort);\n\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") {\n reject(\n new DockerSandboxError(\"Docker CLI was not found.\", \"docker_unavailable\", undefined, {\n cause: error,\n }),\n );\n return;\n }\n\n reject(error);\n });\n\n child.on(\"close\", (code) => {\n if (settled) {\n return;\n }\n settled = true;\n clearTimeout(timeout);\n options.signal?.removeEventListener(\"abort\", abort);\n\n if (options.signal?.aborted === true) {\n reject(options.signal.reason ?? new DOMException(\"Aborted\", \"AbortError\"));\n return;\n }\n\n resolve({\n stdout: stdout.bytes(),\n stderr: stderr.bytes(),\n exitCode: code ?? 1,\n durationMs: Date.now() - startedAt,\n timedOut,\n stdoutTruncated: stdout.truncated,\n stderrTruncated: stderr.truncated,\n });\n });\n\n if (options.input !== undefined) {\n child.stdin.end(options.input);\n } else {\n child.stdin.end();\n }\n });\n}\n\nexport async function assertDockerCli(args: string[], options: DockerCliOptions): Promise<void> {\n const result = await runDockerCli(args, options);\n\n if (result.exitCode !== 0) {\n throw new DockerSandboxError(\n `Docker command failed: docker ${args.join(\" \")}`,\n \"docker_command_failed\",\n result,\n );\n }\n}\n\nfunction createOutputCollector(maxBytes: number, onChunk?: (chunk: Uint8Array) => void) {\n const chunks: Buffer[] = [];\n let length = 0;\n let truncated = false;\n\n return {\n get truncated() {\n return truncated;\n },\n accept(chunk: Buffer) {\n onChunk?.(chunk);\n\n if (length >= maxBytes) {\n truncated = true;\n return;\n }\n\n const remaining = maxBytes - length;\n const next = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;\n chunks.push(next);\n length += next.length;\n\n if (next.length < chunk.length) {\n truncated = true;\n }\n },\n bytes() {\n const bytes = Buffer.concat(chunks, length);\n return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength).slice();\n },\n };\n}\n\nexport function decodeUtf8(bytes: Uint8Array): string {\n return new TextDecoder(\"utf-8\", { fatal: true }).decode(bytes);\n}\n"],"mappings":";AAeO,IAAM,qBAAN,cAAiC,MAAM;AAAA,EAC5C,YACE,SACS,MACA,SACT,SACA;AACA,UAAM,SAAS,OAAO;AAJb;AACA;AAIT,SAAK,OAAO;AAAA,EACd;AAAA,EANW;AAAA,EACA;AAMb;;;ACzBA,SAAS,aAAa;AAuBtB,IAAM,wBAAwB,OAAO;AAErC,eAAsB,aACpB,MACA,SAC0B;AAC1B,QAAM,YAAY,KAAK,IAAI;AAC3B,QAAM,iBAAiB,QAAQ,kBAAkB;AACjD,QAAM,SAAS,sBAAsB,gBAAgB,QAAQ,QAAQ;AACrE,QAAM,SAAS,sBAAsB,gBAAgB,QAAQ,QAAQ;AAErE,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,UAAM,QAAQ,MAAM,QAAQ,YAAY,MAAM;AAAA,MAC5C,OAAO,CAAC,QAAQ,QAAQ,MAAM;AAAA,IAChC,CAAC;AAED,QAAI,WAAW;AACf,QAAI,UAAU;AAEd,UAAM,UACJ,QAAQ,cAAc,SAClB,SACA,WAAW,MAAM;AACf,iBAAW;AACX,YAAM,KAAK,SAAS;AAAA,IACtB,GAAG,QAAQ,SAAS;AAE1B,UAAM,QAAQ,MAAM;AAClB,YAAM,KAAK,SAAS;AAAA,IACtB;AAEA,QAAI,QAAQ,QAAQ,YAAY,MAAM;AACpC,YAAM;AAAA,IACR,OAAO;AACL,cAAQ,QAAQ,iBAAiB,SAAS,OAAO,EAAE,MAAM,KAAK,CAAC;AAAA,IACjE;AAEA,UAAM,OAAO,GAAG,QAAQ,CAAC,UAAkB,OAAO,OAAO,KAAK,CAAC;AAC/D,UAAM,OAAO,GAAG,QAAQ,CAAC,UAAkB,OAAO,OAAO,KAAK,CAAC;AAE/D,UAAM,GAAG,SAAS,CAAC,UAAU;AAC3B,UAAI,SAAS;AACX;AAAA,MACF;AACA,gBAAU;AACV,mBAAa,OAAO;AACpB,cAAQ,QAAQ,oBAAoB,SAAS,KAAK;AAElD,UAAK,MAAgC,SAAS,UAAU;AACtD;AAAA,UACE,IAAI,mBAAmB,6BAA6B,sBAAsB,QAAW;AAAA,YACnF,OAAO;AAAA,UACT,CAAC;AAAA,QACH;AACA;AAAA,MACF;AAEA,aAAO,KAAK;AAAA,IACd,CAAC;AAED,UAAM,GAAG,SAAS,CAAC,SAAS;AAC1B,UAAI,SAAS;AACX;AAAA,MACF;AACA,gBAAU;AACV,mBAAa,OAAO;AACpB,cAAQ,QAAQ,oBAAoB,SAAS,KAAK;AAElD,UAAI,QAAQ,QAAQ,YAAY,MAAM;AACpC,eAAO,QAAQ,OAAO,UAAU,IAAI,aAAa,WAAW,YAAY,CAAC;AACzE;AAAA,MACF;AAEA,cAAQ;AAAA,QACN,QAAQ,OAAO,MAAM;AAAA,QACrB,QAAQ,OAAO,MAAM;AAAA,QACrB,UAAU,QAAQ;AAAA,QAClB,YAAY,KAAK,IAAI,IAAI;AAAA,QACzB;AAAA,QACA,iBAAiB,OAAO;AAAA,QACxB,iBAAiB,OAAO;AAAA,MAC1B,CAAC;AAAA,IACH,CAAC;AAED,QAAI,QAAQ,UAAU,QAAW;AAC/B,YAAM,MAAM,IAAI,QAAQ,KAAK;AAAA,IAC/B,OAAO;AACL,YAAM,MAAM,IAAI;AAAA,IAClB;AAAA,EACF,CAAC;AACH;AAEA,eAAsB,gBAAgB,MAAgB,SAA0C;AAC9F,QAAM,SAAS,MAAM,aAAa,MAAM,OAAO;AAE/C,MAAI,OAAO,aAAa,GAAG;AACzB,UAAM,IAAI;AAAA,MACR,iCAAiC,KAAK,KAAK,GAAG,CAAC;AAAA,MAC/C;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACF;AAEA,SAAS,sBAAsB,UAAkB,SAAuC;AACtF,QAAM,SAAmB,CAAC;AAC1B,MAAI,SAAS;AACb,MAAI,YAAY;AAEhB,SAAO;AAAA,IACL,IAAI,YAAY;AACd,aAAO;AAAA,IACT;AAAA,IACA,OAAO,OAAe;AACpB,gBAAU,KAAK;AAEf,UAAI,UAAU,UAAU;AACtB,oBAAY;AACZ;AAAA,MACF;AAEA,YAAM,YAAY,WAAW;AAC7B,YAAM,OAAO,MAAM,SAAS,YAAY,MAAM,SAAS,GAAG,SAAS,IAAI;AACvE,aAAO,KAAK,IAAI;AAChB,gBAAU,KAAK;AAEf,UAAI,KAAK,SAAS,MAAM,QAAQ;AAC9B,oBAAY;AAAA,MACd;AAAA,IACF;AAAA,IACA,QAAQ;AACN,YAAM,QAAQ,OAAO,OAAO,QAAQ,MAAM;AAC1C,aAAO,IAAI,WAAW,MAAM,QAAQ,MAAM,YAAY,MAAM,UAAU,EAAE,MAAM;AAAA,IAChF;AAAA,EACF;AACF;AAEO,SAAS,WAAW,OAA2B;AACpD,SAAO,IAAI,YAAY,SAAS,EAAE,OAAO,KAAK,CAAC,EAAE,OAAO,KAAK;AAC/D;","names":[]}
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  assertDockerCli
4
- } from "./chunk-4D2GWLEH.js";
4
+ } from "./chunk-D5XZQ2ES.js";
5
5
 
6
6
  // src/cli.ts
7
7
  import { lstat, mkdir, readFile, rm, writeFile } from "fs/promises";
package/dist/index.d.ts CHANGED
@@ -42,6 +42,11 @@ type CreateDockerSandboxOptions = Readonly<{
42
42
  workdir?: string;
43
43
  workspace: DockerSandboxWorkspace;
44
44
  network: DockerSandboxNetwork;
45
+ /** Docker runtime used to run the container, as registered in the daemon
46
+ * (for example "runsc" for gVisor). The runtime must already be registered;
47
+ * createSandbox() fails with `runtime_not_found` otherwise. Omitted means
48
+ * the daemon default. resumeSandbox() keeps the container's runtime. */
49
+ containerRuntime?: string;
45
50
  files?: Readonly<Record<string, string | Uint8Array>>;
46
51
  directories?: readonly string[];
47
52
  env?: Readonly<Record<string, string>>;
@@ -208,6 +213,7 @@ type DockerSandboxInspector = Readonly<{
208
213
  id: string;
209
214
  provider: "docker";
210
215
  workdir: string;
216
+ containerRuntime: string;
211
217
  listFiles?: DockerSandboxRuntime["listFiles"];
212
218
  readFile?: DockerSandboxRuntime["readFile"];
213
219
  publishedPorts?: readonly DockerSandboxPublishedPort[];
@@ -229,6 +235,7 @@ type DockerSandboxToolName = "exec_command" | "read_file" | "write_file" | "list
229
235
  type DockerSandboxCommandPolicy = Readonly<{
230
236
  mode: "allow";
231
237
  values: readonly string[];
238
+ allowShellInterpreters?: boolean;
232
239
  }> | Readonly<{
233
240
  mode: "block";
234
241
  values: readonly string[];
@@ -270,10 +277,11 @@ declare class DockerSandboxClient {
270
277
  private assertImageExists;
271
278
  private assertContainerDoesNotExist;
272
279
  private assertVolumeExists;
280
+ private assertRuntimeAvailable;
273
281
  private cliOptions;
274
282
  }
275
283
 
276
- type DockerSandboxErrorCode = "docker_unavailable" | "docker_command_failed" | "image_not_found" | "volume_not_found" | "sandbox_not_found" | "invalid_state" | "invalid_path" | "timeout" | "file_too_large" | "tool_policy" | "port" | "process";
284
+ type DockerSandboxErrorCode = "docker_unavailable" | "docker_command_failed" | "image_not_found" | "runtime_not_found" | "volume_not_found" | "sandbox_not_found" | "invalid_state" | "invalid_path" | "timeout" | "file_too_large" | "tool_policy" | "port" | "process";
277
285
  declare class DockerSandboxError extends Error {
278
286
  readonly code: DockerSandboxErrorCode;
279
287
  readonly details?: unknown | undefined;
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  assertDockerCli,
4
4
  decodeUtf8,
5
5
  runDockerCli
6
- } from "./chunk-4D2GWLEH.js";
6
+ } from "./chunk-D5XZQ2ES.js";
7
7
 
8
8
  // src/docker-sandbox.ts
9
9
  import { randomUUID as randomUUID2 } from "crypto";
@@ -610,6 +610,7 @@ var labels = {
610
610
  workspaceType: `${labelPrefix}workspace.type`,
611
611
  workspaceVolume: `${labelPrefix}workspace.volume`,
612
612
  networkMode: `${labelPrefix}network.mode`,
613
+ containerRuntime: `${labelPrefix}container-runtime`,
613
614
  commandTimeoutMs: `${labelPrefix}runtime.command-timeout-ms`,
614
615
  maxOutputBytes: `${labelPrefix}runtime.max-output-bytes`,
615
616
  maxFileBytes: `${labelPrefix}runtime.max-file-bytes`,
@@ -650,6 +651,7 @@ var DockerSandboxClient = class {
650
651
  assertSandboxId(id);
651
652
  const containerName = containerNameFor(id);
652
653
  const workdir = options.workdir ?? defaultWorkdir;
654
+ const containerRuntime = options.containerRuntime ?? "default";
653
655
  const runtime = resolveRuntimeLimits(options.runtime);
654
656
  const workspace = copyWorkspace(options.workspace);
655
657
  const network = copyNetwork(options.network);
@@ -662,6 +664,7 @@ var DockerSandboxClient = class {
662
664
  if (workspace.type === "docker-volume") {
663
665
  await this.assertVolumeExists(workspace.name, options.abortSignal);
664
666
  }
667
+ await this.assertRuntimeAvailable(containerRuntime, options.abortSignal);
665
668
  let containerCreated = false;
666
669
  let volumeCreated = false;
667
670
  try {
@@ -678,6 +681,7 @@ var DockerSandboxClient = class {
678
681
  containerName,
679
682
  image: options.image,
680
683
  workdir,
684
+ containerRuntime,
681
685
  workspace,
682
686
  volumeName,
683
687
  env,
@@ -704,6 +708,7 @@ var DockerSandboxClient = class {
704
708
  id,
705
709
  containerName,
706
710
  workdir,
711
+ containerRuntime,
707
712
  workspace,
708
713
  volumeName,
709
714
  ownsVolume,
@@ -818,6 +823,37 @@ var DockerSandboxClient = class {
818
823
  result
819
824
  );
820
825
  }
826
+ async assertRuntimeAvailable(name, abortSignal) {
827
+ if (name === "default") return;
828
+ const result = await runDockerCli(["info", "--format", "{{json .Runtimes}}"], {
829
+ ...this.cliOptions(abortSignal),
830
+ maxOutputBytes: defaultMaxOutputBytes
831
+ });
832
+ if (result.exitCode !== 0) {
833
+ throw new DockerSandboxError(
834
+ "Unable to inspect Docker runtimes.",
835
+ "docker_command_failed",
836
+ result
837
+ );
838
+ }
839
+ let runtimes;
840
+ try {
841
+ runtimes = JSON.parse(decodeUtf8(result.stdout));
842
+ } catch (error) {
843
+ throw new DockerSandboxError(
844
+ "Docker returned invalid runtime metadata.",
845
+ "docker_command_failed",
846
+ void 0,
847
+ { cause: error }
848
+ );
849
+ }
850
+ if (!isRecord(runtimes) || !(name in runtimes)) {
851
+ throw new DockerSandboxError(
852
+ `Docker runtime is not registered in the daemon: ${JSON.stringify(name)}. Install the runtime (for gVisor: runsc install) and restart the Docker daemon.`,
853
+ "runtime_not_found"
854
+ );
855
+ }
856
+ }
821
857
  cliOptions(abortSignal) {
822
858
  return { dockerPath: this.dockerPath, signal: abortSignal };
823
859
  }
@@ -853,7 +889,8 @@ var DockerSandboxHandle = class {
853
889
  let inspector = {
854
890
  id: this.id,
855
891
  provider: "docker",
856
- workdir: this.configuration.workdir
892
+ workdir: this.configuration.workdir,
893
+ containerRuntime: this.configuration.containerRuntime
857
894
  };
858
895
  if (options.files === true) {
859
896
  inspector = {
@@ -1370,6 +1407,7 @@ function createRunArgs(options) {
1370
1407
  [labels.workspaceType]: options.workspace.type,
1371
1408
  [labels.workspaceVolume]: options.volumeName,
1372
1409
  [labels.networkMode]: options.network.mode,
1410
+ [labels.containerRuntime]: options.containerRuntime,
1373
1411
  [labels.commandTimeoutMs]: `${options.runtime.commandTimeoutMs}`,
1374
1412
  [labels.maxOutputBytes]: `${options.runtime.maxOutputBytes}`,
1375
1413
  [labels.maxFileBytes]: `${options.runtime.maxFileBytes}`,
@@ -1385,6 +1423,9 @@ function createRunArgs(options) {
1385
1423
  "-w",
1386
1424
  options.workdir
1387
1425
  ];
1426
+ if (options.containerRuntime !== "default") {
1427
+ args.push("--runtime", options.containerRuntime);
1428
+ }
1388
1429
  for (const [key, value] of Object.entries({ ...options.userLabels, ...runtimeLabels })) {
1389
1430
  args.push("--label", `${key}=${value}`);
1390
1431
  }
@@ -1464,6 +1505,9 @@ function validateCreateOptions(options) {
1464
1505
  if (key.startsWith(labelPrefix)) throw new TypeError(`Docker label is reserved: ${key}`);
1465
1506
  }
1466
1507
  if (options.user !== void 0) assertNonEmptyString(options.user, "user");
1508
+ if (options.containerRuntime !== void 0) {
1509
+ assertNonEmptyString(options.containerRuntime, "containerRuntime");
1510
+ }
1467
1511
  if (options.directories !== void 0 && !Array.isArray(options.directories)) {
1468
1512
  throw new TypeError("directories must be an array.");
1469
1513
  }
@@ -1606,6 +1650,9 @@ function snapshotCreateOptions(options) {
1606
1650
  };
1607
1651
  if (options.id !== void 0) snapshot = { ...snapshot, id: options.id };
1608
1652
  if (options.workdir !== void 0) snapshot = { ...snapshot, workdir: options.workdir };
1653
+ if (options.containerRuntime !== void 0) {
1654
+ snapshot = { ...snapshot, containerRuntime: options.containerRuntime };
1655
+ }
1609
1656
  if (options.files !== void 0) snapshot = { ...snapshot, files: Object.freeze(files) };
1610
1657
  if (options.directories !== void 0) {
1611
1658
  snapshot = { ...snapshot, directories: Object.freeze([...options.directories]) };
@@ -1664,6 +1711,9 @@ function configurationFromInspection(id, containerName, inspection) {
1664
1711
  const workspaceType = requiredLabel(containerLabels, labels.workspaceType);
1665
1712
  const volumeName = requiredLabel(containerLabels, labels.workspaceVolume);
1666
1713
  const networkMode = requiredLabel(containerLabels, labels.networkMode);
1714
+ const labeledRuntime = containerLabels[labels.containerRuntime];
1715
+ const hostRuntime = inspection.HostConfig?.Runtime;
1716
+ const containerRuntime = labeledRuntime !== void 0 && labeledRuntime !== "" ? labeledRuntime : hostRuntime !== void 0 && hostRuntime !== "" ? hostRuntime : "default";
1667
1717
  if (networkMode !== "none" && networkMode !== "bridge") invalidInspection("network mode");
1668
1718
  const workspace = workspaceType === "ephemeral" ? { type: "ephemeral" } : workspaceType === "docker-volume" ? { type: "docker-volume", name: volumeName } : invalidInspection("workspace type");
1669
1719
  const runtime = {
@@ -1675,6 +1725,7 @@ function configurationFromInspection(id, containerName, inspection) {
1675
1725
  return {
1676
1726
  id,
1677
1727
  containerName,
1728
+ containerRuntime,
1678
1729
  workdir,
1679
1730
  workspace,
1680
1731
  volumeName,
@@ -1926,6 +1977,22 @@ function isRecord(value) {
1926
1977
  // src/tools.ts
1927
1978
  import { createTool } from "@anvia/core/tool";
1928
1979
  import { z } from "zod";
1980
+
1981
+ // src/types.ts
1982
+ var shellInterpreters = [
1983
+ "sh",
1984
+ "bash",
1985
+ "zsh",
1986
+ "ksh",
1987
+ "dash",
1988
+ "ash",
1989
+ "busybox",
1990
+ "fish",
1991
+ "csh",
1992
+ "tcsh"
1993
+ ];
1994
+
1995
+ // src/tools.ts
1929
1996
  var allToolNames = [
1930
1997
  "exec_command",
1931
1998
  "read_file",
@@ -2361,7 +2428,8 @@ function snapshotFactoryOptions(options) {
2361
2428
  const toolNames = Object.freeze([...options.tools]);
2362
2429
  const commands = options.exec?.commands === void 0 ? void 0 : Object.freeze({
2363
2430
  mode: options.exec.commands.mode,
2364
- values: Object.freeze([...options.exec.commands.values])
2431
+ values: Object.freeze([...options.exec.commands.values]),
2432
+ ...options.exec.commands.mode === "allow" && options.exec.commands.allowShellInterpreters !== void 0 ? { allowShellInterpreters: options.exec.commands.allowShellInterpreters } : {}
2365
2433
  });
2366
2434
  let snapshot = {
2367
2435
  sandbox: options.sandbox,
@@ -2389,6 +2457,9 @@ function validateCommandPolicy(policy) {
2389
2457
  if (policy.mode !== "allow" && policy.mode !== "block") {
2390
2458
  throw toolPolicyError("exec.commands must use mode allow or block.");
2391
2459
  }
2460
+ if (policy.mode === "allow" && policy.allowShellInterpreters !== void 0 && typeof policy.allowShellInterpreters !== "boolean") {
2461
+ throw toolPolicyError("exec.commands.allowShellInterpreters must be a boolean.");
2462
+ }
2392
2463
  if (!Array.isArray(policy.values))
2393
2464
  throw toolPolicyError("exec.commands.values must be an array.");
2394
2465
  const seen = /* @__PURE__ */ new Set();
@@ -2407,6 +2478,14 @@ function assertCommandAllowed(command, policy) {
2407
2478
  if (policy.mode === "allow" && !included || policy.mode === "block" && included) {
2408
2479
  throw toolPolicyError(`Command is rejected by sandbox tool policy: ${command}`);
2409
2480
  }
2481
+ if (policy.mode === "allow" && policy.allowShellInterpreters !== true) {
2482
+ const commandBasename = command.split("/").pop() ?? command;
2483
+ if (shellInterpreters.includes(commandBasename)) {
2484
+ throw toolPolicyError(
2485
+ `Command is rejected by sandbox tool policy: ${command} (shell interpreter not allowed)`
2486
+ );
2487
+ }
2488
+ }
2410
2489
  }
2411
2490
  function assertTimeoutAllowed(timeoutMs, maxTimeoutMs) {
2412
2491
  if (timeoutMs !== void 0 && maxTimeoutMs !== void 0 && timeoutMs > maxTimeoutMs) {