@sagentlab/navarch-runtime 0.1.31 → 0.1.33

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. |
@@ -93,7 +93,13 @@ function attachUsage(result) {
93
93
  if (!parsed)
94
94
  return result;
95
95
  const usage = (0, exit_conditions_cjs_1.extractUsageFromClaudeJson)(parsed);
96
- return { ...result, tokensIn: usage.tokensIn, tokensOut: usage.tokensOut, costUsd: usage.costUsd };
96
+ return {
97
+ ...result,
98
+ tokensIn: usage.tokensIn,
99
+ tokensOut: usage.tokensOut,
100
+ cacheHitTokensIn: usage.cacheHitTokensIn,
101
+ costUsd: usage.costUsd,
102
+ };
97
103
  }
98
104
  async function runOnHost(options, args) {
99
105
  return new Promise((resolve) => {
@@ -156,6 +156,7 @@ function attachUsage(result, model) {
156
156
  // measured zero — leave the fields unset so aggregation skips them.
157
157
  ...(usage.tokensIn !== undefined ? { tokensIn: usage.tokensIn } : {}),
158
158
  ...(usage.tokensOut !== undefined ? { tokensOut: usage.tokensOut } : {}),
159
+ ...(usage.cacheHitTokensIn !== undefined ? { cacheHitTokensIn: usage.cacheHitTokensIn } : {}),
159
160
  ...(costUsd !== undefined ? { costUsd } : {}),
160
161
  ...(reportText !== undefined ? { reportText } : {}),
161
162
  };
@@ -123,6 +123,7 @@ function attachOpenCodeOutput(result) {
123
123
  return result;
124
124
  let tokensIn;
125
125
  let tokensOut;
126
+ let cacheHitTokensIn;
126
127
  let costUsd;
127
128
  let finalMessageId;
128
129
  const textByMessage = new Map();
@@ -144,6 +145,9 @@ function attachOpenCodeOutput(result) {
144
145
  const cost = nonNegativeMetric(part.cost);
145
146
  if (input !== undefined || cacheRead !== undefined || cacheWrite !== undefined) {
146
147
  tokensIn = (tokensIn ?? 0) + (input ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0);
148
+ // Cache reads are the "hit" component of tokensIn; cache writes count
149
+ // as misses (AdapterResult.cacheHitTokensIn semantics).
150
+ cacheHitTokensIn = (cacheHitTokensIn ?? 0) + (cacheRead ?? 0);
147
151
  }
148
152
  if (output !== undefined)
149
153
  tokensOut = (tokensOut ?? 0) + output;
@@ -157,6 +161,7 @@ function attachOpenCodeOutput(result) {
157
161
  ...result,
158
162
  ...(tokensIn !== undefined ? { tokensIn } : {}),
159
163
  ...(tokensOut !== undefined ? { tokensOut } : {}),
164
+ ...(cacheHitTokensIn !== undefined ? { cacheHitTokensIn } : {}),
160
165
  ...(costUsd !== undefined ? { costUsd } : {}),
161
166
  ...(reportText ? { reportText } : {}),
162
167
  };
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.
@@ -52,6 +52,8 @@ function parseClaudeJsonResult(stdout) {
52
52
  * no cache breakdown, so the components are folded together here rather
53
53
  * than dropped). Cost prefers `total_cost_usd` (the field name used in
54
54
  * multi-turn/agentic CLI output) and falls back to `cost_usd`.
55
+ * `cacheHitTokensIn` is the cache-read component of that sum -- cache writes
56
+ * count as misses, matching sessions.cache_miss_input_tokens semantics.
55
57
  */
56
58
  function extractUsageFromClaudeJson(parsed) {
57
59
  const usage = parsed.usage ?? {};
@@ -60,7 +62,7 @@ function extractUsageFromClaudeJson(parsed) {
60
62
  (usage.cache_read_input_tokens ?? 0);
61
63
  const tokensOut = usage.output_tokens ?? 0;
62
64
  const costUsd = parsed.total_cost_usd ?? parsed.cost_usd ?? 0;
63
- return { tokensIn, tokensOut, costUsd };
65
+ return { tokensIn, tokensOut, cacheHitTokensIn: usage.cache_read_input_tokens ?? 0, costUsd };
64
66
  }
65
67
  /**
66
68
  * Best-effort line-by-line parse of `codex exec --json` stdout into the
@@ -105,15 +107,21 @@ function parseCodexJsonEvents(stdout) {
105
107
  function extractUsageFromCodexEvents(events) {
106
108
  let turnTokensIn = 0;
107
109
  let turnTokensOut = 0;
110
+ let turnCacheHit = 0;
108
111
  let sawTurnUsage = false;
109
112
  let legacyTokensIn;
110
113
  let legacyTokensOut;
114
+ let legacyCacheHit;
111
115
  let costUsd;
112
116
  for (const event of events) {
113
117
  if (event.type === "turn.completed" && event.usage) {
114
118
  sawTurnUsage = true;
115
119
  turnTokensIn += event.usage.input_tokens ?? 0;
116
120
  turnTokensOut += event.usage.output_tokens ?? 0;
121
+ // cached_input_tokens is a component of input_tokens in the verified
122
+ // stream (see codex-pricing.cts priceUsage) -- same inclusive
123
+ // semantics as AdapterResult.cacheHitTokensIn.
124
+ turnCacheHit += event.usage.cached_input_tokens ?? 0;
117
125
  if (typeof event.usage.total_cost_usd === "number") {
118
126
  costUsd = (costUsd ?? 0) + event.usage.total_cost_usd;
119
127
  }
@@ -124,6 +132,9 @@ function extractUsageFromCodexEvents(events) {
124
132
  ? (nested.input_tokens ?? 0)
125
133
  : (event.msg.input_tokens ?? 0) + (event.msg.cached_input_tokens ?? 0);
126
134
  legacyTokensOut = nested?.output_tokens ?? event.msg.output_tokens ?? 0;
135
+ legacyCacheHit = nested
136
+ ? (nested.cached_input_tokens ?? 0)
137
+ : (event.msg.cached_input_tokens ?? 0);
127
138
  if (typeof nested?.total_cost_usd === "number")
128
139
  costUsd = nested.total_cost_usd;
129
140
  }
@@ -132,6 +143,7 @@ function extractUsageFromCodexEvents(events) {
132
143
  if (total) {
133
144
  legacyTokensIn = total.input_tokens ?? 0;
134
145
  legacyTokensOut = total.output_tokens ?? 0;
146
+ legacyCacheHit = total.cached_input_tokens ?? 0;
135
147
  if (typeof total.total_cost_usd === "number")
136
148
  costUsd = total.total_cost_usd;
137
149
  }
@@ -140,9 +152,24 @@ function extractUsageFromCodexEvents(events) {
140
152
  costUsd = event.msg.total_cost_usd;
141
153
  }
142
154
  }
143
- if (sawTurnUsage)
144
- return { tokensIn: turnTokensIn, tokensOut: turnTokensOut, costUsd };
145
- return { tokensIn: legacyTokensIn, tokensOut: legacyTokensOut, costUsd };
155
+ if (sawTurnUsage) {
156
+ return {
157
+ tokensIn: turnTokensIn,
158
+ tokensOut: turnTokensOut,
159
+ // Clamp like codex-pricing.cts: a malformed event must not report more
160
+ // cache reads than input tokens.
161
+ cacheHitTokensIn: Math.min(turnCacheHit, turnTokensIn),
162
+ costUsd,
163
+ };
164
+ }
165
+ return {
166
+ tokensIn: legacyTokensIn,
167
+ tokensOut: legacyTokensOut,
168
+ ...(legacyTokensIn !== undefined && legacyCacheHit !== undefined
169
+ ? { cacheHitTokensIn: Math.min(legacyCacheHit, legacyTokensIn) }
170
+ : {}),
171
+ costUsd,
172
+ };
146
173
  }
147
174
  /** The last completed agent message, with legacy `msg.agent_message` fallback. */
148
175
  function extractFinalMessageFromCodexEvents(events) {
@@ -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.
@@ -400,6 +419,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
400
419
  ...turnResult,
401
420
  tokensIn: sumReportedUsage(attempts, "tokensIn"),
402
421
  tokensOut: sumReportedUsage(attempts, "tokensOut"),
422
+ cacheHitTokensIn: sumReportedUsage(attempts, "cacheHitTokensIn"),
403
423
  costUsd: sumReportedUsage(attempts, "costUsd"),
404
424
  };
405
425
  const mapping = (0, exit_conditions_cjs_1.mapExitCondition)({
@@ -468,6 +488,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
468
488
  ...(result.tokensIn !== undefined ? { tokens_in: result.tokensIn } : {}),
469
489
  ...(result.tokensOut !== undefined ? { tokens_out: result.tokensOut } : {}),
470
490
  ...(result.costUsd !== undefined ? { cost_usd: result.costUsd } : {}),
491
+ ...cacheSplitFields(result.tokensIn, result.cacheHitTokensIn),
471
492
  },
472
493
  transcript_url: transcriptUrl,
473
494
  exit_status: mapping.exitStatus,
@@ -593,6 +614,21 @@ function adapterCommand(config, runtime) {
593
614
  return { bin: config.opencodeBin, extraArgs: config.opencodeExtraArgs };
594
615
  }
595
616
  }
617
+ /**
618
+ * The wire-shape prompt-cache split derived from adapter usage, or {} when no
619
+ * attempt reported a cache breakdown. The control plane persists the split
620
+ * only when hit + miss equals tokens_in (dispatch-service.ts
621
+ * byoCacheSplitOrNull), so the miss half is derived from the same summed
622
+ * total rather than reported independently. An attempt that reported tokens
623
+ * without a breakdown (plain-text fallback) inflates the miss half -- those
624
+ * tokens are "uncached or unknown", never fabricated hits.
625
+ */
626
+ function cacheSplitFields(tokensIn, cacheHitTokensIn) {
627
+ if (tokensIn === undefined || cacheHitTokensIn === undefined)
628
+ return {};
629
+ const hit = Math.min(cacheHitTokensIn, tokensIn);
630
+ return { cache_hit_input_tokens: hit, cache_miss_input_tokens: tokensIn - hit };
631
+ }
596
632
  function sumReportedUsage(attempts, key) {
597
633
  const reported = attempts.flatMap((attempt) => {
598
634
  const value = attempt[key];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
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",