@nathapp/nax-agent 0.87.1 → 0.88.0-canary.2

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/coding-tools/coding-tool-support.d.ts +16 -0
  3. package/dist/coding-tools/coding-tool-support.js +12 -0
  4. package/dist/command-safety/rule-scorer.d.ts +1 -1
  5. package/dist/command-safety/rule-scorer.js +11 -113
  6. package/dist/config/schemas-sandbox.d.ts +1 -0
  7. package/dist/config/schemas-sandbox.js +9 -0
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +2 -2
  10. package/dist/internal/process-kill.d.ts +6 -2
  11. package/dist/internal/process-kill.js +6 -2
  12. package/dist/internal.d.ts +1 -0
  13. package/dist/internal.js +1 -0
  14. package/dist/mcp/connect.js +15 -1
  15. package/dist/mcp/types.d.ts +6 -0
  16. package/dist/native/auth.d.ts +2 -2
  17. package/dist/native/auth.js +4 -4
  18. package/dist/native/errors.js +7 -0
  19. package/dist/native/failed-round-trip-spend.d.ts +16 -0
  20. package/dist/native/failed-round-trip-spend.js +33 -0
  21. package/dist/native/session/partial-spend.d.ts +20 -0
  22. package/dist/native/session/partial-spend.js +14 -0
  23. package/dist/native/session/session.js +11 -4
  24. package/dist/native/session/turn-complete-step.js +23 -3
  25. package/dist/native/session-adapter.js +3 -2
  26. package/dist/permissions/bash-lex.js +36 -4
  27. package/dist/sandbox/claude-settings.d.ts +46 -0
  28. package/dist/sandbox/claude-settings.js +76 -0
  29. package/dist/sandbox/index.d.ts +2 -1
  30. package/dist/sandbox/index.js +1 -0
  31. package/dist/sandbox/launcher.js +4 -0
  32. package/dist/sandbox/messages.d.ts +1 -1
  33. package/dist/sandbox/messages.js +15 -3
  34. package/dist/sandbox/policy-builder.js +27 -2
  35. package/dist/sandbox/session-sandbox.d.ts +3 -0
  36. package/dist/sandbox/session-sandbox.js +9 -0
  37. package/dist/sandbox/srt-backend.d.ts +10 -1
  38. package/dist/sandbox/srt-backend.js +14 -4
  39. package/dist/sandbox/types.d.ts +23 -0
  40. package/dist/session/agent-session-options.js +1 -1
  41. package/dist/session/agent-session-types.d.ts +1 -1
  42. package/dist/session/native-backend-options.js +5 -3
  43. package/dist/session/session-backend.d.ts +9 -0
  44. package/dist/session/session-tool-support.d.ts +1 -1
  45. package/dist/session/session-tool-support.js +4 -2
  46. package/dist/session/session-types.d.ts +10 -0
  47. package/dist/tools/bash.d.ts +7 -1
  48. package/dist/tools/bash.js +18 -3
  49. package/dist/tools/denial-redirect.d.ts +22 -7
  50. package/dist/tools/denial-redirect.js +29 -13
  51. package/dist/tools/git-flags/locate.d.ts +47 -0
  52. package/dist/tools/git-flags/locate.js +361 -0
  53. package/dist/tools/index.d.ts +1 -0
  54. package/dist/tools/index.js +1 -0
  55. package/dist/tools/nax-dirs.d.ts +22 -0
  56. package/dist/tools/nax-dirs.js +89 -0
  57. package/dist/tools/nax-evasion.d.ts +19 -0
  58. package/dist/tools/nax-evasion.js +88 -0
  59. package/dist/tools/nax-revert.d.ts +34 -0
  60. package/dist/tools/nax-revert.js +233 -0
  61. package/dist/tools/nax-stash-restore.d.ts +7 -0
  62. package/dist/tools/nax-stash-restore.js +92 -0
  63. package/dist/tools/nax-state-guard.d.ts +18 -0
  64. package/dist/tools/nax-state-guard.js +478 -0
  65. package/dist/tools/policy-bash-raw.d.ts +3 -0
  66. package/dist/tools/policy-bash-raw.js +40 -12
  67. package/dist/tools/policy-bash.d.ts +3 -0
  68. package/dist/tools/policy-bash.js +38 -9
  69. package/dist/tools/policy-command-branch.d.ts +3 -0
  70. package/dist/tools/policy-command-branch.js +3 -1
  71. package/dist/tools/policy.js +13 -0
  72. package/dist/tools/provider-advertise.d.ts +13 -0
  73. package/dist/tools/provider-advertise.js +41 -1
  74. package/dist/tools/provider-instructions.d.ts +33 -0
  75. package/dist/tools/provider-instructions.js +63 -0
  76. package/dist/tools/provider-types.d.ts +12 -0
  77. package/dist/tools/run-command.d.ts +15 -0
  78. package/dist/tools/run-command.js +16 -1
  79. package/dist/tools/tool-audit.d.ts +1 -1
  80. package/package.json +3 -3
@@ -20,6 +20,8 @@
20
20
  */
21
21
  import { getSafeLogger } from "#src/infra/index";
22
22
  import { applyHistoryPatch } from "./loop-events/cache-boundary.js";
23
+ import { takePartialSpend } from "./partial-spend.js";
24
+ import { usageBeat } from "./turn-accumulator.js";
23
25
  import { runOverflowCompaction } from "./turn-compaction-step.js";
24
26
  import { realSleep, retryTransportFault } from "./turn-retry.js";
