@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 +2 -0
- package/dist/adapters/claude.cjs +7 -1
- package/dist/adapters/codex.cjs +1 -0
- package/dist/adapters/opencode.cjs +5 -0
- package/dist/config.cjs +8 -0
- package/dist/exit-conditions.cjs +31 -4
- package/dist/sandbox-profile.cjs +223 -0
- package/dist/sandbox.cjs +98 -17
- package/dist/session.cjs +36 -0
- package/package.json +1 -1
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. |
|
package/dist/adapters/claude.cjs
CHANGED
|
@@ -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 {
|
|
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) => {
|
package/dist/adapters/codex.cjs
CHANGED
|
@@ -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.
|
package/dist/exit-conditions.cjs
CHANGED
|
@@ -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 {
|
|
145
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|