Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8,455 changes: 6,325 additions & 2,130 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"test:quick": "playwright test --project=unit --project=integration"
},
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.29.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
"@anthropic-ai/sdk": "^0.71.2",
"@electron-toolkit/utils": "^4.0.0",
Expand Down
10 changes: 8 additions & 2 deletions src/extensions/mail-ext-web-search/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ExtensionContext, ExtensionAPI, ExtensionModule } from "../../../shared/extension-types";
import { getModelIdForFeature } from "../../../main/ipc/settings.ipc";
import { getModelIdForFeature, getConfig } from "../../../main/ipc/settings.ipc";
import { createAnthropicClientFromConfig } from "../../../main/lib/anthropic-client";
import { createWebSearchProvider } from "./web-search-provider";

/**
Expand All @@ -13,7 +14,12 @@ const extension: ExtensionModule = {
// Register the enrichment provider.
// Model resolver is injected here (entry point) rather than deep in the provider,
// keeping the provider decoupled from Electron main-process internals.
const provider = createWebSearchProvider(context, () => getModelIdForFeature("senderLookup"));
const provider = createWebSearchProvider(
context,
() => getModelIdForFeature("senderLookup"),
() => createAnthropicClientFromConfig(getConfig()),
() => getConfig().apiProvider === "bedrock",
);
api.registerEnrichmentProvider(provider);

context.logger.info("Web-search extension activated");
Expand Down
10 changes: 6 additions & 4 deletions src/extensions/mail-ext-web-search/src/web-search-provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Anthropic from "@anthropic-ai/sdk";
import type { AnthropicClient } from "../../../main/lib/anthropic-client";
import type {
ExtensionContext,
EnrichmentProvider,
Expand Down Expand Up @@ -186,16 +186,18 @@ function validateProfileData(
export function createWebSearchProvider(
context: ExtensionContext,
getModelId: () => string,
getClient: () => AnthropicClient,
isBedrockActive: () => boolean,
): EnrichmentProvider {
const client = new Anthropic();

return {
id: "sender-lookup",
panelId: "sender-profile",
priority: 100,

canEnrich(email: DashboardEmail): boolean {
// Skip if the email is from a reminder service with no thread context
// web_search_20250305 is an Anthropic-only first-party tool; Bedrock does not support it
if (isBedrockActive()) return false;
return !isReminderService(email.from);
},

Expand Down Expand Up @@ -249,7 +251,7 @@ export function createWebSearchProvider(
// Use Claude with web search to find information
const searchQuery = buildSearchQuery(senderName, realSenderEmail);

const response = await client.messages.create({
const response = await getClient().messages.create({
model: getModelId(),
max_tokens: 200, // Responses are ~100 tokens
tools: [
Expand Down
83 changes: 83 additions & 0 deletions src/main/agents/providers/bash-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import type { CliToolConfig } from "../../../shared/types";

/**
* Characters permitted in a CLI tool command string.
*
* An allowlist is fundamentally more secure than a blocklist: instead of
* enumerating known-dangerous characters (and inevitably missing some),
* we explicitly permit only characters that are safe in shell arguments.
*
* Permitted: letters, digits, spaces, hyphens, underscores, dots, forward
* slashes (paths), colons, equals, commas, @, tildes, square brackets, and
* single/double quotes (safe without $, `, or \ which are not in the set).
*
* Rejected (implicitly, by absence): ; & | ` $ > < ( ) { } \ ! ? and
* newlines — all of which enable command chaining or shell expansion.
*/
const SAFE_COMMAND_PATTERN = /^[a-zA-Z0-9 \-_./\:=,@~[\]"']*$/;

/**
* Build a PreToolUse hook that gates Bash commands against the CLI tool allowlist.
*
* Returns null if no CLI tools are configured (Bash won't be in the tools list).
* When active, each Bash invocation is checked:
* 1. The full command string must match SAFE_COMMAND_PATTERN (allowlist).
* 2. The base command (first token) must be in the configured CLI tool set.
*/
export function buildBashPreToolUseHook(
cliTools: CliToolConfig[],
): { matcher: string; hooks: HookCallback[] } | null {
const activeCli = cliTools.filter((t) => t.command.trim());
if (activeCli.length === 0) return null;

const allowedCommands = new Set(activeCli.map((t) => t.command.trim()));

const hook: HookCallback = async (input) => {
const toolInput = (input as Record<string, unknown>).tool_input as
| { command?: string }
| undefined;
const command = toolInput?.command ?? "";

// Reject any character outside the safe allowlist.
// This closes bypass vectors that a blocklist misses: subshells (()),
// brace expansion ({}), line continuation (\), history expansion (!),
// and command substitution ($(), ``).
if (!SAFE_COMMAND_PATTERN.test(command)) {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "deny" as const,
permissionDecisionReason:
"Command contains characters not permitted in CLI tool commands. " +
"Only letters, digits, spaces, and common path/option characters are allowed.",
},
};
}

// Extract the base command (first token, stripping any path prefix)
const firstToken = command.trim().split(/\s+/)[0] ?? "";
const baseCommand = firstToken.split("/").pop() ?? firstToken;

if (allowedCommands.has(baseCommand)) {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "allow" as const,
},
};
}

