@vanillagreen/pi-claude-bridge 1.6.2 → 1.9.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.
@@ -0,0 +1,281 @@
1
+ // Deterministic enumeration of a Claude account's installed claude.ai connectors.
2
+ //
3
+ // The capability probe this replaces asked the MODEL to enumerate connectors via
4
+ // ToolSearch. A search returns what the search surfaced, which is a LOWER BOUND —
5
+ // nothing in the result distinguishes "these are the connectors" from "these are
6
+ // the connectors the search happened to return this time". Downstream then stored
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).
9
+ //
10
+ // This module asks the account instead of the model. Verified live against a
11
+ // personal claude_max org: the endpoint is POST (a GET returns 405) and each
12
+ // result carries BOTH `directoryUuid` (the catalog identity) and
13
+ // `installedServerId` (this account's installed instance). A marketplace catalog
14
+ // entry has no installed-server id, which is what establishes this as the
15
+ // INSTALLED set rather than the registry listing.
16
+ //
17
+ // INSTALLED IS NOT ATTACHED. This endpoint reports what the ACCOUNT has
18
+ // installed. It says nothing about whether a given connector's MCP server has
19
+ // finished attaching inside the `claude` child that is about to run a turn —
20
+ // this is a plain HTTPS call and does not consult that process at all. The two
21
+ // were previously conflated by accident: the ToolSearch probe could only report
22
+ // what was already attached, so an inventory implied availability (wrongly, but
23
+ // conservatively — it under-reported, which fails safe). They are now separately
24
+ // observable and can legitimately disagree: a correct `complete: true` inventory
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
27
+ // availability and keep an attach-time check on the call path; do not derive
28
+ // "can I call this tool right now" from this result.
29
+ //
30
+ // Because the answer comes from the account rather than a model turn, the result
31
+ // is complete by construction — hence `complete: true` on success, and no
32
+ // "partial" state. A failure is a failure, never an empty-but-successful list.
33
+
34
+ const CONNECTOR_NS_PREFIX = "mcp__claude_ai_";
35
+ const DEFAULT_API_BASE = "https://api.anthropic.com";
36
+ // OAuth-token requests to the Anthropic API require this beta header; without it
37
+ // endpoints reject the bearer credential.
38
+ const OAUTH_BETA_HEADER = "oauth-2025-04-20";
39
+
40
+ export type ConnectorEntry = {
41
+ name: string;
42
+ /** This account's installed instance. Present on every live result observed. */
43
+ installedServerId?: string;
44
+ /** Catalog identity, shared across accounts that install the same connector. */
45
+ directoryUuid?: string;
46
+ description?: string;
47
+ isAuthless?: boolean;
48
+ };
49
+
50
+ // Discriminated so a caller cannot read `connectors` without having checked `ok`.
51
+ // `complete` is carried explicitly rather than implied: the whole defect this
52
+ // fixes was a result that looked authoritative while being a lower bound.
53
+ // The absent side of each variant is declared as `?: undefined` rather than
54
+ // omitted: this package compiles with `strict: false`, where narrowing a union
55
+ // by a boolean discriminant does not reliably filter members, so a bare
56
+ // `{ok:true}|{ok:false}` pair makes `inventory.reason` a compile error at every
57
+ // call site. Spelling both sides keeps the union discriminated AND readable
58
+ // without depending on strictNullChecks-era narrowing.
59
+ export type ConnectorInventory =
60
+ | { ok: true; complete: true; connectors: ConnectorEntry[]; reason?: undefined }
61
+ | { ok: false; complete: false; connectors?: undefined; reason: string };
62
+
63
+ // SCOPING IS BY TOKEN, NOT BY ORG. Verified live: the org UUID in the path is
64
+ // ignored — an all-zero UUID and the literal string "not-a-uuid" both returned
65
+ // the bearer token's own account, identically to the real org. A multi-account
66
+ // host therefore CANNOT select an account by passing its organizationUuid; the
67
+ // only thing that selects an account is which credential the token came from
68
+ // (i.e. which CLAUDE_CONFIG_DIR was read). Getting that wrong yields a
69
+ // confident, well-formed answer for the WRONG account.
70
+ //
71
+ // The real UUID is still sent rather than a placeholder, so the call keeps
72
+ // working if the API starts enforcing it.
73
+ export type ClaudeOAuthCredentials = {
74
+ accessToken: string;
75
+ organizationUuid: string;
76
+ };
77
+
78
+ type Json = Record<string, any>;
79
+
80
+ /**
81
+ * Tool-namespace prefix for a connector, e.g. `Google Calendar` →
82
+ * `mcp__claude_ai_Google_Calendar__`. Connector servers are named after the
83
+ * connector with whitespace replaced by underscores; corroborated against the
84
+ * independently-authored CLAUDE_AI_CONNECTOR_TOOL_PATTERNS in connectors.ts,
85
+ * which was built from a live tool enumeration rather than from this rule.
86
+ */
87
+ export function connectorServerNamespace(connectorName: string): string {
88
+ return `${CONNECTOR_NS_PREFIX}${connectorName.trim().replace(/\s+/g, "_")}__`;
89
+ }
90
+
91
+ // Candidate credential files, in precedence order. CLAUDE_CONFIG_DIR is set
92
+ // per-account by hosts that run one sidecar per Claude account, so it must win
93
+ // over the home-directory default or a multi-account host reads the wrong
94
+ // account's connectors. Both file names are probed under each root because the
95
+ // token and the org UUID do not reliably live in the same file across versions.
96
+ export function credentialCandidatePaths(env: NodeJS.ProcessEnv = process.env): string[] {
97
+ const roots: string[] = [];
98
+ const configDir = env.CLAUDE_CONFIG_DIR?.trim();
99
+ if (configDir) roots.push(configDir);
100
+ const home = env.HOME?.trim();
101
+ if (home) roots.push(`${home}/.claude`, home);
102
+ const seen = new Set<string>();
103
+ const paths: string[] = [];
104
+ for (const root of roots) {
105
+ for (const name of [".credentials.json", ".claude.json"]) {
106
+ const p = `${root}/${name}`;
107
+ if (!seen.has(p)) { seen.add(p); paths.push(p); }
108
+ }
109
+ }
110
+ return paths;
111
+ }
112
+
113
+ /**
114
+ * Pull the OAuth access token and organization UUID out of the Claude config.
115
+ * They are scanned independently across all candidate files because they are not
116
+ * guaranteed to co-locate: on the machine this was verified against, the token
117
+ * lives in `.credentials.json` and the org UUID in `.claude.json`.
118
+ *
119
+ * `readFile` returns undefined for a missing/unreadable path. Parse failures are
120
+ * skipped rather than thrown — a corrupt file must not mask a good one later in
121
+ * the list.
122
+ */
123
+ export function resolveClaudeOAuth(
124
+ readFile: (path: string) => string | undefined,
125
+ env: NodeJS.ProcessEnv = process.env,
126
+ ): ClaudeOAuthCredentials | undefined {
127
+ let accessToken: string | undefined;
128
+ let organizationUuid: string | undefined;
129
+
130
+ for (const path of credentialCandidatePaths(env)) {
131
+ const raw = readFile(path);
132
+ if (!raw) continue;
133
+ let parsed: Json;
134
+ try {
135
+ parsed = JSON.parse(raw) as Json;
136
+ } catch {
137
+ continue;
138
+ }
139
+ accessToken ??= nonEmptyString(parsed?.claudeAiOauth?.accessToken);
140
+ organizationUuid ??= nonEmptyString(parsed?.oauthAccount?.organizationUuid);
141
+ if (accessToken && organizationUuid) break;
142
+ }
143
+
144
+ if (!accessToken || !organizationUuid) return undefined;
145
+ return { accessToken, organizationUuid };
146
+ }
147
+
148
+ function nonEmptyString(value: unknown): string | undefined {
149
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
150
+ }
151
+
152
+ export function connectorsListUrl(organizationUuid: string, apiBase: string = DEFAULT_API_BASE): string {
153
+ return `${trimTrailingSlashes(apiBase)}/api/oauth/organizations/${encodeURIComponent(organizationUuid)}/mcp/connectors/list`;
154
+ }
155
+
156
+ // Linear-time trailing-slash trim. This was `apiBase.replace(/\/+$/, "")`, which
157
+ // CodeQL correctly flags as a polynomial regex on uncontrolled input: `apiBase`
158
+ // is a caller-supplied parameter, and an anchored `+` backtracks on a long run
159
+ // of slashes. It only became reachable as library input once this module gained
160
+ // a real export surface, which is exactly the exposure the export was for.
161
+ function trimTrailingSlashes(value: string): string {
162
+ let end = value.length;
163
+ while (end > 0 && value.charCodeAt(end - 1) === 47 /* "/" */) end--;
164
+ return value.slice(0, end);
165
+ }
166
+
167
+ export type ListConnectorsDeps = {
168
+ credentials: ClaudeOAuthCredentials;
169
+ fetchImpl?: typeof fetch;
170
+ apiBase?: string;
171
+ signal?: AbortSignal;
172
+ };
173
+
174
+ /**
175
+ * Enumerate the account's installed connectors. Never throws: transport and
176
+ * protocol failures come back as `{ ok: false }` with a reason, so a caller can
177
+ * distinguish "this account has no connectors" (ok, empty list) from "we could
178
+ * not find out" — the distinction the search-driven probe could not express.
179
+ *
180
+ * The reason string is built only from the HTTP status and the API's own error
181
+ * message; the bearer token is never interpolated into it or logged.
182
+ */
183
+ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<ConnectorInventory> {
184
+ const { credentials, apiBase, signal } = deps;
185
+ const fetchImpl = deps.fetchImpl ?? fetch;
186
+ const url = connectorsListUrl(credentials.organizationUuid, apiBase);
187
+ // Every failure return goes through this. Transport errors are the risk: a
188
+ // fetch/proxy layer is free to put the request headers — and therefore the
189
+ // bearer token — into the message it throws, and that message would otherwise
190
+ // land in a reason string that callers log.
191
+ const fail = (reason: string): ConnectorInventory =>
192
+ ({ ok: false, complete: false, reason: redactSecret(reason, credentials.accessToken) });
193
+
194
+ let response: Response;
195
+ try {
196
+ response = await fetchImpl(url, {
197
+ method: "POST",
198
+ headers: {
199
+ "Authorization": `Bearer ${credentials.accessToken}`,
200
+ "anthropic-beta": OAUTH_BETA_HEADER,
201
+ "Content-Type": "application/json",
202
+ },
203
+ body: "{}",
204
+ signal,
205
+ });
206
+ } catch (error) {
207
+ return fail(`connector list request failed: ${errorText(error)}`);
208
+ }
209
+
210
+ let bodyText: string;
211
+ try {
212
+ bodyText = await response.text();
213
+ } catch (error) {
214
+ return fail(`connector list response unreadable: ${errorText(error)}`);
215
+ }
216
+
217
+ if (!response.ok) {
218
+ return fail(`connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
219
+ }
220
+
221
+ let parsed: Json;
222
+ try {
223
+ parsed = JSON.parse(bodyText) as Json;
224
+ } catch {
225
+ return fail("connector list returned a non-JSON body");
226
+ }
227
+
228
+ // A missing/!Array `results` is a protocol change, not an empty account. Treat
229
+ // it as failure — reporting "no connectors" here would recreate exactly the
230
+ // silent-wrong-answer failure this module exists to remove.
231
+ if (!Array.isArray(parsed?.results)) {
232
+ return fail("connector list response had no results array");
233
+ }
234
+
235
+ const connectors: ConnectorEntry[] = [];
236
+ for (const raw of parsed.results as unknown[]) {
237
+ const entry = raw as Json;
238
+ const name = nonEmptyString(entry?.name);
239
+ // An unnamed entry cannot be matched to a tool namespace by any consumer,
240
+ // so silently keeping it would understate the inventory in a way the
241
+ // caller could not detect. Fail instead.
242
+ if (!name) {
243
+ return fail("connector list contained an entry with no name");
244
+ }
245
+ connectors.push({
246
+ name,
247
+ installedServerId: nonEmptyString(entry?.installedServerId),
248
+ directoryUuid: nonEmptyString(entry?.directoryUuid),
249
+ description: nonEmptyString(entry?.description),
250
+ isAuthless: typeof entry?.isAuthless === "boolean" ? entry.isAuthless : undefined,
251
+ });
252
+ }
253
+
254
+ return { ok: true, complete: true, connectors };
255
+ }
256
+
257
+ function apiErrorSuffix(bodyText: string): string {
258
+ try {
259
+ const message = (JSON.parse(bodyText) as Json)?.error?.message;
260
+ return typeof message === "string" && message.trim() ? ` (${message.trim()})` : "";
261
+ } catch {
262
+ return "";
263
+ }
264
+ }
265
+
266
+ // Replace the bearer token wherever it appears in text headed for a caller.
267
+ // Also covers a URL-encoded rendering, since some transports encode headers into
268
+ // an error's message. Short/empty tokens are not substituted — an over-eager
269
+ // match would corrupt unrelated text.
270
+ function redactSecret(text: string, secret: string): string {
271
+ if (!secret || secret.length < 8) return text;
272
+ let out = text;
273
+ for (const form of new Set([secret, encodeURIComponent(secret)])) {
274
+ out = out.split(form).join("[redacted]");
275
+ }
276
+ return out;
277
+ }
278
+
279
+ function errorText(error: unknown): string {
280
+ return error instanceof Error ? error.message : String(error);
281
+ }
@@ -0,0 +1,359 @@
1
+ import { type HookCallback, type query } from "@anthropic-ai/claude-agent-sdk";
2
+ import { normalizeConnectorWriteMode, type Config, type ConnectorWriteMode } from "./config.js";
3
+ import { MCP_SERVER_NAME } from "./skills.js";
4
+
5
+ // Disable Claude Code built-ins in the provider path. Pi owns tool execution;
6
+ // Claude reaches Pi tools through the bridged MCP server instead.
7
+ //
8
+ // `allowedTools` is a permission auto-allow list in the Claude Agent SDK, not a
9
+ // visibility allowlist. Use `tools: []` to remove the built-in tool set, and keep
10
+ // this disallow list as a belt-and-suspenders guard for SDK/CLI built-ins that may
11
+ // otherwise leak into the model context (e.g. TodoWrite, CronList, SendMessage).
12
+ export const DISALLOWED_BUILTIN_TOOLS = [
13
+ "Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "Bash", "Agent", "Task",
14
+ "NotebookEdit", "EnterWorktree", "ExitWorktree",
15
+ "CronList", "CronCreate", "CronDelete", "TeamCreate", "TeamDelete",
16
+ "TaskOutput", "TaskStop", "SendMessage", "Skill",
17
+ "TodoRead", "TodoWrite",
18
+ "ListMcpResources", "ReadMcpResource",
19
+ "WebFetch", "WebSearch",
20
+ "AskUserQuestion", "EnterPlanMode", "ExitPlanMode",
21
+ "ToolSearch", "ScheduleWakeup",
22
+ ];
23
+
24
+ export const CLAUDE_BRIDGE_TOOL_ISOLATION = {
25
+ tools: [] as string[],
26
+ disallowedTools: DISALLOWED_BUILTIN_TOOLS,
27
+ allowedTools: [`mcp__${MCP_SERVER_NAME}__*`],
28
+ } satisfies Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools">;
29
+
30
+ // --- Claude account cloud MCP connectors (Gmail / Calendar / Drive) ---
31
+ //
32
+ // By default the bridge suppresses claude.ai cloud MCP servers (see the
33
+ // ENABLE_CLAUDEAI_MCP_SERVERS="0" note near the query builder) so Pi owns tool
34
+ // execution and tokens stay lean. This opt-in flag lets the authenticated
35
+ // Claude account's authorized Google connectors flow through to the model,
36
+ // 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.
39
+ export function connectorsEnabledFromEnv(): boolean {
40
+ const v = (process.env.CLAUDE_BRIDGE_ENABLE_CONNECTORS ?? "").trim().toLowerCase();
41
+ return v === "1" || v === "true" || v === "yes" || v === "on";
42
+ }
43
+
44
+ // Connectors are enabled if EITHER the env var is truthy OR the resolved bridge
45
+ // config sets `provider.enableConnectors`. Env is the simplest per-process knob
46
+ // (one sidecar per Claude account sets it in its child env); config lets a host
47
+ // app enable it declaratively via its written settings.json.
48
+ export function connectorsEnabledFor(config?: Config): boolean {
49
+ return connectorsEnabledFromEnv() || config?.provider?.enableConnectors === true;
50
+ }
51
+
52
+ // Cloud MCP connector tool namespaces auto-allowed when connectors are enabled.
53
+ // Names match Claude Code's claude.ai connector servers.
54
+ // Whole-server globs (the only glob shape the CLI matcher honors). Deny rules
55
+ // take precedence, so listing a server here never exposes its writes — the
56
+ // write ids above and the PreToolUse hook still remove them.
57
+ export const CLAUDE_AI_CONNECTOR_TOOL_PATTERNS = [
58
+ "mcp__claude_ai_Gmail__*",
59
+ "mcp__claude_ai_Google_Calendar__*",
60
+ "mcp__claude_ai_Google_Drive__*",
61
+ "mcp__claude_ai_Slack__*",
62
+ "mcp__claude_ai_Atlassian__*",
63
+ ];
64
+
65
+ // Claude Code registers a Claude account's cloud connectors as DEFERRED tools
66
+ // that the model must load via ToolSearch (and enumerate via the MCP-resource
67
+ // tools). The default bridge isolation disallows all three so Pi owns tool
68
+ // discovery — but that hides the connectors from the model entirely. When
69
+ // connectors are enabled we must let these through so Gmail/Calendar/Drive are
70
+ // discoverable. Verified: disallowing ToolSearch reliably yields NO_CONNECTORS.
71
+ export const CONNECTOR_DISCOVERY_TOOLS = ["ToolSearch", "ListMcpResources", "ReadMcpResource"];
72
+
73
+ // --- Connector WRITE tool control (read-inline / write-by-approval) ---
74
+ //
75
+ // Connector tools execute INSIDE claude via the bridge, so Memsira's Pi-level
76
+ // ConsentGate never sees them. To keep every connector WRITE explicit + gated,
77
+ // connector chat sessions run read-only (writes denied); the model performs a
78
+ // write only through a gated Pi custom-tool whose app-side dispatcher runs a
79
+ // ONE-SHOT write-enabled bridge query. This block is the bridge lever for that:
80
+ // deny connector write tools by default, allow them only for that executor.
81
+ //
82
+ // Cloud connector server namespaces (the `mcp__<server>__` prefix). Every
83
+ // claude.ai connector server lives under CONNECTOR_NS_PREFIX, so that prefix —
84
+ // not the named trio — is what marks a tool as connector-owned.
85
+ const CONNECTOR_NS_PREFIX = "mcp__claude_ai_";
86
+ const CONNECTOR_NS_GMAIL = `${CONNECTOR_NS_PREFIX}Gmail__`;
87
+ const CONNECTOR_NS_CALENDAR = `${CONNECTOR_NS_PREFIX}Google_Calendar__`;
88
+ const CONNECTOR_NS_DRIVE = `${CONNECTOR_NS_PREFIX}Google_Drive__`;
89
+ const CONNECTOR_NS_SLACK = `${CONNECTOR_NS_PREFIX}Slack__`;
90
+ const CONNECTOR_NS_ATLASSIAN = `${CONNECTOR_NS_PREFIX}Atlassian__`;
91
+
92
+ // Read verbs: a connector tool whose tool segment BEGINS with one of these
93
+ // words is a non-mutating READ and stays available. Everything else on a
94
+ // connector namespace is treated as a WRITE. Matching is on WORDS, not on a
95
+ // literal `verb_` prefix, because connector servers do not share a naming
96
+ // convention — verified live against a Claude account with Slack + Atlassian
97
+ // attached:
98
+ //
99
+ // Gmail search_threads, get_message (snake_case)
100
+ // Slack slack_read_channel, slack_search_public
101
+ // (snake_case, server-prefixed)
102
+ // Atlassian getJiraIssue, searchJiraIssuesUsingJql, getConfluencePage
103
+ // (camelCase)
104
+ //
105
+ // A literal-prefix test only matched the Gmail shape, so EVERY Slack and
106
+ // Atlassian tool — reads included — classified as a write and was denied at
107
+ // runtime, making those connectors unusable in a read-only session.
108
+ const CONNECTOR_READ_VERBS = new Set([
109
+ "list", "search", "get", "read", "fetch", "find",
110
+ "download", "describe", "query", "count", "view", "lookup", "whoami",
111
+ ]);
112
+
113
+ // Mutating words. Two jobs, both on the deny side:
114
+ //
115
+ // 1. A name that begins with a read verb but also names a mutation
116
+ // (`fetchAndLock`, `get_incident_and_acknowledge`) is a WRITE — deny wins
117
+ // over the read exemption, so a compound name cannot earn read treatment.
118
+ // 2. A leading word that repeats the server name is NOT skipped when it is a
119
+ // mutation word, so a connector whose SERVER is verb-shaped
120
+ // (`Delete__delete_get_thing`) keeps its real verb.
121
+ //
122
+ // This list is deliberately broad and errs toward over-denial: a read wrongly
123
+ // called a write only blocks a read, whereas the reverse runs an ungated
124
+ // mutation. It is a backstop, not the primary protection — that is the leading
125
+ // verb, which real connector tools put first (`createJiraIssue`,
126
+ // `slack_send_message`, `delete_file`). Nouns that collide with real read names
127
+ // are deliberately excluded (`comment`/`tag`/`flag`/`run`, since
128
+ // `getComment`, `searchByTag`, `getFeatureFlag`, `getWorkflowRun` are reads).
129
+ const CONNECTOR_MUTATION_WORDS = new Set([
130
+ "create", "update", "delete", "remove", "add", "edit", "send", "post",
131
+ "write", "upload", "publish", "schedule", "transition", "archive", "move",
132
+ "copy", "revoke", "assign", "invite", "share", "rename", "replace", "set",
133
+ "merge", "resolve", "lock", "unlock", "acknowledge", "ack", "book",
134
+ "start", "stop", "terminate", "restart", "join", "leave", "star", "unstar",
135
+ "forward", "sync", "approve", "reject", "close", "reopen", "cancel",
136
+ "enable", "disable", "grant", "trigger", "execute", "apply", "submit",
137
+ "pin", "unpin", "mute", "unmute", "subscribe", "unsubscribe", "follow",
138
+ "unfollow", "clear", "purge", "reset", "rotate", "deploy", "install",
139
+ "uninstall", "save", "store", "put", "patch", "insert", "append",
140
+ "prepend", "duplicate", "restore", "revert", "import", "export", "upsert",
141
+ "sign", "complete", "claim", "release", "promote", "demote", "escalate",
142
+ "resend", "retry", "react", "vote",
143
+ ]);
144
+
145
+ // Splits a tool (or server) segment into lowercase words, handling snake_case,
146
+ // camelCase, PascalCase, and acronym runs (`getHTTPResponse` → get, http,
147
+ // response). Punctuation-only input yields an empty list, which callers treat
148
+ // as unparseable → write.
149
+ function connectorNameWords(segment: string): string[] {
150
+ return segment
151
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
152
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
153
+ .split(/[^A-Za-z0-9]+/)
154
+ .filter(Boolean)
155
+ .map((word) => word.toLowerCase());
156
+ }
157
+
158
+ // Explicit known write tool names (current claude.ai connectors). Passed to the
159
+ // SDK disallowedTools so today's writes are removed from the model's context by
160
+ // exact tool id (the CLI matcher only supports exact ids or a whole-server glob).
161
+ export const CONNECTOR_WRITE_TOOLS = [
162
+ `${CONNECTOR_NS_GMAIL}create_draft`,
163
+ `${CONNECTOR_NS_GMAIL}create_label`,
164
+ `${CONNECTOR_NS_GMAIL}label_message`,
165
+ `${CONNECTOR_NS_GMAIL}label_thread`,
166
+ `${CONNECTOR_NS_GMAIL}unlabel_message`,
167
+ `${CONNECTOR_NS_GMAIL}unlabel_thread`,
168
+ `${CONNECTOR_NS_GMAIL}apply_sensitive_label`,
169
+ `${CONNECTOR_NS_GMAIL}remove_sensitive_label`,
170
+ `${CONNECTOR_NS_CALENDAR}create_event`,
171
+ `${CONNECTOR_NS_CALENDAR}update_event`,
172
+ `${CONNECTOR_NS_CALENDAR}delete_event`,
173
+ `${CONNECTOR_NS_CALENDAR}respond_to_event`,
174
+ `${CONNECTOR_NS_DRIVE}create_file`,
175
+ `${CONNECTOR_NS_DRIVE}copy_file`,
176
+ // Slack + Atlassian writes, taken from a live enumeration of an account with
177
+ // both connectors attached. The PreToolUse hook already denies these by verb;
178
+ // listing them by id also removes them from the model's context in a
179
+ // read-only session (the CLI matcher needs exact ids). Additive only — an id
180
+ // missing here is still denied at call time.
181
+ `${CONNECTOR_NS_SLACK}slack_send_message`,
182
+ `${CONNECTOR_NS_SLACK}slack_send_message_draft`,
183
+ `${CONNECTOR_NS_SLACK}slack_schedule_message`,
184
+ `${CONNECTOR_NS_SLACK}slack_create_canvas`,
185
+ `${CONNECTOR_NS_SLACK}slack_update_canvas`,
186
+ `${CONNECTOR_NS_ATLASSIAN}createJiraIssue`,
187
+ `${CONNECTOR_NS_ATLASSIAN}editJiraIssue`,
188
+ `${CONNECTOR_NS_ATLASSIAN}transitionJiraIssue`,
189
+ `${CONNECTOR_NS_ATLASSIAN}addCommentToJiraIssue`,
190
+ `${CONNECTOR_NS_ATLASSIAN}addWorklogToJiraIssue`,
191
+ `${CONNECTOR_NS_ATLASSIAN}createIssueLink`,
192
+ `${CONNECTOR_NS_ATLASSIAN}createConfluencePage`,
193
+ `${CONNECTOR_NS_ATLASSIAN}updateConfluencePage`,
194
+ `${CONNECTOR_NS_ATLASSIAN}createConfluenceFooterComment`,
195
+ `${CONNECTOR_NS_ATLASSIAN}createConfluenceInlineComment`,
196
+ `${CONNECTOR_NS_ATLASSIAN}createCompassComponent`,
197
+ `${CONNECTOR_NS_ATLASSIAN}createCompassComponentRelationship`,
198
+ `${CONNECTOR_NS_ATLASSIAN}createCompassCustomFieldDefinition`,
199
+ ];
200
+
201
+ // Classify a connector tool name as a WRITE (mutating) tool. FAIL CLOSED, twice:
202
+ //
203
+ // 1. Namespace: the whole `mcp__claude_ai_<Server>__` space counts, not just the
204
+ // known Gmail/Calendar/Drive trio. Connectors attach account-wide, so ANY
205
+ // other connector on the account (Slack, Atlassian, Figma, org-custom) shows
206
+ // up in a bridge session — and the connector path deliberately omits
207
+ // `tools: []` (see toolIsolationForQuery), so those tools are discoverable
208
+ // and callable. Keying on the trio meant e.g. a Slack send_message ran
209
+ // ungated inside claude, invisible to Pi's ConsentGate.
210
+ // 2. Verb: a tool on that space is a write UNLESS its tool segment BEGINS with a
211
+ // known read verb, so not-yet-known write tools (e.g. Gmail send_message,
212
+ // Drive delete_file, Calendar add_attendee) are blocked in a read-only
213
+ // session. A name under the connector prefix with no parseable `__<tool>`
214
+ // segment is also a write: it is a connector by construction, so deny wins.
215
+ // The verb is matched as a WORD across snake_case and camelCase, and a
216
+ // leading word that merely repeats the server name is skipped first
217
+ // (`Slack__slack_read_channel` reads as `read channel`), because connector
218
+ // servers name their tools differently from one another.
219
+ //
220
+ // Non-connector tools (Pi custom-tools, ToolSearch, MCP-resource tools, other MCP
221
+ // servers) are never connector writes → false. Used by connectorWriteDenyHook and
222
+ // by callers (e.g. the one-shot write executor) that enumerate live connector tools.
223
+ export function isConnectorWriteTool(name: string): boolean {
224
+ if (!name.startsWith(CONNECTOR_NS_PREFIX)) return false;
225
+ // First `__` after the prefix ends the server segment. First (not last) so a
226
+ // server name containing `__` leaves the extra segment in `tool`, which then
227
+ // fails the read-prefix test — ambiguity resolves to write.
228
+ const sep = name.indexOf("__", CONNECTOR_NS_PREFIX.length);
229
+ // No separator (sep < 0) OR an EMPTY server segment (sep at the prefix, e.g.
230
+ // `mcp__claude_ai___search_messages`) means the name doesn't parse as
231
+ // <server>__<tool> — it never earns the read-prefix exemption.
232
+ if (sep <= CONNECTOR_NS_PREFIX.length) return true;
233
+ const server = name.slice(CONNECTOR_NS_PREFIX.length, sep);
234
+ const words = connectorNameWords(name.slice(sep + "__".length));
235
+ // Skip a leading run of words that just repeats the server name, so a
236
+ // server-prefixed tool (`Slack__slack_read_channel`) is judged on its real
237
+ // verb. Only an exact leading match is skipped — an unrelated first word
238
+ // (`Weird__Server__list_things`) stays and fails the read test. A mutation
239
+ // word is never skipped, so a verb-shaped server name
240
+ // (`Delete__delete_get_thing`, `Sync__sync_get_status`) cannot launder its
241
+ // own tool's verb away.
242
+ const serverWords = connectorNameWords(server);
243
+ let skipped = 0;
244
+ while (
245
+ skipped < serverWords.length
246
+ && words[skipped] === serverWords[skipped]
247
+ && !CONNECTOR_MUTATION_WORDS.has(words[skipped])
248
+ ) skipped++;
249
+ const rest = words.slice(skipped);
250
+ // Nothing parseable left (empty/punctuation-only tool segment, or a tool
251
+ // named exactly after its server) → write.
252
+ if (rest.length === 0) return true;
253
+ if (!CONNECTOR_READ_VERBS.has(rest[0])) return true;
254
+ // Begins as a read but also names a mutation → deny wins.
255
+ return rest.some((word) => CONNECTOR_MUTATION_WORDS.has(word));
256
+ }
257
+
258
+ // Connector write mode from the env override. `allow` exposes connector write
259
+ // tools; `deny` hides them. Returns undefined when unset so config can decide.
260
+ export function connectorWriteModeFromEnv(): ConnectorWriteMode | undefined {
261
+ const v = (process.env.CLAUDE_BRIDGE_CONNECTOR_WRITE ?? "").trim().toLowerCase();
262
+ if (v === "allow") return "allow";
263
+ if (v === "deny") return "deny";
264
+ return undefined;
265
+ }
266
+
267
+ // Resolve the connector write mode: env wins over config, default `deny`
268
+ // (mirrors connectorsEnabledFor's env-first precedence). Only meaningful when
269
+ // connectors are enabled; connector chat sessions keep the default deny and the
270
+ // one-shot approved-write executor sets allow (env or config).
271
+ //
272
+ // FAIL CLOSED: writes are enabled ONLY by an explicit, validated `allow`. The
273
+ // config value is re-normalized here (defense in depth over normalizeProviderConfig)
274
+ // so a raw legacy-config value like "Deny"/"read-only"/true can never be treated
275
+ // as a truthy non-deny and silently open writes — anything but exact allow → deny.
276
+ export function connectorWriteModeFor(config?: Config): ConnectorWriteMode {
277
+ const resolved = connectorWriteModeFromEnv() ?? normalizeConnectorWriteMode(config?.provider?.connectorWriteMode);
278
+ return resolved === "allow" ? "allow" : "deny";
279
+ }
280
+
281
+ // PreToolUse hook that hard-blocks connector WRITE tools at call time. Hooks run
282
+ // regardless of permissionMode (we use bypassPermissions), so this — not the
283
+ // static deny lists — is the real prefix-based runtime enforcement of
284
+ // isConnectorWriteTool. disallowedTools removes today's KNOWN writes from model
285
+ // context, but it lists exact ids on the known trio only, so a future write tool
286
+ // (e.g. mcp__claude_ai_Gmail__send_message, ..._Drive__delete_file) or any tool
287
+ // on another connector the account has attached (mcp__claude_ai_Slack__send_message)
288
+ // would otherwise be callable in a read-only session; this hook denies it by prefix.
289
+ export function connectorWriteDenyHook(): HookCallback {
290
+ return async (input) => {
291
+ // The CLI treats a hook error/timeout as an EMPTY hook output and lets
292
+ // the tool call proceed (fail OPEN) — so any exception in this body
293
+ // must convert to a deny, never an allow. Today's body is pure string
294
+ // checks on schema-validated input; the catch pins that invariant for
295
+ // whatever gets added here later.
296
+ try {
297
+ if (input.hook_event_name !== "PreToolUse") return { continue: true };
298
+ if (!isConnectorWriteTool(input.tool_name)) return { continue: true };
299
+ return connectorWriteDenyOutput(String(input.tool_name));
300
+ } 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);
305
+ }
306
+ };
307
+ }
308
+
309
+ function connectorWriteDenyOutput(toolName: string) {
310
+ return {
311
+ hookSpecificOutput: {
312
+ hookEventName: "PreToolUse" as const,
313
+ permissionDecision: "deny" as const,
314
+ permissionDecisionReason:
315
+ `Connector write tool "${toolName}" is blocked in read-only connector mode. ` +
316
+ `Connector writes must go through Memsira's gated approval flow.`,
317
+ },
318
+ };
319
+ }
320
+
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.
325
+ export function connectorQueryOptions(connectorsEnabled: boolean, writeMode: ConnectorWriteMode = "deny"): Partial<Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools" | "hooks">> {
326
+ 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()] }] } };
330
+ }
331
+
332
+ // Tool isolation for a query. When connectors are enabled we still remove
333
+ // Claude Code's filesystem/shell built-ins (via disallowedTools; Pi owns those)
334
+ // and auto-allow the cloud connector tool namespaces so the model can call
335
+ // Gmail/Calendar/Drive.
336
+ //
337
+ // Critically, we must OMIT `tools: []` in the connector path: an empty --tools
338
+ // allowlist strips the claude.ai cloud MCP connector tools from the model's
339
+ // view (verified — Pi's SDK-injected custom-tools survive it, but connectors do
340
+ // not). Dropping `tools` leaves the connectors visible; disallowedTools still
341
+ // hard-denies the built-ins so Pi keeps ownership of file/shell/web tools.
342
+ export function toolIsolationForQuery(connectorsEnabled: boolean, writeMode: ConnectorWriteMode = "deny"): Partial<Pick<NonNullable<Parameters<typeof query>[0]["options"]>, "tools" | "allowedTools" | "disallowedTools">> {
343
+ if (!connectorsEnabled) return CLAUDE_BRIDGE_TOOL_ISOLATION;
344
+ // Keep ToolSearch + MCP-resource tools available so the model can discover the
345
+ // deferred cloud connector tools; still block file/shell/web built-ins.
346
+ const disallowedTools = DISALLOWED_BUILTIN_TOOLS.filter((t) => !CONNECTOR_DISCOVERY_TOOLS.includes(t));
347
+ // Deny connector WRITE tools unless writes are explicitly allowed (fail
348
+ // closed: any mode but exact "allow" is treated as read-only). This removes
349
+ // today's KNOWN writes from the model's context by exact id; deny rules take
350
+ // precedence over the CLAUDE_AI_CONNECTOR_TOOL_PATTERNS allow rules below, so
351
+ // reads stay available. Runtime enforcement covering future write tools and
352
+ // connector namespaces we don't enumerate here (Slack, Atlassian, org-custom)
353
+ // is done by connectorWriteDenyHook — see connectorQueryOptions.
354
+ if (writeMode !== "allow") disallowedTools.push(...CONNECTOR_WRITE_TOOLS);
355
+ return {
356
+ disallowedTools,
357
+ allowedTools: [...CLAUDE_BRIDGE_TOOL_ISOLATION.allowedTools, ...CLAUDE_AI_CONNECTOR_TOOL_PATTERNS],
358
+ };
359
+ }