Skip to content
Merged
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
40 changes: 32 additions & 8 deletions src/safety/checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,12 @@ impl SafetyChecker {
}
unknown => {
tracing::error!(
"Safety checker: unregistered tool '{}' — add to checker.rs dispatch. Allowing with caution.",
"Safety checker: unregistered tool '{}' blocked — add to checker.rs dispatch if legitimate.",
unknown
);
anyhow::bail!(
"Unregistered tool '{}' blocked by safety checker. \
Register it in checker.rs to allow execution.",
unknown
);
}
Expand Down Expand Up @@ -1213,14 +1218,19 @@ mod tests {
}

#[test]
fn test_safety_allows_unknown_tool_with_error_log() {
// Unknown tools are still allowed (dynamic plugin tools have arbitrary names)
// but now logged at error level to surface unregistered tools.
fn test_safety_blocks_unknown_tool() {
// Unknown/unregistered tools must be blocked to prevent
// untrusted code from bypassing safety checks.
let config = SafetyConfig::default();
let checker = SafetyChecker::new(&config);

let call = create_test_call("unknown_tool", r#"{"arg": "value"}"#);
assert!(checker.check_tool_call(&call).is_ok());
let result = checker.check_tool_call(&call);
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("Unregistered tool"),
"Error should mention unregistered tool"
);
}

#[test]
Expand Down Expand Up @@ -1279,19 +1289,33 @@ mod tests {
}

#[test]
fn test_check_path_allows_when_no_allowed_paths_configured() {
fn test_check_path_restricts_to_workdir_when_no_allowed_paths() {
let config = SafetyConfig {
allowed_paths: vec![], // Empty = allow all
allowed_paths: vec![], // Empty = restrict to working directory
denied_paths: vec![],
..Default::default()
};
let checker = SafetyChecker::new(&config);

// Paths outside working directory should be blocked
let call = create_test_call(
"file_write",
r#"{"path": "/any/path/at/all.txt", "content": ""}"#,
);
assert!(checker.check_tool_call(&call).is_ok());
assert!(
checker.check_tool_call(&call).is_err(),
"Paths outside working directory should be blocked when allowed_paths is empty"
);

// Paths inside working directory should be allowed
let call_local = create_test_call(
"file_write",
r#"{"path": "src/main.rs", "content": ""}"#,
);
assert!(
checker.check_tool_call(&call_local).is_ok(),
"Paths inside working directory should be allowed"
);
}

// Additional edge case tests for improved coverage
Expand Down
23 changes: 20 additions & 3 deletions src/safety/path_validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,26 @@ impl PathValidator {
}
}

if !self.config.allowed_paths.is_empty()
&& !self.is_path_in_allowed_list(&canonical_str, path)?
{
if self.config.allowed_paths.is_empty() {
// No allowed_paths configured: restrict to working directory
// to prevent unrestricted filesystem access.
let working_dir_canonical = self
.working_dir
.canonicalize()
.unwrap_or_else(|_| self.working_dir.clone());
if !canonical.starts_with(&working_dir_canonical) {
tracing::warn!(
"No allowed_paths configured — restricting to working directory. \
Path '{}' is outside '{}'",
canonical_str,
working_dir_canonical.display()
);
anyhow::bail!(
"Path '{}' is outside working directory and no allowed_paths configured",
canonical_str
);
}
} else if !self.is_path_in_allowed_list(&canonical_str, path)? {
anyhow::bail!("Path not in allowed list: {}", canonical_str);
}

Expand Down
7 changes: 5 additions & 2 deletions src/session/local_first.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ fn generate_sync_id() -> String {
format!("sync-{}", SYNC_ID_COUNTER.fetch_add(1, Ordering::SeqCst))
}

/// Get current timestamp
/// Get current timestamp in seconds.
///
/// All TTL and interval comparisons in this module use seconds,
/// so this must return seconds (not milliseconds).
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
.as_secs()
}

/// Cache priority level
Expand Down
13 changes: 13 additions & 0 deletions system_tests/projecte2e/config/qwen3_5_27b_nvfp4.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
endpoint = "http://localhost:8000/v1"
model = "qwen3.5-27b"
max_tokens = 32768

[safety]
allowed_paths = ["./**", "~/**"]
denied_paths = ["**/.env", "**/secrets/**", "**/.ssh/**", "**/target/**"]

[agent]
max_iterations = 80
step_timeout_secs = 600
native_function_calling = true
token_budget = 32768
Loading