@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.
- package/CHANGELOG.md +8 -0
- package/dist/coding-tools/coding-tool-extras.d.ts +42 -0
- package/dist/coding-tools/coding-tool-extras.js +31 -0
- package/dist/coding-tools/coding-tool-support.d.ts +6 -0
- package/dist/coding-tools/coding-tool-support.js +7 -1
- package/dist/config/catalog-overrides.d.ts +5 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/internal/argv-exec.d.ts +2 -0
- package/dist/internal/argv-exec.js +72 -7
- package/dist/mcp/connect.js +26 -3
- package/dist/mcp/types.d.ts +7 -0
- package/dist/native/models.js +1 -0
- package/dist/native/session/loop-events/types.d.ts +5 -0
- package/dist/native/session-adapter.js +16 -5
- package/dist/permissions/approval-memory.d.ts +18 -0
- package/dist/permissions/approval-memory.js +88 -0
- package/dist/permissions/index.d.ts +1 -0
- package/dist/permissions/index.js +1 -0
- package/dist/permissions/types.d.ts +6 -0
- package/dist/sandbox/claude-settings.d.ts +2 -1
- package/dist/sandbox/claude-settings.js +7 -3
- package/dist/sandbox/defaults.js +1 -0
- package/dist/sandbox/launcher.js +3 -0
- package/dist/sandbox/types.d.ts +2 -0
- package/dist/session/agent-session-command.d.ts +19 -0
- package/dist/session/agent-session-command.js +38 -0
- package/dist/session/agent-session-types.d.ts +9 -0
- package/dist/session/agent-session.js +49 -1
- package/dist/session/native-backend-options.d.ts +18 -2
- package/dist/session/native-backend-options.js +26 -3
- package/dist/session/native-backend.js +42 -4
- package/dist/session/session-ask-link.d.ts +6 -0
- package/dist/session/session-ask-link.js +5 -0
- package/dist/session/session-ask-port.js +4 -0
- package/dist/session/session-backend.d.ts +21 -1
- package/dist/session/session-tool-support.d.ts +39 -3
- package/dist/session/session-tool-support.js +54 -10
- package/dist/session-store/index.d.ts +3 -0
- package/dist/session-store/index.js +3 -0
- package/dist/session-store/session-lock-guard.d.ts +6 -0
- package/dist/session-store/session-lock-guard.js +85 -0
- package/dist/session-store/session-lock.d.ts +39 -0
- package/dist/session-store/session-lock.js +175 -0
- package/dist/session-store/session-meta.d.ts +27 -0
- package/dist/session-store/session-meta.js +20 -0
- package/dist/session-store/storage.d.ts +39 -0
- package/dist/session-store/storage.js +164 -0
- package/dist/tools/ask-request.d.ts +18 -0
- package/dist/tools/ask-request.js +30 -0
- package/dist/tools/bash.d.ts +9 -0
- package/dist/tools/bash.js +61 -16
- package/dist/tools/git.js +10 -3
- package/dist/tools/policy-match.d.ts +2 -2
- package/dist/tools/policy-match.js +9 -6
- package/dist/tools/policy-paths-branch.js +4 -1
- package/dist/tools/provider-advertise.d.ts +7 -0
- package/dist/tools/provider-advertise.js +13 -1
- package/dist/tools/provider-types.d.ts +6 -0
- package/dist/tools/run-command-exec.js +12 -0
- package/dist/tools/run-command-surface.d.ts +28 -0
- package/dist/tools/run-command-surface.js +109 -0
- package/dist/tools/run-command.d.ts +17 -16
- package/dist/tools/run-command.js +48 -68
- package/dist/tools/runtime-calltool.d.ts +100 -0
- package/dist/tools/runtime-calltool.js +112 -3
- package/dist/tools/runtime.d.ts +11 -0
- package/dist/tools/runtime.js +36 -18
- package/dist/tools/types.d.ts +8 -0
- 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
|
|
240
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
...(
|
|
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:
|
|
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) {
|
package/dist/tools/runtime.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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": "
|
|
57
|
+
"tag": "canary"
|
|
58
58
|
}
|
|
59
59
|
}
|