return {
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "deny" as const,
permissionDecisionReason:
`Command "${baseCommand}" is not in the allowed CLI tools list. ` +
`Allowed commands: ${[...allowedCommands].join(", ")}`,
},
};
};

return { matcher: "Bash", hooks: [hook] };
}
62 changes: 1 addition & 61 deletions src/main/agents/providers/claude-agent-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
} from "../types";
import type { CliToolConfig } from "../../../shared/types";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { buildBashPreToolUseHook } from "./bash-hook";

/**
* Claude Agent Provider - Uses the Claude Agent SDK to run an agent that
Expand Down Expand Up @@ -408,67 +409,6 @@ function buildMcpToolWithTracking(
);
}

/**
* Build a PreToolUse hook that gates Bash commands against the CLI tool allowlist.
*
* Returns null if no CLI tools are configured (Bash won't be in the tools list).
* When active, each Bash invocation is checked: the base command (first token)
* must match one of the configured CLI tool commands. Non-matching commands
* are denied with an explanation.
*/
function buildBashPreToolUseHook(
cliTools: CliToolConfig[],
): { matcher: string; hooks: HookCallback[] } | null {
const activeCli = cliTools.filter((t) => t.command.trim());
if (activeCli.length === 0) return null;

const allowedCommands = new Set(activeCli.map((t) => t.command.trim()));

const hook: HookCallback = async (input) => {
const toolInput = (input as Record<string, unknown>).tool_input as
| { command?: string }
| undefined;
const command = toolInput?.command ?? "";

// Reject commands containing shell operators or newlines that could chain additional commands
// e.g. "ls && rm -rf /" or "ls; cat /etc/passwd" or "ls\nrm -rf /"
if (/[;&|`$><\n\r]/.test(command)) {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "deny" as const,
permissionDecisionReason:
"Shell operators (;, &, |, `, $, >, <) are not allowed in CLI tool commands.",
},
};
}

// Extract the base command (first token, stripping any path prefix)
const firstToken = command.trim().split(/\s+/)[0] ?? "";
const baseCommand = firstToken.split("/").pop() ?? firstToken;

if (allowedCommands.has(baseCommand)) {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "allow" as const,
},
};
}

return {
hookSpecificOutput: {
hookEventName: "PreToolUse" as const,
permissionDecision: "deny" as const,
permissionDecisionReason:
`Command "${baseCommand}" is not in the allowed CLI tools list. ` +
`Allowed commands: ${[...allowedCommands].join(", ")}`,
},
};
};

return { matcher: "Bash", hooks: [hook] };
}

