diff --git a/.agents/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh b/.agents/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh index 2cf0913da..42b11a8b5 100755 --- a/.agents/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh +++ b/.agents/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh @@ -63,24 +63,20 @@ fi # Check 4: Unmatched expect/actual declarations echo echo "📋 Checking expect/actual pairs..." -expect_files_count=$( - find ./*/src/commonMain -name "*.kt" -print0 2>/dev/null \ - | xargs -0 grep -l --null "^expect " 2>/dev/null \ - | tr -cd '\0' \ - | wc -c -) +expect_files_count=$(find ./*/src/commonMain -name "*.kt" -print0 2>/dev/null | xargs -0 grep -l --null -E "^\s*expect\s+(class|object|fun|interface)\b" 2>/dev/null | tr -cd '\0' | wc -c) if [[ "$expect_files_count" -gt 0 ]]; then while IFS= read -r -d '' file; do # Extract declarations - expects=$(grep "^expect \(class\|object\|fun\|interface\)" "$file" | sed 's/expect //' | awk '{print $2}' | sed 's/[({].*$//') + expects=$(grep -E "^\s*expect\s+(class|object|fun|interface)\b" "$file" | sed -E 's/^\s*expect\s+(class|object|fun|interface)\s+//' | awk '{print $1}' | sed 's/[({].*$//') # Check for actuals in platform source sets for expect_name in $expects; do actual_count=0 for platform in androidMain jvmMain iosMain; do platform_dir=$(dirname "$file" | sed "s/commonMain/$platform/") + ESCAPED_EXPECT_NAME=$(echo "$expect_name" | sed 's/\./\\./g') platform_file="${platform_dir}/$(basename "$file")" - if [[ -f "$platform_file" ]] && grep -q "actual.*$expect_name" "$platform_file"; then + if [[ -f "$platform_file" ]] && grep -E -q "actual[[:space:]]+(class|object|fun|interface)[[:space:]]+${ESCAPED_EXPECT_NAME}([[:space:]]|\b|\()" "$platform_file"; then actual_count=$((actual_count + 1)) fi done @@ -91,10 +87,7 @@ if [[ "$expect_files_count" -gt 0 ]]; then ISSUES_FOUND=$((ISSUES_FOUND + 1)) fi done - done < <( - find ./*/src/commonMain -name "*.kt" -print0 2>/dev/null \ - | xargs -0 grep -l --null "^expect " 2>/dev/null || true - ) + done < <(find ./*/src/commonMain -name "*.kt" -print0 2>/dev/null | xargs -0 grep -l --null -E "^\s*expect\s+(class|object|fun|interface)\b" 2>/dev/null || true) else echo -e "${GREEN}✓${NC} No expect declarations to validate" fi @@ -106,17 +99,16 @@ echo "📋 Checking for potential code duplication..." common_functions=$(find ./*/src/commonMain -name "*.kt" -print0 2>/dev/null | xargs -0 grep -h "^fun " | awk '{print $2}' | sed 's/[({<].*$//' | sort -u || true) if [[ -n "$common_functions" ]]; then for func in $common_functions; do + ESCAPED_FUNC=$(echo "$func" | sed 's/\./\\./g') android_count=$( find ./*/src/androidMain -name "*.kt" -print0 2>/dev/null \ - | xargs -0 grep -l --null "^fun $func" 2>/dev/null \ - | tr -cd '\0' \ - | wc -c + | xargs -0 grep -l --null -E "^fun[[:space:]]+${ESCAPED_FUNC}" 2>/dev/null \ + | tr -cd '\0' | wc -c ) jvm_count=$( find ./*/src/jvmMain -name "*.kt" -print0 2>/dev/null \ - | xargs -0 grep -l --null "^fun $func" 2>/dev/null \ - | tr -cd '\0' \ - | wc -c + | xargs -0 grep -l --null -E "^fun[[:space:]]+${ESCAPED_FUNC}" 2>/dev/null \ + | tr -cd '\0' | wc -c ) if [[ "$android_count" -gt 0 ]] && [[ "$jvm_count" -gt 0 ]]; then diff --git a/clients/agent-runtime/src/identity.rs b/clients/agent-runtime/src/identity.rs index f76bd8f1d..1361c5713 100755 --- a/clients/agent-runtime/src/identity.rs +++ b/clients/agent-runtime/src/identity.rs @@ -4,6 +4,7 @@ //! portable AI identity. This module handles loading and converting AIEOS v1.1 //! JSON to Corvus's system prompt format. +use std::path::PathBuf; use crate::config::IdentityConfig; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -166,13 +167,26 @@ pub fn load_aieos_identity( // Try aieos_path first if let Some(ref path) = config.aieos_path { + let workspace_canonical = std::fs::canonicalize(workspace_dir) + .with_context(|| format!("Failed to canonicalize workspace directory: {}", workspace_dir.display()))?; + let full_path = if Path::new(path).is_absolute() { PathBuf::from(path) } else { workspace_dir.join(path) }; - let content = std::fs::read_to_string(&full_path) + let full_path_canonical = std::fs::canonicalize(&full_path) + .with_context(|| format!("Failed to canonicalize AIEOS path: {}", full_path.display()))?; + + if !full_path_canonical.starts_with(&workspace_canonical) { + anyhow::bail!( + "Security error: AIEOS path {} is outside the workspace directory {}", + full_path.display(), + workspace_dir.display() + ); + } + let content = std::fs::read_to_string(&full_path_canonical) .with_context(|| format!("Failed to read AIEOS file: {}", full_path.display()))?; let identity: AieosIdentity = serde_json::from_str(&content) @@ -206,8 +220,6 @@ pub fn load_aieos_identity( ) } -use std::path::PathBuf; - /// Convert AIEOS identity to a system prompt string. /// /// Formats the AIEOS data into a structured markdown prompt compatible diff --git a/clients/agent-runtime/src/tools/browser.rs b/clients/agent-runtime/src/tools/browser.rs index 03430f50e..1d3813263 100755 --- a/clients/agent-runtime/src/tools/browser.rs +++ b/clients/agent-runtime/src/tools/browser.rs @@ -214,7 +214,7 @@ impl BrowserTool { ) -> Self { Self { security, - allowed_domains: normalize_domains(allowed_domains), + allowed_domains: normalize_domains(allowed_domains).expect("Invalid allowlist entries"), session_name, backend, native_headless, @@ -1933,12 +1933,14 @@ fn unavailable_action_for_backend_error(action: &str, backend: ResolvedBackend) ) } -fn normalize_domains(domains: Vec) -> Vec { - domains - .into_iter() - .map(|d| d.trim().to_lowercase()) - .filter(|d| !d.is_empty()) - .collect() +fn normalize_domains(domains: Vec) -> anyhow::Result> { + let mut normalized = Vec::new(); + for domain in domains { + normalized.push(crate::tools::url_safety::normalize_domain(&domain)?); + } + normalized.sort_unstable(); + normalized.dedup(); + Ok(normalized) } fn endpoint_reachable(endpoint: &reqwest::Url, timeout: Duration) -> bool { @@ -2085,7 +2087,7 @@ mod tests { "docs.example.com".into(), String::new(), ]; - let normalized = normalize_domains(domains); + let normalized = normalize_domains(domains).unwrap(); assert_eq!(normalized, vec!["example.com", "docs.example.com"]); } diff --git a/clients/agent-runtime/src/tools/browser_open.rs b/clients/agent-runtime/src/tools/browser_open.rs index 77abef165..4095ccf8e 100755 --- a/clients/agent-runtime/src/tools/browser_open.rs +++ b/clients/agent-runtime/src/tools/browser_open.rs @@ -17,7 +17,7 @@ impl BrowserOpenTool { pub fn new(security: Arc, allowed_domains: Vec) -> Self { Self { security, - allowed_domains: normalize_allowed_domains(allowed_domains), + allowed_domains: normalize_allowed_domains(allowed_domains).expect("Invalid allowlist entries"), } } @@ -258,7 +258,7 @@ mod tests { "EXAMPLE.COM".into(), "https://example.com/".into(), ]); - assert_eq!(got, vec!["example.com".to_string()]); + assert_eq!(got.unwrap(), vec!["example.com".to_string()]); } #[test] diff --git a/clients/agent-runtime/src/tools/http_request.rs b/clients/agent-runtime/src/tools/http_request.rs index 72c3fcbf1..0734ed105 100755 --- a/clients/agent-runtime/src/tools/http_request.rs +++ b/clients/agent-runtime/src/tools/http_request.rs @@ -70,7 +70,7 @@ impl HttpRequestTool { ) -> Self { Self { security, - allowed_domains: normalize_allowed_domains(allowed_domains), + allowed_domains: normalize_allowed_domains(allowed_domains).expect("Invalid allowlist entries"), max_response_size, timeout_secs, } @@ -472,7 +472,7 @@ mod tests { "EXAMPLE.COM".into(), "https://example.com/".into(), ]); - assert_eq!(got, vec!["example.com".to_string()]); + assert_eq!(got.unwrap(), vec!["example.com".to_string()]); } #[test] diff --git a/clients/agent-runtime/src/tools/url_safety.rs b/clients/agent-runtime/src/tools/url_safety.rs index 2f1fe62d7..8ce17eecd 100644 --- a/clients/agent-runtime/src/tools/url_safety.rs +++ b/clients/agent-runtime/src/tools/url_safety.rs @@ -1,19 +1,19 @@ use url::{Host, Url}; -pub(crate) fn normalize_allowed_domains(domains: Vec) -> Vec { - let mut normalized = domains - .into_iter() - .filter_map(|domain| normalize_domain(&domain)) - .collect::>(); +pub(crate) fn normalize_allowed_domains(domains: Vec) -> anyhow::Result> { + let mut normalized = Vec::new(); + for domain in domains { + normalized.push(normalize_domain(&domain)?); + } normalized.sort_unstable(); normalized.dedup(); - normalized + Ok(normalized) } -pub(crate) fn normalize_domain(raw: &str) -> Option { +pub(crate) fn normalize_domain(raw: &str) -> anyhow::Result { let mut domain = raw.trim().to_lowercase(); if domain.is_empty() { - return None; + anyhow::bail!("Domain cannot be empty"); } if let Some(stripped) = domain.strip_prefix("https://") { @@ -31,15 +31,15 @@ pub(crate) fn normalize_domain(raw: &str) -> Option { .trim_end_matches('.') .to_string(); - if let Some((host, _)) = domain.split_once(':') { - domain = host.to_string(); + if domain.contains(':') { + anyhow::bail!("Domain cannot contain a port: {}", domain); } if domain.is_empty() || domain.chars().any(char::is_whitespace) { - return None; + anyhow::bail!("Invalid domain: {}", domain); } - Some(domain) + Ok(domain) } pub(crate) fn extract_host( @@ -82,3 +82,27 @@ pub(crate) fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> .is_some_and(|prefix| prefix.ends_with('.')) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_domain() { + assert_eq!(normalize_domain("HTTPS://EXAMPLE.COM/path").unwrap(), "example.com".to_string()); + assert!(normalize_domain("example.com:8080").is_err()); + assert_eq!(normalize_domain(" .example.com. ").unwrap(), "example.com".to_string()); + assert!(normalize_domain("example com").is_err()); + assert!(normalize_domain("").is_err()); + } + + #[test] + fn test_normalize_allowed_domains() { + let domains = vec!["example.com".into(), "EXAMPLE.COM".into(), "https://google.com/".into()]; + let normalized = normalize_allowed_domains(domains).unwrap(); + assert_eq!(normalized, vec!["example.com".to_string(), "google.com".to_string()]); + + let bad_domains = vec!["example.com".into(), "localhost:8080".into()]; + assert!(normalize_allowed_domains(bad_domains).is_err()); + } +} diff --git a/clients/agent-runtime/tests/identity_security.rs b/clients/agent-runtime/tests/identity_security.rs new file mode 100644 index 000000000..9d9ed2990 --- /dev/null +++ b/clients/agent-runtime/tests/identity_security.rs @@ -0,0 +1,64 @@ +use corvus::identity::load_aieos_identity; +use corvus::config::IdentityConfig; +use tempfile::tempdir; +use std::fs; +use std::os::unix::fs::symlink; + +#[test] +fn test_load_aieos_identity_within_workspace() { + let workspace = tempdir().unwrap(); + let identity_file = workspace.path().join("identity.json"); + fs::write(&identity_file, "{\"identity\":{\"names\":{\"first\":\"Test\"}}}").unwrap(); + + let config = IdentityConfig { + format: "aieos".into(), + aieos_path: Some("identity.json".into()), + aieos_inline: None, + }; + + let result = load_aieos_identity(&config, workspace.path()).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().identity.unwrap().names.unwrap().first.unwrap(), "Test"); +} + +#[test] +fn test_load_aieos_identity_traversal_dots() { + let workspace = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let identity_file = outside.path().join("identity.json"); + fs::write(&identity_file, "{}").unwrap(); + + let relative_path = format!("../{}/identity.json", + outside.path().file_name().unwrap().to_str().unwrap()); + + let config = IdentityConfig { + format: "aieos".into(), + aieos_path: Some(relative_path), + aieos_inline: None, + }; + + let result = load_aieos_identity(&config, workspace.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("outside the workspace")); +} + +#[test] +fn test_load_aieos_identity_traversal_symlink() { + let workspace = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let identity_file = outside.path().join("identity.json"); + fs::write(&identity_file, "{}").unwrap(); + + let link_path = workspace.path().join("malicious_link.json"); + symlink(&identity_file, &link_path).unwrap(); + + let config = IdentityConfig { + format: "aieos".into(), + aieos_path: Some("malicious_link.json".into()), + aieos_inline: None, + }; + + let result = load_aieos_identity(&config, workspace.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("outside the workspace")); +} diff --git a/clients/web/apps/dashboard/src/App.vue b/clients/web/apps/dashboard/src/App.vue index c70d80901..79ddf5383 100644 --- a/clients/web/apps/dashboard/src/App.vue +++ b/clients/web/apps/dashboard/src/App.vue @@ -1,28 +1,38 @@ diff --git a/clients/web/apps/dashboard/src/components/config/SecuritySettings.spec.ts b/clients/web/apps/dashboard/src/components/config/SecuritySettings.spec.ts index e12145c54..e3f5fb550 100644 --- a/clients/web/apps/dashboard/src/components/config/SecuritySettings.spec.ts +++ b/clients/web/apps/dashboard/src/components/config/SecuritySettings.spec.ts @@ -8,6 +8,7 @@ import { createAdminConfigForm } from "@/test/adminConfigFormFactory"; describe("SecuritySettings", () => { it("renders identity-focused controls", () => { + const i18n = createI18n(i18nConfig); const wrapper = mount(SecuritySettings, { props: { modelValue: createAdminConfigForm({ @@ -23,11 +24,11 @@ describe("SecuritySettings", () => { saving: false, }, global: { - plugins: [createI18n(i18nConfig)], + plugins: [i18n], }, }); - expect(wrapper.text()).toContain("Identity format"); - expect(wrapper.text()).toContain("Identity AIEOS path"); + expect(wrapper.text()).toContain(i18n.global.t("security.identity_format")); + expect(wrapper.text()).toContain(i18n.global.t("security.identity_aieos_path")); }); }); diff --git a/clients/web/packages/locales/src/en.json b/clients/web/packages/locales/src/en.json new file mode 100644 index 000000000..0220358a2 --- /dev/null +++ b/clients/web/packages/locales/src/en.json @@ -0,0 +1,117 @@ +{ + "app": { + "simpleChat": "Simple AI Chat", + "gatewayConfig": "Gateway Configuration", + "config": "Configuration", + "backToChat": "Back to Chat", + "title": "Corvus Dashboard", + "subtitle": "Secure Gateway Configuration" + }, + "sections": { + "auth": "Authentication", + "core": "Base Configuration", + "observability": "Observability", + "runtime": "Runtime", + "autonomy": "Autonomy", + "scheduler": "Scheduler", + "gateway": "Gateway", + "webhook": "Webhook" + }, + "auth": { + "baseUrl": "@:common.baseUrl", + "pairingCode": "@:common.pairingCode", + "bearerToken": "@:common.bearerToken", + "connect": "Connect", + "pair": "Pair", + "pairSuccess": "Pairing successful. Token loaded.", + "connected": "Connected successfully.", + "loadError": "Could not load admin configuration.", + "networkError": "Could not connect to gateway. Verify network, URL, and that the service is active.", + "emptyWebhookSecret": "Webhook secret cannot be empty. Use 'Clear' if you want to remove it.", + "unauthorized": "Unauthorized. Please pair and try again." + }, + "form": { + "baseUrl": "@:common.baseUrl", + "baseUrlPlaceholder": "http://127.0.0.1:3000", + "pairingCode": "@:common.pairingCode", + "pairingCodePlaceholder": "Pairing Code", + "bearerToken": "@:common.bearerToken", + "bearerTokenPlaceholder": "Bearer Token", + "webhookSecret": "Webhook Secret", + "webhookSecretPlaceholder": "Webhook Secret", + "save": "Save changes", + "saveSuccess": "Configuration saved successfully.", + "saveError": "Could not save configuration.", + "pairingInvalidError": "Invalid pairing code.", + "pairingRateLimitError": "Too many pairing attempts. Try again later.", + "pairingMissingTokenError": "No pairing token received.", + "timeoutError": "Timeout", + "configSubtitle": "Connect with your Corvus gateway", + "noChanges": "No changes to save.", + "connectBeforeSave": "Connect to the gateway first to load current configuration.", + "restartRequired": "These changes require restarting the gateway to apply: {fields}", + "provider": "Default provider", + "model": "Default model", + "temperature": "Temperature", + "memoryBackend": "Memory backend", + "observabilityBackend": "Observability backend", + "otelEndpoint": "OTEL endpoint", + "otelServiceName": "OTEL service name", + "runtimeKind": "Runtime kind", + "autonomyLevel": "Autonomy level", + "workspaceOnly": "Workspace only", + "maxActionsPerHour": "Max actions per hour", + "maxCostPerDayCents": "Max cost per day (cents)", + "schedulerEnabled": "Scheduler enabled", + "schedulerMaxTasks": "Scheduler max tasks", + "schedulerMaxConcurrent": "Scheduler max concurrent", + "gatewayPort": "Gateway port", + "gatewayHost": "Gateway host", + "requirePairing": "Require pairing", + "allowPublicBind": "Allow public bind", + "webhookPort": "Webhook port", + "webhookSecretMode": "Webhook secret", + "webhookSecretValue": "New webhook secret", + "secretUnchanged": "Unchanged", + "secretReplace": "Replace", + "secretClear": "Clear" + }, + "general": { + "apiUrl": "API URL" + }, + "gateway": { + "pair_rate_limit_per_min": "Pairing limit/min", + "webhook_rate_limit_per_min": "Webhook limit/min" + }, + "security": { + "identity_format": "Identity format", + "identity_aieos_path": "Identity AIEOS path" + }, + "errors": { + "insecureUrlError": "Credentials cannot be sent over HTTP to a remote host. Use HTTPS or a local host." + }, + "chat": { + "welcome": "Hello, I am {modelName}. How can I help you?", + "processing": "Processing \"{text}\" with {modelName}. Gateway: {gateway}", + "requestError": "Could not get response for \"{text}\". Check the gateway and try again.", + "unauthorizedError": "Unauthorized. Pair with /pair and use the bearer token.", + "rateLimitError": "Too many requests. Please wait a moment and try again.", + "timeoutError": "The gateway took too long to respond.", + "emptyResponse": "The gateway returned no content.", + "inputPlaceholder": "Type a message...", + "send": "Send", + "newChat": "New chat", + "disclaimer": "Corvus Agent can make mistakes. Check important information." + }, + "webhook": { + "enabled": "Enabled", + "secretStatus": "Current secret: {status}", + "statusConfigured": "configured", + "statusNotConfigured": "not configured" + }, + "common": { + "baseUrl": "Base URL", + "pairingCode": "Pairing Code", + "bearerToken": "Bearer Token" + } +} \ No newline at end of file diff --git a/clients/web/packages/locales/src/es.json b/clients/web/packages/locales/src/es.json index 8bbc53730..0cd7cd7ea 100644 --- a/clients/web/packages/locales/src/es.json +++ b/clients/web/packages/locales/src/es.json @@ -18,9 +18,9 @@ "webhook": "Webhook" }, "auth": { - "baseUrl": "Base URL", - "pairingCode": "Código de emparejamiento", - "bearerToken": "Token bearer", + "baseUrl": "@:common.baseUrl", + "pairingCode": "@:common.pairingCode", + "bearerToken": "@:common.bearerToken", "connect": "Conectar", "pair": "Emparejar", "pairSuccess": "Emparejamiento exitoso. Token cargado.", @@ -31,11 +31,11 @@ "unauthorized": "No autorizado. Haz pairing y vuelve a intentar." }, "form": { - "baseUrl": "Base URL", + "baseUrl": "@:common.baseUrl", "baseUrlPlaceholder": "http://127.0.0.1:3000", - "pairingCode": "Código de emparejamiento", + "pairingCode": "@:common.pairingCode", "pairingCodePlaceholder": "Código de emparejamiento", - "bearerToken": "Token bearer", + "bearerToken": "@:common.bearerToken", "bearerTokenPlaceholder": "Token bearer", "webhookSecret": "Secreto del webhook", "webhookSecretPlaceholder": "Secreto del webhook", @@ -108,5 +108,10 @@ "secretStatus": "Secret actual: {status}", "statusConfigured": "configurado", "statusNotConfigured": "no configurado" + }, + "common": { + "baseUrl": "Base URL", + "pairingCode": "Código de emparejamiento", + "bearerToken": "Token bearer" } -} +} \ No newline at end of file diff --git a/clients/web/packages/locales/src/parity.spec.ts b/clients/web/packages/locales/src/parity.spec.ts new file mode 100644 index 000000000..369c91f0c --- /dev/null +++ b/clients/web/packages/locales/src/parity.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import en from "./en.json"; +import es from "./es.json"; + +function flatten(obj: Record, prefix = ""): Record { + let result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === "object" && !Array.isArray(value)) { + result = { + ...result, + ...flatten(value as Record, fullKey), + }; + } else { + result[fullKey] = typeof value === "object" ? JSON.stringify(value) : String(value); + } + } + return result; +} + +function extractPlaceholders(text: string): string[] { + const matches = text.match(/\{[^}]+\}/g) || []; + return matches.sort((a, b) => a.localeCompare(b)); +} + +describe("Locale Parity Guard", () => { + const flattenedEs = flatten(es as unknown as Record); + const flattenedEn = flatten(en as unknown as Record); + + it("has identical sets of keys between Spanish and English", () => { + const esKeys = Object.keys(flattenedEs).sort((a, b) => a.localeCompare(b)); + const enKeys = Object.keys(flattenedEn).sort((a, b) => a.localeCompare(b)); + + expect(esKeys).toEqual(enKeys); + }); + + it("has matching placeholders for all shared keys", () => { + for (const key of Object.keys(flattenedEs)) { + if (flattenedEn[key]) { + const esPlaceholders = extractPlaceholders(flattenedEs[key]); + const enPlaceholders = extractPlaceholders(flattenedEn[key]); + + expect(esPlaceholders, `Placeholder mismatch for key: ${key}`).toEqual(enPlaceholders); + } + } + }); +});