25
27
  /**
@@ -89,8 +91,26 @@ export async function completeWithRecovery(args) {
89
91
  honoured = wire.honoured;
90
92
  return deps.complete(wire.messages, tools, options, turnEvents.deltaSink(roundTrip));
91
93
  };
94
+ // A failed round trip was still billed for what it streamed. Folding it in
95
+ // here, per attempt, is what makes a retry count both attempts and an
96
+ // aborted or timed-out one count what it cost; `takePartialSpend` consumes
97
+ // the entry, and the retry loop rethrows the error it was given without
98
+ // re-entering this wrapper, so nothing counts twice.
99
+ const countedRequest = async (msgs) => {
100
+ try {
101
+ return await request(msgs);
102
+ }
103
+ catch (err) {
104
+ const spend = takePartialSpend(err);
105
+ if (spend !== undefined) {
106
+ usage.add(spend.usage, spend.costUsd, spend.rates);
107
+ deps.onActivity?.(usageBeat(spend.usage, spend.costUsd));
108
+ }
109
+ throw err;
110
+ }
111
+ };
92
112
  try {
93
- res = await request(messages);
113
+ res = await countedRequest(messages);
94
114
  }
95
115
  catch (err) {
96
116
  // Written as one guarded `if` (not a separate `canRetry` boolean) so
@@ -109,7 +129,7 @@ export async function completeWithRecovery(args) {
109
129
  if (deps.transportRetry === undefined)
110
130
  throw err;
111
131
  res = await retryTransportFault(err, {
112
- attempt: () => request(messages),
132
+ attempt: () => countedRequest(messages),
113
133
  config: deps.transportRetry,
114
134
  deadline: deps.deadline,
115
135
  signal,
@@ -161,7 +181,7 @@ export async function completeWithRecovery(args) {
161
181
  turnEvents.emit({ type: "compaction", reason: "overflow" });
162
182
  // Retried once. A second overflow propagates: compacting further would be
163
183
  // guessing, and the failure now carries a correct diagnosis.
164
- res = await request(messages);
184
+ res = await countedRequest(messages);
165
185
  }
166
186
  }
167
187
  return { res, messages, compacted, honoured, boundary: compacted };
@@ -15,6 +15,7 @@ import { _adapterDeps, authFields, isProtocolStreamError } from "./adapter-deps.
15
15
  import { _clientDeps, getNativeClient } from "./client.js";
16
16
  import { createSessionCredentialStore } from "./credentials/session-source.js";
17
17
  import { toAdapterFailure } from "./errors.js";
18
+ import { billedOnFailure } from "./failed-round-trip-spend.js";
18
19
  import { buildRateCard, NATIVE_AGENT, parseNativeModel, resolveContextWindow, toThinkingLevel } from "./models.js";
19
20
  import { createInstructionInteraction } from "./session/instruction-interaction.js";
20
21
  import { closeNativeSession, createNativeSessionState, markNativeTurnOutcome, openNativeSession, systemFieldFor, } from "./session/session.js";
@@ -305,7 +306,7 @@ export class NativeSessionAdapter {
305
306
  const requestThinking = requestOptions?.thinking === false ? undefined : thinking;
306
307
  try {
307
308
  instructionInteraction.acknowledge();
308
- const res = await streamComplete(client, resolved, {
309
+ const res = await billedOnFailure(rates, () => streamComplete(client, resolved, {
309
310
  messages,
310
311
  ...(tools.length > 0 ? { tools } : {}),
311
312
  sessionId,
@@ -324,7 +325,7 @@ export class NativeSessionAdapter {
324
325
  // to reuse the entry, so a cache write there costs more than it
325
326
  // saves.
326
327
  cacheRetention: "short",
327
- }, onDelta);
328
+ }, onDelta));
328
329
  const usage = res.usage;
329
330
  // Single `priceCall` invocation: `costUsd` and `resolvedRates`
330
331
  // come from the same call so they cannot diverge — the
@@ -128,23 +128,55 @@ function lexDoubleQuote(state, i) {
128
128
  const end = doubleQuoteEnd(state.command, i + 1);
129
129
  if (end === -1)
130
130
  return { kind: "refuse", construct: "an unbalanced double quote" };
131
+ // The checks read the text with line continuations joined, as sh does: `"$\<NL>(id)"` runs `id`.
132
+ // Joining over-matches only after an escaped backslash, which errs toward refusing.
131
133
  const inner = state.command.slice(i + 1, end);
132
- if (inner.includes("$("))
134
+ const joined = inner.replaceAll("\\\n", "");
135
+ if (joined.includes("$("))
133
136
  return { kind: "refuse", construct: "a command substitution `$(...)`" };
134
- if (inner.includes("`"))
137
+ if (joined.includes("`"))
135
138
  return { kind: "refuse", construct: "a backtick command substitution" };
136
- if (inner.includes("$"))
139
+ if (joined.includes("$"))
137
140
  state.opaque = true;
138
- state.word += inner;
141
+ state.word += doubleQuotedWord(inner);
139
142
  state.started = true;
140
143
  return { kind: "advance", by: end + 1 - i };
141
144
  }
145
+ /** The characters a backslash escapes inside double quotes; before any other, it is literal. */
146
+ const DQ_ESCAPABLE = new Set(["$", "`", '"', "\\"]);
147
+ /**
148
+ * The word sh reads from double-quoted text (nax#2551): `\<newline>` is removed, `\$ \` \" \\`
149
+ * keep only the escaped character, and any other backslash stays. A scan, not a replace: in
150
+ * `\\<newline>` the first backslash escapes the second, so the newline stays.
151
+ */
152
+ function doubleQuotedWord(inner) {
153
+ let out = "";
154
+ for (let i = 0; i < inner.length; i++) {
155
+ const c = inner[i];
156
+ const next = inner[i + 1] ?? "";
157
+ if (c !== "\\" || next === "") {
158
+ out += c;
159
+ }
160
+ else if (next === "\n") {
161
+ i++;
162
+ }
163
+ else {
164
+ out += DQ_ESCAPABLE.has(next) ? next : c + next;
165
+ i++;
166
+ }
167
+ }
168
+ return out;
169
+ }
142
170
  function lexEscape(state, i) {
143
171
  if (state.command[i] !== "\\")
144
172
  return undefined;
145
173
  const next = state.command[i + 1];
146
174
  if (next === undefined)
147
175
  return { kind: "refuse", construct: "a trailing backslash" };
176
+ // nax#2551: a line continuation contributes nothing — `r\<NL>m` is the word `rm` to sh, so it
177
+ // must be to the policy too. It starts no word, so between words it adds no empty token.
178
+ if (next === "\n")
179
+ return { kind: "advance", by: 2 };
148
180
  state.word += next;
149
181
  state.started = true;
150
182
  return { kind: "advance", by: 2 };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * US-006: the two session-root Claude Code settings files.
3
+ *
4
+ * Claude Code loads `allow` rules, hooks and MCP servers from these files at
5
+ * the session ROOT, so a write into one weakens every later session started in
6
+ * that workdir — a privilege escalation that survives the run that made it.
7
+ *
8
+ * One canonical list, because two independent guards consume it and they must
9
+ * never diverge:
10
+ *
11
+ * - the OS sandbox denies writes to these paths (`buildSandboxPolicy`), which
12
+ * covers a sandboxed command (Bash, RunCommand) and the ACP edit path;
13
+ * - the native tool policy refuses the same writes (`claudeSettingsWriteRefusal`
14
+ * through `compileToolPolicy`), which is the ONLY guard on the native
15
+ * `Write` / `Edit` / `Delete` tools — they run through Node's fs APIs with no
16
+ * sandbox around them (branch review B1).
17
+ *
18
+ * `.claude` itself is deliberately NOT listed: a project keeps skills, commands
19
+ * and agents under it, so this is a file deny, never a directory one (on Linux
20
+ * srt turns a directory deny into a bind mount over all of them). The sandbox
21
+ * resolves each entry through the same `literal()`/`realOrRaw` path as every
22
+ * other entry, so the deny holds whether or not the file — or `.claude/` itself
23
+ * — exists yet.
24
+ *
25
+ * Only the session root's own files are listed. A monorepo package's
26
+ * `packages/<pkg>/.claude/settings.json` is out of scope (spec, "Out of Scope").
27
+ */
28
+ export declare const CLAUDE_SETTINGS_FILES: readonly string[];
29
+ /**
30
+ * Why `tool` may not modify the root-relative path `rel`, or undefined.
31
+ *
32
+ * `rel` must be the policy's OWN canonical spelling (root-relative, `/`-joined,
33
+ * symlink-resolved), never the caller's arguments: matching the caller's
34
+ * spelling would let `./.claude/settings.json` or `.claude/../.claude/x` walk
35
+ * past this guard while reaching the same file.
36
+ *
37
+ * Because `rel` is symlink-resolved, each settings entry is resolved the same
38
+ * way before it is compared (branch review B2): a `.claude/settings.json` that
39
+ * is a symlink, or a `.claude/` that is one, arrives here as its target, and a
40
+ * literal-only match would let the write through. Resolved per call, not at
41
+ * policy compile time, so a symlink created mid-session is still seen.
42
+ *
43
+ * Segment-exact on the full path — a sibling such as `settings.json.bak`, or any
44
+ * other file under `.claude/`, is an ordinary path a tool must still reach.
45
+ */
46
+ export declare function claudeSettingsWriteRefusal(tool: string, rel: string, resolvedRoot: string): string | undefined;
@@ -0,0 +1,76 @@
1
+ import { join, relative, sep } from "node:path";
2
+ import { realOrRaw } from "#src/internal/realpath";
3
+ /**
4
+ * US-006: the two session-root Claude Code settings files.
5
+ *
6
+ * Claude Code loads `allow` rules, hooks and MCP servers from these files at
7
+ * the session ROOT, so a write into one weakens every later session started in
8
+ * that workdir — a privilege escalation that survives the run that made it.
9
+ *
10
+ * One canonical list, because two independent guards consume it and they must
11
+ * never diverge:
12
+ *
13
+ * - the OS sandbox denies writes to these paths (`buildSandboxPolicy`), which
14
+ * covers a sandboxed command (Bash, RunCommand) and the ACP edit path;
15
+ * - the native tool policy refuses the same writes (`claudeSettingsWriteRefusal`
16
+ * through `compileToolPolicy`), which is the ONLY guard on the native
17
+ * `Write` / `Edit` / `Delete` tools — they run through Node's fs APIs with no
18
+ * sandbox around them (branch review B1).
19
+ *
20
+ * `.claude` itself is deliberately NOT listed: a project keeps skills, commands
21
+ * and agents under it, so this is a file deny, never a directory one (on Linux
22
+ * srt turns a directory deny into a bind mount over all of them). The sandbox
23
+ * resolves each entry through the same `literal()`/`realOrRaw` path as every
24
+ * other entry, so the deny holds whether or not the file — or `.claude/` itself
25
+ * — exists yet.
26
+ *
27
+ * Only the session root's own files are listed. A monorepo package's
28
+ * `packages/<pkg>/.claude/settings.json` is out of scope (spec, "Out of Scope").
29
+ */
30
+ export const CLAUDE_SETTINGS_FILES = [".claude/settings.json", ".claude/settings.local.json"];
31
+ /**
32
+ * The path-mutating tools this deny covers: the three that resolve a path
33
+ * through `compileToolPolicy` and then write it with Node's fs APIs.
34
+ *
35
+ * `GitCommit` is deliberately absent — committing a settings file does not
36
+ * change it, and the deny is about the file's CONTENT. Read-only tools are
37
+ * absent for the other reason: a session must be able to read the settings it
38
+ * is bound by, or a refusal becomes indistinguishable from a broken environment.
39
+ */
40
+ const CLAUDE_SETTINGS_WRITE_TOOLS = new Set(["Write", "Edit", "Delete"]);
41
+ /**
42
+ * Why `tool` may not modify the root-relative path `rel`, or undefined.
43
+ *
44
+ * `rel` must be the policy's OWN canonical spelling (root-relative, `/`-joined,
45
+ * symlink-resolved), never the caller's arguments: matching the caller's
46
+ * spelling would let `./.claude/settings.json` or `.claude/../.claude/x` walk
47
+ * past this guard while reaching the same file.
48
+ *
49
+ * Because `rel` is symlink-resolved, each settings entry is resolved the same
50
+ * way before it is compared (branch review B2): a `.claude/settings.json` that
51
+ * is a symlink, or a `.claude/` that is one, arrives here as its target, and a
52
+ * literal-only match would let the write through. Resolved per call, not at
53
+ * policy compile time, so a symlink created mid-session is still seen.
54
+ *
55
+ * Segment-exact on the full path — a sibling such as `settings.json.bak`, or any
56
+ * other file under `.claude/`, is an ordinary path a tool must still reach.
57
+ */
58
+ export function claudeSettingsWriteRefusal(tool, rel, resolvedRoot) {
59
+ if (!CLAUDE_SETTINGS_WRITE_TOOLS.has(tool))
60
+ return undefined;
61
+ const entry = CLAUDE_SETTINGS_FILES.find((e) => e === rel || resolvedSettingsRel(resolvedRoot, e) === rel);
62
+ if (entry === undefined)
63
+ return undefined;
64
+ const subject = entry === rel
65
+ ? `"${rel}" is a Claude Code settings file`
66
+ : `"${rel}" is the target of the Claude Code settings file "${entry}"`;
67
+ return (`${subject}: Claude loads allow rules, hooks and MCP servers from it for every ` +
68
+ "session started in this workdir, so a change here outlives this run and is not made from inside it. " +
69
+ "The sandbox refuses writes to it as well; a human changes it by hand, outside the run.");
70
+ }
71
+ /** `entry`'s symlink-resolved spelling, relative to the already-resolved root, `/`-joined. */
72
+ function resolvedSettingsRel(resolvedRoot, entry) {
73
+ return relative(resolvedRoot, realOrRaw(join(resolvedRoot, entry)))
74
+ .split(sep)
75
+ .join("/");
76
+ }
@@ -6,6 +6,7 @@ export { buildSandboxPolicy, type SandboxPolicyInput } from "./policy-builder.ts
6
6
  export { _policyInputDeps, defaultTempRoots, type GitLayout, listCredentialFiles, listNaxEntries, resolveGitLayout, runTempRoots, } from "./policy-inputs.ts";
7
7
  export { _probeDeps, probeSandbox } from "./probe.ts";
8
8
  export { _resetSandboxRegistryForTests, _sandboxRegistryDeps, probeSandboxOnce, resetSandboxBackend, sandboxBackendFor, warnSandboxUnavailableOnce, } from "./registry.ts";
9
+ export { sessionSandboxFrom } from "./session-sandbox.ts";
9
10
  export { _sessionTmpDeps, runTmpRoot, sessionTmpDir, sessionTmpDirUnder } from "./session-tmp.ts";
10
11
  export { _srtBackendDeps, createSrtBackend } from "./srt-backend.ts";
11
- export type { CommandLauncher, LaunchRequest, LaunchResult, LaunchSpec, ProbeResult, SandboxBackend, SandboxBackendName, SandboxNetworkPolicy, SandboxPolicy, SandboxRecord, SandboxState, SandboxWrapRequest, } from "./types.ts";
12
+ export type { CommandLauncher, LaunchRequest, LaunchResult, LaunchSpec, ProbeResult, SandboxBackend, SandboxBackendName, SandboxNetworkPolicy, SandboxPolicy, SandboxRecord, SandboxState, SandboxWrapRequest, SessionSandbox, } from "./types.ts";
@@ -6,5 +6,6 @@ export { buildSandboxPolicy } from "./policy-builder.js";
6
6
  export { _policyInputDeps, defaultTempRoots, listCredentialFiles, listNaxEntries, resolveGitLayout, runTempRoots, } from "./policy-inputs.js";
7
7
  export { _probeDeps, probeSandbox } from "./probe.js";
8
8
  export { _resetSandboxRegistryForTests, _sandboxRegistryDeps, probeSandboxOnce, resetSandboxBackend, sandboxBackendFor, warnSandboxUnavailableOnce, } from "./registry.js";
9
+ export { sessionSandboxFrom } from "./session-sandbox.js";
9
10
  export { _sessionTmpDeps, runTmpRoot, sessionTmpDir, sessionTmpDirUnder } from "./session-tmp.js";
10
11
  export { _srtBackendDeps, createSrtBackend } from "./srt-backend.js";
@@ -156,5 +156,9 @@ export function createCommandLauncher(opts) {
156
156
  await opts.afterWrapped?.();
157
157
  }
158
158
  },
