@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 +116 -12
- package/bin/worktree-guard-hook.cjs +311 -0
- package/dist/adapters/claude.cjs +20 -19
- package/dist/adapters/codex.cjs +41 -10
- package/dist/capacity.cjs +12 -0
- package/dist/claim-loop.cjs +22 -0
- package/dist/cli.cjs +46 -7
- package/dist/config.cjs +10 -0
- package/dist/git-worktree.cjs +28 -1
- package/dist/heartbeat-loop.cjs +29 -3
- package/dist/session.cjs +39 -1
- package/dist/supervisor.cjs +149 -0
- package/dist/update-coordinator.cjs +53 -0
- package/dist/update-installer.cjs +164 -0
- package/dist/version.cjs +9 -0
- package/dist/worktree-guard.cjs +125 -0
- package/package.json +2 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/dist/adapters/claude.cjs
CHANGED
|
@@ -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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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
|
package/dist/adapters/codex.cjs
CHANGED
|
@@ -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
|
-
* -
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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;
|