@sweny-ai/core 0.1.139 → 0.1.141
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/harness/policy.d.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* policyGate: the one place that decides "degrade" versus "refuse".
|
|
3
3
|
*
|
|
4
4
|
* Pure. Called before every harness run. For Claude Code every opinion is
|
|
5
|
-
* enforced natively, so the result is always `{ degraded: [] }`.
|
|
5
|
+
* enforced natively, so the result is always `{ degraded: [] }`. A harness
|
|
6
|
+
* without a native sandbox is covered by a process wrapper when the host has
|
|
7
|
+
* one (`wrappers`; see `prepareAgentSpawn` in sandbox-wrapper.ts).
|
|
6
8
|
*/
|
|
7
9
|
import type { HarnessCapabilities, NodePolicy, PolicyGateResult, PolicyWrappers } from "./types.js";
|
|
8
10
|
export declare function policyGate(caps: HarnessCapabilities, policy: NodePolicy, wrappers?: PolicyWrappers): PolicyGateResult;
|
package/dist/harness/policy.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* policyGate: the one place that decides "degrade" versus "refuse".
|
|
3
3
|
*
|
|
4
4
|
* Pure. Called before every harness run. For Claude Code every opinion is
|
|
5
|
-
* enforced natively, so the result is always `{ degraded: [] }`.
|
|
5
|
+
* enforced natively, so the result is always `{ degraded: [] }`. A harness
|
|
6
|
+
* without a native sandbox is covered by a process wrapper when the host has
|
|
7
|
+
* one (`wrappers`; see `prepareAgentSpawn` in sandbox-wrapper.ts).
|
|
6
8
|
*/
|
|
7
9
|
export function policyGate(caps, policy, wrappers = {}) {
|
|
8
10
|
const degraded = [];
|
|
@@ -26,8 +28,22 @@ export function policyGate(caps, policy, wrappers = {}) {
|
|
|
26
28
|
if (policy.egress.length > 0 && !caps.sandbox.network && !wrappers.egress) {
|
|
27
29
|
degraded.push("egress allowlist: harness has no network sandbox and no egress wrapper is active");
|
|
28
30
|
}
|
|
31
|
+
const sandboxMode = policy.sandbox ?? "off";
|
|
32
|
+
let sandboxGap;
|
|
33
|
+
if (sandboxMode !== "off" && !(caps.sandbox.fs && caps.sandbox.network) && !wrappers.sandbox) {
|
|
34
|
+
sandboxGap = "sandbox: harness has no native fs and network sandbox and no process sandbox wrapper is available";
|
|
35
|
+
degraded.push(sandboxGap);
|
|
36
|
+
}
|
|
29
37
|
if (degraded.length > 0 && policy.strict) {
|
|
30
38
|
return { degraded, refuse: `strict policy: ${degraded.join("; ")}` };
|
|
31
39
|
}
|
|
40
|
+
if (sandboxGap && sandboxMode === "strict") {
|
|
41
|
+
return {
|
|
42
|
+
degraded,
|
|
43
|
+
refuse: `strict sandbox (SWENY_SANDBOX=strict): ${sandboxGap}. Install srt ` +
|
|
44
|
+
`(npm i -g @anthropic-ai/sandbox-runtime, plus bubblewrap, socat and ripgrep on Linux), ` +
|
|
45
|
+
`or set SWENY_SANDBOX=auto to run with a warning.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
32
48
|
return { degraded };
|
|
33
49
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness-agnostic process sandbox (#360 step 2): the W column in
|
|
3
|
+
* research/2026-09-30-sota/harness-design.md, section 3.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code sandboxes its own shell commands (the SDK `sandbox` option, step
|
|
6
|
+
* 1). pi, ACP agents and any other harness without a native sandbox run their
|
|
7
|
+
* whole agent process through a {@link SandboxWrapper} instead:
|
|
8
|
+
*
|
|
9
|
+
* const prep = await prepareAgentSpawn({ caps, policy, wrapper, spawn: { command, args, env, cwd } });
|
|
10
|
+
* if (prep.refuse) return failed(prep.refuse);
|
|
11
|
+
* const child = spawn(prep.spawn.command, prep.spawn.args, { cwd: prep.spawn.cwd, env: prep.spawn.env });
|
|
12
|
+
* ... finally await prep.cleanup();
|
|
13
|
+
*
|
|
14
|
+
* The wrapped process gets:
|
|
15
|
+
* - network: only the node's egress allowlist (the same hosts the SDK sandbox
|
|
16
|
+
* gets: defaults, the node's skill hosts, `SWENY_SANDBOX_ALLOWED_DOMAINS`, and
|
|
17
|
+
* the adapter's own backend hosts), through srt's filtering proxy. On Linux
|
|
18
|
+
* the process has no network namespace of its own, so nothing reaches the
|
|
19
|
+
* host network except through that proxy.
|
|
20
|
+
* - filesystem: the workspace (cwd) and a scratch HOME are writable; the rest
|
|
21
|
+
* of the filesystem is read-only; the operator's credential files are
|
|
22
|
+
* unreadable. Dry runs (`readOnly`) leave the workspace read-only too.
|
|
23
|
+
* - env: exactly the env the adapter passes (already scoped by agent-env.ts),
|
|
24
|
+
* with HOME, XDG dirs and TMPDIR pointed into the scratch HOME. On Linux the
|
|
25
|
+
* process also gets its own PID namespace, so it cannot read another
|
|
26
|
+
* process's environment through /proc.
|
|
27
|
+
*
|
|
28
|
+
* Backend: `@anthropic-ai/sandbox-runtime` (the `srt` CLI), which wraps an
|
|
29
|
+
* arbitrary argv command with bubblewrap on Linux and sandbox-exec on macOS
|
|
30
|
+
* (probed in CI, `sandbox-wrapper` job). It is a host dependency, like bwrap and
|
|
31
|
+
* socat for step 1: install it with `npm i -g @anthropic-ai/sandbox-runtime`, or
|
|
32
|
+
* point `SWENY_SRT_PATH` at the binary. Linux also needs bubblewrap, socat and
|
|
33
|
+
* ripgrep, and unprivileged user namespaces (Ubuntu 23.10+:
|
|
34
|
+
* `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`).
|
|
35
|
+
*
|
|
36
|
+
* Modes are the step 1 modes, unchanged (`SWENY_SANDBOX`, default `auto` in CI
|
|
37
|
+
* and `off` locally): `off` never wraps; `auto` wraps when a wrapper is
|
|
38
|
+
* available and otherwise degrades with a warning; `strict` refuses the node
|
|
39
|
+
* when the harness has no native sandbox and no wrapper is available.
|
|
40
|
+
*/
|
|
41
|
+
import type { HarnessCapabilities, NodePolicy, PolicyWrappers } from "./types.js";
|
|
42
|
+
/** What an adapter would spawn without a sandbox. */
|
|
43
|
+
export interface AgentSpawn {
|
|
44
|
+
command: string;
|
|
45
|
+
args: string[];
|
|
46
|
+
/** Already scoped (agent-env.ts); the adapter adds only its own auth vars. */
|
|
47
|
+
env: Record<string, string>;
|
|
48
|
+
cwd: string;
|
|
49
|
+
}
|
|
50
|
+
export interface SandboxWrapRequest extends AgentSpawn {
|
|
51
|
+
/** Hosts the process may reach. Everything else is refused. */
|
|
52
|
+
egress: readonly string[];
|
|
53
|
+
/** Dry run: the workspace is read-only too (only the scratch HOME is writable). */
|
|
54
|
+
readOnly?: boolean;
|
|
55
|
+
}
|
|
56
|
+
/** The spawn to run instead, plus the scratch it owns. */
|
|
57
|
+
export interface WrappedSpawn extends AgentSpawn {
|
|
58
|
+
/** Scratch HOME. Adapters may write config here (generated MCP config, copied auth) before spawning. */
|
|
59
|
+
home: string;
|
|
60
|
+
/** Remove the scratch HOME and the sandbox settings. Idempotent. */
|
|
61
|
+
cleanup(): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
export interface SandboxWrapper {
|
|
64
|
+
/** Backend id, for logs and run receipts. */
|
|
65
|
+
readonly backend: "srt";
|
|
66
|
+
/** The opinions this wrapper enforces, handed to {@link policyGate}. */
|
|
67
|
+
readonly provides: Required<PolicyWrappers>;
|
|
68
|
+
wrap(req: SandboxWrapRequest): Promise<WrappedSpawn>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Operator credential files and dirs (relative to the real HOME) that a wrapped
|
|
72
|
+
* agent must never read, even though the rest of the filesystem stays readable
|
|
73
|
+
* so toolchains keep working.
|
|
74
|
+
*/
|
|
75
|
+
export declare const CREDENTIAL_PATHS: readonly string[];
|
|
76
|
+
/** srt settings file shape (the subset sweny writes). */
|
|
77
|
+
export interface SrtSettings {
|
|
78
|
+
network: {
|
|
79
|
+
allowedDomains: string[];
|
|
80
|
+
deniedDomains: string[];
|
|
81
|
+
strictAllowlist: true;
|
|
82
|
+
allowLocalBinding: false;
|
|
83
|
+
};
|
|
84
|
+
filesystem: {
|
|
85
|
+
denyRead: string[];
|
|
86
|
+
allowRead: string[];
|
|
87
|
+
allowWrite: string[];
|
|
88
|
+
denyWrite: string[];
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* srt rejects a whole settings file over one bad domain entry, so sweny keeps
|
|
93
|
+
* only entries srt accepts: a host (`example.com`), a `*.` wildcard with at
|
|
94
|
+
* least two labels, `localhost`, an IPv4 literal, or a bracketed IPv6 literal,
|
|
95
|
+
* each with an optional `:port`. Dropping an entry only narrows egress.
|
|
96
|
+
*/
|
|
97
|
+
export declare function isSrtDomainPattern(entry: string): boolean;
|
|
98
|
+
/**
|
|
99
|
+
* The srt settings for one wrapped spawn. Pure apart from `exists`.
|
|
100
|
+
* `credentialHome` is the operator's real HOME (not the scratch HOME).
|
|
101
|
+
*/
|
|
102
|
+
export declare function buildSrtSettings(req: Pick<SandboxWrapRequest, "cwd" | "egress" | "readOnly">, opts: {
|
|
103
|
+
home: string;
|
|
104
|
+
credentialHome: string;
|
|
105
|
+
exists?: (p: string) => boolean;
|
|
106
|
+
credentialPaths?: readonly string[];
|
|
107
|
+
}): SrtSettings;
|
|
108
|
+
/** Env for the wrapped process: the adapter's env with HOME, XDG and temp dirs moved into the scratch HOME. */
|
|
109
|
+
export declare function scratchEnv(env: Record<string, string>, home: string): Record<string, string>;
|
|
110
|
+
export interface SrtWrapperOptions {
|
|
111
|
+
/** Path to the `srt` binary. */
|
|
112
|
+
srtPath: string;
|
|
113
|
+
/** Operator HOME whose credential files are denied. Default: `os.homedir()`. */
|
|
114
|
+
credentialHome?: string;
|
|
115
|
+
/** Override {@link CREDENTIAL_PATHS} (relative to `credentialHome`, or absolute). */
|
|
116
|
+
credentialPaths?: readonly string[];
|
|
117
|
+
/** Where scratch dirs are created. Default: `os.tmpdir()`. */
|
|
118
|
+
scratchRoot?: string;
|
|
119
|
+
}
|
|
120
|
+
export declare class SrtSandboxWrapper implements SandboxWrapper {
|
|
121
|
+
private readonly opts;
|
|
122
|
+
readonly backend: "srt";
|
|
123
|
+
readonly provides: Required<PolicyWrappers>;
|
|
124
|
+
constructor(opts: SrtWrapperOptions);
|
|
125
|
+
wrap(req: SandboxWrapRequest): Promise<WrappedSpawn>;
|
|
126
|
+
}
|
|
127
|
+
export interface SandboxWrapperDetection {
|
|
128
|
+
wrapper?: SandboxWrapper;
|
|
129
|
+
/** Why no wrapper is available (absent when `wrapper` is set). */
|
|
130
|
+
reason?: string;
|
|
131
|
+
}
|
|
132
|
+
export interface DetectSandboxWrapperOptions {
|
|
133
|
+
env?: Record<string, string | undefined>;
|
|
134
|
+
platform?: NodeJS.Platform;
|
|
135
|
+
exists?: (p: string) => boolean;
|
|
136
|
+
/** Functional check; test seam. Default: run `srt` on `true`. */
|
|
137
|
+
probe?: (srtPath: string) => Promise<string | undefined>;
|
|
138
|
+
/** Options for the wrapper when one is found. */
|
|
139
|
+
wrapper?: Omit<SrtWrapperOptions, "srtPath">;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Find a working process wrapper on this host. `SWENY_SRT_PATH` names the
|
|
143
|
+
* `srt` binary; otherwise `srt` is looked up on PATH. The binary is then run
|
|
144
|
+
* once on `true` to prove it can sandbox here (bubblewrap may be missing, or
|
|
145
|
+
* user namespaces restricted). Only macOS and Linux are supported.
|
|
146
|
+
*/
|
|
147
|
+
export declare function detectSandboxWrapper(opts?: DetectSandboxWrapperOptions): Promise<SandboxWrapperDetection>;
|
|
148
|
+
/** {@link detectSandboxWrapper} for this host, computed once per process. */
|
|
149
|
+
export declare function defaultSandboxWrapper(): Promise<SandboxWrapperDetection>;
|
|
150
|
+
/** Test seam: forget the cached host detection. */
|
|
151
|
+
export declare function resetSandboxWrapperCache(): void;
|
|
152
|
+
/** The {@link PolicyWrappers} a (possibly absent) wrapper provides. */
|
|
153
|
+
export declare function wrappersFrom(wrapper: SandboxWrapper | null | undefined): PolicyWrappers;
|
|
154
|
+
export interface PrepareAgentSpawnOptions {
|
|
155
|
+
caps: HarnessCapabilities;
|
|
156
|
+
policy: NodePolicy;
|
|
157
|
+
spawn: AgentSpawn;
|
|
158
|
+
/**
|
|
159
|
+
* The host's wrapper. `undefined` = detect once per process
|
|
160
|
+
* ({@link defaultSandboxWrapper}); `null` = none available.
|
|
161
|
+
*/
|
|
162
|
+
wrapper?: SandboxWrapper | null;
|
|
163
|
+
/** The adapter's own backend hosts (its model API), always allowed. */
|
|
164
|
+
harnessEgress?: readonly string[];
|
|
165
|
+
/** Sandbox mode when `policy.sandbox` is unset. Default: `SWENY_SANDBOX` via {@link resolveSandboxMode}. */
|
|
166
|
+
env?: Record<string, string | undefined>;
|
|
167
|
+
}
|
|
168
|
+
export interface PreparedAgentSpawn {
|
|
169
|
+
/** Set when the node must not run. The adapter fails the node with this message. */
|
|
170
|
+
refuse?: string;
|
|
171
|
+
/** Opinions this run cannot honor (for `HarnessRunResult.degraded`). */
|
|
172
|
+
degraded: string[];
|
|
173
|
+
/** What to spawn: the wrapped command, or the original when no wrapping is needed or possible. */
|
|
174
|
+
spawn: AgentSpawn;
|
|
175
|
+
/** The wrapper backend in use, if the spawn was wrapped. */
|
|
176
|
+
wrappedBy?: SandboxWrapper["backend"];
|
|
177
|
+
/** Scratch HOME of the wrapped spawn; write generated config here before spawning. */
|
|
178
|
+
home?: string;
|
|
179
|
+
/** Release scratch. Call after the process exits, on every path. Idempotent. */
|
|
180
|
+
cleanup(): Promise<void>;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Gate and wrap one agent process. Runs {@link policyGate} with whatever the
|
|
184
|
+
* host's wrapper provides, then:
|
|
185
|
+
* - refuse: returns `{ refuse }` and spawns nothing;
|
|
186
|
+
* - harness has a native fs + network sandbox, or sandbox mode is `off`: the
|
|
187
|
+
* original spawn, unwrapped;
|
|
188
|
+
* - otherwise, with a wrapper: the wrapped spawn (egress = defaults + node
|
|
189
|
+
* hosts + `SWENY_SANDBOX_ALLOWED_DOMAINS` + the adapter's backend hosts);
|
|
190
|
+
* - otherwise (`auto`, no wrapper): the original spawn, with the gap in `degraded`.
|
|
191
|
+
*/
|
|
192
|
+
export declare function prepareAgentSpawn(opts: PrepareAgentSpawnOptions): Promise<PreparedAgentSpawn>;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness-agnostic process sandbox (#360 step 2): the W column in
|
|
3
|
+
* research/2026-09-30-sota/harness-design.md, section 3.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code sandboxes its own shell commands (the SDK `sandbox` option, step
|
|
6
|
+
* 1). pi, ACP agents and any other harness without a native sandbox run their
|
|
7
|
+
* whole agent process through a {@link SandboxWrapper} instead:
|
|
8
|
+
*
|
|
9
|
+
* const prep = await prepareAgentSpawn({ caps, policy, wrapper, spawn: { command, args, env, cwd } });
|
|
10
|
+
* if (prep.refuse) return failed(prep.refuse);
|
|
11
|
+
* const child = spawn(prep.spawn.command, prep.spawn.args, { cwd: prep.spawn.cwd, env: prep.spawn.env });
|
|
12
|
+
* ... finally await prep.cleanup();
|
|
13
|
+
*
|
|
14
|
+
* The wrapped process gets:
|
|
15
|
+
* - network: only the node's egress allowlist (the same hosts the SDK sandbox
|
|
16
|
+
* gets: defaults, the node's skill hosts, `SWENY_SANDBOX_ALLOWED_DOMAINS`, and
|
|
17
|
+
* the adapter's own backend hosts), through srt's filtering proxy. On Linux
|
|
18
|
+
* the process has no network namespace of its own, so nothing reaches the
|
|
19
|
+
* host network except through that proxy.
|
|
20
|
+
* - filesystem: the workspace (cwd) and a scratch HOME are writable; the rest
|
|
21
|
+
* of the filesystem is read-only; the operator's credential files are
|
|
22
|
+
* unreadable. Dry runs (`readOnly`) leave the workspace read-only too.
|
|
23
|
+
* - env: exactly the env the adapter passes (already scoped by agent-env.ts),
|
|
24
|
+
* with HOME, XDG dirs and TMPDIR pointed into the scratch HOME. On Linux the
|
|
25
|
+
* process also gets its own PID namespace, so it cannot read another
|
|
26
|
+
* process's environment through /proc.
|
|
27
|
+
*
|
|
28
|
+
* Backend: `@anthropic-ai/sandbox-runtime` (the `srt` CLI), which wraps an
|
|
29
|
+
* arbitrary argv command with bubblewrap on Linux and sandbox-exec on macOS
|
|
30
|
+
* (probed in CI, `sandbox-wrapper` job). It is a host dependency, like bwrap and
|
|
31
|
+
* socat for step 1: install it with `npm i -g @anthropic-ai/sandbox-runtime`, or
|
|
32
|
+
* point `SWENY_SRT_PATH` at the binary. Linux also needs bubblewrap, socat and
|
|
33
|
+
* ripgrep, and unprivileged user namespaces (Ubuntu 23.10+:
|
|
34
|
+
* `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0`).
|
|
35
|
+
*
|
|
36
|
+
* Modes are the step 1 modes, unchanged (`SWENY_SANDBOX`, default `auto` in CI
|
|
37
|
+
* and `off` locally): `off` never wraps; `auto` wraps when a wrapper is
|
|
38
|
+
* available and otherwise degrades with a warning; `strict` refuses the node
|
|
39
|
+
* when the harness has no native sandbox and no wrapper is available.
|
|
40
|
+
*/
|
|
41
|
+
import { execFile } from "node:child_process";
|
|
42
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
43
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
44
|
+
import { homedir, tmpdir } from "node:os";
|
|
45
|
+
import * as path from "node:path";
|
|
46
|
+
import { DEFAULT_SANDBOX_DOMAINS, parseList, resolveSandboxMode } from "../agent-env.js";
|
|
47
|
+
import { policyGate } from "./policy.js";
|
|
48
|
+
// ─── srt settings ────────────────────────────────────────────────
|
|
49
|
+
/**
|
|
50
|
+
* Operator credential files and dirs (relative to the real HOME) that a wrapped
|
|
51
|
+
* agent must never read, even though the rest of the filesystem stays readable
|
|
52
|
+
* so toolchains keep working.
|
|
53
|
+
*/
|
|
54
|
+
export const CREDENTIAL_PATHS = [
|
|
55
|
+
".ssh",
|
|
56
|
+
".aws",
|
|
57
|
+
".azure",
|
|
58
|
+
".gnupg",
|
|
59
|
+
".kube",
|
|
60
|
+
".docker/config.json",
|
|
61
|
+
".config/gcloud",
|
|
62
|
+
".config/gh",
|
|
63
|
+
".config/hub",
|
|
64
|
+
".netrc",
|
|
65
|
+
".git-credentials",
|
|
66
|
+
".npmrc",
|
|
67
|
+
".pypirc",
|
|
68
|
+
".cargo/credentials",
|
|
69
|
+
".cargo/credentials.toml",
|
|
70
|
+
".claude/.credentials.json",
|
|
71
|
+
".codex/auth.json",
|
|
72
|
+
];
|
|
73
|
+
/**
|
|
74
|
+
* srt rejects a whole settings file over one bad domain entry, so sweny keeps
|
|
75
|
+
* only entries srt accepts: a host (`example.com`), a `*.` wildcard with at
|
|
76
|
+
* least two labels, `localhost`, an IPv4 literal, or a bracketed IPv6 literal,
|
|
77
|
+
* each with an optional `:port`. Dropping an entry only narrows egress.
|
|
78
|
+
*/
|
|
79
|
+
export function isSrtDomainPattern(entry) {
|
|
80
|
+
let host = entry;
|
|
81
|
+
const v6 = /^\[([0-9a-fA-F:.]+)\](?::([1-9][0-9]{0,4}))?$/.exec(entry);
|
|
82
|
+
if (v6)
|
|
83
|
+
return !v6[2] || Number(v6[2]) <= 65535;
|
|
84
|
+
const m = /^(.*?)(?::([1-9][0-9]{0,4}))?$/.exec(entry);
|
|
85
|
+
if (!m)
|
|
86
|
+
return false;
|
|
87
|
+
if (m[2] && Number(m[2]) > 65535)
|
|
88
|
+
return false;
|
|
89
|
+
host = m[1];
|
|
90
|
+
if (host === "localhost")
|
|
91
|
+
return true;
|
|
92
|
+
if (/[\s/:@]/.test(host) || host.length === 0)
|
|
93
|
+
return false;
|
|
94
|
+
if (host.startsWith("*.")) {
|
|
95
|
+
const labels = host.slice(2).split(".");
|
|
96
|
+
return labels.length >= 2 && labels.every((l) => /^[A-Za-z0-9-]+$/.test(l));
|
|
97
|
+
}
|
|
98
|
+
if (host.includes("*"))
|
|
99
|
+
return false;
|
|
100
|
+
const labels = host.split(".");
|
|
101
|
+
return labels.length >= 2 && labels.every((l) => /^[A-Za-z0-9-]+$/.test(l));
|
|
102
|
+
}
|
|
103
|
+
/** Resolve symlinks when the path exists (macOS /var is /private/var), else keep it. */
|
|
104
|
+
function real(p) {
|
|
105
|
+
try {
|
|
106
|
+
return realpathSync(p);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return p;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The srt settings for one wrapped spawn. Pure apart from `exists`.
|
|
114
|
+
* `credentialHome` is the operator's real HOME (not the scratch HOME).
|
|
115
|
+
*/
|
|
116
|
+
export function buildSrtSettings(req, opts) {
|
|
117
|
+
const exists = opts.exists ?? existsSync;
|
|
118
|
+
const allowedDomains = [...new Set(req.egress.map((d) => d.trim()).filter(isSrtDomainPattern))];
|
|
119
|
+
const denyRead = (opts.credentialPaths ?? CREDENTIAL_PATHS)
|
|
120
|
+
.map((rel) => (path.isAbsolute(rel) ? rel : path.join(opts.credentialHome, rel)))
|
|
121
|
+
.filter((p) => exists(p));
|
|
122
|
+
const allowWrite = req.readOnly ? [opts.home] : [real(req.cwd), opts.home];
|
|
123
|
+
return {
|
|
124
|
+
network: { allowedDomains, deniedDomains: [], strictAllowlist: true, allowLocalBinding: false },
|
|
125
|
+
filesystem: { denyRead, allowRead: [], allowWrite, denyWrite: [] },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Env for the wrapped process: the adapter's env with HOME, XDG and temp dirs moved into the scratch HOME. */
|
|
129
|
+
export function scratchEnv(env, home) {
|
|
130
|
+
const tmp = path.join(home, "tmp");
|
|
131
|
+
return {
|
|
132
|
+
...env,
|
|
133
|
+
HOME: home,
|
|
134
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
135
|
+
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
136
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
137
|
+
TMPDIR: tmp,
|
|
138
|
+
// srt points TMPDIR at this for the wrapped command (default /tmp/claude, shared across runs).
|
|
139
|
+
CLAUDE_CODE_TMPDIR: tmp,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export class SrtSandboxWrapper {
|
|
143
|
+
opts;
|
|
144
|
+
backend = "srt";
|
|
145
|
+
provides = { sandbox: true, egress: true, readOnlyMount: true };
|
|
146
|
+
constructor(opts) {
|
|
147
|
+
this.opts = opts;
|
|
148
|
+
}
|
|
149
|
+
async wrap(req) {
|
|
150
|
+
const dir = await mkdtemp(path.join(real(this.opts.scratchRoot ?? tmpdir()), "sweny-sbx-"));
|
|
151
|
+
let cleaned = false;
|
|
152
|
+
const cleanup = async () => {
|
|
153
|
+
if (cleaned)
|
|
154
|
+
return;
|
|
155
|
+
cleaned = true;
|
|
156
|
+
await rm(dir, { recursive: true, force: true });
|
|
157
|
+
};
|
|
158
|
+
try {
|
|
159
|
+
const home = path.join(dir, "home");
|
|
160
|
+
await mkdir(path.join(home, "tmp"), { recursive: true });
|
|
161
|
+
const settings = buildSrtSettings(req, {
|
|
162
|
+
home,
|
|
163
|
+
credentialHome: this.opts.credentialHome ?? homedir(),
|
|
164
|
+
credentialPaths: this.opts.credentialPaths,
|
|
165
|
+
});
|
|
166
|
+
const settingsPath = path.join(dir, "srt-settings.json");
|
|
167
|
+
await writeFile(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
168
|
+
return {
|
|
169
|
+
command: this.opts.srtPath,
|
|
170
|
+
args: ["--settings", settingsPath, "--", req.command, ...req.args],
|
|
171
|
+
env: scratchEnv(req.env, home),
|
|
172
|
+
cwd: req.cwd,
|
|
173
|
+
home,
|
|
174
|
+
cleanup,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
await cleanup();
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Find an executable on PATH. */
|
|
184
|
+
function which(bin, pathEnv, exists) {
|
|
185
|
+
for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) {
|
|
186
|
+
const p = path.join(dir, bin);
|
|
187
|
+
if (exists(p))
|
|
188
|
+
return p;
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
/** Run `srt` on `true` with no network and no writes: can it actually sandbox on this host? */
|
|
193
|
+
async function srtWorks(srtPath, env) {
|
|
194
|
+
const dir = await mkdtemp(path.join(real(tmpdir()), "sweny-sbx-probe-"));
|
|
195
|
+
try {
|
|
196
|
+
const settings = path.join(dir, "srt-settings.json");
|
|
197
|
+
const s = {
|
|
198
|
+
network: { allowedDomains: [], deniedDomains: [], strictAllowlist: true, allowLocalBinding: false },
|
|
199
|
+
filesystem: { denyRead: [], allowRead: [], allowWrite: [], denyWrite: [] },
|
|
200
|
+
};
|
|
201
|
+
await writeFile(settings, JSON.stringify(s));
|
|
202
|
+
return await new Promise((resolve) => {
|
|
203
|
+
execFile(srtPath, ["--settings", settings, "--", "true"], { env: { PATH: env.PATH ?? "", HOME: dir }, timeout: 30_000 }, (err, _stdout, stderr) => {
|
|
204
|
+
if (!err)
|
|
205
|
+
return resolve(undefined);
|
|
206
|
+
const first = String(stderr || err.message)
|
|
207
|
+
.trim()
|
|
208
|
+
.split("\n")[0] || "unknown error";
|
|
209
|
+
const userns = /user namespace|uid map|setting up uid|Operation not permitted|Permission denied/i.test(String(stderr));
|
|
210
|
+
resolve(`srt cannot sandbox on this host (${first})` +
|
|
211
|
+
(userns
|
|
212
|
+
? "; unprivileged user namespaces look restricted (Ubuntu 23.10+: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0)"
|
|
213
|
+
: ""));
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
await rm(dir, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Find a working process wrapper on this host. `SWENY_SRT_PATH` names the
|
|
223
|
+
* `srt` binary; otherwise `srt` is looked up on PATH. The binary is then run
|
|
224
|
+
* once on `true` to prove it can sandbox here (bubblewrap may be missing, or
|
|
225
|
+
* user namespaces restricted). Only macOS and Linux are supported.
|
|
226
|
+
*/
|
|
227
|
+
export async function detectSandboxWrapper(opts = {}) {
|
|
228
|
+
const env = opts.env ?? process.env;
|
|
229
|
+
const platform = opts.platform ?? process.platform;
|
|
230
|
+
const exists = opts.exists ?? existsSync;
|
|
231
|
+
if (platform !== "linux" && platform !== "darwin") {
|
|
232
|
+
return { reason: `the process sandbox does not support platform "${platform}"` };
|
|
233
|
+
}
|
|
234
|
+
const explicit = env.SWENY_SRT_PATH?.trim();
|
|
235
|
+
const srtPath = explicit || which("srt", env.PATH ?? "", exists);
|
|
236
|
+
if (!srtPath) {
|
|
237
|
+
return {
|
|
238
|
+
reason: "srt (@anthropic-ai/sandbox-runtime) is not on PATH " +
|
|
239
|
+
"(install: npm i -g @anthropic-ai/sandbox-runtime" +
|
|
240
|
+
(platform === "linux" ? "; plus sudo apt-get install -y bubblewrap socat ripgrep" : "") +
|
|
241
|
+
"; or set SWENY_SRT_PATH)",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (explicit && !exists(srtPath))
|
|
245
|
+
return { reason: `SWENY_SRT_PATH=${srtPath} does not exist` };
|
|
246
|
+
const reason = await (opts.probe ?? ((p) => srtWorks(p, env)))(srtPath);
|
|
247
|
+
if (reason)
|
|
248
|
+
return { reason };
|
|
249
|
+
return { wrapper: new SrtSandboxWrapper({ srtPath, ...opts.wrapper }) };
|
|
250
|
+
}
|
|
251
|
+
let cachedDetection;
|
|
252
|
+
/** {@link detectSandboxWrapper} for this host, computed once per process. */
|
|
253
|
+
export function defaultSandboxWrapper() {
|
|
254
|
+
cachedDetection ??= detectSandboxWrapper();
|
|
255
|
+
return cachedDetection;
|
|
256
|
+
}
|
|
257
|
+
/** Test seam: forget the cached host detection. */
|
|
258
|
+
export function resetSandboxWrapperCache() {
|
|
259
|
+
cachedDetection = undefined;
|
|
260
|
+
}
|
|
261
|
+
/** The {@link PolicyWrappers} a (possibly absent) wrapper provides. */
|
|
262
|
+
export function wrappersFrom(wrapper) {
|
|
263
|
+
return wrapper ? { ...wrapper.provides } : {};
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Gate and wrap one agent process. Runs {@link policyGate} with whatever the
|
|
267
|
+
* host's wrapper provides, then:
|
|
268
|
+
* - refuse: returns `{ refuse }` and spawns nothing;
|
|
269
|
+
* - harness has a native fs + network sandbox, or sandbox mode is `off`: the
|
|
270
|
+
* original spawn, unwrapped;
|
|
271
|
+
* - otherwise, with a wrapper: the wrapped spawn (egress = defaults + node
|
|
272
|
+
* hosts + `SWENY_SANDBOX_ALLOWED_DOMAINS` + the adapter's backend hosts);
|
|
273
|
+
* - otherwise (`auto`, no wrapper): the original spawn, with the gap in `degraded`.
|
|
274
|
+
*/
|
|
275
|
+
export async function prepareAgentSpawn(opts) {
|
|
276
|
+
const env = opts.env ?? process.env;
|
|
277
|
+
const mode = opts.policy.sandbox ?? resolveSandboxMode(env);
|
|
278
|
+
const policy = { ...opts.policy, sandbox: mode };
|
|
279
|
+
const native = opts.caps.sandbox.fs && opts.caps.sandbox.network;
|
|
280
|
+
const needsWrapper = mode !== "off" && !native;
|
|
281
|
+
const wrapper = opts.wrapper === null
|
|
282
|
+
? undefined
|
|
283
|
+
: needsWrapper
|
|
284
|
+
? (opts.wrapper ?? (await defaultSandboxWrapper()).wrapper)
|
|
285
|
+
: undefined;
|
|
286
|
+
const gate = policyGate(opts.caps, policy, wrappersFrom(needsWrapper ? wrapper : undefined));
|
|
287
|
+
const noop = async () => { };
|
|
288
|
+
if (gate.refuse)
|
|
289
|
+
return { refuse: gate.refuse, degraded: gate.degraded, spawn: opts.spawn, cleanup: noop };
|
|
290
|
+
if (!needsWrapper || !wrapper)
|
|
291
|
+
return { degraded: gate.degraded, spawn: opts.spawn, cleanup: noop };
|
|
292
|
+
const egress = [
|
|
293
|
+
...DEFAULT_SANDBOX_DOMAINS,
|
|
294
|
+
...policy.egress,
|
|
295
|
+
...parseList(env.SWENY_SANDBOX_ALLOWED_DOMAINS),
|
|
296
|
+
...(opts.harnessEgress ?? []),
|
|
297
|
+
];
|
|
298
|
+
const wrapped = await wrapper.wrap({ ...opts.spawn, egress, readOnly: policy.readOnly });
|
|
299
|
+
const { home, cleanup, ...spawn } = wrapped;
|
|
300
|
+
return { degraded: gate.degraded, spawn, wrappedBy: wrapper.backend, home, cleanup };
|
|
301
|
+
}
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -49,9 +49,17 @@ export interface NodePolicy {
|
|
|
49
49
|
egress: string[];
|
|
50
50
|
/** Refuse instead of degrade when an opinion cannot be enforced. */
|
|
51
51
|
strict: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Process sandbox mode (`SWENY_SANDBOX`, #360). `auto` and `strict` need fs
|
|
54
|
+
* and network containment: native, or a process wrapper (sandbox-wrapper.ts).
|
|
55
|
+
* `strict` refuses the node without it, even when `strict` above is false.
|
|
56
|
+
* Unset means `off`: no requirement.
|
|
57
|
+
*/
|
|
58
|
+
sandbox?: "off" | "auto" | "strict";
|
|
52
59
|
}
|
|
53
60
|
/** Process-level wrappers the host provides (OS sandbox, egress proxy, read-only mount). */
|
|
54
61
|
export interface PolicyWrappers {
|
|
62
|
+
/** A process wrapper contains the agent's filesystem and network (sandbox-wrapper.ts). */
|
|
55
63
|
sandbox?: boolean;
|
|
56
64
|
egress?: boolean;
|
|
57
65
|
readOnlyMount?: boolean;
|