@salesforce/sfdx-agent-sdk 0.24.0 → 0.26.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.
@@ -31,6 +31,16 @@ export class DefaultChatSession {
31
31
  * are stale and should not bleed into the next turn).
32
32
  */
33
33
  toolStartMs = new Map();
34
+ /**
35
+ * Tracks the `(toolName, serverName?)` of every tool call currently awaiting
36
+ * approval, keyed by `toolCallId`. Populated when a `tool-approval-request`
37
+ * ChatEvent flows through {@link wrapEventStream}; read by
38
+ * {@link approveToolCall} / {@link declineToolCall} when `remember: true` so
39
+ * the appended policy rule carries the right matcher. An entry is removed
40
+ * once its approval settles (or the turn ends), so a `remember` settle for an
41
+ * unknown / already-settled `toolCallId` throws `TOOL_CALL_NOT_FOUND`.
42
+ */
43
+ pendingApprovalsByToolCallId = new Map();
34
44
  /**
35
45
  * Live getter for the agent's currently-bound model's context window.
36
46
  * Called by {@link getContextUsage} so reads reflect the model in
@@ -38,6 +48,14 @@ export class DefaultChatSession {
38
48
  * (an `Agent.updateAgentConfig()` swap can change it mid-life).
39
49
  */
40
50
  getContextWindow;
51
+ /**
52
+ * Persists a `'remember'` policy rule on behalf of a settle call with
53
+ * `{ remember: true }`. Injected by {@link DefaultAgent}; `undefined` only
54
+ * in unit tests that construct a session without the persister, in which
55
+ * case a `remember: true` settle is treated as a one-shot (no write). See
56
+ * {@link RememberedRulePersister}.
57
+ */
58
+ persistRememberedRule;
41
59
  /**
42
60
  * Last per-step usage reading observed on this session. Initialized
43
61
  * to `{}` (every token field undefined) so {@link getContextUsage}
@@ -57,16 +75,17 @@ export class DefaultChatSession {
57
75
  * @param parent - Parent agent's buses; this session forwards its events upward into them.
58
76
  * @param getContextWindow - Live getter for the agent's currently-bound model's `contextWindow`.
59
77
  * Called by `getContextUsage()` so reads stay correct across `Agent.updateAgentConfig()` model swaps.
60
- * @param clock - Source of monotonic timestamps for telemetry events. Defaults to `RealClock`.
61
- * @param idGenerator - Source of message ids for `addContext()`. Defaults to `UUIDGenerator`.
78
+ * @param deps - Optional injected dependencies ({@link ChatSessionDeps}): `clock`, `idGenerator`,
79
+ * `persistRememberedRule`. Each has a production default; tests override only what they need.
62
80
  */
