@nathapp/nax-agent 0.91.6 → 0.91.7-canary.10
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 +40 -0
- package/dist/coding-tools/coding-tool-extras.d.ts +52 -0
- package/dist/coding-tools/coding-tool-extras.js +56 -0
- package/dist/coding-tools/coding-tool-support.d.ts +9 -0
- package/dist/coding-tools/coding-tool-support.js +12 -1
- package/dist/config/catalog-overrides.d.ts +9 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/internal/argv-exec.d.ts +6 -0
- package/dist/internal/argv-exec.js +158 -63
- package/dist/mcp/connect.js +26 -3
- package/dist/mcp/types.d.ts +7 -0
- package/dist/native/adapter-deps.d.ts +2 -0
- package/dist/native/adapter-deps.js +2 -0
- package/dist/native/models.js +2 -0
- package/dist/native/session/loop-events/types.d.ts +5 -0
- package/dist/native/session-adapter.js +19 -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/runtime/node-runtime.js +5 -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 +7 -0
- package/dist/sandbox/types.d.ts +6 -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 +26 -0
- package/dist/session/agent-session.js +86 -1
- package/dist/session/native-backend-options.d.ts +37 -2
- package/dist/session/native-backend-options.js +39 -3
- package/dist/session/native-backend.js +55 -5
- package/dist/session/native-posture-rules.d.ts +18 -0
- package/dist/session/native-posture-rules.js +33 -0
- 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 +23 -1
- package/dist/session/session-interaction.d.ts +2 -0
- package/dist/session/session-interaction.js +8 -1
- package/dist/session/session-tool-support.d.ts +49 -3
- package/dist/session/session-tool-support.js +66 -11
- 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 +178 -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-background.d.ts +74 -0
- package/dist/tools/bash-background.js +151 -0
- package/dist/tools/bash.d.ts +11 -0
- package/dist/tools/bash.js +77 -17
- 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 +101 -0
- package/dist/tools/runtime-calltool.js +112 -3
- package/dist/tools/runtime.d.ts +12 -0
- package/dist/tools/runtime.js +36 -18
- package/dist/tools/types.d.ts +8 -0
- package/package.json +3 -3
package/dist/mcp/connect.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* once the process is gone. A failed connect is closed the same way.
|
|
7
7
|
*/
|
|
8
8
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
9
|
-
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { ErrorCode, ListToolsResultSchema, McpError, ToolSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { z } from "zod";
|
|
10
11
|
import { McpCallError, McpConnectError } from "#src/mcp/errors";
|
|
11
12
|
import { resultText } from "#src/mcp/result-text";
|
|
12
13
|
import { waitForStdioExit } from "#src/mcp/stdio-close";
|
|
@@ -53,13 +54,35 @@ function toolInfo(raw) {
|
|
|
53
54
|
if (!isRaw(raw) || typeof raw.name !== "string" || raw.name.length === 0)
|
|
54
55
|
return undefined;
|
|
55
56
|
const inputSchema = isRaw(raw.inputSchema) ? { ...raw.inputSchema } : {};
|
|
56
|
-
|
|
57
|
+
const readOnly = isRaw(raw.annotations) && raw.annotations.readOnlyHint === true;
|
|
58
|
+
return {
|
|
59
|
+
name: raw.name,
|
|
60
|
+
description: typeof raw.description === "string" ? raw.description : "",
|
|
61
|
+
inputSchema,
|
|
62
|
+
...(readOnly ? { readOnly: true } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The SDK's own tools/list schema, except that a tool's `annotations` are not
|
|
67
|
+
* type-checked: the SDK would reject the WHOLE page over one server's
|
|
68
|
+
* `readOnlyHint: "yes"`, which would cost the connection every tool. `toolInfo`
|
|
69
|
+
* reads the hint strictly (`=== true`), so a malformed one just means "not
|
|
70
|
+
* read-only" and every other field is still validated.
|
|
71
|
+
*/
|
|
72
|
+
const LenientListToolsSchema = ListToolsResultSchema.extend({
|
|
73
|
+
tools: z.array(ToolSchema.extend({ annotations: z.record(z.string(), z.unknown()).optional() })),
|
|
74
|
+
});
|
|
75
|
+
async function listPage(client, cursor, signal, timeout) {
|
|
76
|
+
const page = await client.request({ method: "tools/list", ...(cursor !== undefined ? { params: { cursor } } : {}) }, LenientListToolsSchema, { signal, timeout });
|
|
77
|
+
// What Client.listTools does after its own request: the SDK reads these caches in callTool.
|
|
78
|
+
client["cacheToolMetadata"](page.tools);
|
|
79
|
+
return page;
|
|
57
80
|
}
|
|
58
81
|
async function listAll(client, signal, timeout) {
|
|
59
82
|
const tools = [];
|
|
60
83
|
let cursor;
|
|
61
84
|
do {
|
|
62
|
-
const page = await client
|
|
85
|
+
const page = await listPage(client, cursor, signal, timeout);
|
|
63
86
|
for (const raw of page.tools ?? []) {
|
|
64
87
|
const info = toolInfo(raw);
|
|
65
88
|
if (info !== undefined)
|
package/dist/mcp/types.d.ts
CHANGED
|
@@ -15,6 +15,13 @@ export interface McpToolInfo {
|
|
|
15
15
|
readonly name: string;
|
|
16
16
|
readonly description: string;
|
|
17
17
|
readonly inputSchema: JSONSchema;
|
|
18
|
+
/**
|
|
19
|
+
* Present (as `true`) only when the server annotated the tool
|
|
20
|
+
* `readOnlyHint: true`. Absent in every other case, so an unannotated tool
|
|
21
|
+
* keeps its `{ name, description, inputSchema }` shape. Trusted as the server
|
|
22
|
+
* states it; the lock pins input schemas, not this hint.
|
|
23
|
+
*/
|
|
24
|
+
readonly readOnly?: true;
|
|
18
25
|
}
|
|
19
26
|
export interface McpCallResult {
|
|
20
27
|
readonly text: string;
|
|
@@ -16,6 +16,8 @@ export declare function isProtocolStreamError(err: unknown): err is {
|
|
|
16
16
|
export declare const _adapterDeps: {
|
|
17
17
|
listStoredProviders: typeof listStoredProviders;
|
|
18
18
|
anyAmbientCredential: typeof anyAmbientCredential;
|
|
19
|
+
/** Environment lookup for custom providers not covered by pi's ambient probe. */
|
|
20
|
+
readEnv: (name: string) => string | undefined;
|
|
19
21
|
/**
|
|
20
22
|
* US-004: whether the global auth config points at an exec helper. Injectable
|
|
21
23
|
* so a test can pin the credential-source decision without writing
|
|
@@ -12,6 +12,8 @@ export function isProtocolStreamError(err) {
|
|
|
12
12
|
export const _adapterDeps = {
|
|
13
13
|
listStoredProviders,
|
|
14
14
|
anyAmbientCredential,
|
|
15
|
+
/** Environment lookup for custom providers not covered by pi's ambient probe. */
|
|
16
|
+
readEnv: (name) => process.env[name],
|
|
15
17
|
/**
|
|
16
18
|
* US-004: whether the global auth config points at an exec helper. Injectable
|
|
17
19
|
* so a test can pin the credential-source decision without writing
|
package/dist/native/models.js
CHANGED
|
@@ -103,6 +103,7 @@ export function toProviderOverrides(overrides) {
|
|
|
103
103
|
// `!== undefined` as a declaration and checks it against the protocol
|
|
104
104
|
// entries (nax#2019).
|
|
105
105
|
...(override.baseUrl !== undefined ? { baseUrl: override.baseUrl } : {}),
|
|
106
|
+
...(override.authEnv !== undefined ? { authEnv: override.authEnv } : {}),
|
|
106
107
|
// Copied, not aliased: nax-ai's client catalog assigns this reference
|
|
107
108
|
// straight onto ResolvedProvider.headers (providers/catalog.ts) without a
|
|
108
109
|
// copy of its own, so forwarding the live config object would let a later
|
|
@@ -117,6 +118,7 @@ export function toProviderOverrides(overrides) {
|
|
|
117
118
|
// Omitted when the override declares none: nax-ai then falls back to the
|
|
118
119
|
// template sibling's ceiling, which is the only honest value available.
|
|
119
120
|
...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
|
|
121
|
+
...(model.thinkingFormat !== undefined ? { thinkingFormat: model.thinkingFormat } : {}),
|
|
120
122
|
supportsTools: model.supportsTools,
|
|
121
123
|
thinkingLevels: model.thinkingLevels,
|
|
122
124
|
// nax#2191: forwarded verbatim. nax-ai's `assertOverrideModelRouting`
|
|
@@ -128,6 +128,11 @@ export interface BeforeRequestPatch {
|
|
|
128
128
|
* parameter, not a new concept.
|
|
129
129
|
*/
|
|
130
130
|
export interface CompleteCallOptions {
|
|
131
|
+
/**
|
|
132
|
+
* `false` asks for no thinking on this request; on a model that cannot
|
|
133
|
+
* disable thinking it gets the least thinking the model supports instead.
|
|
134
|
+
* `true` keeps the session's resolved level.
|
|
135
|
+
*/
|
|
131
136
|
readonly thinking?: boolean;
|
|
132
137
|
readonly temperature?: number;
|
|
133
138
|
}
|
|
@@ -128,6 +128,9 @@ export class NativeSessionAdapter {
|
|
|
128
128
|
return true;
|
|
129
129
|
if ((await _adapterDeps.listStoredProviders()).length > 0)
|
|
130
130
|
return true;
|
|
131
|
+
if (this.catalogOverrides.some((override) => override.authEnv !== undefined && !!_adapterDeps.readEnv(override.authEnv))) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
131
134
|
return await _adapterDeps.anyAmbientCredential();
|
|
132
135
|
}
|
|
133
136
|
catch {
|
|
@@ -271,6 +274,12 @@ export class NativeSessionAdapter {
|
|
|
271
274
|
messages: [...span, { role: "user", content: summaryPrompt(previousSummary) }],
|
|
272
275
|
sessionId,
|
|
273
276
|
signal,
|
|
277
|
+
// A summary restates the transcript; it does not need reasoning.
|
|
278
|
+
// "off", not an absent level: absent means the backend default,
|
|
279
|
+
// which on a model that cannot disable thinking is adaptive at
|
|
280
|
+
// effort high (#2686). The client clamps "off" to the least
|
|
281
|
+
// thinking such a model allows.
|
|
282
|
+
thinking: "off",
|
|
274
283
|
});
|
|
275
284
|
const summaryUsage = res.usage;
|
|
276
285
|
const { costUsd, resolvedRates } = priceCall(summaryUsage, rates);
|
|
@@ -299,11 +308,16 @@ export class NativeSessionAdapter {
|
|
|
299
308
|
const signal = AbortSignal.any(opts.signal !== undefined
|
|
300
309
|
? [opts.signal, controller.signal, turnController.signal, deadlineController.signal]
|
|
301
310
|
: [controller.signal, turnController.signal, deadlineController.signal]);
|
|
302
|
-
// The loop-event bag speaks in booleans: `false`
|
|
303
|
-
// the session's
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
|
|
311
|
+
// The loop-event bag speaks in booleans: `false` turns thinking off
|
|
312
|
+
// for this request, while `true` leaves the session's resolved level
|
|
313
|
+
// in place. nax-ai accepts a thinking LEVEL rather than a boolean, so
|
|
314
|
+
// normalize at this boundary. `false` must become "off", not an
|
|
315
|
+
// absent level: absent means the backend default, which on a model
|
|
316
|
+
// that cannot disable thinking is adaptive at effort high (#2686).
|
|
317
|
+
// nax-ai clamps "off" to the lowest level such a model supports, so
|
|
318
|
+
// there `false` still enables thinking, and a `temperature` set
|
|
319
|
+
// alongside it is dropped by the backend as for any thinking request.
|
|
320
|
+
const requestThinking = requestOptions?.thinking === false ? "off" : thinking;
|
|
307
321
|
try {
|
|
308
322
|
instructionInteraction.acknowledge();
|
|
309
323
|
const res = await billedOnFailure(rates, () => streamComplete(client, resolved, {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Shared session approval-memory policy, ported from the ACP server (nax#2553). */
|
|
2
|
+
export interface ApprovalMemoryRequest {
|
|
3
|
+
readonly tool: string;
|
|
4
|
+
readonly command?: string;
|
|
5
|
+
}
|
|
6
|
+
export type ApprovalMemoryDecision = "allow" | "deny";
|
|
7
|
+
/**
|
|
8
|
+
* Tool key, or execute tool plus command/subcommand. Unsafe, masked, wrapper and
|
|
9
|
+
* flag-second-word requests have no key. A key alone never authorizes a command:
|
|
10
|
+
* callers must also check approvalMemoryApplies against the remembered decision.
|
|
11
|
+
*/
|
|
12
|
+
export declare function approvalMemoryKey(request: ApprovalMemoryRequest): string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Whether a decision found under this request's key may answer without asking.
|
|
15
|
+
* Allows cover only simple commands with plain, non-flag words after the key;
|
|
16
|
+
* denies cover their matching key regardless. Does not validate key eligibility.
|
|
17
|
+
*/
|
|
18
|
+
export declare function approvalMemoryApplies(request: ApprovalMemoryRequest, decision: ApprovalMemoryDecision): boolean;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Keep aligned with the ACP display's execute tools, without depending on that transport. */
|
|
2
|
+
const APPROVAL_EXECUTE_TOOLS = new Set(["Bash", "RunCommand", "Git", "GitCommit"]);
|
|
3
|
+
/** Chaining, pipes, substitution, subshells, redirection and line breaks. */
|
|
4
|
+
const SHELL_METACHARACTERS = /[;&|`$()<>\n\r]/;
|
|
5
|
+
/** First words that run another command or arbitrary code. */
|
|
6
|
+
const WRAPPERS = new Set([
|
|
7
|
+
"sh",
|
|
8
|
+
"bash",
|
|
9
|
+
"zsh",
|
|
10
|
+
"dash",
|
|
11
|
+
"ksh",
|
|
12
|
+
"fish",
|
|
13
|
+
"env",
|
|
14
|
+
"xargs",
|
|
15
|
+
"sudo",
|
|
16
|
+
"doas",
|
|
17
|
+
"su",
|
|
18
|
+
"command",
|
|
19
|
+
"builtin",
|
|
20
|
+
"eval",
|
|
21
|
+
"exec",
|
|
22
|
+
"source",
|
|
23
|
+
".",
|
|
24
|
+
"nohup",
|
|
25
|
+
"time",
|
|
26
|
+
"timeout",
|
|
27
|
+
"nice",
|
|
28
|
+
"ionice",
|
|
29
|
+
"stdbuf",
|
|
30
|
+
"watch",
|
|
31
|
+
"find",
|
|
32
|
+
"node",
|
|
33
|
+
"bun",
|
|
34
|
+
"deno",
|
|
35
|
+
"npx",
|
|
36
|
+
"bunx",
|
|
37
|
+
"pnpx",
|
|
38
|
+
"python",
|
|
39
|
+
"python3",
|
|
40
|
+
"perl",
|
|
41
|
+
"ruby",
|
|
42
|
+
"php",
|
|
43
|
+
"uv",
|
|
44
|
+
"uvx",
|
|
45
|
+
]);
|
|
46
|
+
/** A plain command name or path: no quotes, escapes, globs or expansions the shell would rewrite. */
|
|
47
|
+
const PLAIN_WORD = /^[A-Za-z0-9._/-]+$/;
|
|
48
|
+
/** The masking prefix S3 puts in place of a secret in a shown command. */
|
|
49
|
+
const MASK_MARKER = "[REDACTED";
|
|
50
|
+
/**
|
|
51
|
+
* Tool key, or execute tool plus command/subcommand. Unsafe, masked, wrapper and
|
|
52
|
+
* flag-second-word requests have no key. A key alone never authorizes a command:
|
|
53
|
+
* callers must also check approvalMemoryApplies against the remembered decision.
|
|
54
|
+
*/
|
|
55
|
+
export function approvalMemoryKey(request) {
|
|
56
|
+
if (!APPROVAL_EXECUTE_TOOLS.has(request.tool))
|
|
57
|
+
return request.tool;
|
|
58
|
+
const command = request.command ?? "";
|
|
59
|
+
if (command.includes(MASK_MARKER))
|
|
60
|
+
return undefined;
|
|
61
|
+
const [first = "", second] = command.trim().split(/\s+/);
|
|
62
|
+
if (!PLAIN_WORD.test(first))
|
|
63
|
+
return undefined;
|
|
64
|
+
const name = first.slice(first.lastIndexOf("/") + 1).toLowerCase();
|
|
65
|
+
if (name === "" || WRAPPERS.has(name))
|
|
66
|
+
return undefined;
|
|
67
|
+
if (second === undefined)
|
|
68
|
+
return `${request.tool}:${first}`;
|
|
69
|
+
// A flag can change what the command does (`rm -f`, `git -C repo`): remembering the bare command would
|
|
70
|
+
// approve every later invocation of it, so a flag second word is never remembered (nax#2553).
|
|
71
|
+
if (second.startsWith("-"))
|
|
72
|
+
return undefined;
|
|
73
|
+
return PLAIN_WORD.test(second) ? `${request.tool}:${first} ${second}` : undefined;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Whether a decision found under this request's key may answer without asking.
|
|
77
|
+
* Allows cover only simple commands with plain, non-flag words after the key;
|
|
78
|
+
* denies cover their matching key regardless. Does not validate key eligibility.
|
|
79
|
+
*/
|
|
80
|
+
export function approvalMemoryApplies(request, decision) {
|
|
81
|
+
if (decision === "deny" || !APPROVAL_EXECUTE_TOOLS.has(request.tool))
|
|
82
|
+
return true;
|
|
83
|
+
const command = request.command ?? "";
|
|
84
|
+
if (SHELL_METACHARACTERS.test(command))
|
|
85
|
+
return false;
|
|
86
|
+
const rest = command.trim().split(/\s+/).slice(2);
|
|
87
|
+
return rest.every((word) => PLAIN_WORD.test(word) && !word.startsWith("-"));
|
|
88
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from "./approval-audit.ts";
|
|
2
|
+
export { type ApprovalMemoryDecision, type ApprovalMemoryRequest, approvalMemoryApplies, approvalMemoryKey, } from "./approval-memory.ts";
|
|
2
3
|
export * from "./approvals-link.ts";
|
|
3
4
|
export * from "./approvals-store.ts";
|
|
4
5
|
export * from "./approvals-taint.ts";
|
|
@@ -36,6 +36,12 @@ export interface AskRequest {
|
|
|
36
36
|
* hide shell syntax (review #9). The human link denies without prompting.
|
|
37
37
|
*/
|
|
38
38
|
readonly unshowable?: true;
|
|
39
|
+
/**
|
|
40
|
+
* US-004: the call wants to run OUTSIDE the OS sandbox (the sandbox is never
|
|
41
|
+
* turned off for the whole session). A host that keys on it can offer a
|
|
42
|
+
* distinct decision; one that ignores it shows the ordinary approval prompt.
|
|
43
|
+
*/
|
|
44
|
+
readonly unsandboxed?: true;
|
|
39
45
|
/** The permitted root -- where the shell actually starts (src/tools/bash.ts:165). */
|
|
40
46
|
readonly root?: string;
|
|
41
47
|
/** The verdict's original reason, NOT rewritten as `matched ask rule "..."`. */
|
|
@@ -67,6 +67,11 @@ function exitStatus(code, signal) {
|
|
|
67
67
|
return code;
|
|
68
68
|
return 128 + (signal !== null ? (osConstants.signals[signal] ?? 0) : 0);
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* `Readable.toWeb` is declared against `node:stream/web`'s `ReadableStream`, while
|
|
72
|
+
* `AgentSpawnResult` carries the global one. They are the same class at runtime and differ
|
|
73
|
+
* only in how their `getReader` overloads are written, so the bridge has to go via `unknown`.
|
|
74
|
+
*/
|
|
70
75
|
function toWeb(stream) {
|
|
71
76
|
return stream === null ? emptyStream() : Readable.toWeb(stream);
|
|
72
77
|
}
|
|
@@ -41,6 +41,7 @@ export declare const CLAUDE_SETTINGS_FILES: readonly string[];
|
|
|
41
41
|
* policy compile time, so a symlink created mid-session is still seen.
|
|
42
42
|
*
|
|
43
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.
|
|
44
|
+
* other file under `.claude/`, is an ordinary path a tool must still reach. The
|
|
45
|
+
* compare ignores case: `.Claude/settings.json` is the same file on macOS and Windows.
|
|
45
46
|
*/
|
|
46
47
|
export declare function claudeSettingsWriteRefusal(tool: string, rel: string, resolvedRoot: string): string | undefined;
|
|
@@ -53,15 +53,19 @@ const CLAUDE_SETTINGS_WRITE_TOOLS = new Set(["Write", "Edit", "Delete"]);
|
|
|
53
53
|
* policy compile time, so a symlink created mid-session is still seen.
|
|
54
54
|
*
|
|
55
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.
|
|
56
|
+
* other file under `.claude/`, is an ordinary path a tool must still reach. The
|
|
57
|
+
* compare ignores case: `.Claude/settings.json` is the same file on macOS and Windows.
|
|
57
58
|
*/
|
|
58
59
|
export function claudeSettingsWriteRefusal(tool, rel, resolvedRoot) {
|
|
59
60
|
if (!CLAUDE_SETTINGS_WRITE_TOOLS.has(tool))
|
|
60
61
|
return undefined;
|
|
61
|
-
|
|
62
|
+
// Case-folded for the same reason as the `.git/` ban: on a case-insensitive filesystem
|
|
63
|
+
// `.Claude/settings.json` is the settings file, under a spelling a literal compare misses.
|
|
64
|
+
const folded = rel.toLowerCase();
|
|
65
|
+
const entry = CLAUDE_SETTINGS_FILES.find((e) => e.toLowerCase() === folded || resolvedSettingsRel(resolvedRoot, e).toLowerCase() === folded);
|
|
62
66
|
if (entry === undefined)
|
|
63
67
|
return undefined;
|
|
64
|
-
const subject = entry ===
|
|
68
|
+
const subject = entry.toLowerCase() === folded
|
|
65
69
|
? `"${rel}" is a Claude Code settings file`
|
|
66
70
|
: `"${rel}" is the target of the Claude Code settings file "${entry}"`;
|
|
67
71
|
return (`${subject}: Claude loads allow rules, hooks and MCP servers from it for every ` +
|
package/dist/sandbox/defaults.js
CHANGED
package/dist/sandbox/launcher.js
CHANGED
|
@@ -66,6 +66,8 @@ async function runUnwrapped(req, sandbox, env) {
|
|
|
66
66
|
stripEnvVars: [...req.stripEnvVars],
|
|
67
67
|
...(overlay !== undefined ? { env: overlay } : {}),
|
|
68
68
|
...(req.signal !== undefined ? { signal: req.signal } : {}),
|
|
69
|
+
...(req.releaseDeadline !== undefined ? { releaseDeadline: req.releaseDeadline } : {}),
|
|
70
|
+
...(req.onOutput !== undefined ? { onOutput: req.onOutput } : {}),
|
|
69
71
|
});
|
|
70
72
|
return { ...result, executed: argv, sandbox };
|
|
71
73
|
}
|
|
@@ -102,6 +104,8 @@ async function runWrapped(req, backend, policy, env) {
|
|
|
102
104
|
stripEnvVars: [...req.stripEnvVars],
|
|
103
105
|
...(req.env !== undefined ? { env: req.env } : {}),
|
|
104
106
|
...(req.signal !== undefined ? { signal: req.signal } : {}),
|
|
107
|
+
...(req.releaseDeadline !== undefined ? { releaseDeadline: req.releaseDeadline } : {}),
|
|
108
|
+
...(req.onOutput !== undefined ? { onOutput: req.onOutput } : {}),
|
|
105
109
|
});
|
|
106
110
|
const denied = result.exitCode !== 0 && LIKELY_SANDBOX_DENIAL.test(result.stderr);
|
|
107
111
|
const violations = backend.annotate(commandId, result.stderr);
|
|
@@ -137,6 +141,9 @@ export function createCommandLauncher(opts) {
|
|
|
137
141
|
state,
|
|
138
142
|
async run(req) {
|
|
139
143
|
const tmpDir = env.tmpDir !== undefined && (await ensureTmpDir(env.tmpDir)) ? env.tmpDir : undefined;
|
|
144
|
+
if (req.unwrapped === true) {
|
|
145
|
+
return runUnwrapped(req, { backend: "none", wrapped: false, reason: "requested" }, { ...env, tmpDir });
|
|
146
|
+
}
|
|
140
147
|
if (state.kind === "disabled")
|
|
141
148
|
return runUnwrapped(req, { backend: "none", wrapped: false }, { ...env, tmpDir });
|
|
142
149
|
if (state.kind === "unavailable") {
|
package/dist/sandbox/types.d.ts
CHANGED
|
@@ -112,8 +112,14 @@ export interface LaunchRequest {
|
|
|
112
112
|
readonly stripEnvVars: readonly string[];
|
|
113
113
|
/** The CALLER's own overlay (Exec's Yarn no-scripts env). Never the backend's. */
|
|
114
114
|
readonly env?: Readonly<Record<string, string>>;
|
|
115
|
+
/** Explicitly run this command outside the sandbox without changing launcher state. */
|
|
116
|
+
readonly unwrapped?: true;
|
|
115
117
|
/** Aborts the run: forwarded to runArgv, which SIGKILLs the process group. US-001. */
|
|
116
118
|
readonly signal?: AbortSignal;
|
|
119
|
+
/** Releases the deadline without cancelling the run. Forwarded to runArgv. */
|
|
120
|
+
readonly releaseDeadline?: AbortSignal;
|
|
121
|
+
/** Best-effort per-chunk observer, forwarded before bounded output capture. */
|
|
122
|
+
readonly onOutput?: (stream: "stdout" | "stderr", chunk: Uint8Array) => void;
|
|
117
123
|
}
|
|
118
124
|
export interface LaunchResult extends ArgvExecResult {
|
|
119
125
|
/** The logical argv (what the tool asked to run), not the sandbox wrapper argv. */
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ClaimedTurn, LiveTurn, TurnRunContext } from "./agent-session-turn.ts";
|
|
2
|
+
import type { CommandRunResult, SessionEvent } from "./agent-session-types.ts";
|
|
3
|
+
import type { OpenedBackend } from "./session-backend.ts";
|
|
4
|
+
interface CommandHooks {
|
|
5
|
+
onStart(live: LiveTurn): void;
|
|
6
|
+
onSettle(): void;
|
|
7
|
+
}
|
|
8
|
+
export interface ClaimedCommand extends Pick<ClaimedTurn, "turnId" | "settled" | "cancel"> {
|
|
9
|
+
readonly result: Promise<CommandRunResult>;
|
|
10
|
+
}
|
|
11
|
+
/** Claim synchronously, but invoke callbacks only after the facade installs the claim. */
|
|
12
|
+
export declare function claimCommand(options: {
|
|
13
|
+
ctx: TurnRunContext;
|
|
14
|
+
run: NonNullable<OpenedBackend["runCommand"]>;
|
|
15
|
+
command: string;
|
|
16
|
+
onEvent(event: SessionEvent): void;
|
|
17
|
+
hooks: CommandHooks;
|
|
18
|
+
}): ClaimedCommand;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { NaxError } from "#src/infra/nax-error";
|
|
2
|
+
import { displayToolInput, toolResultPreview } from "#src/native/session/turn-event-emitter";
|
|
3
|
+
import { _agentSessionDeps } from "./agent-session-deps.js";
|
|
4
|
+
/** Claim synchronously, but invoke callbacks only after the facade installs the claim. */
|
|
5
|
+
export function claimCommand(options) {
|
|
6
|
+
const { ctx, run, command, onEvent, hooks } = options;
|
|
7
|
+
const turnId = _agentSessionDeps.randomUUID();
|
|
8
|
+
const callId = _agentSessionDeps.randomUUID();
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
const emit = (body) => onEvent({
|
|
11
|
+
sessionId: ctx.sessionId,
|
|
12
|
+
turnId,
|
|
13
|
+
at: new Date(_agentSessionDeps.now()).toISOString(),
|
|
14
|
+
metadata: ctx.metadata,
|
|
15
|
+
...body,
|
|
16
|
+
});
|
|
17
|
+
const abort = (code, reason) => controller.abort(new NaxError(reason, code, { stage: "agent-session" }));
|
|
18
|
+
const result = Promise.resolve().then(async () => {
|
|
19
|
+
const timer = _agentSessionDeps.setTimeout(() => abort("AGENT_SESSION_TURN_TIMEOUT", "turn deadline"), ctx.turnTimeoutSeconds * 1000);
|
|
20
|
+
try {
|
|
21
|
+
hooks.onStart({ turnId, signal: controller.signal, emit });
|
|
22
|
+
emit({ type: "tool_call", name: "Bash", input: displayToolInput({ command }), callId });
|
|
23
|
+
const outcome = await run({ command, callId, signal: controller.signal });
|
|
24
|
+
emit({ type: "tool_result", callId, isError: outcome.isError, preview: toolResultPreview(outcome.content) });
|
|
25
|
+
return outcome;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
_agentSessionDeps.clearTimeout(timer);
|
|
29
|
+
hooks.onSettle();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
turnId,
|
|
34
|
+
result,
|
|
35
|
+
settled: result.then(() => { }, () => { }),
|
|
36
|
+
cancel: (reason) => abort("AGENT_SESSION_CANCELLED", reason),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* @nathapp/nax-agent-acp backend (S4) reuses these types unchanged.
|
|
5
5
|
*/
|
|
6
6
|
import type { CommandInterceptor } from "#src/command-interceptor/index";
|
|
7
|
+
import type { CommandShadow } from "#src/command-safety/types";
|
|
7
8
|
import type { TokenUsage } from "#src/cost/standard-types";
|
|
8
9
|
import type { TranscriptStore } from "#src/native/session/transcript-types";
|
|
10
|
+
import type { BackgroundCommandPort } from "#src/tools/bash-background";
|
|
9
11
|
import type { ProtectedPathsPolicy } from "#src/tools/protected-paths";
|
|
10
12
|
import type { BackendInfo, SessionBackend } from "./session-backend.ts";
|
|
11
13
|
import type { JSONSchema } from "./tool-descriptor.ts";
|
|
@@ -43,6 +45,9 @@ export interface AgentSessionHostPorts {
|
|
|
43
45
|
/** Paths the sandbox and the read tools deny. Default: the configured credentials directory, if any. */
|
|
44
46
|
readonly protectedPaths?: ProtectedPathsPolicy;
|
|
45
47
|
readonly commandInterceptor?: CommandInterceptor;
|
|
48
|
+
readonly commandShadow?: CommandShadow;
|
|
49
|
+
/** Opt in to host-owned background Bash commands. */
|
|
50
|
+
readonly backgroundCommands?: BackgroundCommandPort;
|
|
46
51
|
}
|
|
47
52
|
export interface CreateAgentSessionOptions {
|
|
48
53
|
/** The driver: `nativeBackend({ model })`, or an ACP backend. Native-only options live on the backend. */
|
|
@@ -117,6 +122,8 @@ export type SessionEventBody = {
|
|
|
117
122
|
readonly command?: string;
|
|
118
123
|
readonly reason: string;
|
|
119
124
|
readonly expiresAt: string;
|
|
125
|
+
/** US-004: the call wants to run outside the OS sandbox. Absent on every ordinary ask. */
|
|
126
|
+
readonly unsandboxed?: true;
|
|
120
127
|
/** `false`: decided already (by the profile); informational. Absent: a person's answer is awaited. */
|
|
121
128
|
readonly answerable?: false;
|
|
122
129
|
} | {
|
|
@@ -167,7 +174,26 @@ export type AnswerReply = {
|
|
|
167
174
|
readonly text: string;
|
|
168
175
|
};
|
|
169
176
|
export type AnswerStatus = "accepted" | "expired" | "cancelled" | "unknown";
|
|
177
|
+
export interface CommandRunResult {
|
|
178
|
+
/** The Bash tool's result text as the model would see it (capped by the tool runtime). */
|
|
179
|
+
readonly content: string;
|
|
180
|
+
readonly isError: boolean;
|
|
181
|
+
}
|
|
182
|
+
/** What `AgentSession.configure` changes on a live session; at least one field, never an empty string. */
|
|
183
|
+
export interface SessionConfigureInput {
|
|
184
|
+
readonly model?: string;
|
|
185
|
+
readonly effort?: string;
|
|
186
|
+
}
|
|
170
187
|
export interface AgentSession {
|
|
188
|
+
/**
|
|
189
|
+
* Sets the model and/or effort on the live session without a new process. Refuses, in order:
|
|
190
|
+
* `AGENT_SESSION_CLOSED`, `AGENT_SESSION_BUSY` (a turn, a command or another configure is in
|
|
191
|
+
* flight), `AGENT_SESSION_INVALID_OPTIONS` (neither field, or an empty string), then
|
|
192
|
+
* `AGENT_SESSION_CAPABILITY_UNSUPPORTED` (the backend cannot). The facade always provides this method.
|
|
193
|
+
*/
|
|
194
|
+
configure?(input: SessionConfigureInput): Promise<void>;
|
|
195
|
+
/** Run through the session's Bash tool without a model turn. The facade always provides this method. */
|
|
196
|
+
runCommand?(command: string, onEvent: (event: SessionEvent) => void): Promise<CommandRunResult>;
|
|
171
197
|
readonly id: string;
|
|
172
198
|
/** The backend that opened this session (`kind` and its capability summary). */
|
|
173
199
|
readonly backend: BackendInfo;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { claimCommand } from "./agent-session-command.js";
|
|
1
2
|
import { _agentSessionDeps } from "./agent-session-deps.js";
|
|
2
3
|
import { AgentSessionError } from "./agent-session-errors.js";
|
|
3
4
|
import { resolveAgentSessionOptions } from "./agent-session-options.js";
|
|
@@ -7,11 +8,24 @@ import { createPendingAskTable } from "./pending-asks.js";
|
|
|
7
8
|
import { createSessionAskPort } from "./session-ask-port.js";
|
|
8
9
|
/** The handler's turn signal between turns: never aborts. */
|
|
9
10
|
const IDLE_SIGNAL = new AbortController().signal;
|
|
11
|
+
/**
|
|
12
|
+
* The sandbox state as the embedder sees it: a frozen COPY, down to the
|
|
13
|
+
* `network` list an `available` state carries. Copying keeps the backend's own
|
|
14
|
+
* record out of the embedder's reach; freezing keeps the embedder from editing
|
|
15
|
+
* the record of what its commands ran under (US-001).
|
|
16
|
+
*/
|
|
17
|
+
function frozenSandbox(state) {
|
|
18
|
+
if (state.kind !== "available" || typeof state.network === "string")
|
|
19
|
+
return Object.freeze({ ...state });
|
|
20
|
+
return Object.freeze({ ...state, network: Object.freeze([...state.network]) });
|
|
21
|
+
}
|
|
10
22
|
class FacadeAgentSession {
|
|
11
23
|
parts;
|
|
12
24
|
active;
|
|
13
25
|
last;
|
|
14
26
|
closing;
|
|
27
|
+
/** True while the backend's configure is pending: send, runCommand and configure refuse BUSY. */
|
|
28
|
+
configuring = false;
|
|
15
29
|
constructor(parts, last) {
|
|
16
30
|
this.parts = parts;
|
|
17
31
|
this.last = last;
|
|
@@ -21,7 +35,13 @@ class FacadeAgentSession {
|
|
|
21
35
|
}
|
|
22
36
|
get backend() {
|
|
23
37
|
const info = this.parts.opened.info;
|
|
24
|
-
|
|
38
|
+
// US-001: the sandbox state rides through the facade frozen, so an embedder
|
|
39
|
+
// cannot edit the record of what its commands ran under.
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
kind: info.kind,
|
|
42
|
+
capabilities: Object.freeze({ ...info.capabilities }),
|
|
43
|
+
...(info.sandbox !== undefined ? { sandbox: frozenSandbox(info.sandbox) } : {}),
|
|
44
|
+
});
|
|
25
45
|
}
|
|
26
46
|
get lastTurn() {
|
|
27
47
|
return this.last;
|
|
@@ -35,6 +55,11 @@ class FacadeAgentSession {
|
|
|
35
55
|
sessionId: this.id,
|
|
36
56
|
});
|
|
37
57
|
}
|
|
58
|
+
if (this.configuring) {
|
|
59
|
+
throw new AgentSessionError(`Session "${this.id}" is being configured`, "AGENT_SESSION_BUSY", {
|
|
60
|
+
sessionId: this.id,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
38
63
|
const turn = claimTurn(this.parts.ctx, message, {
|
|
39
64
|
onStart: (live) => {
|
|
40
65
|
this.parts.slot.turn = live;
|
|
@@ -49,6 +74,66 @@ class FacadeAgentSession {
|
|
|
49
74
|
this.active = turn;
|
|
50
75
|
return turn.iterable;
|
|
51
76
|
}
|
|
77
|
+
async configure(input) {
|
|
78
|
+
const refusal = this.configureRefusal(input);
|
|
79
|
+
if (refusal !== undefined)
|
|
80
|
+
throw refusal;
|
|
81
|
+
const configure = this.parts.opened.configure;
|
|
82
|
+
if (configure === undefined)
|
|
83
|
+
throw this.refusal("AGENT_SESSION_CAPABILITY_UNSUPPORTED", { capability: "configure" });
|
|
84
|
+
this.configuring = true;
|
|
85
|
+
try {
|
|
86
|
+
await configure.call(this.parts.opened, input);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
this.configuring = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** The refusals that precede the backend call, in the documented order; the backend check is last and separate. */
|
|
93
|
+
configureRefusal(input) {
|
|
94
|
+
if (this.closing !== undefined)
|
|
95
|
+
return this.refusal("AGENT_SESSION_CLOSED");
|
|
96
|
+
if (this.active !== undefined || this.configuring)
|
|
97
|
+
return this.refusal("AGENT_SESSION_BUSY");
|
|
98
|
+
const { model, effort } = input;
|
|
99
|
+
if ((model === undefined && effort === undefined) || model === "" || effort === "") {
|
|
100
|
+
return this.refusal("AGENT_SESSION_INVALID_OPTIONS", { path: "configure" });
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
refusal(code, context = {}) {
|
|
105
|
+
return new AgentSessionError(`Session "${this.id}" cannot be configured`, code, { sessionId: this.id, ...context });
|
|
106
|
+
}
|
|
107
|
+
async runCommand(command, onEvent) {
|
|
108
|
+
const code = this.closing !== undefined
|
|
109
|
+
? "AGENT_SESSION_CLOSED"
|
|
110
|
+
: this.active !== undefined || this.configuring
|
|
111
|
+
? "AGENT_SESSION_BUSY"
|
|
112
|
+
: this.parts.opened.runCommand === undefined
|
|
113
|
+
? "AGENT_SESSION_CAPABILITY_UNSUPPORTED"
|
|
114
|
+
: undefined;
|
|
115
|
+
const run = this.parts.opened.runCommand;
|
|
116
|
+
if (code !== undefined || run === undefined) {
|
|
117
|
+
throw new AgentSessionError(`Session "${this.id}" cannot run a command`, code ?? "AGENT_SESSION_CAPABILITY_UNSUPPORTED", { sessionId: this.id });
|
|
118
|
+
}
|
|
119
|
+
const claim = claimCommand({
|
|
120
|
+
ctx: this.parts.ctx,
|
|
121
|
+
command,
|
|
122
|
+
onEvent,
|
|
123
|
+
run: (request) => run.call(this.parts.opened, request),
|
|
124
|
+
hooks: {
|
|
125
|
+
onStart: (live) => {
|
|
126
|
+
this.parts.slot.turn = live;
|
|
127
|
+
},
|
|
128
|
+
onSettle: () => {
|
|
129
|
+
this.parts.slot.turn = undefined;
|
|
130
|
+
this.active = undefined;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
this.active = claim;
|
|
135
|
+
return claim.result;
|
|
136
|
+
}
|
|
52
137
|
answer(requestId, reply) {
|
|
53
138
|
return this.parts.table.answer(requestId, reply);
|
|
54
139
|
}
|