@sagentlab/navarch-runtime 0.1.5 → 0.1.7

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
@@ -24,7 +24,7 @@ git clone <this repo> && cd sagentlab/runtime
24
24
  ./install.sh # checks node, npm install, npm run build
25
25
  export NAVARCH_API_BASE=https://navarch.example.com
26
26
  node bin/navarch.cjs register --token <enrollment-token> --name my-machine-1
27
- node bin/navarch.cjs start
27
+ node bin/navarch.cjs supervise
28
28
  ```
29
29
 
30
30
  Or, once dependencies are installed:
@@ -32,7 +32,7 @@ Or, once dependencies are installed:
32
32
  ```sh
33
33
  npm run build
34
34
  NAVARCH_API_BASE=https://navarch.example.com npm run register -- --token <enrollment-token> --name my-machine-1
35
- npm start
35
+ npm run supervise
36
36
  ```
37
37
 
38
38
  `register` is the one command that prints the machine's auth token — exactly
@@ -61,7 +61,7 @@ project only (it will never be dispatched work from any other project).
61
61
  ```sh
62
62
  npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
63
63
  --name my-agent-1 --agent codex --api-base https://navarch.example.com
64
- node bin/navarch.cjs start
64
+ npx @sagentlab/navarch-runtime supervise
65
65
  ```
66
66
 
67
67
  (`@sagentlab/navarch-runtime` is published to npm, so `npx
@@ -76,6 +76,7 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
76
76
  | `register --token <t> --name <n> [--agent claude-code\|codex] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
77
77
  | `connect --token <t> --name <n> [--agent claude-code\|codex] [--project <id>] […]` | Connects this machine to one project, saves its local agent choice, and prints the token once. |
78
78
  | `start [--agent claude-code\|codex]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
79
+ | `supervise [--agent claude-code\|codex]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
79
80
  | `doctor` | Prints resolved config + Docker/registration status; no side effects. |
80
81
 
81
82
  ## Active-task guidance
@@ -102,13 +103,49 @@ The daemon logs `restarting the agent turn in the same worktree` when it
102
103
  delivers guidance. Lowering the lease-heartbeat interval makes guidance arrive
103
104
  sooner, but keep it comfortably below the 15-minute lease TTL.
104
105
 
105
- ## Safely upgrading and restarting the daemon
106
-
107
- The daemon does not currently drain sessions during shutdown: `SIGINT` or
108
- `SIGTERM` stops new claims and heartbeats, then exits immediately. Stopping it
109
- with an active session can interrupt the agent before it reports completion;
110
- the control plane must then wait for the lease to expire and requeue the task.
111
- Do not use an ordinary daemon restart as a way to deliver guidance.
106
+ ## Automatic and manual runtime upgrades
107
+
108
+ Run long-lived agents with `navarch-runtime supervise`. The worker advertises
109
+ its package version and updater protocol on each machine heartbeat. When the
110
+ control plane assigns an eligible release, it:
111
+
112
+ 1. Downloads the exact `@sagentlab/navarch-runtime@<version>` tarball with npm
113
+ lifecycle scripts disabled and verifies the control-plane-recorded SHA-512
114
+ integrity.
115
+ 2. Stages it under `$NAVARCH_CONFIG_DIR/versions/<version>` while existing
116
+ sessions continue.
117
+ 3. Stops claiming, reports zero available capacity, and waits for both an
118
+ in-flight claim and every active session to finish.
119
+ 4. Atomically writes `pending-update.json` and exits with the supervisor-only
120
+ handoff code. The supervisor starts the staged binary with the same config
121
+ directory and machine identity.
122
+ 5. Commits the activation after the replacement completes its first successful
123
+ heartbeat, persisting `active-runtime.json` so machine restarts keep using
124
+ the verified release. A crash or two-minute health timeout before then
125
+ restores the previous binary.
126
+
127
+ The control plane never supplies a package name, URL, path, or shell command:
128
+ only an exact semver and npm SHA-512 integrity for the fixed package name are
129
+ accepted. Rollouts are assigned by the machine's server-managed `stable` or
130
+ `canary` channel and deterministic rollout percentage. Set
131
+ `NAVARCH_AUTO_UPDATE=off` to keep telemetry but disable automatic activation.
132
+
133
+ Plain `navarch-runtime start` continues to work and reports available releases,
134
+ but deliberately does not activate them because no parent process would exist
135
+ to perform a safe handoff or rollback.
136
+
137
+ After publishing a runtime, an operator records the immutable npm metadata in
138
+ `runtime_releases`. Obtain the integrity with
139
+ `npm view @sagentlab/navarch-runtime@<version> dist.integrity`, then insert the
140
+ exact version, returned integrity, release channel, and desired rollout
141
+ percentage. Start with `channel='canary'` or a small `rollout_percent`; raising
142
+ the percentage keeps existing machines in the same deterministic cohort.
143
+
144
+ ### Manual fallback
145
+
146
+ `SIGINT` or `SIGTERM` now stops new claims and drains active sessions before
147
+ exiting. A second signal forces an immediate exit and can interrupt work, so use
148
+ it only when abandoning the active leases is intentional.
112
149
 
113
150
  Use this sequence for an upgrade:
114
151
 
@@ -158,11 +195,68 @@ unchanged across the deployment.
158
195
  | `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