63
- constructor(harness, agentId, threadId, inbound, parent, getContextWindow, clock = new RealClock(), idGenerator = new UUIDGenerator()) {
81
+ constructor(harness, agentId, threadId, inbound, parent, getContextWindow, deps = {}) {
64
82
  this.harness = harness;
65
83
  this.agentId = agentId;
66
84
  this.threadId = threadId;
67
85
  this.getContextWindow = getContextWindow;
68
- this.clock = clock;
69
- this.idGenerator = idGenerator;
86
+ this.clock = deps.clock ?? new RealClock();
87
+ this.idGenerator = deps.idGenerator ?? new UUIDGenerator();
88
+ this.persistRememberedRule = deps.persistRememberedRule;
70
89
  this.inboundUnsubs = [inbound.telemetry.forwardTo(this.telemetryBus), inbound.log.forwardTo(this.logBus)];
71
90
  this.parentUnsubs = [this.telemetryBus.forwardTo(parent.telemetry), this.logBus.forwardTo(parent.log)];
72
91
  }
@@ -185,7 +204,7 @@ export class DefaultChatSession {
185
204
  // would lose tracking for tool-calls whose tool-result lands on a later
186
205
  // continuation stream. Only terminal `FinishReason`s end the turn.
187
206
  if (event.finishReason !== 'tool-calls') {
188
- this.toolStartMs.clear();
207
+ this.clearPerTurnTracking();
189
208
  }
190
209
  }
191
210
  if (event.type === 'error')
@@ -204,9 +223,8 @@ export class DefaultChatSession {
204
223
  this.chatEventBus.emit(finishEvent);
205
224
  yield finishEvent;
206
225
  // Match the natural-finish branch: every terminal `finish` clears the
207
- // tool-start tracking map so a stale entry can't pair with an unrelated
208
- // tool-result on the next turn.
209
- this.toolStartMs.clear();
226
+ // per-turn tracking maps.
227
+ this.clearPerTurnTracking();
210
228
  }
211
229
  const finishedAt = this.clock.now();
212
230
  const durationMs = finishedAt.getTime() - startedAt.getTime();
@@ -242,13 +260,17 @@ export class DefaultChatSession {
242
260
  * `tool-approval-requested` by `toolCallId` and observe the failure on the chat-stream contract.
243
261
  * - MUST notify listeners with `ErrorEvent` + `FinishEvent` and re-throw if the harness throws
244
262
  * before returning a stream result.
245
- * - The `options.remember` flag is consumer-only metadata the harness does not use it.
263
+ * - WHEN `options.remember` is `true`, MUST append an `'allow'` `'remember'` rule to the agent's
264
+ * `toolPolicies` and persist it via the injected persister BEFORE delegating the settle to the
265
+ * harness, so a persistence failure surfaces as the settle's rejection and the decision is durable
266
+ * before the tool runs. Throws `TOOL_CALL_NOT_FOUND` if `toolCallId` has no pending approval.
246
267
  */
247
- async approveToolCall(toolCallId, _options) {
268
+ async approveToolCall(toolCallId, options) {
248
269
  this.assertNotDisposed();
249
270
  // issue #529 contract change: see `submitToolResult` for the rationale.
250
271
  // Settle is a control message on the existing turn; events flow on
251
272
  // the chat()-returned stream.
273
+ const policyWritten = await this.maybePersistRememberedRule(toolCallId, 'allow', options?.remember === true);
252
274
  try {
253
275
  await this.harness.approveToolCall(this.agentId, this.threadId, toolCallId);
254
276
  }
@@ -256,7 +278,8 @@ export class DefaultChatSession {
256
278
  this.notifySettleRejection(err);
257
279
  throw err;
258
280
  }
259
- this.emitToolApprovalResolved(toolCallId, true);
281
+ this.pendingApprovalsByToolCallId.delete(toolCallId);
282
+ this.emitToolApprovalResolved(toolCallId, true, policyWritten);
260
283
  }
261
284
  /**
262
285
  * @requirements
@@ -268,10 +291,13 @@ export class DefaultChatSession {
268
291
  * and intentionally skip approval-resolved emission.
269
292
  * - MUST notify listeners with `ErrorEvent` + `FinishEvent` and re-throw if the harness throws
270
293
  * before returning a stream result.
294
+ * - WHEN `options.remember` is `true`, MUST append a `'deny'` `'remember'` rule and persist it
295
+ * BEFORE delegating the settle to the harness — symmetric with {@link approveToolCall}.
271
296
  */
272
- async declineToolCall(toolCallId) {
297
+ async declineToolCall(toolCallId, options) {
273
298
  this.assertNotDisposed();
274
299
  // issue #529 contract change: see `submitToolResult` for the rationale.
300
+ const policyWritten = await this.maybePersistRememberedRule(toolCallId, 'deny', options?.remember === true);
275
301
  try {
276
302
  await this.harness.declineToolCall(this.agentId, this.threadId, toolCallId);
277
303
  }
@@ -279,7 +305,8 @@ export class DefaultChatSession {
279
305
  this.notifySettleRejection(err);
280
306
  throw err;
281
307
  }
282
- this.emitToolApprovalResolved(toolCallId, false);
308
+ this.pendingApprovalsByToolCallId.delete(toolCallId);
309
+ this.emitToolApprovalResolved(toolCallId, false, policyWritten);
283
310
  }
284
311
  /**
285
312
  * @requirements
@@ -408,7 +435,7 @@ export class DefaultChatSession {
408
435
  this.logBus.dispose();
409
436
  this.disposed = true;
410
437
  }
411
- emitToolApprovalResolved(toolCallId, approved) {
438
+ emitToolApprovalResolved(toolCallId, approved, policyWritten) {
412
439
  this.telemetryBus.emit({
413
440
  type: 'tool-approval-resolved',
414
441
  timestamp: this.clock.now(),
@@ -416,8 +443,22 @@ export class DefaultChatSession {
416
443
  threadId: this.threadId,
417
444
  toolCallId,
418
445
  approved,
446
+ ...(policyWritten ? { policyWritten: true } : {}),
419
447
  });
420
448
  }
449
+ /**
450
+ * Clears the per-turn tracking maps at a terminal `finish`. Both maps are
451
+ * scoped to one logical chat turn: `toolStartMs` pairs `tool-call` with
452
+ * `tool-result` for `tool-execution-completed.durationMs`, and
453
+ * `pendingApprovalsByToolCallId` lets a `remember` settle build the right
454
+ * matcher. A stale entry surviving into the next turn would mispair a
455
+ * duration or remember the wrong tool, so the two clears must always fire
456
+ * together — hence one helper rather than two call sites.
457
+ */
458
+ clearPerTurnTracking() {
459
+ this.toolStartMs.clear();
460
+ this.pendingApprovalsByToolCallId.clear();
461
+ }
421
462
  /**
422
463
  * Derives `tool-execution-*` and `tool-approval-requested` telemetry from `ChatEvent`s as
423
464
  * they pass through the stream wrapper. Centralizing the derivation here keeps every harness
@@ -466,6 +507,15 @@ export class DefaultChatSession {
466
507
  });
467
508
  }
468
509
  else if (event.type === 'tool-approval-request') {
510
+ // Record the (toolName, serverName?) so a later settle with
511
+ // `remember: true` can build the right matcher for the persisted
512
+ // rule. Drained on settle, and on every terminal `finish` (see
513
+ // `wrapEventStream`) so a stale entry can't be remembered after the
514
+ // turn that requested it has ended.
515
+ this.pendingApprovalsByToolCallId.set(event.toolCall.toolCallId, {
516
+ toolName: event.toolCall.toolName,
517
+ ...(event.serverName ? { serverName: event.serverName } : {}),
518
+ });
469
519
  this.telemetryBus.emit({
470
520
  type: 'tool-approval-requested',
471
521
  timestamp: this.clock.now(),
@@ -478,6 +528,34 @@ export class DefaultChatSession {
478
528
  });
479
529
  }
480
530
  }
531
+ /**
532
+ * If `remember` is requested, builds the matcher for the pending approval
533
+ * referenced by `toolCallId` and persists a `'remember'` rule with the
534
+ * given `decision` via the injected {@link RememberedRulePersister}, BEFORE
535
+ * the caller settles with the harness. Returns whether a rule was written.
536
+ *
537
+ * - A `remember` settle for a `toolCallId` with no pending approval throws
538
+ * `TOOL_CALL_NOT_FOUND` — the same outcome as settling an unknown id, and
539
+ * the correct outcome for a consumer-executed tool (never gated, so never
540
+ * in the pending map).
541
+ * - When no persister was injected (unit-test construction), `remember`
542
+ * degrades to a one-shot settle: no write, returns `false`.
543
+ */
544
+ async maybePersistRememberedRule(toolCallId, decision, remember) {
545
+ if (!remember)
546
+ return false;
547
+ const ref = this.pendingApprovalsByToolCallId.get(toolCallId);
548
+ if (!ref) {
549
+ throw new AgentSDKError(`No pending tool approval found with id: "${toolCallId}"`, AgentSDKErrorType.TOOL_CALL_NOT_FOUND);
550
+ }
551
+ if (!this.persistRememberedRule)
552
+ return false;
553
+ const matcher = ref.serverName !== undefined
554
+ ? { type: 'mcp', serverName: ref.serverName, toolName: ref.toolName }
555
+ : { type: 'builtin', name: ref.toolName };
556
+ await this.persistRememberedRule({ matcher, decision, source: 'remember' });
557
+ return true;
558
+ }
481
559
  /**
482
560
  * Emits a `chat-stream-started` telemetry event and returns the `startedAt` timestamp the
483
561
  * caller threads through to the stream wrapper / pre-stream error notifier so terminal
@@ -1,4 +1,4 @@
1
- import type { ToolDefinition } from '../types/tools.js';
1
+ import type { Decision, ToolDefinition, ToolPolicyRule } from '../types/tools.js';
2
2
  import type { MCPConfiguration } from '../mcp-config.js';
3
3
  import type { JSONWebToken } from '@salesforce/agentic-common';
4
4
  import type { Model, ModelName } from '../models/index.js';
@@ -59,6 +59,39 @@ export type AgentConfig = {
59
59
  * opaquely.
60
60
  */
61
61
  rules?: string[];
62
+ /**
63
+ * Ordered list of tool-approval rules. For each harness-executed tool call,
64
+ * `resolveToolApprovalPolicy` walks this list (concatenated after the SDK's
65
+ * `BUILT_IN_TOOL_POLICIES`, the harness's built-in array, and any harness
66
+ * factory rules) and returns a {@link Decision} via **cross-tier deny-wins /
67
+ * within-tier last-wins** precedence: any `'deny'` in any tier wins;
68
+ * otherwise the last matching non-deny rule decides. A later consumer rule
69
+ * beats an earlier one, and a `'remember'` rule appended by
70
+ * `approveToolCall(id, { remember: true })` beats an earlier built-in
71
+ * `'require-approval'` of the same matcher.
72
+ *
73
+ * Author rules with the structured {@link ToolPolicyRule} shape, or the
74
+ * `definePolicy({ Bash: 'deny', 'mcp:sfdx': 'require-approval' })` helper for
75
+ * the common cases. Per-MCP-server policy is expressed here via
76
+ * `{ matcher: { type: 'mcp', serverName } }` rules — `MCPServerConfig`
77
+ * carries no policy fields.
78
+ *
79
+ * Has no effect until a harness wires the resolver into its gate site
80
+ * (Phase 2). Until then, harnesses gate via the deprecated
81
+ * {@link StreamOptions.requireToolApproval}.
82
+ */
83
+ toolPolicies?: ToolPolicyRule[];
84
+ /**
85
+ * Fallback decision for tool invocations that match no rule in
86
+ * {@link toolPolicies} (or any built-in / harness tier). Defaults to
87
+ * `'allow'` for back-compat ("no policy ⇒ no gating," matching today's
88
+ * behavior when `requireToolApproval` is unset).
89
+ *
90
+ * Set to `'require-approval'` for a fail-closed posture — recommended for
91
+ * tenants whose MCP catalog includes un-annotated servers, since
92
+ * annotation-matcher rules don't fire on tools with no annotations.
93
+ */
94
+ defaultToolDecision?: Decision;
62
95
  };
63
96
  /**
64
97
  * Harness-facing configuration for creating/updating an agent.
@@ -102,6 +135,11 @@ export declare function toHarnessConfig(config: AgentConfig, orgJwt?: JSONWebTok
102
135
  * surface on the same stream so the consumer can render them as a
103
136
  * batch approval card). See `requireToolApproval` for the safety
104
137
  * note on choosing `batch`.
138
+ *
139
+ * @deprecated Tool-approval gating is moving to per-tool policy on
140
+ * {@link AgentConfig.toolPolicies}, resolved by `resolveToolApprovalPolicy`.
141
+ * The serial-vs-batch UX axis survives as {@link StreamOptions.batchApprovals}.
142
+ * Removed in a future major once harnesses and consumers have migrated.
105
143
  */
106
144
  export type ToolApprovalMode = 'serial' | 'batch';
107
145
  /**
@@ -115,6 +153,11 @@ export type ToolApprovalMode = 'serial' | 'batch';
115
153
  * - `true` → `'serial'` (back-compat shorthand for the original `boolean` shape).
116
154
  * - `'serial'` → `'serial'` (explicit, equivalent to `true`).
117
155
  * - `'batch'` → `'batch'`.
156
+ *
157
+ * @deprecated Superseded by per-tool policy on {@link AgentConfig.toolPolicies}
158
+ * (resolved by `resolveToolApprovalPolicy`) plus {@link StreamOptions.batchApprovals}
159
+ * for the UX axis. Kept functional for one release so harness PRs can migrate
160
+ * independently; removed in a future major.
118
161
  */
119
162
  export declare function resolveToolApprovalMode(requireToolApproval: boolean | ToolApprovalMode | undefined): ToolApprovalMode | undefined;
120
163
  /**
@@ -152,8 +195,33 @@ export type StreamOptions = {
152
195
  * Does not affect consumer-executed tools (those defined via
153
196
  * `AgentConfig.tools` without an execute handler) — the consumer
154
197
  * already controls execution for those via `submitToolResult()`.
198
+ *
199
+ * @deprecated Per-call all-or-nothing gating is superseded by per-tool
200
+ * policy on {@link AgentConfig.toolPolicies}. The serial-vs-batch UX axis
201
+ * moved to {@link batchApprovals}. Kept functional for one release so
202
+ * harness PRs can migrate independently; removed in a future major. New
203
+ * code should configure `AgentConfig.toolPolicies` and, if a batch UX is
204
+ * wanted, set `batchApprovals: true`.
155
205
  */
156
206
  requireToolApproval?: boolean | ToolApprovalMode;
207
+ /**
208
+ * When `true`, parallel approval-requests within a turn (the model emits
209
+ * multiple `tool_use` blocks in one batch, more than one of which resolves
210
+ * to `'require-approval'`) surface on the same stream so the consumer can
211
+ * render a batch approval card. The consumer's iterator MUST drain to
212
+ * natural park before settling.
213
+ *
214
+ * Defaults to `false` (serial: one approval-request at a time; the consumer
215
+ * settles before the next surfaces). Identical to pre-#447 behavior under
216
+ * `requireToolApproval: true`.
217
+ *
218
+ * Has no effect when no tool in the turn resolves to `'require-approval'` —
219
+ * there is nothing to batch. This is the self-documenting replacement for
220
+ * the UX half of the deprecated {@link requireToolApproval} enum; the
221
+ * "gating on/off" half moves to per-tool policy on
222
+ * {@link AgentConfig.toolPolicies}.
223
+ */
224
+ batchApprovals?: boolean;
157
225
  /**
158
226
  * Maximum number of LLM call steps the agent may take per `stream()` invocation.
159
227
  * Each step is one LLM call (which may produce text, tool calls, or both).
@@ -35,6 +35,11 @@ export function toHarnessConfig(config, orgJwt) {
35
35
  * - `true` → `'serial'` (back-compat shorthand for the original `boolean` shape).
36
36
  * - `'serial'` → `'serial'` (explicit, equivalent to `true`).
37
37
  * - `'batch'` → `'batch'`.
38
+ *
39
+ * @deprecated Superseded by per-tool policy on {@link AgentConfig.toolPolicies}
40
+ * (resolved by `resolveToolApprovalPolicy`) plus {@link StreamOptions.batchApprovals}
41
+ * for the UX axis. Kept functional for one release so harness PRs can migrate
42
+ * independently; removed in a future major.
38
43
  */
39
44
  export function resolveToolApprovalMode(requireToolApproval) {
40
45
  if (requireToolApproval === undefined || requireToolApproval === false)
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export type { Message, MessagePart, ImagePart, FilePart } from './types/messages.js';
2
2
  export type { ChatEvent, StartEvent, TextDeltaEvent, ReasoningDeltaEvent, ToolCallEvent, ToolCallDeltaEvent, ToolApprovalRequestEvent, ToolResultEvent, ToolProgressEvent, StepStartEvent, StepFinishEvent, ErrorEvent, FinishEvent, ChatStreamResult, } from './types/events.js';
3
- export type { ToolDefinition, ToolCallInfo, ToolResultInfo } from './types/tools.js';
3
+ export type { Decision, ToolDefinition, ToolCallInfo, ToolMatcher, ToolPolicyRule, ToolResultInfo, } from './types/tools.js';
4
+ export { BUILT_IN_TOOL_POLICIES, SKILL_BRIDGE_SERVER_ID, definePolicy, matcherMatches, resolveToolApprovalPolicy, } from './policy-resolver.js';
5
+ export type { ResolverResult, ResolverTiers, ToolInvocation } from './policy-resolver.js';
4
6
  export type { ContextUsage, FinishReason, UsageMetadata } from './types/usage.js';
5
7
  export type { AgentHooks, HooksForAgent, ToolResultRedactor, ToolResultRedactionInput, ToolResultRedactionResult, } from './types/redaction.js';
6
8
  export type { AgentConfig, HarnessAgentConfig, StreamOptions, ToolApprovalMode } from './harness/harness-config.js';
@@ -21,7 +23,7 @@ export type { LlmRequestEvent, LlmResponseEvent, WireCommunicationEvent, WireCom
21
23
  export { WireCommunicationFileWriter, type WireCommunicationEmitter, type WireCommunicationFileWriterOptions, } from './wire-communication-file-writer.js';
22
24
  export type { AgentHarness, HarnessFactory, WithAgentConfig, ConfigOf } from './harness/index.js';
23
25
  export { AgentSDKError, AgentSDKErrorType } from './errors.js';
24
- export type { AgentCreatedEvent, AgentDestroyedEvent, ChatStreamCompletedEvent, ChatStreamErrorEvent, ChatStreamStartedEvent, ChatStreamTrigger, McpServerDiscoveryCompletedEvent, McpServerDiscoveryFailedEvent, McpServerDiscoveryStartedEvent, McpServerStatusChangedEvent, SessionCreatedEvent, SessionDestroyedEvent, TelemetryEvent, TelemetryEventCallback, ToolApprovalRequestedEvent, ToolApprovalResolvedEvent, ToolExecutionCompletedEvent, ToolExecutionStartedEvent, } from './types/telemetry-events.js';
26
+ export type { AgentCreatedEvent, AgentDestroyedEvent, ChatStreamCompletedEvent, ChatStreamErrorEvent, ChatStreamStartedEvent, ChatStreamTrigger, McpServerDiscoveryCompletedEvent, McpServerDiscoveryFailedEvent, McpServerDiscoveryStartedEvent, McpServerStatusChangedEvent, SessionCreatedEvent, SessionDestroyedEvent, TelemetryEvent, TelemetryEventCallback, ToolApprovalPolicyResolvedEvent, ToolApprovalRequestedEvent, ToolApprovalResolvedEvent, ToolExecutionCompletedEvent, ToolExecutionStartedEvent, } from './types/telemetry-events.js';
25
27
  export type { LogLevel, LogRecord, Unsubscribe } from '@salesforce/agentic-common';
26
28
  export { resolveMcpServerHeaders } from './mcp-auth.js';
27
29
  export type { OrgConnection, OrgConnectionFactory } from '@salesforce/agentic-common';
package/dist/index.js CHANGED
@@ -2,6 +2,12 @@
2
2
  * Copyright 2026, Salesforce, Inc. All rights reserved.
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
+ // ── Tool-approval policy ─────────────────────────────────────────────
6
+ // The resolver, the SDK-shipped cross-harness rule set, the matcher, the
7
+ // ergonomics helper, and the well-known skill-bridge serverId. Harnesses wire
8
+ // `resolveToolApprovalPolicy` into their gate sites (Phase 2); consumers author
9
+ // `AgentConfig.toolPolicies` (directly or via `definePolicy`).
10
+ export { BUILT_IN_TOOL_POLICIES, SKILL_BRIDGE_SERVER_ID, definePolicy, matcherMatches, resolveToolApprovalPolicy, } from './policy-resolver.js';
5
11
  export { DEFAULT_MAX_STEPS, resolveToolApprovalMode } from './harness/harness-config.js';
6
12
  export { McpServerStatus, mcpServerConfigEqual } from './mcp-config.js';
7
13
  export { Model, ModelName, createClaudeModel, Models, validateMultimodalFiles } from './models/index.js';
@@ -0,0 +1,126 @@
1
+ import type { McpToolAnnotations } from './mcp-config.js';
2
+ import type { AgentConfig } from './harness/harness-config.js';
3
+ import type { Decision, ToolMatcher, ToolPolicyRule } from './types/tools.js';
4
+ /**
5
+ * Well-known MCP server identity for the capability-discovery meta-tools
6
+ * (`search_tools`, `load_tool`, `search_skills`, `load_skill`). Both harnesses
7
+ * expose these tools through this serverId — Claude via its in-process
8
+ * skill-bridge MCP server, Mastra gains it as a Phase 2a prerequisite
9
+ * ([#606](https://github.com/forcedotcom/agentic-dx/issues/606)). The
10
+ * `mcp:skill_bridge:*` rules in {@link BUILT_IN_TOOL_POLICIES} anchor on this
11
+ * constant so operator-side rule authors and harness implementations share one
12
+ * identity rather than each hard-coding the string.
13
+ */
14
+ export declare const SKILL_BRIDGE_SERVER_ID = "skill_bridge";
15
+ /**
16
+ * A single tool invocation the resolver decides on. The harness strips its own
17
+ * tool-name namespacing before constructing this — `serverName` / `toolName`
18
+ * are the un-namespaced MCP pair, `toolName` alone is the built-in name.
19
+ */
20
+ export type ToolInvocation = {
21
+ /** The bare tool name (un-namespaced for MCP tools). */
22
+ toolName: string;
23
+ /** Originating MCP server name; `undefined` for built-in / consumer tools. */
24
+ serverName?: string;
25
+ /** Discovered MCP annotations, when the server declared them. */
26
+ annotations?: McpToolAnnotations;
27
+ };
28
+ /**
29
+ * Optional rule tiers supplied by the harness layer.
30
+ *
31
+ * The shape extends additively when future tiers ship: a future
32
+ * `tiers.managed` slot will sit between consumer rules and `'remember'` rules
33
+ * without reshaping this type or the resolver signature.
34
+ */
35
+ export type ResolverTiers = {
36
+ /** Harness-shipped built-in rules (e.g. `MASTRA_BUILT_IN_TOOL_POLICIES`). */
37
+ harness?: ReadonlyArray<ToolPolicyRule>;
38
+ /**
39
+ * Synthetic rules from harness factory inputs during a deprecation window.
40
+ * Today: Claude's `bypassApprovalTools` translation. Empty after Phase 4.
41
+ */
42
+ factory?: ReadonlyArray<ToolPolicyRule>;
43
+ };
44
+ /** The outcome of resolving a tool invocation against the concatenated rule list. */
45
+ export type ResolverResult = {
46
+ /** The resolved decision. */
47
+ decision: Decision;
48
+ /**
49
+ * The rule that drove the decision: the last `'deny'` when the decision is
50
+ * `'deny'`, otherwise the last matching non-deny rule. `undefined` when no
51
+ * rule matched and the decision came from `defaultToolDecision`.
52
+ */
53
+ matchedRule?: ToolPolicyRule;
54
+ /** Every matching rule, in resolved-list order. Debug verbosity for sinks. */
55
+ allMatchedRules?: ToolPolicyRule[];
56
+ };
57
+ /**
58
+ * Cross-harness tool-approval rules shipped with the SDK. Frozen — adding
59
+ * entries is a soft contract change consumers will have noticed; removing or
60
+ * modifying an entry is a breaking change.
61
+ *
62
+ * Per the R4 layering invariant, this array holds **only harness-agnostic
63
+ * rules** — rules meaningful regardless of which harness is loaded. Harness-
64
+ * specific built-ins (Claude's `Bash`, Mastra's `updateWorkingMemory`) live in
65
+ * the respective harness package's `<HARNESS>_BUILT_IN_TOOL_POLICIES` array.
66
+ */
67
+ export declare const BUILT_IN_TOOL_POLICIES: ReadonlyArray<ToolPolicyRule>;
68
+ /**
69
+ * Tests a single matcher against a tool invocation. Pure; one switch on
70
+ * `matcher.type`.
71
+ *
72
+ * - `'builtin'` matches when the invocation has no `serverName` and its
73
+ * `toolName` equals the matcher name. (MCP tools always carry a `serverName`,
74
+ * so a built-in matcher never matches one even if the bare names collide.)
75
+ * - `'mcp'` matches an invocation that has a `serverName`, optionally narrowed
76
+ * by the matcher's `serverName` / `toolName` (each `undefined` field is a
77
+ * wildcard).
78
+ * - `'mcp-annotation'` matches an MCP invocation whose `annotations` carry
79
+ * every hint set on the matcher. An invocation with no annotations never
80
+ * matches.
81
+ */
82
+ export declare function matcherMatches(matcher: ToolMatcher, invocation: ToolInvocation): boolean;
83
+ /**
84
+ * Resolves the approval decision for a tool invocation.
85
+ *
86
+ * Concatenates the four rule tiers in precedence order
87
+ * `[...BUILT_IN_TOOL_POLICIES, ...tiers.harness, ...tiers.factory, ...agentConfig.toolPolicies]`
88
+ * and applies **cross-tier deny-wins / within-tier last-wins**:
89
+ *
90
+ * - If any matching rule is a `'deny'`, the decision is `'deny'` and
91
+ * `matchedRule` is the **last** `'deny'` (closest-to-consumer — most useful
92
+ * for "why was my tool blocked" debugging).
93
+ * - Otherwise, the **last** matching non-deny rule decides.
94
+ * - If nothing matched, the decision is `agentConfig.defaultToolDecision`, or
95
+ * `'allow'` when that is unset (back-compat: "no policy ⇒ no gating").
96
+ *
97
+ * **Pure function** — no I/O, no logging, no side effects. The unmatched-rule
98
+ * `LogBus.warn` is the harness's responsibility (after observing
99
+ * `matchedRule === undefined`), keeping the resolver dependency-free and
100
+ * trivially testable.
101
+ */
102
+ export declare function resolveToolApprovalPolicy(invocation: ToolInvocation, agentConfig: AgentConfig, tiers?: ResolverTiers): ResolverResult;
103
+ /**
104
+ * Compresses the common policy cases into a `ToolPolicyRule[]` with
105
+ * `source: 'agent-config'`. Each key is a tool selector, each value its
106
+ * {@link Decision}:
107
+ *
108
+ * ```ts
109
+ * definePolicy({
110
+ * Bash: 'deny', // builtin matcher
111
+ * 'mcp:sfdx': 'require-approval', // every tool from the 'sfdx' MCP server
112
+ * 'mcp:sfdx/list_orgs': 'allow', // one tool from the 'sfdx' server
113
+ * });
114
+ * ```
115
+ *
116
+ * Key grammar:
117
+ * - `'<name>'` → `{ type: 'builtin', name }`.
118
+ * - `'mcp:<server>'` → `{ type: 'mcp', serverName }`.
119
+ * - `'mcp:<server>/<tool>'` → `{ type: 'mcp', serverName, toolName }`.
120
+ *
121
+ * Consumers needing `mcp-annotation` matchers, the bare `{ type: 'mcp' }`
122
+ * server-agnostic wildcard, or any tool name containing `/` must author the
123
+ * structured {@link ToolPolicyRule} form directly — the shorthand intentionally
124
+ * covers only the simple `builtin` and `mcp:server[/tool]` cases.
125
+ */
126
+ export declare function definePolicy(spec: Record<string, Decision>): ToolPolicyRule[];
@@ -0,0 +1,175 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ /**
6
+ * Well-known MCP server identity for the capability-discovery meta-tools
7
+ * (`search_tools`, `load_tool`, `search_skills`, `load_skill`). Both harnesses
8
+ * expose these tools through this serverId — Claude via its in-process
9
+ * skill-bridge MCP server, Mastra gains it as a Phase 2a prerequisite
10
+ * ([#606](https://github.com/forcedotcom/agentic-dx/issues/606)). The
11
+ * `mcp:skill_bridge:*` rules in {@link BUILT_IN_TOOL_POLICIES} anchor on this
12
+ * constant so operator-side rule authors and harness implementations share one
13
+ * identity rather than each hard-coding the string.
14
+ */
15
+ export const SKILL_BRIDGE_SERVER_ID = 'skill_bridge';
16
+ /**
17
+ * Cross-harness tool-approval rules shipped with the SDK. Frozen — adding
18
+ * entries is a soft contract change consumers will have noticed; removing or
19
+ * modifying an entry is a breaking change.
20
+ *
21
+ * Per the R4 layering invariant, this array holds **only harness-agnostic
22
+ * rules** — rules meaningful regardless of which harness is loaded. Harness-
23
+ * specific built-ins (Claude's `Bash`, Mastra's `updateWorkingMemory`) live in
24
+ * the respective harness package's `<HARNESS>_BUILT_IN_TOOL_POLICIES` array.
25
+ */
26
+ export const BUILT_IN_TOOL_POLICIES = Object.freeze([
27
+ // MCP annotation-driven defaults. Under within-tier last-wins, a consumer
28
+ // rule appended later beats these — so a "remember: allow" on a
29
+ // destructiveHint tool actually takes effect.
30
+ { matcher: { type: 'mcp-annotation', destructiveHint: true }, decision: 'require-approval', source: 'built-in' },
31
+ { matcher: { type: 'mcp-annotation', readOnlyHint: true }, decision: 'allow', source: 'built-in' },
32
+ // Capability-discovery meta-tools — exposed by both harnesses through the
33
+ // well-known SKILL_BRIDGE_SERVER_ID MCP serverId (Mastra gains this
34
+ // identity per #606). Closes the consumer pain that motivated AFV PR #2455.
35
+ {
36
+ matcher: { type: 'mcp', serverName: SKILL_BRIDGE_SERVER_ID, toolName: 'search_tools' },
37
+ decision: 'allow',
38
+ source: 'built-in',
39
+ },
40
+ {
41
+ matcher: { type: 'mcp', serverName: SKILL_BRIDGE_SERVER_ID, toolName: 'load_tool' },
42
+ decision: 'allow',
43
+ source: 'built-in',
44
+ },
45
+ {
46
+ matcher: { type: 'mcp', serverName: SKILL_BRIDGE_SERVER_ID, toolName: 'search_skills' },
47
+ decision: 'allow',
48
+ source: 'built-in',
49
+ },
50
+ {
51
+ matcher: { type: 'mcp', serverName: SKILL_BRIDGE_SERVER_ID, toolName: 'load_skill' },
52
+ decision: 'allow',
53
+ source: 'built-in',
54
+ },
55
+ ]);
56
+ /**
57
+ * Tests a single matcher against a tool invocation. Pure; one switch on
58
+ * `matcher.type`.
59
+ *
60
+ * - `'builtin'` matches when the invocation has no `serverName` and its
61
+ * `toolName` equals the matcher name. (MCP tools always carry a `serverName`,
62
+ * so a built-in matcher never matches one even if the bare names collide.)
63
+ * - `'mcp'` matches an invocation that has a `serverName`, optionally narrowed
64
+ * by the matcher's `serverName` / `toolName` (each `undefined` field is a
65
+ * wildcard).
66
+ * - `'mcp-annotation'` matches an MCP invocation whose `annotations` carry
67
+ * every hint set on the matcher. An invocation with no annotations never
68
+ * matches.
69
+ */
70
+ export function matcherMatches(matcher, invocation) {
71
+ switch (matcher.type) {
72
+ case 'builtin':
73
+ return invocation.serverName === undefined && invocation.toolName === matcher.name;
74
+ case 'mcp':
75
+ return (invocation.serverName !== undefined &&
76
+ (matcher.serverName === undefined || invocation.serverName === matcher.serverName) &&
77
+ (matcher.toolName === undefined || invocation.toolName === matcher.toolName));
78
+ case 'mcp-annotation': {
79
+ if (invocation.serverName === undefined || invocation.annotations === undefined)
80
+ return false;
81
+ const { readOnlyHint, destructiveHint } = matcher;
82
+ if (readOnlyHint !== undefined && invocation.annotations.readOnlyHint !== readOnlyHint)
83
+ return false;
84
+ if (destructiveHint !== undefined && invocation.annotations.destructiveHint !== destructiveHint)
85
+ return false;
86
+ // At least one hint must be set on the matcher for it to mean
87
+ // anything — an empty annotation matcher matching every annotated
88
+ // tool would be a footgun. Treat a hint-less matcher as a non-match.
89
+ return readOnlyHint !== undefined || destructiveHint !== undefined;
90
+ }
91
+ }
92
+ }
93
+ /**
94
+ * Resolves the approval decision for a tool invocation.
95
+ *
96
+ * Concatenates the four rule tiers in precedence order
97
+ * `[...BUILT_IN_TOOL_POLICIES, ...tiers.harness, ...tiers.factory, ...agentConfig.toolPolicies]`
98
+ * and applies **cross-tier deny-wins / within-tier last-wins**:
99
+ *
100
+ * - If any matching rule is a `'deny'`, the decision is `'deny'` and
101
+ * `matchedRule` is the **last** `'deny'` (closest-to-consumer — most useful
102
+ * for "why was my tool blocked" debugging).
103
+ * - Otherwise, the **last** matching non-deny rule decides.
104
+ * - If nothing matched, the decision is `agentConfig.defaultToolDecision`, or
105
+ * `'allow'` when that is unset (back-compat: "no policy ⇒ no gating").
106
+ *
107
+ * **Pure function** — no I/O, no logging, no side effects. The unmatched-rule
108
+ * `LogBus.warn` is the harness's responsibility (after observing
109
+ * `matchedRule === undefined`), keeping the resolver dependency-free and
110
+ * trivially testable.
111
+ */
112
+ export function resolveToolApprovalPolicy(invocation, agentConfig, tiers) {
113
+ const allRules = [
114
+ ...BUILT_IN_TOOL_POLICIES,
115
+ ...(tiers?.harness ?? []),
116
+ ...(tiers?.factory ?? []),
117
+ ...(agentConfig.toolPolicies ?? []),
118
+ ];
119
+ const matched = allRules.filter((rule) => matcherMatches(rule.matcher, invocation));
120
+ // Cross-tier deny-wins: any 'deny' anywhere wins. Report the last one.
121
+ const denyRules = matched.filter((rule) => rule.decision === 'deny');
122
+ if (denyRules.length > 0) {
123
+ return { decision: 'deny', matchedRule: denyRules[denyRules.length - 1], allMatchedRules: matched };
124
+ }
125
+ // Within-tier last-wins: the last matching non-deny rule decides.
126
+ if (matched.length > 0) {
127
+ const last = matched[matched.length - 1];
128
+ return { decision: last.decision, matchedRule: last, allMatchedRules: matched };
129
+ }
130
+ // Nothing matched — fall back to the agent's default (or 'allow').
131
+ return { decision: agentConfig.defaultToolDecision ?? 'allow' };
132
+ }
133
+ /**
134
+ * Compresses the common policy cases into a `ToolPolicyRule[]` with
135
+ * `source: 'agent-config'`. Each key is a tool selector, each value its
136
+ * {@link Decision}:
137
+ *
138
+ * ```ts
139
+ * definePolicy({
140
+ * Bash: 'deny', // builtin matcher
141
+ * 'mcp:sfdx': 'require-approval', // every tool from the 'sfdx' MCP server
142
+ * 'mcp:sfdx/list_orgs': 'allow', // one tool from the 'sfdx' server
143
+ * });
144
+ * ```
145
+ *
146
+ * Key grammar:
147
+ * - `'<name>'` → `{ type: 'builtin', name }`.
148
+ * - `'mcp:<server>'` → `{ type: 'mcp', serverName }`.
149
+ * - `'mcp:<server>/<tool>'` → `{ type: 'mcp', serverName, toolName }`.
150
+ *
151
+ * Consumers needing `mcp-annotation` matchers, the bare `{ type: 'mcp' }`
152
+ * server-agnostic wildcard, or any tool name containing `/` must author the
153
+ * structured {@link ToolPolicyRule} form directly — the shorthand intentionally
154
+ * covers only the simple `builtin` and `mcp:server[/tool]` cases.
155
+ */
156
+ export function definePolicy(spec) {
157
+ return Object.entries(spec).map(([key, decision]) => ({
158
+ matcher: parsePolicyKey(key),
159
+ decision,
160
+ source: 'agent-config',
161
+ }));
162
+ }
163
+ /** Parses a {@link definePolicy} key into its structured {@link ToolMatcher}. */
164
+ function parsePolicyKey(key) {
165
+ if (!key.startsWith('mcp:')) {
166
+ return { type: 'builtin', name: key };
167
+ }
168
+ const rest = key.slice('mcp:'.length);
169
+ const slash = rest.indexOf('/');
170
+ if (slash === -1) {
171
+ return { type: 'mcp', serverName: rest };
172
+ }
173
+ return { type: 'mcp', serverName: rest.slice(0, slash), toolName: rest.slice(slash + 1) };
174
+ }
175
+ //# sourceMappingURL=policy-resolver.js.map