@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/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
- // docs/plans/claude-bridge-google-connectors.md.
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.startsWith(CONNECTOR_NS_PREFIX)) return false;
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(String(input.tool_name));
499
+ return connectorWriteDenyOutput(input.tool_name);
300
500
  } catch {
301
- const toolName = typeof (input as { tool_name?: unknown })?.tool_name === "string"
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 Memsira's gated approval flow.`,
519
+ `Connector writes must go through the host application's gated approval flow.`,
317
520
  },
318
521
  };
319
522
  }
320
523
 
321
- // Connector query-option fragment: tool isolation (allow/deny lists) plus, when
322
- // connectors are enabled and writes are denied, the runtime PreToolUse write
323
- // hook. Spread into the SDK query options; continuation queries inherit it via
324
- // `{ ...queryOptions }`. Exported so the wiring is unit-testable end to end.
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
- // Only enforce (and only meaningful) when connectors are on and writes denied.
328
- if (!connectorsEnabled || writeMode === "allow") return isolation;
329
- return { ...isolation, hooks: { PreToolUse: [{ hooks: [connectorWriteDenyHook()] }] } };
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
- const disallowedTools = DISALLOWED_BUILTIN_TOOLS.filter((t) => !CONNECTOR_DISCOVERY_TOOLS.includes(t));
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
+ }