@nathapp/nax-agent 0.1.0 → 0.2.0
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 +22 -0
- package/README.md +47 -2
- package/dist/coding-tools/coding-tool-sandbox.d.ts +3 -0
- package/dist/coding-tools/coding-tool-sandbox.js +14 -3
- package/dist/coding-tools/coding-tool-support.d.ts +3 -0
- package/dist/coding-tools/coding-tool-support.js +2 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +4 -1
- package/dist/internal/redact.d.ts +23 -0
- package/dist/internal/redact.js +60 -0
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +8 -0
- package/dist/native/adapter-deps.d.ts +3 -3
- package/dist/native/adapter-deps.js +3 -3
- package/dist/native/client.d.ts +7 -1
- package/dist/native/client.js +1 -1
- package/dist/native/credentials/change-guard.d.ts +8 -1
- package/dist/native/credentials/change-guard.js +1 -1
- package/dist/native/credentials/fingerprint.d.ts +4 -1
- package/dist/native/credentials/fingerprint.js +6 -3
- package/dist/native/credentials/session-source.d.ts +12 -0
- package/dist/native/credentials/session-source.js +33 -0
- package/dist/native/index.d.ts +3 -1
- package/dist/native/index.js +2 -1
- package/dist/native/session/loop-events/types.d.ts +1 -1
- package/dist/native/session/loop-handlers.d.ts +3 -0
- package/dist/native/session/loop-handlers.js +1 -1
- package/dist/native/session/memory-transcript-store.d.ts +12 -0
- package/dist/native/session/memory-transcript-store.js +36 -0
- package/dist/native/session/session.d.ts +138 -96
- package/dist/native/session/session.js +107 -127
- package/dist/native/session/transcript-identity.d.ts +39 -0
- package/dist/native/session/transcript-identity.js +81 -0
- package/dist/native/session/transcript-store.d.ts +22 -21
- package/dist/native/session/transcript-store.js +59 -68
- package/dist/native/session/transcript-types.d.ts +38 -0
- package/dist/native/session/transcript-types.js +7 -0
- package/dist/native/session/truncation-handler.d.ts +4 -3
- package/dist/native/session/truncation-handler.js +7 -10
- package/dist/native/session/turn-complete-step.d.ts +4 -1
- package/dist/native/session/turn-complete-step.js +11 -4
- package/dist/native/session/turn-event-emitter.d.ts +48 -0
- package/dist/native/session/turn-event-emitter.js +134 -0
- package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
- package/dist/native/session/turn-loop-round-trip.js +8 -4
- package/dist/native/session/turn-loop.js +27 -13
- package/dist/native/session/turn-result.d.ts +1 -1
- package/dist/native/session/turn-result.js +1 -1
- package/dist/native/session/turn-tool-batch.d.ts +3 -0
- package/dist/native/session/turn-tool-batch.js +14 -6
- package/dist/native/session/turn-types.d.ts +17 -1
- package/dist/native/session-adapter.d.ts +22 -4
- package/dist/native/session-adapter.js +79 -35
- package/dist/native/stream-complete.d.ts +17 -0
- package/dist/native/stream-complete.js +40 -0
- package/dist/permissions/approvals-link.js +5 -4
- package/dist/sandbox/policy-builder.d.ts +6 -1
- package/dist/sandbox/policy-builder.js +18 -14
- package/dist/session/agent-session-deps.d.ts +12 -0
- package/dist/session/agent-session-deps.js +28 -0
- package/dist/session/agent-session-errors.d.ts +11 -0
- package/dist/session/agent-session-errors.js +12 -0
- package/dist/session/agent-session-options.d.ts +16 -0
- package/dist/session/agent-session-options.js +137 -0
- package/dist/session/agent-session-resume.d.ts +12 -0
- package/dist/session/agent-session-resume.js +47 -0
- package/dist/session/agent-session-turn.d.ts +45 -0
- package/dist/session/agent-session-turn.js +179 -0
- package/dist/session/agent-session-types.d.ts +182 -0
- package/dist/session/agent-session-types.js +1 -0
- package/dist/session/agent-session.d.ts +8 -0
- package/dist/session/agent-session.js +223 -0
- package/dist/session/pending-asks.d.ts +24 -0
- package/dist/session/pending-asks.js +77 -0
- package/dist/session/session-ask-link.d.ts +26 -0
- package/dist/session/session-ask-link.js +52 -0
- package/dist/session/session-event-channel.d.ts +17 -0
- package/dist/session/session-event-channel.js +132 -0
- package/dist/session/session-interaction.d.ts +20 -0
- package/dist/session/session-interaction.js +164 -0
- package/dist/session/session-tool-support.d.ts +48 -0
- package/dist/session/session-tool-support.js +101 -0
- package/dist/session/session-types.d.ts +31 -2
- package/dist/session/turn-event.d.ts +61 -0
- package/dist/session/turn-event.js +1 -0
- package/dist/tools/bash-cwd.d.ts +3 -3
- package/dist/tools/credential-read-deny.d.ts +22 -0
- package/dist/tools/credential-read-deny.js +56 -0
- package/dist/tools/glob.js +9 -2
- package/dist/tools/grep.js +55 -26
- package/dist/tools/owned-paths.d.ts +43 -0
- package/dist/tools/owned-paths.js +17 -0
- package/dist/tools/package-managers.d.ts +3 -0
- package/dist/tools/package-managers.js +7 -3
- package/dist/tools/policy-bash-raw.d.ts +3 -0
- package/dist/tools/policy-bash-raw.js +49 -62
- package/dist/tools/policy-command-branch.d.ts +3 -0
- package/dist/tools/policy-command-branch.js +2 -1
- package/dist/tools/policy-paths-branch.d.ts +14 -8
- package/dist/tools/policy-paths-branch.js +20 -19
- package/dist/tools/policy.d.ts +10 -7
- package/dist/tools/policy.js +9 -7
- package/dist/tools/protected-paths.d.ts +6 -6
- package/dist/tools/read.js +38 -20
- package/dist/tools/registry.d.ts +3 -0
- package/dist/tools/run-command-exec.js +1 -0
- package/dist/tools/runtime.d.ts +3 -0
- package/dist/tools/runtime.js +1 -0
- package/dist/tools/scratchpad.js +2 -1
- package/dist/tools/types.d.ts +2 -2
- package/package.json +2 -2
- package/dist/tools/nax-owned-writes.d.ts +0 -135
- package/dist/tools/nax-owned-writes.js +0 -259
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The facade's InteractionHandler (spec 5.1, 4.3, 6.1). Routes each loop
|
|
3
|
+
* request:
|
|
4
|
+
* - question -> the pending-ask table (null on timeout or cancel, which the
|
|
5
|
+
* loop answers with its no-operator text);
|
|
6
|
+
* - an embedder tool -> its own run, after an approval when it is "always";
|
|
7
|
+
* - a built-in coding tool -> the session's CodingToolRuntime, with the call
|
|
8
|
+
* id in the current-call slot so a policy ask can name it.
|
|
9
|
+
* A failed tool is reported by throwing: the tool batch turns a handler throw
|
|
10
|
+
* into an isError result whose content is the thrown message.
|
|
11
|
+
*/
|
|
12
|
+
import { NaxError } from "#src/infra/nax-error";
|
|
13
|
+
import { capStrings, redactSecrets } from "#src/internal/redact";
|
|
14
|
+
import { askDenyReason } from "#src/tools/ask-request";
|
|
15
|
+
import { cutToByteCap } from "#src/tools/truncate";
|
|
16
|
+
import { askPerson } from "./session-ask-link.js";
|
|
17
|
+
/** Byte cap on an embedder approval's default summary. */
|
|
18
|
+
export const EMBEDDER_SUMMARY_BYTES = 1024;
|
|
19
|
+
/**
|
|
20
|
+
* Redaction scans at most this many bytes of an embedder tool input; the
|
|
21
|
+
* summary keeps far fewer. Same 16x pattern as `turn-event-emitter.ts`'s
|
|
22
|
+
* `REDACTION_SCAN_BYTES` so the bound is comparable to a tool-call input.
|
|
23
|
+
*/
|
|
24
|
+
const EMBEDDER_SCAN_BYTES = EMBEDDER_SUMMARY_BYTES * 16;
|
|
25
|
+
/** What the model sees of an embedder tool. The facade's handler runs it; this run is never reached. */
|
|
26
|
+
export function embedderToolDescriptor(tool) {
|
|
27
|
+
return {
|
|
28
|
+
name: tool.name,
|
|
29
|
+
description: tool.description,
|
|
30
|
+
inputSchema: tool.inputSchema,
|
|
31
|
+
scope: { pathFields: [] },
|
|
32
|
+
async run() {
|
|
33
|
+
return { content: `"${tool.name}" runs through the agent session, not the coding-tool runtime.`, isError: true };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function defaultSummary(input) {
|
|
38
|
+
let json;
|
|
39
|
+
try {
|
|
40
|
+
// Cap before redactSecrets walks: a multi-MB tool input must not run
|
|
41
|
+
// every SECRET_VALUE_PATTERNS regex (PEM block up to 64KB per match)
|
|
42
|
+
// over megabytes. The post-serialize cutToByteCap is the display cap;
|
|
43
|
+
// this is the scan cap.
|
|
44
|
+
json = JSON.stringify(redactSecrets(capStrings(input, EMBEDDER_SCAN_BYTES))) ?? "null";
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return "[input not serializable]";
|
|
48
|
+
}
|
|
49
|
+
return cutToByteCap(json, EMBEDDER_SUMMARY_BYTES);
|
|
50
|
+
}
|
|
51
|
+
function toolError(message, tool) {
|
|
52
|
+
return new NaxError(message, "AGENT_SESSION_TOOL_ERROR", { stage: "agent-session", tool });
|
|
53
|
+
}
|
|
54
|
+
function summaryFor(tool, input) {
|
|
55
|
+
if (tool.describe === undefined)
|
|
56
|
+
return defaultSummary(input);
|
|
57
|
+
try {
|
|
58
|
+
// Same bound as defaultSummary: cap first, then redact, then display-cut.
|
|
59
|
+
// capStrings preserves the string's type at runtime; the cast is the
|
|
60
|
+
// single point where the generic is recovered for cutToByteCap.
|
|
61
|
+
const described = redactSecrets(capStrings(String(tool.describe(input)), EMBEDDER_SCAN_BYTES));
|
|
62
|
+
return cutToByteCap(described, EMBEDDER_SUMMARY_BYTES);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return defaultSummary(input);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function runSafely(tool, input, ctx) {
|
|
69
|
+
return Promise.resolve()
|
|
70
|
+
.then(() => tool.run(input, ctx))
|
|
71
|
+
.catch((err) => {
|
|
72
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
73
|
+
return { content: `Tool "${tool.name}" failed: ${cause}`, isError: true };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Runs the tool, but answers at once when the turn aborts: the batch awaits
|
|
78
|
+
* this handler with no abort race of its own, so a run that ignores its signal
|
|
79
|
+
* would otherwise hang the turn. The abandoned run's late settlement is ignored.
|
|
80
|
+
*/
|
|
81
|
+
async function invoke(tool, input, ctx) {
|
|
82
|
+
const abandoned = {
|
|
83
|
+
content: `Tool "${tool.name}" was abandoned: the turn ended.`,
|
|
84
|
+
isError: true,
|
|
85
|
+
};
|
|
86
|
+
if (ctx.signal.aborted)
|
|
87
|
+
return abandoned;
|
|
88
|
+
let onAbort = () => { };
|
|
89
|
+
const aborted = new Promise((resolve) => {
|
|
90
|
+
onAbort = () => resolve(abandoned);
|
|
91
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
return await Promise.race([runSafely(tool, input, ctx), aborted]);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function runEmbedderTool(deps, tool, request) {
|
|
101
|
+
const toolCallId = request.toolCallId ?? "";
|
|
102
|
+
const signal = request.signal ?? deps.turnSignal();
|
|
103
|
+
const input = request.input ?? {};
|
|
104
|
+
if (tool.approval === "always") {
|
|
105
|
+
const ask = {
|
|
106
|
+
tool: tool.name,
|
|
107
|
+
summary: summaryFor(tool, input),
|
|
108
|
+
reason: `"${tool.name}" asks before every run`,
|
|
109
|
+
callId: toolCallId,
|
|
110
|
+
};
|
|
111
|
+
const outcome = await askPerson(deps.asks, ask, signal);
|
|
112
|
+
if (outcome.decision !== "allow") {
|
|
113
|
+
const reason = askDenyReason(outcome.decidedBy);
|
|
114
|
+
return { answer: `Denied: ${reason}`, denied: { reason, breach: false } };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const result = await invoke(tool, input, { sessionId: deps.sessionId, toolCallId, signal });
|
|
118
|
+
if (result.isError === true)
|
|
119
|
+
throw toolError(result.content, tool.name);
|
|
120
|
+
return { answer: result.content };
|
|
121
|
+
}
|
|
122
|
+
function toolCallContext(request) {
|
|
123
|
+
return {
|
|
124
|
+
...(request.turnId !== undefined ? { turnId: request.turnId } : {}),
|
|
125
|
+
...(request.roundTrips !== undefined ? { roundTrips: request.roundTrips } : {}),
|
|
126
|
+
...(request.toolCallId !== undefined ? { toolCallId: request.toolCallId } : {}),
|
|
127
|
+
...(request.deferModelTruncation !== undefined ? { deferModelTruncation: request.deferModelTruncation } : {}),
|
|
128
|
+
...(request.signal !== undefined ? { signal: request.signal } : {}),
|
|
129
|
+
...(request.onWaiting !== undefined ? { onWaiting: request.onWaiting } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
async function runCodingTool(deps, request) {
|
|
133
|
+
deps.setCurrentCallId(request.toolCallId);
|
|
134
|
+
try {
|
|
135
|
+
const outcome = await deps.runtime.callTool(request.name, request.input ?? {}, toolCallContext(request));
|
|
136
|
+
if (outcome.kind === "denied") {
|
|
137
|
+
return { answer: `Denied: ${outcome.reason}`, denied: { reason: outcome.reason, breach: outcome.breach } };
|
|
138
|
+
}
|
|
139
|
+
if (outcome.kind === "error")
|
|
140
|
+
throw toolError(outcome.content, request.name);
|
|
141
|
+
return { answer: outcome.content };
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
deps.setCurrentCallId(undefined);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function answerQuestion(deps, text) {
|
|
148
|
+
const { requestId, expiresAt, settled } = deps.asks.table.issue("question", deps.turnSignal());
|
|
149
|
+
deps.asks.emit({ type: "question", requestId, text, expiresAt });
|
|
150
|
+
const settlement = await settled;
|
|
151
|
+
return settlement.by === "human" && "text" in settlement.reply ? { answer: settlement.reply.text } : null;
|
|
152
|
+
}
|
|
153
|
+
export function createSessionInteractionHandler(deps) {
|
|
154
|
+
return {
|
|
155
|
+
async onInteraction(request) {
|
|
156
|
+
if (request.kind === "question")
|
|
157
|
+
return answerQuestion(deps, request.text);
|
|
158
|
+
if (request.kind === "context-tool")
|
|
159
|
+
throw toolError(`Unknown tool "${request.name}"`, request.name);
|
|
160
|
+
const tool = deps.embedderTools.get(request.name);
|
|
161
|
+
return tool !== undefined ? runEmbedderTool(deps, tool, request) : runCodingTool(deps, request);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type CodingToolSupport } from "#src/coding-tools/coding-tool-support";
|
|
2
|
+
import type { CommandInterceptor } from "#src/command-interceptor/index";
|
|
3
|
+
import type { BashApprovalMode } from "#src/config/bash-approval";
|
|
4
|
+
import type { AskResolver } from "#src/permissions/index";
|
|
5
|
+
import type { CommandLauncher } from "#src/sandbox/index";
|
|
6
|
+
import type { ProtectedPathsPolicy } from "#src/tools/protected-paths";
|
|
7
|
+
import type { CodingToolName, ToolGrant } from "#src/tools/types";
|
|
8
|
+
import type { AgentSessionProfile } from "./agent-session-types.ts";
|
|
9
|
+
/** The pipeline stage the facade's tool calls and asks carry. */
|
|
10
|
+
export declare const SESSION_STAGE = "session";
|
|
11
|
+
export declare function declaredToolsFor(profile: AgentSessionProfile, protectedPaths: ProtectedPathsPolicy): readonly CodingToolName[];
|
|
12
|
+
export declare function grantsFor(declared: readonly CodingToolName[]): readonly ToolGrant[];
|
|
13
|
+
/**
|
|
14
|
+
* Under "gated", every Bash command is put to the person: an unconditional ask
|
|
15
|
+
* rule on top of the unconditional grant (ask rules are evaluated after
|
|
16
|
+
* grants). "raw" and "escalate" add none.
|
|
17
|
+
*/
|
|
18
|
+
export declare function askRulesFor(declared: readonly CodingToolName[], bashApproval: BashApprovalMode): readonly ToolGrant[];
|
|
19
|
+
/**
|
|
20
|
+
* The embedder default (spec 6.2). A session with its own credentials source
|
|
21
|
+
* (memory or exec) has no credentials directory on disk; one that falls back
|
|
22
|
+
* to the configureCredentials slot protects that slot's directory.
|
|
23
|
+
*/
|
|
24
|
+
export declare function defaultProtectedPaths(ownCredentials: boolean): ProtectedPathsPolicy;
|
|
25
|
+
export interface SessionLauncherArgs {
|
|
26
|
+
readonly profile: AgentSessionProfile;
|
|
27
|
+
readonly root: string;
|
|
28
|
+
readonly protectedPaths: ProtectedPathsPolicy;
|
|
29
|
+
readonly bashApproval: BashApprovalMode;
|
|
30
|
+
readonly allowUnsandboxed: boolean;
|
|
31
|
+
}
|
|
32
|
+
/** The sandbox floor (spec 6.3): "full" needs a usable sandbox unless gated + allowUnsandboxed. */
|
|
33
|
+
export declare function resolveSessionLauncher(args: SessionLauncherArgs): Promise<CommandLauncher | undefined>;
|
|
34
|
+
export interface SessionToolSupportArgs {
|
|
35
|
+
readonly profile: AgentSessionProfile;
|
|
36
|
+
readonly root: string;
|
|
37
|
+
readonly sessionName: string;
|
|
38
|
+
readonly protectedPaths: ProtectedPathsPolicy;
|
|
39
|
+
readonly bashApproval: BashApprovalMode;
|
|
40
|
+
readonly launcher: CommandLauncher | undefined;
|
|
41
|
+
readonly askResolver: AskResolver;
|
|
42
|
+
readonly interceptor: CommandInterceptor | undefined;
|
|
43
|
+
}
|
|
44
|
+
export interface SessionToolSupport {
|
|
45
|
+
readonly support: CodingToolSupport;
|
|
46
|
+
readonly grants: readonly ToolGrant[];
|
|
47
|
+
}
|
|
48
|
+
export declare function buildSessionToolSupport(args: SessionToolSupportArgs): SessionToolSupport;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile -> coding tools (spec 4.5, 6.2, 6.3). A profile is a capability
|
|
3
|
+
* statement; this module turns it into the declared tools and grants that
|
|
4
|
+
* buildCodingToolSupport takes, picks the embedder default protected-paths
|
|
5
|
+
* policy, and enforces the sandbox floor for "full".
|
|
6
|
+
*/
|
|
7
|
+
import { resolveSessionSandbox } from "#src/coding-tools/coding-tool-sandbox";
|
|
8
|
+
import { buildCodingToolSupport } from "#src/coding-tools/coding-tool-support";
|
|
9
|
+
import { UNIVERSAL_CODING_TOOLS } from "#src/coding-tools/universal-coding-tools";
|
|
10
|
+
import { DEFAULT_SANDBOX_CONFIG } from "#src/config/schemas-sandbox";
|
|
11
|
+
import { credentialsConfig } from "#src/infra/credentials-config";
|
|
12
|
+
import { EMPTY_OWNED_PATHS_POLICY } from "#src/tools/owned-paths";
|
|
13
|
+
import { AgentSessionError } from "./agent-session-errors.js";
|
|
14
|
+
/** The pipeline stage the facade's tool calls and asks carry. */
|
|
15
|
+
export const SESSION_STAGE = "session";
|
|
16
|
+
const READ_TOOLS = ["Read", "Glob", "Grep", "Git"];
|
|
17
|
+
const WRITE_TOOLS = ["Write", "Edit", "Delete", "Bash"];
|
|
18
|
+
export function declaredToolsFor(profile, protectedPaths) {
|
|
19
|
+
if (profile === "none")
|
|
20
|
+
return [...UNIVERSAL_CODING_TOOLS];
|
|
21
|
+
if (profile === "read")
|
|
22
|
+
return [...UNIVERSAL_CODING_TOOLS, ...READ_TOOLS];
|
|
23
|
+
// GitCommit refuses every path without ignore patterns, so it is offered only with them.
|
|
24
|
+
const commit = protectedPaths.gitIgnorePatterns.length > 0 ? ["GitCommit"] : [];
|
|
25
|
+
return [...UNIVERSAL_CODING_TOOLS, ...READ_TOOLS, ...WRITE_TOOLS, ...commit];
|
|
26
|
+
}
|
|
27
|
+
export function grantsFor(declared) {
|
|
28
|
+
return declared.map((tool) => ({ tool, patterns: ["*"] }));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Under "gated", every Bash command is put to the person: an unconditional ask
|
|
32
|
+
* rule on top of the unconditional grant (ask rules are evaluated after
|
|
33
|
+
* grants). "raw" and "escalate" add none.
|
|
34
|
+
*/
|
|
35
|
+
export function askRulesFor(declared, bashApproval) {
|
|
36
|
+
return bashApproval === "gated" && declared.includes("Bash") ? [{ tool: "Bash", patterns: ["*"] }] : [];
|
|
37
|
+
}
|
|
38
|
+
/** The configured credentials directory; undefined when configureCredentials was never called. */
|
|
39
|
+
function configuredCredentialDir() {
|
|
40
|
+
try {
|
|
41
|
+
return credentialsConfig().configDir();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The embedder default (spec 6.2). A session with its own credentials source
|
|
49
|
+
* (memory or exec) has no credentials directory on disk; one that falls back
|
|
50
|
+
* to the configureCredentials slot protects that slot's directory.
|
|
51
|
+
*/
|
|
52
|
+
export function defaultProtectedPaths(ownCredentials) {
|
|
53
|
+
const base = { gitExcludePathspecs: [], gitIgnorePatterns: [] };
|
|
54
|
+
const credentialDir = ownCredentials ? undefined : configuredCredentialDir();
|
|
55
|
+
return credentialDir === undefined ? base : { ...base, credentialDir };
|
|
56
|
+
}
|
|
57
|
+
/** The sandbox floor (spec 6.3): "full" needs a usable sandbox unless gated + allowUnsandboxed. */
|
|
58
|
+
export async function resolveSessionLauncher(args) {
|
|
59
|
+
if (args.profile !== "full")
|
|
60
|
+
return undefined;
|
|
61
|
+
const launcher = await resolveSessionSandbox({
|
|
62
|
+
config: DEFAULT_SANDBOX_CONFIG,
|
|
63
|
+
root: args.root,
|
|
64
|
+
needsLauncher: true,
|
|
65
|
+
protectedPaths: args.protectedPaths,
|
|
66
|
+
ownedPaths: EMPTY_OWNED_PATHS_POLICY,
|
|
67
|
+
});
|
|
68
|
+
if (launcher.state.kind === "available")
|
|
69
|
+
return launcher;
|
|
70
|
+
if (args.bashApproval === "gated" && args.allowUnsandboxed)
|
|
71
|
+
return undefined;
|
|
72
|
+
const reason = launcher.state.kind === "unavailable" ? launcher.state.reason : "the sandbox is disabled";
|
|
73
|
+
throw new AgentSessionError(`Profile "full" needs a usable sandbox (${reason}). Pass bashApproval "gated" with allowUnsandboxed: true to run without one.`, "AGENT_SESSION_SANDBOX_UNAVAILABLE", { reason });
|
|
74
|
+
}
|
|
75
|
+
export function buildSessionToolSupport(args) {
|
|
76
|
+
const declared = declaredToolsFor(args.profile, args.protectedPaths);
|
|
77
|
+
const grants = grantsFor(declared);
|
|
78
|
+
const askRules = askRulesFor(declared, args.bashApproval);
|
|
79
|
+
const support = buildCodingToolSupport({
|
|
80
|
+
root: args.root,
|
|
81
|
+
commandCwd: args.root,
|
|
82
|
+
pipelineStage: SESSION_STAGE,
|
|
83
|
+
sessionName: args.sessionName,
|
|
84
|
+
declared,
|
|
85
|
+
grants,
|
|
86
|
+
bashApproval: args.bashApproval,
|
|
87
|
+
protectedPaths: args.protectedPaths,
|
|
88
|
+
ownedPaths: EMPTY_OWNED_PATHS_POLICY,
|
|
89
|
+
askResolver: args.askResolver,
|
|
90
|
+
...(askRules.length > 0 ? { askRules } : {}),
|
|
91
|
+
...(args.interceptor !== undefined ? { interceptor: args.interceptor } : {}),
|
|
92
|
+
...(args.launcher !== undefined ? { launcher: args.launcher } : {}),
|
|
93
|
+
});
|
|
94
|
+
// Unreachable while the scratchpad trio is always declared and granted; kept for the type.
|
|
95
|
+
if (support === undefined) {
|
|
96
|
+
throw new AgentSessionError("No coding tools resolved for the session", "AGENT_SESSION_INVALID_OPTIONS", {
|
|
97
|
+
profile: args.profile,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return { support, grants };
|
|
101
|
+
}
|
|
@@ -26,8 +26,8 @@ import type { ToolDescriptor } from "./tool-descriptor.ts";
|
|
|
26
26
|
export interface AuthStamp {
|
|
27
27
|
/** Keyed credential digest (see `fingerprint.ts`). Not comparable across machines. */
|
|
28
28
|
fingerprint: string;
|
|
29
|
-
/** Where the credential was read from. */
|
|
30
|
-
source: "file" | "exec";
|
|
29
|
+
/** Where the credential was read from: the `file` store, an `exec` helper, or an in-process `memory` store. */
|
|
30
|
+
source: "file" | "exec" | "memory";
|
|
31
31
|
/** Account label the source reported, when it reports one. */
|
|
32
32
|
account?: string;
|
|
33
33
|
}
|
|
@@ -143,6 +143,28 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
|
|
|
143
143
|
* boundary transcript resumption must respect (nax#1877). ACP ignores it.
|
|
144
144
|
*/
|
|
145
145
|
transcriptOwner?: string;
|
|
146
|
+
/**
|
|
147
|
+
* Native: where the session's history lives (S3 spec 5.5). Mutually exclusive
|
|
148
|
+
* with `transcriptDir`, which is shorthand for the file store; exactly one of
|
|
149
|
+
* the two must be set. nax sets `transcriptDir`. ACP ignores it.
|
|
150
|
+
*/
|
|
151
|
+
transcriptStore?: import("#src/native/session/transcript-types").TranscriptStore;
|
|
152
|
+
/**
|
|
153
|
+
* Native: leave the live transcript in place on close (S3 spec 5.5). By
|
|
154
|
+
* default a clean close deletes it and a failed close moves it aside; the
|
|
155
|
+
* facade sets this so the session stays resumable. nax leaves it unset.
|
|
156
|
+
* Limitation: the file store's prune on a failed close (cap 50, oldest first)
|
|
157
|
+
* counts every transcript in its directory, live ones included, so a
|
|
158
|
+
* retained session should not share a file-store directory with sessions
|
|
159
|
+
* that close failed.
|
|
160
|
+
*/
|
|
161
|
+
retainOnClose?: boolean;
|
|
162
|
+
/**
|
|
163
|
+
* Native: the session's system prompt, sent as the request's top-level
|
|
164
|
+
* `system` on every round trip (not on the compaction summary). The S3
|
|
165
|
+
* facade sets it from `instructions`; nax leaves it unset. ACP ignores it.
|
|
166
|
+
*/
|
|
167
|
+
systemPrompt?: string;
|
|
146
168
|
/**
|
|
147
169
|
* Native: resolved compaction settings. A resolved primitive, never NaxConfig —
|
|
148
170
|
* src/agents/native/ must not read config (check:adapter-no-config-import).
|
|
@@ -208,6 +230,13 @@ export interface SendTurnOpts {
|
|
|
208
230
|
* precisely so tool calls made during the turn can carry it.
|
|
209
231
|
*/
|
|
210
232
|
turnId?: string;
|
|
233
|
+
/**
|
|
234
|
+
* S3-3: per-turn event sink (deltas, stream_reset, tool calls and results,
|
|
235
|
+
* per-round usage, compaction). Native honours it; ACP ignores it until S4.
|
|
236
|
+
* Called synchronously; a throw or rejection is contained by the backend.
|
|
237
|
+
* nax sets none.
|
|
238
|
+
*/
|
|
239
|
+
onTurnEvent?: import("./turn-event.ts").TurnEventSink;
|
|
211
240
|
}
|
|
212
241
|
/**
|
|
213
242
|
* A single mid-turn interactive Q&A exchange between the agent and a human
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn events a backend pushes to `SendTurnOpts.onTurnEvent` (S3 spec
|
|
3
|
+
* 5.3, 5.4). Backend-neutral: the S3 facade adds `sessionId`, `turnId`, `at`
|
|
4
|
+
* and `metadata` and turns these into its `SessionEvent`s. nax sets no sink.
|
|
5
|
+
*
|
|
6
|
+
* - Deltas are provisional. `after_response` handlers may patch the recorded
|
|
7
|
+
* text, so the transcript and `TurnResult.output` are authoritative.
|
|
8
|
+
* - `stream_reset` voids the deltas of `round` so far: the round's request is
|
|
9
|
+
* being re-issued (a retry), and `attempt` is the new attempt's 1-based number.
|
|
10
|
+
* - `usage` is one per round-trip model call and marks the round's end.
|
|
11
|
+
* - `stream_reset` voids everything since the failed attempt began, including
|
|
12
|
+
* any retry backoff; the deltas after it are the new attempt's.
|
|
13
|
+
* - `usage` excludes the compaction summary call, so a turn's totals come from
|
|
14
|
+
* `TurnResult` (`tokenUsage`, `estimatedCostUsd`), not from summing events.
|
|
15
|
+
* - `tool_call.input` and `tool_result.preview` are redacted BEST-EFFORT (the
|
|
16
|
+
* logger's redactor: secret-named keys, known token shapes, KEY=value text)
|
|
17
|
+
* and byte-capped. A secret file's text can still appear; treat previews and
|
|
18
|
+
* inputs as sensitive. Text and thinking deltas are not redacted.
|
|
19
|
+
* - Every `tool_call` is followed by exactly one `tool_result`, also when the
|
|
20
|
+
* turn throws. A refusal is a non-error result carrying the refusal text.
|
|
21
|
+
* Calls the loop answers without running (spin stop, cancel) and `ask_human`
|
|
22
|
+
* emit no tool events; they appear only in the transcript.
|
|
23
|
+
*/
|
|
24
|
+
export type TurnEvent = {
|
|
25
|
+
readonly type: "text_delta";
|
|
26
|
+
readonly round: number;
|
|
27
|
+
readonly text: string;
|
|
28
|
+
} | {
|
|
29
|
+
readonly type: "thinking_delta";
|
|
30
|
+
readonly round: number;
|
|
31
|
+
readonly text: string;
|
|
32
|
+
} | {
|
|
33
|
+
readonly type: "stream_reset";
|
|
34
|
+
readonly round: number;
|
|
35
|
+
readonly attempt: number;
|
|
36
|
+
} | {
|
|
37
|
+
readonly type: "tool_call";
|
|
38
|
+
readonly callId: string;
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly input: unknown;
|
|
41
|
+
} | {
|
|
42
|
+
readonly type: "tool_result";
|
|
43
|
+
readonly callId: string;
|
|
44
|
+
readonly isError: boolean;
|
|
45
|
+
readonly preview: string;
|
|
46
|
+
} | {
|
|
47
|
+
readonly type: "usage";
|
|
48
|
+
readonly round: number;
|
|
49
|
+
readonly inputTokens: number;
|
|
50
|
+
readonly outputTokens: number;
|
|
51
|
+
/** Absent when the call reported no cache data -- never coerced to 0. */
|
|
52
|
+
readonly cacheRead?: number;
|
|
53
|
+
/** Absent when the call reported no cache data -- never coerced to 0. */
|
|
54
|
+
readonly cacheWrite?: number;
|
|
55
|
+
readonly costUsd: number;
|
|
56
|
+
} | {
|
|
57
|
+
readonly type: "compaction";
|
|
58
|
+
readonly reason: "proactive" | "overflow";
|
|
59
|
+
};
|
|
60
|
+
/** The per-turn sink. Called synchronously; a throw or a rejected promise is contained by the backend. */
|
|
61
|
+
export type TurnEventSink = (event: TurnEvent) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/tools/bash-cwd.d.ts
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* `<root>/child/secret`), so both screens must track it identically to avoid
|
|
9
9
|
* being fooled by it. This module is the ONE definition of "what does a `cd`
|
|
10
10
|
* segment resolve to, and how do separators fold that into the running set
|
|
11
|
-
* of candidate working directories" -- mirroring `
|
|
12
|
-
* `
|
|
13
|
-
* for a different seam.
|
|
11
|
+
* of candidate working directories" -- mirroring the `OwnedPathsPolicy` port's
|
|
12
|
+
* `writeRefusal` (`tools/owned-paths.ts`), which is the same
|
|
13
|
+
* one-definition-two-callers shape for a different seam.
|
|
14
14
|
*
|
|
15
15
|
* What this module does NOT decide: what happens when a `cd` cannot be
|
|
16
16
|
* modelled (an option-shaped target, an opaque one, one that fails to
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-deny for the host's credential directory and trust-store file in the
|
|
3
|
+
* read tools (S3 spec 6.3). Before S3-2 only the sandbox enforced these, and
|
|
4
|
+
* only for Bash; Read, Glob and Grep enforced root containment alone, so a
|
|
5
|
+
* workdir containing the credential directory exposed it.
|
|
6
|
+
*
|
|
7
|
+
* Paths are compared in canonical form: `realpathSync.native` for a path that
|
|
8
|
+
* exists returns the on-disk casing on a case-insensitive filesystem, where
|
|
9
|
+
* `realOrRaw` (JS `realpathSync`) keeps the caller's casing and would let
|
|
10
|
+
* `.NAX/credentials.json` past a `.nax` prefix check. A path that does not
|
|
11
|
+
* exist cannot be read, so its lexical form is enough.
|
|
12
|
+
*/
|
|
13
|
+
import type { ProtectedPathsPolicy } from "./protected-paths.ts";
|
|
14
|
+
/**
|
|
15
|
+
* A predicate over resolved paths, with the credential roots canonicalised
|
|
16
|
+
* once: Glob tests every hit, and a native realpath per root per hit is waste.
|
|
17
|
+
*/
|
|
18
|
+
export declare function credentialPathTest(protectedPaths: ProtectedPathsPolicy | undefined): (resolved: string) => boolean;
|
|
19
|
+
/** Why `resolved` may not be read, or undefined when it may. */
|
|
20
|
+
export declare function credentialReadRefusal(protectedPaths: ProtectedPathsPolicy | undefined, resolved: string): string | undefined;
|
|
21
|
+
/** True when searching `dir` would reach the credential directory or the trust store. */
|
|
22
|
+
export declare function containsCredentialPath(protectedPaths: ProtectedPathsPolicy | undefined, dir: string): boolean;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-deny for the host's credential directory and trust-store file in the
|
|
3
|
+
* read tools (S3 spec 6.3). Before S3-2 only the sandbox enforced these, and
|
|
4
|
+
* only for Bash; Read, Glob and Grep enforced root containment alone, so a
|
|
5
|
+
* workdir containing the credential directory exposed it.
|
|
6
|
+
*
|
|
7
|
+
* Paths are compared in canonical form: `realpathSync.native` for a path that
|
|
8
|
+
* exists returns the on-disk casing on a case-insensitive filesystem, where
|
|
9
|
+
* `realOrRaw` (JS `realpathSync`) keeps the caller's casing and would let
|
|
10
|
+
* `.NAX/credentials.json` past a `.nax` prefix check. A path that does not
|
|
11
|
+
* exist cannot be read, so its lexical form is enough.
|
|
12
|
+
*/
|
|
13
|
+
import { realpathSync } from "node:fs";
|
|
14
|
+
import { sep } from "node:path";
|
|
15
|
+
import { realOrRaw } from "#src/internal/realpath";
|
|
16
|
+
function canonical(p) {
|
|
17
|
+
try {
|
|
18
|
+
return realpathSync.native(p);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return realOrRaw(p);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function isWithin(base, target) {
|
|
25
|
+
return target === base || target.startsWith(`${base}${sep}`);
|
|
26
|
+
}
|
|
27
|
+
function credentialRoots(protectedPaths) {
|
|
28
|
+
if (protectedPaths === undefined)
|
|
29
|
+
return [];
|
|
30
|
+
return [protectedPaths.credentialDir, protectedPaths.trustStoreFile]
|
|
31
|
+
.filter((p) => p !== undefined)
|
|
32
|
+
.map(canonical);
|
|
33
|
+
}
|
|
34
|
+
const CREDENTIAL_REFUSAL = "is a host credential or trust-store path, which the read tools are refused regardless of grant";
|
|
35
|
+
/**
|
|
36
|
+
* A predicate over resolved paths, with the credential roots canonicalised
|
|
37
|
+
* once: Glob tests every hit, and a native realpath per root per hit is waste.
|
|
38
|
+
*/
|
|
39
|
+
export function credentialPathTest(protectedPaths) {
|
|
40
|
+
const roots = credentialRoots(protectedPaths);
|
|
41
|
+
if (roots.length === 0)
|
|
42
|
+
return () => false;
|
|
43
|
+
return (resolved) => {
|
|
44
|
+
const target = canonical(resolved);
|
|
45
|
+
return roots.some((base) => isWithin(base, target));
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Why `resolved` may not be read, or undefined when it may. */
|
|
49
|
+
export function credentialReadRefusal(protectedPaths, resolved) {
|
|
50
|
+
return credentialPathTest(protectedPaths)(resolved) ? CREDENTIAL_REFUSAL : undefined;
|
|
51
|
+
}
|
|
52
|
+
/** True when searching `dir` would reach the credential directory or the trust store. */
|
|
53
|
+
export function containsCredentialPath(protectedPaths, dir) {
|
|
54
|
+
const base = canonical(dir);
|
|
55
|
+
return credentialRoots(protectedPaths).some((cred) => isWithin(base, cred));
|
|
56
|
+
}
|
package/dist/tools/glob.js
CHANGED
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
* `<dir>/ <b1> <b2> ...`, unescaping each quoted basename, and joining each
|
|
15
15
|
* basename to its directory reconstructs the matched set exactly.
|
|
16
16
|
*/
|
|
17
|
-
import { sep } from "node:path";
|
|
17
|
+
import { join, sep } from "node:path";
|
|
18
18
|
import { getAgentRuntime } from "#src/runtime/index";
|
|
19
|
+
import { credentialPathTest } from "./credential-read-deny.js";
|
|
20
|
+
import { EMPTY_OWNED_PATHS_POLICY } from "./owned-paths.js";
|
|
19
21
|
import { resolveWithin } from "./policy.js";
|
|
20
22
|
const MAX_MATCHES = 500;
|
|
21
23
|
/**
|
|
@@ -127,6 +129,9 @@ export const globTool = {
|
|
|
127
129
|
if (climbsOut(pattern))
|
|
128
130
|
return { content: "no matches" };
|
|
129
131
|
const matches = [];
|
|
132
|
+
// S3 spec 6.3: credential roots are canonicalised once; a native realpath
|
|
133
|
+
// per hit would be waste when a listing covers the credential directory.
|
|
134
|
+
const isCredential = credentialPathTest(ctx.protectedPaths);
|
|
130
135
|
try {
|
|
131
136
|
// `absolute: false` is the repo-wide idiom (test-scanner.ts:318,
|
|
132
137
|
// fragments/store.ts:53, manifest-purge.ts:64) and yields root-relative
|
|
@@ -136,7 +141,9 @@ export const globTool = {
|
|
|
136
141
|
// a throwing iterator and exercise the catch branch without planting a
|
|
137
142
|
// malformed pattern on disk.
|
|
138
143
|
for await (const hit of _globDeps.scan(pattern, { cwd: ctx.root, absolute: false })) {
|
|
139
|
-
if (resolveWithin(ctx.root, hit) === null)
|
|
144
|
+
if (resolveWithin(ctx.root, hit, ctx.ownedPaths ?? EMPTY_OWNED_PATHS_POLICY) === null)
|
|
145
|
+
continue;
|
|
146
|
+
if (isCredential(join(ctx.root, hit)))
|
|
140
147
|
continue;
|
|
141
148
|
matches.push(hit.split(sep).join("/"));
|
|
142
149
|
if (matches.length >= MAX_MATCHES)
|