Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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/[({].*$//')
Comment on lines +66 to +70
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and read the relevant lines
if [[ -f ".agents/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh" ]]; then
  echo "=== File content (lines 60-120) ==="
  sed -n '60,120p' ".agents/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh" | cat -n
else
  echo "File not found, searching for similar files..."
  find . -name "*validate-kmp*" -type f 2>/dev/null || echo "No matching files"
fi

Repository: dallay/corvus

Length of output: 3618


🏁 Script executed:

# Test \s and \b portability in grep -E
echo "=== Testing \\s and \\b in grep -E ==="
echo "Testing actual bash/grep on this runner:"
echo
echo "Test 1: \\s in grep -E"
printf '%s\n' 'expect class Foo' '  expect class Bar' '	expect class Baz' \
| grep -En '^\s*expect\s+(class|object|fun|interface)\b' 2>&1 || echo "[No matches or error]"

echo
echo "Test 2: \\b word boundary in grep -E"
printf '%s\n' 'expect class Foo' 'expectation invalid' 'actualexpect class' \
| grep -En '\bexpect\b' 2>&1 || echo "[No matches or error]"

echo
echo "Test 3: Current actual matcher with \\b"
printf '%s\n' \
  'actual class Foo<T>' \
  'actual class Foo: Base' \
  'actual class Foo' \
  'actual fun Foo(' \
| grep -En 'actual[[:space:]]+(class|object|fun|interface)[[:space:]]+Foo([[:space:]]|\b|\()' 2>&1 || echo "[No matches found]"

echo
echo "Test 4: Proposed replacement pattern"
printf '%s\n' \
  'expect class Foo' \
  '  expect class Bar' \
  '	expect  class Baz' \
  'expectation class X' \
| grep -En '^[[:space:]]*expect[[:space:]]+(class|object|fun|interface)($|[[:space:]])' 2>&1 || echo "[No matches found]"

Repository: dallay/corvus

Length of output: 496


🏁 Script executed:

# Test the sed escaping difference
echo "=== Testing sed escaping for names ==="
test_name="Foo.Bar"
echo "Input name: $test_name"

echo "Current escaping (only .):"
echo "$test_name" | sed 's/\./\\./g'

echo "Proposed escaping (all special chars):"
printf '%s' "$test_name" | sed -E 's/[][(){}.^$*+?|\\-]/\\&/g'

# Test with more complex cases
echo
echo "Test with special characters:"
for name in "Foo" "Foo.Bar" "Foo[T]" "Foo(Bar)"; do
  echo "Name: $name"
  escaped_current=$(echo "$name" | sed 's/\./\\./g')
  escaped_proposed=$(printf '%s' "$name" | sed -E 's/[][(){}.^$*+?|\\-]/\\&/g')
  echo "  Current escaping: $escaped_current"
  echo "  Proposed escaping: $escaped_proposed"
done

Repository: dallay/corvus

Length of output: 507


Consider using POSIX character classes for portability, though current patterns work on this runner.

While \s and \b function correctly in the grep patterns on standard environments, they are not guaranteed to work consistently across all POSIX ERE implementations. The patterns on lines 7, 11, 20, and 32 can be hardened by replacing \s with [[:space:]] and adding explicit suffix matching for type parameters and inheritance declarations.

The escaping on line 18 currently handles only .; expanding it to [][(){}.^$*+?|\\-] guards against future edge cases with special characters in identifiers, though typical Kotlin names rarely contain these.

Suggested improvements
-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)
+expect_files_count=$(find ./*/src/commonMain -name "*.kt" -print0 2>/dev/null | xargs -0 grep -l --null -E '^[[:space:]]*expect[[:space:]]+(class|object|fun|interface)($|[[:space:]])' 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 -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/[({].*$//')
+        expects=$(grep -E '^[[:space:]]*expect[[:space:]]+(class|object|fun|interface)($|[[:space:]])' "$file" | sed -E 's/^[[:space:]]*expect[[:space:]]+(class|object|fun|interface)[[:space:]]+//' | awk '{print $1}' | sed 's/[({<:].*$//')
Also applies to: line 32, line 20 (extend suffix check to `<|:|$`)
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @.agents/skills/kotlin-multiplatform/scripts/validate-kmp-structure.sh around
lines 66 - 70, Update the regexes in validate-kmp-structure.sh to use POSIX
character classes and stricter suffix matches: replace instances of "\s" and
"\b" in the grep patterns used to compute expect_files_count and to extract
expects (the grep line and the later grep in the "expects" assignment) with
"[[:space:]]" and ensure the following token match anchors allow type parameter
or inheritance suffixes (e.g., match "<", ":", or end-of-line) rather than
relying on "\b"; also expand the identifier-sanitizing sed/awk step that strips
trailing punctuation to account for additional special characters (use a
bracketed class like "[][(){}.^$*+?|\-]" when removing trailing tokens) so
functions/variables that reference expect_files_count and the extraction block
produce portable, robust matches across POSIX systems.


</details>

<!-- fingerprinting:phantom:poseidon:hawk -->

<!-- This is an auto-generated comment by CodeRabbit -->


# 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
Expand All @@ -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
Expand All @@ -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
Expand Down
18 changes: 15 additions & 3 deletions clients/agent-runtime/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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()))?;
Comment on lines +170 to 190
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add regression tests for traversal and workspace-boundary enforcement.

Lines [170-190] add important path hardening, but there’s no focused test coverage here for: (1) in-workspace success, (2) .. traversal rejection, and (3) symlink escape rejection. This is security-critical behavior and should be locked with tests.

🧪 Suggested test cases to add
+    #[test]
+    fn load_aieos_identity_rejects_path_outside_workspace() {
+        // setup temp workspace and external file, configure aieos_path = "../outside.json"
+        // assert error contains "outside the workspace directory"
+    }
+
+    #[test]
+    fn load_aieos_identity_accepts_path_inside_workspace() {
+        // setup temp workspace with identity.json
+        // assert Ok(Some(...))
+    }
+
+    #[test]
+    fn load_aieos_identity_rejects_symlink_escape() {
+        // setup symlink inside workspace pointing outside
+        // assert error contains "outside the workspace directory"
+    }
As per coding guidelines "`**/*`: Security first, performance second. Look for behavioral regressions, missing tests, and contract breaks across modules."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/agent-runtime/src/identity.rs` around lines 170 - 190, Add regression
tests in clients/agent-runtime/src/identity.rs (or its test module) that
exercise the new workspace-boundary checks around
workspace_canonical/full_path_canonical and the final read_to_string call: (1) a
positive test where a file inside the workspace loads successfully, (2) a test
that attempts path traversal using ".." and asserts the function returns an
error (security bail), and (3) a test that creates a symlink inside the
workspace pointing outside and asserts that resolving the symlink is rejected;
use temporary directories/files and assert on the error messages or error kinds
produced by the code paths that call canonicalize and bail.


let identity: AieosIdentity = serde_json::from_str(&content)
Expand Down Expand Up @@ -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
Expand Down
18 changes: 10 additions & 8 deletions clients/agent-runtime/src/tools/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Do not panic on invalid allowlist during tool construction.

expect(...) can crash startup/runtime on config mistakes; return an error and let the caller surface a structured failure.

💡 Suggested fix
-    pub fn new(
+    pub fn new(
         security: Arc<SecurityPolicy>,
         allowed_domains: Vec<String>,
         session_name: Option<String>,
-    ) -> Self {
-        Self::new_with_backend(
+    ) -> anyhow::Result<Self> {
+        Self::new_with_backend(
             security,
             allowed_domains,
             session_name,
             "agent_browser".into(),
             true,
             "http://127.0.0.1:9515".into(),
             None,
             ComputerUseConfig::default(),
         )
     }

@@
-    ) -> Self {
-        Self {
+    ) -> anyhow::Result<Self> {
+        let allowed_domains = normalize_domains(allowed_domains)
+            .context("Invalid browser.allowed_domains configuration")?;
+        Ok(Self {
             security,
-            allowed_domains: normalize_domains(allowed_domains).expect("Invalid allowlist entries"),
+            allowed_domains,
             session_name,
             backend,
             native_headless,
             native_webdriver_url,
             native_chrome_path,
             computer_use,
             #[cfg(feature = "browser-native")]
             native_state: tokio::sync::Mutex::new(native_backend::NativeBrowserState::default()),
-        }
+        })
     }
As per coding guidelines "`clients/agent-runtime/src/tools/**/*.rs`: ... return structured `ToolResult` without panics in runtime path".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/agent-runtime/src/tools/browser.rs` at line 217, The constructor
currently panics via expect("Invalid allowlist entries") when
normalize_domains(allowed_domains) fails; replace this panic by propagating a
structured error (ToolResult) back to the caller instead: change the
construction path that sets allowed_domains (look for normalize_domains and the
struct/constructor that builds the Browser tool) to return Err(...) with a
descriptive ToolResult/ToolError when normalize_domains returns Err, so tool
creation fails gracefully rather than calling expect.

session_name,
backend,
native_headless,
Expand Down Expand Up @@ -1933,12 +1933,14 @@ fn unavailable_action_for_backend_error(action: &str, backend: ResolvedBackend)
)
}

fn normalize_domains(domains: Vec<String>) -> Vec<String> {
domains
.into_iter()
.map(|d| d.trim().to_lowercase())
.filter(|d| !d.is_empty())
.collect()
fn normalize_domains(domains: Vec<String>) -> anyhow::Result<Vec<String>> {
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)
}
Comment on lines +1936 to 1944
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Prefer the shared normalizer to avoid policy drift.

This helper duplicates clients/agent-runtime/src/tools/url_safety.rs::normalize_allowed_domains. Reuse the shared function so all tools enforce identical normalization semantics.

♻️ Suggested simplification
 fn normalize_domains(domains: Vec<String>) -> anyhow::Result<Vec<String>> {
-    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)
+    crate::tools::url_safety::normalize_allowed_domains(domains)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/agent-runtime/src/tools/browser.rs` around lines 1936 - 1944, The
helper function normalize_domains duplicates existing logic in
url_safety::normalize_allowed_domains; replace its body to call
crate::tools::url_safety::normalize_allowed_domains (or import and call
normalize_allowed_domains) to perform normalization, then preserve the
post-processing (sort_unstable and dedup) and return the Result<Vec<String>>
from that call (or map its Ok value to sorted/deduped Vec). Update the function
normalize_domains to delegate to normalize_allowed_domains so all callers use
the shared normalizer.


fn endpoint_reachable(endpoint: &reqwest::Url, timeout: Duration) -> bool {
Expand Down Expand Up @@ -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"]);
}

Expand Down
4 changes: 2 additions & 2 deletions clients/agent-runtime/src/tools/browser_open.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ impl BrowserOpenTool {
pub fn new(security: Arc<SecurityPolicy>, allowed_domains: Vec<String>) -> Self {
Self {
security,
allowed_domains: normalize_allowed_domains(allowed_domains),
allowed_domains: normalize_allowed_domains(allowed_domains).expect("Invalid allowlist entries"),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Avoid expect panic in BrowserOpenTool::new.

Invalid allowlist config should return a typed error, not abort process initialization.

💡 Suggested fix
 impl BrowserOpenTool {
-    pub fn new(security: Arc<SecurityPolicy>, allowed_domains: Vec<String>) -> Self {
-        Self {
+    pub fn new(security: Arc<SecurityPolicy>, allowed_domains: Vec<String>) -> anyhow::Result<Self> {
+        let allowed_domains = normalize_allowed_domains(allowed_domains)
+            .context("Invalid browser.allowed_domains configuration")?;
+        Ok(Self {
             security,
-            allowed_domains: normalize_allowed_domains(allowed_domains).expect("Invalid allowlist entries"),
-        }
+            allowed_domains,
+        })
     }
 }
As per coding guidelines "`clients/agent-runtime/src/tools/**/*.rs`: ... return structured `ToolResult` without panics in runtime path".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/agent-runtime/src/tools/browser_open.rs` at line 20, Replace the
panic-causing expect in BrowserOpenTool::new by returning a structured error:
change BrowserOpenTool::new to return a ToolResult (or Result<Self,
ToolError>/ToolResult alias used in repo), call
normalize_allowed_domains(allowed_domains) and map or propagate its error into a
typed ToolError (e.g., InvalidConfig or similar) instead of calling expect,
construct and return Ok(BrowserOpenTool { allowed_domains: parsed, ... }) on
success, and update any call sites to handle the ToolResult accordingly.

}
}

Expand Down Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions clients/agent-runtime/src/tools/http_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Do not panic on invalid allowlist in HttpRequestTool::new.

Use error propagation from constructor instead of expect(...) so config problems are reported cleanly.

💡 Suggested fix
 impl HttpRequestTool {
-    pub fn new(
+    pub fn new(
         security: Arc<SecurityPolicy>,
         allowed_domains: Vec<String>,
         max_response_size: usize,
         timeout_secs: u64,
-    ) -> Self {
-        Self {
+    ) -> anyhow::Result<Self> {
+        let allowed_domains = normalize_allowed_domains(allowed_domains)
+            .context("Invalid http_request.allowed_domains configuration")?;
+        Ok(Self {
             security,
-            allowed_domains: normalize_allowed_domains(allowed_domains).expect("Invalid allowlist entries"),
+            allowed_domains,
             max_response_size,
             timeout_secs,
-        }
+        })
     }
 }
As per coding guidelines "`clients/agent-runtime/src/tools/**/*.rs`: ... return structured `ToolResult` without panics in runtime path".
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
allowed_domains: normalize_allowed_domains(allowed_domains).expect("Invalid allowlist entries"),
impl HttpRequestTool {
pub fn new(
security: Arc<SecurityPolicy>,
allowed_domains: Vec<String>,
max_response_size: usize,
timeout_secs: u64,
) -> anyhow::Result<Self> {
let allowed_domains = normalize_allowed_domains(allowed_domains)
.context("Invalid http_request.allowed_domains configuration")?;
Ok(Self {
security,
allowed_domains,
max_response_size,
timeout_secs,
})
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/agent-runtime/src/tools/http_request.rs` at line 73, The constructor
HttpRequestTool::new must not panic on invalid allowlist entries; replace the
panic-inducing call normalize_allowed_domains(...).expect(...) with error
propagation by changing HttpRequestTool::new to return a Result/ToolResult and
convert the normalization error into the tool's structured error type (e.g.,
ToolResult::Err or a ToolError) using map_err or the ? operator; update callers
to handle the Result and ensure the allowed_domains field is set from the
successful normalize_allowed_domains(...) output (refer to HttpRequestTool::new
and normalize_allowed_domains).

max_response_size,
timeout_secs,
}
Expand Down Expand Up @@ -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]
Expand Down
48 changes: 36 additions & 12 deletions clients/agent-runtime/src/tools/url_safety.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
use url::{Host, Url};

pub(crate) fn normalize_allowed_domains(domains: Vec<String>) -> Vec<String> {
let mut normalized = domains
.into_iter()
.filter_map(|domain| normalize_domain(&domain))
.collect::<Vec<_>>();
pub(crate) fn normalize_allowed_domains(domains: Vec<String>) -> anyhow::Result<Vec<String>> {
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<String> {
pub(crate) fn normalize_domain(raw: &str) -> anyhow::Result<String> {
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://") {
Expand All @@ -31,15 +31,15 @@ pub(crate) fn normalize_domain(raw: &str) -> Option<String> {
.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(
Expand Down Expand Up @@ -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());
}
}
64 changes: 64 additions & 0 deletions clients/agent-runtime/tests/identity_security.rs
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the file
echo "Looking for identity_security.rs..."
find . -name "identity_security.rs" -type f 2>/dev/null | head -20

Repository: dallay/corvus

Length of output: 143


🏁 Script executed:

#!/bin/bash
# Check if the file exists and read its content
if [ -f "clients/agent-runtime/tests/identity_security.rs" ]; then
  echo "File found. Reading content..."
  wc -l "clients/agent-runtime/tests/identity_security.rs"
  echo "---"
  head -60 "clients/agent-runtime/tests/identity_security.rs"
else
  echo "File not found at expected location"
fi

Repository: dallay/corvus

Length of output: 2096


🏁 Script executed:

#!/bin/bash
# Check CI workflows for non-Unix targets
echo "Checking CI/workflows for non-Unix targets/runners..."
if [ -d ".github/workflows" ]; then
  find .github/workflows -type f \( -name "*.yml" -o -name "*.yaml" \) | while read f; do
    echo "=== $f ==="
    cat "$f" | grep -E "(windows|macos|ubuntu|target|runs-on|os:)" || echo "  (no matches found)"
  done
else
  echo "No .github/workflows directory found"
fi

Repository: dallay/corvus

Length of output: 3921


🏁 Script executed:

#!/bin/bash
# Check Cargo.toml for target specifications
echo "Checking Cargo.toml files for target specifications..."
find clients/agent-runtime -name "Cargo.toml" -type f | while read f; do
  echo "=== $f ==="
  head -50 "$f" | grep -E "(target|platform)" || echo "  (no explicit targets)"
done

Repository: dallay/corvus

Length of output: 659


Add platform guards to Unix-only symlink imports to prevent Windows build failures.

std::os::unix::fs::symlink is Unix-only. The CI explicitly builds for Windows (x86_64-pc-windows-msvc), macOS, and Linux, so this test file will fail to compile on non-Unix targets without proper guards.

🔧 Proposed fix
 use corvus::identity::load_aieos_identity;
 use corvus::config::IdentityConfig;
 use tempfile::tempdir;
 use std::fs;
-use std::os::unix::fs::symlink;
+#[cfg(unix)]
+use std::os::unix::fs::symlink;
@@
 #[test]
+#[cfg(unix)]
 fn test_load_aieos_identity_traversal_symlink() {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
use std::os::unix::fs::symlink;
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[test]
#[cfg(unix)]
fn test_load_aieos_identity_traversal_symlink() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/agent-runtime/tests/identity_security.rs` at line 5, The test imports
the Unix-only symbol std::os::unix::fs::symlink which breaks Windows builds;
guard that import and any tests that rely on it with a platform cfg attribute
(e.g., #[cfg(unix)]) or provide platform-specific alternatives, so only Unix
targets compile the symlink-dependent code; locate the symlink import and the
tests in identity_security.rs and wrap them with the appropriate #[cfg(unix)] or
refactor tests to use std::os::windows::fs::symlink_file/symlink_dir on Windows
and conditional #[cfg(test)] guards.


#[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"));
}
Loading