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
37 changes: 37 additions & 0 deletions .claude/rules/20-enhancement-backends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Enhancement Backend Rules

Rules for the AI text enhancement subsystem (OpenAI-compatible, Anthropic, Ollama).

## API Key Handling

- API keys MUST be stored via config.rs `save_to_disk()` which enforces 0600 permissions
- API keys MUST NOT appear in log output, error messages, or frontend console
- Frontend API key inputs MUST use `type="password"` with optional show/hide toggle
- Anthropic API key auto-detection from `ANTHROPIC_API_KEY` env var is permitted

## URL Validation

- OpenAI-compatible `base_url` MUST be validated: only `http://` and `https://` schemes accepted
- Anthropic `base_url` MUST warn if not HTTPS (unless localhost) — API key transmitted in headers
- No scheme validation bypass — `file://`, `ftp://`, `javascript:` etc. are always rejected

## Backend Selection

- Pipeline MUST use the correct model for the active backend:
- `backend == "anthropic"` → use `anthropicModel` from config
- `backend == "ollama" | "openai_compat"` → use `model` from config
- Tray menu MUST display the active backend and model (e.g. "Cloud: claude-haiku-4-5-20251001")
- Tray MUST refresh after backend switch

## Timeouts and Retries

- Default timeout: 30 seconds for all backends
- OpenAI-compatible: retry with exponential backoff (3 attempts)
- Anthropic: single attempt (cloud service, retries add cost)
- Ollama: single attempt (local, fast failure preferred)

## Error Handling

- Error messages MUST include attempt count and generic error description
- Error messages MUST NOT include request bodies, headers, or API keys
- Failed enhancement MUST NOT block the transcription pipeline — return original text
33 changes: 33 additions & 0 deletions .github/workflows/security-audit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Security Audit

on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
# Weekly on Monday at 06:00 UTC
- cron: '0 6 * * 1'

jobs:
cargo-audit:
name: Cargo Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run cargo audit
working-directory: src-tauri
run: cargo audit

npm-audit:
name: npm Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- run: npm audit --audit-level=high
60 changes: 57 additions & 3 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,18 +137,39 @@ impl Default for ShortcutConfig {
}
}

fn default_anthropic_model() -> String {
"claude-haiku-4-5-20251001".to_string()
}

fn default_anthropic_url() -> String {
"https://api.anthropic.com".to_string()
}

/// AI enhancement configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct EnhancementConfig {
/// Whether AI enhancement is enabled
pub enabled: bool,
/// Ollama model to use for enhancement
/// Model to use for enhancement
pub model: String,
/// Selected prompt template ID
pub prompt_id: String,
/// Ollama server URL
/// Server URL (used for both Ollama and OpenAI-compatible backends)
pub ollama_url: String,
/// Backend type: "ollama" | "openai_compat" | "anthropic"
pub backend: String,
/// Optional API key for OpenAI-compatible backends
pub api_key: Option<String>,
/// Anthropic API key (persists independently)
#[serde(default)]
pub anthropic_api_key: Option<String>,
/// Anthropic model (e.g. "claude-haiku-4-5-20251001")
#[serde(default = "default_anthropic_model")]
pub anthropic_model: String,
/// Anthropic API base URL
#[serde(default = "default_anthropic_url")]
pub anthropic_url: String,
}

impl Default for EnhancementConfig {
Expand All @@ -158,6 +179,11 @@ impl Default for EnhancementConfig {
model: "llama3.2".to_string(),
prompt_id: "fix-grammar".to_string(),
ollama_url: "http://localhost:11434".to_string(),
backend: "ollama".to_string(),
api_key: None,
anthropic_api_key: None,
anthropic_model: default_anthropic_model(),
anthropic_url: default_anthropic_url(),
}
}
}
Expand Down Expand Up @@ -319,7 +345,14 @@ fn save_to_disk(config: &Config) -> Result<(), String> {
let contents = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialise config: {}", e))?;

fs::write(&path, contents).map_err(|e| format!("Failed to write config file: {}", e))?;
fs::write(&path, &contents).map_err(|e| format!("Failed to write config file: {}", e))?;

// Restrict permissions to owner-only (0600) — config may contain API keys
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
}

