@nathapp/nax-agent 0.91.6 → 0.91.7-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/coding-tools/coding-tool-extras.d.ts +42 -0
  3. package/dist/coding-tools/coding-tool-extras.js +31 -0
  4. package/dist/coding-tools/coding-tool-support.d.ts +6 -0
  5. package/dist/coding-tools/coding-tool-support.js +7 -1
  6. package/dist/config/catalog-overrides.d.ts +5 -0
  7. package/dist/index.d.ts +3 -2
  8. package/dist/index.js +2 -1
  9. package/dist/internal/argv-exec.d.ts +2 -0
  10. package/dist/internal/argv-exec.js +72 -7
  11. package/dist/mcp/connect.js +26 -3
  12. package/dist/mcp/types.d.ts +7 -0
  13. package/dist/native/models.js +1 -0
  14. package/dist/native/session/loop-events/types.d.ts +5 -0
  15. package/dist/native/session-adapter.js +16 -5
  16. package/dist/permissions/approval-memory.d.ts +18 -0
  17. package/dist/permissions/approval-memory.js +88 -0
  18. package/dist/permissions/index.d.ts +1 -0
  19. package/dist/permissions/index.js +1 -0
  20. package/dist/permissions/types.d.ts +6 -0
  21. package/dist/sandbox/claude-settings.d.ts +2 -1
  22. package/dist/sandbox/claude-settings.js +7 -3
  23. package/dist/sandbox/defaults.js +1 -0
  24. package/dist/sandbox/launcher.js +3 -0
  25. package/dist/sandbox/types.d.ts +2 -0
  26. package/dist/session/agent-session-command.d.ts +19 -0
  27. package/dist/session/agent-session-command.js +38 -0
  28. package/dist/session/agent-session-types.d.ts +9 -0
  29. package/dist/session/agent-session.js +49 -1
  30. package/dist/session/native-backend-options.d.ts +18 -2
  31. package/dist/session/native-backend-options.js +26 -3
  32. package/dist/session/native-backend.js +42 -4
  33. package/dist/session/session-ask-link.d.ts +6 -0
  34. package/dist/session/session-ask-link.js +5 -0
  35. package/dist/session/session-ask-port.js +4 -0
  36. package/dist/session/session-backend.d.ts +21 -1
  37. package/dist/session/session-tool-support.d.ts +39 -3
  38. package/dist/session/session-tool-support.js +54 -10
  39. package/dist/session-store/index.d.ts +3 -0
  40. package/dist/session-store/index.js +3 -0
  41. package/dist/session-store/session-lock-guard.d.ts +6 -0
  42. package/dist/session-store/session-lock-guard.js +85 -0
  43. package/dist/session-store/session-lock.d.ts +39 -0
  44. package/dist/session-store/session-lock.js +175 -0
  45. package/dist/session-store/session-meta.d.ts +27 -0
  46. package/dist/session-store/session-meta.js +20 -0
  47. package/dist/session-store/storage.d.ts +39 -0
  48. package/dist/session-store/storage.js +164 -0
  49. package/dist/tools/ask-request.d.ts +18 -0
  50. package/dist/tools/ask-request.js +30 -0
  51. package/dist/tools/bash.d.ts +9 -0
  52. package/dist/tools/bash.js +61 -16
  53. package/dist/tools/git.js +10 -3
  54. package/dist/tools/policy-match.d.ts +2 -2
  55. package/dist/tools/policy-match.js +9 -6
  56. package/dist/tools/policy-paths-branch.js +4 -1
  57. package/dist/tools/provider-advertise.d.ts +7 -0
  58. package/dist/tools/provider-advertise.js +13 -1
  59. package/dist/tools/provider-types.d.ts +6 -0
  60. package/dist/tools/run-command-exec.js +12 -0
  61. package/dist/tools/run-command-surface.d.ts +28 -0
  62. package/dist/tools/run-command-surface.js +109 -0
  63. package/dist/tools/run-command.d.ts +17 -16
  64. package/dist/tools/run-command.js +48 -68
  65. package/dist/tools/runtime-calltool.d.ts +100 -0
  66. package/dist/tools/runtime-calltool.js +112 -3
  67. package/dist/tools/runtime.d.ts +11 -0
  68. package/dist/tools/runtime.js +36 -18
  69. package/dist/tools/types.d.ts +8 -0
  70. package/package.json +3 -3
