@sweny-ai/core 0.1.138 → 0.1.140

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/cli/main.js CHANGED
@@ -17,7 +17,7 @@ import { buildAutoMcpServers, buildSkillMcpServers, buildProviderContext } from
17
17
  import { loadAdditionalContext } from "../templates.js";
18
18
  import { loadAndValidateWorkflow } from "../loader.js";
19
19
  import { validateRuntimeInput } from "../inputs.js";
20
- import { mergeDryRunIntoInput, parseRunBudgetFlags } from "./workflow-input.js";
20
+ import { mergeDryRunIntoInput, parseInputFlag, parseRunBudgetFlags } from "./workflow-input.js";
21
21
  import { stringify as stringifyYaml } from "yaml";
22
22
  import { buildWorkflow, refineWorkflow } from "../workflow-builder.js";
23
23
  import { toMermaidBlock } from "../mermaid.js";
@@ -74,7 +74,7 @@ loadDotenv();
74
74
  applyAgentFileConfig(loadConfigFile());
75
75
  const program = new Command()
76
76
  .name("sweny")
77
- .description("SWEny CLI \u2014 autonomous engineering workflows")
77
+ .description("Workflows for coding agents. One set of rules, a receipt for every run.")
78
78
  .version(version);
79
79
  // ── sweny new ─────────────────────────────────────────────────────────
80
80
  program
@@ -626,8 +626,7 @@ implementCmd.action(async (issueId, options) => {
626
626
  process.exit(0);
627
627
  }
628
628
  catch (err) {
629
- const crashMsg = err instanceof Error ? err.message : String(err);
630
- console.error(chalk.red(`\n Error: ${crashMsg}\n`));
629
+ console.error(formatCrashError(err));
631
630
  // Finalize the cloud run as failed (covers thrown errors, incl.
632
631
  // RouteEvaluationError). Without this a crashed implement run stays
633
632
  // "running" in cloud forever.
@@ -804,15 +803,14 @@ export async function workflowRunAction(file, options) {
804
803
  // Build workflow input — prefer --input JSON if provided, else fall back to config-derived input
805
804
  let workflowInput;
806
805
  if (options.input && typeof options.input === "string") {
807
- let parsed;
808
- try {
809
- parsed = JSON.parse(options.input);
810
- }
811
- catch {
812
- console.error(chalk.red(" --input must be valid JSON"));
806
+ const parsedInput = parseInputFlag(options.input);
807
+ if (!parsedInput.ok) {
808
+ for (const line of parsedInput.lines)
809
+ console.error(chalk.red(` ${line}`));
813
810
  process.exit(1);
814
811
  return;
815
812
  }
813
+ const parsed = parsedInput.value;
816
814
  // Validate against the workflow's declared `inputs` contract (when present).
817
815
  // Workflows without an `inputs` block pass through unchanged.
818
816
  const validated = validateRuntimeInput(workflow.inputs, parsed);
@@ -990,8 +988,7 @@ export async function workflowRunAction(file, options) {
990
988
  process.exit(0);
991
989
  }
992
990
  catch (err) {
993
- const crashMsg = err instanceof Error ? err.message : String(err);
994
- console.error(chalk.red(`\n Error: ${crashMsg}\n`));
991
+ console.error(formatCrashError(err));
995
992
  runLogger.flush();
996
993
  recordHistory(nodeTimer.lastResults, undefined, true);
997
994
  console.error(` ${renderReceiptLine(summarizeRun(new Map(), Date.now() - runStart, true), isTTY)}\n`);
@@ -1149,7 +1146,7 @@ workflowCmd
1149
1146
  }
1150
1147
  }
1151
1148
  catch (err) {
1152
- console.error(chalk.red(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`));
1149
+ console.error(formatCrashError(err));
1153
1150
  process.exit(1);
1154
1151
  }
1155
1152
  });
@@ -1220,7 +1217,7 @@ workflowCmd
1220
1217
  }
1221
1218
  }
1222
1219
  catch (err) {
1223
- console.error(chalk.red(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`));
1220
+ console.error(formatCrashError(err));
1224
1221
  process.exit(1);
1225
1222
  }
1226
1223
  });
@@ -360,7 +360,7 @@ export function extractCredentialHint(err) {
360
360
  }
361
361
  // ── Crash error ─────────────────────────────────────────────────
362
362
  export function formatCrashError(error) {
363
- const msg = error instanceof Error ? error.message : "Unknown error";
363
+ const msg = error instanceof Error ? error.message : error == null ? "Unknown error" : String(error);
364
364
  const title = `${c.fail("\u2717")} ${chalk.bold("Unexpected Error")}`;
365
365
  const header = [title];
366
366
  const hint = extractCredentialHint(error);
@@ -39,3 +39,16 @@ export declare function parseRunBudgetFlags(timeout: string | undefined, maxStep
39
39
  timeoutMs: number;
40
40
  maxSteps: number | undefined;
41
41
  };
42
+ /**
43
+ * Parse the `--input` flag (#339). The old catch printed only "--input must be
44
+ * valid JSON" and dropped the parser's reason, so a stray quote from shell
45
+ * escaping left the user guessing. Returns the parsed value, or the lines to
46
+ * print: the real parse error plus a valid-shape example.
47
+ */
48
+ export declare function parseInputFlag(raw: string): {
49
+ ok: true;
50
+ value: unknown;
51
+ } | {
52
+ ok: false;
53
+ lines: string[];
54
+ };
@@ -54,3 +54,21 @@ export function parseRunBudgetFlags(timeout, maxSteps, defaultTimeoutMs) {
54
54
  maxSteps: maxSteps === undefined ? undefined : parse(maxSteps, "--max-steps", 1),
55
55
  };
56
56
  }
57
+ /**
58
+ * Parse the `--input` flag (#339). The old catch printed only "--input must be
59
+ * valid JSON" and dropped the parser's reason, so a stray quote from shell
60
+ * escaping left the user guessing. Returns the parsed value, or the lines to
61
+ * print: the real parse error plus a valid-shape example.
62
+ */
63
+ export function parseInputFlag(raw) {
64
+ try {
65
+ return { ok: true, value: JSON.parse(raw) };
66
+ }
67
+ catch (err) {
68
+ const reason = err instanceof Error ? err.message : String(err);
69
+ return {
70
+ ok: false,
71
+ lines: [`--input must be valid JSON: ${reason}`, `Example: --input '{"repository": "owner/repo", "limit": 5}'`],
72
+ };
73
+ }
74
+ }
@@ -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;
@@ -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
+ }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.138",
3
+ "version": "0.1.140",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"