@sagentlab/navarch-runtime 0.1.32 → 0.1.34

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
@@ -299,6 +299,8 @@ unchanged across the deployment.
299
299
  | `NAVARCH_WORKTREE_STALE_AFTER_MS` | `86400000` (24 hours) | Minimum inactivity age before an abandoned session worktree is removed. Active sessions are always protected. |
300
300
  | `NAVARCH_GIT_AUTHOR_NAME` / `NAVARCH_GIT_AUTHOR_EMAIL` | `sagentlab` / `z@sagentlab.com` | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. |
301
301
  | `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
302
+ | `NAVARCH_SANDBOX_PROFILE` | `trusted-development` | Named security profile for Docker sessions (`src/sandbox-profile.cts`): `trusted-development` (image-default user, uncapped, open egress), `untrusted-code` (non-root, 2 CPU / 4g / 512 PIDs, deny-by-default egress, read-only shared git), `elevated-verification` (non-root, 4 CPU / 8g / 2048 PIDs, egress limited to GitHub plus package registries). The default is the exact pre-profile flag set. |
303
+ | `NAVARCH_SANDBOX_EGRESS_NETWORK` | _(unset)_ | Docker network that enforces a profile's egress allowlist. Docker cannot filter by domain itself, so an allowlist profile without this fails closed to `--network=none` and records the denial. |
302
304
  | `NAVARCH_DOCKER_IMAGE` | `ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0` | Version-pinned per-session image with Node 20, git, GitHub CLI, ripgrep, jq, SSH, and Claude Code 2.1.218. Override with an image tag or digest you control. |
303
305
  | `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent CLI: `claude-code`, `codex`, `gemini`, or `opencode`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
304
306
  | `NAVARCH_RUNTIMES` | selected `NAVARCH_AGENT` | Comma list of installed/authenticated adapters advertised to dispatch. The control plane chooses among these per project/task. |
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runOpenCodeAdapter = exports.openCodeAdapter = exports.runGeminiAdapter = exports.geminiAdapter = exports.runCodexAdapter = exports.codexAdapter = exports.runClaudeCodeAdapter = exports.claudeCodeAdapter = void 0;
3
+ exports.runPiAdapter = exports.piAdapter = exports.runOpenCodeAdapter = exports.openCodeAdapter = exports.runGeminiAdapter = exports.geminiAdapter = exports.runCodexAdapter = exports.codexAdapter = exports.runClaudeCodeAdapter = exports.claudeCodeAdapter = void 0;
4
4
  exports.selectAdapter = selectAdapter;
5
5
  const claude_cjs_1 = require("./claude.cjs");
6
6
  Object.defineProperty(exports, "claudeCodeAdapter", { enumerable: true, get: function () { return claude_cjs_1.claudeCodeAdapter; } });
