@vanillagreen/pi-claude-bridge 2.0.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 +40 -135
- package/bundle/connector-inventory.js +6 -3
- package/bundle/index.js +2432 -1123
- package/package.json +11 -20
- 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 +189 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +157 -14
- package/src/config.ts +170 -20
- package/src/connector-cache.ts +43 -13
- package/src/connector-inventory.ts +14 -7
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +286 -40
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +6 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +769 -702
- package/src/models.ts +0 -7
- package/src/native-provider.ts +9 -4
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +296 -40
- package/src/rate-limit.ts +17 -14
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +369 -54
- package/src/tool-pairing-audit.ts +69 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Connector prime/snapshot host: the per-credential-scope inventory cache the
|
|
2
|
+
// query path reads synchronously (vstack#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 (vstack#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 (vstack#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
|
-
//
|
|
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 (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
|
+
|
|
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 (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
|
+
|
|
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 (vstack#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
|
|
@@ -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
|
+
// (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
|
+
|
|
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
|
-
*
|
|
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
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
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).
|
|
247
385
|
*
|
|
248
|
-
* Takes the RAW SDK tool name, before `mapToolName` —
|
|
249
|
-
* Pi-side counterpart, so mapping them is meaningless. Accepts a missing
|
|
250
|
-
* rather than asserting one: this decides whether Pi is allowed to
|
|
251
|
-
* block, and a nameless block is
|
|
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
|
|
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
|
|
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(
|
|
499
|
+
return connectorWriteDenyOutput(input.tool_name);
|
|
356
500
|
} catch {
|
|
357
|
-
|
|
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
|
}
|
|
@@ -380,15 +521,114 @@ function connectorWriteDenyOutput(toolName: string) {
|
|
|
380
521
|
};
|
|
381
522
|
}
|
|
382
523
|
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
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.
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|