use super::*;
use serde_json::json;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use tempfile::tempdir;
#[test]
fn test_tool_result_success() {
let result = ToolResult::success("hello");
assert!(result.success);
assert_eq!(result.content, "hello");
assert!(result.metadata.is_none());
}
#[test]
fn test_tool_result_error() {
let result = ToolResult::error("something failed");
assert!(!result.success);
assert_eq!(result.content, "something failed");
}
#[test]
fn test_tool_result_json() {
let data = json!({"key": "value"});
let result = ToolResult::json(&data).unwrap();
assert!(result.success);
assert!(result.content.contains("key"));
}
#[test]
fn test_tool_result_with_metadata() {
let result = ToolResult::success("content").with_metadata(json!({"extra": true}));
assert!(result.metadata.is_some());
}
#[test]
fn test_tool_context_resolve_path_relative() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let test_file = tmp.path().join("test.txt");
std::fs::write(&test_file, "test").expect("write");
let resolved = ctx.resolve_path("test.txt").expect("resolve");
assert!(resolved.ends_with("test.txt"));
}
#[test]
fn test_tool_context_resolve_path_escape() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = ctx.resolve_path("/etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_tool_context_resolve_path_parent_traversal() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = ctx.resolve_path("../escape.txt");
assert!(result.is_err());
}
#[test]
fn test_tool_context_resolve_path_normalizes_parent() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = ctx.resolve_path("new/../safe.txt");
assert!(result.is_ok());
}
#[test]
fn test_tool_context_trust_mode() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf()).with_trust_mode(true);
let result = ctx.resolve_path("/tmp");
assert!(result.is_ok());
}
#[test]
fn tool_context_keeps_execution_state_grouped_and_value_cloned() {
let mut context = ToolContext::new(".");
context.auto_approve = true;
context.state_namespace = "session-a".to_string();
assert!(context.execution.auto_approve);
assert_eq!(context.execution.state_namespace, "session-a");
let mut cloned = context.clone();
cloned.state_namespace = "session-b".to_string();
assert_eq!(context.state_namespace, "session-a");
assert_eq!(cloned.execution.state_namespace, "session-b");
}
#[test]
fn tool_context_top_level_stays_slim_as_services_grow() {
assert!(
std::mem::size_of::<ToolContext>()
<= std::mem::size_of::<PathBuf>() + 2 * std::mem::size_of::<usize>(),
"ToolContext should contain only the workspace and boxed execution group"
);
}
#[test]
fn test_tool_context_trusted_external_path_allows_escape() {
let workspace = tempdir().expect("workspace tempdir");
let trusted_root = tempdir().expect("trusted tempdir");
let trusted_file = trusted_root.path().join("notes.md");
std::fs::write(&trusted_file, "shared notes").unwrap();
let ctx = ToolContext::new(workspace.path().to_path_buf()).with_trusted_external_paths(vec![
trusted_root
.path()
.canonicalize()
.unwrap_or_else(|_| trusted_root.path().to_path_buf()),
]);
let resolved = ctx
.resolve_path(trusted_file.to_str().unwrap())
.expect("trusted path should resolve");
assert!(resolved.ends_with("notes.md"));
let other = tempdir().expect("untrusted tempdir");
let other_file = other.path().join("secret.md");
std::fs::write(&other_file, "x").unwrap();
let err = ctx
.resolve_path(other_file.to_str().unwrap())
.expect_err("untrusted path must error");
assert!(matches!(err, ToolError::PathEscape { .. }));
}
#[test]
#[cfg(unix)]
fn test_tool_context_follow_symlinks_allows_nonexistent_path_under_workspace_symlink() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
std::fs::create_dir_all(outside.join("target")).expect("mkdir outside target");
symlink(outside.join("target"), workspace.join("linked")).expect("symlink");
let ctx = ToolContext::new(workspace).with_follow_symlinks(true);
let resolved = ctx
.resolve_path("linked/new.txt")
.expect("path under workspace symlink should resolve");
let expected = outside
.join("target")
.canonicalize()
.expect("canonical target")
.join("new.txt");
assert_eq!(resolved, normalize_path(&expected));
}
#[test]
#[cfg(unix)]
fn test_tool_context_default_mode_rejects_nonexistent_path_under_workspace_symlink() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
std::fs::create_dir_all(outside.join("target")).expect("mkdir outside target");
symlink(outside.join("target"), workspace.join("linked")).expect("symlink");
let ctx = ToolContext::new(workspace);
let err = ctx
.resolve_path("linked/new.txt")
.expect_err("default mode should still reject workspace symlink escapes");
assert!(matches!(err, ToolError::PathEscape { .. }));
}
fn scoped_authority(roots: &[&str], files: &[&str]) -> ToolAuthorityEnvelope {
ToolAuthorityEnvelope {
schema_version: 1,
owner: "fleet-worker-1".to_string(),
authority: ToolMutationAuthority::ScopedWrite,
network_access: None,
shell: ToolShellAuthority::None,
verification: ToolVerificationAuthority::None,
writable_roots: roots.iter().map(|value| (*value).to_string()).collect(),
writable_files: files.iter().map(|value| (*value).to_string()).collect(),
coordination_contracts: Vec::new(),
}
.normalized()
.expect("valid test authority")
}
#[test]
fn tool_authority_allows_normal_nonexistent_children_only_inside_scope() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("src")).expect("src");
let context = ToolContext::new(tmp.path().to_path_buf());
let authority = scoped_authority(&["src"], &[]);
assert!(
authority
.permits_mutation_path(&context, "src/new/nested.rs")
.expect("normal nonexistent child")
);
assert!(
!authority
.permits_mutation_path(&context, "docs/outside.md")
.expect("ordinary out-of-scope path")
);
}
#[cfg(unix)]
#[test]
fn tool_authority_rejects_exact_file_symlink_aliases() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("src")).expect("src");
std::fs::create_dir(tmp.path().join("other")).expect("other");
std::fs::write(tmp.path().join("other/target.rs"), "outside scope\n").expect("target");
symlink("../other/target.rs", tmp.path().join("src/alias.rs")).expect("alias");
let context = ToolContext::new(tmp.path().to_path_buf());
let authority = scoped_authority(&[], &["src/alias.rs"]);
let error = authority
.permits_mutation_path(&context, "src/alias.rs")
.expect_err("an exact-file claim must not authorize a symlink target")
.to_string();
assert!(error.contains("must not traverse symlinks"), "{error}");
}
#[cfg(unix)]
#[test]
fn tool_authority_rejects_claimed_root_and_child_symlink_aliases() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("real")).expect("real");
symlink("real", tmp.path().join("linked")).expect("linked root");
let context = ToolContext::new(tmp.path().to_path_buf());
let claimed_alias = scoped_authority(&["linked"], &[]);
let claimed_real = scoped_authority(&["real"], &[]);
for (authority, path) in [
(&claimed_alias, "linked/new.rs"),
(&claimed_real, "linked/new.rs"),
] {
let error = authority
.permits_mutation_path(&context, path)
.expect_err("symlinked roots and mutation paths must fail closed")
.to_string();
assert!(error.contains("must not traverse symlinks"), "{error}");
}
}
#[test]
fn nested_tool_authority_may_only_narrow_the_outer_cap() {
let tmp = tempdir().expect("tempdir");
let outer = scoped_authority(&["src"], &["Cargo.toml"]);
let narrower = scoped_authority(&["src/parser"], &[]);
let expansion = scoped_authority(&["docs"], &[]);
ToolContext::new(tmp.path().to_path_buf())
.with_tool_authority(outer.clone())
.unwrap()
.with_tool_authority(narrower)
.expect("nested scope may narrow");
let error = ToolContext::new(tmp.path().to_path_buf())
.with_tool_authority(outer.clone())
.unwrap()
.with_tool_authority(expansion)
.err()
.expect("nested scope expansion must fail closed");
assert!(error.contains("cannot expand"), "{error}");
let read_only = ToolAuthorityEnvelope {
schema_version: 1,
owner: "read-only-child".to_string(),
authority: ToolMutationAuthority::ReadOnly,
network_access: None,
shell: ToolShellAuthority::None,
verification: ToolVerificationAuthority::None,
writable_roots: Vec::new(),
writable_files: Vec::new(),
coordination_contracts: Vec::new(),
};
ToolContext::new(tmp.path().to_path_buf())
.with_tool_authority(outer.clone())
.unwrap()
.with_tool_authority(read_only)
.expect("read-only always narrows a write cap");
let shell_expansion = ToolAuthorityEnvelope {
schema_version: 1,
owner: "shell-expansion".to_string(),
authority: ToolMutationAuthority::ReadOnly,
network_access: None,
shell: ToolShellAuthority::ReadOnly,
verification: ToolVerificationAuthority::None,
writable_roots: Vec::new(),
writable_files: Vec::new(),
coordination_contracts: Vec::new(),
};
ToolContext::new(tmp.path().to_path_buf())
.with_tool_authority(outer)
.unwrap()
.with_tool_authority(shell_expansion)
.err()
.expect("nested authority cannot add a shell cap the outer process lacks");
}
#[test]
fn legacy_v1_authority_envelopes_default_to_shell_none() {
let authority = ToolAuthorityEnvelope::from_json(
r#"{"schema_version":1,"owner":"legacy-worker","authority":"read_only"}"#,
)
.expect("pre-shell v1 envelope remains readable");
assert_eq!(authority.shell, ToolShellAuthority::None);
assert_eq!(authority.verification, ToolVerificationAuthority::None);
}
#[test]
fn headless_fleet_registers_bash_only_when_the_clamped_ceiling_keeps_it() {
assert!(fleet_exec_shell_enabled(
true,
ToolShellAuthority::ReadOnly,
None
));
assert!(!fleet_exec_shell_enabled(
true,
ToolShellAuthority::ReadOnly,
Some(&["ba*".into()])
));
assert!(!fleet_exec_shell_enabled(
true,
ToolShellAuthority::None,
None
));
}
#[test]
fn bounded_verification_is_typed_and_cannot_smuggle_bash_authority() {
let bounded = ToolAuthorityEnvelope::from_json(
r#"{"schema_version":1,"owner":"verifier","authority":"read_only","verification":"bounded"}"#,
)
.expect("bounded verifier authority");
assert_eq!(bounded.verification, ToolVerificationAuthority::Bounded);
let widened = ToolAuthorityEnvelope {
shell: ToolShellAuthority::ReadOnly,
..bounded
};
assert!(
widened.normalized().is_err(),
"bounded verification and Bash authority are separate, non-composable caps"
);
}
#[test]
fn read_only_machine_authority_clamps_live_shell_policy() {
let tmp = tempdir().expect("tempdir");
let read_only = ToolAuthorityEnvelope {
schema_version: 1,
owner: "scout".to_string(),
authority: ToolMutationAuthority::ReadOnly,
network_access: Some(true),
shell: ToolShellAuthority::ReadOnly,
verification: ToolVerificationAuthority::None,
writable_roots: Vec::new(),
writable_files: Vec::new(),
coordination_contracts: Vec::new(),
};
let mut context = ToolContext::new(tmp.path().to_path_buf())
.with_tool_authority(read_only)
.expect("read-only authority");
assert_eq!(context.shell_policy, ShellPolicy::ReadOnly);
context.set_shell_policy(ShellPolicy::Full);
assert_eq!(
context.shell_policy,
ShellPolicy::ReadOnly,
"a live mode refresh must not widen the process authority cap"
);
let scoped = ToolContext::new(tmp.path().to_path_buf())
.with_tool_authority(scoped_authority(&["src"], &[]))
.expect("scoped authority")
.with_shell_policy(ShellPolicy::Full);
assert_eq!(scoped.shell_policy, ShellPolicy::None);
}
#[test]
fn process_tool_authority_inherits_into_all_context_constructors() {
const CHILD_ENV: &str = "CODEWHALE_TEST_PROCESS_TOOL_AUTHORITY_CHILD";
if std::env::var_os(CHILD_ENV).is_some() {
let tmp = tempdir().expect("tempdir");
install_process_tool_authority(ToolAuthorityEnvelope {
schema_version: 1,
owner: "fleet-worker-child-process".to_string(),
authority: ToolMutationAuthority::ReadOnly,
network_access: None,
shell: ToolShellAuthority::ReadOnly,
verification: ToolVerificationAuthority::None,
writable_roots: Vec::new(),
writable_files: Vec::new(),
coordination_contracts: Vec::new(),
})
.expect("install process authority once in isolated child");
let notes = tmp.path().join("notes.md");
let mcp = tmp.path().join("mcp.json");
let contexts = [
ToolContext::new(tmp.path().to_path_buf()),
ToolContext::with_options(tmp.path().to_path_buf(), false, notes.clone(), mcp.clone()),
ToolContext::with_auto_approve(tmp.path().to_path_buf(), false, notes, mcp, true),
];
for context in contexts {
let authority = context
.tool_authority
.as_ref()
.expect("every constructor inherits process authority");
assert_eq!(authority.owner, "fleet-worker-child-process");
assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly);
assert_eq!(context.shell_policy, ShellPolicy::ReadOnly);
}
return;
}
let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
.arg("--exact")
.arg("tools::spec::tests::process_tool_authority_inherits_into_all_context_constructors")
.arg("--nocapture")
.env(CHILD_ENV, "1")
.output()
.expect("spawn isolated authority test child");
assert!(
output.status.success(),
"child failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_required_str() {
let input = json!({"name": "test", "count": 42});
assert_eq!(required_str(&input, "name").unwrap(), "test");
assert!(required_str(&input, "missing").is_err());
assert!(required_str(&input, "count").is_err()); }
#[test]
fn test_optional_str() {
let input = json!({"name": "test", "count": 7});
assert_eq!(optional_str(&input, "name").unwrap(), Some("test"));
assert_eq!(optional_str(&input, "missing").unwrap(), None);
assert_eq!(optional_str(&json!({"name": null}), "name").unwrap(), None);
let err = optional_str(&input, "count").expect_err("a number is not a string");
let err = err.to_string();
assert!(
err.contains("count") && err.contains("number") && err.contains("string"),
"{err}"
);
}
#[test]
fn test_required_u64() {
let input = json!({"count": 42});
assert_eq!(required_u64(&input, "count").unwrap(), 42);
assert!(required_u64(&input, "missing").is_err());
}
#[test]
fn test_optional_u64() {
let input = json!({"count": 42});
assert_eq!(optional_u64(&input, "count", 0).unwrap(), 42);
assert_eq!(optional_u64(&input, "missing", 100).unwrap(), 100);
assert_eq!(
optional_u64(&json!({"count": null}), "count", 9).unwrap(),
9
);
for bad in [json!("42"), json!(-1), json!(2.5), json!([42])] {
let err = optional_u64(&json!({"count": bad}), "count", 100)
.expect_err("a non-integer must not fall back to the default")
.to_string();
assert!(
err.contains("count") && err.contains("non-negative integer"),
"{err}"
);
}
}
#[test]
fn test_optional_bool() {
let input = json!({"flag": true});
assert!(optional_bool(&input, "flag", false).unwrap());
assert!(!optional_bool(&input, "missing", false).unwrap());
assert!(optional_bool(&json!({"flag": null}), "flag", true).unwrap());
for bad in [json!("true"), json!("false"), json!(1), json!(0), json!([])] {
let err = optional_bool(&json!({"flag": bad}), "flag", false)
.expect_err("a non-boolean must not fall back to the default")
.to_string();
assert!(err.contains("flag") && err.contains("boolean"), "{err}");
}
}
#[test]
fn test_tool_error_display() {
let err = ToolError::missing_field("path");
assert_eq!(
format!("{err}"),
"Failed to validate input: missing required field 'path'"
);
let err = ToolError::execution_failed("boom");
assert_eq!(format!("{err}"), "Failed to execute tool: boom");
}
#[test]
fn test_approval_requirement_default() {
let level = ApprovalRequirement::default();
assert_eq!(level, ApprovalRequirement::Auto);
}