159
196
  | `NAVARCH_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. |
160
197
  | `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. |
198
+ | `NAVARCH_UPDATE_CHANNEL` | `stable` | Release channel advertised by the worker (`stable` or `canary`); the server-managed machine channel remains authoritative. |
199
+ | `NAVARCH_AUTO_UPDATE` | on under `supervise` | Set `off`, `false`, or `0` to report releases without staging or activating them. Automatic activation is always off under plain `start`. |
161
200
  | `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). |
201
+ | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). `--allowedTools`/`--disallowedTools` layer rules onto auto mode; an explicit `--permission-mode`, `--permission-prompt-tool`, or bypass flag replaces the unattended default `--permission-mode auto`. |
163
202
  | `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
164
203
  | `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
165
204
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
205
+ | `NAVARCH_WORKTREE_GUARD` | on | Host-mode Claude and Codex sessions get a per-session worktree boundary guard (see below). Set `off` to disable. |
206
+ | `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. |
207
+
208
+ ## Worktree boundary guard (host mode)
209
+
210
+ A machine typically runs several sessions concurrently (`NAVARCH_MAX_SESSIONS`),
211
+ each in its own git worktree. The runtime enforces the same boundary for both
212
+ supported coding agents, using each CLI's native enforcement point:
213
+
214
+ - **Claude Code:** the runtime uses `--permission-mode auto`, which sends
215
+ approval decisions through Claude Code's background safety classifier. It
216
+ does not blanket-preapprove the lease-scoped Navarch MCP tools. A generated
217
+ settings file (`src/worktree-guard.cts`, passed as `--settings`) also installs
218
+ `bin/worktree-guard-hook.cjs` as a fail-closed `PreToolUse` boundary hook.
219
+ - **Codex:** the runtime passes a one-off native permission profile with
220
+ `approval_policy="on-request"` and `approvals_reviewer="auto_review"`.
221
+ Codex's OS sandbox grants read/write access only to the allowed roots and
222
+ denies the surrounding multi-session workspace, while eligible escalations
223
+ are decided by the automatic reviewer rather than waiting for human input.
224
+ `--ignore-user-config` and an untrusted project-config override prevent a
225
+ user or checked-in legacy `sandbox_mode` from silently disabling the
226
+ generated profile; Codex authentication still comes from `CODEX_HOME`, and
227
+ repository instructions such as `AGENTS.md` still load.
228
+
229
+ The resulting boundary is:
230
+
231
+ - **File tools** (`Read`/`Write`/`Edit`/`Glob`/`Grep`/...) may only touch the
232
+ session worktree, the project's shared bare repo, temp dirs, and any
233
+ `NAVARCH_GUARD_EXTRA_ROOTS`. Read-only tools may additionally read standard
234
+ system prefixes (`/usr`, `/etc`, ...). Symlinks are resolved before the
235
+ containment check.
236
+ - **Bash commands** are screened lexically: absolute, `~`/`$HOME`, and
237
+ `..`-traversal path references must land inside the allowed roots or the
238
+ system prefixes.
239
+ - The **rest of the workspace root** — sibling sessions' worktrees, other
240
+ projects' bare repos, and the session's own metadata dir (lease-scoped MCP
241
+ config, the guard files themselves) — is denied outright, so an agent can
242
+ neither read another agent's checkout nor rewrite its own guard policy.
243
+
244
+ The Claude hook is a strong guardrail rather than a hard security boundary
245
+ because shell paths are screened lexically. Codex's permission profile is
246
+ enforced by its OS sandbox. For container-grade whole-process isolation use
247
+ `NAVARCH_SANDBOX_MODE=docker`; neither host guard is installed in Docker mode.
248
+ An operator-supplied `--settings` in `NAVARCH_CLAUDE_EXTRA_ARGS`, or an
249
+ explicit Codex permission/sandbox option in `NAVARCH_CODEX_EXTRA_ARGS`, takes
250
+ precedence over the generated policy.
251
+
252
+ Claude auto mode requires Claude Code 2.1.83 or later and an eligible account,
253
+ model, and first-party Anthropic API provider. If those requirements are not
254
+ met, Claude Code rejects auto mode instead of silently bypassing checks.
255
+
256
+ The auto-review launch paths were verified live on 2026-07-20 with Claude Code
257
+ 2.1.214 (`--permission-mode auto`) and Codex CLI 0.144.1 (the generated
258
+ permission profile plus `approvals_reviewer="auto_review"`). Both completed an
259
+ unattended smoke task successfully.
166
260
 
