@vanillagreen/pi-claude-bridge 2.0.0 → 4.0.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.
@@ -5,7 +5,7 @@
5
5
  // nothing in the result distinguishes "these are the connectors" from "these are
6
6
  // the connectors the search happened to return this time". Downstream then stored
7
7
  // that lower bound as authoritative, so an account with Slack attached could
8
- // report an inventory without Slack and no failure signal (vstack#838).
8
+ // report an inventory without Slack and no failure signal (kendex#838).
9
9
  //
10
10
  // This module asks the account instead of the model. Verified live against a
11
11
  // personal claude_max org: the endpoint is POST (a GET returns 405) and each
@@ -23,7 +23,7 @@
23
23
  // conservatively — it under-reported, which fails safe). They are now separately
24
24
  // observable and can legitimately disagree: a correct `complete: true` inventory
25
25
  // can name Slack while `mcp__claude_ai_Slack__*` is not yet callable in this
26
- // process (vstack#832). Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
26
+ // process (kendex#832). Treat an inventory as NECESSARY BUT NOT SUFFICIENT for
27
27
  // availability and keep an attach-time check on the call path; do not derive
28
28
  // "can I call this tool right now" from this result.
29
29
  //
@@ -140,16 +140,23 @@ export function connectorServerNamespace(connectorName: string): string {
140
140
  }
141
141
 
142
142
  // Candidate credential files, in precedence order. CLAUDE_CONFIG_DIR is set
