@salesforce/sfdx-agent-harness-mastra 0.24.0 → 0.25.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 CHANGED
@@ -3,6 +3,12 @@
3
3
  All notable changes to `@salesforce/sfdx-agent-harness-mastra` are documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.25.0] - 2026-06-29
7
+
8
+ ### Features
9
+ - **harness-mastra**: consult tool-approval policy resolver at gate sites @W-23090028 ([#622](https://github.com/forcedotcom/agentic-dx/pull/622))
10
+ - **harness-mastra**: stable skill_bridge identity for capability-discovery meta-tools @W-23177289 ([#621](https://github.com/forcedotcom/agentic-dx/pull/621))
11
+
6
12
  ## [0.24.0] - 2026-06-24
7
13
 
8
14
  ### Chores
package/README.md CHANGED
@@ -44,10 +44,12 @@ Linux and macOS release the handle eagerly; the retry options are no-ops on thos
44
44
 
45
45
  ### Core
46
46
 
47
- | Export | Kind | Description |
48
- | ---------------------------- | --------- | -------------------------------------------------------------------------------------- |
49
- | `MastraHarnessFactory` | class | `HarnessFactory` that produces a Mastra-backed `AgentHarness` bound to a storage root. |
50
- | `MastraHarnessFactoryConfig` | interface | Configuration for `MastraHarnessFactory` (`toolApprovalTimeoutMs?`). |
47
+ | Export | Kind | Description |
48
+ | ------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
+ | `MastraHarnessFactory` | class | `HarnessFactory` that produces a Mastra-backed `AgentHarness` bound to a storage root. |
50
+ | `MastraHarnessFactoryConfig` | interface | Configuration for `MastraHarnessFactory` (`toolApprovalTimeoutMs?`). |
51
+ | `MASTRA_BUILT_IN_TOOL_POLICIES` | const | Frozen `ReadonlyArray<ToolPolicyRule>` the harness feeds the SDK's `resolveToolApprovalPolicy` as the `tiers.harness` slice — `allow` rules for Mastra's built-in `updateWorkingMemory` / `skill` / `skill_search` / `skill_read` tools. |
52
+ | `MASTRA_BUILT_IN_TOOL_NAMES` | const | The four built-in tool leaf names `MASTRA_BUILT_IN_TOOL_POLICIES` covers. |
51
53
 
52
54
  Connectivity (model + provider auth) is supplied by the SDK's `AgentConnectivityResolver`. Use the bundled
53
55
  `DefaultAgentConnectivityResolver` for the standard Salesforce LLM Gateway path, or `ApiKeyConnectivityResolver` for
@@ -191,9 +193,33 @@ for await (const event of result.eventStream) {
191
193
  }
192
194
  ```
193
195
 
196
+ ### Tool-approval policy
197
+
198
+ The harness gates harness-executed tools through the SDK's `resolveToolApprovalPolicy` (configure rules via
199
+ `AgentConfig.toolPolicies` / `AgentConfig.defaultToolDecision` — see the SDK README). For each non-consumer tool the
200
+ harness resolves a decision and acts on it:
201
+
202
+ - **`allow`** — the tool runs without surfacing a `tool-approval-request`.
203
+ - **`deny`** — the tool is refused and the model receives a `tool-result(isError=true)` so it can recover; no
204
+ `tool-approval-request` surfaces.
205
+ - **`require-approval`** — a `tool-approval-request` is emitted and the workflow suspends until the consumer settles.
206
+
207
+ Gating is **opt-in**: it engages only when the agent's config sets `toolPolicies` or `defaultToolDecision`, or the
208
+ `chat()` call sets the deprecated `requireToolApproval` or `batchApprovals`. With none configured, tools run without
209
+ gating (no suspension), matching the SDK's "no policy ⇒ no gating" default. The harness adds
210
+ `MASTRA_BUILT_IN_TOOL_POLICIES` (Mastra's `updateWorkingMemory` / `skill` / `skill_search` / `skill_read` built-ins) and
211
+ the SDK's `skill_bridge` meta-tool rules as `allow` defaults, so capability-discovery never prompts. Consumer-executed
212
+ tools (`AgentConfig.tools`) always bypass the gate — their execution is the consumer's responsibility via
213
+ `submitToolResult`. Set `StreamOptions.batchApprovals: true` to surface parallel `require-approval` requests on one
214
+ stream for a batch-approval UI.
215
+
216
+ > `StreamOptions.requireToolApproval` is **deprecated** — superseded by per-tool `AgentConfig.toolPolicies`. It still
217
+ > works for one release: a truthy value engages all-or-nothing gating (every non-consumer tool requires approval), and
218
+ > `'batch'` maps onto `batchApprovals`.
219
+
194
220
  ### Tool approval timeout
195
221
 
196
- When `requireToolApproval: true` is set on a `chat()` call, the harness suspends the workflow on each
222
+ When the approval gate is engaged and a tool resolves to `require-approval`, the harness suspends the workflow on each
197
223
  `tool-approval-request` and waits for the consumer to call `approveToolCall(...)` / `declineToolCall(...)` /
198
224
  `submitToolResult(...)`. To bound how long a pending approval can wait — for example, when a UI process crashes or a
199
225
  user walks away — the factory accepts a `toolApprovalTimeoutMs` option. It defaults to `600_000` (10 minutes) and
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { MastraHarnessFactory, type MastraHarnessFactoryConfig } from './mastra-harness-factory.js';
2
2
  export type { MastraAgentHarness } from './mastra-harness.js';
3
+ export { MASTRA_BUILT_IN_TOOL_NAMES, MASTRA_BUILT_IN_TOOL_POLICIES } from './mastra-built-in-policies.js';
3
4
  export { type MastraAgentConfig, type MastraSkillSearchConfig, type MastraToolSearchConfig, type SkillSearchHandle, type ToolSearchHandle, } from './tool-search.js';
package/dist/index.js CHANGED
@@ -3,6 +3,10 @@
3
3
  * See LICENSE.txt for license terms.
4
4
  */
5
5
  export { MastraHarnessFactory } from './mastra-harness-factory.js';
6
+ // Harness-specific built-in tool-approval rules — the `tiers.harness` slice the
7
+ // harness feeds `resolveToolApprovalPolicy`. Exported so consumers and tests can
8
+ // inspect which Mastra built-ins the harness auto-approves.
9
+ export { MASTRA_BUILT_IN_TOOL_NAMES, MASTRA_BUILT_IN_TOOL_POLICIES } from './mastra-built-in-policies.js';
6
10
  export {} from './tool-search.js';
7
11
  // Mastra drives requests through provider-native pass-through endpoints —
8
12
  // Bedrock-Anthropic, direct Anthropic Messages API, OpenAI Responses, and
@@ -1,7 +1,7 @@
1
1
  import type { Agent } from '@mastra/core/agent';
2
2
  import type { RequestContext } from '@mastra/core/request-context';
3
3
  import type { ChunkType } from '@mastra/core/stream';
4
- import { type ChatEvent, type McpToolAnnotations } from '@salesforce/sfdx-agent-sdk';
4
+ import { type ChatEvent, type Decision as PolicyDecision, type McpToolAnnotations, type ToolInvocation } from '@salesforce/sfdx-agent-sdk';
5
5
  import { GenSink } from '@salesforce/sfdx-agent-sdk/harness';
6
6
  import type { LogBus } from '@salesforce/agentic-common';
7
7
  /**
@@ -36,35 +36,57 @@ export interface MastraApprovalCoordinatorOptions {
36
36
  requestContext: RequestContext;
37
37
  toolApprovalTimeoutMs: number;
38
38
  /**
39
- * Approval-emission mode (#447). Defaults to `undefined` (no gating).
39
+ * Per-tool approval-decision callback (Phase 2a). When defined, gating is
40
+ * **active**: the harness passed `requireToolApproval: true` to Mastra so
41
+ * every non-consumer tool suspends with a `tool-call-approval` chunk, and
42
+ * the coordinator consults this callback to decide each one:
40
43
  *
41
- * - `undefined`: stream is run without `requireToolApproval`. Mastra
42
- * suspends only on tools whose per-tool `requireApproval` is `true`
43
- * (consumer tools post-2c are `false` so they don't suspend; the
44
- * bridge's parker `execute` handles their lifecycle natively).
45
- * - `'serial'`: stream runs with `requireToolApproval: true`. The
46
- * coordinator emits one `tool-approval-request` per natural
47
- * `tool-call-approval` chunk — Mastra serializes one approval per
48
- * suspension.
49
- * - `'batch'`: same as serial except `tool-approval-request` is
50
- * front-loaded off the adapter's `tool-call` events so parallel
51
- * `tool_use` batches all surface their approval-requests on the
52
- * single per-turn stream.
44
+ * - `'allow'` → silent approve (no `tool-approval-request` ChatEvent; the
45
+ * pump drives `agent.approveToolCall`).
46
+ * - `'deny'` → silent decline (no ChatEvent; the pump drives
47
+ * `agent.declineToolCall`, which surfaces a `tool-result(isError=true)`
48
+ * to the model so it can recover).
49
+ * - `'require-approval'` → emit a `tool-approval-request` and park
50
+ * (front-loaded off `tool-call` when `batchApprovals`, else on the
51
+ * natural `tool-call-approval` chunk).
53
52
  *
54
- * Note: in approval mode, consumer-executed tools still bypass the
55
- * approval gate (per the `StreamOptions.requireToolApproval` JSDoc) —
56
- * the coordinator silently calls `agent.approveToolCall(id)` for them
57
- * without surfacing a `tool-approval-request` ChatEvent.
53
+ * `undefined` means gating is **off** (the consumer configured no policy
54
+ * and no `requireToolApproval` / `batchApprovals`): the harness does not
55
+ * pass `requireToolApproval`, Mastra suspends nothing, and this callback is
56
+ * never consulted — today's no-gating behavior.
57
+ *
58
+ * The coordinator builds the {@link ToolInvocation} it passes here from the
59
+ * same identity sources it uses for event enrichment
60
+ * (`skillBridgeToolNames` + `lookupMcpToolMetadata`), so the identity that
61
+ * drives the decision can never drift from the one stamped on the emitted
62
+ * ChatEvent. The callback itself closes over `AgentConfig` +
63
+ * `MASTRA_BUILT_IN_TOOL_POLICIES` in the harness.
64
+ *
65
+ * Consumer-executed tools bypass this callback entirely — the structural
66
+ * `requireApproval: false` parker gate is their primary contract; they are
67
+ * silently auto-approved before the resolver is consulted.
68
+ */
69
+ lookupPolicy?: (invocation: ToolInvocation) => PolicyDecision;
70
+ /**
71
+ * When `true`, `require-approval` decisions are front-loaded off the
72
+ * adapter's `tool-call` events so parallel `tool_use` batches all surface
73
+ * their approval-requests on the single per-turn stream (the consumer
74
+ * iterates to natural park). When `false`, each `require-approval` surfaces
75
+ * on its natural `tool-call-approval` suspension chunk — one at a time.
76
+ *
77
+ * Replaces the serial/batch role of the deprecated
78
+ * `StreamOptions.requireToolApproval` enum (#447). No effect when
79
+ * {@link lookupPolicy} is `undefined` (gating off) or when no tool resolves
80
+ * to `require-approval`.
58
81
  */
59
- mode?: 'serial' | 'batch';
82
+ batchApprovals?: boolean;
60
83
  /**
61
84
  * Set of tool names registered via `AgentConfig.tools`. The coordinator
62
85
  * uses this to route `tool-call-approval` chunks: if the toolName is in
63
86
  * the set, auto-approve silently (consumer tools bypass the gate); else
64
- * surface a `tool-approval-request` ChatEvent and park.
87
+ * consult {@link lookupPolicy}.
65
88
  *
66
- * Empty set when no consumer tools are configured — every approval
67
- * chunk surfaces to the consumer in approval mode.
89
+ * Empty set when no consumer tools are configured.
68
90
  */
69
91
  consumerToolNames: ReadonlySet<string>;
70
92
  /**
@@ -74,6 +96,23 @@ export interface MastraApprovalCoordinatorOptions {
74
96
  * `tool-result` / `tool-approval-request` events.
75
97
  */
76
98
  lookupMcpToolMetadata?: MastraMcpToolMetadataLookup;
99
+ /**
100
+ * Capability-discovery meta-tool leaf names that are ACTIVE on this turn
101
+ * (#606). The harness populates this from the live
102
+ * `ToolSearchProcessor` / `SkillSearchProcessor` so it contains
103
+ * `search_tools` / `load_tool` only when tool-search is configured and
104
+ * `search_skills` / `load_skill` only when skill-search is configured.
105
+ *
106
+ * The coordinator stamps `serverName: SKILL_BRIDGE_SERVER_ID` onto
107
+ * `tool-call` / `tool-result` / `tool-approval-request` events for these
108
+ * leaves so the SDK's `mcp:skill_bridge:*` built-in rules auto-approve
109
+ * them, reaching parity with Claude's in-process `skill_bridge` MCP
110
+ * server. Gating on the ACTIVE set (not the leaf name alone) is the
111
+ * security boundary: a consumer-declared tool named `load_skill` with no
112
+ * `skillSearch` configured is never in this set, so it cannot inherit the
113
+ * exemption. Empty set when neither processor is active.
114
+ */
115
+ skillBridgeToolNames?: ReadonlySet<string>;
77
116
  logBus?: LogBus;
78
117
  }
79
118
  /**
@@ -82,10 +121,13 @@ export interface MastraApprovalCoordinatorOptions {
82
121
  * sink, single multi-source pump loop, and single set of teardown paths.
83
122
  *
84
123
  * Responsibilities:
85
- * - Approval gating in `'serial'` / `'batch'` mode: emit
86
- * `tool-approval-request` for non-consumer tools, drive
87
- * `agent.approveToolCall(...)` / `declineToolCall(...)` when the
88
- * consumer settles.
124
+ * - Policy-driven approval gating (Phase 2a): for each non-consumer tool,
125
+ * consult `lookupPolicy` — `allow` silently approves, `deny` silently
126
+ * declines (Mastra synthesizes a `tool-result(isError=true)`),
127
+ * `require-approval` emits a `tool-approval-request` and drives
128
+ * `agent.approveToolCall(...)` / `declineToolCall(...)` when the consumer
129
+ * settles. `batchApprovals` controls whether parallel `require-approval`
130
+ * requests front-load onto one stream (#447).
89
131
  * - Consumer-tool plumbing: every `AgentConfig.tools` entry has
90
132
  * `requireApproval: false` and a Promise-returning `execute` that parks
91
133
  * on the coordinator's `parkConsumerToolCall(...)`. Mastra natively
@@ -114,14 +156,27 @@ export declare class MastraApprovalCoordinator {
114
156
  private readonly memory;
115
157
  private readonly requestContext;
116
158
  private readonly toolApprovalTimeoutMs;
117
- private readonly mode;
159
+ private readonly lookupPolicy?;
160
+ private readonly batchApprovals;
118
161
  private readonly consumerToolNames;
119
162
  private readonly lookupMcpToolMetadata?;
163
+ private readonly skillBridgeToolNames;
120
164
  private readonly adapter;
121
165
  /** Single per-turn sink. Issue #529 contract: one stream per turn. */
122
166
  private readonly initial;
123
167
  /** Buffered consumer decisions keyed by `toolCallId`. */
124
168
  private readonly decisions;
169
+ /**
170
+ * ToolCallIds the policy resolver decided `'deny'` (Phase 2a). The pump
171
+ * drives `agent.declineToolCall` for these, and Mastra emits a native
172
+ * `tool-result` on the resumed stream with `isError: false` + the text
173
+ * "Tool call was not approved by the user". The SDK `Decision` contract
174
+ * requires a denied tool to surface as `tool-result(isError=true)`, so the
175
+ * coordinator re-stamps `isError: true` on the matching result as it routes
176
+ * (see {@link routeEnriched}). Tracked separately from consumer-driven
177
+ * declines, which keep Mastra's native shape.
178
+ */
179
+ private readonly policyDeniedIds;
125
180
  /** ToolCallIds for which a `tool-approval-request` ChatEvent has been emitted. */
126
181
  private readonly emittedApprovals;
127
182
  /** ToolCallIds the consumer has settled. Rejects double-settle. */
@@ -352,6 +407,56 @@ export declare class MastraApprovalCoordinator {
352
407
  */
353
408
  private routeEnriched;
354
409
  private maybeEnrich;
410
+ /**
411
+ * Re-stamp `isError: true` on the `tool-result` for a policy-denied tool
412
+ * call (Phase 2a). When the resolver returns `'deny'`, the coordinator
413
+ * drives `agent.declineToolCall`; Mastra emits a native `tool-result` with
414
+ * `isError: false` and the text "Tool call was not approved by the user".
415
+ * The SDK `Decision` contract requires a denied tool to surface as
416
+ * `tool-result(isError=true)` so consumers and the model treat it as a
417
+ * failed call, not a successful one — so we flip the flag exactly once, on
418
+ * the matching result. Consumer-driven declines (not policy denials) are not
419
+ * in `policyDeniedIds` and keep Mastra's native shape.
420
+ */
421
+ private policyDenyStamp;
422
+ /**
423
+ * Build the {@link ToolInvocation} the policy resolver matches against, and
424
+ * the single source of the `serverName` / `annotations` identity stamped on
425
+ * emitted events ({@link maybeEnrich}). One helper feeds both so the
426
+ * identity that drives the approval decision can never drift from the one
427
+ * the consumer observes on the `tool-call` / `tool-result` /
428
+ * `tool-approval-request` ChatEvent.
429
+ *
430
+ * Three disjoint cases:
431
+ *
432
+ * - **Capability-discovery meta-tool active this turn (#606)** — wins first.
433
+ * Gets the well-known `SKILL_BRIDGE_SERVER_ID` identity so the SDK's
434
+ * `mcp:skill_bridge:*` built-in rules auto-approve it, matching Claude's
435
+ * in-process skill-bridge MCP server. These tools are flat (un-namespaced),
436
+ * so the chunk's `toolName` is already the bare leaf. This branch fires
437
+ * even when the agent has no MCP servers (`lookupMcpToolMetadata` unset).
438
+ * - **Genuinely MCP-discovered tool** — recover the bare un-namespaced leaf
439
+ * the SDK's `mcp` matcher compares against by stripping Mastra's
440
+ * `${serverName}_` namespace prefix.
441
+ * - **Built-in / consumer / workspace tool** — bare name, no `serverName`.
442
+ * Matches `{ type: 'builtin', name }` rules (e.g. the Mastra built-ins in
443
+ * `MASTRA_BUILT_IN_TOOL_POLICIES`).
444
+ *
445
+ * The meta-tool and MCP-catalog identities are disjoint: a real MCP tool is
446
+ * never in `skillBridgeToolNames`, and a meta-tool is never in the catalog.
447
+ */
448
+ private buildToolInvocation;
449
+ /**
450
+ * Resolve the approval decision for a non-consumer tool via the harness-
451
+ * supplied {@link MastraApprovalCoordinatorOptions.lookupPolicy} callback.
452
+ * Falls back to `'require-approval'` when no policy callback is wired — a
453
+ * defensive default so a future caller that passes `requireToolApproval`
454
+ * without a resolver surfaces the request rather than silently allowing.
455
+ * In practice this fallback is unreachable: gating is active iff
456
+ * `lookupPolicy` is defined, and only then does Mastra suspend non-consumer
457
+ * tools.
458
+ */
459
+ private policyFor;
355
460
  private emitApprovalRequest;
356
461
  private armApprovalTimer;
357
462
  private cancelTimer;
@@ -5,6 +5,16 @@
5
5
  import { AgentSDKError, AgentSDKErrorType, } from '@salesforce/sfdx-agent-sdk';
6
6
  import { GenSink } from '@salesforce/sfdx-agent-sdk/harness';
7
7
  import { MastraEventAdapter } from './mastra-event-adapter.js';
8
+ import { SKILL_BRIDGE_SERVER_ID } from './skill-bridge.js';
9
+ /**
10
+ * True when `serverName` identifies a genuinely MCP-discovered tool, as opposed
11
+ * to the synthetic `skill_bridge` identity stamped onto capability-discovery
12
+ * meta-tools (#606) or `undefined` for built-ins / consumer / workspace tools.
13
+ * Used to scope MCP-progress attribution to real MCP servers.
14
+ */
15
+ function isRealMcpServer(serverName) {
16
+ return serverName !== undefined && serverName !== SKILL_BRIDGE_SERVER_ID;
17
+ }
8
18
  function pickIdWithDecision(pendingIds, decisions) {
9
19
  for (const id of pendingIds) {
10
20
  if (decisions.has(id))
@@ -18,10 +28,13 @@ function pickIdWithDecision(pendingIds, decisions) {
18
28
  * sink, single multi-source pump loop, and single set of teardown paths.
19
29
  *
20
30
  * Responsibilities:
21
- * - Approval gating in `'serial'` / `'batch'` mode: emit
22
- * `tool-approval-request` for non-consumer tools, drive
23
- * `agent.approveToolCall(...)` / `declineToolCall(...)` when the
24
- * consumer settles.
31
+ * - Policy-driven approval gating (Phase 2a): for each non-consumer tool,
32
+ * consult `lookupPolicy` — `allow` silently approves, `deny` silently
33
+ * declines (Mastra synthesizes a `tool-result(isError=true)`),
34
+ * `require-approval` emits a `tool-approval-request` and drives
35
+ * `agent.approveToolCall(...)` / `declineToolCall(...)` when the consumer
36
+ * settles. `batchApprovals` controls whether parallel `require-approval`
37
+ * requests front-load onto one stream (#447).
25
38
  * - Consumer-tool plumbing: every `AgentConfig.tools` entry has
26
39
  * `requireApproval: false` and a Promise-returning `execute` that parks
27
40
  * on the coordinator's `parkConsumerToolCall(...)`. Mastra natively
@@ -50,14 +63,27 @@ export class MastraApprovalCoordinator {
50
63
  memory;
51
64
  requestContext;
52
65
  toolApprovalTimeoutMs;
53
- mode;
66
+ lookupPolicy;
67
+ batchApprovals;
54
68
  consumerToolNames;
55
69
  lookupMcpToolMetadata;
70
+ skillBridgeToolNames;
56
71
  adapter;
57
72
  /** Single per-turn sink. Issue #529 contract: one stream per turn. */
58
73
  initial = new GenSink();
59
74
  /** Buffered consumer decisions keyed by `toolCallId`. */
60
75
  decisions = new Map();
76
+ /**
77
+ * ToolCallIds the policy resolver decided `'deny'` (Phase 2a). The pump
78
+ * drives `agent.declineToolCall` for these, and Mastra emits a native
79
+ * `tool-result` on the resumed stream with `isError: false` + the text
80
+ * "Tool call was not approved by the user". The SDK `Decision` contract
81
+ * requires a denied tool to surface as `tool-result(isError=true)`, so the
82
+ * coordinator re-stamps `isError: true` on the matching result as it routes
83
+ * (see {@link routeEnriched}). Tracked separately from consumer-driven
84
+ * declines, which keep Mastra's native shape.
85
+ */
86
+ policyDeniedIds = new Set();
61
87
  /** ToolCallIds for which a `tool-approval-request` ChatEvent has been emitted. */
62
88
  emittedApprovals = new Set();
63
89
  /** ToolCallIds the consumer has settled. Rejects double-settle. */
@@ -149,9 +175,11 @@ export class MastraApprovalCoordinator {
149
175
  this.memory = opts.memory;
150
176
  this.requestContext = opts.requestContext;
151
177
  this.toolApprovalTimeoutMs = opts.toolApprovalTimeoutMs;
152
- this.mode = opts.mode;
178
+ this.lookupPolicy = opts.lookupPolicy;
179
+ this.batchApprovals = opts.batchApprovals ?? false;
153
180
  this.consumerToolNames = opts.consumerToolNames;
154
181
  this.lookupMcpToolMetadata = opts.lookupMcpToolMetadata;
182
+ this.skillBridgeToolNames = opts.skillBridgeToolNames ?? new Set();
155
183
  this.adapter = new MastraEventAdapter(opts.logBus);
156
184
  }
157
185
  // ───────────────────────── lifecycle / sinks ────────────────────────────
@@ -314,6 +342,10 @@ export class MastraApprovalCoordinator {
314
342
  this.initial.push({ type: 'finish', finishReason: 'error' });
315
343
  this.initial.end();
316
344
  this.pendingSuspendIds.clear();
345
+ // The turn is over; any deny re-stamp that never landed (the declined
346
+ // tool-result will never arrive now) is moot. Clear so the set doesn't
347
+ // outlive the turn.
348
+ this.policyDeniedIds.clear();
317
349
  this.runTeardownCallbacks();
318
350
  }
319
351
  // ───────────────────────── settle methods ───────────────────────────────
@@ -536,13 +568,16 @@ export class MastraApprovalCoordinator {
536
568
  }
537
569
  if (event.type === 'tool-call') {
538
570
  this.toolCallArgs.set(event.toolCallId, event.args);
539
- // In batch mode, front-load `tool-approval-request` for
540
- // non-consumer tools so parallel tool_use blocks all
541
- // surface their approvals on the single per-turn
542
- // stream. Consumer tools always bypass the gate.
543
- if (this.mode === 'batch' &&
571
+ // Batch mode (#447): front-load the `tool-approval-request`
572
+ // for non-consumer tools whose policy is `require-approval`
573
+ // so parallel tool_use blocks all surface their approvals on
574
+ // the single per-turn stream. `allow` / `deny` need no
575
+ // ChatEvent — they're applied silently at the suspension
576
+ // chunk. Consumer tools always bypass the gate.
577
+ if (this.batchApprovals &&
544
578
  !this.consumerToolNames.has(event.toolName) &&
545
- !this.emittedApprovals.has(event.toolCallId)) {
579
+ !this.emittedApprovals.has(event.toolCallId) &&
580
+ this.policyFor(event.toolName) === 'require-approval') {
546
581
  this.emitApprovalRequest(event.toolCallId, event.toolName, event.args);
547
582
  }
548
583
  this.routeEnriched(event);
@@ -567,10 +602,37 @@ export class MastraApprovalCoordinator {
567
602
  // non-approval-mode consumer-tool flow.
568
603
  this.settledByCoordinator.add(tc.toolCallId);
569
604
  this.decisions.set(tc.toolCallId, { kind: 'approve' });
605
+ continue;
570
606
  }
571
- else if (this.mode === 'serial' && !this.emittedApprovals.has(tc.toolCallId)) {
572
- // Serial mode: emit on the natural chunk (didn't
573
- // pre-emit in tool-call routing).
607
+ // Non-consumer tool: consult the policy resolver (Phase 2a).
608
+ const decision = this.policyFor(tc.toolName);
609
+ if (decision === 'allow') {
610
+ // Silent approve — no `tool-approval-request` surfaces.
611
+ // The pump drives `agent.approveToolCall` from the
612
+ // buffered decision; the tool then executes natively
613
+ // and its `tool-result` flows on the resumed stream.
614
+ this.settledByCoordinator.add(tc.toolCallId);
615
+ this.decisions.set(tc.toolCallId, { kind: 'approve' });
616
+ }
617
+ else if (decision === 'deny') {
618
+ // Silent decline — no `tool-approval-request` surfaces.
619
+ // The pump drives `agent.declineToolCall`; Mastra emits a
620
+ // native `tool-result` on the resumed stream (with
621
+ // `isError: false` + "Tool call was not approved by the
622
+ // user") so the model can recover. We mark the id
623
+ // `policyDeniedIds` so `routeEnriched` re-stamps that
624
+ // result `isError: true` to honor the SDK `Decision`
625
+ // contract. The un-emitted approval is marked
626
+ // coordinator-settled so a (spurious) consumer
627
+ // `approveToolCall` for this id is a no-op rather than a
628
+ // TOOL_CALL_NOT_FOUND throw.
629
+ this.settledByCoordinator.add(tc.toolCallId);
630
+ this.policyDeniedIds.add(tc.toolCallId);
631
+ this.decisions.set(tc.toolCallId, { kind: 'decline' });
632
+ }
633
+ else if (!this.batchApprovals && !this.emittedApprovals.has(tc.toolCallId)) {
634
+ // require-approval, serial: emit on the natural chunk
635
+ // (batch front-loaded it off the prior `tool-call`).
574
636
  this.emitApprovalRequest(tc.toolCallId, tc.toolName, tc.args);
575
637
  }
576
638
  continue;
@@ -665,57 +727,137 @@ export class MastraApprovalCoordinator {
665
727
  routeEnriched(event) {
666
728
  if (this.aborted || this.disposed)
667
729
  return;
668
- const enriched = this.maybeEnrich(event);
730
+ const enriched = this.policyDenyStamp(this.maybeEnrich(event));
669
731
  // Issue #591: track in-flight MCP tool call so progress notifications
670
732
  // arriving via `progress.onUpdate(...)` can be attributed to the
671
- // right `toolCallId`. Set on enriched (i.e. MCP-namespaced) tool-call;
672
- // cleared on the matching tool-result. Non-MCP tools don't carry
673
- // `serverName` (the lookup returns undefined) so this never fires
674
- // for built-ins, consumer-declared tools, or workspace tools.
675
- if (enriched.type === 'tool-call' && enriched.serverName !== undefined) {
733
+ // right `toolCallId`. Set on a genuinely MCP-discovered tool-call;
734
+ // cleared on the matching tool-result. Built-ins, consumer-declared
735
+ // tools, and workspace tools carry no `serverName` so this never
736
+ // fires for them.
737
+ //
738
+ // #606: the capability-discovery meta-tools now carry a `serverName`
739
+ // too (the synthetic `skill_bridge` identity), but they are NOT MCP
740
+ // tools and emit no `notifications/progress`. Excluding them keeps a
741
+ // real MCP server's progress notification from being mis-attributed to
742
+ // an interleaved meta-tool call.
743
+ if (enriched.type === 'tool-call' && isRealMcpServer(enriched.serverName)) {
676
744
  this.currentMcpToolCallId = enriched.toolCallId;
677
745
  this.toolNameForCallId.set(enriched.toolCallId, enriched.toolName);
678
746
  }
679
747
  else if (enriched.type === 'tool-result' &&
680
- enriched.serverName !== undefined &&
748
+ isRealMcpServer(enriched.serverName) &&
681
749
  this.currentMcpToolCallId === enriched.toolCallId) {
682
750
  this.currentMcpToolCallId = null;
683
751
  }
684
752
  this.initial.push(enriched);
685
753
  }
686
754
  maybeEnrich(event) {
687
- if (!this.lookupMcpToolMetadata)
688
- return event;
689
755
  if (event.type !== 'tool-call' && event.type !== 'tool-result' && event.type !== 'tool-approval-request') {
690
756
  return event;
691
757
  }
692
758
  const toolName = event.type === 'tool-approval-request' ? event.toolCall.toolName : event.toolName;
693
- const metadata = this.lookupMcpToolMetadata(toolName);
694
- if (!metadata)
759
+ const invocation = this.buildToolInvocation(toolName);
760
+ // No serverName ⇒ built-in / consumer tool ⇒ nothing to stamp. The
761
+ // event keeps its Mastra-namespaced display `toolName`; enrichment only
762
+ // adds `serverName` / `annotations`.
763
+ if (invocation.serverName === undefined)
695
764
  return event;
765
+ const fields = {
766
+ serverName: invocation.serverName,
767
+ ...(invocation.annotations ? { annotations: invocation.annotations } : {}),
768
+ };
696
769
  if (event.type === 'tool-call') {
697
- const out = {
698
- ...event,
699
- serverName: metadata.serverName,
700
- ...(metadata.annotations ? { annotations: metadata.annotations } : {}),
701
- };
770
+ const out = { ...event, ...fields };
702
771
  return out;
703
772
  }
704
773
  if (event.type === 'tool-result') {
705
- const out = {
706
- ...event,
707
- serverName: metadata.serverName,
708
- ...(metadata.annotations ? { annotations: metadata.annotations } : {}),
709
- };
774
+ const out = { ...event, ...fields };
710
775
  return out;
711
776
  }
712
- const out = {
713
- ...event,
714
- serverName: metadata.serverName,
715
- ...(metadata.annotations ? { annotations: metadata.annotations } : {}),
716
- };
777
+ const out = { ...event, ...fields };
778
+ return out;
779
+ }
780
+ /**
781
+ * Re-stamp `isError: true` on the `tool-result` for a policy-denied tool
782
+ * call (Phase 2a). When the resolver returns `'deny'`, the coordinator
783
+ * drives `agent.declineToolCall`; Mastra emits a native `tool-result` with
784
+ * `isError: false` and the text "Tool call was not approved by the user".
785
+ * The SDK `Decision` contract requires a denied tool to surface as
786
+ * `tool-result(isError=true)` so consumers and the model treat it as a
787
+ * failed call, not a successful one — so we flip the flag exactly once, on
788
+ * the matching result. Consumer-driven declines (not policy denials) are not
789
+ * in `policyDeniedIds` and keep Mastra's native shape.
790
+ */
791
+ policyDenyStamp(event) {
792
+ if (event.type !== 'tool-result' || !this.policyDeniedIds.has(event.toolCallId)) {
793
+ return event;
794
+ }
795
+ // One-shot: clear so a later same-id result (shouldn't happen, but be
796
+ // safe) isn't mislabeled, and the set doesn't grow unbounded.
797
+ this.policyDeniedIds.delete(event.toolCallId);
798
+ if (event.isError)
799
+ return event;
800
+ const out = { ...event, isError: true };
717
801
  return out;
718
802
  }
803
+ /**
804
+ * Build the {@link ToolInvocation} the policy resolver matches against, and
805
+ * the single source of the `serverName` / `annotations` identity stamped on
806
+ * emitted events ({@link maybeEnrich}). One helper feeds both so the
807
+ * identity that drives the approval decision can never drift from the one
808
+ * the consumer observes on the `tool-call` / `tool-result` /
809
+ * `tool-approval-request` ChatEvent.
810
+ *
811
+ * Three disjoint cases:
812
+ *
813
+ * - **Capability-discovery meta-tool active this turn (#606)** — wins first.
814
+ * Gets the well-known `SKILL_BRIDGE_SERVER_ID` identity so the SDK's
815
+ * `mcp:skill_bridge:*` built-in rules auto-approve it, matching Claude's
816
+ * in-process skill-bridge MCP server. These tools are flat (un-namespaced),
817
+ * so the chunk's `toolName` is already the bare leaf. This branch fires
818
+ * even when the agent has no MCP servers (`lookupMcpToolMetadata` unset).
819
+ * - **Genuinely MCP-discovered tool** — recover the bare un-namespaced leaf
820
+ * the SDK's `mcp` matcher compares against by stripping Mastra's
821
+ * `${serverName}_` namespace prefix.
822
+ * - **Built-in / consumer / workspace tool** — bare name, no `serverName`.
823
+ * Matches `{ type: 'builtin', name }` rules (e.g. the Mastra built-ins in
824
+ * `MASTRA_BUILT_IN_TOOL_POLICIES`).
825
+ *
826
+ * The meta-tool and MCP-catalog identities are disjoint: a real MCP tool is
827
+ * never in `skillBridgeToolNames`, and a meta-tool is never in the catalog.
828
+ */
829
+ buildToolInvocation(toolName) {
830
+ if (this.skillBridgeToolNames.has(toolName)) {
831
+ return { toolName, serverName: SKILL_BRIDGE_SERVER_ID };
832
+ }
833
+ const mcp = this.lookupMcpToolMetadata?.(toolName);
834
+ if (mcp) {
835
+ const bare = toolName.startsWith(`${mcp.serverName}_`)
836
+ ? toolName.slice(mcp.serverName.length + 1)
837
+ : toolName;
838
+ return {
839
+ toolName: bare,
840
+ serverName: mcp.serverName,
841
+ ...(mcp.annotations ? { annotations: mcp.annotations } : {}),
842
+ };
843
+ }
844
+ return { toolName };
845
+ }
846
+ /**
847
+ * Resolve the approval decision for a non-consumer tool via the harness-
848
+ * supplied {@link MastraApprovalCoordinatorOptions.lookupPolicy} callback.
849
+ * Falls back to `'require-approval'` when no policy callback is wired — a
850
+ * defensive default so a future caller that passes `requireToolApproval`
851
+ * without a resolver surfaces the request rather than silently allowing.
852
+ * In practice this fallback is unreachable: gating is active iff
853
+ * `lookupPolicy` is defined, and only then does Mastra suspend non-consumer
854
+ * tools.
855
+ */
856
+ policyFor(toolName) {
857
+ if (!this.lookupPolicy)
858
+ return 'require-approval';
859
+ return this.lookupPolicy(this.buildToolInvocation(toolName));
860
+ }
719
861
  emitApprovalRequest(toolCallId, toolName, args) {
720
862
  const event = {
721
863
  type: 'tool-approval-request',
@@ -0,0 +1,53 @@
1
+ import type { ToolPolicyRule } from '@salesforce/sfdx-agent-sdk';
2
+ /**
3
+ * Leaf names of the Mastra-injected built-in tools this harness auto-approves.
4
+ *
5
+ * These are the tools Mastra's runtime registers itself — distinct from the
6
+ * capability-discovery meta-tools (`search_tools` / `load_tool` /
7
+ * `search_skills` / `load_skill`), which carry the `skill_bridge` MCP identity
8
+ * and are auto-approved by the SDK's `mcp:skill_bridge:*` rules in
9
+ * `BUILT_IN_TOOL_POLICIES` (see `skill-bridge.ts`).
10
+ *
11
+ * - `updateWorkingMemory` — Mastra's working-memory write tool, registered when
12
+ * `workingMemory.enabled` is set (the harness always enables it; see
13
+ * ARCHITECTURE.md → "Memory Strategy"). Gating it would prompt the user every
14
+ * time the agent updates its own scratchpad — pure friction with no security
15
+ * value.
16
+ * - `skill` / `skill_search` / `skill_read` — Mastra's eager `SkillsProcessor`
17
+ * workspace tools, registered when `skills` is set without `skillSearch` (the
18
+ * eager path; see ARCHITECTURE.md → "Skill search"). These read skill bodies
19
+ * the operator already vetted by configuring `skills`; gating them is the same
20
+ * capability-discovery friction the `skill_bridge` meta-tools avoid on the
21
+ * on-demand path.
22
+ *
23
+ * Each name here is the **wire `toolName`** the resolver matches against — the
24
+ * tools-map key Mastra registers the tool under, NOT its `createTool` `id`. The
25
+ * two differ for working memory: Mastra registers
26
+ * `{ updateWorkingMemory: createTool({ id: 'update-working-memory' }) }`, and
27
+ * its own approval-boundary checks key on the camelCase `toolName`
28
+ * (`@mastra/core`: `toolName !== 'updateWorkingMemory'`), so the matcher MUST use
29
+ * `updateWorkingMemory`, never the kebab-case `id`. (For the three skill tools
30
+ * the `id` and `toolName` coincide.) A wrong leaf name here is a silent no-op —
31
+ * the rule never matches and the user gets prompted anyway — so do NOT "correct"
32
+ * these to the `createTool` ids. The drift test in
33
+ * `test/mastra-built-in-policies.test.ts` pins only the shape (all `allow`, all
34
+ * `builtin` matchers), not the names; this caveat is the names' guard rail.
35
+ */
36
+ export declare const MASTRA_BUILT_IN_TOOL_NAMES: readonly ["updateWorkingMemory", "skill", "skill_search", "skill_read"];
37
+ /**
38
+ * Mastra harness-specific built-in tool-approval rules — the `tiers.harness`
39
+ * slice passed to `resolveToolApprovalPolicy`. Per the SDK's R4 layering
40
+ * invariant, harness-specific built-ins live here, not in the SDK's
41
+ * harness-agnostic `BUILT_IN_TOOL_POLICIES`.
42
+ *
43
+ * Frozen: adding an entry is a soft contract change consumers will have noticed;
44
+ * removing or modifying one is a breaking change. Every entry is an `allow`
45
+ * decision on a `builtin` matcher — Mastra's built-in tools carry no MCP
46
+ * `serverName`, so they match `{ type: 'builtin', name }`.
47
+ *
48
+ * Resolution order places this tier after the SDK built-ins and before consumer
49
+ * `toolPolicies`, so a consumer rule of the same matcher (e.g. an explicit
50
+ * `definePolicy({ skill: 'require-approval' })`) still wins via within-tier
51
+ * last-wins, and any `deny` anywhere wins via cross-tier deny-wins.
52
+ */
53
+ export declare const MASTRA_BUILT_IN_TOOL_POLICIES: ReadonlyArray<ToolPolicyRule>;
@@ -0,0 +1,61 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ /**
6
+ * Leaf names of the Mastra-injected built-in tools this harness auto-approves.
7
+ *
8
+ * These are the tools Mastra's runtime registers itself — distinct from the
9
+ * capability-discovery meta-tools (`search_tools` / `load_tool` /
10
+ * `search_skills` / `load_skill`), which carry the `skill_bridge` MCP identity
11
+ * and are auto-approved by the SDK's `mcp:skill_bridge:*` rules in
12
+ * `BUILT_IN_TOOL_POLICIES` (see `skill-bridge.ts`).
13
+ *
14
+ * - `updateWorkingMemory` — Mastra's working-memory write tool, registered when
15
+ * `workingMemory.enabled` is set (the harness always enables it; see
16
+ * ARCHITECTURE.md → "Memory Strategy"). Gating it would prompt the user every
17
+ * time the agent updates its own scratchpad — pure friction with no security
18
+ * value.
19
+ * - `skill` / `skill_search` / `skill_read` — Mastra's eager `SkillsProcessor`
20
+ * workspace tools, registered when `skills` is set without `skillSearch` (the
21
+ * eager path; see ARCHITECTURE.md → "Skill search"). These read skill bodies
22
+ * the operator already vetted by configuring `skills`; gating them is the same
23
+ * capability-discovery friction the `skill_bridge` meta-tools avoid on the
24
+ * on-demand path.
25
+ *
26
+ * Each name here is the **wire `toolName`** the resolver matches against — the
27
+ * tools-map key Mastra registers the tool under, NOT its `createTool` `id`. The
28
+ * two differ for working memory: Mastra registers
29
+ * `{ updateWorkingMemory: createTool({ id: 'update-working-memory' }) }`, and
30
+ * its own approval-boundary checks key on the camelCase `toolName`
31
+ * (`@mastra/core`: `toolName !== 'updateWorkingMemory'`), so the matcher MUST use
32
+ * `updateWorkingMemory`, never the kebab-case `id`. (For the three skill tools
33
+ * the `id` and `toolName` coincide.) A wrong leaf name here is a silent no-op —
34
+ * the rule never matches and the user gets prompted anyway — so do NOT "correct"
35
+ * these to the `createTool` ids. The drift test in
36
+ * `test/mastra-built-in-policies.test.ts` pins only the shape (all `allow`, all
37
+ * `builtin` matchers), not the names; this caveat is the names' guard rail.
38
+ */
39
+ export const MASTRA_BUILT_IN_TOOL_NAMES = ['updateWorkingMemory', 'skill', 'skill_search', 'skill_read'];
40
+ /**
41
+ * Mastra harness-specific built-in tool-approval rules — the `tiers.harness`
42
+ * slice passed to `resolveToolApprovalPolicy`. Per the SDK's R4 layering
43
+ * invariant, harness-specific built-ins live here, not in the SDK's
44
+ * harness-agnostic `BUILT_IN_TOOL_POLICIES`.
45
+ *
46
+ * Frozen: adding an entry is a soft contract change consumers will have noticed;
47
+ * removing or modifying one is a breaking change. Every entry is an `allow`
48
+ * decision on a `builtin` matcher — Mastra's built-in tools carry no MCP
49
+ * `serverName`, so they match `{ type: 'builtin', name }`.
50
+ *
51
+ * Resolution order places this tier after the SDK built-ins and before consumer
52
+ * `toolPolicies`, so a consumer rule of the same matcher (e.g. an explicit
53
+ * `definePolicy({ skill: 'require-approval' })`) still wins via within-tier
54
+ * last-wins, and any `deny` anywhere wins via cross-tier deny-wins.
55
+ */
56
+ export const MASTRA_BUILT_IN_TOOL_POLICIES = Object.freeze(MASTRA_BUILT_IN_TOOL_NAMES.map((name) => ({
57
+ matcher: { type: 'builtin', name },
58
+ decision: 'allow',
59
+ source: 'built-in',
60
+ })));
61
+ //# sourceMappingURL=mastra-built-in-policies.js.map
@@ -66,6 +66,14 @@ interface MastraAgentState {
66
66
  projectRoot: string;
67
67
  /** Mastra Agent instance. */
68
68
  agent: Agent;
69
+ /**
70
+ * The harness config applied to this agent (re-set on every `updateAgent`).
71
+ * Phase 2a reads `config.toolPolicies` / `config.defaultToolDecision` to
72
+ * build the per-stream policy resolver. Stored as the full config (not just
73
+ * fragments) so the resolver sees the agent's current policy without the
74
+ * harness threading individual fields through `MastraAgentState`.
75
+ */
76
+ config: HarnessAgentConfig;
69
77
  /**
70
78
  * The instructions string passed to the Mastra `Agent`'s constructor —
71
79
  * `config.instructions` after rule bodies have been composed in (see
@@ -405,6 +413,37 @@ export declare class MastraHarness implements MastraAgentHarness {
405
413
  * the thread's `ActiveRun` entry. See ARCHITECTURE.md → "Tool Approval Flow → Abort
406
414
  * during pending approval (#409)".
407
415
  */
416
+ /**
417
+ * Phase 2a: decide whether the approval gate engages this turn and, if so,
418
+ * build the per-tool policy resolver the coordinator consults.
419
+ *
420
+ * Gating is **opt-in**. It engages when the consumer set any of:
421
+ * - `config.toolPolicies` (non-empty),
422
+ * - `config.defaultToolDecision`,
423
+ * - the deprecated `StreamOptions.requireToolApproval` (any non-`false`
424
+ * value — `deprecatedMode` is `'serial'`/`'batch'`/`undefined`), or
425
+ * - `StreamOptions.batchApprovals: true`.
426
+ *
427
+ * When none are set, returns `{ lookupPolicy: undefined }` — the caller
428
+ * omits `requireToolApproval` from Mastra's `stream()` and tools run free
429
+ * (today's behavior).
430
+ *
431
+ * **Deprecation bridge.** When the gate engages *only* because the deprecated
432
+ * `requireToolApproval` was truthy (no `toolPolicies`, no
433
+ * `defaultToolDecision`), the effective default must be `'require-approval'`
434
+ * — legacy all-or-none — not the resolver's `'allow'` default, or the old
435
+ * behavior would silently flip to "auto-approve everything." We apply this
436
+ * by resolving against a shallow-overridden config carrying
437
+ * `defaultToolDecision: 'require-approval'`. Built-in allow-rules
438
+ * (skill_bridge meta-tools, the Mastra built-ins) and any consumer
439
+ * `toolPolicies` still win over the default via within-tier last-wins /
440
+ * cross-tier deny-wins, so the capability-discovery meta-tools stay
441
+ * auto-approved even under legacy gating.
442
+ *
443
+ * `batchApprovals` is the serial-vs-batch emission axis: the explicit
444
+ * `StreamOptions.batchApprovals` OR the legacy `requireToolApproval: 'batch'`.
445
+ */
446
+ private buildPolicyGate;
408
447
  /**
409
448
  * Maps an SDK `MessagePart[]` into the single AI SDK v5 user `ModelMessage` that
410
449
  * `agent.stream()` accepts.
@@ -11,9 +11,11 @@ import { SkillSearchProcessor, ToolSearchProcessor } from '@mastra/core/processo
11
11
  import { MASTRA_RESOURCE_ID_KEY, MASTRA_THREAD_ID_KEY, RequestContext } from '@mastra/core/request-context';
12
12
  import { LibSQLStore } from '@mastra/libsql';
13
13
  import { MCPClient } from '@mastra/mcp';
14
- import { AgentSDKError, AgentSDKErrorType, DEFAULT_MAX_STEPS, McpServerStatus, resolveToolApprovalMode, } from '@salesforce/sfdx-agent-sdk';
14
+ import { AgentSDKError, AgentSDKErrorType, DEFAULT_MAX_STEPS, McpServerStatus, resolveToolApprovalMode, resolveToolApprovalPolicy, } from '@salesforce/sfdx-agent-sdk';
15
15
  import { HarnessBusOwner, lowerStreamInput, mcpServerConfigEqual, } from '@salesforce/sfdx-agent-sdk/harness';
16
16
  import { MastraApprovalCoordinator } from './mastra-approval-coordinator.js';
17
+ import { MASTRA_BUILT_IN_TOOL_POLICIES } from './mastra-built-in-policies.js';
18
+ import { SKILL_SEARCH_META_TOOL_NAMES, TOOL_SEARCH_META_TOOL_NAMES } from './skill-bridge.js';
17
19
  import { mapMastraToMessage, mapMessageToMastra, partToTranscriptText } from './mastra-message-mapper.js';
18
20
  import { isStandardSchemaWithJSON, standardSchemaToJSONSchema } from '@mastra/schema-compat';
19
21
  import { mapToolDefinitionsToMastraTools } from './mastra-tool-mapper.js';
@@ -251,6 +253,25 @@ function lookupMcpToolMetadata(mcpServers, namespacedToolName) {
251
253
  const annotations = best.serverState.tools[namespacedToolName]?.mcp?.annotations;
252
254
  return { serverName: best.serverName, ...(annotations ? { annotations } : {}) };
253
255
  }
256
+ /**
257
+ * Build the set of capability-discovery meta-tool leaf names that are ACTIVE
258
+ * for a turn (#606). Tool-search injects `search_tools` / `load_tool`;
259
+ * skill-search injects `search_skills` / `load_skill`. The set drives the
260
+ * coordinator's `skill_bridge` identity stamp — and gating it on the live
261
+ * processors (not the leaf names alone) is what keeps a consumer-declared
262
+ * `load_skill` from inheriting the auto-approval exemption when `skillSearch`
263
+ * is not configured.
264
+ */
265
+ function activeSkillBridgeToolNames(state) {
266
+ const names = new Set();
267
+ if (state.toolSearchProcessor)
268
+ for (const n of TOOL_SEARCH_META_TOOL_NAMES)
269
+ names.add(n);
270
+ if (state.skillSearchProcessor)
271
+ for (const n of SKILL_SEARCH_META_TOOL_NAMES)
272
+ names.add(n);
273
+ return names;
274
+ }
254
275
  /**
255
276
  * Projects the namespaced Mastra `ToolAction` map for one MCP server into the
256
277
  * SDK's harness-agnostic `McpToolInfo[]` shape.
@@ -641,6 +662,7 @@ export class MastraHarness {
641
662
  this.activeAgents.set(agentId, {
642
663
  projectRoot,
643
664
  agent,
665
+ config,
644
666
  composedInstructions,
645
667
  validateFiles,
646
668
  mcpServers: mcpServerStates,
@@ -1065,6 +1087,7 @@ export class MastraHarness {
1065
1087
  const previousWorkspace = state.mastraWorkspace;
1066
1088
  const previousSkillSearchProcessor = state.skillSearchProcessor;
1067
1089
  state.agent = newAgent;
1090
+ state.config = config;
1068
1091
  state.composedInstructions = composedInstructions;
1069
1092
  state.validateFiles = validateFiles;
1070
1093
  state.mcpConfig = config.mcpServers;
@@ -1298,6 +1321,51 @@ export class MastraHarness {
1298
1321
  * the thread's `ActiveRun` entry. See ARCHITECTURE.md → "Tool Approval Flow → Abort
1299
1322
  * during pending approval (#409)".
1300
1323
  */
1324
+ /**
1325
+ * Phase 2a: decide whether the approval gate engages this turn and, if so,
1326
+ * build the per-tool policy resolver the coordinator consults.
1327
+ *
1328
+ * Gating is **opt-in**. It engages when the consumer set any of:
1329
+ * - `config.toolPolicies` (non-empty),
1330
+ * - `config.defaultToolDecision`,
1331
+ * - the deprecated `StreamOptions.requireToolApproval` (any non-`false`
1332
+ * value — `deprecatedMode` is `'serial'`/`'batch'`/`undefined`), or
1333
+ * - `StreamOptions.batchApprovals: true`.
1334
+ *
1335
+ * When none are set, returns `{ lookupPolicy: undefined }` — the caller
1336
+ * omits `requireToolApproval` from Mastra's `stream()` and tools run free
1337
+ * (today's behavior).
1338
+ *
1339
+ * **Deprecation bridge.** When the gate engages *only* because the deprecated
1340
+ * `requireToolApproval` was truthy (no `toolPolicies`, no
1341
+ * `defaultToolDecision`), the effective default must be `'require-approval'`
1342
+ * — legacy all-or-none — not the resolver's `'allow'` default, or the old
1343
+ * behavior would silently flip to "auto-approve everything." We apply this
1344
+ * by resolving against a shallow-overridden config carrying
1345
+ * `defaultToolDecision: 'require-approval'`. Built-in allow-rules
1346
+ * (skill_bridge meta-tools, the Mastra built-ins) and any consumer
1347
+ * `toolPolicies` still win over the default via within-tier last-wins /
1348
+ * cross-tier deny-wins, so the capability-discovery meta-tools stay
1349
+ * auto-approved even under legacy gating.
1350
+ *
1351
+ * `batchApprovals` is the serial-vs-batch emission axis: the explicit
1352
+ * `StreamOptions.batchApprovals` OR the legacy `requireToolApproval: 'batch'`.
1353
+ */
1354
+ buildPolicyGate(agentState, options, deprecatedMode) {
1355
+ const config = agentState.config;
1356
+ const hasPolicy = (config.toolPolicies?.length ?? 0) > 0 || config.defaultToolDecision !== undefined;
1357
+ const legacyGating = deprecatedMode !== undefined;
1358
+ const batchApprovals = options?.batchApprovals === true || deprecatedMode === 'batch';
1359
+ const gatingActive = hasPolicy || legacyGating || batchApprovals;
1360
+ if (!gatingActive) {
1361
+ return { batchApprovals: false };
1362
+ }
1363
+ // Apply the deprecation bridge only when the gate engages purely from
1364
+ // the legacy flag (no explicit policy of any kind).
1365
+ const resolverConfig = !hasPolicy && legacyGating ? { ...config, defaultToolDecision: 'require-approval' } : config;
1366
+ const lookupPolicy = (invocation) => resolveToolApprovalPolicy(invocation, resolverConfig, { harness: MASTRA_BUILT_IN_TOOL_POLICIES }).decision;
1367
+ return { lookupPolicy, batchApprovals };
1368
+ }
1301
1369
  /**
1302
1370
  * Maps an SDK `MessagePart[]` into the single AI SDK v5 user `ModelMessage` that
1303
1371
  * `agent.stream()` accepts.
@@ -1322,13 +1390,21 @@ export class MastraHarness {
1322
1390
  }
1323
1391
  async stream(agentId, threadId, message, options) {
1324
1392
  const agentState = this.getAgentStateOrThrow(agentId);
1325
- // Resolve the union-typed `requireToolApproval` to one of:
1326
- // undefined → no gating (consumer tools still park via the bridge
1327
- // execute handler — issue #529 step 2c Path A)
1328
- // 'serial' → one approval per stream
1329
- // 'batch' → parallel `tool_use` blocks surface all approvals
1330
- // on one stream (#447)
1331
- const approvalMode = resolveToolApprovalMode(options?.requireToolApproval);
1393
+ // Phase 2a: resolve whether the approval gate engages this turn, and if
1394
+ // so, build the per-tool policy resolver the coordinator consults.
1395
+ //
1396
+ // `resolveToolApprovalMode` still validates the deprecated
1397
+ // `requireToolApproval` union (throws on garbage) and tells us whether
1398
+ // the consumer asked for batch UX (`'batch'`). The gate is **opt-in**:
1399
+ // it engages only when the consumer configured `toolPolicies`,
1400
+ // `defaultToolDecision`, the deprecated `requireToolApproval`, or
1401
+ // `batchApprovals`. With none set, behavior is unchanged from today —
1402
+ // no suspension, no resolver, tools run free (the SDK's "no policy ⇒ no
1403
+ // gating" back-compat invariant), avoiding a suspend/resume round-trip
1404
+ // per tool call.
1405
+ const deprecatedMode = resolveToolApprovalMode(options?.requireToolApproval);
1406
+ const { lookupPolicy, batchApprovals } = this.buildPolicyGate(agentState, options, deprecatedMode);
1407
+ const gatingActive = lookupPolicy !== undefined;
1332
1408
  // Dispose any coordinator the prior turn on this thread allocated.
1333
1409
  // Without this a stale coordinator from the prior turn would still
1334
1410
  // route settle calls and accumulate listeners. Mode-flip safe: the
@@ -1366,10 +1442,13 @@ export class MastraHarness {
1366
1442
  requestContext: this.buildRequestContext(agentId, threadId),
1367
1443
  maxSteps: options?.maxSteps ?? DEFAULT_MAX_STEPS,
1368
1444
  abortSignal: options?.abortSignal,
1369
- // Only pass `requireToolApproval` when the consumer actually
1370
- // set it — Mastra is sensitive to the field's presence vs.
1371
- // explicit `false`.
1372
- ...(approvalMode !== undefined ? { requireToolApproval: true } : {}),
1445
+ // Pass `requireToolApproval: true` to Mastra iff the gate is active
1446
+ // so Mastra suspends every non-consumer tool with a
1447
+ // `tool-call-approval` chunk and the coordinator can consult the
1448
+ // policy resolver per tool. When inactive, omit it — Mastra is
1449
+ // sensitive to the field's presence vs. explicit `false`, and
1450
+ // omitting preserves today's no-suspend path.
1451
+ ...(gatingActive ? { requireToolApproval: true } : {}),
1373
1452
  });
1374
1453
  // Issue #529 step 2c: every stream allocates a coordinator,
1375
1454
  // regardless of approval mode. The coordinator's pump loop owns
@@ -1381,9 +1460,11 @@ export class MastraHarness {
1381
1460
  memory: { thread: threadId, resource: this.agentResourceId(agentId) },
1382
1461
  requestContext: this.buildRequestContext(agentId, threadId),
1383
1462
  toolApprovalTimeoutMs: this.toolApprovalTimeoutMs,
1384
- mode: approvalMode,
1463
+ ...(lookupPolicy ? { lookupPolicy } : {}),
1464
+ batchApprovals,
1385
1465
  consumerToolNames: agentState.consumerToolNames,
1386
1466
  lookupMcpToolMetadata: (name) => lookupMcpToolMetadata(agentState.mcpServers, name),
1467
+ skillBridgeToolNames: activeSkillBridgeToolNames(agentState),
1387
1468
  logBus: this.buses.getLogBus(),
1388
1469
  });
1389
1470
  agentState.coordinators.set(threadId, coordinator);
@@ -43,6 +43,17 @@ export function mapToolDefinitionsToMastraTools(tools, parkerLookup) {
43
43
  // JSDoc. With `requireApproval: false`, Mastra parks the
44
44
  // agentic run on the parker Promise without suspending the
45
45
  // workflow. The coordinator handles the parker lifecycle.
46
+ //
47
+ // Phase 2a dual-enforcement contract: this `requireApproval: false`
48
+ // is the PRIMARY, structural gate for consumer tools — it is
49
+ // hardcoded, never policy-derived. The coordinator's policy
50
+ // resolver is the SECONDARY gate, and the two must AGREE: the
51
+ // coordinator short-circuits consumer tools (by name, via
52
+ // `consumerToolNames`) to a silent auto-approve BEFORE it consults
53
+ // `resolveToolApprovalPolicy`, so a stray `toolPolicies` rule that
54
+ // happened to match a consumer tool can never flip it to
55
+ // `require-approval` / `deny`. Consumer-tool execution stays the
56
+ // consumer's responsibility via `submitToolResult`.
46
57
  requireApproval: false,
47
58
  execute: async (_inputData, context) => {
48
59
  const toolCallId = readToolCallId(context);
@@ -0,0 +1,32 @@
1
+ import { SKILL_BRIDGE_SERVER_ID } from '@salesforce/sfdx-agent-sdk';
2
+ /**
3
+ * Stable harness identity for the capability-discovery meta-tools (#606).
4
+ *
5
+ * Mastra's `ToolSearchProcessor` injects `search_tools` / `load_tool` and its
6
+ * `SkillSearchProcessor` injects `search_skills` / `load_skill` as flat tools
7
+ * with no MCP `serverName`. Claude exposes the same four through its in-process
8
+ * `skill_bridge` MCP server, so the SDK's `mcp:skill_bridge:*` rules in
9
+ * `BUILT_IN_TOOL_POLICIES` match there but silently miss on Mastra.
10
+ *
11
+ * The harness closes that gap by stamping `serverName = SKILL_BRIDGE_SERVER_ID`
12
+ * onto these tools at the approval boundary — but ONLY for leaves whose
13
+ * originating processor is actually active on the turn (see
14
+ * {@link MastraApprovalCoordinator}'s `skillBridgeToolNames`). The identity is
15
+ * therefore anchored on `(serverId, leaf)` against tools the harness itself
16
+ * injected, never on the bare leaf name alone. A consumer-declared tool named
17
+ * `load_skill` (with no `skillSearch` configured) never enters the active set,
18
+ * so it cannot inherit the auto-approval exemption — the security hole that
19
+ * sank the host-side bare-leaf workaround in AFV PR #2455.
20
+ */
21
+ /** Leaf names injected by Mastra's `ToolSearchProcessor` (no `autoLoad`). */
22
+ export declare const TOOL_SEARCH_META_TOOL_NAMES: readonly ["search_tools", "load_tool"];
23
+ /** Leaf names injected by Mastra's `SkillSearchProcessor`. */
24
+ export declare const SKILL_SEARCH_META_TOOL_NAMES: readonly ["search_skills", "load_skill"];
25
+ /**
26
+ * Every capability-discovery meta-tool leaf the harness gives the
27
+ * `skill_bridge` identity. Kept in lockstep with the SDK's `mcp:skill_bridge:*`
28
+ * rules in `BUILT_IN_TOOL_POLICIES` — a drift test in
29
+ * `test/skill-bridge.test.ts` asserts each name here has a matching SDK rule.
30
+ */
31
+ export declare const SKILL_BRIDGE_META_TOOL_NAMES: readonly ["search_tools", "load_tool", "search_skills", "load_skill"];
32
+ export { SKILL_BRIDGE_SERVER_ID };
@@ -0,0 +1,37 @@
1
+ /*
2
+ * Copyright 2026, Salesforce, Inc. All rights reserved.
3
+ * See LICENSE.txt for license terms.
4
+ */
5
+ import { SKILL_BRIDGE_SERVER_ID } from '@salesforce/sfdx-agent-sdk';
6
+ /**
7
+ * Stable harness identity for the capability-discovery meta-tools (#606).
8
+ *
9
+ * Mastra's `ToolSearchProcessor` injects `search_tools` / `load_tool` and its
10
+ * `SkillSearchProcessor` injects `search_skills` / `load_skill` as flat tools
11
+ * with no MCP `serverName`. Claude exposes the same four through its in-process
12
+ * `skill_bridge` MCP server, so the SDK's `mcp:skill_bridge:*` rules in
13
+ * `BUILT_IN_TOOL_POLICIES` match there but silently miss on Mastra.
14
+ *
15
+ * The harness closes that gap by stamping `serverName = SKILL_BRIDGE_SERVER_ID`
16
+ * onto these tools at the approval boundary — but ONLY for leaves whose
17
+ * originating processor is actually active on the turn (see
18
+ * {@link MastraApprovalCoordinator}'s `skillBridgeToolNames`). The identity is
19
+ * therefore anchored on `(serverId, leaf)` against tools the harness itself
20
+ * injected, never on the bare leaf name alone. A consumer-declared tool named
21
+ * `load_skill` (with no `skillSearch` configured) never enters the active set,
22
+ * so it cannot inherit the auto-approval exemption — the security hole that
23
+ * sank the host-side bare-leaf workaround in AFV PR #2455.
24
+ */
25
+ /** Leaf names injected by Mastra's `ToolSearchProcessor` (no `autoLoad`). */
26
+ export const TOOL_SEARCH_META_TOOL_NAMES = ['search_tools', 'load_tool'];
27
+ /** Leaf names injected by Mastra's `SkillSearchProcessor`. */
28
+ export const SKILL_SEARCH_META_TOOL_NAMES = ['search_skills', 'load_skill'];
29
+ /**
30
+ * Every capability-discovery meta-tool leaf the harness gives the
31
+ * `skill_bridge` identity. Kept in lockstep with the SDK's `mcp:skill_bridge:*`
32
+ * rules in `BUILT_IN_TOOL_POLICIES` — a drift test in
33
+ * `test/skill-bridge.test.ts` asserts each name here has a matching SDK rule.
34
+ */
35
+ export const SKILL_BRIDGE_META_TOOL_NAMES = [...TOOL_SEARCH_META_TOOL_NAMES, ...SKILL_SEARCH_META_TOOL_NAMES];
36
+ export { SKILL_BRIDGE_SERVER_ID };
37
+ //# sourceMappingURL=skill-bridge.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/sfdx-agent-harness-mastra",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Mastra-backed AgentHarness implementation for @salesforce/sfdx-agent-sdk",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -40,11 +40,11 @@
40
40
  "openai": "^6.0.0"
41
41
  },
42
42
  "peerDependencies": {
43
- "@salesforce/sfdx-agent-sdk": "0.25.0"
43
+ "@salesforce/sfdx-agent-sdk": "0.26.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@eslint/js": "^10.0.1",
47
- "@salesforce/sfdx-agent-sdk": "0.25.0",
47
+ "@salesforce/sfdx-agent-sdk": "0.26.0",
48
48
  "@types/node": "^22.20.0",
49
49
  "@vitest/coverage-istanbul": "^4.1.8",
50
50
  "@vitest/eslint-plugin": "^1.6.20",