Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
9 changes: 7 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,11 @@ 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()),
);
api.registerEnrichmentProvider(provider);

context.logger.info("Web-search extension activated");
Expand Down
6 changes: 3 additions & 3 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,8 +186,8 @@ function validateProfileData(
export function createWebSearchProvider(
context: ExtensionContext,
getModelId: () => string,
getClient: () => AnthropicClient,
): EnrichmentProvider {
const client = new Anthropic();

return {
id: "sender-lookup",
Expand Down Expand Up @@ -249,7 +249,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
58 changes: 52 additions & 6 deletions src/main/ipc/settings.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
DEFAULT_AGENT_DRAFTER_PROMPT,
DEFAULT_MODEL_CONFIG,
MODEL_TIER_IDS,
BEDROCK_MODEL_TIER_IDS,
resolveModelId,
} from "../../shared/types";
import { resetAnalyzer } from "./analysis.ipc";
Expand Down Expand Up @@ -110,10 +111,15 @@ export function getModelConfig(): ModelConfig {
return { ...DEFAULT_MODEL_CONFIG, ...config.modelConfig };
}

/** Resolve the concrete model ID for a given feature. */
/** Resolve the concrete model ID for a given feature, using the configured API provider's ID format. */
export function getModelIdForFeature(feature: keyof ModelConfig): string {
const mc = getModelConfig();
return resolveModelId(mc[feature]);
const config = getConfig();
const mc = { ...DEFAULT_MODEL_CONFIG, ...config.modelConfig };
const tier: ModelTier = mc[feature];
if (config.apiProvider === "bedrock") {
return BEDROCK_MODEL_TIER_IDS[tier];
}
return resolveModelId(tier);
}

export function registerSettingsIpc(): void {
Expand Down Expand Up @@ -156,6 +162,46 @@ export function registerSettingsIpc(): void {
}
);

// Validate AWS Bedrock credentials with a minimal API call
ipcMain.handle(
"settings:validate-bedrock-credentials",
async (_, { region, accessKeyId, secretAccessKey, sessionToken }: {
region: string;
accessKeyId?: string;
secretAccessKey?: string;
sessionToken?: string;
}): Promise<IpcResponse<void>> => {
try {
const AnthropicBedrock = (await import("@anthropic-ai/bedrock-sdk")).default;
const { BEDROCK_MODEL_TIER_IDS: bedrockIds } = await import("../../shared/types");
const client = new AnthropicBedrock({
awsRegion: region || "us-east-1",
awsAccessKey: accessKeyId,
awsSecretKey: secretAccessKey,
awsSessionToken: sessionToken,
});
await client.messages.create({
model: bedrockIds.haiku,
max_tokens: 1,
messages: [{ role: "user", content: "hi" }],
});
return { success: true, data: undefined };
} catch (error) {
const msg = error instanceof Error ? error.message : "Unknown error";
if (msg.includes("credentials") || msg.includes("auth") || msg.includes("403") || msg.includes("UnrecognizedClient")) {
return { success: false, error: "Invalid AWS credentials. Please check your access key ID and secret access key." };
}
if (msg.includes("Could not load credentials")) {
return { success: false, error: "No AWS credentials found. Please enter your access key ID and secret access key." };
}
if (msg.includes("ResourceNotFoundException") || msg.includes("model") || msg.includes("ModelNotFound")) {
return { success: false, error: "Model not available in this region. Check that Claude models are enabled in your AWS Bedrock console." };
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
return { success: false, error: `Bedrock validation failed: ${msg}` };
}
}
);

// Get current config
ipcMain.handle("settings:get", async (): Promise<IpcResponse<Config>> => {
try {
Expand Down Expand Up @@ -254,9 +300,9 @@ export function registerSettingsIpc(): void {
});
}

// Reset cached analyzer/service instances when model config or API key changes,
// since they hold Anthropic client instances that capture the key at construction.
if ("modelConfig" in config || "anthropicApiKey" in config) {
// Reset cached analyzer/service instances when model config, API key, or provider changes,
// since they hold Anthropic client instances that capture the credentials at construction.
if ("modelConfig" in config || "anthropicApiKey" in config || "apiProvider" in config || "bedrock" in config) {
resetAnalyzer();
resetArchiveReadyAnalyzer();
prefetchService.reset();
Expand Down
Loading