@zhijiewang/openharness 2.32.0 → 2.33.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.
@@ -214,6 +214,34 @@ export type OhConfig = {
214
214
  rateLimit?: number;
215
215
  allowedTools?: string[];
216
216
  };
217
+ /**
218
+ * Opt-in OS-level sandbox via `@anthropic-ai/sandbox-runtime` (an optional
219
+ * dependency). When `enabled: true`, BashTool wraps every command in
220
+ * bubblewrap (Linux) or sandbox-exec (macOS) plus a domain-allowlist
221
+ * network proxy. Windows isn't supported by the package — the wrap is a
222
+ * silent passthrough there. Off by default; users opt in via config or the
223
+ * `--sandbox` CLI flag.
224
+ *
225
+ * `network.allowedDomains` is the proxy allowlist (e.g. `["github.com",
226
+ * "registry.npmjs.org"]`); `deniedDomains` blocks specific hosts before
227
+ * the allowlist applies. `filesystem.allowWrite` defaults to `[cwd]` —
228
+ * the sandbox can write to the project tree but nowhere else.
229
+ *
230
+ * See `src/harness/sandbox-runtime.ts` and SECURITY.md for the full
231
+ * threat-model boundary.
232
+ */
233
+ sandbox?: {
234
+ enabled?: boolean;
235
+ network?: {
236
+ allowedDomains?: string[];
237
+ deniedDomains?: string[];
238
+ };
239
+ filesystem?: {
240
+ allowWrite?: string[];
241
+ denyWrite?: string[];
242
+ denyRead?: string[];
243
+ };
244
+ };
217
245
  /**
218
246
  * Environment variables injected into child processes spawned by the harness —
219
247
  * Bash/Monitor/PowerShell tool executions and MCP server subprocesses. Useful
@@ -0,0 +1,47 @@
1
+ /**
2
+ * OS-level sandbox integration via the optional `@anthropic-ai/sandbox-runtime`
3
+ * package. The package wraps a shell command in bubblewrap (Linux) or
4
+ * sandbox-exec (macOS) plus a network proxy that filters by domain allowlist.
5
+ *
6
+ * Boundaries:
7
+ * - **Linux + macOS**: real sandboxing via the package's static API.
8
+ * - **Windows**: not supported by the package — every wrap call returns null
9
+ * (graceful passthrough; tools spawn unsandboxed). Documented in SECURITY.md.
10
+ * - **Package not installed**: same passthrough behavior — installs cleanly
11
+ * without the optional dep on any platform.
12
+ *
13
+ * Lifecycle:
14
+ * - Initialized once per process on the first wrap request.
15
+ * - One `SandboxManager.initialize` covers all subsequent wrap calls.
16
+ * - No reset — the package documents auto-cleanup on process exit.
17
+ *
18
+ * Opt-in: callers pass `{ enabled: true }` (typically derived from
19
+ * `OhConfig.sandbox.enabled` or the `--sandbox` CLI flag). The default is
20
+ * off so existing users see no behavior change.
21
+ */
22
+ import type { OhConfig } from "./config.js";
23
+ export type SandboxConfig = NonNullable<OhConfig["sandbox"]>;
24
+ /**
25
+ * Returns true on Linux/macOS where sandboxing is supported. Windows is
26
+ * unsupported by the underlying package, so we short-circuit there to avoid
27
+ * a misleading "tried to load and failed" log.
28
+ */
29
+ export declare function isSandboxAvailable(): boolean;
30
+ /**
31
+ * Wrap a shell command for sandboxed execution.
32
+ *
33
+ * Returns the wrapped command (a single shell string suitable for
34
+ * `spawn(cmd, { shell: "/bin/bash" })`) when sandboxing is enabled and
35
+ * available. Returns null in every other case — Windows, missing package,
36
+ * disabled config, init failure — so the caller falls through to the
37
+ * unsandboxed code path unchanged.
38
+ */
39
+ export declare function wrapForSandbox(command: string, config: SandboxConfig): Promise<string | null>;
40
+ /**
41
+ * Test-only: reset the cached init promise so unit tests can re-init with
42
+ * different configs.
43
+ *
44
+ * @internal
45
+ */
46
+ export declare function _resetSandboxForTest(): void;
47
+ //# sourceMappingURL=sandbox-runtime.d.ts.map
@@ -0,0 +1,100 @@
1
+ /**
2
+ * OS-level sandbox integration via the optional `@anthropic-ai/sandbox-runtime`
3
+ * package. The package wraps a shell command in bubblewrap (Linux) or
4
+ * sandbox-exec (macOS) plus a network proxy that filters by domain allowlist.
5
+ *
6
+ * Boundaries:
7
+ * - **Linux + macOS**: real sandboxing via the package's static API.
8
+ * - **Windows**: not supported by the package — every wrap call returns null
9
+ * (graceful passthrough; tools spawn unsandboxed). Documented in SECURITY.md.
10
+ * - **Package not installed**: same passthrough behavior — installs cleanly
11
+ * without the optional dep on any platform.
12
+ *
13
+ * Lifecycle:
14
+ * - Initialized once per process on the first wrap request.
15
+ * - One `SandboxManager.initialize` covers all subsequent wrap calls.
16
+ * - No reset — the package documents auto-cleanup on process exit.
17
+ *
18
+ * Opt-in: callers pass `{ enabled: true }` (typically derived from
19
+ * `OhConfig.sandbox.enabled` or the `--sandbox` CLI flag). The default is
20
+ * off so existing users see no behavior change.
21
+ */
22
+ // Cached, lazy-initialized handle. We deliberately don't expose this — callers
23
+ // only see `wrapForSandbox` / `isSandboxAvailable` / `resetSandboxForTest`.
24
+ let _initPromise = null;
25
+ /**
26
+ * Returns true on Linux/macOS where sandboxing is supported. Windows is
27
+ * unsupported by the underlying package, so we short-circuit there to avoid
28
+ * a misleading "tried to load and failed" log.
29
+ */
30
+ export function isSandboxAvailable() {
31
+ return process.platform === "linux" || process.platform === "darwin";
32
+ }
33
+ async function loadAndInitialize(config) {
34
+ if (!isSandboxAvailable())
35
+ return null;
36
+ let mod;
37
+ try {
38
+ mod = (await import("@anthropic-ai/sandbox-runtime"));
39
+ }
40
+ catch {
41
+ // Optional dep not installed — graceful passthrough.
42
+ return null;
43
+ }
44
+ try {
45
+ await mod.SandboxManager.initialize({
46
+ network: {
47
+ allowedDomains: config.network?.allowedDomains ?? [],
48
+ deniedDomains: config.network?.deniedDomains ?? [],
49
+ },
50
+ filesystem: {
51
+ allowWrite: config.filesystem?.allowWrite ?? [process.cwd()],
52
+ denyWrite: config.filesystem?.denyWrite ?? [],
53
+ denyRead: config.filesystem?.denyRead ?? [],
54
+ },
55
+ });
56
+ }
57
+ catch {
58
+ // Init can fail when bubblewrap / sandbox-exec aren't installed, or when
59
+ // the user's profile rejects the proxy ports. Falling back to passthrough
60
+ // is correct — opting in promised "use sandbox if you can," not "fail
61
+ // closed" — that's a separate `requireSandbox` mode for a future revision.
62
+ return null;
63
+ }
64
+ return mod;
65
+ }
66
+ /**
67
+ * Wrap a shell command for sandboxed execution.
68
+ *
69
+ * Returns the wrapped command (a single shell string suitable for
70
+ * `spawn(cmd, { shell: "/bin/bash" })`) when sandboxing is enabled and
71
+ * available. Returns null in every other case — Windows, missing package,
72
+ * disabled config, init failure — so the caller falls through to the
73
+ * unsandboxed code path unchanged.
74
+ */
75
+ export async function wrapForSandbox(command, config) {
76
+ if (!config.enabled)
77
+ return null;
78
+ if (!_initPromise) {
79
+ _initPromise = loadAndInitialize(config);
80
+ }
81
+ const mod = await _initPromise;
82
+ if (!mod)
83
+ return null;
84
+ try {
85
+ return await mod.SandboxManager.wrapWithSandbox(command);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ /**
92
+ * Test-only: reset the cached init promise so unit tests can re-init with
93
+ * different configs.
94
+ *
95
+ * @internal
96
+ */
97
+ export function _resetSandboxForTest() {
98
+ _initPromise = null;
99
+ }
100
+ //# sourceMappingURL=sandbox-runtime.js.map
@@ -1,5 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { z } from "zod";
3
+ import { readOhConfig } from "../../harness/config.js";
4
+ import { wrapForSandbox } from "../../harness/sandbox-runtime.js";
3
5
  import { safeEnv } from "../../utils/safe-env.js";
4
6
  const inputSchema = z.object({
5
7
  command: z.string(),
@@ -21,12 +23,30 @@ export const BashTool = {
21
23
  isConcurrencySafe() {
22
24
  return false;
23
25
  },
24
- call(input, context) {
26
+ async call(input, context) {
25
27
  // input.timeout is in seconds; convert to ms. Default 120s.
26
28
  const timeoutMs = Math.min((input.timeout ?? 120) * 1000, MAX_TIMEOUT);
27
29
  const isWin = process.platform === "win32";
28
- const shell = isWin ? "cmd.exe" : "/bin/bash";
29
- const shellArgs = isWin ? ["/c", input.command] : ["-c", input.command];
30
+ // Optional OS-level sandbox via @anthropic-ai/sandbox-runtime. Returns null
31
+ // when disabled / on Windows / when the optional dep isn't installed —
32
+ // caller falls back to the existing unsandboxed spawn unchanged.
33
+ const sandboxCfg = readOhConfig()?.sandbox;
34
+ const wrappedCommand = sandboxCfg ? await wrapForSandbox(input.command, sandboxCfg) : null;
35
+ let shell;
36
+ let shellArgs;
37
+ let extraSpawnOpts = {};
38
+ if (wrappedCommand) {
39
+ // sandbox-runtime returns a shell-string. Pin the shell to /bin/bash so
40
+ // the surrounding command syntax (heredocs, $((...)) etc.) keeps working
41
+ // — `shell: true` would default to /bin/sh on Linux.
42
+ shell = wrappedCommand;
43
+ shellArgs = [];
44
+ extraSpawnOpts = { shell: "/bin/bash" };
45
+ }
46
+ else {
47
+ shell = isWin ? "cmd.exe" : "/bin/bash";
48
+ shellArgs = isWin ? ["/c", input.command] : ["-c", input.command];
49
+ }
30
50
  // Background execution: spawn and return immediately
31
51
  if (input.run_in_background) {
32
52
  const bgId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
@@ -35,9 +55,13 @@ export const BashTool = {
35
55
  env: safeEnv(),
36
56
  stdio: ["ignore", "pipe", "pipe"],
37
57
  detached: false,
58
+ ...extraSpawnOpts,
38
59
  });
39
60
  let stdout = "";
40
61
  let stderr = "";
62
+ // stdio is fixed to ["ignore", "pipe", "pipe"] above, so stdout/stderr
63
+ // are always streams. Adding `...extraSpawnOpts` widens the spawn
64
+ // overload's return type to potentially-null pipes; assert non-null.
41
65
  proc.stdout.on("data", (chunk) => {
42
66
  stdout += chunk.toString();
43
67
  });
@@ -76,11 +100,15 @@ export const BashTool = {
76
100
  cwd: context.workingDir,
77
101
  env: safeEnv(),
78
102
  stdio: ["ignore", "pipe", "pipe"],
103
+ ...extraSpawnOpts,
79
104
  });
80
105
  const timer = setTimeout(() => {
81
106
  killed = true;
82
107
  proc.kill("SIGTERM");
83
108
  }, timeoutMs);
109
+ // stdio: ["ignore", "pipe", "pipe"] is set above — pipes are always
110
+ // present here; the spread of extraSpawnOpts just widens the return
111
+ // type. Non-null asserts are safe.
84
112
  proc.stdout.on("data", (chunk) => {
85
113
  const text = chunk.toString();
86
114
  stdout += text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhijiewang/openharness",
3
- "version": "2.32.0",
3
+ "version": "2.33.0",
4
4
  "description": "Open-source terminal coding agent. Works with any LLM.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -91,6 +91,7 @@
91
91
  },
92
92
  "homepage": "https://github.com/zhijiewong/openharness#readme",
93
93
  "optionalDependencies": {
94
+ "@anthropic-ai/sandbox-runtime": "^0.0.49",
94
95
  "@napi-rs/keyring": "^1.2.0",
95
96
  "sharp": "^0.34.5"
96
97
  }