159
+ // nax#2500: `false` keeps /tmp/claude writable; Claude's own srt forces that TMPDIR.
160
+ ...(opts.policyFor !== undefined
161
+ ? { policyAt: (root) => opts.policyFor(root, false) }
162
+ : {}),
159
163
  };
160
164
  }
@@ -4,7 +4,7 @@
4
4
  * always know whether it is sandboxed and what it may write (spec S5).
5
5
  */
6
6
  export declare const LIKELY_SANDBOX_DENIAL: RegExp;
7
- export declare function sandboxSentence(network: "open" | readonly string[], sharedTmp?: boolean): string;
7
+ export declare function sandboxSentence(network: "open" | readonly string[], sharedTmp?: boolean, platform?: NodeJS.Platform): string;
8
8
  export declare function unsandboxedSentence(reason: string): string;
9
9
  export declare function rawBashRefusalReason(reason: string): string;
10
10
  /**
@@ -18,12 +18,24 @@ function describeNetwork(network) {
18
18
  * write; a shared-temp session keeps the wording it had before.
19
19
  */
20
20
  const tempRootsPhrase = (sharedTmp) => sharedTmp ? "the system temp directories" : "this run's temp directory ($TMPDIR)";
21
- export function sandboxSentence(network, sharedTmp = true) {
22
- return (`inside an OS sandbox: writes are allowed only under the repository root, ${tempRootsPhrase(sharedTmp)} and ` +
21
+ /**
22
+ * US-006: on macOS srt refuses every setuid program, `/bin/ps` among them, and
23
+ * kills a non-setuid copy with SIGKILL. A test that shells out to ps therefore
24
+ * fails for a reason that has nothing to do with the test, and an agent reading
25
+ * only "Operation not permitted" has no way to tell the two apart. Appended on
26
+ * darwin alone: on Linux ps runs, where the sentence would excuse a real
27
+ * failure. It is deliberately in `sandboxSentence` rather than at each call
28
+ * site so every description that advertises a WORKING sandbox carries it.
29
+ */
30
+ const DARWIN_SETUID_REFUSAL_SENTENCE = 'On macOS the sandbox also refuses ps and other setuid programs ("Operation not permitted"), so a test that ' +
31
+ "shells out to ps fails here for that reason alone.";
32
+ export function sandboxSentence(network, sharedTmp = true, platform) {
33
+ const sentence = `inside an OS sandbox: writes are allowed only under the repository root, ${tempRootsPhrase(sharedTmp)} and ` +
23
34
  "package-manager caches -- and nothing under .nax/ except .nax/scratchpad/, since .nax/ is nax's own state " +
24
35
  "(change a feature's acceptance test with the Edit tool); credential files (~/.ssh, ~/.aws, ~/.npmrc, nax credentials and similar) are unreadable; " +
25
36
  `${describeNetwork(network)}. A write anywhere else fails with "Operation not permitted" or "Read-only file system" ` +
26
- "-- that is the sandbox, not a bug in your command.");
37
+ "-- that is the sandbox, not a bug in your command.";
38
+ return platform === "darwin" ? `${sentence} ${DARWIN_SETUID_REFUSAL_SENTENCE}` : sentence;
27
39
  }
28
40
  export function unsandboxedSentence(reason) {
29
41
  return `Commands are NOT sandboxed on this machine (${reason}).`;
@@ -10,6 +10,7 @@ import { isAbsolute, join, resolve } from "node:path";
10
10
  import { NaxError } from "#src/infra/index";
11
11
  import { realOrRaw } from "#src/internal/realpath";
12
12
  import { SANDBOX_GLOB_CHARS } from "../config/schemas-sandbox.js";
13
+ import { CLAUDE_SETTINGS_FILES } from "./claude-settings.js";
13
14
  import { BUILTIN_CACHE_WRITE_ROOTS, BUILTIN_CREDENTIAL_READ_DENIES, MACOS_CACHE_WRITE_ROOT, SRT_MACOS_TMPDIR, SRT_MACOS_TMPDIR_DENIES, } from "./defaults.js";
14
15
  import { WORKTREE_COMMON_WRITE_DIRS, WORKTREE_CONFIG_FILE } from "./git-guards.js";
15
16
  function expandHome(p, home) {
@@ -35,7 +36,13 @@ function gitDenies(root, git) {
35
36
  const common = git.kind === "worktree" ? git.commonDir : git.gitDir;
36
37
  // config.worktree is read when extensions.worktreeConfig is on; an absent one
37
38
  // is safe to deny (srt stubs it empty, and empty is valid config) (#2198).
38
- const shared = [join(common, "hooks"), join(common, "config"), join(common, WORKTREE_CONFIG_FILE)];
39
+ // info/ holds exclude and sparse-checkout: an agent-written ignore rule hides files from review guards (#2552).
40
+ const shared = [
41
+ join(common, "hooks"),
42
+ join(common, "config"),
43
+ join(common, "info"),
44
+ join(common, WORKTREE_CONFIG_FILE),
45
+ ];
39
46
  if (git.kind === "main")
40
47
  return shared;
41
48
  // A worktree's `.git` is a pointer FILE, and gitdir/commondir point back;
@@ -47,6 +54,8 @@ function gitDenies(root, git) {
47
54
  join(git.gitDir, "gitdir"),
48
55
  join(git.gitDir, "commondir"),
49
56
  join(git.gitDir, WORKTREE_CONFIG_FILE),
57
+ // A linked worktree keeps its own info/ (sparse-checkout) beside the shared one (#2552).
58
+ join(git.gitDir, "info"),
50
59
  ];
51
60
  }
52
61
  /**
@@ -141,6 +150,10 @@ export function buildSandboxPolicy(input) {
141
150
  ...input.gitGuardFiles,
142
151
  ...(input.approvalsFile !== undefined ? [input.approvalsFile] : []),
143
152
  ...(input.trustStoreFile !== undefined ? [input.trustStoreFile] : []),
153
+ // US-006: the two session-root Claude settings files. One canonical list
154
+ // with the native tool policy that refuses the same paths (B1) -- see
155
+ // ./claude-settings.ts for why each is denied and why `.claude` is not.
156
+ ...CLAUDE_SETTINGS_FILES.map((rel) => join(root, rel)),
144
157
  ...(darwin && confined ? SRT_MACOS_TMPDIR_DENIES : []),
145
158
  ]);
146
159
  const denyRead = literal([
@@ -149,5 +162,17 @@ export function buildSandboxPolicy(input) {
149
162
  ...config.filesystem.denyRead.map((p) => resolve(root, expandHome(p, home))),
150
163
  ]);
151
164
  const allowed = config.network.allowedDomains;
152
- return { writeRoots, denyWrite, denyRead, network: allowed === undefined ? {} : { allowedDomains: [...allowed] } };
165
+ // US-002: the flag is carried only when the config asks for it, so an absent
166
+ // -- or explicitly false -- config leaves `network` without the key. The type
167
+ // only admits `true` (see SandboxNetworkPolicy), and srt treats absent as off.
168
+ const loopback = config.network.allowLocalBinding === true;
169
+ return {
170
+ writeRoots,
171
+ denyWrite,
172
+ denyRead,
173
+ network: {
174
+ ...(allowed === undefined ? {} : { allowedDomains: [...allowed] }),
175
+ ...(loopback ? { allowLocalBinding: true } : {}),
176
+ },
177
+ };
153
178
  }
@@ -0,0 +1,3 @@
1
+ /** nax#2500: a launcher's sandbox as plain data for a backend that runs its own shell. */
2
+ import type { CommandLauncher, SessionSandbox } from "./types.ts";
3
+ export declare function sessionSandboxFrom(launcher: CommandLauncher | undefined, root: string): Promise<SessionSandbox | undefined>;
@@ -0,0 +1,9 @@
1
+ export async function sessionSandboxFrom(launcher, root) {
2
+ if (launcher === undefined || launcher.state.kind === "disabled")
3
+ return undefined;
4
+ if (launcher.state.kind === "unavailable")
5
+ return { kind: "unavailable", reason: launcher.state.reason };
6
+ if (launcher.policyAt === undefined)
7
+ return { kind: "unavailable", reason: "the launcher exposes no policy" };
8
+ return { kind: "policy", policy: await launcher.policyAt(root) };
9
+ }
@@ -12,8 +12,17 @@ import { mkdir } from "node:fs/promises";
12
12
  import type { SandboxConfig } from "../config/schemas-sandbox.ts";
13
13
  import type { SandboxBackend } from "./types.ts";
14
14
  type SrtModule = typeof import("@anthropic-ai/sandbox-runtime");
15
+ /**
16
+ * The slice of the srt module this backend calls. Narrowed on purpose: this seam
17
+ * is what a test replaces, and a double for the whole module needs an
18
+ * `as unknown as` cast, which test/ forbids (check-test-as-unknown-as). The real
19
+ * `typeof import(...)` satisfies this structurally, so nothing is lost.
20
+ */
21
+ type SrtModuleSlice = {
22
+ SandboxManager: Pick<SrtModule["SandboxManager"], "initialize" | "isSupportedPlatform" | "wrapWithSandboxArgv" | "annotateStderrWithSandboxFailures" | "cleanupAfterCommand" | "reset">;
23
+ };
15
24
  export declare const _srtBackendDeps: {
16
- load: () => Promise<SrtModule>;
25
+ load: () => Promise<SrtModuleSlice>;
17
26
  platform: () => NodeJS.Platform;
18
27
  mkdir: typeof mkdir;
19
28
  };
@@ -22,11 +22,18 @@ export const _srtBackendDeps = {
22
22
  * `network`, so the object must exist. This is the one cast in the module; the
23
23
  * live suite pins the behaviour (open => no proxy variables in the argv), so
24
24
  * an srt bump that changes it fails a test rather than silently restricting.
25
+ *
26
+ * US-002 (#2538): `allowLocalBinding` rides along. srt reads it once per process
27
+ * from THIS object (`getAllowLocalBinding`, sandbox-manager.js:1171), never from
28
+ * a per-command `customConfig`, so it is emitted only here. Only `true` is
29
+ * emitted: srt's default is off, so absent and `false` are equivalent, and the
30
+ * policy type (`SandboxNetworkPolicy`) cannot express an explicit `false`.
25
31
  */
26
- function srtNetwork(allowedDomains) {
32
+ function srtNetwork(allowedDomains, allowLocalBinding) {
33
+ const localBinding = allowLocalBinding === true ? { allowLocalBinding: true } : {};
27
34
  if (allowedDomains !== undefined)
28
- return { allowedDomains: [...allowedDomains], deniedDomains: [] };
29
- const open = { deniedDomains: [] };
35
+ return { allowedDomains: [...allowedDomains], deniedDomains: [], ...localBinding };
36
+ const open = { deniedDomains: [], ...localBinding };
30
37
  return open;
31
38
  }
32
39
  function customConfig(policy) {
@@ -36,6 +43,9 @@ function customConfig(policy) {
36
43
  allowWrite: [...policy.writeRoots],
37
44
  denyWrite: [...policy.denyWrite],
38
45
  },
46
+ // US-002: `allowLocalBinding` is deliberately NOT here. srt applies it
47
+ // process-wide, from the `initialize` config, so a copy on the per-command
48
+ // config would promise a per-command effect it does not have.
39
49
  ...(policy.network.allowedDomains !== undefined ? { network: srtNetwork(policy.network.allowedDomains) } : {}),
40
50
  };
41
51
  }
@@ -49,7 +59,7 @@ export function createSrtBackend(network) {
49
59
  if (_srtBackendDeps.platform() === "darwin")
50
60
  await _srtBackendDeps.mkdir(SRT_MACOS_TMPDIR, { recursive: true });
51
61
  await mod.SandboxManager.initialize({
52
- network: srtNetwork(network.allowedDomains),
62
+ network: srtNetwork(network.allowedDomains, network.allowLocalBinding),
53
63
  filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
54
64
  });
55
65
  loaded = mod;
@@ -10,6 +10,15 @@ export type SandboxBackendName = "srt";
10
10
  export interface SandboxNetworkPolicy {
11
11
  /** Absent = unrestricted. */
12
12
  readonly allowedDomains?: readonly string[];
13
+ /**
14
+ * US-002: srt may let a command bind to -- and reach -- loopback ports, so a
15
+ * DB-backed test run sees a local server that is up (#2538).
16
+ *
17
+ * `true` only. srt's own default is off, so absent and `false` are the same
18
+ * thing to it, and a policy that cannot express "off" cannot contradict the
19
+ * config it was built from.
20
+ */
21
+ readonly allowLocalBinding?: true;
13
22
  }
14
23
  export interface SandboxPolicy {
15
24
  readonly writeRoots: readonly string[];
@@ -17,6 +26,18 @@ export interface SandboxPolicy {
17
26
  readonly denyRead: readonly string[];
18
27
  readonly network: SandboxNetworkPolicy;
19
28
  }
29
+ /**
30
+ * nax#2500: the sandbox an embedder hands a backend that runs its own shell
31
+ * (ACP Claude). Absent = sandbox disabled by config. `unavailable` = enabled but
32
+ * unusable on this host; the backend must refuse rather than run unsandboxed.
33
+ */
34
+ export type SessionSandbox = {
35
+ readonly kind: "policy";
36
+ readonly policy: SandboxPolicy;
37
+ } | {
38
+ readonly kind: "unavailable";
39
+ readonly reason: string;
40
+ };
20
41
  export type ProbeResult = {
21
42
  readonly available: true;
22
43
  } | {
@@ -99,4 +120,6 @@ export interface LaunchResult extends ArgvExecResult {
99
120
  export interface CommandLauncher {
100
121
  readonly state: SandboxState;
101
122
  run(req: LaunchRequest): Promise<LaunchResult>;
123
+ /** nax#2500: the policy this launcher would apply at `root`, without the session TMPDIR. Present when available. */
124
+ policyAt?(root: string): Promise<SandboxPolicy>;
102
125
  }
@@ -36,7 +36,7 @@ const OptionsSchema = z.strictObject({
36
36
  .string()
37
37
  .regex(SESSION_ID, "must be 1-128 letters, digits, '.', '_' or '-', starting with a letter or digit")
38
38
  .optional(),
39
- profile: z.enum(["none", "read", "ask", "full"]),
39
+ profile: z.enum(["none", "read", "review", "ask", "full"]),
40
40
  workdir: z
41
41
  .string()
42
42
  .refine((dir) => isAbsolute(dir), "must be an absolute path")
@@ -13,7 +13,7 @@ import type { JSONSchema } from "./tool-descriptor.ts";
13
13
  * What the session's agent may touch. Capability statements, not tool lists
14
14
  * (spec 4.5). `ask`: every mutating action is approved through `answer()`.
15
15
  */
16
- export type AgentSessionProfile = "none" | "read" | "ask" | "full";
16
+ export type AgentSessionProfile = "none" | "read" | "review" | "ask" | "full";
17
17
  export interface EmbedderToolContext {
18
18
  readonly sessionId: string;
19
19
  /** The provider's tool-call id: the same id as the `tool_call` / `tool_result` events. */
@@ -69,12 +69,14 @@ export function parseNativeBackendOptions(input) {
69
69
  return { raw: options, provider: providerOf(options.model) };
70
70
  }
71
71
  export function nativeProfileRules(profile, raw) {
72
- const tools = profile === "full" || profile === "ask";
72
+ const tools = profile === "full" || profile === "ask" || profile === "review";
73
73
  if (!tools && raw.bashApproval !== undefined) {
74
- throw invalid('bashApproval applies to profiles "ask" and "full" only', { path: "backend.bashApproval" });
74
+ throw invalid('bashApproval applies to profiles "ask", "full" and "review" only', { path: "backend.bashApproval" });
75
75
  }
76
76
  if (!tools && raw.allowUnsandboxed !== undefined) {
77
- throw invalid('allowUnsandboxed applies to profiles "ask" and "full" only', { path: "backend.allowUnsandboxed" });
77
+ throw invalid('allowUnsandboxed applies to profiles "ask", "full" and "review" only', {
78
+ path: "backend.allowUnsandboxed",
79
+ });
78
80
  }
79
81
  const bashApproval = raw.bashApproval ?? "gated";
80
82
  if (profile === "ask" && bashApproval !== "gated") {
@@ -64,6 +64,15 @@ export interface BackendOpenContext {
64
64
  readonly metadata: Readonly<Record<string, string>>;
65
65
  /** Aborted when the session starts closing. */
66
66
  readonly openSignal: AbortSignal;
67
+ /** nax#2500: present only for profiles review/full when the embedder supplied one. */
68
+ readonly sandbox?: import("#src/sandbox/types").SessionSandbox;
69
+ /**
70
+ * The rendered `## MCP servers` section for this open (#2517). The ACP
71
+ * backend (nax-agent-acp) offers it to its tool host, so the agent's own MCP
72
+ * client receives it in `initialize`; the native backend leaves it alone —
73
+ * native reaches the same text through `OpenSessionOpts.providerInstructions`.
74
+ */
75
+ readonly providerInstructions?: string;
67
76
  }
68
77
  export interface OpenedBackend {
69
78
  readonly adapter: AgentSessionAdapter;
@@ -29,7 +29,7 @@ export interface SessionLauncherArgs {
29
29
  readonly bashApproval: BashApprovalMode;
30
30
  readonly allowUnsandboxed: boolean;
31
31
  }
32
- /** The sandbox floor (spec 6.3): "ask" and "full" need a usable sandbox unless gated + allowUnsandboxed. */
32
+ /** The sandbox floor (spec 6.3): "ask", "full" and "review" need a usable sandbox unless gated + allowUnsandboxed. */
33
33
  export declare function resolveSessionLauncher(args: SessionLauncherArgs): Promise<CommandLauncher | undefined>;
34
34
  export interface SessionToolSupportArgs {
35
35
  readonly profile: AgentSessionProfile;
@@ -21,6 +21,8 @@ export function declaredToolsFor(profile, protectedPaths) {
21
21
  return [...UNIVERSAL_CODING_TOOLS];
22
22
  if (profile === "read")
23
23
  return [...UNIVERSAL_CODING_TOOLS, ...READ_TOOLS];
24
+ if (profile === "review")
25
+ return [...UNIVERSAL_CODING_TOOLS, ...READ_TOOLS, "Bash"];
24
26
  // GitCommit refuses every path without ignore patterns, so it is offered only with them.
25
27
  const commit = protectedPaths.gitIgnorePatterns.length > 0 ? ["GitCommit"] : [];
26
28
  return [...UNIVERSAL_CODING_TOOLS, ...READ_TOOLS, ...WRITE_TOOLS, ...commit];
@@ -57,9 +59,9 @@ export function defaultProtectedPaths(ownCredentials) {
57
59
  const credentialDir = ownCredentials ? undefined : configuredCredentialDir();
58
60
  return credentialDir === undefined ? base : { ...base, credentialDir };
59
61
  }
60
- /** The sandbox floor (spec 6.3): "ask" and "full" need a usable sandbox unless gated + allowUnsandboxed. */
62
+ /** The sandbox floor (spec 6.3): "ask", "full" and "review" need a usable sandbox unless gated + allowUnsandboxed. */
61
63
  export async function resolveSessionLauncher(args) {
62
- if (args.profile !== "full" && args.profile !== "ask")
64
+ if (args.profile !== "full" && args.profile !== "ask" && args.profile !== "review")
63
65
  return undefined;
64
66
  const launcher = await resolveSessionSandbox({
65
67
  config: DEFAULT_SANDBOX_CONFIG,
@@ -155,6 +155,8 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
155
155
  providerTools?: readonly import("#src/tools/registry").CodingTool[];
156
156
  /** ACP: the op's materialized declaration, which narrows the session profile (nax#2444, D2). */
157
157
  declaredTools?: readonly string[];
158
+ /** nax#2500: the dispatch sandbox for ACP backends that run their own shell (claude). Native ignores it. */
159
+ sandbox?: import("#src/sandbox/types").SessionSandbox;
158
160
  /** ACP: the transport-less surface draft; the adapter stamps transport + profile (nax#2410, D4). */
159
161
  toolSurface?: import("#src/tools/tool-audit").ToolSurfaceDraft;
160
162
  /**
@@ -194,6 +196,14 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
194
196
  * facade sets it from `instructions`; nax leaves it unset. ACP ignores it.
195
197
  */
196
198
  systemPrompt?: string;
199
+ /**
200
+ * Native: the rendered `## MCP servers` section, appended to `systemPrompt`
201
+ * after a blank line (#2517) — one `system` field, so the instructions reach
202
+ * the model exactly where `systemPrompt` already goes and no second state
203
+ * map is needed. Set together with `systemPrompt` by nax
204
+ * (`openSessionExtras`); either may be set alone. ACP ignores it.
205
+ */
206
+ providerInstructions?: string;
197
207
  /** Native: repository-relative instruction scopes, independent of the file-tool root. */
198
208
  instructionDirectories?: readonly string[];
199
209
  /** Native: instruction Markdown basename; defaults to AGENTS.md and its legacy fallback chain. */