@@ -14,6 +14,9 @@ Object.defineProperty(exports, "runGeminiAdapter", { enumerable: true, get: func
14
14
  const opencode_cjs_1 = require("./opencode.cjs");
15
15
  Object.defineProperty(exports, "openCodeAdapter", { enumerable: true, get: function () { return opencode_cjs_1.openCodeAdapter; } });
16
16
  Object.defineProperty(exports, "runOpenCodeAdapter", { enumerable: true, get: function () { return opencode_cjs_1.runOpenCodeAdapter; } });
17
+ const pi_cjs_1 = require("./pi.cjs");
18
+ Object.defineProperty(exports, "piAdapter", { enumerable: true, get: function () { return pi_cjs_1.piAdapter; } });
19
+ Object.defineProperty(exports, "runPiAdapter", { enumerable: true, get: function () { return pi_cjs_1.runPiAdapter; } });
17
20
  /**
18
21
  * Picks the AgentAdapter (adapters/types.cts) session.cts should run a
19
22
  * session with, keyed off config.cts's `agentType` (NAVARCH_AGENT). This is
@@ -31,6 +34,11 @@ function selectAdapter(agentType) {
31
34
  return opencode_cjs_1.openCodeAdapter;
32
35
  case "claude-code":
33
36
  return claude_cjs_1.claudeCodeAdapter;
37
+ case "pi":
38
+ // Managed hosted runtime (#885): reachable only on a managed runner
39
+ // executing a `runtime: "pi"` claim — config.cts never produces "pi"
40
+ // from NAVARCH_AGENT, so a BYO machine cannot select it locally.
41
+ return pi_cjs_1.piAdapter;
34
42
  default: {
35
43
  // Exhaustiveness guard: config.cts only ever produces the values
36
44
  // above, but fall back to Claude Code rather than throwing if this
@@ -0,0 +1,321 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.piAdapter = exports.DEFAULT_HOSTED_PI_MODEL = exports.HOSTED_PI_MODELS = exports.DEEPSEEK_API_KEY_ENV = exports.PI_PROVIDER_NAME = void 0;
7
+ exports.resolveHostedPiModel = resolveHostedPiModel;
8
+ exports.buildPiProviderConfig = buildPiProviderConfig;
9
+ exports.parsePiJsonEvents = parsePiJsonEvents;
10
+ exports.runPiAdapter = runPiAdapter;
11
+ const node_child_process_1 = require("node:child_process");
12
+ const node_fs_1 = require("node:fs");
13
+ const node_os_1 = __importDefault(require("node:os"));
14
+ const node_path_1 = __importDefault(require("node:path"));
15
+ /**
16
+ * Headless Pi coding-agent adapter (#885) — the process-side twin of the
17
+ * Cloudflare hosted executor (worker/hosted-pi/lifecycle.ts). It invokes
18
+ * `pi --mode json -p <prompt>` against DeepSeek V4 through a generated
19
+ * OpenAI-compatible custom-provider config and normalizes the JSON event
20
+ * stream into the common AdapterResult boundary.
21
+ *
22
+ * Hosted Pi is control-plane provisioned (docs/navarch/runtime-adapter-contract.md
23
+ * — "Hosted Pi is not a fifth AgentAdapter" for BYO workers): config.cts
24
+ * never selects this adapter from NAVARCH_AGENT, and dispatch never returns
25
+ * `runtime: "pi"` to a BYO machine. This module exists so the managed runner
26
+ * has the same normalized process boundary as every other CLI, with one
27
+ * shared parsing/provider-config contract.
28
+ *
29
+ * Secret hygiene: the generated models.json references the DeepSeek key ONLY
30
+ * as a `$DEEPSEEK_API_KEY` environment interpolation (Pi expands it from the
31
+ * child environment at load time). The literal key never appears in argv, in
32
+ * the config file, or in any file that survives cleanup. A missing
33
+ * DEEPSEEK_API_KEY in the session environment is a hard configuration
34
+ * failure — the adapter refuses to launch rather than fall back to an
35
+ * ambient credential.
36
+ *
37
+ * MCP: Pi has no MCP client, so `mcpConfigPath` is a documented unsupported
38
+ * control for this adapter (contract: "document an unsupported control
39
+ * instead of inventing one"); platform MCP tools are unavailable to hosted
40
+ * Pi sessions.
41
+ */
42
+ // ---------------------------------------------------------------------------
43
+ // Provider config — MIRROR of worker/hosted-pi/provider-config.ts. Both
44
+ // sides must keep the same provider name, env reference, and model ids.
45
+ // ---------------------------------------------------------------------------
46
+ exports.PI_PROVIDER_NAME = "deepseek";
47
+ exports.DEEPSEEK_API_KEY_ENV = "DEEPSEEK_API_KEY";
48
+ exports.HOSTED_PI_MODELS = ["deepseek-v4-flash", "deepseek-v4-pro"];
49
+ exports.DEFAULT_HOSTED_PI_MODEL = "deepseek-v4-flash";
50
+ function resolveHostedPiModel(configured) {
51
+ const trimmed = (configured ?? "").trim();
52
+ const bare = trimmed.startsWith(`${exports.PI_PROVIDER_NAME}/`)
53
+ ? trimmed.slice(exports.PI_PROVIDER_NAME.length + 1)
54
+ : trimmed;
55
+ return exports.HOSTED_PI_MODELS.includes(bare)
56
+ ? bare
57
+ : exports.DEFAULT_HOSTED_PI_MODEL;
58
+ }
59
+ /**
60
+ * Pi custom-provider models.json. Cost figures mirror the OFF-PEAK DeepSeek
61
+ * prices from lib/navarch/hosted-credits.ts and only drive Pi's local cost
62
+ * display; billing authority is the control plane's immutable price
63
+ * snapshot, never this file.
64
+ */
65
+ function buildPiProviderConfig() {
66
+ const models = [
67
+ {
68
+ id: "deepseek-v4-flash",
69
+ name: "DeepSeek V4 Flash",
70
+ reasoning: true,
71
+ input: ["text"],
72
+ cost: { input: 0.22, output: 0.66, cacheRead: 0.007, cacheWrite: 0.22 },
73
+ contextWindow: 128_000,
74
+ maxTokens: 16_384,
75
+ },
76
+ {
77
+ id: "deepseek-v4-pro",
78
+ name: "DeepSeek V4 Pro",
79
+ reasoning: true,
80
+ input: ["text"],
81
+ cost: { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0.66 },
82
+ contextWindow: 128_000,
83
+ maxTokens: 16_384,
84
+ },
85
+ ];
86
+ return `${JSON.stringify({
87
+ providers: {
88
+ [exports.PI_PROVIDER_NAME]: {
89
+ baseUrl: "https://api.deepseek.com",
90
+ api: "openai-completions",
91
+ apiKey: `$${exports.DEEPSEEK_API_KEY_ENV}`,
92
+ models,
93
+ },
94
+ },
95
+ }, null, 2)}\n`;
96
+ }
97
+ function tokenCount(value) {
98
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
99
+ ? Math.floor(value)
100
+ : undefined;
101
+ }
102
+ function usdAmount(value) {
103
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
104
+ }
105
+ function extractText(content) {
106
+ if (typeof content === "string")
107
+ return content;
108
+ if (!Array.isArray(content))
109
+ return "";
110
+ return content
111
+ .map((block) => {
112
+ if (typeof block === "string")
113
+ return block;
114
+ if (block &&
115
+ typeof block === "object" &&
116
+ block.type === "text" &&
117
+ typeof block.text === "string") {
118
+ return block.text;
119
+ }
120
+ return "";
121
+ })
122
+ .filter(Boolean)
123
+ .join("\n");
124
+ }
125
+ function parsePiJsonEvents(stdout) {
126
+ const events = [];
127
+ for (const line of stdout.split(/\r?\n/)) {
128
+ const trimmed = line.trim();
129
+ if (!trimmed.startsWith("{"))
130
+ continue;
131
+ try {
132
+ const parsed = JSON.parse(trimmed);
133
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
134
+ events.push(parsed);
135
+ }
136
+ }
137
+ catch {
138
+ // Unknown lines remain available in the raw transcript.
139
+ }
140
+ }
141
+ return events;
142
+ }
143
+ function attachPiOutput(result) {
144
+ const events = parsePiJsonEvents(result.stdout);
145
+ if (events.length === 0)
146
+ return result;
147
+ let cacheHit = 0;
148
+ let cacheMiss = 0;
149
+ let output = 0;
150
+ let costUsd;
151
+ let sawUsage = false;
152
+ let reportText = "";
153
+ for (const event of events) {
154
+ const message = event.message;
155
+ if (event.type !== "message_end" || !message || message.role !== "assistant")
156
+ continue;
157
+ const text = extractText(message.content);
158
+ if (text.trim())
159
+ reportText = text.trim();
160
+ const usage = message.usage;
161
+ if (usage && typeof usage === "object") {
162
+ const input = tokenCount(usage.input);
163
+ const read = tokenCount(usage.cacheRead);
164
+ const write = tokenCount(usage.cacheWrite);
165
+ const out = tokenCount(usage.output);
166
+ if (input !== undefined || read !== undefined || write !== undefined || out !== undefined) {
167
+ sawUsage = true;
168
+ cacheHit += read ?? 0;
169
+ // DeepSeek bills cache writes at the miss rate; cache-miss input is
170
+ // regular input plus cache writes (docs/navarch/hosted-prepaid-credits.md).
171
+ cacheMiss += (input ?? 0) + (write ?? 0);
172
+ output += out ?? 0;
173
+ const total = usage.cost ? usdAmount(usage.cost.total) : undefined;
174
+ if (total !== undefined)
175
+ costUsd = (costUsd ?? 0) + total;
176
+ }
177
+ }
178
+ }
179
+ return {
180
+ ...result,
181
+ ...(sawUsage
182
+ ? {
183
+ // AdapterResult semantics: tokensIn folds every input category
184
+ // together and cacheHitTokensIn is its cache-read component.
185
+ tokensIn: cacheHit + cacheMiss,
186
+ tokensOut: output,
187
+ cacheHitTokensIn: cacheHit,
188
+ }
189
+ : {}),
190
+ ...(costUsd !== undefined ? { costUsd } : {}),
191
+ ...(reportText ? { reportText } : {}),
192
+ };
193
+ }
194
+ // ---------------------------------------------------------------------------
195
+ // Invocation
196
+ // ---------------------------------------------------------------------------
197
+ const CONTAINER_PI_DIR = "/tmp/navarch-pi-agent";
198
+ function hasArg(args, ...flags) {
199
+ return args.some((arg) => flags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
200
+ }
201
+ async function runPiAdapter(options) {
202
+ if (!options.env[exports.DEEPSEEK_API_KEY_ENV]) {
203
+ // Fail closed: launching without the referenced managed credential would
204
+ // let Pi fall back to ambient/operator auth outside the session boundary.
205
+ throw new Error(`pi adapter: ${exports.DEEPSEEK_API_KEY_ENV} is missing from the session environment; ` +
206
+ "the managed provider credential must be injected by the hosted runner");
207
+ }
208
+ const args = ["--mode", "json"];
209
+ if (!hasArg(options.extraArgs, "--no-session")) {
210
+ // Ephemeral by default: session persistence would leave conversation
211
+ // files (which may quote repository content) behind after cleanup.
212
+ args.push("--no-session");
213
+ }
214
+ args.push(...options.extraArgs);
215
+ const model = resolveHostedPiModel(options.model);
216
+ args.push("--model", `${exports.PI_PROVIDER_NAME}/${model}`);
217
+ if (options.reasoningEffort) {
218
+ // Pi's --thinking accepts off|minimal|low|medium|high|xhigh|max; the
219
+ // resolved efforts are a subset, so they map directly.
220
+ args.push("--thinking", options.reasoningEffort);
221
+ }
222
+ args.push("-p", options.prompt);
223
+ const raw = options.dockerExec
224
+ ? await runViaDocker(options, args)
225
+ : await runOnHost(options, args);
226
+ return attachPiOutput(raw);
227
+ }
228
+ async function runOnHost(options, args) {
229
+ // Isolated agent dir so the generated provider config never touches (or
230
+ // reads) an operator's ~/.pi configuration/credentials.
231
+ const agentDir = await node_fs_1.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "navarch-pi-agent-"));
232
+ await node_fs_1.promises.writeFile(node_path_1.default.join(agentDir, "models.json"), buildPiProviderConfig(), { mode: 0o600 });
233
+ const env = { ...options.env, PI_CODING_AGENT_DIR: agentDir };
234
+ try {
235
+ return await new Promise((resolve) => {
236
+ let stdout = "";
237
+ let stderr = "";
238
+ let timedOut = false;
239
+ let killedByLeaseLoss = false;
240
+ const child = (0, node_child_process_1.spawn)(options.bin, args, {
241
+ cwd: options.cwd,
242
+ env: { ...process.env, ...env },
243
+ });
244
+ child.stdin?.end();
245
+ const timer = setTimeout(() => {
246
+ timedOut = true;
247
+ child.kill("SIGKILL");
248
+ }, options.timeoutMs);
249
+ const onAbort = () => {
250
+ killedByLeaseLoss = true;
251
+ child.kill("SIGKILL");
252
+ };
253
+ options.signal?.addEventListener("abort", onAbort, { once: true });
254
+ child.stdout.on("data", (data) => {
255
+ stdout += data.toString();
256
+ });
257
+ child.stderr.on("data", (data) => {
258
+ stderr += data.toString();
259
+ });
260
+ child.on("error", (err) => {
261
+ clearTimeout(timer);
262
+ options.signal?.removeEventListener("abort", onAbort);
263
+ stderr += `\n${String(err)}`;
264
+ resolve({ exitCode: null, timedOut, killedByLeaseLoss, stdout, stderr });
265
+ });
266
+ child.on("close", (code) => {
267
+ clearTimeout(timer);
268
+ options.signal?.removeEventListener("abort", onAbort);
269
+ resolve({ exitCode: code, timedOut, killedByLeaseLoss, stdout, stderr });
270
+ });
271
+ });
272
+ }
273
+ finally {
274
+ await node_fs_1.promises.rm(agentDir, { recursive: true, force: true }).catch(() => undefined);
275
+ }
276
+ }
277
+ async function runViaDocker(options, args) {
278
+ const { containerName, runner } = options.dockerExec;
279
+ // The provider config carries no secret values (only the $DEEPSEEK_API_KEY
280
+ // env reference), so materializing it through the container shell is safe.
281
+ const command = `[ -f /tmp/session.env ] && . /tmp/session.env; cd repo 2>/dev/null; ` +
282
+ `mkdir -p ${CONTAINER_PI_DIR} && cat > ${CONTAINER_PI_DIR}/models.json <<'NAVARCH_PI_EOF'\n` +
283
+ `${buildPiProviderConfig()}NAVARCH_PI_EOF\n` +
284
+ `PI_CODING_AGENT_DIR=${CONTAINER_PI_DIR} ${[options.bin, ...args].map(shellQuote).join(" ")}`;
285
+ let killedByLeaseLoss = false;
286
+ const onAbort = () => {
287
+ killedByLeaseLoss = true;
288
+ runner.run("docker", ["kill", containerName]).catch(() => undefined);
289
+ };
290
+ options.signal?.addEventListener("abort", onAbort, { once: true });
291
+ try {
292
+ const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
293
+ const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], { timeoutMs: options.timeoutMs, env: { ...process.env, ...options.env } });
294
+ return {
295
+ exitCode: result.code,
296
+ timedOut: false,
297
+ killedByLeaseLoss,
298
+ stdout: result.stdout,
299
+ stderr: result.stderr,
300
+ };
301
+ }
302
+ catch (err) {
303
+ return {
304
+ exitCode: null,
305
+ timedOut: false,
306
+ killedByLeaseLoss,
307
+ stdout: "",
308
+ stderr: String(err),
309
+ };
310
+ }
311
+ finally {
312
+ options.signal?.removeEventListener("abort", onAbort);
313
+ }
314
+ }
315
+ function shellQuote(value) {
316
+ return `'${value.replace(/'/g, `'\\''`)}'`;
317
+ }
318
+ exports.piAdapter = {
319
+ agentType: "pi",
320
+ run: runPiAdapter,
321
+ };
package/dist/config.cjs CHANGED
@@ -8,6 +8,7 @@ exports.isRuntimeAgentType = isRuntimeAgentType;
8
8
  exports.loadRuntimeConfig = loadRuntimeConfig;
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
10
  const node_os_1 = __importDefault(require("node:os"));
11
+ const sandbox_profile_cjs_1 = require("./sandbox-profile.cjs");
11
12
  /** Published image containing git, GitHub CLI, and the pinned Claude Code CLI. */
12
13
  exports.DEFAULT_SANDBOX_IMAGE = "ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0";
13
14
  function isRuntimeAgentType(value) {
@@ -86,6 +87,13 @@ function loadRuntimeConfig(env = process.env) {
86
87
  gitAuthorEmail: env.NAVARCH_GIT_AUTHOR_EMAIL ?? "z@sagentlab.com",
87
88
  mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
88
89
  sandboxMode,
90
+ // An unrecognized profile name falls back to the default rather than
91
+ // failing startup: a typo must not silently drop a machine out of the
92
+ // dispatch pool, and the default is the posture sessions already had.
93
+ sandboxProfile: (0, sandbox_profile_cjs_1.isSandboxProfileId)(env.NAVARCH_SANDBOX_PROFILE)
94
+ ? env.NAVARCH_SANDBOX_PROFILE
95
+ : sandbox_profile_cjs_1.DEFAULT_SANDBOX_PROFILE_ID,
96
+ sandboxEgressNetwork: env.NAVARCH_SANDBOX_EGRESS_NETWORK?.trim() || null,
89
97
  dockerImage: env.NAVARCH_DOCKER_IMAGE ?? exports.DEFAULT_SANDBOX_IMAGE,
90
98
  // Multiple sessions share one machine; keeping each agent inside its own
91
99
  // worktree is the safe default, so disabling is the explicit opt-out.
package/dist/redact.cjs CHANGED
@@ -7,10 +7,14 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.SecretRegistry = void 0;
9
9
  exports.redactText = redactText;
10
+ // Keep in sync with lib/navarch/redaction.ts's SECRET_SHAPE_PATTERNS — the
11
+ // hosted Pi executor (worker/hosted-pi) shares these shapes for its own
12
+ // transcript/progress scrubbing.
10
13
  const SECRET_PATTERNS = [
11
14
  /gh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub PAT / OAuth / user-to-server / refresh tokens
12
15
  /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT
13
- /sk-[A-Za-z0-9-]{20,}/g, // generic vendor secret-key style token
16
+ /sk-[A-Za-z0-9-]{20,}/g, // OpenAI-compatible secret keys, incl. DeepSeek `sk-…` (hosted Pi provider)
17
+ /\/\/[^/\s:@]+:[^/\s@]+@/g, // basic-auth userinfo in URLs (e.g. x-access-token:…@github.com clones)
14
18
  /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
15
19
  ];
16
20
  const REDACTED = "[REDACTED]";
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+ /**
3
+ * Named sandbox security profiles (issue #660).
4
+ *
5
+ * A profile is the *declared* security posture of one session: which user the
6
+ * agent runs as, what resources it may consume, which host paths it may see,
7
+ * and where it may reach on the network. `dockerRunFlags()` /
8
+ * `resolveMounts()` / `resolveNetwork()` turn that declaration into concrete
9
+ * `docker run` arguments, so the policy is written once and audited in one
10
+ * place rather than being spread across ad-hoc flags in sandbox.cts.
11
+ *
12
+ * NOTE ON NAMING: `SandboxPolicyDenial` here is a *security policy* denial (a
13
+ * mount outside the allowlist, an unenforceable egress allowlist). It is
14
+ * deliberately NOT the same concept as `SandboxDenialReason` in
15
+ * lib/navarch/sandbox.ts, which is free-tier billing/eligibility. Do not
16
+ * conflate the two.
17
+ *
18
+ * DEFAULT: `trusted-development` reproduces the pre-#660 Docker flags exactly
19
+ * — root user, no resource caps, Docker's default bridge network — so
20
+ * adopting profiles is a pure refactor for every existing session. The
21
+ * hardened postures are opt-in via NAVARCH_SANDBOX_PROFILE until the default
22
+ * flip is validated on real workloads.
23
+ */
24
+ var __importDefault = (this && this.__importDefault) || function (mod) {
25
+ return (mod && mod.__esModule) ? mod : { "default": mod };
26
+ };
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.DEFAULT_SANDBOX_PROFILE_ID = exports.GITHUB_EGRESS_ALLOWLIST = exports.SANDBOX_PROFILE_IDS = void 0;
29
+ exports.isSandboxProfileId = isSandboxProfileId;
30
+ exports.resolveSandboxProfile = resolveSandboxProfile;
31
+ exports.resolveMounts = resolveMounts;
32
+ exports.resolveNetwork = resolveNetwork;
33
+ exports.dockerRunFlags = dockerRunFlags;
34
+ const node_path_1 = __importDefault(require("node:path"));
35
+ exports.SANDBOX_PROFILE_IDS = [
36
+ "trusted-development",
37
+ "untrusted-code",
38
+ "elevated-verification",
39
+ ];
40
+ function isSandboxProfileId(value) {
41
+ return exports.SANDBOX_PROFILE_IDS.includes(value ?? "");
42
+ }
43
+ /** tmpfs mounts every profile shares: injected secrets must never hit a disk. */
44
+ const SECRET_TMPFS = ["/tmp", "/run"];
45
+ const BASE_SECRETS = {
46
+ allowEnvFlagInjection: false,
47
+ tmpfs: SECRET_TMPFS,
48
+ };
49
+ /**
50
+ * The GitHub egress worktree-guard.cts already grants host-mode Codex
51
+ * sessions (`**.github.com` / `**.githubusercontent.com`). Container profiles
52
+ * reuse the same set so a session cannot reach more from inside the sandbox
53
+ * than the host-mode guard would have allowed.
54
+ */
55
+ exports.GITHUB_EGRESS_ALLOWLIST = [
56
+ "**.github.com",
57
+ "**.githubusercontent.com",
58
+ ];
59
+ /** Registries an elevated verification run needs to install dependencies from. */
60
+ const PACKAGE_REGISTRY_ALLOWLIST = [
61
+ "registry.npmjs.org",
62
+ "**.pypi.org",
63
+ "**.crates.io",
64
+ ];
65
+ const PROFILES = {
66
+ // Byte-for-byte the pre-#660 flag set. Changing anything here changes the
67
+ // behavior of every existing dispatch — see sandbox.test.cts, which pins
68
+ // the resulting argv exactly.
69
+ "trusted-development": {
70
+ id: "trusted-development",
71
+ description: "First-party repositories run by the operator's own agents. Image-default user, no resource caps, unrestricted egress — the pre-#660 Docker posture.",
72
+ user: null,
73
+ memory: null,
74
+ cpus: null,
75
+ pidsLimit: null,
76
+ capDrop: ["ALL"],
77
+ securityOpt: ["no-new-privileges"],
78
+ network: { mode: "open", allow: [] },
79
+ mounts: { allow: ["workspace", "shared-git"], readOnly: [] },
80
+ secrets: BASE_SECRETS,
81
+ },
82
+ // Tightest posture: non-root, capped, GitHub-only egress that fails closed,
83
+ // and the shared bare repo mounted read-only so a hostile checkout cannot
84
+ // rewrite objects other sessions' worktrees depend on.
85
+ "untrusted-code": {
86
+ id: "untrusted-code",
87
+ description: "Third-party or unreviewed code. Non-root, hard CPU/memory/PID caps, deny-by-default egress with a GitHub allowlist, read-only shared git.",
88
+ user: "1000:1000",
89
+ memory: "4g",
90
+ cpus: "2",
91
+ pidsLimit: 512,
92
+ capDrop: ["ALL"],
93
+ securityOpt: ["no-new-privileges"],
94
+ network: { mode: "allowlist", allow: exports.GITHUB_EGRESS_ALLOWLIST },
95
+ mounts: { allow: ["workspace", "shared-git"], readOnly: ["shared-git"] },
96
+ secrets: BASE_SECRETS,
97
+ },
98
+ // Verification workloads (build + full test suite) need real resources and
99
+ // package registries, but stay non-root and stay off the open internet.
100
+ "elevated-verification": {
101
+ id: "elevated-verification",
102
+ description: "Build/test verification of reviewed code. Non-root with raised CPU/memory/PID caps and egress limited to GitHub plus package registries.",
103
+ user: "1000:1000",
104
+ memory: "8g",
105
+ cpus: "4",
106
+ pidsLimit: 2048,
107
+ capDrop: ["ALL"],
108
+ securityOpt: ["no-new-privileges"],
109
+ network: {
110
+ mode: "allowlist",
111
+ allow: [...exports.GITHUB_EGRESS_ALLOWLIST, ...PACKAGE_REGISTRY_ALLOWLIST],
112
+ },
113
+ mounts: { allow: ["workspace", "shared-git"], readOnly: [] },
114
+ secrets: BASE_SECRETS,
115
+ },
116
+ };
117
+ exports.DEFAULT_SANDBOX_PROFILE_ID = "trusted-development";
118
+ /**
119
+ * The profile for `id`, with the operator's egress network (if any) bound in.
120
+ * Returns a fresh object each call so callers cannot mutate the shared table.
121
+ */
122
+ function resolveSandboxProfile(id = exports.DEFAULT_SANDBOX_PROFILE_ID, options = {}) {
123
+ const base = PROFILES[id];
124
+ return {
125
+ ...base,
126
+ network: { ...base.network, egressNetwork: options.egressNetwork ?? null },
127
+ mounts: { ...base.mounts },
128
+ secrets: { ...base.secrets },
129
+ };
130
+ }
131
+ /**
132
+ * `-v` arguments for the requests this profile permits, plus a denial for
133
+ * each one it refuses. Denied mounts are dropped, not fatal: the session
134
+ * still starts with the mounts it is entitled to, and the refusal is recorded.
135
+ */
136
+ function resolveMounts(profile, requests) {
137
+ const args = [];
138
+ const denials = [];
139
+ for (const request of requests) {
140
+ if (!profile.mounts.allow.includes(request.kind)) {
141
+ denials.push({
142
+ policy: "mount",
143
+ code: "mount_kind_not_allowed",
144
+ detail: `profile ${profile.id} does not allow "${request.kind}" mounts (${request.hostPath})`,
145
+ });
146
+ continue;
147
+ }
148
+ if (!isInside(request.hostPath, request.allowedRoot)) {
149
+ denials.push({
150
+ policy: "mount",
151
+ code: "mount_outside_allowed_root",
152
+ detail: `${request.hostPath} is outside the allowed root ${request.allowedRoot} for "${request.kind}" mounts`,
153
+ });
154
+ continue;
155
+ }
156
+ const suffix = profile.mounts.readOnly.includes(request.kind) ? ":ro" : "";
157
+ args.push("-v", `${request.hostPath}:${request.containerPath}${suffix}`);
158
+ }
159
+ return { args, denials };
160
+ }
161
+ /** Same containment test worktree-guard.cts uses for its allowed roots. */
162
+ function isInside(candidate, root) {
163
+ const relative = node_path_1.default.relative(node_path_1.default.resolve(root), node_path_1.default.resolve(candidate));
164
+ return relative === "" || (!relative.startsWith("..") && !node_path_1.default.isAbsolute(relative));
165
+ }
166
+ /**
167
+ * `--network` argument for this policy. An `allowlist` policy with no
168
+ * enforcing network degrades to `--network=none` (deny-by-default) and
169
+ * records the denial — it never degrades to open egress.
170
+ */
171
+ function resolveNetwork(policy) {
172
+ switch (policy.mode) {
173
+ case "open":
174
+ // No flag at all: Docker's default bridge, identical to pre-#660.
175
+ return { args: [], denials: [] };
176
+ case "isolated":
177
+ return { args: ["--network=none"], denials: [] };
178
+ case "allowlist": {
179
+ const network = policy.egressNetwork?.trim();
180
+ if (network)
181
+ return { args: ["--network", network], denials: [] };
182
+ return {
183
+ args: ["--network=none"],
184
+ denials: [
185
+ {
186
+ policy: "network",
187
+ code: "egress_allowlist_unenforceable",
188
+ detail: `no egress network configured (NAVARCH_SANDBOX_EGRESS_NETWORK); denying all egress instead of allowing ${policy.allow.join(", ")}`,
189
+ },
190
+ ],
191
+ };
192
+ }
193
+ }
194
+ }
195
+ /**
196
+ * Hardening + resource + network flags for `docker run`, in a fixed order.
197
+ * Mounts are resolved separately (they need per-session paths) and appended
198
+ * by the backend.
199
+ *
200
+ * Order matters only for the equivalence test that pins the default profile's
201
+ * argv against the pre-#660 command line; Docker itself is order-insensitive
202
+ * among these.
203
+ */
204
+ function dockerRunFlags(profile) {
205
+ const args = [];
206
+ for (const cap of profile.capDrop)
207
+ args.push(`--cap-drop=${cap}`);
208
+ for (const opt of profile.securityOpt)
209
+ args.push(`--security-opt=${opt}`);
210
+ for (const mount of profile.secrets.tmpfs)
211
+ args.push("--tmpfs", mount);
212
+ if (profile.user)
213
+ args.push("--user", profile.user);
214
+ if (profile.memory)
215
+ args.push("--memory", profile.memory);
216
+ if (profile.cpus)
217
+ args.push("--cpus", profile.cpus);
218
+ if (profile.pidsLimit !== null)
219
+ args.push("--pids-limit", String(profile.pidsLimit));
220
+ const network = resolveNetwork(profile.network);
221
+ args.push(...network.args);
222
+ return { args, denials: network.denials };
223
+ }
package/dist/sandbox.cjs CHANGED
@@ -8,6 +8,7 @@ exports.isDockerAvailable = isDockerAvailable;
8
8
  const node_child_process_1 = require("node:child_process");
9
9
  const node_fs_1 = require("node:fs");
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
+ const sandbox_profile_cjs_1 = require("./sandbox-profile.cjs");
11
12
  class SandboxUnavailableError extends Error {
12
13
  }
13
14
  exports.SandboxUnavailableError = SandboxUnavailableError;
@@ -85,45 +86,92 @@ function containerName(sessionId) {
85
86
  * helper, so the literal secret value never appears in any argv the host's
86
87
  * `ps` can see and is never written to the container's persistent layer
87
88
  * (tmpfs only) — it disappears with the container on wipe().
89
+ *
90
+ * Since #660 every one of those flags comes from a named security profile
91
+ * (sandbox-profile.cts) rather than being hard-coded here, and this class is
92
+ * the `SandboxBackend` implementation for Docker rather than the only sandbox
93
+ * there can be. The default profile (`trusted-development`) emits exactly the
94
+ * flag set above, so nothing about an existing session changed.
88
95
  */
89
96
  class DockerSandbox {
97
+ id = "docker";
90
98
  name;
99
+ image;
100
+ profile;
91
101
  runner;
102
+ workspaceRoot;
92
103
  workDir;
93
- image;
94
104
  containerWorkDir;
95
105
  sharedGitDir;
106
+ appliedFlags = [];
107
+ denials = [];
96
108
  constructor(opts) {
97
109
  this.name = containerName(opts.sessionId);
98
110
  this.runner = opts.runner ?? exports.nodeCommandRunner;
111
+ this.workspaceRoot = opts.workspaceRoot;
99
112
  this.workDir = node_path_1.default.join(opts.workspaceRoot, opts.sessionId);
100
113
  this.image = opts.image;
101
114
  this.containerWorkDir = opts.containerWorkDir ?? null;
102
115
  this.sharedGitDir = opts.sharedGitDir ?? null;
116
+ this.profile = opts.profile ?? (0, sandbox_profile_cjs_1.resolveSandboxProfile)(sandbox_profile_cjs_1.DEFAULT_SANDBOX_PROFILE_ID);
117
+ }
118
+ /**
119
+ * Host paths this session wants mounted, each pinned to the root it must
120
+ * stay under so resolveMounts() can refuse anything else.
121
+ *
122
+ * - workspace ⊂ the sessions root this sandbox was constructed with, so a
123
+ * crafted session id containing `..` cannot mount a sibling session or
124
+ * escape the workspace entirely.
125
+ * - shared-git ⊂ the Navarch workspace root (the sessions root's parent).
126
+ * session.cts passes `<workspaceRoot>/sessions` here and GitWorktree puts
127
+ * the bare repo cache at the sibling `<workspaceRoot>/repositories/...`
128
+ * (or inside the session root for repo-local-token sessions), so both
129
+ * legitimate locations are covered while `~/.ssh` or `/` are not.
130
+ */
131
+ mountRequests() {
132
+ const sessionsRoot = node_path_1.default.resolve(this.workspaceRoot);
133
+ const navarchRoot = node_path_1.default.dirname(sessionsRoot);
134
+ if (!this.containerWorkDir) {
135
+ return [
136
+ {
137
+ kind: "workspace",
138
+ hostPath: this.workDir,
139
+ containerPath: "/workspace",
140
+ allowedRoot: sessionsRoot,
141
+ },
142
+ ];
143
+ }
144
+ const requests = [
145
+ {
146
+ kind: "workspace",
147
+ hostPath: this.workDir,
148
+ containerPath: this.workDir,
149
+ allowedRoot: sessionsRoot,
150
+ },
151
+ ];
152
+ if (this.sharedGitDir) {
153
+ requests.push({
154
+ kind: "shared-git",
155
+ hostPath: this.sharedGitDir,
156
+ containerPath: this.sharedGitDir,
157
+ allowedRoot: navarchRoot,
158
+ });
159
+ }
160
+ return requests;
103
161
  }
104
162
  async create() {
105
163
  await node_fs_1.promises.mkdir(this.workDir, { recursive: true });
106
- const mounts = this.containerWorkDir
107
- ? [
108
- "-v",
109
- `${this.workDir}:${this.workDir}`,
110
- ...(this.sharedGitDir ? ["-v", `${this.sharedGitDir}:${this.sharedGitDir}`] : []),
111
- "-w",
112
- this.containerWorkDir,
113
- ]
114
- : ["-v", `${this.workDir}:/workspace`, "-w", "/workspace"];
164
+ const policy = (0, sandbox_profile_cjs_1.dockerRunFlags)(this.profile);
165
+ const mounts = (0, sandbox_profile_cjs_1.resolveMounts)(this.profile, this.mountRequests());
166
+ this.denials.push(...policy.denials, ...mounts.denials);
167
+ const workdir = this.containerWorkDir ?? "/workspace";
168
+ this.appliedFlags = [...policy.args, ...mounts.args, "-w", workdir];
115
169
  const result = await this.runner.run("docker", [
116
170
  "run",
117
171
  "-d",
118
172
  "--name",
119
173
  this.name,
120
- "--cap-drop=ALL",
121
- "--security-opt=no-new-privileges",
122
- "--tmpfs",
123
- "/tmp",
124
- "--tmpfs",
125
- "/run",
126
- ...mounts,
174
+ ...this.appliedFlags,
127
175
  this.image,
128
176
  "tail",
129
177
  "-f",
@@ -164,6 +212,39 @@ class DockerSandbox {
164
212
  : `git clone --depth 1 ${shellQuote(url)} repo`;
165
213
  return this.exec(command);
166
214
  }
215
+ /**
216
+ * Copies one container path out to `<workDir>/artifacts/<artifactName>` and
217
+ * returns that host path, or null when it could not be retrieved.
218
+ * Best-effort by contract: evidence collection never fails a session, and
219
+ * the artifact root lives beside the workspace so wipe() removes it too.
220
+ */
221
+ async collectArtifact(containerPath, artifactName) {
222
+ const artifactRoot = node_path_1.default.join(this.workDir, "artifacts");
223
+ const destination = node_path_1.default.join(artifactRoot, artifactName);
224
+ try {
225
+ await node_fs_1.promises.mkdir(artifactRoot, { recursive: true });
226
+ const result = await this.runner.run("docker", [
227
+ "cp",
228
+ `${this.name}:${containerPath}`,
229
+ destination,
230
+ ]);
231
+ return result.code === 0 ? destination : null;
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ /** Profile/flag/denial metadata for run evidence and the sessions audit columns. */
238
+ describe() {
239
+ return {
240
+ backend: this.id,
241
+ profile: this.profile.id,
242
+ image: this.image,
243
+ name: this.name,
244
+ appliedFlags: [...this.appliedFlags],
245
+ denials: [...this.denials],
246
+ };
247
+ }
167
248
  /** Force-removes the container and the host-side workspace mount. Best-effort: never throws. */
168
249
  async wipe() {
169
250
  await this.stop();
package/dist/session.cjs CHANGED
@@ -9,6 +9,7 @@ const node_path_1 = __importDefault(require("node:path"));
9
9
  const node_fs_1 = require("node:fs");
10
10
  const api_cjs_1 = require("./api.cjs");
11
11
  const sandbox_cjs_1 = require("./sandbox.cjs");
12
+ const sandbox_profile_cjs_1 = require("./sandbox-profile.cjs");
12
13
  const index_cjs_1 = require("./adapters/index.cjs");
13
14
  const exit_conditions_cjs_1 = require("./exit-conditions.cjs");
14
15
  const redact_cjs_1 = require("./redact.cjs");
@@ -109,10 +110,19 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
109
110
  : "best",
110
111
  reasoning_effort: "medium",
111
112
  };
113
+ // Isolation posture reported on every completion so `sessions` records what
114
+ // a run actually executed under (#660). Derived from config alone, so the
115
+ // pre-start failure paths below can stamp it too. Host mode records
116
+ // "host" — an honest answer to "what isolated this run?", not a profile id.
117
+ const sandboxReport = {
118
+ sandbox_profile: config.sandboxMode === "docker" ? config.sandboxProfile : "host",
119
+ ...(config.sandboxMode === "docker" ? { sandbox_image: config.dockerImage } : {}),
120
+ };
112
121
  const executionReport = {
113
122
  model: execution.model,
114
123
  execution_profile: execution.profile,
115
124
  reasoning_effort: execution.reasoning_effort,
125
+ ...sandboxReport,
116
126
  };
117
127
  // The session's identity is the pre-allocated session id sent at claim time
118
128
  // (recorded on the lease by the dispatcher). Lease-scoped API calls
@@ -252,6 +262,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
252
262
  image: config.dockerImage,
253
263
  containerWorkDir: gitWorktree.worktreePath,
254
264
  sharedGitDir: gitWorktree.repositoryPath,
265
+ profile: (0, sandbox_profile_cjs_1.resolveSandboxProfile)(config.sandboxProfile, {
266
+ egressNetwork: config.sandboxEgressNetwork,
267
+ }),
255
268
  })
256
269
  : null;
257
270
  // Platform MCP config (implementation-plan.md WP-07: "--mcp-config
@@ -333,6 +346,12 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
333
346
  await gitWorktree.prepare();
334
347
  if (sandbox) {
335
348
  await sandbox.create();
349
+ // Security-policy refusals (a mount outside the allowlist, an egress
350
+ // allowlist with no network able to enforce it). Unrelated to the
351
+ // billing-tier SandboxDenialReason in lib/navarch/sandbox.ts.
352
+ for (const denial of sandbox.describe().denials) {
353
+ log.warn(`sandbox policy denial [${denial.policy}/${denial.code}]: ${denial.detail}`);
354
+ }
336
355
  await sandbox.injectEnv(sessionEnv);
337
356
  }
338
357
  // The control plane returns the agent type selected for this worker.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, Gemini, or OpenCode.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",