143
- // per-account by hosts that run one sidecar per Claude account, so it must win
144
- // over the home-directory default or a multi-account host reads the wrong
145
- // account's connectors. Both file names are probed under each root because the
146
- // token and the org UUID do not reliably live in the same file across versions.
143
+ // per-account by hosts that run one sidecar per Claude account, so when it is
144
+ // set that root is probed EXCLUSIVELY: falling through to `~/.claude`/$HOME
145
+ // would silently borrow the DEFAULT account's token for a managed profile
146
+ // whose own `.credentials.json` is missing a confident, well-formed answer
147
+ // for the wrong account (see the token-scoping note above). Only the
148
+ // no-config-dir default probes the home locations. Both file names are probed
149
+ // under each root because the token and the org UUID do not reliably live in
150
+ // the same file across versions.
147
151
  export function credentialCandidatePaths(env: NodeJS.ProcessEnv = process.env): string[] {
148
152
  const roots: string[] = [];
149
153
  const configDir = env.CLAUDE_CONFIG_DIR?.trim();
150
- if (configDir) roots.push(configDir);
151
- const home = env.HOME?.trim();
152
- if (home) roots.push(`${home}/.claude`, home);
154
+ if (configDir) {
155
+ roots.push(configDir);
156
+ } else {
157
+ const home = env.HOME?.trim();
158
+ if (home) roots.push(`${home}/.claude`, home);
159
+ }
153
160
  const seen = new Set<string>();
154
161
  const paths: string[] = [];
155
162
  for (const root of roots) {
@@ -0,0 +1,158 @@
1
+ // Connector prime/snapshot host: the per-credential-scope inventory cache the
2
+ // query path reads synchronously (kendex#832/#870). Extracted from index.ts —
3
+ // this is process-lifetime runtime state, not provider streaming logic.
4
+ //
5
+ // Connector declarations for the query path, cached per credential scope. The
6
+ // inventory is one HTTPS round trip; doing it per TURN would add that latency
7
+ // to every message, and an account's connector set does not change
8
+ // mid-session. Keyed by CLAUDE_CONFIG_DIR because that is what selects the
9
+ // account — the org UUID in the request path is ignored, so two accounts on
10
+ // one host differ only by which credential directory was read.
11
+ //
12
+ // FAILS OPEN. If credentials or the inventory call fail we return no
13
+ // declarations and the turn proceeds exactly as it does today: connectors may
14
+ // race, which is the bug, but a network blip must not break the turn outright.
15
+
16
+ import { readFileSync as nodeReadFileSync } from "node:fs";
17
+ import { readCachedConnectors, scopeKeyFor, writeCachedConnectors } from "./connector-cache.js";
18
+ import { listAccountConnectors, resolveClaudeOAuth } from "./connector-inventory.js";
19
+ import { connectorMcpServers } from "./connectors.js";
20
+ import { debug } from "./debug.js";
21
+
22
+ // Read a credential file, treating any read error as "absent" — a missing or
23
+ // unreadable candidate must fall through to the next one, not abort resolution.
24
+ export function readCredentialFile(path: string): string | undefined {
25
+ try {
26
+ return nodeReadFileSync(path, "utf8");
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ const connectorServerCache = new Map<string, Record<string, unknown>>();
33
+ const connectorServerPending = new Set<string>();
34
+ // Epoch ms of the last FAILED inventory attempt per scope, so a persistently
35
+ // failing account cools down instead of issuing one new HTTPS request per turn
36
+ // (VST-14). Missing credentials are exempt: that check is a local file read
37
+ // with no request to bound, and a just-completed `claude login` must take
38
+ // effect on the next turn.
39
+ const connectorServerFailureAt = new Map<string, number>();
40
+
41
+ // Deadline on the inventory round trip. Without one, a hung claude.ai request
42
+ // held the pending flag forever — same budget as the account-host probe's
43
+ // ACCOUNT_PROBE_DEADLINE_MS (VST-14).
44
+ const CONNECTOR_PRIME_TIMEOUT_MS = 10_000;
45
+ const CONNECTOR_PRIME_FAILURE_COOLDOWN_MS = 60_000;
46
+
47
+ /** Overrides for tests; production callers use the defaults. */
48
+ export type PrimeConnectorOverrides = {
49
+ timeoutMs?: number;
50
+ failureCooldownMs?: number;
51
+ now?: () => number;
52
+ };
53
+
54
+ function connectorScopeKey(claudeConfigDir: string | undefined = process.env.CLAUDE_CONFIG_DIR): string {
55
+ // One rule for every scope key — the on-disk cache and this in-memory cache
56
+ // must name the same scopes (see scopeKeyFor).
57
+ return scopeKeyFor(claudeConfigDir);
58
+ }
59
+
60
+ // Credential resolution env for a selected scope: managed requests always pass
61
+ // their RESOLVED dir (accountSessionScope), so the parent's CLAUDE_CONFIG_DIR
62
+ // never leaks into another profile's lookup; legacy passes undefined and keeps
63
+ // the process-env rule.
64
+ export function connectorCredentialEnv(claudeConfigDir: string | undefined = process.env.CLAUDE_CONFIG_DIR): NodeJS.ProcessEnv {
65
+ const env = { ...process.env };
66
+ if (claudeConfigDir?.trim()) env.CLAUDE_CONFIG_DIR = claudeConfigDir.trim();
67
+ else delete env.CLAUDE_CONFIG_DIR;
68
+ return env;
69
+ }
70
+
71
+ // Kick off the inventory fetch for the current credential scope. Fire and
72
+ // forget: the query path can only read a SYNCHRONOUS snapshot, because
73
+ // streamClaudeAgentSdk returns a stream and claims the SDK query handle in the
74
+ // same tick — there is no await boundary to hang a fetch on without
75
+ // restructuring abort handling.
76
+ //
77
+ // Primed at provider registration so the result is in hand well before the
78
+ // first turn (the call measured ~400ms against app startup). If a turn arrives
79
+ // first it declares nothing and behaves exactly as it does today — the race is
80
+ // back for that one turn, which is the bug, but never worse than the status quo.
81
+ //
82
+ // FAILS OPEN throughout: no credentials, a failed inventory, or a thrown call
83
+ // all resolve to "declare nothing" rather than breaking the turn. Failures are
84
+ // NOT cached — a transient blip at registration used to pin `{}` for the
85
+ // process lifetime, keeping every later turn undeclared and the disk-cache
86
+ // fallback unreachable. Leaving the key unset makes the next snapshot retry;
87
+ // the pending set dedupes concurrent fetches, and a failed inventory attempt
88
+ // stamps a per-scope cooldown so a persistently failing account backs off
89
+ // instead of re-priming on every turn (VST-14).
90
+ export function primeConnectorServers(claudeConfigDir?: string, overrides: PrimeConnectorOverrides = {}): void {
91
+ const key = connectorScopeKey(claudeConfigDir);
92
+ if (connectorServerCache.has(key) || connectorServerPending.has(key)) return;
93
+ const now = overrides.now ?? Date.now;
94
+ const cooldownMs = overrides.failureCooldownMs ?? CONNECTOR_PRIME_FAILURE_COOLDOWN_MS;
95
+ const failedAt = connectorServerFailureAt.get(key);
96
+ if (failedAt !== undefined && now() - failedAt < cooldownMs) {
97
+ debug("connectors: prime cooling down after failure; declaring none until retry window opens");
98
+ return;
99
+ }
100
+ connectorServerPending.add(key);
101
+ void (async () => {
102
+ let failed = false;
103
+ try {
104
+ const credentials = resolveClaudeOAuth(readCredentialFile, connectorCredentialEnv(claudeConfigDir));
105
+ if (!credentials) {
106
+ debug("connectors: no OAuth credentials; declaring none (will retry)");
107
+ return;
108
+ }
109
+ const inventory = await listAccountConnectors({
110
+ credentials,
111
+ // A hung inventory request must not outlive the deadline: the abort
112
+ // surfaces as `ok: false` and takes the cooldown path below.
113
+ signal: AbortSignal.timeout(overrides.timeoutMs ?? CONNECTOR_PRIME_TIMEOUT_MS),
114
+ });
115
+ if (!inventory.ok) {
116
+ failed = true;
117
+ debug(`connectors: inventory failed (${inventory.reason}); declaring none (will retry after cooldown)`);
118
+ return;
119
+ }
120
+ const servers = connectorMcpServers(inventory);
121
+ debug(`connectors: declaring ${Object.keys(servers).length} of ${inventory.connectors.length} installed`,
122
+ Object.keys(servers).join(", ") || "none");
123
+ connectorServerCache.set(key, servers);
124
+ connectorServerFailureAt.delete(key);
125
+ // Persist so the NEXT cold process has this synchronously. Priming always
126
+ // loses the race against turn 1 in its own process; a cache written by an
127
+ // earlier run is the only thing turn 1 can read in time (kendex#870).
128
+ if (writeCachedConnectors(inventory.connectors, key)) {
129
+ debug(`connectors: cached ${inventory.connectors.length} entries`);
130
+ }
131
+ } catch (error) {
132
+ failed = true;
133
+ debug("connectors: declaration lookup threw; declaring none (will retry after cooldown)", error);
134
+ } finally {
135
+ if (failed) connectorServerFailureAt.set(key, now());
136
+ connectorServerPending.delete(key);
137
+ }
138
+ })();
139
+ }
140
+
141
+ /** Synchronous snapshot for the query path; `{}` until priming resolves. */
142
+ export function connectorServersSnapshot(claudeConfigDir?: string): Record<string, unknown> {
143
+ const key = connectorScopeKey(claudeConfigDir);
144
+ const ready = connectorServerCache.get(key);
145
+ if (ready) return ready;
146
+ // Always start (or continue) the live fetch — the cache is a head start, not
147
+ // a replacement, and the refresh keeps the next process current.
148
+ primeConnectorServers(claudeConfigDir);
149
+ // Fall back to the previous run's inventory, read synchronously. This is the
150
+ // only thing that can populate turn 1 of a cold process, because priming
151
+ // cannot finish before the first query is built (kendex#870).
152
+ const cached = readCachedConnectors(key);
153
+ if (!cached) return {};
154
+ const servers = connectorMcpServers({ ok: true, complete: true, connectors: cached });
155
+ if (Object.keys(servers).length === 0) return {};
156
+ debug(`connectors: turn-1 declarations from cache — ${Object.keys(servers).join(", ")}`);
157
+ return servers;
158
+ }
package/src/connectors.ts CHANGED
@@ -1,6 +1,6 @@
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
4
  import { connectorProxyUrl, connectorServerName, type ConnectorInventory } from "./connector-inventory.js";
5
5
 
6
6
  // Disable Claude Code built-ins in the provider path. Pi owns tool execution;
@@ -35,8 +35,8 @@ export const CLAUDE_BRIDGE_TOOL_ISOLATION = {
35
35
  // execution and tokens stay lean. This opt-in flag lets the authenticated
36
36
  // Claude account's authorized Google connectors flow through to the model,
37
37
  // exposing Gmail/Calendar/Drive tools the account has connected. Gated so the
38
- // default behavior is unchanged. See
39
- // docs/plans/claude-bridge-google-connectors.md.
38
+ // default behavior is unchanged. See the Connectors section of this package's
39
+ // README.
40
40
  export function connectorsEnabledFromEnv(): boolean {
41
41
  const v = (process.env.CLAUDE_BRIDGE_ENABLE_CONNECTORS ?? "").trim().toLowerCase();
42
42
  return v === "1" || v === "true" || v === "yes" || v === "on";
@@ -50,6 +50,41 @@ export function connectorsEnabledFor(config?: Config): boolean {
50
50
  return connectorsEnabledFromEnv() || config?.provider?.enableConnectors === true;
51
51
  }
52
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 (kendex#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
+
53
88
  // Cloud MCP connector tool namespaces auto-allowed when connectors are enabled.
54
89
  // Names match Claude Code's claude.ai connector servers.
55
90
  // Whole-server globs (the only glob shape the CLI matcher honors). Deny rules
@@ -63,14 +98,61 @@ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
63
98
  "mcp__claude_ai_Atlassian__*",
64
99
  ];
65
100
 
101
+ // --- The SDK's two-name trap for built-in tools (kendex#1007, kendex#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 (kendex#1007), and
122
+ // the connectors allowlist hook DENIED the two discovery tools it exists to
123
+ // permit (kendex#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
+
66
137
  // Claude Code registers a Claude account's cloud connectors as DEFERRED tools
67
138
  // that the model must load via ToolSearch (and enumerate via the MCP-resource
68
139
  // tools). The default bridge isolation disallows all three so Pi owns tool
69
140
  // discovery — but that hides the connectors from the model entirely. When
70
141
  // connectors are enabled we must let these through so Gmail/Calendar/Drive are
71
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.
72
147
  export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", "ListMcpResources", "ReadMcpResource"];
73
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 (kendex#1011).
154
+ const CONNECTOR_DISCOVERY_TOOL_NAMES = new Set(CONNECTOR_DISCOVERY_TOOLS.flatMap(deliveredSpellings));
155
+
74
156
  // --- Connector WRITE tool control (read-inline / write-by-approval) ---
75
157
  //
76
158
  // Connector tools execute INSIDE claude via the bridge, so Memsira's Pi-level
@@ -161,7 +243,7 @@ function connectorNameWords(segment: string): string[] {
161
243
  // exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
162
244
  //
163
245
  // PUBLIC CONTRACT — this list and `isConnectorWriteTool` have downstream
164
- // dependents that gate real user-facing approvals on them (vstack#892):
246
+ // dependents that gate real user-facing approvals on them (kendex#892):
165
247
  //
166
248
  // memsira routes connector writes through its own gated approval flow
167
249
  // drovr keeps its chat sidecar permanently write-`deny` and runs an
@@ -215,6 +297,66 @@ export const CONNECTOR_WRITE_TOOLS = [
215
297
  `${CONNECTOR_NS_ATLASSIAN}createCompassCustomFieldDefinition`,
216
298
  ];
217
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
+ // (kendex#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 kendex#1007. The allowlist hook is what lets those calls run at
342
+ // all — see isAllowlistedConnectorSessionTool (kendex#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 (kendex#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
+
218
360
  /**
219
361
  * True for a tool that the `claude` CHILD executes itself, so Pi must never be
220
362
  * asked to dispatch it.
@@ -226,32 +368,29 @@ export const CONNECTOR_WRITE_TOOLS = [
226
368
  * it mirrors the call into the Pi stream, ends the Pi turn with `toolUse`, and
227
369
  * the MCP handler blocks until Pi delivers the result.
228
370
  *
229
- * claude.ai connectors run the other way. They are the CHILD's own MCP servers,
230
- * attached to the authenticated account and reachable only from inside that
231
- * process. Pi has never heard of them. Mirroring one into the Pi stream anyway
232
- * made Pi's agent loop look the name up in `context.tools`, miss, and write a
233
- * synthetic `Tool <name> not found` error result into the transcript — while the
234
- * child went on and executed the real call. The Pi transcript then RECORDED A
235
- * FAILURE FOR A CALL THAT SUCCEEDED, next to an answer built from the real
236
- * payload, so the model's correct answer read as a fabrication (drovr#311,
237
- * memsira#320). The false result is also projected back into the child's session
238
- * on a rebuild (`syncSharedSession`), which is how a lie in a mirror becomes a
239
- * lie in the conversation of record.
371
+ * Two tool classes run the other way, and both must stay un-mirrored:
240
372
  *
241
- * The test is the NAMESPACE, deliberately, not "does this name resolve to a Pi
242
- * tool". Every claude.ai connector server lives under `mcp__claude_ai_`, and
243
- * that is the only tool class the bridge knowingly delegates. A broader
244
- * "unresolvable delegated" rule would also swallow a genuine tool-name
245
- * mismatch between Pi and the child, which SHOULD still surface as a loud
246
- * dispatcher error.
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 (kendex#980).
247
385
  *
248
- * Takes the RAW SDK tool name, before `mapToolName` — connector names have no
249
- * Pi-side counterpart, so mapping them is meaningless. Accepts a missing name
250
- * rather than asserting one: this decides whether Pi is allowed to dispatch a
251
- * block, and a nameless block is not a connector, so it answers `false`.
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`.
252
391
  */
253
392
  export function isChildExecutedTool(name: string | undefined): boolean {
254
- return typeof name === "string" && name.startsWith(CONNECTOR_NS_PREFIX);
393
+ return isConnectorTool(name) || isChildInternalTool(name);
255
394
  }
256
395
 
257
396
  // Classify a connector tool name as a WRITE (mutating) tool. FAIL CLOSED, twice:
@@ -277,7 +416,7 @@ export function isChildExecutedTool(name: string | undefined): boolean {
277
416
  // servers) are never connector writes → false. Used by connectorWriteDenyHook and
278
417
  // by callers (e.g. the one-shot write executor) that enumerate live connector tools.
279
418
  export function isConnectorWriteTool(name: string): boolean {
280
- if (!name.startsWith(CONNECTOR_NS_PREFIX)) return false;
419
+ if (!isConnectorTool(name)) return false;
281
420
  // First `__` after the prefix ends the server segment. First (not last) so a
282
421
  // server name containing `__` leaves the extra segment in `tool`, which then
283
422
  // fails the read-prefix test — ambiguity resolves to write.
@@ -351,13 +490,15 @@ export function connectorWriteDenyHook(): HookCallback {
351
490
  // whatever gets added here later.
352
491
  try {
353
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>");
354
498
  if (!isConnectorWriteTool(input.tool_name)) return { continue: true };
355
- return connectorWriteDenyOutput(String(input.tool_name));
499
+ return connectorWriteDenyOutput(input.tool_name);
356
500
  } catch {
357
- const toolName = typeof (input as { tool_name?: unknown })?.tool_name === "string"
358
- ? (input as { tool_name: string }).tool_name
359
- : "<unknown>";
360
- return connectorWriteDenyOutput(toolName);
501
+ return connectorWriteDenyOutput(safeToolNameFrom(input));
361
502
  }
362
503
  };
363
504
  }
@@ -366,7 +507,7 @@ export function connectorWriteDenyHook(): HookCallback {
366
507
  // stay PRODUCT-NEUTRAL: this is shared source and every consuming app shows it.
367
508
  // Naming one host told a different app's model to use a product it has never
368
509
  // heard of, which is confusing at exactly the moment someone is debugging a
369
- // refused write (vstack#892). Each host describes its own approval flow in its
510
+ // refused write (kendex#892). Each host describes its own approval flow in its
370
511
  // own prompt; this string only has to say that one exists.
371
512
  function connectorWriteDenyOutput(toolName: string) {
372
513
  return {
@@ -380,15 +521,114 @@ function connectorWriteDenyOutput(toolName: string) {
380
521
  };
381
522
  }
382
523
 
383
- // Connector query-option fragment: tool isolation (allow/deny lists) plus, when
384
- // connectors are enabled and writes are denied, the runtime PreToolUse write
385
- // hook. Spread into the SDK query options; continuation queries inherit it via
386
- // `{ ...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
+ // (kendex#1011). That also carries kendex#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.
387
622
  export function connectorQueryOptions(connectorsEnabled: boolean, writeMode: ConnectorWriteMode = "deny"): Partial<Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools" | "hooks">> {
388
623
  const isolation = toolIsolationForQuery(connectorsEnabled, writeMode);
389
- // Only enforce (and only meaningful) when connectors are on and writes denied.
390
- if (!connectorsEnabled || writeMode === "allow") return isolation;
391
- 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 }] } };
392
632
  }
393
633
 
394
634
  // Tool isolation for a query. When connectors are enabled we still remove
@@ -405,7 +645,13 @@ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: Con
405
645
  if (!connectorsEnabled) return CLAUDE_BRIDGE_TOOL_ISOLATION;
406
646
  // Keep ToolSearch + MCP-resource tools available so the model can discover the
407
647
  // deferred cloud connector tools; still block file/shell/web built-ins.
408
- 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));
409
655
  // Deny connector WRITE tools unless writes are explicitly allowed (fail
410
656
  // closed: any mode but exact "allow" is treated as read-only). This removes
411
657
  // today's KNOWN writes from the model's context by exact id; deny rules take
@@ -423,7 +669,7 @@ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: Con
423
669
  /**
424
670
  * Explicit `mcpServers` declarations for the account's CONNECTED connectors.
425
671
  *
426
- * Why this exists (vstack#832): claude.ai connectors load async and non-blocking,
672
+ * Why this exists (kendex#832): claude.ai connectors load async and non-blocking,
427
673
  * and the turn-1 tool manifest is built at +410-665ms — roughly 300ms BEFORE the
428
674
  * CLI has even fetched the connector list. The model therefore composes its first
429
675
  * answer against a manifest containing no connectors and says it has no access,