@nathapp/nax-agent 0.91.6 → 0.91.7-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 (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/coding-tools/coding-tool-extras.d.ts +42 -0
  3. package/dist/coding-tools/coding-tool-extras.js +31 -0
  4. package/dist/coding-tools/coding-tool-support.d.ts +6 -0
  5. package/dist/coding-tools/coding-tool-support.js +7 -1
  6. package/dist/config/catalog-overrides.d.ts +5 -0
  7. package/dist/index.d.ts +3 -2
  8. package/dist/index.js +2 -1
  9. package/dist/internal/argv-exec.d.ts +2 -0
  10. package/dist/internal/argv-exec.js +72 -7
  11. package/dist/mcp/connect.js +26 -3
  12. package/dist/mcp/types.d.ts +7 -0
  13. package/dist/native/models.js +1 -0
  14. package/dist/native/session/loop-events/types.d.ts +5 -0
  15. package/dist/native/session-adapter.js +16 -5
  16. package/dist/permissions/approval-memory.d.ts +18 -0
  17. package/dist/permissions/approval-memory.js +88 -0
  18. package/dist/permissions/index.d.ts +1 -0
  19. package/dist/permissions/index.js +1 -0
  20. package/dist/permissions/types.d.ts +6 -0
  21. package/dist/sandbox/claude-settings.d.ts +2 -1
  22. package/dist/sandbox/claude-settings.js +7 -3
  23. package/dist/sandbox/defaults.js +1 -0
  24. package/dist/sandbox/launcher.js +3 -0
  25. package/dist/sandbox/types.d.ts +2 -0
  26. package/dist/session/agent-session-command.d.ts +19 -0
  27. package/dist/session/agent-session-command.js +38 -0
  28. package/dist/session/agent-session-types.d.ts +9 -0
  29. package/dist/session/agent-session.js +49 -1
  30. package/dist/session/native-backend-options.d.ts +18 -2
  31. package/dist/session/native-backend-options.js +26 -3
  32. package/dist/session/native-backend.js +42 -4
  33. package/dist/session/session-ask-link.d.ts +6 -0
  34. package/dist/session/session-ask-link.js +5 -0
  35. package/dist/session/session-ask-port.js +4 -0
  36. package/dist/session/session-backend.d.ts +21 -1
  37. package/dist/session/session-tool-support.d.ts +39 -3
  38. package/dist/session/session-tool-support.js +54 -10
  39. package/dist/session-store/index.d.ts +3 -0
  40. package/dist/session-store/index.js +3 -0
  41. package/dist/session-store/session-lock-guard.d.ts +6 -0
  42. package/dist/session-store/session-lock-guard.js +85 -0
  43. package/dist/session-store/session-lock.d.ts +39 -0
  44. package/dist/session-store/session-lock.js +175 -0
  45. package/dist/session-store/session-meta.d.ts +27 -0
  46. package/dist/session-store/session-meta.js +20 -0
  47. package/dist/session-store/storage.d.ts +39 -0
  48. package/dist/session-store/storage.js +164 -0
  49. package/dist/tools/ask-request.d.ts +18 -0
  50. package/dist/tools/ask-request.js +30 -0
  51. package/dist/tools/bash.d.ts +9 -0
  52. package/dist/tools/bash.js +61 -16
  53. package/dist/tools/git.js +10 -3
  54. package/dist/tools/policy-match.d.ts +2 -2
  55. package/dist/tools/policy-match.js +9 -6
  56. package/dist/tools/policy-paths-branch.js +4 -1
  57. package/dist/tools/provider-advertise.d.ts +7 -0
  58. package/dist/tools/provider-advertise.js +13 -1
  59. package/dist/tools/provider-types.d.ts +6 -0
  60. package/dist/tools/run-command-exec.js +12 -0
  61. package/dist/tools/run-command-surface.d.ts +28 -0
  62. package/dist/tools/run-command-surface.js +109 -0
  63. package/dist/tools/run-command.d.ts +17 -16
  64. package/dist/tools/run-command.js +48 -68
  65. package/dist/tools/runtime-calltool.d.ts +100 -0
  66. package/dist/tools/runtime-calltool.js +112 -3
  67. package/dist/tools/runtime.d.ts +11 -0
  68. package/dist/tools/runtime.js +36 -18
  69. package/dist/tools/types.d.ts +8 -0
  70. package/package.json +3 -3
@@ -17,11 +17,14 @@
17
17
  import { statSync } from "node:fs";
18
18
  import { describeValuesType } from "#src/internal/describe-value-type";
19
19
  import { shellQuoteArg } from "#src/internal/shell-quote";
20
- import { sandboxSentence, unsandboxedSentence } from "../sandbox/index.js";
21
- import { describeExecAllowlist } from "./exec-allowlist-text.js";
22
20
  import { pathListElements } from "./path-list.js";
23
21
  import { runExecBranch } from "./run-command-exec.js";
22
+ import { runCommandDescription, runCommandInputSchema } from "./run-command-surface.js";
24
23
  import { cutToByteCap, READ_CEILING } from "./truncate.js";
24
+ // Re-exported so the seam keeps its `#src/tools/run-command` specifier (tests)
25
+ // and its `./internal` API-snapshot name after US-008 moved it next to the
26
+ // description it feeds. Same object instance either way: one import graph.
27
+ export { _runCommandToolDeps } from "./run-command-surface.js";
25
28
  const PLACEHOLDER = /\{\{([a-zA-Z]+)\}\}/g;
26
29
  function quoteAt(template, end) {
27
30
  let quote;
@@ -194,29 +197,6 @@ function otherCommandsDeclaring(declared, key, currentCommand) {
194
197
  }
195
198
  return names;
196
199
  }
197
- /**
198
- * US-006: the platform seam `execSandboxNote` reads to decide whether the
199
- * description carries the macOS ps note.
200
- *
201
- * Kept out of `src/tools/index.ts` on purpose: `src/index.ts` takes named
202
- * exports from that barrel, so a seam there would reach the package's public
203
- * `.` entry, where `_` names are forbidden. It DOES reach `./internal`, because
204
- * `src/internal.ts` star-exports this module — which is exactly where a
205
- * `_*Deps` seam belongs (nax-only, outside semver), and why adding it here
206
- * needed the one-line api/ snapshot update. Tests reach it through
207
- * `#src/tools/run-command`.
208
- */
209
- export const _runCommandToolDeps = {
210
- platform: () => process.platform,
211
- };
212
- function execSandboxNote(launcher) {
213
- const state = launcher?.state;
214
- if (state?.kind === "available")
215
- return ` An "argv" call runs ${sandboxSentence(state.network, state.sharedTmp !== false, _runCommandToolDeps.platform())}`;
216
- if (state?.kind === "unavailable")
217
- return ` ${unsandboxedSentence(state.reason)}`;
218
- return "";
219
- }
220
200
  const MISSING_RUNNER_OUTPUT = "no declared-command runner is configured for this session";
221
201
  /** The runner a tool built without one uses: refuses, spawns nothing. */
222
202
  async function refuseWithoutRunner() {
@@ -226,6 +206,36 @@ async function refuseWithoutRunner() {
226
206
  function declaredCommandRunner(opts) {
227
207
  return opts.runDeclaredCommand ?? refuseWithoutRunner;
228
208
  }
209
+ /**
210
+ * The refusal for a field that applies only to an `argv` call, or undefined when
211
+ * the call carries none.
212
+ *
213
+ * Every refusal lives in ONE function because `run` sits at its
214
+ * cognitive-complexity baseline (`scripts/baselines/complexity-baseline.json`:
215
+ * the gate only ever lowers, and another `if` in `run` fails
216
+ * `bun run check:complexity`), which is why the message strings moved out too.
217
+ *
218
+ * #2066 is the reason `target` is refused rather than ignored: 48 of 136
219
+ * declared-command calls in one audited run carried a `target` that was silently
220
+ * discarded, and the transcript shows the model reasoning about it while trying
221
+ * to fix a failure. `unsandboxed` gets the same treatment for the same reason,
222
+ * plus a harder one: silently dropping an escape request would run the command
223
+ * SANDBOXED while the caller believes it escaped, which is a wrong answer rather
224
+ * than a missing one. `unsandboxedReason` is refused for the #2066 reason alone:
225
+ * on its own no escape was requested, so nothing unsafe happens -- but a silent
226
+ * drop teaches the model nothing, and it re-sends the same call.
227
+ */
228
+ function argvOnlyFieldRefusal(input) {
229
+ if (input.unsandboxed === true)
230
+ return '"unsandboxed" applies only to "argv" calls';
231
+ if (input.unsandboxedReason !== undefined) {
232
+ return '"unsandboxedReason" applies only to "argv" calls, alongside "unsandboxed": true. Remove "unsandboxedReason".';
233
+ }
234
+ if (input.target === undefined)
235
+ return undefined;
236
+ const key = typeof input.command === "string" ? input.command : "";
237
+ return `"target" applies only to "argv" calls; a declared command runs in the directory its configuration declares. Remove "target" and call "${key}" on its own.`;
238
+ }
229
239
  export function createRunCommandTool(declared, opts = {}) {
230
240
  const keys = [...declared.keys()];
231
241
  const exec = opts.exec;
@@ -236,37 +246,8 @@ export function createRunCommandTool(declared, opts = {}) {
236
246
  name: "RunCommand",
237
247
  // A non-zero exit here is the agent's red/green loop, not a fault.
238
248
  routineErrors: true,
239
- description: exec !== undefined
240
- ? `Two ways to run something. (1) Run one of this project's declared commands: ${commandDescriptions} — supply "command" and, optionally, "values" for its placeholders. (2) Run an allowlisted external command via "argv" (an array, e.g. ["bun","add","left-pad"]) — no shell, so no quoting and no shell metacharacters; ${describeExecAllowlist(exec.patterns)}. Supply exactly one of "command" or "argv".${execSandboxNote(exec.launcher)}`
241
- : `Run one of this project's declared commands: ${commandDescriptions}. Supply values for its placeholders; you cannot write a command of your own.`,
242
- inputSchema: {
243
- type: "object",
244
- properties: {
245
- command: { type: "string", enum: keys, description: "Which declared command to run" },
246
- values: {
247
- type: "object",
248
- description: 'Values for the command\'s placeholders, e.g. { files: "a.test.ts" }. A path placeholder takes several paths separated by spaces, e.g. { files: "a.test.ts b.test.ts" }, or as an array of paths, e.g. { files: ["test/a.test.ts", "test/b.test.ts"] } -- one call, not one per file.',
249
- },
250
- ...(hasExec
251
- ? {
252
- argv: {
253
- type: "array",
254
- items: { type: "string" },
255
- description: 'Argv for an allowlisted external command, e.g. ["bun","add","left-pad"]. No shell: each element is one literal argument. Supply exactly one of "command" or "argv".',
256
- },
257
- target: {
258
- type: "string",
259
- enum: ["repoRoot", "package"],
260
- description: 'Working directory for "argv": this package (default) or the repo root.',
261
- },
262
- }
263
- : {}),
264
- },
265
- // With exec available, neither field is unconditionally required —
266
- // run() enforces "exactly one of command or argv" at call time, which
267
- // a static JSON Schema `required` list cannot express as an either/or.
268
- ...(hasExec ? {} : { required: ["command"] }),
269
- },
249
+ description: runCommandDescription(commandDescriptions, exec),
250
+ inputSchema: runCommandInputSchema(keys, exec),
270
251
  // `{{files}}` is the declared scoped-test path parameter. Keeping it in
271
252
  // the policy's containment seam prevents a quoted-but-otherwise harmless
272
253
  // absolute filename from making the configured command act outside root.
@@ -274,12 +255,19 @@ export function createRunCommandTool(declared, opts = {}) {
274
255
  // policy recognize an argv call and check it under the `Exec` identity
275
256
  // (see src/tools/runtime.ts and src/tools/policy.ts) rather than under
276
257
  // RunCommand's own grant.
258
+ //
259
+ // US-008: `unsandboxedField` is likewise set whenever exec is available,
260
+ // unconditionally -- the runtime reads the field to route an escaping argv
261
+ // call (Bash's rule, US-003), while whether the argument is OFFERED is
262
+ // `exec.unsandboxedCommands`' business. A declared-command call carries no
263
+ // Exec identity, so the runtime leaves that flag to `run` below, which
264
+ // refuses it by name.
277
265
  scope: {
278
266
  pathFields: [],
279
267
  listPathFields: ["values.files"],
280
268
  verbField: "command",
281
269
  allowedVerbs: keys,
282
- ...(hasExec ? { argvField: "argv" } : {}),
270
+ ...(hasExec ? { argvField: "argv", unsandboxedField: "unsandboxed" } : {}),
283
271
  },
284
272
  async run(input, ctx) {
285
273
  const hasCommand = typeof input.command === "string" && input.command.length > 0;
@@ -289,17 +277,9 @@ export function createRunCommandTool(declared, opts = {}) {
289
277
  }
290
278
  if (hasArgv)
291
279
  return runExecBranch(input, ctx, opts);
292
- // #2066: `target` is read only on the argv branch (run-command-exec.ts).
293
- // 48 of 136 declared-command calls in one audited run carried a `target`
294
- // that was silently discarded, and the transcript shows the model
295
- // reasoning about it while trying to fix a failure. Silently ignoring it
296
- // is the one option that teaches nothing.
297
- if (input.target !== undefined) {
298
- return {
299
- content: `"target" applies only to "argv" calls; a declared command runs in the directory its configuration declares. Remove "target" and call "${typeof input.command === "string" ? input.command : ""}" on its own.`,
300
- isError: true,
301
- };
302
- }
280
+ const misplaced = argvOnlyFieldRefusal(input);
281
+ if (misplaced !== undefined)
282
+ return { content: misplaced, isError: true };
303
283
  const key = typeof input.command === "string" ? input.command : "";
304
284
  const template = declared.get(key);
305
285
  if (template === undefined)
@@ -16,6 +16,7 @@
16
16
  * the seam stub in the mirror suites has never covered either warn.
17
17
  */
18
18
  import { type CommandGuard, type CommandShadow, openShadowTap } from "#src/command-safety/index";
19
+ import type { BashApprovalMode } from "#src/config/bash-approval";
19
20
  import { type AskResolver } from "#src/permissions/index";
20
21
  import type { SandboxRecord } from "../sandbox/index.ts";
21
22
  import type { CodingTool } from "./registry.ts";
@@ -63,6 +64,96 @@ export interface PolicyIdentity {
63
64
  * only which identity the policy and ledger see for THIS call.
64
65
  */
65
66
  export declare function resolvePolicyIdentity(name: string, tool: CodingTool, input: Record<string, unknown>): PolicyIdentity;
67
+ /**
68
+ * What `resolveUnsandboxedCall` answers for a call that really asked to run
69
+ * outside the sandbox: `error` is the refusal, `unsandboxed` the permission.
70
+ */
71
+ export type UnsandboxedCall = {
72
+ readonly kind: "error";
73
+ readonly content: string;
74
+ } | {
75
+ readonly kind: "unsandboxed";
76
+ /** The clause appended to any ask this call raises: WHY it wants out. */
77
+ readonly suffix: string;
78
+ /**
79
+ * US-006: true when an ALLOWED verdict must still become an ask. False
80
+ * under `raw`, which never asks by definition (ADR-030) — the mode's
81
+ * whole point is that a command the policy let through runs.
82
+ */
83
+ readonly asksOnAllow: boolean;
84
+ };
85
+ /**
86
+ * US-005: the sandbox-escape gate, run once per call BEFORE the policy.
87
+ *
88
+ * Whether an escape is PERMITTED is the runtime's decision, not the tool's:
89
+ * the tool only knows what the call asked for, and it runs exactly as it
90
+ * always did once this gate lets it through. Three conditions narrow the field
91
+ * to the calls that really are escape requests — the tool declares an
92
+ * `unsandboxedField`, the policy identity is one the flag is defined for, and
93
+ * the value is exactly `true` (a `"true"` string is not an escape: an opted-in
94
+ * session's schema rejects it at the transport, and every other session reads
95
+ * it as the ordinary call it is).
96
+ *
97
+ * - `error` — this session was not opened with `unsandboxed.enabled`, so the
98
+ * call never reaches the policy, an ask or a spawn. Deciding it here rather
99
+ * than in the tool is what makes "disabled" mean disabled.
100
+ * - `unsandboxed` — the escape is allowed. `suffix` is the clause an ask this
101
+ * call raises must carry, so the person approving it is told the command
102
+ * runs outside the sandbox; `asksOnAllow` says whether an ALLOW still has to
103
+ * be raised as one (US-006). The policy itself is evaluated unchanged: the
104
+ * flag is not a policy input, so the verdict is the one the call would get
105
+ * without it.
106
+ *
107
+ * The mode is read from the runtime's option rather than the compiled policy:
108
+ * `bashApproval` decides how an ENABLED request is ROUTED, which is the
109
+ * runtime's business, while the policy line answers only "is this command
110
+ * form granted". An `Exec` (argv) call is routed by the same mode — it is the
111
+ * other model-authored identity, and no separate Exec approval mode exists.
112
+ */
113
+ export declare function resolveUnsandboxedCall(p: {
114
+ identity: string;
115
+ tool: CodingTool;
116
+ input: Record<string, unknown>;
117
+ option: {
118
+ readonly enabled: boolean;
119
+ readonly bashApproval: BashApprovalMode;
120
+ } | undefined;
121
+ }): UnsandboxedCall | undefined;
122
+ /**
123
+ * US-006: the ask a permitted escape request raises where the policy only
124
+ * allowed it.
125
+ *
126
+ * An allow says "this command form is granted", nothing about the sandbox:
127
+ * under `gated` that same grant runs the command WRAPPED, and under `escalate`
128
+ * only a grant MISS asks. So an escape request the policy merely allowed would
129
+ * otherwise run unwrapped with nobody told, which is the whole thing the
130
+ * approval exists to prevent. `raw` is exempt (`asksOnAllow`), and a verdict
131
+ * that already asks or denies is left to the ordinary paths.
132
+ *
133
+ * The rule is SYNTHETIC — `Unsandboxed(<identity>)` is expressible in no
134
+ * config — so it goes on `rule` only; `matchedRule` stays unset (see
135
+ * `SYNTHETIC_UNSANDBOXED_RULE`). `resolvedPaths` is carried over so an
136
+ * approved call admits exactly what the policy resolved.
137
+ */
138
+ export declare function convertUnsandboxedAllow(p: {
139
+ identity: string;
140
+ unsandboxed: UnsandboxedCall | undefined;
141
+ verdict: PolicyVerdict;
142
+ }): DeniedVerdict | undefined;
143
+ /**
144
+ * The verdict this call raises an ask with, or undefined when it raises none.
145
+ *
146
+ * One place decides both the ask the policy itself produced (US-005) and the
147
+ * one an ALLOWED escape request is converted into (US-006), so `callTool`
148
+ * keeps a single ask branch and the conversion cannot drift from the ordinary
149
+ * route. A denial that is final — `outcome` absent or "denied" — is NOT an
150
+ * ask and comes back undefined, leaving `resolveDenialOutcome` to answer it.
151
+ */
152
+ export declare function resolveAskVerdict(p: {
153
+ identity: string;
154
+ verdict: PolicyVerdict;
155
+ unsandboxed: UnsandboxedCall | undefined;
156
+ }): DeniedVerdict | undefined;
66
157
  /**
67
158
  * P5: observe the command now (not awaited), settled from the caller's
68
159
  * logCall below. Undefined whenever the runtime has no shadow configured.
@@ -124,6 +215,15 @@ export declare function resolveAskOutcome(p: {
124
215
  root: string;
125
216
  logCall: LogCallFn;
126
217
  runTool: RunToolFn;
218
+ /**
219
+ * US-004: set when this ask is an unsandboxed run. `suffix` is
220
+ * `unsandboxedReasonSuffix(...)`, appended to the request's `reason` so the
221
+ * person sees WHY the call wants to leave the sandbox; the flag itself is
222
+ * what a host keys on.
223
+ */
224
+ unsandboxed?: {
225
+ readonly suffix: string;
226
+ };
127
227
  }): Promise<CodingToolOutcome>;
128
228
  /**
129
229
  * The deny branch: a final policy refusal, sharpened where possible with a
@@ -20,7 +20,7 @@ import { openShadowTap } from "#src/command-safety/index";
20
20
  import { errorMessage } from "#src/infra/errors";
21
21
  import { getSafeLogger } from "#src/infra/index";
22
22
  import { ASK_CANCELLED_REASON } from "#src/permissions/index";
23
- import { askDenyReason, askSummary } from "./ask-request.js";
23
+ import { askDenyReason, askSummary, unsandboxedReasonSuffix } from "./ask-request.js";
24
24
  import { redirectForArgv, redirectForCommand, redirectForVerb } from "./denial-redirect.js";
25
25
  import { BASH_TOOL_NAME, EXEC_TOOL_NAME } from "./types.js";
26
26
  /**
@@ -38,6 +38,98 @@ export function resolvePolicyIdentity(name, tool, input) {
38
38
  const policyIdentity = hasArgv ? EXEC_TOOL_NAME : name;
39
39
  return { policyIdentity, argvField, hasArgv };
40
40
  }
41
+ /** The content of the refusal a disabled escape request gets (spec US-005). */
42
+ const UNSANDBOXED_NOT_ENABLED = "unsandboxed commands are not enabled for this session (allowUnsandboxedCommands)";
43
+ /**
44
+ * US-005: the sandbox-escape gate, run once per call BEFORE the policy.
45
+ *
46
+ * Whether an escape is PERMITTED is the runtime's decision, not the tool's:
47
+ * the tool only knows what the call asked for, and it runs exactly as it
48
+ * always did once this gate lets it through. Three conditions narrow the field
49
+ * to the calls that really are escape requests — the tool declares an
50
+ * `unsandboxedField`, the policy identity is one the flag is defined for, and
51
+ * the value is exactly `true` (a `"true"` string is not an escape: an opted-in
52
+ * session's schema rejects it at the transport, and every other session reads
53
+ * it as the ordinary call it is).
54
+ *
55
+ * - `error` — this session was not opened with `unsandboxed.enabled`, so the
56
+ * call never reaches the policy, an ask or a spawn. Deciding it here rather
57
+ * than in the tool is what makes "disabled" mean disabled.
58
+ * - `unsandboxed` — the escape is allowed. `suffix` is the clause an ask this
59
+ * call raises must carry, so the person approving it is told the command
60
+ * runs outside the sandbox; `asksOnAllow` says whether an ALLOW still has to
61
+ * be raised as one (US-006). The policy itself is evaluated unchanged: the
62
+ * flag is not a policy input, so the verdict is the one the call would get
63
+ * without it.
64
+ *
65
+ * The mode is read from the runtime's option rather than the compiled policy:
66
+ * `bashApproval` decides how an ENABLED request is ROUTED, which is the
67
+ * runtime's business, while the policy line answers only "is this command
68
+ * form granted". An `Exec` (argv) call is routed by the same mode — it is the
69
+ * other model-authored identity, and no separate Exec approval mode exists.
70
+ */
71
+ export function resolveUnsandboxedCall(p) {
72
+ const field = p.tool.scope.unsandboxedField;
73
+ if (field === undefined)
74
+ return undefined;
75
+ if (p.identity !== BASH_TOOL_NAME && p.identity !== EXEC_TOOL_NAME)
76
+ return undefined;
77
+ if (p.input[field] !== true)
78
+ return undefined;
79
+ if (p.option?.enabled !== true)
80
+ return { kind: "error", content: UNSANDBOXED_NOT_ENABLED };
81
+ return {
82
+ kind: "unsandboxed",
83
+ suffix: unsandboxedReasonSuffix(p.input),
84
+ asksOnAllow: p.option.bashApproval !== "raw",
85
+ };
86
+ }
87
+ /** The reason a converted allow carries (spec US-006), before the suffix. */
88
+ const UNSANDBOXED_ASK_REASON = "unsandboxed run requested";
89
+ /**
90
+ * US-006: the ask a permitted escape request raises where the policy only
91
+ * allowed it.
92
+ *
93
+ * An allow says "this command form is granted", nothing about the sandbox:
94
+ * under `gated` that same grant runs the command WRAPPED, and under `escalate`
95
+ * only a grant MISS asks. So an escape request the policy merely allowed would
96
+ * otherwise run unwrapped with nobody told, which is the whole thing the
97
+ * approval exists to prevent. `raw` is exempt (`asksOnAllow`), and a verdict
98
+ * that already asks or denies is left to the ordinary paths.
99
+ *
100
+ * The rule is SYNTHETIC — `Unsandboxed(<identity>)` is expressible in no
101
+ * config — so it goes on `rule` only; `matchedRule` stays unset (see
102
+ * `SYNTHETIC_UNSANDBOXED_RULE`). `resolvedPaths` is carried over so an
103
+ * approved call admits exactly what the policy resolved.
104
+ */
105
+ export function convertUnsandboxedAllow(p) {
106
+ if (p.unsandboxed?.kind !== "unsandboxed" || !p.unsandboxed.asksOnAllow)
107
+ return undefined;
108
+ if (!p.verdict.allowed)
109
+ return undefined;
110
+ return {
111
+ allowed: false,
112
+ outcome: "ask",
113
+ breach: false,
114
+ rule: `Unsandboxed(${p.identity})`,
115
+ reason: UNSANDBOXED_ASK_REASON,
116
+ resolvedPaths: p.verdict.resolvedPaths,
117
+ };
118
+ }
119
+ /**
120
+ * The verdict this call raises an ask with, or undefined when it raises none.
121
+ *
122
+ * One place decides both the ask the policy itself produced (US-005) and the
123
+ * one an ALLOWED escape request is converted into (US-006), so `callTool`
124
+ * keeps a single ask branch and the conversion cannot drift from the ordinary
125
+ * route. A denial that is final — `outcome` absent or "denied" — is NOT an
126
+ * ask and comes back undefined, leaving `resolveDenialOutcome` to answer it.
127
+ */
128
+ export function resolveAskVerdict(p) {
129
+ if (p.verdict.allowed)
130
+ return convertUnsandboxedAllow(p);
131
+ return p.verdict.outcome === "ask" ? p.verdict : undefined;
132
+ }
41
133
  /**
42
134
  * P5: observe the command now (not awaited), settled from the caller's
43
135
  * logCall below. Undefined whenever the runtime has no shadow configured.
@@ -127,6 +219,20 @@ export async function resolveGuardDenial(p) {
127
219
  return undefined;
128
220
  }
129
221
  }
222
+ /**
223
+ * US-004: `Unsandboxed(<identity>)` is the SYNTHETIC rule US-006's
224
+ * converted-allow verdict carries — not an ask rule anyone configured. It is
225
+ * kept on `rule` (the request has to name what it is) but never becomes
226
+ * `matchedRule`, because a remembered approval derives its `origin` from that
227
+ * field and would then remember a rule that exists in no config.
228
+ */
229
+ const SYNTHETIC_UNSANDBOXED_RULE = /^Unsandboxed\(.*\)$/;
230
+ /** The configured ask rule behind a verdict, or undefined when there is none. */
231
+ function matchedRuleOf(verdict) {
232
+ if (verdict.rule === undefined)
233
+ return undefined;
234
+ return SYNTHETIC_UNSANDBOXED_RULE.test(verdict.rule) ? undefined : verdict.rule;
235
+ }
130
236
  /**
131
237
  * The ask branch: a call the policy gates matched an ask rule, so an
132
238
  * AskResolver decides. US-003: build the AskControl from the per-call
@@ -141,21 +247,24 @@ export async function resolveAskOutcome(p) {
141
247
  ...(context?.onWaiting !== undefined ? { onWaiting: context.onWaiting } : {}),
142
248
  };
143
249
  const ask = askSummary(p.identity, tool.scope, input);
250
+ const askReason = p.unsandboxed === undefined ? verdict.reason : `${verdict.reason}${p.unsandboxed.suffix}`;
251
+ const matchedRule = matchedRuleOf(verdict);
144
252
  let askVerdict;
145
253
  try {
146
254
  askVerdict = await p.askResolver.resolve({
147
255
  tool: p.identity,
148
256
  stage: p.stage ?? "unknown",
149
257
  rule: verdict.rule ?? verdict.reason,
150
- ...(verdict.rule !== undefined ? { matchedRule: verdict.rule } : {}),
258
+ ...(matchedRule !== undefined ? { matchedRule } : {}),
151
259
  summary: ask.summary,
152
260
  ...(ask.unshowable ? { unshowable: true } : {}),
153
261
  ...(typeof input[tool.scope.commandField ?? ""] === "string"
154
262
  ? { command: input[tool.scope.commandField] }
155
263
  : {}),
156
264
  root: p.root,
157
- reason: verdict.reason,
265
+ reason: askReason,
158
266
  ...(p.storyId !== undefined ? { storyId: p.storyId } : {}),
267
+ ...(p.unsandboxed !== undefined ? { unsandboxed: true } : {}),
159
268
  }, askControl);
160
269
  }
161
270
  catch (err) {
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { CommandInterceptor } from "#src/command-interceptor/index";
12
12
  import type { CommandShadow } from "#src/command-safety/index";
13
+ import type { BashApprovalMode } from "#src/config/bash-approval";
13
14
  import { getSafeLogger } from "#src/infra/index";
14
15
  import { type AskResolver } from "#src/permissions/index";
15
16
  import type { OwnedPathsPolicy } from "./owned-paths.ts";
@@ -149,4 +150,14 @@ export declare function createCodingToolRuntime(opts: {
149
150
  protectedPaths?: ProtectedPathsPolicy;
150
151
  /** S3-2 port: placed on every ToolRunContext this runtime builds. */
151
152
  ownedPaths?: OwnedPathsPolicy;
153
+ /**
154
+ * US-005: whether this session may run one call OUTSIDE the OS sandbox, and
155
+ * the Bash mode that routes an enabled request. ABSENT means disabled: a
156
+ * `Bash`/`Exec` call carrying `unsandboxed: true` is then refused before the
157
+ * policy evaluates it.
158
+ */
159
+ unsandboxed?: {
160
+ readonly enabled: boolean;
161
+ readonly bashApproval: BashApprovalMode;
162
+ };
152
163
  }): CodingToolRuntime;
@@ -21,7 +21,7 @@ import { enforceInstructionAccess } from "./instruction-access.js";
21
21
  import { readTool } from "./read.js";
22
22
  import { getCodingTool, registerBuiltinTool } from "./registry.js";
23
23
  import { requestCapabilityTool } from "./request-capability.js";
24
- import { openCallShadowTap, resolveAskOutcome, resolveDenialOutcome, resolveGuardDenial, resolvePolicyIdentity, } from "./runtime-calltool.js";
24
+ import { openCallShadowTap, resolveAskOutcome, resolveAskVerdict, resolveDenialOutcome, resolveGuardDenial, resolvePolicyIdentity, resolveUnsandboxedCall, } from "./runtime-calltool.js";
25
25
  import { scratchpadListTool, scratchpadReadTool, scratchpadWriteTool } from "./scratchpad.js";
26
26
  import { applyModelTruncationPolicy } from "./spill.js";
27
27
  import { createNoOpToolAuditSink } from "./tool-audit.js";
@@ -199,9 +199,35 @@ export function createCodingToolRuntime(opts) {
199
199
  return { kind: "denied", reason, breach: false };
200
200
  }
201
201
  const { policyIdentity, argvField, hasArgv } = resolvePolicyIdentity(name, tool, input);
202
+ // Settled by `logCall`, which is declared FIRST so the refusal that
203
+ // precedes the policy can ledger through the same wrapper. Undefined
204
+ // until the shadow opens: nothing observed, nothing to settle.
205
+ let tap;
206
+ // Every ledger outcome of this call settles the tap exactly once, with
207
+ // `denied:ask` and `decidedBy` intact -- which CodingToolOutcome.kind
208
+ // alone would lose (spec 4.2). The audit is a named parameter, not a
209
+ // rest-array index: `log`'s positional shape can then change without
210
+ // silently shifting what the tap settles with.
211
+ const logCall = (tool, outcome, resultBytes, input, context, breach, reason, routineErrors, audit, resultBytesPreTruncation) => {
212
+ log(tool, outcome, resultBytes, input, context, breach, reason, routineErrors, audit, resultBytesPreTruncation);
213
+ tap?.settle(outcome, audit?.approval?.decidedBy, audit?.executed === undefined
214
+ ? undefined
215
+ : {
216
+ executed: audit.executed,
217
+ ...(audit.cwd !== undefined ? { cwd: audit.cwd } : {}),
218
+ });
219
+ };
220
+ // US-005: a `Bash`/`Exec` escape request is answered before the policy,
221
+ // the ask chain and any spawn — refused when this session never enabled
222
+ // it, and otherwise marked so the ask below carries the reason suffix.
223
+ const unsandboxed = resolveUnsandboxedCall({ identity: policyIdentity, tool, input, option: opts.unsandboxed });
224
+ if (unsandboxed?.kind === "error") {
225
+ logCall(policyIdentity, "error", unsandboxed.content.length, input, context, false, unsandboxed.content);
226
+ return unsandboxed;
227
+ }
202
228
  const verdict = opts.policy.check(policyIdentity, tool.scope, input);
203
229
  // P5: observe the command now (not awaited), settle from logCall below.
204
- const tap = openCallShadowTap({
230
+ tap = openCallShadowTap({
205
231
  shadow: opts.commandShadow,
206
232
  identity: policyIdentity,
207
233
  tool,
@@ -216,20 +242,6 @@ export function createCodingToolRuntime(opts) {
216
242
  context,
217
243
  verdict,
218
244
  });
219
- // Every ledger outcome of this call settles the tap exactly once, with
220
- // `denied:ask` and `decidedBy` intact -- which CodingToolOutcome.kind
221
- // alone would lose (spec 4.2). The audit is a named parameter, not a
222
- // rest-array index: `log`'s positional shape can then change without
223
- // silently shifting what the tap settles with.
224
- const logCall = (tool, outcome, resultBytes, input, context, breach, reason, routineErrors, audit, resultBytesPreTruncation) => {
225
- log(tool, outcome, resultBytes, input, context, breach, reason, routineErrors, audit, resultBytesPreTruncation);
226
- tap?.settle(outcome, audit?.approval?.decidedBy, audit?.executed === undefined
227
- ? undefined
228
- : {
229
- executed: audit.executed,
230
- ...(audit.cwd !== undefined ? { cwd: audit.cwd } : {}),
231
- });
232
- };
233
245
  /**
234
246
  * Executes a permitted call and records its outcome. Shared by the
235
247
  * ordinary allow path and an ask verdict an AskResolver approved, so an
@@ -283,13 +295,17 @@ export function createCodingToolRuntime(opts) {
283
295
  return { kind: "error", content };
284
296
  }
285
297
  }
286
- if (!verdict.allowed && verdict.outcome === "ask") {
298
+ // US-005/US-006: the ONE ask route. A verdict the policy raised as an
299
+ // ask goes there as it always did; an ALLOWED escape request is
300
+ // converted into one unless the mode already let it run (`raw`).
301
+ const askVerdict = resolveAskVerdict({ identity: policyIdentity, verdict, unsandboxed });
302
+ if (askVerdict !== undefined) {
287
303
  return resolveAskOutcome({
288
304
  identity: policyIdentity,
289
305
  tool,
290
306
  input,
291
307
  context,
292
- verdict,
308
+ verdict: askVerdict,
293
309
  runtimeSignal: signal,
294
310
  askResolver,
295
311
  stage: opts.pipelineStage,
@@ -297,6 +313,8 @@ export function createCodingToolRuntime(opts) {
297
313
  root: opts.policy.root,
298
314
  logCall,
299
315
  runTool,
316
+ // US-004/US-005: the ask says the command runs outside the sandbox.
317
+ ...(unsandboxed?.kind === "unsandboxed" ? { unsandboxed: { suffix: unsandboxed.suffix } } : {}),
300
318
  });
301
319
  }
302
320
  if (!verdict.allowed) {
@@ -110,6 +110,14 @@ export interface ToolScope {
110
110
  * tool declaring it must be granted explicitly, since no profile grants one.
111
111
  */
112
112
  readonly commandField?: string;
113
+ /**
114
+ * US-003: the input field by which a call asks to run OUTSIDE the OS sandbox
115
+ * (`Bash`'s `unsandboxed`). Declared unconditionally, so the runtime can read
116
+ * the field whether or not the session advertises it; whether the argument is
117
+ * OFFERED (schema + description) is the session's `unsandboxedCommands`
118
+ * option, and whether a call is PERMITTED is the runtime's decision.
119
+ */
120
+ readonly unsandboxedField?: string;
113
121
  }
114
122
  /**
115
123
  * `breach` separates "you may not write there" from "that path is not in this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax-agent",
3
- "version": "0.91.6",
3
+ "version": "0.91.7-canary.2",
4
4
  "description": "nax's native coding agent: session contract, native loop, tools, permissions, sandbox, command-safety.",
5
5
  "license": "MIT",
6
6
  "author": "William Khoo",
@@ -47,13 +47,13 @@
47
47
  "dependencies": {
48
48
  "@anthropic-ai/sandbox-runtime": "0.0.77",
49
49
  "@modelcontextprotocol/sdk": "^1.32.1",
50
- "@nathapp/nax-ai": "0.91.6",
50
+ "@nathapp/nax-ai": "0.91.7-canary.2",
51
51
  "zod": "^4.3.6"
52
52
  },
53
53
  "publishConfig": {
54
54
  "access": "public",
55
55
  "registry": "https://registry.npmjs.org/",
56
56
  "provenance": true,
57
- "tag": "latest"
57
+ "tag": "canary"
58
58
  }
59
59
  }