tracing::info!(
"Config saved to disk: device_id={:?}, toggle_recording_alt={:?}",
Expand Down Expand Up @@ -612,6 +645,8 @@ mod tests {
assert_eq!(enhancement.model, "llama3.2");
assert_eq!(enhancement.prompt_id, "fix-grammar");
assert_eq!(enhancement.ollama_url, "http://localhost:11434");
assert_eq!(enhancement.backend, "ollama");
assert_eq!(enhancement.api_key, None);
}

#[test]
Expand Down Expand Up @@ -740,6 +775,11 @@ mod tests {
model: "mistral".to_string(),
prompt_id: "custom".to_string(),
ollama_url: "http://custom:8080".to_string(),
backend: "openai_compat".to_string(),
api_key: Some("sk-test".to_string()),
anthropic_api_key: None,
anthropic_model: default_anthropic_model(),
anthropic_url: default_anthropic_url(),
},
general: GeneralConfig {
launch_at_login: true,
Expand Down Expand Up @@ -828,9 +868,23 @@ mod tests {
model: "custom-model".to_string(),
prompt_id: "summarise".to_string(),
ollama_url: "http://192.168.1.100:11434".to_string(),
backend: "ollama".to_string(),
api_key: None,
anthropic_api_key: None,
anthropic_model: default_anthropic_model(),
anthropic_url: default_anthropic_url(),
};

assert!(enhancement.enabled);
assert_eq!(enhancement.ollama_url, "http://192.168.1.100:11434");
}

#[test]
fn test_enhancement_config_backward_compat() {
// Old config without backend/api_key fields should deserialise with defaults
let json = r#"{"enabled": true, "model": "llama3.2", "prompt_id": "fix-grammar", "ollama_url": "http://localhost:11434"}"#;
let enhancement: EnhancementConfig = serde_json::from_str(json).unwrap();
assert_eq!(enhancement.backend, "ollama");
assert_eq!(enhancement.api_key, None);
}
}
153 changes: 153 additions & 0 deletions src-tauri/src/enhancement/anthropic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//! Anthropic API client for AI text enhancement
//! Uses the Anthropic Messages API directly (not OpenAI-compatible)

use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::time::Duration;

const DEFAULT_TIMEOUT_SECS: u64 = 120;
const ANTHROPIC_API_VERSION: &str = "2023-06-01";

#[derive(Debug, Serialize)]
struct Message {
role: String,
content: String,
}

#[derive(Debug, Serialize)]
struct MessagesRequest {
model: String,
max_tokens: u32,
messages: Vec<Message>,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
}

#[derive(Debug, Deserialize)]
struct MessagesResponse {
content: Vec<ContentBlock>,
}

#[derive(Debug, Deserialize)]
struct ContentBlock {
#[serde(rename = "type")]
block_type: String,
text: Option<String>,
}

#[derive(Debug, Clone)]
pub struct AnthropicClient {
api_key: String,
base_url: String,
model: String,
client: reqwest::Client,
}

impl AnthropicClient {
pub fn new(api_key: String, model: String, base_url: Option<String>) -> Self {
let resolved_url = base_url.unwrap_or_else(|| "https://api.anthropic.com".to_string());

// Warn if sending API key over non-HTTPS — potential credential exposure
if !resolved_url.starts_with("https://") && !resolved_url.starts_with("http://localhost") {
tracing::warn!(
"Anthropic: base_url is not HTTPS ({}). API key may be transmitted in plaintext.",
resolved_url
);
}

let timeout = Duration::from_secs(DEFAULT_TIMEOUT_SECS);
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.expect("Failed to create HTTP client");
Self {
api_key,
base_url: resolved_url,
model,
client,
}
}

pub async fn is_available(&self) -> bool {
!self.api_key.is_empty()
}

pub async fn enhance_text(&self, text: &str, prompt_template: &str) -> Result<String> {
let full_prompt = prompt_template.replace("{text}", text);
self.send_message(&full_prompt, None).await
}

async fn send_message(&self, user_message: &str, system: Option<&str>) -> Result<String> {
let url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));

let request = MessagesRequest {
model: self.model.clone(),
max_tokens: 4096,
messages: vec![Message {
role: "user".to_string(),
content: user_message.to_string(),
}],
system: system.map(|s| s.to_string()),
temperature: Some(0.3),
};

let response = self
.client
.post(&url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_API_VERSION)
.header("content-type", "application/json")
.json(&request)
.send()
.await
.map_err(|e| anyhow!("Anthropic API request failed: {}", e))?;

if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
return Err(anyhow!("Anthropic API error ({}): {}", status, body));
}

let resp: MessagesResponse = response
.json()
.await
.map_err(|e| anyhow!("Failed to parse Anthropic response: {}", e))?;

resp.content
.into_iter()
.find(|b| b.block_type == "text")
.and_then(|b| b.text)
.ok_or_else(|| anyhow!("No text content in Anthropic response"))
}
}

/// Tauri command: detect Anthropic API key from environment
#[tauri::command]
pub fn detect_anthropic_api_key() -> Option<String> {
std::env::var("ANTHROPIC_API_KEY")
.ok()
.filter(|k| !k.is_empty())
}

/// Tauri command: open Anthropic console in browser to get API key
#[tauri::command]
pub async fn open_anthropic_console() -> Result<(), String> {
let url = "https://console.anthropic.com/settings/keys";
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(url)
.spawn()
.map_err(|e| format!("Failed to open browser: {}", e))?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(url)
.spawn()
.map_err(|e| format!("Failed to open browser: {}", e))?;
}
Ok(())
}
Loading