@sagentlab/navarch-runtime 0.1.5 → 0.1.6

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
@@ -159,10 +159,60 @@ unchanged across the deployment.
159
159
  | `NAVARCH_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. |
160
160
  | `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent CLI: `claude-code` or `codex`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
161
161
  | `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
162
- | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). |
162
+ | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). Supplying an explicit permission flag such as `--allowedTools` or `--permission-mode` replaces the unattended session's default `--dangerously-skip-permissions`. |
163
163
  | `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
164
164
  | `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
165
165
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
166
+ | `NAVARCH_WORKTREE_GUARD` | on | Host-mode Claude and Codex sessions get a per-session worktree boundary guard (see below). Set `off` to disable. |
167
+ | `NAVARCH_GUARD_EXTRA_ROOTS` | — | `path.delimiter`-separated (`:` on POSIX) extra directories the worktree guard allows beyond the session worktree, shared bare repo, and temp dirs. |
168
+
169
+ ## Worktree boundary guard (host mode)
170
+
171
+ A machine typically runs several sessions concurrently (`NAVARCH_MAX_SESSIONS`),
172
+ each in its own git worktree. The runtime enforces the same boundary for both
173
+ supported coding agents, using each CLI's native enforcement point:
174
+
175
+ - **Claude Code:** permission bypass skips prompts but not hooks, so the
176
+ runtime generates a Claude settings file (`src/worktree-guard.cts`, passed
177
+ as `--settings`) that installs `bin/worktree-guard-hook.cjs` as a fail-closed
178
+ `PreToolUse` hook.
179
+ - **Codex:** the runtime passes a one-off native permission profile with
180
+ `--ask-for-approval never`. Codex's OS sandbox grants read/write access only
181
+ to the allowed roots and denies the surrounding multi-session workspace.
182
+ `--ignore-user-config` and an untrusted project-config override prevent a
183
+ user or checked-in legacy `sandbox_mode` from silently disabling the
184
+ generated profile; Codex authentication still comes from `CODEX_HOME`, and
185
+ repository instructions such as `AGENTS.md` still load.
186
+
187
+ The resulting boundary is:
188
+
189
+ - **File tools** (`Read`/`Write`/`Edit`/`Glob`/`Grep`/...) may only touch the
190
+ session worktree, the project's shared bare repo, temp dirs, and any
191
+ `NAVARCH_GUARD_EXTRA_ROOTS`. Read-only tools may additionally read standard
192
+ system prefixes (`/usr`, `/etc`, ...). Symlinks are resolved before the
193
+ containment check.
194
+ - **Bash commands** are screened lexically: absolute, `~`/`$HOME`, and
195
+ `..`-traversal path references must land inside the allowed roots or the
196
+ system prefixes.
197
+ - The **rest of the workspace root** — sibling sessions' worktrees, other
198
+ projects' bare repos, and the session's own metadata dir (lease-scoped MCP
199
+ config, the guard files themselves) — is denied outright, so an agent can
200
+ neither read another agent's checkout nor rewrite its own guard policy.
201
+
202
+ The Claude hook is a strong guardrail rather than a hard security boundary
203
+ because shell paths are screened lexically. Codex's permission profile is
204
+ enforced by its OS sandbox. For container-grade whole-process isolation use
205
+ `NAVARCH_SANDBOX_MODE=docker`; neither host guard is installed in Docker mode.
206
+ An operator-supplied `--settings` in `NAVARCH_CLAUDE_EXTRA_ARGS`, or an
207
+ explicit Codex permission/sandbox option in `NAVARCH_CODEX_EXTRA_ARGS`, takes
208
+ precedence over the generated policy.
209
+
210
+ The full mechanism was verified live on 2026-07-19 against a real
211
+ `claude -p --dangerously-skip-permissions --settings <generated file>` run:
212
+ the `PreToolUse` hook fires under permission bypass, an in-worktree Read and
213
+ `git --version` succeed, and a cross-session Read and a Bash redirect into a
214
+ sibling session are both blocked with the guard's message (and nothing lands
215
+ on disk).
166
216
 
167
217
  ## Choosing an agent (Claude Code vs. Codex)
168
218
 
@@ -298,6 +348,12 @@ tests cover:
298
348
  - `adapters/codex.cts` — arg construction on both the host path (mocked `spawn`)
299
349
  and the docker-exec path (fake `CommandRunner`), and usage/report-text
300
350
  attachment from fixed JSONL fixtures (`tests/adapters/codex.test.cts`).
351
+ - `worktree-guard.cts` + `bin/worktree-guard-hook.cjs` — generated settings/
352
+ config shape, native Codex permission-profile construction, and the hook's
353
+ containment verdicts (in-worktree vs. sibling session vs. home dir, symlink
354
+ escapes, Bash path screening, the fail-closed exit-2 protocol run as a real
355
+ subprocess) (`tests/worktree-guard.test.cts`,
356
+ `tests/worktree-guard-hook.test.cts`).
301
357
 
302
358
  Not exercised by unit tests, and needing a real machine per the WP-07 DoD
303
359
  ("on a real machine: register → claim a seeded docs task → session runs
@@ -0,0 +1,312 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * Navarch worktree guard — the PreToolUse hook installed by
6
+ * src/worktree-guard.cts into every host-mode Claude Code session.
7
+ *
8
+ * Multiple sessions run concurrently on one machine (claim-loop.cts), each
9
+ * confined by convention to its own git worktree
10
+ * (`<workspaceRoot>/sessions/<sessionId>/repo` — git-worktree.cts). The
11
+ * adapter launches Claude with --dangerously-skip-permissions (adapters/
12
+ * claude.cts), which bypasses permission prompts but NOT hooks: Claude Code
13
+ * runs PreToolUse hooks in every permission mode, so this script is the
14
+ * enforcement point that keeps an agent from reading or writing another
15
+ * session's worktree, the operator's home directory, or anything else
16
+ * unrelated to its task.
17
+ *
18
+ * Protocol (Claude Code hooks): the hook receives {tool_name, tool_input,
19
+ * cwd, ...} as JSON on stdin. Exit 0 allows the tool call; exit 2 blocks it
20
+ * and feeds stderr back to the model as the reason. Any internal failure
21
+ * exits 2 as well — a safety harness must fail closed.
22
+ *
23
+ * Policy:
24
+ * - File tools (Read/Write/Edit/...) may only touch the allowed roots (the
25
+ * session worktree, the project's shared bare repo, temp dirs, plus any
26
+ * NAVARCH_GUARD_EXTRA_ROOTS). Read-only tools may additionally read
27
+ * standard system prefixes (/usr, /etc, ...) so toolchains keep working.
28
+ * - Bash commands are screened lexically: absolute, `~`/$HOME, and
29
+ * `..`-traversal path references must land inside the allowed roots or
30
+ * the system prefixes. This cannot catch every obfuscated escape (shell
31
+ * is Turing-complete) — it is a strong guardrail, not a security
32
+ * boundary. Operators needing a hard boundary should use
33
+ * NAVARCH_SANDBOX_MODE=docker (sandbox.cts).
34
+ * - Symlinks are resolved (realpath of the longest existing prefix) before
35
+ * the containment check, so `ln -s $HOME escape` doesn't work either.
36
+ *
37
+ * This file is plain CommonJS (not .cts) on purpose: it must be runnable by
38
+ * a bare `node` from both the published package (dist has compiled .cjs
39
+ * next to bin/) and the source tree (vitest imports it directly), without
40
+ * depending on the TypeScript build. Node stdlib only.
41
+ */
42
+
43
+ const fs = require("node:fs");
44
+ const os = require("node:os");
45
+ const path = require("node:path");
46
+
47
+ /**
48
+ * Prefixes shell commands may reference freely (and read-only file tools may
49
+ * read): OS/toolchain locations plus shared temp. Everything here is either
50
+ * world-readable system state or scratch space that no Navarch session owns.
51
+ * Deliberately absent: /Users, /home, /root, /var (beyond tmp), and the
52
+ * workspace root — those are exactly what the guard exists to protect.
53
+ */
54
+ const DEFAULT_SYSTEM_PREFIXES = [
55
+ "/bin",
56
+ "/sbin",
57
+ "/usr",
58
+ "/lib",
59
+ "/lib32",
60
+ "/lib64",
61
+ "/libx32",
62
+ "/opt",
63
+ "/etc",
64
+ "/private/etc",
65
+ "/dev",
66
+ "/proc",
67
+ "/sys",
68
+ "/run",
69
+ // Deliberately NOT the whole /System: /System/Volumes/Data firmlinks the
70
+ // entire macOS data volume (including /Users), which would be an escape.
71
+ "/System/Library",
72
+ "/System/Applications",
73
+ "/Applications",
74
+ "/Library/Developer",
75
+ "/tmp",
76
+ "/private/tmp",
77
+ "/var/tmp",
78
+ "/var/folders",
79
+ "/private/var/folders",
80
+ "/nix",
81
+ "/snap",
82
+ ];
83
+
84
+ /** Path-carrying input fields per guarded file tool (Claude Code tool schemas). */
85
+ const FILE_TOOL_PATH_FIELDS = {
86
+ Read: ["file_path"],
87
+ Write: ["file_path"],
88
+ Edit: ["file_path"],
89
+ MultiEdit: ["file_path"],
90
+ NotebookEdit: ["notebook_path"],
91
+ Glob: ["path"],
92
+ Grep: ["path"],
93
+ LS: ["path"],
94
+ };
95
+
96
+ /** File tools that only read — these may also touch the system prefixes. */
97
+ const READ_ONLY_FILE_TOOLS = new Set(["Read", "Glob", "Grep", "LS"]);
98
+
99
+ /**
100
+ * Loads the per-session guard config written by src/worktree-guard.cts:
101
+ * `{ allowedRoots: string[], deniedRoots?: string[], systemPrefixes?:
102
+ * string[] }`. Temp roots are appended here (not persisted) so the config
103
+ * stays portable across OSes.
104
+ *
105
+ * Precedence (checkPath below): allowedRoots (this session's own dirs) win,
106
+ * then deniedRoots (the whole multi-session workspace root — so sibling
107
+ * sessions and other projects' bare repos stay off-limits even when the
108
+ * workspace happens to live under a temp or system prefix), then the broad
109
+ * temp/system allowances.
110
+ */
111
+ function loadGuardConfig(configPath) {
112
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
113
+ if (!raw || !Array.isArray(raw.allowedRoots) || raw.allowedRoots.length === 0) {
114
+ throw new Error(`guard config ${configPath} does not declare allowedRoots`);
115
+ }
116
+ const canonicalize = (root) => resolveWithRealpath(path.resolve(root));
117
+ return {
118
+ allowedRoots: [...new Set(raw.allowedRoots.map(canonicalize))],
119
+ deniedRoots: [...new Set((raw.deniedRoots ?? []).map(canonicalize))],
120
+ tempRoots: [...new Set([os.tmpdir(), "/tmp", "/var/tmp"].map(canonicalize))],
121
+ systemPrefixes:
122
+ Array.isArray(raw.systemPrefixes) && raw.systemPrefixes.length > 0
123
+ ? raw.systemPrefixes
124
+ : DEFAULT_SYSTEM_PREFIXES,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Containment verdict for one canonicalized absolute path.
130
+ * `allowSystemPrefixes` is true for read-only file tools and for Bash (whose
131
+ * commands must keep reaching toolchains under /usr, /etc, ...).
132
+ */
133
+ function checkPath(resolved, config, allowSystemPrefixes) {
134
+ if (config.allowedRoots.some((root) => isWithin(root, resolved))) return true;
135
+ if (config.deniedRoots.some((root) => isWithin(root, resolved))) return false;
136
+ if (config.tempRoots.some((root) => isWithin(root, resolved))) return true;
137
+ if (allowSystemPrefixes && config.systemPrefixes.some((root) => isWithin(root, resolved))) {
138
+ return true;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Canonicalizes an absolute path: realpath of the longest existing ancestor
145
+ * with the non-existent tail re-appended. Resolving through the existing
146
+ * portion is what defeats symlink escapes; keeping the tail lets the guard
147
+ * judge not-yet-created files (Write) by where they would actually land.
148
+ */
149
+ function resolveWithRealpath(absolutePath) {
150
+ let current = path.normalize(absolutePath);
151
+ const tail = [];
152
+ for (;;) {
153
+ try {
154
+ const real = fs.realpathSync(current);
155
+ return tail.length > 0 ? path.join(real, ...tail.reverse()) : real;
156
+ } catch {
157
+ const parent = path.dirname(current);
158
+ if (parent === current) return path.normalize(absolutePath);
159
+ tail.push(path.basename(current));
160
+ current = parent;
161
+ }
162
+ }
163
+ }
164
+
165
+ function isWithin(root, candidate) {
166
+ return candidate === root || candidate.startsWith(root + path.sep);
167
+ }
168
+
169
+ /** Expands leading `~`, `~/`, `$HOME`, and `${HOME}` to the real home directory. */
170
+ function expandHome(value) {
171
+ if (value === "~") return os.homedir();
172
+ if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
173
+ if (/^\$\{?HOME\}?($|\/)/.test(value)) return value.replace(/^\$\{?HOME\}?/, os.homedir());
174
+ return value;
175
+ }
176
+
177
+ /**
178
+ * Pulls path-like references out of a shell command: absolute paths,
179
+ * home-anchored paths (`~...`, `$HOME/...`), and relative `..` traversals.
180
+ * Lexical by design — see the file header for why this is a guardrail, not
181
+ * a parser. URLs survive untouched (the char before `//` in `https://` is
182
+ * `:`, which is not a path delimiter here).
183
+ */
184
+ function extractBashPathCandidates(command) {
185
+ const candidates = [];
186
+ const seen = new Set();
187
+ const add = (raw) => {
188
+ const cleaned = raw.replace(/[)\]}"'`,]+$/, "");
189
+ if (!cleaned || seen.has(cleaned)) return;
190
+ seen.add(cleaned);
191
+ candidates.push(cleaned);
192
+ };
193
+
194
+ const absoluteRe = /(?:^|[\s'"`=(<>;|&])(\/[^\s'"`<>;|&)]*)/g;
195
+ const homeRe = /(?:^|[\s'"`=(<>;|&])((?:~|\$\{?HOME\}?)[^\s'"`<>;|&)]*)/g;
196
+ let match;
197
+ while ((match = absoluteRe.exec(command))) add(match[1]);
198
+ while ((match = homeRe.exec(command))) add(match[1]);
199
+
200
+ for (const token of command.split(/[\s'"`;|&<>()]+/)) {
201
+ if (!token || token.startsWith("-") || token.startsWith("/") || token.startsWith("~")) continue;
202
+ if (token === ".." || token.startsWith("../") || token.includes("/../") || token.endsWith("/..")) {
203
+ add(token);
204
+ }
205
+ }
206
+ return candidates;
207
+ }
208
+
209
+ function denyMessage(toolName, rawPath, resolvedPath, config) {
210
+ const resolvedNote =
211
+ resolvedPath && resolvedPath !== rawPath ? ` (resolves to "${resolvedPath}")` : "";
212
+ return (
213
+ `Navarch worktree guard blocked this ${toolName} call: "${rawPath}"${resolvedNote} is outside ` +
214
+ `this session's workspace. This machine runs multiple isolated agent sessions; work only under: ` +
215
+ `${config.allowedRoots.join(", ")}. Standard system paths (/usr, /etc, /tmp, ...) stay available ` +
216
+ `to shell commands. Do not attempt to bypass this boundary — if the task genuinely requires that ` +
217
+ `path, state the limitation in your report instead.`
218
+ );
219
+ }
220
+
221
+ /** Screens one Bash command. Returns {allowed} or {allowed:false, reason}. */
222
+ function evaluateBashCommand(command, cwd, config) {
223
+ // A bare `cd` (or `cd -`) jumps to $HOME / an unknowable previous
224
+ // directory — both outside the worktree by construction.
225
+ for (const segment of command.split(/[;&|]+/)) {
226
+ const trimmed = segment.trim();
227
+ if (trimmed === "cd" || trimmed === "cd -") {
228
+ return { allowed: false, reason: denyMessage("Bash", trimmed, os.homedir(), config) };
229
+ }
230
+ }
231
+
232
+ for (const raw of extractBashPathCandidates(command)) {
233
+ // `~otheruser/...` — another account's home; nothing there is in scope.
234
+ if (/^~[^/]/.test(raw)) {
235
+ return { allowed: false, reason: denyMessage("Bash", raw, "", config) };
236
+ }
237
+ const resolved = resolveWithRealpath(path.resolve(cwd, expandHome(raw)));
238
+ if (!checkPath(resolved, config, true)) {
239
+ return { allowed: false, reason: denyMessage("Bash", raw, resolved, config) };
240
+ }
241
+ }
242
+ return { allowed: true };
243
+ }
244
+
245
+ /**
246
+ * Evaluates one PreToolUse payload. Guarded file tools and Bash are checked;
247
+ * every other tool (MCP platform tools, WebFetch, Task, ...) is allowed —
248
+ * the settings matcher (src/worktree-guard.cts) shouldn't even route them
249
+ * here, so this is belt-and-braces.
250
+ */
251
+ function evaluateToolUse(input, config) {
252
+ const toolName = typeof input.tool_name === "string" ? input.tool_name : "";
253
+ const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
254
+ const toolInput = input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
255
+
256
+ const pathFields = FILE_TOOL_PATH_FIELDS[toolName];
257
+ if (pathFields) {
258
+ const candidates = [];
259
+ for (const field of pathFields) {
260
+ const value = toolInput[field];
261
+ if (typeof value === "string" && value) candidates.push(value);
262
+ }
263
+ // No explicit path (e.g. Glob/Grep default to cwd): judge cwd itself.
264
+ if (candidates.length === 0) candidates.push(cwd);
265
+
266
+ for (const candidate of candidates) {
267
+ const resolved = resolveWithRealpath(path.resolve(cwd, expandHome(candidate)));
268
+ if (!checkPath(resolved, config, READ_ONLY_FILE_TOOLS.has(toolName))) {
269
+ return { allowed: false, reason: denyMessage(toolName, candidate, resolved, config) };
270
+ }
271
+ }
272
+ return { allowed: true };
273
+ }
274
+
275
+ if (toolName === "Bash") {
276
+ const command = toolInput.command;
277
+ if (typeof command !== "string" || !command) return { allowed: true };
278
+ return evaluateBashCommand(command, cwd, config);
279
+ }
280
+
281
+ return { allowed: true };
282
+ }
283
+
284
+ function main() {
285
+ try {
286
+ const configPath = process.argv[2];
287
+ if (!configPath) throw new Error("usage: worktree-guard-hook.cjs <guard-config.json>");
288
+ const config = loadGuardConfig(configPath);
289
+ const input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
290
+ const verdict = evaluateToolUse(input, config);
291
+ if (verdict.allowed) process.exit(0);
292
+ process.stderr.write(verdict.reason);
293
+ process.exit(2);
294
+ } catch (err) {
295
+ // Fail closed: a guard that cannot evaluate must not wave the call through.
296
+ process.stderr.write(`Navarch worktree guard failed closed: ${String(err)}`);
297
+ process.exit(2);
298
+ }
299
+ }
300
+
301
+ if (require.main === module) main();
302
+
303
+ module.exports = {
304
+ DEFAULT_SYSTEM_PREFIXES,
305
+ evaluateBashCommand,
306
+ evaluateToolUse,
307
+ expandHome,
308
+ extractBashPathCandidates,
309
+ isWithin,
310
+ loadGuardConfig,
311
+ resolveWithRealpath,
312
+ };
@@ -37,12 +37,39 @@ const NAVARCH_MCP_ALLOWED_TOOLS = [
37
37
  */
38
38
  async function runClaudeCodeAdapter(options) {
39
39
  const args = ["-p", options.prompt];
40
+ const hasExplicitPermissionPolicy = options.extraArgs.some((arg) => [
41
+ "--allowedTools",
42
+ "--allowed-tools",
43
+ "--disallowedTools",
44
+ "--disallowed-tools",
45
+ "--permission-mode",
46
+ "--permission-prompt-tool",
47
+ "--dangerously-skip-permissions",
48
+ ].includes(arg));
49
+ // Navarch sessions are unattended: a permission prompt can never be
50
+ // answered and leaves Claude unable to edit the task's isolated worktree.
51
+ // The runtime owns that worktree (and, in Docker mode, the surrounding
52
+ // container), so make the non-interactive session capable of completing
53
+ // coding tasks by default. Operators can replace this with a narrower
54
+ // Claude permission policy through NAVARCH_CLAUDE_EXTRA_ARGS.
55
+ if (!hasExplicitPermissionPolicy) {
56
+ args.push("--dangerously-skip-permissions");
57
+ }
58
+ // The worktree-guard settings file (worktree-guard.cts) installs the
59
+ // PreToolUse boundary hook. Hooks run even under permission bypass, so this
60
+ // is what keeps an unattended session inside its own worktree on a machine
61
+ // running several agents. An operator-supplied --settings (via
62
+ // NAVARCH_CLAUDE_EXTRA_ARGS) wins — two --settings flags on one invocation
63
+ // would be ambiguous, and session.cts logs the guard as skipped.
64
+ if (options.settingsPath && !options.extraArgs.includes("--settings")) {
65
+ args.push("--settings", options.settingsPath);
66
+ }
40
67
  if (options.mcpConfigPath) {
41
68
  args.push("--mcp-config", options.mcpConfigPath);
42
- // Headless Claude cannot pause for an interactive permission prompt. The
43
- // Navarch server already scopes every call to the authenticated lease and
44
- // enforces task/project authorization, so pre-approve exactly its known
45
- // tools instead of bypassing Claude permissions globally.
69
+ // The Navarch server scopes every call to the authenticated lease and
70
+ // enforces task/project authorization. Keep its tools explicit for
71
+ // operator-supplied policies and for compatibility with Claude versions
72
+ // that still inspect allowedTools while permission bypass is active.
46
73
  if (!options.extraArgs.includes("--allowedTools") &&
47
74
  !options.extraArgs.includes("--allowed-tools")) {
48
75
  args.push("--allowedTools", NAVARCH_MCP_ALLOWED_TOOLS.join(","));
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.codexAdapter = void 0;
4
4
  exports.runCodexAdapter = runCodexAdapter;
5
+ exports.hasExplicitPermissionPolicy = hasExplicitPermissionPolicy;
5
6
  const node_child_process_1 = require("node:child_process");
6
7
  const node_fs_1 = require("node:fs");
7
8
  const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
@@ -29,16 +30,12 @@ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
29
30
  * per-session JSON is translated into one-off `-c mcp_servers.*` overrides.
30
31
  * Authentication and custom-header values are passed through environment
31
32
  * variables so machine/lease credentials never appear in argv.
32
- * - Sandboxing/approvals: a real `codex exec` may prompt for
33
- * approval/sandbox-escalation on some actions by default; because this
34
- * runtime already isolates the session in its own Docker container (or,
35
- * in `NAVARCH_SANDBOX_MODE=host`, trusts the host), the intent is to pass
36
- * whatever flag disables Codex's own approval gate for a fully
37
- * non-interactive run (something like `--full-auto` or
38
- * `--dangerously-bypass-approvals-and-sandbox` in published Codex CLI
39
- * documentation) — deliberately NOT hardcoded here since getting an
40
- * unverified flag wrong could silently change sandboxing behavior; left to
41
- * be supplied via NAVARCH_CODEX_EXTRA_ARGS until confirmed.
33
+ * - Host-mode sessions use a generated native Codex permission profile that
34
+ * denies reads and writes outside the session worktree, shared Git dir,
35
+ * temp dirs, and operator-approved extra roots. It also uses `--ask-for-
36
+ * approval never`, so a boundary violation fails back to the unattended
37
+ * agent instead of waiting for input. Explicit permission-policy arguments
38
+ * in NAVARCH_CODEX_EXTRA_ARGS replace that generated profile.
42
39
  * - `NAVARCH_CODEX_EXTRA_ARGS` (`extraArgs`) wins over the default `--json`
43
40
  * exactly like the Claude adapter's `--output-format` opt-out, so an
44
41
  * operator can fall back to plain-text output (or add the real
@@ -52,6 +49,9 @@ async function runCodexAdapter(options) {
52
49
  if (!options.extraArgs.includes("--json")) {
53
50
  args.push("--json");
54
51
  }
52
+ if (options.codexGuardArgs && !hasExplicitPermissionPolicy(options.extraArgs)) {
53
+ args.push(...options.codexGuardArgs);
54
+ }
55
55
  args.push(...options.extraArgs);
56
56
  if (options.model)
57
57
  args.push("--model", options.model);
@@ -64,6 +64,36 @@ async function runCodexAdapter(options) {
64
64
  : await runOnHost(runOptions, args);
65
65
  return attachUsage(raw);
66
66
  }
67
+ /** Operator policy wins over Navarch's generated host-mode profile. */
68
+ function hasExplicitPermissionPolicy(args) {
69
+ for (let index = 0; index < args.length; index += 1) {
70
+ const arg = args[index];
71
+ if ([
72
+ "--sandbox",
73
+ "-s",
74
+ "--dangerously-bypass-approvals-and-sandbox",
75
+ "--profile",
76
+ "-p",
77
+ "--ignore-user-config",
78
+ ].includes(arg) ||
79
+ arg.startsWith("--sandbox=") ||
80
+ arg.startsWith("-s=")) {
81
+ return true;
82
+ }
83
+ if (arg === "-c" || arg === "--config") {
84
+ const override = args[index + 1] ?? "";
85
+ if (/^(sandbox_mode|default_permissions|permissions)(\.|=)/.test(override))
86
+ return true;
87
+ index += 1;
88
+ }
89
+ else if (arg.startsWith("--config=")) {
90
+ const override = arg.slice("--config=".length);
91
+ if (/^(sandbox_mode|default_permissions|permissions)(\.|=)/.test(override))
92
+ return true;
93
+ }
94
+ }
95
+ return false;
96
+ }
67
97
  /** Convert Claude's per-session MCP JSON into Codex one-off TOML overrides. */
68
98
  async function codexMcpArgs(path, env) {
69
99
  const parsed = JSON.parse(await node_fs_1.promises.readFile(path, "utf8"));
package/dist/config.cjs CHANGED
@@ -60,5 +60,12 @@ function loadRuntimeConfig(env = process.env) {
60
60
  mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
61
61
  sandboxMode,
62
62
  dockerImage: env.NAVARCH_DOCKER_IMAGE ?? "node:20-slim",
63
+ // Multiple sessions share one machine; keeping each agent inside its own
64
+ // worktree is the safe default, so disabling is the explicit opt-out.
65
+ worktreeGuard: !["off", "false", "0"].includes(env.NAVARCH_WORKTREE_GUARD ?? ""),
66
+ guardExtraRoots: (env.NAVARCH_GUARD_EXTRA_ROOTS ?? "")
67
+ .split(node_path_1.default.delimiter)
68
+ .map((s) => s.trim())
69
+ .filter(Boolean),
63
70
  };
64
71
  }
package/dist/session.cjs CHANGED
@@ -16,6 +16,7 @@ const mcp_config_cjs_1 = require("./mcp-config.cjs");
16
16
  const logger_cjs_1 = require("./logger.cjs");
17
17
  const git_worktree_cjs_1 = require("./git-worktree.cjs");
18
18
  const github_pr_cjs_1 = require("./github-pr.cjs");
19
+ const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
19
20
  /** Filename the generated platform MCP config is written under inside the session metadata directory. */
20
21
  const MCP_CONFIG_FILENAME = "mcp-config.json";
21
22
  const log = (0, logger_cjs_1.createLogger)("session");
@@ -42,7 +43,7 @@ async function runSession(deps, claimed, sessionId) {
42
43
  const { lease_id: leaseId, task, context_bundle: bundle } = claimed;
43
44
  const execution = bundle.execution ?? {
44
45
  profile: task.execution_profile ?? "standard",
45
- model: config.agentType === "codex" ? "gpt-5.6" : "best",
46
+ model: config.agentType === "codex" ? "gpt-5.6-sol" : "best",
46
47
  reasoning_effort: "medium",
47
48
  };
48
49
  const executionReport = {
@@ -183,6 +184,39 @@ async function runSession(deps, claimed, sessionId) {
183
184
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, MCP_CONFIG_FILENAME), JSON.stringify(mcpConfig, null, 2), "utf8");
184
185
  mcpConfigPath = node_path_1.default.join(workDir, MCP_CONFIG_FILENAME);
185
186
  }
187
+ // Worktree boundary guard (worktree-guard.cts): several sessions share this
188
+ // machine. Host-mode Claude gets a generated PreToolUse hook; host-mode
189
+ // Codex gets an OS-enforced native permission profile over the same roots.
190
+ // Docker mode already has a container boundary. NAVARCH_WORKTREE_GUARD=off
191
+ // opts out for either agent.
192
+ let claudeSettingsPath = null;
193
+ let codexGuardArgs;
194
+ if (config.sandboxMode === "host" && config.worktreeGuard) {
195
+ if (config.agentType === "claude-code") {
196
+ if (config.claudeExtraArgs.includes("--settings")) {
197
+ log.warn("NAVARCH_CLAUDE_EXTRA_ARGS supplies --settings; skipping the generated worktree-guard settings for this session.");
198
+ }
199
+ else {
200
+ const guard = await (0, worktree_guard_cjs_1.prepareWorktreeGuard)({
201
+ workDir,
202
+ worktreePath: gitWorktree.worktreePath,
203
+ repositoryPath: gitWorktree.repositoryPath,
204
+ workspaceRoot: config.workspaceRoot,
205
+ extraRoots: config.guardExtraRoots,
206
+ });
207
+ claudeSettingsPath = guard.settingsPath;
208
+ }
209
+ }
210
+ else {
211
+ codexGuardArgs = (0, worktree_guard_cjs_1.codexWorktreeGuardArgs)({
212
+ workDir,
213
+ worktreePath: gitWorktree.worktreePath,
214
+ repositoryPath: gitWorktree.repositoryPath,
215
+ workspaceRoot: config.workspaceRoot,
216
+ extraRoots: config.guardExtraRoots,
217
+ });
218
+ }
219
+ }
186
220
  try {
187
221
  await gitWorktree.prepare();
188
222
  if (sandbox) {
@@ -220,6 +254,8 @@ async function runSession(deps, claimed, sessionId) {
220
254
  reasoningEffort: execution.reasoning_effort,
221
255
  timeoutMs: config.sessionTimeoutMs,
222
256
  env: toEnvMap(secrets),
257
+ settingsPath: claudeSettingsPath,
258
+ codexGuardArgs,
223
259
  cwd: sandbox ? undefined : gitWorktree.worktreePath,
224
260
  dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
225
261
  signal: activeAbortController.signal,
@@ -0,0 +1,121 @@
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.guardHookScriptPath = guardHookScriptPath;
7
+ exports.prepareWorktreeGuard = prepareWorktreeGuard;
8
+ exports.codexWorktreeGuardArgs = codexWorktreeGuardArgs;
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const node_fs_1 = require("node:fs");
11
+ const CODEX_GUARD_PROFILE = "navarch-worktree";
12
+ /**
13
+ * Tools the hook screens. Everything else — the lease-scoped Navarch MCP
14
+ * tools, WebFetch, Task, ... — carries no direct filesystem path and passes
15
+ * through unmatched.
16
+ */
17
+ const GUARDED_TOOL_MATCHER = "^(Read|Write|Edit|MultiEdit|NotebookEdit|Glob|Grep|LS|Bash)$";
18
+ /**
19
+ * The hook ships as plain CommonJS in bin/ (see its header for why), which
20
+ * sits one level above this module both in the source tree (src/) and in the
21
+ * published package (dist/), so __dirname-relative resolution works in both.
22
+ */
23
+ function guardHookScriptPath() {
24
+ return node_path_1.default.join(__dirname, "..", "bin", "worktree-guard-hook.cjs");
25
+ }
26
+ /**
27
+ * Writes the per-session guard config + Claude settings file into workDir and
28
+ * returns their paths. The settings file is passed to the CLI as `--settings`
29
+ * by adapters/claude.cts.
30
+ */
31
+ async function prepareWorktreeGuard(options) {
32
+ const hookScriptPath = guardHookScriptPath();
33
+ const configPath = node_path_1.default.join(options.workDir, "worktree-guard.json");
34
+ const settingsPath = node_path_1.default.join(options.workDir, "claude-settings.json");
35
+ const allowedRoots = [
36
+ options.worktreePath,
37
+ options.repositoryPath,
38
+ ...(options.extraRoots ?? []),
39
+ ];
40
+ const deniedRoots = [options.workspaceRoot];
41
+ await node_fs_1.promises.writeFile(configPath, JSON.stringify({ allowedRoots, deniedRoots }, null, 2), "utf8");
42
+ // process.execPath rather than a bare `node`: the hook must run with the
43
+ // same interpreter as the runtime regardless of the agent's PATH.
44
+ const command = [process.execPath, hookScriptPath, configPath].map(shellQuote).join(" ");
45
+ const settings = {
46
+ hooks: {
47
+ PreToolUse: [
48
+ {
49
+ matcher: GUARDED_TOOL_MATCHER,
50
+ hooks: [{ type: "command", command }],
51
+ },
52
+ ],
53
+ },
54
+ };
55
+ await node_fs_1.promises.writeFile(settingsPath, JSON.stringify(settings, null, 2), "utf8");
56
+ return { settingsPath, configPath, hookScriptPath };
57
+ }
58
+ /**
59
+ * Builds one-off Codex permission-profile arguments for a host session.
60
+ *
61
+ * `--ignore-user-config` is deliberate: a sandbox_mode in any loaded user
62
+ * config makes Codex ignore permission profiles, which would silently discard
63
+ * this boundary. Authentication still comes from CODEX_HOME. The profile
64
+ * grants only the runtime paths common tools need, the session worktree, the
65
+ * shared Git directory, temp space, and operator-approved extra roots. The
66
+ * surrounding Navarch workspace is denied, then the more-specific current
67
+ * worktree/repository grants reopen only this session's paths. The worktree
68
+ * is marked untrusted for configuration purposes so a checked-in legacy
69
+ * sandbox_mode cannot disable the generated permission profile; repository
70
+ * instructions such as AGENTS.md still load normally.
71
+ */
72
+ function codexWorktreeGuardArgs(options) {
73
+ const filesystem = {
74
+ ":minimal": "read",
75
+ ":tmpdir": "write",
76
+ ":slash_tmp": "write",
77
+ [node_path_1.default.resolve(options.workspaceRoot)]: "deny",
78
+ [node_path_1.default.resolve(options.worktreePath)]: "write",
79
+ [node_path_1.default.resolve(options.repositoryPath)]: "write",
80
+ };
81
+ for (const root of options.extraRoots ?? []) {
82
+ const resolved = node_path_1.default.resolve(root);
83
+ // Match the Claude hook's denied-root precedence: an extra root cannot
84
+ // reopen sibling sessions or metadata inside the Navarch workspace.
85
+ if (isPathInside(resolved, node_path_1.default.resolve(options.workspaceRoot)))
86
+ continue;
87
+ filesystem[resolved] = "write";
88
+ }
89
+ return [
90
+ "--ignore-user-config",
91
+ "--ask-for-approval",
92
+ "never",
93
+ "-c",
94
+ `projects.${tomlString(node_path_1.default.resolve(options.worktreePath))}.trust_level="untrusted"`,
95
+ "-c",
96
+ `default_permissions=${tomlString(CODEX_GUARD_PROFILE)}`,
97
+ "-c",
98
+ `permissions.${CODEX_GUARD_PROFILE}.filesystem=${tomlInlineTable(filesystem)}`,
99
+ // Navarch coding tasks must be able to fetch dependencies and push their
100
+ // branch. The profile still constrains filesystem access independently.
101
+ "-c",
102
+ `permissions.${CODEX_GUARD_PROFILE}.network.enabled=true`,
103
+ ];
104
+ }
105
+ function isPathInside(candidate, root) {
106
+ const relative = node_path_1.default.relative(root, candidate);
107
+ return relative === "" || (!relative.startsWith("..") && !node_path_1.default.isAbsolute(relative));
108
+ }
109
+ function tomlInlineTable(table) {
110
+ return `{${Object.entries(table)
111
+ .map(([key, value]) => typeof value === "string"
112
+ ? `${tomlString(key)}=${tomlString(value)}`
113
+ : `${tomlString(key)}=${tomlInlineTable(value)}`)
114
+ .join(",")}}`;
115
+ }
116
+ function tomlString(value) {
117
+ return JSON.stringify(value);
118
+ }
119
+ function shellQuote(value) {
120
+ return `'${value.replace(/'/g, `'\\''`)}'`;
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Navarch machine-side session manager: registers a machine, claims tasks from the control-plane dispatcher, runs them via the Claude Code or Codex adapter, and reports results back.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",