167
261
  ## Choosing an agent (Claude Code vs. Codex)
168
262
 
@@ -213,7 +307,7 @@ envelope as a compatibility fallback.
213
307
  cli.cts
214
308
  ├─ register → api.registerMachine() → machine-store.cts (writes machine.json once)
215
309
  ├─ connect → api.connectMachine() → machine-store.cts (writes machine.json once)
216
- └─ start
310
+ ├─ start
217
311
  ├─ MachineHeartbeatLoop (heartbeat-loop.cts) → api.machineHeartbeat() [every NAVARCH_HEARTBEAT_INTERVAL_MS]
218
312
  └─ ClaimLoop (claim-loop.cts) → api.claim() [every NAVARCH_POLL_INTERVAL_MS, gated by CapacityTracker]
219
313
  └─ runSession (session.cts), one per claimed lease, run concurrently up to NAVARCH_MAX_SESSIONS:
@@ -232,6 +326,10 @@ cli.cts
232
326
  6. api.completeLease(), reporting agent_type: config.agentType
233
327
  7. sandbox.wipe() when present; remove the session workspace
234
328
  unconditionally (finally block)
329
+ └─ supervise → supervisor.cts starts the worker with IPC
330
+ └─ update directive → update-installer.cts stages exact npm release
331
+ └─ drain + exit 75 → supervisor activates candidate, waits for
332
+ healthy heartbeat, or rolls back
235
333
  ```
236
334
 
237
335
  `adapter.cts` (top-level) is now a backward-compat re-export of
@@ -298,6 +396,12 @@ tests cover:
298
396
  - `adapters/codex.cts` — arg construction on both the host path (mocked `spawn`)
299
397
  and the docker-exec path (fake `CommandRunner`), and usage/report-text
300
398
  attachment from fixed JSONL fixtures (`tests/adapters/codex.test.cts`).
399
+ - `worktree-guard.cts` + `bin/worktree-guard-hook.cjs` — generated settings/
400
+ config shape, native Codex permission-profile construction, and the hook's
401
+ containment verdicts (in-worktree vs. sibling session vs. home dir, symlink
402
+ escapes, Bash path screening, the fail-closed exit-2 protocol run as a real
403
+ subprocess) (`tests/worktree-guard.test.cts`,
404
+ `tests/worktree-guard-hook.test.cts`).
301
405
 
302
406
  Not exercised by unit tests, and needing a real machine per the WP-07 DoD
303
407
  ("on a real machine: register → claim a seeded docs task → session runs
@@ -0,0 +1,311 @@
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 in auto permission mode (adapters/claude.cts).
12
+ * Claude Code runs PreToolUse hooks in every permission mode, so this script
13
+ * is the enforcement point that keeps an agent from reading or writing
14
+ * another session's worktree, the operator's home directory, or anything
15
+ * else unrelated to its task.
16
+ *
17
+ * Protocol (Claude Code hooks): the hook receives {tool_name, tool_input,
18
+ * cwd, ...} as JSON on stdin. Exit 0 allows the tool call; exit 2 blocks it
19
+ * and feeds stderr back to the model as the reason. Any internal failure
20
+ * exits 2 as well — a safety harness must fail closed.
21
+ *
22
+ * Policy:
23
+ * - File tools (Read/Write/Edit/...) may only touch the allowed roots (the
24
+ * session worktree, the project's shared bare repo, temp dirs, plus any
25
+ * NAVARCH_GUARD_EXTRA_ROOTS). Read-only tools may additionally read
26
+ * standard system prefixes (/usr, /etc, ...) so toolchains keep working.
27
+ * - Bash commands are screened lexically: absolute, `~`/$HOME, and
28
+ * `..`-traversal path references must land inside the allowed roots or
29
+ * the system prefixes. This cannot catch every obfuscated escape (shell
30
+ * is Turing-complete) — it is a strong guardrail, not a security
31
+ * boundary. Operators needing a hard boundary should use
32
+ * NAVARCH_SANDBOX_MODE=docker (sandbox.cts).
33
+ * - Symlinks are resolved (realpath of the longest existing prefix) before
34
+ * the containment check, so `ln -s $HOME escape` doesn't work either.
35
+ *
36
+ * This file is plain CommonJS (not .cts) on purpose: it must be runnable by
37
+ * a bare `node` from both the published package (dist has compiled .cjs
38
+ * next to bin/) and the source tree (vitest imports it directly), without
39
+ * depending on the TypeScript build. Node stdlib only.
40
+ */
41
+
42
+ const fs = require("node:fs");
43
+ const os = require("node:os");
44
+ const path = require("node:path");
45
+
46
+ /**
47
+ * Prefixes shell commands may reference freely (and read-only file tools may
48
+ * read): OS/toolchain locations plus shared temp. Everything here is either
49
+ * world-readable system state or scratch space that no Navarch session owns.
50
+ * Deliberately absent: /Users, /home, /root, /var (beyond tmp), and the
51
+ * workspace root — those are exactly what the guard exists to protect.
52
+ */
53
+ const DEFAULT_SYSTEM_PREFIXES = [
54
+ "/bin",
55
+ "/sbin",
56
+ "/usr",
57
+ "/lib",
58
+ "/lib32",
59
+ "/lib64",
60
+ "/libx32",
61
+ "/opt",
62
+ "/etc",
63
+ "/private/etc",
64
+ "/dev",
65
+ "/proc",
66
+ "/sys",
67
+ "/run",
68
+ // Deliberately NOT the whole /System: /System/Volumes/Data firmlinks the
69
+ // entire macOS data volume (including /Users), which would be an escape.
70
+ "/System/Library",
71
+ "/System/Applications",
72
+ "/Applications",
73
+ "/Library/Developer",
74
+ "/tmp",
75
+ "/private/tmp",
76
+ "/var/tmp",
77
+ "/var/folders",
78
+ "/private/var/folders",
79
+ "/nix",
80
+ "/snap",
81
+ ];
82
+
83
+ /** Path-carrying input fields per guarded file tool (Claude Code tool schemas). */
84
+ const FILE_TOOL_PATH_FIELDS = {
85
+ Read: ["file_path"],
86
+ Write: ["file_path"],
87
+ Edit: ["file_path"],
88
+ MultiEdit: ["file_path"],
89
+ NotebookEdit: ["notebook_path"],
90
+ Glob: ["path"],
91
+ Grep: ["path"],
92
+ LS: ["path"],
93
+ };
94
+
95
+ /** File tools that only read — these may also touch the system prefixes. */
96
+ const READ_ONLY_FILE_TOOLS = new Set(["Read", "Glob", "Grep", "LS"]);
97
+
98
+ /**
99
+ * Loads the per-session guard config written by src/worktree-guard.cts:
100
+ * `{ allowedRoots: string[], deniedRoots?: string[], systemPrefixes?:
101
+ * string[] }`. Temp roots are appended here (not persisted) so the config
102
+ * stays portable across OSes.
103
+ *
104
+ * Precedence (checkPath below): allowedRoots (this session's own dirs) win,
105
+ * then deniedRoots (the whole multi-session workspace root — so sibling
106
+ * sessions and other projects' bare repos stay off-limits even when the
107
+ * workspace happens to live under a temp or system prefix), then the broad
108
+ * temp/system allowances.
109
+ */
110
+ function loadGuardConfig(configPath) {
111
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
112
+ if (!raw || !Array.isArray(raw.allowedRoots) || raw.allowedRoots.length === 0) {
113
+ throw new Error(`guard config ${configPath} does not declare allowedRoots`);
114
+ }
115
+ const canonicalize = (root) => resolveWithRealpath(path.resolve(root));
116
+ return {
117
+ allowedRoots: [...new Set(raw.allowedRoots.map(canonicalize))],
118
+ deniedRoots: [...new Set((raw.deniedRoots ?? []).map(canonicalize))],
119
+ tempRoots: [...new Set([os.tmpdir(), "/tmp", "/var/tmp"].map(canonicalize))],
120
+ systemPrefixes:
121
+ Array.isArray(raw.systemPrefixes) && raw.systemPrefixes.length > 0
122
+ ? raw.systemPrefixes
123
+ : DEFAULT_SYSTEM_PREFIXES,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Containment verdict for one canonicalized absolute path.
129
+ * `allowSystemPrefixes` is true for read-only file tools and for Bash (whose
130
+ * commands must keep reaching toolchains under /usr, /etc, ...).
131
+ */
132
+ function checkPath(resolved, config, allowSystemPrefixes) {
133
+ if (config.allowedRoots.some((root) => isWithin(root, resolved))) return true;
134
+ if (config.deniedRoots.some((root) => isWithin(root, resolved))) return false;
135
+ if (config.tempRoots.some((root) => isWithin(root, resolved))) return true;
136
+ if (allowSystemPrefixes && config.systemPrefixes.some((root) => isWithin(root, resolved))) {
137
+ return true;
138
+ }
139
+ return false;
140
+ }
141
+
142
+ /**
143
+ * Canonicalizes an absolute path: realpath of the longest existing ancestor
144
+ * with the non-existent tail re-appended. Resolving through the existing
145
+ * portion is what defeats symlink escapes; keeping the tail lets the guard
146
+ * judge not-yet-created files (Write) by where they would actually land.
147
+ */
148
+ function resolveWithRealpath(absolutePath) {
149
+ let current = path.normalize(absolutePath);
150
+ const tail = [];
151
+ for (;;) {
152
+ try {
153
+ const real = fs.realpathSync(current);
154
+ return tail.length > 0 ? path.join(real, ...tail.reverse()) : real;
155
+ } catch {
156
+ const parent = path.dirname(current);
157
+ if (parent === current) return path.normalize(absolutePath);
158
+ tail.push(path.basename(current));
159
+ current = parent;
160
+ }
161
+ }
162
+ }
163
+
164
+ function isWithin(root, candidate) {
165
+ return candidate === root || candidate.startsWith(root + path.sep);
166
+ }
167
+
168
+ /** Expands leading `~`, `~/`, `$HOME`, and `${HOME}` to the real home directory. */
169
+ function expandHome(value) {
170
+ if (value === "~") return os.homedir();
171
+ if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
172
+ if (/^\$\{?HOME\}?($|\/)/.test(value)) return value.replace(/^\$\{?HOME\}?/, os.homedir());
173
+ return value;
174
+ }
175
+
176
+ /**
177
+ * Pulls path-like references out of a shell command: absolute paths,
178
+ * home-anchored paths (`~...`, `$HOME/...`), and relative `..` traversals.
179
+ * Lexical by design — see the file header for why this is a guardrail, not
180
+ * a parser. URLs survive untouched (the char before `//` in `https://` is
181
+ * `:`, which is not a path delimiter here).
182
+ */
183
+ function extractBashPathCandidates(command) {
184
+ const candidates = [];
185
+ const seen = new Set();
186
+ const add = (raw) => {
187
+ const cleaned = raw.replace(/[)\]}"'`,]+$/, "");
188
+ if (!cleaned || seen.has(cleaned)) return;
189
+ seen.add(cleaned);
190
+ candidates.push(cleaned);
191
+ };
192
+
193
+ const absoluteRe = /(?:^|[\s'"`=(<>;|&])(\/[^\s'"`<>;|&)]*)/g;
194
+ const homeRe = /(?:^|[\s'"`=(<>;|&])((?:~|\$\{?HOME\}?)[^\s'"`<>;|&)]*)/g;
195
+ let match;
196
+ while ((match = absoluteRe.exec(command))) add(match[1]);
197
+ while ((match = homeRe.exec(command))) add(match[1]);
198
+
199
+ for (const token of command.split(/[\s'"`;|&<>()]+/)) {
200
+ if (!token || token.startsWith("-") || token.startsWith("/") || token.startsWith("~")) continue;
201
+ if (token === ".." || token.startsWith("../") || token.includes("/../") || token.endsWith("/..")) {
202
+ add(token);
203
+ }
204
+ }
205
+ return candidates;
206
+ }
207
+
208
+ function denyMessage(toolName, rawPath, resolvedPath, config) {
209
+ const resolvedNote =
210
+ resolvedPath && resolvedPath !== rawPath ? ` (resolves to "${resolvedPath}")` : "";
211
+ return (
212
+ `Navarch worktree guard blocked this ${toolName} call: "${rawPath}"${resolvedNote} is outside ` +
213
+ `this session's workspace. This machine runs multiple isolated agent sessions; work only under: ` +
214
+ `${config.allowedRoots.join(", ")}. Standard system paths (/usr, /etc, /tmp, ...) stay available ` +
215
+ `to shell commands. Do not attempt to bypass this boundary — if the task genuinely requires that ` +
216
+ `path, state the limitation in your report instead.`
217
+ );
218
+ }
219
+
220
+ /** Screens one Bash command. Returns {allowed} or {allowed:false, reason}. */
221
+ function evaluateBashCommand(command, cwd, config) {
222
+ // A bare `cd` (or `cd -`) jumps to $HOME / an unknowable previous
223
+ // directory — both outside the worktree by construction.
224
+ for (const segment of command.split(/[;&|]+/)) {
225
+ const trimmed = segment.trim();
226
+ if (trimmed === "cd" || trimmed === "cd -") {
227
+ return { allowed: false, reason: denyMessage("Bash", trimmed, os.homedir(), config) };
228
+ }
229
+ }
230
+
231
+ for (const raw of extractBashPathCandidates(command)) {
232
+ // `~otheruser/...` — another account's home; nothing there is in scope.
233
+ if (/^~[^/]/.test(raw)) {
234
+ return { allowed: false, reason: denyMessage("Bash", raw, "", config) };
235
+ }
236
+ const resolved = resolveWithRealpath(path.resolve(cwd, expandHome(raw)));
237
+ if (!checkPath(resolved, config, true)) {
238
+ return { allowed: false, reason: denyMessage("Bash", raw, resolved, config) };
239
+ }
240
+ }
241
+ return { allowed: true };
242
+ }
243
+
244
+ /**
245
+ * Evaluates one PreToolUse payload. Guarded file tools and Bash are checked;
246
+ * every other tool (MCP platform tools, WebFetch, Task, ...) is allowed —
247
+ * the settings matcher (src/worktree-guard.cts) shouldn't even route them
248
+ * here, so this is belt-and-braces.
249
+ */
250
+ function evaluateToolUse(input, config) {
251
+ const toolName = typeof input.tool_name === "string" ? input.tool_name : "";
252
+ const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
253
+ const toolInput = input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {};
254
+
255
+ const pathFields = FILE_TOOL_PATH_FIELDS[toolName];
256
+ if (pathFields) {
257
+ const candidates = [];
258
+ for (const field of pathFields) {
259
+ const value = toolInput[field];
260
+ if (typeof value === "string" && value) candidates.push(value);
261
+ }
262
+ // No explicit path (e.g. Glob/Grep default to cwd): judge cwd itself.
263
+ if (candidates.length === 0) candidates.push(cwd);
264
+
265
+ for (const candidate of candidates) {
266
+ const resolved = resolveWithRealpath(path.resolve(cwd, expandHome(candidate)));
267
+ if (!checkPath(resolved, config, READ_ONLY_FILE_TOOLS.has(toolName))) {
268
+ return { allowed: false, reason: denyMessage(toolName, candidate, resolved, config) };
269
+ }
270
+ }
271
+ return { allowed: true };
272
+ }
273
+
274
+ if (toolName === "Bash") {
275
+ const command = toolInput.command;
276
+ if (typeof command !== "string" || !command) return { allowed: true };
277
+ return evaluateBashCommand(command, cwd, config);
278
+ }
279
+
280
+ return { allowed: true };
281
+ }
282
+
283
+ function main() {
284
+ try {
285
+ const configPath = process.argv[2];
286
+ if (!configPath) throw new Error("usage: worktree-guard-hook.cjs <guard-config.json>");
287
+ const config = loadGuardConfig(configPath);
288
+ const input = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
289
+ const verdict = evaluateToolUse(input, config);
290
+ if (verdict.allowed) process.exit(0);
291
+ process.stderr.write(verdict.reason);
292
+ process.exit(2);
293
+ } catch (err) {
294
+ // Fail closed: a guard that cannot evaluate must not wave the call through.
295
+ process.stderr.write(`Navarch worktree guard failed closed: ${String(err)}`);
296
+ process.exit(2);
297
+ }
298
+ }
299
+
300
+ if (require.main === module) main();
301
+
302
+ module.exports = {
303
+ DEFAULT_SYSTEM_PREFIXES,
304
+ evaluateBashCommand,
305
+ evaluateToolUse,
306
+ expandHome,
307
+ extractBashPathCandidates,
308
+ isWithin,
309
+ loadGuardConfig,
310
+ resolveWithRealpath,
311
+ };
@@ -4,17 +4,6 @@ exports.claudeCodeAdapter = void 0;
4
4
  exports.runClaudeCodeAdapter = runClaudeCodeAdapter;
5
5
  const node_child_process_1 = require("node:child_process");
6
6
  const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
7
- const NAVARCH_MCP_ALLOWED_TOOLS = [
8
- "mcp__navarch__task_comment",
9
- "mcp__navarch__record_decision",
10
- "mcp__navarch__request_approval",
11
- "mcp__navarch__register_resource",
12
- "mcp__navarch__create_task",
13
- "mcp__navarch__list_triage_tasks",
14
- "mcp__navarch__groom_triage_task",
15
- "mcp__navarch__defer_triage_task",
16
- "mcp__navarch__file_pr_review",
17
- ];
18
7
  /**
19
8
  * Headless Claude Code adapter (project-plan.md §3.9 / implementation-plan.md
20
9
  * WP-07): `claude -p "<context bundle>" --mcp-config platform-mcp.json
@@ -37,16 +26,28 @@ const NAVARCH_MCP_ALLOWED_TOOLS = [
37
26
  */
38
27
  async function runClaudeCodeAdapter(options) {
39
28
  const args = ["-p", options.prompt];
29
+ const hasExplicitPermissionMode = options.extraArgs.some((arg) => ["--permission-mode", "--permission-prompt-tool", "--dangerously-skip-permissions"].some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
30
+ // Navarch sessions are unattended, so route permission decisions through
31
+ // Claude Code's native auto-mode classifier instead of prompting a human or
32
+ // bypassing checks. Operators can replace this with a different permission
33
+ // policy through NAVARCH_CLAUDE_EXTRA_ARGS.
34
+ if (!hasExplicitPermissionMode) {
35
+ args.push("--permission-mode", "auto");
36
+ }
37
+ // The worktree-guard settings file (worktree-guard.cts) installs the
38
+ // PreToolUse boundary hook. Hooks run in auto mode, so this remains a
39
+ // fail-closed boundary around each worktree on a machine running several
40
+ // agents. An operator-supplied --settings (via
41
+ // NAVARCH_CLAUDE_EXTRA_ARGS) wins — two --settings flags on one invocation
42
+ // would be ambiguous, and session.cts logs the guard as skipped.
43
+ if (options.settingsPath && !options.extraArgs.includes("--settings")) {
44
+ args.push("--settings", options.settingsPath);
45
+ }
40
46
  if (options.mcpConfigPath) {
41
47
  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.
46
- if (!options.extraArgs.includes("--allowedTools") &&
47
- !options.extraArgs.includes("--allowed-tools")) {
48
- args.push("--allowedTools", NAVARCH_MCP_ALLOWED_TOOLS.join(","));
49
- }
48
+ // Do not blanket-preapprove Navarch MCP tools: auto mode should review
49
+ // their side effects too. Operators can still add an explicit
50
+ // --allowedTools policy through NAVARCH_CLAUDE_EXTRA_ARGS.
50
51
  }
51
52
  // Only append the default when the caller hasn't already asked for a
52
53
  // specific --output-format (extraArgs wins so an operator can opt back
@@ -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,13 @@ 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. Interactive approval
36
+ * requests use `approval_policy="on-request"` with the native automatic
37
+ * reviewer, so unattended sessions retain safety review without waiting for
38
+ * human input. Explicit permission-policy arguments in
39
+ * NAVARCH_CODEX_EXTRA_ARGS replace that generated profile.
42
40
  * - `NAVARCH_CODEX_EXTRA_ARGS` (`extraArgs`) wins over the default `--json`
43
41
  * exactly like the Claude adapter's `--output-format` opt-out, so an
44
42
  * operator can fall back to plain-text output (or add the real
@@ -52,6 +50,9 @@ async function runCodexAdapter(options) {
52
50
  if (!options.extraArgs.includes("--json")) {
53
51
  args.push("--json");
54
52
  }
53
+ if (options.codexGuardArgs && !hasExplicitPermissionPolicy(options.extraArgs)) {
54
+ args.push(...options.codexGuardArgs);
55
+ }
55
56
  args.push(...options.extraArgs);
56
57
  if (options.model)
57
58
  args.push("--model", options.model);
@@ -64,6 +65,36 @@ async function runCodexAdapter(options) {
64
65
  : await runOnHost(runOptions, args);
65
66
  return attachUsage(raw);
66
67
  }
68
+ /** Operator policy wins over Navarch's generated host-mode profile. */
69
+ function hasExplicitPermissionPolicy(args) {
70
+ for (let index = 0; index < args.length; index += 1) {
71
+ const arg = args[index];
72
+ if ([
73
+ "--sandbox",
74
+ "-s",
75
+ "--dangerously-bypass-approvals-and-sandbox",
76
+ "--profile",
77
+ "-p",
78
+ "--ignore-user-config",
79
+ ].includes(arg) ||
80
+ arg.startsWith("--sandbox=") ||
81
+ arg.startsWith("-s=")) {
82
+ return true;
83
+ }
84
+ if (arg === "-c" || arg === "--config") {
85
+ const override = args[index + 1] ?? "";
86
+ if (/^(sandbox_mode|default_permissions|permissions)(\.|=)/.test(override))
87
+ return true;
88
+ index += 1;
89
+ }
90
+ else if (arg.startsWith("--config=")) {
91
+ const override = arg.slice("--config=".length);
92
+ if (/^(sandbox_mode|default_permissions|permissions)(\.|=)/.test(override))
93
+ return true;
94
+ }
95
+ }
96
+ return false;
97
+ }
67
98
  /** Convert Claude's per-session MCP JSON into Codex one-off TOML overrides. */
68
99
  async function codexMcpArgs(path, env) {
69
100
  const parsed = JSON.parse(await node_fs_1.promises.readFile(path, "utf8"));
package/dist/capacity.cjs CHANGED
@@ -14,6 +14,7 @@ function computeAvailableCapacity(maxSessions, activeSessions) {
14
14
  class CapacityTracker {
15
15
  maxSessions;
16
16
  active = new Set();
17
+ idleWaiters = new Set();
17
18
  constructor(maxSessions) {
18
19
  this.maxSessions = maxSessions;
19
20
  }
@@ -36,6 +37,17 @@ class CapacityTracker {
36
37
  }
37
38
  release(sessionId) {
38
39
  this.active.delete(sessionId);
40
+ if (this.active.size === 0) {
41
+ for (const resolve of this.idleWaiters)
42
+ resolve();
43
+ this.idleWaiters.clear();
44
+ }
45
+ }
46
+ /** Resolves once every acquired lease has finished and released its slot. */
47
+ waitForIdle() {
48
+ if (this.active.size === 0)
49
+ return Promise.resolve();
50
+ return new Promise((resolve) => this.idleWaiters.add(resolve));
39
51
  }
40
52
  }
41
53
  exports.CapacityTracker = CapacityTracker;