@@ -137,6 +137,9 @@ export function createCommandLauncher(opts) {
137
137
  state,
138
138
  async run(req) {
139
139
  const tmpDir = env.tmpDir !== undefined && (await ensureTmpDir(env.tmpDir)) ? env.tmpDir : undefined;
140
+ if (req.unwrapped === true) {
141
+ return runUnwrapped(req, { backend: "none", wrapped: false, reason: "requested" }, { ...env, tmpDir });
142
+ }
140
143
  if (state.kind === "disabled")
141
144
  return runUnwrapped(req, { backend: "none", wrapped: false }, { ...env, tmpDir });
142
145
  if (state.kind === "unavailable") {
@@ -112,6 +112,8 @@ 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;
117
119
  }
@@ -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
+ }
@@ -117,6 +117,8 @@ export type SessionEventBody = {
117
117
  readonly command?: string;
118
118
  readonly reason: string;
119
119
  readonly expiresAt: string;
120
+ /** US-004: the call wants to run outside the OS sandbox. Absent on every ordinary ask. */
121
+ readonly unsandboxed?: true;
120
122
  /** `false`: decided already (by the profile); informational. Absent: a person's answer is awaited. */
121
123
  readonly answerable?: false;
122
124
  } | {
@@ -167,7 +169,14 @@ export type AnswerReply = {
167
169
  readonly text: string;
168
170
  };
169
171
  export type AnswerStatus = "accepted" | "expired" | "cancelled" | "unknown";
172
+ export interface CommandRunResult {
173
+ /** The Bash tool's result text as the model would see it (capped by the tool runtime). */
174
+ readonly content: string;
175
+ readonly isError: boolean;
176
+ }
170
177
  export interface AgentSession {
178
+ /** Run through the session's Bash tool without a model turn. The facade always provides this method. */
179
+ runCommand?(command: string, onEvent: (event: SessionEvent) => void): Promise<CommandRunResult>;
171
180
  readonly id: string;
172
181
  /** The backend that opened this session (`kind` and its capability summary). */
173
182
  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,6 +8,17 @@ 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;
@@ -21,7 +33,13 @@ class FacadeAgentSession {
21
33
  }
22
34
  get backend() {
23
35
  const info = this.parts.opened.info;
24
- return Object.freeze({ kind: info.kind, capabilities: Object.freeze({ ...info.capabilities }) });
36
+ // US-001: the sandbox state rides through the facade frozen, so an embedder
37
+ // cannot edit the record of what its commands ran under.
38
+ return Object.freeze({
39
+ kind: info.kind,
40
+ capabilities: Object.freeze({ ...info.capabilities }),
41
+ ...(info.sandbox !== undefined ? { sandbox: frozenSandbox(info.sandbox) } : {}),
42
+ });
25
43
  }
26
44
  get lastTurn() {
27
45
  return this.last;
@@ -49,6 +67,36 @@ class FacadeAgentSession {
49
67
  this.active = turn;
50
68
  return turn.iterable;
51
69
  }
70
+ async runCommand(command, onEvent) {
71
+ const code = this.closing !== undefined
72
+ ? "AGENT_SESSION_CLOSED"
73
+ : this.active !== undefined
74
+ ? "AGENT_SESSION_BUSY"
75
+ : this.parts.opened.runCommand === undefined
76
+ ? "AGENT_SESSION_CAPABILITY_UNSUPPORTED"
77
+ : undefined;
78
+ const run = this.parts.opened.runCommand;
79
+ if (code !== undefined || run === undefined) {
80
+ throw new AgentSessionError(`Session "${this.id}" cannot run a command`, code ?? "AGENT_SESSION_CAPABILITY_UNSUPPORTED", { sessionId: this.id });
81
+ }
82
+ const claim = claimCommand({
83
+ ctx: this.parts.ctx,
84
+ command,
85
+ onEvent,
86
+ run: (request) => run.call(this.parts.opened, request),
87
+ hooks: {
88
+ onStart: (live) => {
89
+ this.parts.slot.turn = live;
90
+ },
91
+ onSettle: () => {
92
+ this.parts.slot.turn = undefined;
93
+ this.active = undefined;
94
+ },
95
+ },
96
+ });
97
+ this.active = claim;
98
+ return claim.result;
99
+ }
52
100
  answer(requestId, reply) {
53
101
  return this.parts.table.answer(requestId, reply);
54
102
  }
@@ -2,17 +2,30 @@ import type { BashApprovalMode } from "#src/config/bash-approval";
2
2
  import type { NativeCatalogOverrides } from "#src/native/client";
3
3
  import type { CredentialSource } from "#src/native/credentials/session-source";
4
4
  import type { LoopHandlerSet } from "#src/native/session/loop-events/types";
5
+ import type { ToolProvider } from "#src/tools/provider-types";
5
6
  import type { AgentSessionHostPorts, AgentSessionProfile } from "./agent-session-types.ts";
6
7
  export interface NativeBackendOptions {
7
8
  readonly model: string;
9
+ /** Static or discovered tool providers attached to this session's profile and stage. */
10
+ readonly toolProviders?: readonly ToolProvider[];
8
11
  /** Repository instruction basename; defaults to AGENTS.md. */
9
12
  readonly instructionFileName?: string;
10
13
  readonly credentials?: CredentialSource;
11
14
  readonly catalogOverrides?: NativeCatalogOverrides;
12
15
  readonly loopHandlers?: LoopHandlerSet;
16
+ /** Copied into every turn's loop-handler context as `role` (for example "tui"). */
17
+ readonly loopHandlerRole?: string;
13
18
  readonly hostPorts?: AgentSessionHostPorts;
14
19
  readonly bashApproval?: BashApprovalMode;
15
20
  readonly allowUnsandboxed?: boolean;
21
+ /** Narrow the declared tools to these names (built-in names, `<provider>__<tool>`, or `<provider>__*`); scratchpad tools are always kept. */
22
+ readonly allowedTools?: readonly string[];
23
+ /**
24
+ * US-001: offer the Bash/Exec tools' `unsandboxed` argument, so a model can ask
25
+ * for one command to run outside the OS sandbox. Refused by the tool-less
26
+ * profiles, which have no shell to unwrap.
27
+ */
28
+ readonly allowUnsandboxedCommands?: boolean;
16
29
  /** Keep a resumed or continued conversation when the model differs (ACP server model switch, S5-3). */
17
30
  readonly carryHistoryAcrossModels?: boolean;
18
31
  /**
@@ -34,7 +47,10 @@ export interface ResolvedNativeOptions {
34
47
  readonly provider: string;
35
48
  }
36
49
  export declare function parseNativeBackendOptions(input: unknown): ResolvedNativeOptions;
37
- export declare function nativeProfileRules(profile: AgentSessionProfile, raw: NativeBackendOptions): {
50
+ /** The profile's resolved knobs: the validator's answer, consumed as data. */
51
+ export interface ResolvedProfileRules {
38
52
  readonly bashApproval: BashApprovalMode;
39
53
  readonly allowUnsandboxed: boolean;
40
- };
54
+ readonly allowUnsandboxedCommands: boolean;
55
+ }
56
+ export declare function nativeProfileRules(profile: AgentSessionProfile, raw: NativeBackendOptions): ResolvedProfileRules;
@@ -18,6 +18,14 @@ function hasMethods(names) {
18
18
  const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
19
19
  const OptionsSchema = z.strictObject({
20
20
  model: z.string().min(1),
21
+ toolProviders: z
22
+ .custom((value) => Array.isArray(value) &&
23
+ value.every((provider) => isRecord(provider) &&
24
+ typeof provider.id === "string" &&
25
+ (provider.kind === "static" || provider.kind === "discovered") &&
26
+ isStringArray(provider.stages) &&
27
+ typeof provider.tools === "function"), "must be an array of tool providers with id, kind, stages and tools()")
28
+ .optional(),
21
29
  instructionFileName: z
22
30
  .string()
23
31
  .refine(isInstructionFileName, "must be a nonhidden Markdown basename without path separators, colon or control characters")
@@ -27,6 +35,7 @@ const OptionsSchema = z.strictObject({
27
35
  .optional(),
28
36
  catalogOverrides: z.array(z.custom(isRecord, "must be a catalog override object")).optional(),
29
37
  loopHandlers: z.array(z.custom(isRecord, "must be a loop handler entry")).optional(),
38
+ loopHandlerRole: z.string().min(1).optional(),
30
39
  hostPorts: z
31
40
  .strictObject({
32
41
  protectedPaths: z
@@ -37,6 +46,8 @@ const OptionsSchema = z.strictObject({
37
46
  .optional(),
38
47
  bashApproval: z.enum(["raw", "gated", "escalate"]).optional(),
39
48
  allowUnsandboxed: z.boolean().optional(),
49
+ allowedTools: z.array(z.string().min(1)).optional(),
50
+ allowUnsandboxedCommands: z.boolean().optional(),
40
51
  carryHistoryAcrossModels: z.boolean().optional(),
41
52
  compaction: compactionSettingsSchema.optional(),
42
53
  });
@@ -78,14 +89,26 @@ export function nativeProfileRules(profile, raw) {
78
89
  path: "backend.allowUnsandboxed",
79
90
  });
80
91
  }
92
+ if (!tools && raw.allowUnsandboxedCommands !== undefined) {
93
+ throw invalid('allowUnsandboxedCommands applies to profiles "ask", "full" and "review" only', {
94
+ path: "backend.allowUnsandboxedCommands",
95
+ });
96
+ }
81
97
  const bashApproval = raw.bashApproval ?? "gated";
82
98
  if (profile === "ask" && bashApproval !== "gated") {
83
99
  throw invalid('profile "ask" requires bashApproval "gated" (other modes run Bash without asking)', {
84
100
  path: "backend.bashApproval",
85
101
  });
86
102
  }
87
- if (raw.allowUnsandboxed === true && bashApproval !== "gated") {
88
- throw invalid('allowUnsandboxed requires bashApproval "gated"', { path: "backend.allowUnsandboxed" });
103
+ // Raw never asks, so an unsandboxed fallback under it is the person's own standing
104
+ // decision; escalate has no one to fall back to. Gated and raw are the two that may.
105
+ const fallbackAllowed = bashApproval === "gated" || bashApproval === "raw";
106
+ if (raw.allowUnsandboxed === true && !fallbackAllowed) {
107
+ throw invalid('allowUnsandboxed requires bashApproval "gated" or "raw"', { path: "backend.allowUnsandboxed" });
89
108
  }
90
- return { bashApproval, allowUnsandboxed: raw.allowUnsandboxed === true };
109
+ return {
110
+ bashApproval,
111
+ allowUnsandboxed: raw.allowUnsandboxed === true,
112
+ allowUnsandboxedCommands: raw.allowUnsandboxedCommands === true,
113
+ };
91
114
  }
@@ -4,16 +4,19 @@
4
4
  * with built-in and embedder tools, the NativeSessionAdapter, loop handlers
5
5
  * and the resume model check.
6
6
  */
7
+ import { getLogger } from "#src/infra/agent-logger";
8
+ import { errorMessage } from "#src/infra/errors";
7
9
  import { DEFAULT_SPIN_BREAKER_SETTINGS } from "#src/infra/spin-breaker/index";
8
10
  import { NATIVE_AGENT } from "#src/native/models";
9
11
  import { resolveCompaction } from "#src/native/session/compaction-settings";
10
12
  import { transcriptModelIdentity } from "#src/native/session/transcript-identity";
11
13
  import { NativeSessionAdapter } from "#src/native/session-adapter";
14
+ import { resolveProviderTools } from "#src/tools/provider-advertise";
12
15
  import { AgentSessionError } from "./agent-session-errors.js";
13
16
  import { nativeProfileRules, parseNativeBackendOptions, } from "./native-backend-options.js";
14
17
  import { createSessionAskLink, createSessionAskResolver } from "./session-ask-link.js";
15
18
  import { createSessionInteractionHandler, embedderToolDescriptor } from "./session-interaction.js";
16
- import { buildSessionToolSupport, defaultProtectedPaths, resolveSessionLauncher } from "./session-tool-support.js";
19
+ import { buildSessionToolSupport, defaultProtectedPaths, providerAdmits, resolveSessionLauncher, SESSION_STAGE, } from "./session-tool-support.js";
17
20
  export const NATIVE_BACKEND_KIND = "native";
18
21
  /** nax's agent.native.transportRetry default (S3 spec 5.3). */
19
22
  const SESSION_TRANSPORT_RETRY = {
@@ -38,28 +41,44 @@ async function openNative(resolved, ctx) {
38
41
  const raw = resolved.raw;
39
42
  if (ctx.resume !== undefined && raw.carryHistoryAcrossModels !== true)
40
43
  checkResumeModel(ctx.resume.doc, ctx.sessionId, raw.model);
41
- const { bashApproval, allowUnsandboxed } = nativeProfileRules(ctx.profile, raw);
44
+ const { bashApproval, allowUnsandboxed, allowUnsandboxedCommands } = nativeProfileRules(ctx.profile, raw);
42
45
  const protectedPaths = {
43
46
  ...defaultProtectedPaths(raw.credentials !== undefined),
44
47
  ...raw.hostPorts?.protectedPaths,
45
48
  };
46
- const launcher = await resolveSessionLauncher({
49
+ const { launcher, sandbox } = await resolveSessionLauncher({
47
50
  profile: ctx.profile,
48
51
  root: ctx.workdir,
49
52
  protectedPaths,
50
53
  bashApproval,
51
54
  allowUnsandboxed,
52
55
  });
56
+ const providerTools = ctx.profile === "none" || raw.toolProviders === undefined
57
+ ? undefined
58
+ : await resolveProviderTools(raw.toolProviders, SESSION_STAGE, ctx.workdir, raw.allowedTools !== undefined ? { admits: providerAdmits(raw.allowedTools) } : undefined);
59
+ for (const failure of providerTools?.failures ?? []) {
60
+ getLogger().warn("session", "Tool provider skipped", { sessionId: ctx.sessionId, ...failure });
61
+ }
62
+ for (const tool of ctx.tools) {
63
+ if (providerTools?.providerIdByTool.has(tool.name)) {
64
+ throw new AgentSessionError(`Embedder tool "${tool.name}" conflicts with a provider tool`, "AGENT_SESSION_TOOL_NAME_RESERVED", { tool: tool.name });
65
+ }
66
+ }
53
67
  let callId;
54
68
  const { support, grants } = buildSessionToolSupport({
55
69
  profile: ctx.profile,
56
70
  root: ctx.workdir,
57
71
  sessionName: ctx.sessionId,
72
+ ...(providerTools !== undefined ? { providerTools } : {}),
73
+ ...(raw.allowedTools !== undefined ? { allowedTools: raw.allowedTools } : {}),
58
74
  protectedPaths,
59
75
  bashApproval,
60
76
  launcher,
61
77
  askResolver: createSessionAskResolver(createSessionAskLink({ port: ctx.asks, currentCallId: () => callId })),
62
78
  interceptor: raw.hostPorts?.commandInterceptor,
79
+ // US-003: the profile's validated opt-in, so an interactive "full" session
80
+ // can offer Bash's per-call escape without turning the sandbox off.
81
+ ...(allowUnsandboxedCommands ? { unsandboxedCommands: true } : {}),
63
82
  });
64
83
  const interactionHandler = createSessionInteractionHandler({
65
84
  sessionId: ctx.sessionId,
@@ -77,6 +96,7 @@ async function openNative(resolved, ctx) {
77
96
  workdir: ctx.workdir,
78
97
  instructionProtectedPaths: protectedPaths,
79
98
  instructionFileName: raw.instructionFileName,
99
+ ...(support.providerInstructions !== undefined ? { providerInstructions: support.providerInstructions } : {}),
80
100
  resolvedPermissions: { mode: "default", toolGrants: grants, bashApproval },
81
101
  modelDef: { provider: resolved.provider, model: raw.model },
82
102
  timeoutSeconds: ctx.turnTimeoutSeconds,
@@ -95,11 +115,29 @@ async function openNative(resolved, ctx) {
95
115
  workdir: ctx.workdir,
96
116
  model: raw.model,
97
117
  provider: resolved.provider,
118
+ ...(raw.loopHandlerRole !== undefined ? { role: raw.loopHandlerRole } : {}),
98
119
  };
99
120
  return {
100
121
  adapter,
101
122
  handle,
102
- info: { kind: NATIVE_BACKEND_KIND, capabilities: {} },
123
+ runCommand: async ({ command, callId, signal }) => {
124
+ try {
125
+ const response = await interactionHandler.onInteraction({
126
+ kind: "coding-tool",
127
+ name: "Bash",
128
+ input: { command },
129
+ toolCallId: callId,
130
+ signal,
131
+ });
132
+ if (response === null)
133
+ return { content: "Bash is not available in this session", isError: true };
134
+ return { content: response.answer, isError: response.denied !== undefined };
135
+ }
136
+ catch (error) {
137
+ return { content: errorMessage(error), isError: true };
138
+ }
139
+ },
140
+ info: { kind: NATIVE_BACKEND_KIND, capabilities: {}, sandbox },
103
141
  turnOpts: () => ({
104
142
  interactionHandler,
105
143
  codingTools,
@@ -19,6 +19,12 @@ export interface ApprovalAsk {
19
19
  readonly command?: string;
20
20
  readonly reason: string;
21
21
  readonly callId?: string;
22
+ /**
23
+ * US-004: the call wants to run OUTSIDE the OS sandbox. Forwarded verbatim
24
+ * from `AskRequest` onto the `approval_requested` event; a host that does not
25
+ * recognise it shows the same prompt it always did.
26
+ */
27
+ readonly unsandboxed?: true;
22
28
  }
23
29
  export declare function askPerson(deps: SessionAskDeps, ask: ApprovalAsk, signal: AbortSignal | undefined): Promise<{
24
30
  readonly decision: "allow" | "deny";
@@ -15,6 +15,7 @@ export async function askPerson(deps, ask, signal) {
15
15
  summary: ask.summary,
16
16
  ...(ask.command !== undefined ? { command: ask.command } : {}),
17
17
  reason: ask.reason,
18
+ ...(ask.unsandboxed !== undefined ? { unsandboxed: ask.unsandboxed } : {}),
18
19
  expiresAt,
19
20
  });
20
21
  const settlement = await settled;
@@ -42,6 +43,10 @@ export function createSessionAskLink(deps) {
42
43
  ...(masked !== undefined ? { command: masked.masked } : {}),
43
44
  reason: req.reason ?? `matched ${req.rule}`,
44
45
  ...(callId !== undefined ? { callId } : {}),
46
+ // US-004: carried through untouched, so a host that offers a distinct
47
+ // unsandboxed decision sees it on the port; a host that ignores it
48
+ // keeps the one approval prompt it has.
49
+ ...(req.unsandboxed !== undefined ? { unsandboxed: req.unsandboxed } : {}),
45
50
  ...(control?.signal !== undefined ? { signal: control.signal } : {}),
46
51
  });
47
52
  },
@@ -40,6 +40,10 @@ export function createSessionAskPort(deps) {
40
40
  tool: req.tool,
41
41
  summary: req.summary,
42
42
  reason: req.reason,
43
+ // US-004: an auto-decision carries whatever the request carried. No
44
+ // caller sets this today (an unsandboxed ask is never auto-decided),
45
+ // so it rides along rather than being silently dropped.
46
+ ...(req.unsandboxed !== undefined ? { unsandboxed: req.unsandboxed } : {}),
43
47
  expiresAt: now(),
44
48
  answerable: false,
45
49
  });
@@ -6,13 +6,21 @@
6
6
  * satisfy this contract; nothing here is backend-specific.
7
7
  */
8
8
  import type { TranscriptDoc, TranscriptStore } from "#src/native/session/transcript-types";
9
- import type { AgentSessionProfile, ApprovalDecidedBy, EmbedderTool } from "./agent-session-types.ts";
9
+ import type { SandboxState } from "#src/sandbox/types";
10
+ import type { AgentSessionProfile, ApprovalDecidedBy, CommandRunResult, EmbedderTool } from "./agent-session-types.ts";
10
11
  import type { AgentSessionAdapter, SendTurnOpts, SessionHandle } from "./session-types.ts";
11
12
  export interface BackendInfo {
12
13
  /** "native" or "acp:<agent>"; recorded in the transcript document. */
13
14
  readonly kind: string;
14
15
  /** Read-only, JSON-safe capability summary. `{}` for native. */
15
16
  readonly capabilities: Readonly<Record<string, unknown>>;
17
+ /**
18
+ * US-001: the sandbox this backend resolved when it opened, so the embedder can
19
+ * say what the session's commands actually run under. Reported once at open — a
20
+ * sandbox that stops working mid-session is not re-probed. Absent for a backend
21
+ * that runs no shell of its own (the ACP backend leaves it out).
22
+ */
23
+ readonly sandbox?: SandboxState;
16
24
  }
17
25
  export interface ApprovalRequest {
18
26
  readonly callId?: string;
@@ -20,6 +28,12 @@ export interface ApprovalRequest {
20
28
  readonly summary: string;
21
29
  readonly command?: string;
22
30
  readonly reason: string;
31
+ /**
32
+ * US-004: the ask is for a run OUTSIDE the OS sandbox. Copied from the
33
+ * `AskRequest` by `createSessionAskLink` and forwarded onto the
34
+ * `approval_requested` event by `askPerson`; absent on every ordinary ask.
35
+ */
36
+ readonly unsandboxed?: true;
23
37
  /** Extra abort source, combined with the turn signal (for example a tool-level abort). */
24
38
  readonly signal?: AbortSignal;
25
39
  }
@@ -75,6 +89,12 @@ export interface BackendOpenContext {
75
89
  readonly providerInstructions?: string;
76
90
  }
77
91
  export interface OpenedBackend {
92
+ /** Absent on a backend that cannot run a host-side command (ACP). */
93
+ runCommand?(request: {
94
+ command: string;
95
+ callId: string;
96
+ signal: AbortSignal;
97
+ }): Promise<CommandRunResult>;
78
98
  readonly adapter: AgentSessionAdapter;
79
99
  readonly handle: SessionHandle;
80
100
  readonly info: BackendInfo;
@@ -2,8 +2,9 @@ import { type CodingToolSupport } from "#src/coding-tools/coding-tool-support";
2
2
  import type { CommandInterceptor } from "#src/command-interceptor/index";
3
3
  import type { BashApprovalMode } from "#src/config/bash-approval";
4
4
  import type { AskResolver } from "#src/permissions/index";
5
- import type { CommandLauncher } from "#src/sandbox/index";
5
+ import type { CommandLauncher, SandboxState } from "#src/sandbox/index";
6
6
  import type { ProtectedPathsPolicy } from "#src/tools/protected-paths";
7
+ import type { ResolvedProviderTools } from "#src/tools/provider-advertise";
7
8
  import type { CodingToolName, ToolGrant } from "#src/tools/types";
8
9
  import type { AgentSessionProfile } from "./agent-session-types.ts";
9
10
  /** The pipeline stage the facade's tool calls and asks carry. */
@@ -26,11 +27,28 @@ export interface SessionLauncherArgs {
26
27
  readonly profile: AgentSessionProfile;
27
28
  readonly root: string;
28
29
  readonly protectedPaths: ProtectedPathsPolicy;
30
+ /**
31
+ * The resolved mode. Since US-001 the floor itself no longer keys on it —
32
+ * `nativeProfileRules` has already validated which mode may pair with
33
+ * `allowUnsandboxed` — but it stays part of the call so the caller declares
34
+ * the session's posture in one place.
35
+ */
29
36
  readonly bashApproval: BashApprovalMode;
30
37
  readonly allowUnsandboxed: boolean;
31
38
  }
32
- /** The sandbox floor (spec 6.3): "ask", "full" and "review" need a usable sandbox unless gated + allowUnsandboxed. */
33
- export declare function resolveSessionLauncher(args: SessionLauncherArgs): Promise<CommandLauncher | undefined>;
39
+ /** What the floor decided: the launcher to run under (if any), and the state to report. */
40
+ export interface ResolvedSessionLauncher {
41
+ readonly launcher: CommandLauncher | undefined;
42
+ readonly sandbox: SandboxState;
43
+ }
44
+ /**
45
+ * The sandbox floor (spec 6.3): "ask", "full" and "review" need a usable sandbox,
46
+ * unless the session explicitly allows running without one — under `gated` (every
47
+ * call still goes to the person) or under `raw` (the person's own standing choice).
48
+ * The resolved state rides back beside the launcher, so the session can report
49
+ * what its commands actually run under.
50
+ */
51
+ export declare function resolveSessionLauncher(args: SessionLauncherArgs): Promise<ResolvedSessionLauncher>;
34
52
  export interface SessionToolSupportArgs {
35
53
  readonly profile: AgentSessionProfile;
36
54
  readonly root: string;
@@ -40,7 +58,25 @@ export interface SessionToolSupportArgs {
40
58
  readonly launcher: CommandLauncher | undefined;
41
59
  readonly askResolver: AskResolver;
42
60
  readonly interceptor: CommandInterceptor | undefined;
61
+ /**
62
+ * US-003: offer Bash's per-call `unsandboxed` argument for this session
63
+ * (from `nativeProfileRules(...).allowUnsandboxedCommands`). Optional and
64
+ * defaulting to off, so every existing caller keeps today's tool surface.
65
+ */
66
+ readonly unsandboxedCommands?: boolean;
67
+ readonly providerTools?: ResolvedProviderTools;
68
+ /**
69
+ * Narrow the profile's built-in tools to these names; the universal scratchpad
70
+ * tools are always kept. ABSENT keeps every profile tool. Never adds a tool.
71
+ * Provider tools are narrowed earlier, by `providerAdmits` at resolution.
72
+ */
73
+ readonly allowedTools?: readonly string[];
43
74
  }
75
+ /**
76
+ * The `admits` predicate for `resolveProviderTools`: a tool `<id>__<local>` is
77
+ * admitted when `allowedTools` names it exactly or names `<id>__*`.
78
+ */
79
+ export declare function providerAdmits(allowedTools: readonly string[]): (providerId: string, localName: string) => boolean;
44
80
  export interface SessionToolSupport {
45
81
  readonly support: CodingToolSupport;
46
82
  readonly grants: readonly ToolGrant[];