@vanillagreen/pi-claude-bridge 1.9.0 → 3.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -125
- package/bundle/connector-inventory.js +16 -3
- package/bundle/index.js +3743 -1810
- package/package.json +14 -23
- package/src/account-host.ts +112 -0
- package/src/account-router.ts +272 -0
- package/src/agents-md.ts +54 -10
- package/src/assistant-stream.ts +472 -66
- package/src/auth-presence.ts +6 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +200 -15
- package/src/config.ts +170 -20
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +148 -0
- package/src/connector-inventory.ts +66 -7
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +406 -19
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +20 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +901 -676
- package/src/models.ts +0 -7
- package/src/native-provider.ts +94 -0
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +490 -25
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +48 -13
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +370 -50
- package/src/tool-pairing-audit.ts +117 -0
- package/src/typebox-to-zod.ts +9 -3
package/src/connectors.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type HookCallback, type query } from "@anthropic-ai/claude-agent-sdk";
|
|
1
|
+
import { type HookCallback, type query, type SettingSource } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import { normalizeConnectorWriteMode, type Config, type ConnectorWriteMode } from "./config.js";
|
|
3
|
-
import { MCP_SERVER_NAME } from "./skills.js";
|
|
3
|
+
import { MCP_SERVER_NAME, MCP_TOOL_PREFIX } from "./skills.js";
|
|
4
|
+
import { connectorProxyUrl, connectorServerName, type ConnectorInventory } from "./connector-inventory.js";
|
|
4
5
|
|
|
5
6
|
// Disable Claude Code built-ins in the provider path. Pi owns tool execution;
|
|
6
7
|
// Claude reaches Pi tools through the bridged MCP server instead.
|
|
@@ -34,8 +35,8 @@ export const CLAUDE_BRIDGE_TOOL_ISOLATION = {
|
|
|
34
35
|
// execution and tokens stay lean. This opt-in flag lets the authenticated
|
|
35
36
|
// Claude account's authorized Google connectors flow through to the model,
|
|
36
37
|
// exposing Gmail/Calendar/Drive tools the account has connected. Gated so the
|
|
37
|
-
// default behavior is unchanged. See
|
|
38
|
-
//
|
|
38
|
+
// default behavior is unchanged. See the Connectors section of this package's
|
|
39
|
+
// README.
|
|
39
40
|
export function connectorsEnabledFromEnv(): boolean {
|
|
40
41
|
const v = (process.env.CLAUDE_BRIDGE_ENABLE_CONNECTORS ?? "").trim().toLowerCase();
|
|
41
42
|
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
@@ -49,6 +50,41 @@ export function connectorsEnabledFor(config?: Config): boolean {
|
|
|
49
50
|
return connectorsEnabledFromEnv() || config?.provider?.enableConnectors === true;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// Which filesystem setting sources the `claude` child may load.
|
|
54
|
+
//
|
|
55
|
+
// claude.ai cloud MCP connectors only load when Claude Code resolves its
|
|
56
|
+
// filesystem setting sources at all: the SDK treats settingSources=undefined as
|
|
57
|
+
// isolation (no sources), which drops the connectors even with
|
|
58
|
+
// ENABLE_CLAUDEAI_MCP_SERVERS=1. So connectors mode must pass SOME source list.
|
|
59
|
+
//
|
|
60
|
+
// It must be `["user"]` and nothing more (vstack#990). Connector state lives in
|
|
61
|
+
// USER scope — the account's config dir (CLAUDE_CONFIG_DIR for managed router
|
|
62
|
+
// profiles) — so user scope is sufficient for connectors to surface. Claude
|
|
63
|
+
// Code settings files can also carry an `env` map and `apiKeyHelper`; including
|
|
64
|
+
// "project"/"local" would let a repo's checked-in `.claude/settings.json`
|
|
65
|
+
// reintroduce exactly the provider-override env the bridge scrubs from the
|
|
66
|
+
// child (e.g. ANTHROPIC_BASE_URL → traffic redirection) on any bridge query
|
|
67
|
+
// whose cwd is a hostile checkout. User scope is the account owner's own
|
|
68
|
+
// machine config: whoever writes it already owns the child's env.
|
|
69
|
+
//
|
|
70
|
+
// An explicit `provider.settingSources` in bridge config still wins verbatim —
|
|
71
|
+
// that config channel is user-scope/trust-gated (see loadConfig) — but adding
|
|
72
|
+
// "project"/"local" there reopens the repo-controlled settings surface; the
|
|
73
|
+
// README says so.
|
|
74
|
+
export function settingSourcesForQuery(
|
|
75
|
+
connectorsEnabled: boolean,
|
|
76
|
+
appendSystemPrompt: boolean,
|
|
77
|
+
configured?: SettingSource[],
|
|
78
|
+
): SettingSource[] | undefined {
|
|
79
|
+
if (connectorsEnabled) return configured ?? ["user"];
|
|
80
|
+
// Non-connectors: appendSystemPrompt=true (default) keeps SDK isolation
|
|
81
|
+
// (undefined = no filesystem settings; configured sources deliberately do
|
|
82
|
+
// not apply in isolation mode). If users turn it off they opted into Claude
|
|
83
|
+
// Code's own settings behavior; project scope there is the historical
|
|
84
|
+
// contract and runs alongside --strict-mcp-config (see the query builder).
|
|
85
|
+
return appendSystemPrompt ? undefined : configured ?? ["user", "project"];
|
|
86
|
+
}
|
|
87
|
+
|
|
52
88
|
// Cloud MCP connector tool namespaces auto-allowed when connectors are enabled.
|
|
53
89
|
// Names match Claude Code's claude.ai connector servers.
|
|
54
90
|
// Whole-server globs (the only glob shape the CLI matcher honors). Deny rules
|
|
@@ -62,14 +98,61 @@ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
|
|
|
62
98
|
"mcp__claude_ai_Atlassian__*",
|
|
63
99
|
];
|
|
64
100
|
|
|
101
|
+
// --- The SDK's two-name trap for built-in tools (vstack#1007, vstack#1011) ---
|
|
102
|
+
//
|
|
103
|
+
// The CLI gives some built-ins TWO spellings, and which one you see depends on
|
|
104
|
+
// which SURFACE the name crosses:
|
|
105
|
+
//
|
|
106
|
+
// REQUEST side — anything passed INTO the SDK query options (`tools`,
|
|
107
|
+
// `allowedTools`, `disallowedTools`, permission rules). These strings go
|
|
108
|
+
// through the SDK's rule parser, which normalizes a request-side spelling
|
|
109
|
+
// (`ListMcpResources`) through its alias map before matching. Constants that
|
|
110
|
+
// feed only this surface keep the request-side spelling.
|
|
111
|
+
//
|
|
112
|
+
// DELIVERED side — any name the CLI hands BACK to us at runtime: a PreToolUse
|
|
113
|
+
// hook's `input.tool_name`, the stream's `content_block_start.name`,
|
|
114
|
+
// assistant-message tool_use blocks. These carry the CANONICAL (aliased)
|
|
115
|
+
// name (`ListMcpResourcesTool`), and nothing normalizes them for us — a raw
|
|
116
|
+
// membership test against a request-side spelling silently never matches.
|
|
117
|
+
//
|
|
118
|
+
// This map declares each alias pair ONCE; every delivered-side membership set
|
|
119
|
+
// derives its spellings from it instead of hand-copying names. Both prior
|
|
120
|
+
// instances of the trap were exactly that hand-copy: CHILD_INTERNAL_TOOLS held
|
|
121
|
+
// request-side spellings that no stream name ever matched (vstack#1007), and
|
|
122
|
+
// the connectors allowlist hook DENIED the two discovery tools it exists to
|
|
123
|
+
// permit (vstack#1011). Delivered-side sets accept BOTH spellings, so a CLI
|
|
124
|
+
// version that drops the aliasing cannot reintroduce the bug in either
|
|
125
|
+
// direction.
|
|
126
|
+
const SDK_TOOL_ALIASES: Record<string, string> = {
|
|
127
|
+
ListMcpResources: "ListMcpResourcesTool",
|
|
128
|
+
ReadMcpResource: "ReadMcpResourceTool",
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Every spelling a DELIVERED tool name may carry for a request-side name.
|
|
132
|
+
function deliveredSpellings(name: string): string[] {
|
|
133
|
+
const canonical = SDK_TOOL_ALIASES[name];
|
|
134
|
+
return canonical ? [name, canonical] : [name];
|
|
135
|
+
}
|
|
136
|
+
|
|
65
137
|
// Claude Code registers a Claude account's cloud connectors as DEFERRED tools
|
|
66
138
|
// that the model must load via ToolSearch (and enumerate via the MCP-resource
|
|
67
139
|
// tools). The default bridge isolation disallows all three so Pi owns tool
|
|
68
140
|
// discovery — but that hides the connectors from the model entirely. When
|
|
69
141
|
// connectors are enabled we must let these through so Gmail/Calendar/Drive are
|
|
70
142
|
// discoverable. Verified: disallowing ToolSearch reliably yields NO_CONNECTORS.
|
|
143
|
+
//
|
|
144
|
+
// REQUEST-side spellings: this list feeds the SDK option surface (the
|
|
145
|
+
// disallowedTools filter in toolIsolationForQuery) and is the exported public
|
|
146
|
+
// name. Delivered-side checks use CONNECTOR_DISCOVERY_TOOL_NAMES below.
|
|
71
147
|
export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", "ListMcpResources", "ReadMcpResource"];
|
|
72
148
|
|
|
149
|
+
// Delivered-side membership set for the discovery tools: both spellings of
|
|
150
|
+
// each entry, derived from SDK_TOOL_ALIASES. A PreToolUse hook's `tool_name`
|
|
151
|
+
// carries the canonical spelling (`ListMcpResourcesTool`), so testing the
|
|
152
|
+
// request-side list directly made the fail-closed allowlist hook DENY the two
|
|
153
|
+
// resource tools it exists to permit (vstack#1011).
|
|
154
|
+
const CONNECTOR_DISCOVERY_TOOL_NAMES = new Set(CONNECTOR_DISCOVERY_TOOLS.flatMap(deliveredSpellings));
|
|
155
|
+
|
|
73
156
|
// --- Connector WRITE tool control (read-inline / write-by-approval) ---
|
|
74
157
|
//
|
|
75
158
|
// Connector tools execute INSIDE claude via the bridge, so Memsira's Pi-level
|
|
@@ -158,6 +241,22 @@ function connectorNameWords(segment: string): string[] {
|
|
|
158
241
|
// Explicit known write tool names (current claude.ai connectors). Passed to the
|
|
159
242
|
// SDK disallowedTools so today's writes are removed from the model's context by
|
|
160
243
|
// exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
|
|
244
|
+
//
|
|
245
|
+
// PUBLIC CONTRACT — this list and `isConnectorWriteTool` have downstream
|
|
246
|
+
// dependents that gate real user-facing approvals on them (vstack#892):
|
|
247
|
+
//
|
|
248
|
+
// memsira routes connector writes through its own gated approval flow
|
|
249
|
+
// drovr keeps its chat sidecar permanently write-`deny` and runs an
|
|
250
|
+
// approved write as a separate one-shot `claude -p` scoped by
|
|
251
|
+
// `--allowedTools` to exactly one connector tool (drovr#288)
|
|
252
|
+
//
|
|
253
|
+
// Both pin the actions they expose against this classification, because "the
|
|
254
|
+
// sidecar structurally cannot do this itself" is THIS module's claim, not
|
|
255
|
+
// theirs. RECLASSIFYING AN ENTRY HERE AS A READ WOULD MAKE A CONSUMER'S
|
|
256
|
+
// CONFIRMATION CARD BYPASSABLE. Additions are safe and expected; removals and
|
|
257
|
+
// read-verb reclassifications are breaking — coordinate first, see
|
|
258
|
+
// docs/cross-repo.md. `unit-connectors.mjs` pins the set so a change has to be
|
|
259
|
+
// deliberate.
|
|
161
260
|
export const CONNECTOR_WRITE_TOOLS = [
|
|
162
261
|
`${CONNECTOR_NS_GMAIL}create_draft`,
|
|
163
262
|
`${CONNECTOR_NS_GMAIL}create_label`,
|
|
@@ -198,6 +297,102 @@ export const CONNECTOR_WRITE_TOOLS = [
|
|
|
198
297
|
`${CONNECTOR_NS_ATLASSIAN}createCompassCustomFieldDefinition`,
|
|
199
298
|
];
|
|
200
299
|
|
|
300
|
+
/**
|
|
301
|
+
* True for a claude.ai connector tool — the CHILD's own cloud MCP servers,
|
|
302
|
+
* attached to the authenticated account and reachable only from inside that
|
|
303
|
+
* process (`mcp__claude_ai_<Server>__<tool>`).
|
|
304
|
+
*
|
|
305
|
+
* The test is the NAMESPACE, deliberately, not "does this name resolve to a Pi
|
|
306
|
+
* tool". Every claude.ai connector server lives under `mcp__claude_ai_`, and
|
|
307
|
+
* that is the only tool class the bridge knowingly delegates. A broader
|
|
308
|
+
* "unresolvable ⇒ delegated" rule would also swallow a genuine tool-name
|
|
309
|
+
* mismatch between Pi and the child, which SHOULD still surface as a loud
|
|
310
|
+
* dispatcher error.
|
|
311
|
+
*
|
|
312
|
+
* This is the CONNECTOR test: it gates connector-only concerns (the
|
|
313
|
+
* connector-call audit, write classification). For the broader "the child runs
|
|
314
|
+
* this itself, never mirror it" question, use `isChildExecutedTool`.
|
|
315
|
+
*/
|
|
316
|
+
export function isConnectorTool(name: string | undefined): boolean {
|
|
317
|
+
return typeof name === "string" && name.startsWith(CONNECTOR_NS_PREFIX);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Claude Code built-in meta-tools the child resolves ENTIRELY in-process:
|
|
321
|
+
// deferred-tool discovery and scheduled wakeups. They surface in a bridge
|
|
322
|
+
// stream when connectors are enabled (CONNECTOR_DISCOVERY_TOOLS un-blocks
|
|
323
|
+
// discovery so deferred connector tools are reachable), but they are not
|
|
324
|
+
// connector calls and Pi cannot run them. Matched EXACTLY, never by prefix or
|
|
325
|
+
// substring: a Pi tool that merely resembles one of these names is a Pi tool,
|
|
326
|
+
// and a mismatch on it must still surface as a dispatcher error.
|
|
327
|
+
//
|
|
328
|
+
// Membership is tested against the STREAM-side spelling — the `name` on
|
|
329
|
+
// `content_block_start` / assistant-message blocks, the exact fields
|
|
330
|
+
// processStreamEvent/processAssistantMessage read. Stream names are a
|
|
331
|
+
// DELIVERED surface (see SDK_TOOL_ALIASES): the two MCP-resource built-ins'
|
|
332
|
+
// request-side spellings used to sit in this set and never matched anything
|
|
333
|
+
// (vstack#1007). If an aliased name is ever added here, derive its spellings
|
|
334
|
+
// via deliveredSpellings rather than hand-copying them.
|
|
335
|
+
//
|
|
336
|
+
// The MCP-resource tools are now EXCLUDED deliberately, under BOTH spellings:
|
|
337
|
+
// a resource read is a real account-surface access, and both consumer hosts
|
|
338
|
+
// audit it through the Pi mirror (an out-of-process sidecar has no view of the
|
|
339
|
+
// bridge's own connector-call entries or the child transcript), so it stays
|
|
340
|
+
// mirrored into Pi. Do not re-add either spelling without revisiting that
|
|
341
|
+
// decision in vstack#1007. The allowlist hook is what lets those calls run at
|
|
342
|
+
// all — see isAllowlistedConnectorSessionTool (vstack#1011).
|
|
343
|
+
const CHILD_INTERNAL_TOOLS = new Set(["ToolSearch", "ScheduleWakeup"]);
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* True for a Claude Code built-in meta-tool the child resolves in-process.
|
|
347
|
+
*
|
|
348
|
+
* Mirroring one into the Pi stream (vstack#980) made Pi's agent loop dispatch a
|
|
349
|
+
* tool it does not have and deliver an error result for an id no MCP handler
|
|
350
|
+
* ever claimed. The result queued in `pendingResults` until the reaper dropped
|
|
351
|
+
* it — one "dropped 1 tool result(s) whose handler never matched (ToolSearch)"
|
|
352
|
+
* warning and one phantom failed tool call per discovery, plus a spurious
|
|
353
|
+
* pi-turn boundary. These calls also never enter the connector-call audit:
|
|
354
|
+
* that trail records account-data access, and tool discovery is not that.
|
|
355
|
+
*/
|
|
356
|
+
export function isChildInternalTool(name: string | undefined): boolean {
|
|
357
|
+
return typeof name === "string" && CHILD_INTERNAL_TOOLS.has(name);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* True for a tool that the `claude` CHILD executes itself, so Pi must never be
|
|
362
|
+
* asked to dispatch it.
|
|
363
|
+
*
|
|
364
|
+
* WHY THIS EXISTS. Every other tool the model calls in a bridge turn is a PI
|
|
365
|
+
* tool: Pi hands its tool set to the bridge, the bridge re-offers it to the
|
|
366
|
+
* child through the in-process MCP server, and a `tool_use` coming back is the
|
|
367
|
+
* child asking PI to run something. The bridge is built around that direction —
|
|
368
|
+
* it mirrors the call into the Pi stream, ends the Pi turn with `toolUse`, and
|
|
369
|
+
* the MCP handler blocks until Pi delivers the result.
|
|
370
|
+
*
|
|
371
|
+
* Two tool classes run the other way, and both must stay un-mirrored:
|
|
372
|
+
*
|
|
373
|
+
* 1. claude.ai connectors (`isConnectorTool`). Pi has never heard of them.
|
|
374
|
+
* Mirroring one made Pi's agent loop look the name up in `context.tools`,
|
|
375
|
+
* miss, and write a synthetic `Tool <name> not found` error result into the
|
|
376
|
+
* transcript — while the child went on and executed the real call. The Pi
|
|
377
|
+
* transcript then RECORDED A FAILURE FOR A CALL THAT SUCCEEDED, next to an
|
|
378
|
+
* answer built from the real payload, so the model's correct answer read as
|
|
379
|
+
* a fabrication (drovr#311, memsira#320). The false result is also projected
|
|
380
|
+
* back into the child's session on a rebuild (`syncSharedSession`), which is
|
|
381
|
+
* how a lie in a mirror becomes a lie in the conversation of record.
|
|
382
|
+
*
|
|
383
|
+
* 2. Claude Code's own in-process meta-tools (`isChildInternalTool`), which the
|
|
384
|
+
* child resolves without any dispatcher at all (vstack#980).
|
|
385
|
+
*
|
|
386
|
+
* Takes the RAW SDK tool name, before `mapToolName` — child-executed names have
|
|
387
|
+
* no Pi-side counterpart, so mapping them is meaningless. Accepts a missing
|
|
388
|
+
* name rather than asserting one: this decides whether Pi is allowed to
|
|
389
|
+
* dispatch a block, and a nameless block is neither a connector nor a child
|
|
390
|
+
* built-in, so it answers `false`.
|
|
391
|
+
*/
|
|
392
|
+
export function isChildExecutedTool(name: string | undefined): boolean {
|
|
393
|
+
return isConnectorTool(name) || isChildInternalTool(name);
|
|
394
|
+
}
|
|
395
|
+
|
|
201
396
|
// Classify a connector tool name as a WRITE (mutating) tool. FAIL CLOSED, twice:
|
|
202
397
|
//
|
|
203
398
|
// 1. Namespace: the whole `mcp__claude_ai_<Server>__` space counts, not just the
|
|
@@ -221,7 +416,7 @@ export const CONNECTOR_WRITE_TOOLS = [
|
|
|
221
416
|
// servers) are never connector writes → false. Used by connectorWriteDenyHook and
|
|
222
417
|
// by callers (e.g. the one-shot write executor) that enumerate live connector tools.
|
|
223
418
|
export function isConnectorWriteTool(name: string): boolean {
|
|
224
|
-
if (!name
|
|
419
|
+
if (!isConnectorTool(name)) return false;
|
|
225
420
|
// First `__` after the prefix ends the server segment. First (not last) so a
|
|
226
421
|
// server name containing `__` leaves the extra segment in `tool`, which then
|
|
227
422
|
// fails the read-prefix test — ambiguity resolves to write.
|
|
@@ -295,17 +490,25 @@ export function connectorWriteDenyHook(): HookCallback {
|
|
|
295
490
|
// whatever gets added here later.
|
|
296
491
|
try {
|
|
297
492
|
if (input.hook_event_name !== "PreToolUse") return { continue: true };
|
|
493
|
+
// A non-string tool name cannot be classified, and this hook fails
|
|
494
|
+
// CLOSED: deny it rather than let an unclassifiable call proceed.
|
|
495
|
+
// (Before isConnectorTool tolerated non-strings, `startsWith` threw
|
|
496
|
+
// here and the catch denied — this keeps that contract explicit.)
|
|
497
|
+
if (typeof input.tool_name !== "string") return connectorWriteDenyOutput("<unknown>");
|
|
298
498
|
if (!isConnectorWriteTool(input.tool_name)) return { continue: true };
|
|
299
|
-
return connectorWriteDenyOutput(
|
|
499
|
+
return connectorWriteDenyOutput(input.tool_name);
|
|
300
500
|
} catch {
|
|
301
|
-
|
|
302
|
-
? (input as { tool_name: string }).tool_name
|
|
303
|
-
: "<unknown>";
|
|
304
|
-
return connectorWriteDenyOutput(toolName);
|
|
501
|
+
return connectorWriteDenyOutput(safeToolNameFrom(input));
|
|
305
502
|
}
|
|
306
503
|
};
|
|
307
504
|
}
|
|
308
505
|
|
|
506
|
+
// The deny reason is handed verbatim to the `claude` child's model, so it must
|
|
507
|
+
// stay PRODUCT-NEUTRAL: this is shared source and every consuming app shows it.
|
|
508
|
+
// Naming one host told a different app's model to use a product it has never
|
|
509
|
+
// heard of, which is confusing at exactly the moment someone is debugging a
|
|
510
|
+
// refused write (vstack#892). Each host describes its own approval flow in its
|
|
511
|
+
// own prompt; this string only has to say that one exists.
|
|
309
512
|
function connectorWriteDenyOutput(toolName: string) {
|
|
310
513
|
return {
|
|
311
514
|
hookSpecificOutput: {
|
|
@@ -313,20 +516,119 @@ function connectorWriteDenyOutput(toolName: string) {
|
|
|
313
516
|
permissionDecision: "deny" as const,
|
|
314
517
|
permissionDecisionReason:
|
|
315
518
|
`Connector write tool "${toolName}" is blocked in read-only connector mode. ` +
|
|
316
|
-
`Connector writes must go through
|
|
519
|
+
`Connector writes must go through the host application's gated approval flow.`,
|
|
317
520
|
},
|
|
318
521
|
};
|
|
319
522
|
}
|
|
320
523
|
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
524
|
+
// The names a connectors-mode child session may call at all — the fail-closed
|
|
525
|
+
// complement of DISALLOWED_BUILTIN_TOOLS. That denylist blocks the built-ins we
|
|
526
|
+
// know about TODAY, but a connectors session ingests untrusted third-party
|
|
527
|
+
// content (mail bodies, tickets, documents), and a future CLI built-in absent
|
|
528
|
+
// from the list would be callable by whatever that content talks the model
|
|
529
|
+
// into. Exactly three name classes have any business executing in a connector
|
|
530
|
+
// session: Pi's bridged custom tools, the claude.ai connector namespace (whose
|
|
531
|
+
// writes the write-deny hook still catches), and the discovery built-ins that
|
|
532
|
+
// make deferred connector tools reachable.
|
|
533
|
+
//
|
|
534
|
+
// This is a DELIVERED-side check — `name` is a hook's `input.tool_name`, which
|
|
535
|
+
// carries the canonical spelling — so membership is tested against
|
|
536
|
+
// CONNECTOR_DISCOVERY_TOOL_NAMES (both spellings), not the request-side list
|
|
537
|
+
// (vstack#1011). That also carries vstack#1007's mirroring decision: the
|
|
538
|
+
// MCP-resource tools are deliberately NOT child-internal so every resource
|
|
539
|
+
// read mirrors into Pi as the consumers' audit surface — a mirror that can
|
|
540
|
+
// only exist if this allowlist lets the call execute. Denying the canonical
|
|
541
|
+
// spellings didn't just remove discovery; it silently emptied that audit
|
|
542
|
+
// trail too.
|
|
543
|
+
export function isAllowlistedConnectorSessionTool(name: string): boolean {
|
|
544
|
+
return name.startsWith(MCP_TOOL_PREFIX)
|
|
545
|
+
|| name.startsWith(CONNECTOR_NS_PREFIX)
|
|
546
|
+
|| CONNECTOR_DISCOVERY_TOOL_NAMES.has(name);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// PreToolUse ALLOWLIST hook for connectors mode. Same fail-closed shape as
|
|
550
|
+
// connectorWriteDenyHook: the CLI treats a hook error/timeout as an empty hook
|
|
551
|
+
// output and lets the call proceed, so every exception in this body must
|
|
552
|
+
// convert to a deny, never an allow.
|
|
553
|
+
export function connectorBuiltinAllowlistHook(): HookCallback {
|
|
554
|
+
return async (input) => {
|
|
555
|
+
try {
|
|
556
|
+
if (input.hook_event_name !== "PreToolUse") return { continue: true };
|
|
557
|
+
if (typeof input.tool_name !== "string") return allowlistDenyOutput("<unknown>");
|
|
558
|
+
if (isAllowlistedConnectorSessionTool(input.tool_name)) return { continue: true };
|
|
559
|
+
return allowlistDenyOutput(input.tool_name);
|
|
560
|
+
} catch {
|
|
561
|
+
return allowlistDenyOutput(safeToolNameFrom(input));
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Exception-proof tool-name read for hook catch handlers: the input may be
|
|
567
|
+
// hostile enough that even reading `tool_name` throws, and a catch handler
|
|
568
|
+
// that throws makes the CLI treat the hook as empty output — fail OPEN.
|
|
569
|
+
function safeToolNameFrom(input: unknown): string {
|
|
570
|
+
try {
|
|
571
|
+
const candidate = (input as { tool_name?: unknown })?.tool_name;
|
|
572
|
+
return typeof candidate === "string" ? candidate : "<unknown>";
|
|
573
|
+
} catch {
|
|
574
|
+
return "<unknown>";
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Product-neutral for the same reason as connectorWriteDenyOutput: this string
|
|
579
|
+
// is shown verbatim to the child's model in every consuming app.
|
|
580
|
+
function allowlistDenyOutput(toolName: string) {
|
|
581
|
+
return {
|
|
582
|
+
hookSpecificOutput: {
|
|
583
|
+
hookEventName: "PreToolUse" as const,
|
|
584
|
+
permissionDecision: "deny" as const,
|
|
585
|
+
permissionDecisionReason:
|
|
586
|
+
`Tool "${toolName}" is not available in this connector session. ` +
|
|
587
|
+
`Only bridged custom tools, claude.ai connector tools, and tool discovery are permitted here.`,
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// PreToolUse hook that denies EVERY tool call. For children that must never
|
|
593
|
+
// execute anything — the account probe runs `/usage` with bypassPermissions,
|
|
594
|
+
// and a slash command needs no tools at all. Same fail-closed try/catch-deny
|
|
595
|
+
// shape as the hooks above.
|
|
596
|
+
export function denyAllToolsHook(): HookCallback {
|
|
597
|
+
return async (input) => {
|
|
598
|
+
try {
|
|
599
|
+
if (input.hook_event_name !== "PreToolUse") return { continue: true };
|
|
600
|
+
return denyAllOutput(typeof input.tool_name === "string" ? input.tool_name : "<unknown>");
|
|
601
|
+
} catch {
|
|
602
|
+
return denyAllOutput("<unknown>");
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function denyAllOutput(toolName: string) {
|
|
608
|
+
return {
|
|
609
|
+
hookSpecificOutput: {
|
|
610
|
+
hookEventName: "PreToolUse" as const,
|
|
611
|
+
permissionDecision: "deny" as const,
|
|
612
|
+
permissionDecisionReason: `Tool "${toolName}" is not available: this session executes no tools.`,
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Connector query-option fragment: tool isolation (allow/deny lists) plus the
|
|
618
|
+
// runtime PreToolUse hooks — the fail-closed builtin allowlist always, and the
|
|
619
|
+
// write-deny hook additionally while writes are denied. Spread into the SDK
|
|
620
|
+
// query options; continuation queries inherit it via `{ ...queryOptions }`.
|
|
621
|
+
// Exported so the wiring is unit-testable end to end.
|
|
325
622
|
export function connectorQueryOptions(connectorsEnabled: boolean, writeMode: ConnectorWriteMode = "deny"): Partial<Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools" | "hooks">> {
|
|
326
623
|
const isolation = toolIsolationForQuery(connectorsEnabled, writeMode);
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
624
|
+
if (!connectorsEnabled) return isolation;
|
|
625
|
+
// The allowlist applies in BOTH write modes — the one-shot write executor is
|
|
626
|
+
// still a connectors session ingesting third-party content. Deny rules from
|
|
627
|
+
// either hook win over any allow.
|
|
628
|
+
const hooks = writeMode === "allow"
|
|
629
|
+
? [connectorBuiltinAllowlistHook()]
|
|
630
|
+
: [connectorBuiltinAllowlistHook(), connectorWriteDenyHook()];
|
|
631
|
+
return { ...isolation, hooks: { PreToolUse: [{ hooks }] } };
|
|
330
632
|
}
|
|
331
633
|
|
|
332
634
|
// Tool isolation for a query. When connectors are enabled we still remove
|
|
@@ -343,7 +645,13 @@ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: Con
|
|
|
343
645
|
if (!connectorsEnabled) return CLAUDE_BRIDGE_TOOL_ISOLATION;
|
|
344
646
|
// Keep ToolSearch + MCP-resource tools available so the model can discover the
|
|
345
647
|
// deferred cloud connector tools; still block file/shell/web built-ins.
|
|
346
|
-
|
|
648
|
+
//
|
|
649
|
+
// REQUEST-side surface: the surviving list goes into the SDK options, where
|
|
650
|
+
// the rule parser alias-normalizes it, so DISALLOWED_BUILTIN_TOOLS correctly
|
|
651
|
+
// holds request-side spellings and this filter's OUTPUT stays request-side.
|
|
652
|
+
// Filtering through the both-spellings set only makes the un-block
|
|
653
|
+
// spelling-proof should a canonical name ever land in the denylist.
|
|
654
|
+
const disallowedTools = DISALLOWED_BUILTIN_TOOLS.filter((t) => !CONNECTOR_DISCOVERY_TOOL_NAMES.has(t));
|
|
347
655
|
// Deny connector WRITE tools unless writes are explicitly allowed (fail
|
|
348
656
|
// closed: any mode but exact "allow" is treated as read-only). This removes
|
|
349
657
|
// today's KNOWN writes from the model's context by exact id; deny rules take
|
|
@@ -357,3 +665,82 @@ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: Con
|
|
|
357
665
|
allowedTools: [...CLAUDE_BRIDGE_TOOL_ISOLATION.allowedTools, ...CLAUDE_AI_CONNECTOR_TOOL_PATTERNS],
|
|
358
666
|
};
|
|
359
667
|
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Explicit `mcpServers` declarations for the account's CONNECTED connectors.
|
|
671
|
+
*
|
|
672
|
+
* Why this exists (vstack#832): claude.ai connectors load async and non-blocking,
|
|
673
|
+
* and the turn-1 tool manifest is built at +410-665ms — roughly 300ms BEFORE the
|
|
674
|
+
* CLI has even fetched the connector list. The model therefore composes its first
|
|
675
|
+
* answer against a manifest containing no connectors and says it has no access,
|
|
676
|
+
* while the connector attaches ~1s later and is never asked. Measured end to end
|
|
677
|
+
* on 40 cold sidecars (memsira, 2026-07-26): a connector tool call happened in
|
|
678
|
+
* 7/20 baseline runs versus 20/20 with the declaration, and "I don't have access"
|
|
679
|
+
* went 13/20 → 0/20, one-sided Fisher exact p = 6.4e-6. Confirmed at seven
|
|
680
|
+
* declarations over a further 30 runs: 5/10 → 10/10 calls, 5/10 → 0/10 denials.
|
|
681
|
+
*
|
|
682
|
+
* It is also FASTER, which is the opposite of what the startup barrier suggests.
|
|
683
|
+
* The barrier is real but small and sub-linear — manifest build 490ms none /
|
|
684
|
+
* 1996ms one / 2574ms seven, so seven costs +578ms over one, not 7x. Meanwhile
|
|
685
|
+
* first token drops from a 9840ms median (worst 35.7s) to 6887ms (worst 7.8s),
|
|
686
|
+
* because declaring removes the model's speculative ToolSearch and dead ends.
|
|
687
|
+
* The barrier buys back more than it spends.
|
|
688
|
+
*
|
|
689
|
+
* `alwaysLoad` is the mechanism: it blocks startup until the server is connected
|
|
690
|
+
* (5s cap) precisely "since the tools must be present when the turn-1 prompt is
|
|
691
|
+
* built". It is a field on the server config, so the connector has to be a server
|
|
692
|
+
* WE declare — the CLI's own loader never applies it.
|
|
693
|
+
*
|
|
694
|
+
* Two things here are load-bearing and were established by measurement, not
|
|
695
|
+
* inference:
|
|
696
|
+
*
|
|
697
|
+
* 1. The key MUST be the CLI's own server name (`connectorServerName`). The key
|
|
698
|
+
* is the tool namespace, so any other key yields the connector twice under two
|
|
699
|
+
* namespaces. Consumers that pin fully-qualified tool names rather than
|
|
700
|
+
* globbing a namespace then break.
|
|
701
|
+
* 2. Only `installState === "connected"` connectors are declared. The rest are
|
|
702
|
+
* never attempted by the CLI either, and declaring them would mean asking
|
|
703
|
+
* `alwaysLoad` to block startup on servers that cannot connect.
|
|
704
|
+
*
|
|
705
|
+
* Deliberately NOT typed against the SDK's `McpServerConfig`: that exported union
|
|
706
|
+
* omits the `claudeai-proxy` variant entirely, and its `McpClaudeAIProxyServerConfig`
|
|
707
|
+
* has no `alwaysLoad` field — while the runtime zod schema in the shipped CLI does.
|
|
708
|
+
* Typings and runtime disagree; the runtime honours `alwaysLoad` (verified live),
|
|
709
|
+
* so this builds the object the runtime accepts and casts once, here, with the
|
|
710
|
+
* reason recorded rather than spread across call sites.
|
|
711
|
+
*/
|
|
712
|
+
export function connectorMcpServers(inventory: ConnectorInventory): Record<string, unknown> {
|
|
713
|
+
if (!inventory.ok) return {};
|
|
714
|
+
// Escape hatch. `alwaysLoad` holds startup until each declared server
|
|
715
|
+
// connects, and the bound on that wait is NOT established: the SDK doc
|
|
716
|
+
// comment says a 5s cap while the CLI logs `timeout of 30000ms`, and four
|
|
717
|
+
// attempts to force a genuine mid-handshake hang each failed fast for a
|
|
718
|
+
// different reason, so the worst case was never observed. An account with
|
|
719
|
+
// slow or numerous connectors therefore has an unquantified turn-1 delay,
|
|
720
|
+
// and this switch turns declarations off without giving up connectors.
|
|
721
|
+
if (connectorDeclarationsDisabled()) return {};
|
|
722
|
+
const servers: Record<string, unknown> = {};
|
|
723
|
+
for (const entry of inventory.connectors) {
|
|
724
|
+
if (entry.installState !== "connected") continue;
|
|
725
|
+
if (!entry.installedServerId) continue;
|
|
726
|
+
servers[connectorServerName(entry.name)] = {
|
|
727
|
+
type: "claudeai-proxy",
|
|
728
|
+
url: connectorProxyUrl(entry.installedServerId),
|
|
729
|
+
id: entry.installedServerId,
|
|
730
|
+
alwaysLoad: true,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
return servers;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* `CLAUDE_BRIDGE_CONNECTOR_DECLARE=off` (or `0`/`false`/`no`) disables explicit
|
|
739
|
+
* connector declarations while leaving connectors themselves enabled. Falls back
|
|
740
|
+
* to the pre-#832 behaviour: connectors still load, they just race the turn-1
|
|
741
|
+
* manifest again.
|
|
742
|
+
*/
|
|
743
|
+
export function connectorDeclarationsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
744
|
+
const v = (env.CLAUDE_BRIDGE_CONNECTOR_DECLARE ?? "").trim().toLowerCase();
|
|
745
|
+
return v === "off" || v === "0" || v === "false" || v === "no";
|
|
746
|
+
}
|