/**
* Strip the MCP server prefix from a tool name.
Expand Down
2 changes: 1 addition & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ ipcMain.handle("default-mail-app:get-pending", () => {
initDatabase();

// If no ANTHROPIC_API_KEY in env (e.g. packaged app with no .env), read from stored config
// so that services using `new Anthropic()` pick it up automatically.
// so that the Anthropic SDK picks it up automatically when the anthropic provider is used.
{
const config = getConfig();
if (!process.env.ANTHROPIC_API_KEY && config.anthropicApiKey) {
Expand Down
3 changes: 2 additions & 1 deletion src/main/ipc/analysis.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ipcMain, BrowserWindow } from "electron";
import { EmailAnalyzer } from "../services/email-analyzer";
import { getEmail, saveAnalysis, getAllEmails, getInboxEmails, getAccounts } from "../db";
import { getConfig, getModelIdForFeature } from "./settings.ipc";
import { createAnthropicClientFromConfig } from "../lib/anthropic-client";
import type { IpcResponse, DashboardEmail, Email } from "../../shared/types";
import { DEMO_INBOX_EMAILS, DEMO_EXPECTED_ANALYSIS } from "../demo/fake-inbox";
import { learnFromPriorityOverrideWithReason, learnFromPriorityOverrideInferred } from "../services/analysis-edit-learner";
Expand Down Expand Up @@ -31,7 +32,7 @@ let analyzer: EmailAnalyzer | null = null;
function getAnalyzer(): EmailAnalyzer {
if (!analyzer) {
const config = getConfig();
analyzer = new EmailAnalyzer(getModelIdForFeature("analysis"), config.analysisPrompt);
analyzer = new EmailAnalyzer(createAnthropicClientFromConfig(config), getModelIdForFeature("analysis"), config.analysisPrompt);
}
return analyzer;
}
Expand Down
3 changes: 2 additions & 1 deletion src/main/ipc/archive-ready.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
updateEmailLabelIds,
} from "../db";
import { getConfig, getModelIdForFeature } from "./settings.ipc";
import { createAnthropicClientFromConfig } from "../lib/anthropic-client";
import { getEmailSyncService } from "./sync.ipc";
import type { IpcResponse, DashboardEmail } from "../../shared/types";

Expand All @@ -23,7 +24,7 @@ let analyzer: ArchiveReadyAnalyzer | null = null;
function getAnalyzer(): ArchiveReadyAnalyzer {
if (!analyzer) {
const config = getConfig();
analyzer = new ArchiveReadyAnalyzer(getModelIdForFeature("archiveReady"), config.archiveReadyPrompt);
analyzer = new ArchiveReadyAnalyzer(createAnthropicClientFromConfig(config), getModelIdForFeature("archiveReady"), config.archiveReadyPrompt);
}
return analyzer;
}
Expand Down
6 changes: 2 additions & 4 deletions src/main/ipc/drafts.ipc.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { ipcMain } from "electron";
import Anthropic from "@anthropic-ai/sdk";
import { DraftGenerator } from "../services/draft-generator";
import { generateDraftForEmail } from "../services/draft-pipeline";
import { createAnthropicClientFromConfig } from "../lib/anthropic-client";
import { getEmail, deleteDraft, deleteAgentTrace, clearInboxPendingDraftsAndTraces, getInboxPendingDraftsWithGmail, updateDraftAgentTaskId } from "../db";
import { saveDraftAndSync, deleteGmailDraftById, deleteGmailDraftsBatch } from "../services/gmail-draft-sync";
import { getConfig, getModelIdForFeature } from "./settings.ipc";
Expand Down Expand Up @@ -77,7 +75,7 @@ export function registerDraftsIpc(): void {
}

const config = getConfig();
const anthropic = new Anthropic();
const anthropic = createAnthropicClientFromConfig(config);

// Include relevant memories so refinement doesn't contradict saved preferences
const senderMatch = email.from.match(/<([^>]+)>/) ?? email.from.match(/([^\s<]+@[^\s>]+)/);
Expand Down
7 changes: 4 additions & 3 deletions src/main/ipc/memory.ipc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ipcMain } from "electron";
import { randomUUID } from "crypto";
import Anthropic from "@anthropic-ai/sdk";
import { createAnthropicClientFromConfig } from "../lib/anthropic-client";
import { getConfig, getModelIdForFeature } from "./settings.ipc";
import {
saveMemory,
getMemory,
Expand Down Expand Up @@ -146,9 +147,9 @@ export function registerMemoryIpc(): void {
};
}
try {
const anthropic = new Anthropic();
const anthropic = createAnthropicClientFromConfig(getConfig());
const response = await anthropic.messages.create({
model: "claude-haiku-4-5-20251001", // simple JSON classification — always haiku, independent of user model config
model: getModelIdForFeature("senderLookup"), // simple JSON classification — haiku tier
max_tokens: 256,
messages: [{
role: "user",
Expand Down
Loading