返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tools / spec / tests.rs
1 use super::*;
2 use serde_json::json;
3 #[cfg(unix)]
4 use std::os::unix::fs::symlink;
5 use tempfile::tempdir;
6
7 #[test]
8 fn test_tool_result_success() {
9 let result = ToolResult::success("hello");
10 assert!(result.success);
11 assert_eq!(result.content, "hello");
12 assert!(result.metadata.is_none());
13 }
14
15 #[test]
16 fn test_tool_result_error() {
17 let result = ToolResult::error("something failed");
18 assert!(!result.success);
19 assert_eq!(result.content, "something failed");
20 }
21
22 #[test]
23 fn test_tool_result_json() {
24 let data = json!({"key": "value"});
25 let result = ToolResult::json(&data).unwrap();
26 assert!(result.success);
27 assert!(result.content.contains("key"));
28 }
29
30 #[test]
31 fn test_tool_result_with_metadata() {
32 let result = ToolResult::success("content").with_metadata(json!({"extra": true}));
33 assert!(result.metadata.is_some());
34 }
35
36 #[test]
37 fn test_tool_context_resolve_path_relative() {
38 let tmp = tempdir().expect("tempdir");
39 let ctx = ToolContext::new(tmp.path().to_path_buf());
40
41 // Create a test file
42 let test_file = tmp.path().join("test.txt");
43 std::fs::write(&test_file, "test").expect("write");
44
45 let resolved = ctx.resolve_path("test.txt").expect("resolve");
46 assert!(resolved.ends_with("test.txt"));
47 }
48
49 #[test]
50 fn test_tool_context_resolve_path_escape() {
51 let tmp = tempdir().expect("tempdir");
52 let ctx = ToolContext::new(tmp.path().to_path_buf());
53
54 // Try to escape workspace
55 let result = ctx.resolve_path("/etc/passwd");
56 assert!(result.is_err());
57 }
58
59 #[test]
60 fn test_tool_context_resolve_path_parent_traversal() {
61 let tmp = tempdir().expect("tempdir");
62 let ctx = ToolContext::new(tmp.path().to_path_buf());
63
64 let result = ctx.resolve_path("../escape.txt");
65 assert!(result.is_err());
66 }
67
68 #[test]
69 fn test_tool_context_resolve_path_normalizes_parent() {
70 let tmp = tempdir().expect("tempdir");
71 let ctx = ToolContext::new(tmp.path().to_path_buf());
72
73 let result = ctx.resolve_path("new/../safe.txt");
74 assert!(result.is_ok());
75 }
76
77 #[test]
78 fn test_tool_context_trust_mode() {
79 let tmp = tempdir().expect("tempdir");
80 let ctx = ToolContext::new(tmp.path().to_path_buf()).with_trust_mode(true);
81
82 // In trust mode, absolute paths should work
83 let result = ctx.resolve_path("/tmp");
84 assert!(result.is_ok());
85 }
86
87 #[test]
88 fn tool_context_keeps_execution_state_grouped_and_value_cloned() {
89 let mut context = ToolContext::new(".");
90 context.auto_approve = true;
91 context.state_namespace = "session-a".to_string();
92
93 assert!(context.execution.auto_approve);
94 assert_eq!(context.execution.state_namespace, "session-a");
95
96 let mut cloned = context.clone();
97 cloned.state_namespace = "session-b".to_string();
98 assert_eq!(context.state_namespace, "session-a");
99 assert_eq!(cloned.execution.state_namespace, "session-b");
100 }
101
102 #[test]
103 fn legacy_auto_approve_bit_folds_into_the_context_posture() {
104 let plain = ToolContext::new(".");
105 assert_eq!(
106 plain.approval_mode,
107 codewhale_execpolicy::ApprovalMode::Suggest,
108 "a context with no authority runs under Ask"
109 );
110
111 let yolo = ToolContext::with_auto_approve(".", false, "notes.md", "mcp.json", true);
112 assert!(yolo.auto_approve);
113 assert_eq!(
114 yolo.approval_mode,
115 codewhale_execpolicy::ApprovalMode::Bypass,
116 "a set auto-approve bit is the Full Access posture"
117 );
118 }
119
120 #[test]
121 fn tool_context_top_level_stays_slim_as_services_grow() {
122 assert!(
123 std::mem::size_of::<ToolContext>()
124 <= std::mem::size_of::<PathBuf>() + 2 * std::mem::size_of::<usize>(),
125 "ToolContext should contain only the workspace and boxed execution group"
126 );
127 }
128
129 /// Issue #29: paths under a user-trusted external directory resolve
130 /// successfully even though they fall outside the workspace, while
131 /// untrusted external paths still error with `PathEscape`.
132 #[test]
133 fn test_tool_context_trusted_external_path_allows_escape() {
134 let workspace = tempdir().expect("workspace tempdir");
135 let trusted_root = tempdir().expect("trusted tempdir");
136 let trusted_file = trusted_root.path().join("notes.md");
137 std::fs::write(&trusted_file, "shared notes").unwrap();
138
139 let ctx = ToolContext::new(workspace.path().to_path_buf()).with_trusted_external_paths(vec![
140 trusted_root
141 .path()
142 .canonicalize()
143 .unwrap_or_else(|_| trusted_root.path().to_path_buf()),
144 ]);
145
146 let resolved = ctx
147 .resolve_path(trusted_file.to_str().unwrap())
148 .expect("trusted path should resolve");
149 assert!(resolved.ends_with("notes.md"));
150
151 // Path outside workspace AND outside the trust list should still fail.
152 let other = tempdir().expect("untrusted tempdir");
153 let other_file = other.path().join("secret.md");
154 std::fs::write(&other_file, "x").unwrap();
155 let err = ctx
156 .resolve_path(other_file.to_str().unwrap())
157 .expect_err("untrusted path must error");
158 assert!(matches!(err, ToolError::PathEscape { .. }));
159 }
160
161 #[test]
162 #[cfg(unix)]
163 fn test_tool_context_follow_symlinks_allows_nonexistent_path_under_workspace_symlink() {
164 let tmp = tempdir().expect("tempdir");
165 let workspace = tmp.path().join("workspace");
166 let outside = tmp.path().join("outside");
167 std::fs::create_dir_all(&workspace).expect("mkdir workspace");
168 std::fs::create_dir_all(outside.join("target")).expect("mkdir outside target");
169 symlink(outside.join("target"), workspace.join("linked")).expect("symlink");
170
171 let ctx = ToolContext::new(workspace).with_follow_symlinks(true);
172 let resolved = ctx
173 .resolve_path("linked/new.txt")
174 .expect("path under workspace symlink should resolve");
175
176 let expected = outside
177 .join("target")
178 .canonicalize()
179 .expect("canonical target")
180 .join("new.txt");
181 assert_eq!(resolved, normalize_path(&expected));
182 }
183
184 #[test]
185 #[cfg(unix)]
186 fn test_tool_context_default_mode_rejects_nonexistent_path_under_workspace_symlink() {
187 let tmp = tempdir().expect("tempdir");
188 let workspace = tmp.path().join("workspace");
189 let outside = tmp.path().join("outside");
190 std::fs::create_dir_all(&workspace).expect("mkdir workspace");
191 std::fs::create_dir_all(outside.join("target")).expect("mkdir outside target");
192 symlink(outside.join("target"), workspace.join("linked")).expect("symlink");
193
194 let ctx = ToolContext::new(workspace);
195 let err = ctx
196 .resolve_path("linked/new.txt")
197 .expect_err("default mode should still reject workspace symlink escapes");
198
199 assert!(matches!(err, ToolError::PathEscape { .. }));
200 }
201
202 fn scoped_authority(roots: &[&str], files: &[&str]) -> ToolAuthorityEnvelope {
203 ToolAuthorityEnvelope {
204 schema_version: 1,
205 owner: "fleet-worker-1".to_string(),
206 authority: ToolMutationAuthority::ScopedWrite,
207 network_access: None,
208 shell: ToolShellAuthority::None,
209 verification: ToolVerificationAuthority::None,
210 writable_roots: roots.iter().map(|value| (*value).to_string()).collect(),
211 writable_files: files.iter().map(|value| (*value).to_string()).collect(),
212 coordination_contracts: Vec::new(),
213 }
214 .normalized()
215 .expect("valid test authority")
216 }
217
218 #[test]
219 fn tool_authority_allows_normal_nonexistent_children_only_inside_scope() {
220 let tmp = tempdir().expect("tempdir");
221 std::fs::create_dir(tmp.path().join("src")).expect("src");
222 let context = ToolContext::new(tmp.path().to_path_buf());
223 let authority = scoped_authority(&["src"], &[]);
224
225 assert!(
226 authority
227 .permits_mutation_path(&context, "src/new/nested.rs")
228 .expect("normal nonexistent child")
229 );
230 assert!(
231 !authority
232 .permits_mutation_path(&context, "docs/outside.md")
233 .expect("ordinary out-of-scope path")
234 );
235 }
236
237 #[cfg(unix)]
238 #[test]
239 fn tool_authority_rejects_exact_file_symlink_aliases() {
240 let tmp = tempdir().expect("tempdir");
241 std::fs::create_dir(tmp.path().join("src")).expect("src");
242 std::fs::create_dir(tmp.path().join("other")).expect("other");
243 std::fs::write(tmp.path().join("other/target.rs"), "outside scope\n").expect("target");
244 symlink("../other/target.rs", tmp.path().join("src/alias.rs")).expect("alias");
245 let context = ToolContext::new(tmp.path().to_path_buf());
246 let authority = scoped_authority(&[], &["src/alias.rs"]);
247
248 let error = authority
249 .permits_mutation_path(&context, "src/alias.rs")
250 .expect_err("an exact-file claim must not authorize a symlink target")
251 .to_string();
252 assert!(error.contains("must not traverse symlinks"), "{error}");
253 }
254
255 #[cfg(unix)]
256 #[test]
257 fn tool_authority_rejects_claimed_root_and_child_symlink_aliases() {
258 let tmp = tempdir().expect("tempdir");
259 std::fs::create_dir(tmp.path().join("real")).expect("real");
260 symlink("real", tmp.path().join("linked")).expect("linked root");
261 let context = ToolContext::new(tmp.path().to_path_buf());
262 let claimed_alias = scoped_authority(&["linked"], &[]);
263 let claimed_real = scoped_authority(&["real"], &[]);
264
265 for (authority, path) in [
266 (&claimed_alias, "linked/new.rs"),
267 (&claimed_real, "linked/new.rs"),
268 ] {
269 let error = authority
270 .permits_mutation_path(&context, path)
271 .expect_err("symlinked roots and mutation paths must fail closed")
272 .to_string();
273 assert!(error.contains("must not traverse symlinks"), "{error}");
274 }
275 }
276
277 #[test]
278 fn nested_tool_authority_may_only_narrow_the_outer_cap() {
279 let tmp = tempdir().expect("tempdir");
280 let outer = scoped_authority(&["src"], &["Cargo.toml"]);
281 let narrower = scoped_authority(&["src/parser"], &[]);
282 let expansion = scoped_authority(&["docs"], &[]);
283 ToolContext::new(tmp.path().to_path_buf())
284 .with_tool_authority(outer.clone())
285 .unwrap()
286 .with_tool_authority(narrower)
287 .expect("nested scope may narrow");
288 let error = ToolContext::new(tmp.path().to_path_buf())
289 .with_tool_authority(outer.clone())
290 .unwrap()
291 .with_tool_authority(expansion)
292 .err()
293 .expect("nested scope expansion must fail closed");
294 assert!(error.contains("cannot expand"), "{error}");
295
296 let read_only = ToolAuthorityEnvelope {
297 schema_version: 1,
298 owner: "read-only-child".to_string(),
299 authority: ToolMutationAuthority::ReadOnly,
300 network_access: None,
301 shell: ToolShellAuthority::None,
302 verification: ToolVerificationAuthority::None,
303 writable_roots: Vec::new(),
304 writable_files: Vec::new(),
305 coordination_contracts: Vec::new(),
306 };
307 ToolContext::new(tmp.path().to_path_buf())
308 .with_tool_authority(outer.clone())
309 .unwrap()
310 .with_tool_authority(read_only)
311 .expect("read-only always narrows a write cap");
312
313 let shell_expansion = ToolAuthorityEnvelope {
314 schema_version: 1,
315 owner: "shell-expansion".to_string(),
316 authority: ToolMutationAuthority::ReadOnly,
317 network_access: None,
318 shell: ToolShellAuthority::ReadOnly,
319 verification: ToolVerificationAuthority::None,
320 writable_roots: Vec::new(),
321 writable_files: Vec::new(),
322 coordination_contracts: Vec::new(),
323 };
324 ToolContext::new(tmp.path().to_path_buf())
325 .with_tool_authority(outer)
326 .unwrap()
327 .with_tool_authority(shell_expansion)
328 .err()
329 .expect("nested authority cannot add a shell cap the outer process lacks");
330 }
331
332 #[test]
333 fn legacy_v1_authority_envelopes_default_to_shell_none() {
334 let authority = ToolAuthorityEnvelope::from_json(
335 r#"{"schema_version":1,"owner":"legacy-worker","authority":"read_only"}"#,
336 )
337 .expect("pre-shell v1 envelope remains readable");
338 assert_eq!(authority.shell, ToolShellAuthority::None);
339 assert_eq!(authority.verification, ToolVerificationAuthority::None);
340 }
341
342 #[test]
343 fn headless_fleet_registers_bash_only_when_the_clamped_ceiling_keeps_it() {
344 assert!(fleet_exec_shell_enabled(
345 true,
346 ToolShellAuthority::ReadOnly,
347 None
348 ));
349 assert!(!fleet_exec_shell_enabled(
350 true,
351 ToolShellAuthority::ReadOnly,
352 Some(&["ba*".into()])
353 ));
354 assert!(!fleet_exec_shell_enabled(
355 true,
356 ToolShellAuthority::None,
357 None
358 ));
359 }
360
361 #[test]
362 fn bounded_verification_is_typed_and_cannot_smuggle_bash_authority() {
363 let bounded = ToolAuthorityEnvelope::from_json(
364 r#"{"schema_version":1,"owner":"verifier","authority":"read_only","verification":"bounded"}"#,
365 )
366 .expect("bounded verifier authority");
367 assert_eq!(bounded.verification, ToolVerificationAuthority::Bounded);
368
369 let widened = ToolAuthorityEnvelope {
370 shell: ToolShellAuthority::ReadOnly,
371 ..bounded
372 };
373 assert!(
374 widened.normalized().is_err(),
375 "bounded verification and Bash authority are separate, non-composable caps"
376 );
377 }
378
379 #[test]
380 fn read_only_machine_authority_clamps_live_shell_policy() {
381 let tmp = tempdir().expect("tempdir");
382 let read_only = ToolAuthorityEnvelope {
383 schema_version: 1,
384 owner: "scout".to_string(),
385 authority: ToolMutationAuthority::ReadOnly,
386 network_access: Some(true),
387 shell: ToolShellAuthority::ReadOnly,
388 verification: ToolVerificationAuthority::None,
389 writable_roots: Vec::new(),
390 writable_files: Vec::new(),
391 coordination_contracts: Vec::new(),
392 };
393 let mut context = ToolContext::new(tmp.path().to_path_buf())
394 .with_tool_authority(read_only)
395 .expect("read-only authority");
396
397 assert_eq!(context.shell_policy, ShellPolicy::ReadOnly);
398 context.set_shell_policy(ShellPolicy::Full);
399 assert_eq!(
400 context.shell_policy,
401 ShellPolicy::ReadOnly,
402 "a live mode refresh must not widen the process authority cap"
403 );
404
405 let scoped = ToolContext::new(tmp.path().to_path_buf())
406 .with_tool_authority(scoped_authority(&["src"], &[]))
407 .expect("scoped authority")
408 .with_shell_policy(ShellPolicy::Full);
409 assert_eq!(scoped.shell_policy, ShellPolicy::None);
410 }
411
412 #[test]
413 fn process_tool_authority_inherits_into_all_context_constructors() {
414 const CHILD_ENV: &str = "CODEWHALE_TEST_PROCESS_TOOL_AUTHORITY_CHILD";
415 if std::env::var_os(CHILD_ENV).is_some() {
416 let tmp = tempdir().expect("tempdir");
417 install_process_tool_authority(ToolAuthorityEnvelope {
418 schema_version: 1,
419 owner: "fleet-worker-child-process".to_string(),
420 authority: ToolMutationAuthority::ReadOnly,
421 network_access: None,
422 shell: ToolShellAuthority::ReadOnly,
423 verification: ToolVerificationAuthority::None,
424 writable_roots: Vec::new(),
425 writable_files: Vec::new(),
426 coordination_contracts: Vec::new(),
427 })
428 .expect("install process authority once in isolated child");
429 let notes = tmp.path().join("notes.md");
430 let mcp = tmp.path().join("mcp.json");
431 let contexts = [
432 ToolContext::new(tmp.path().to_path_buf()),
433 ToolContext::with_options(tmp.path().to_path_buf(), false, notes.clone(), mcp.clone()),
434 ToolContext::with_auto_approve(tmp.path().to_path_buf(), false, notes, mcp, true),
435 ];
436 for context in contexts {
437 let authority = context
438 .tool_authority
439 .as_ref()
440 .expect("every constructor inherits process authority");
441 assert_eq!(authority.owner, "fleet-worker-child-process");
442 assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly);
443 assert_eq!(context.shell_policy, ShellPolicy::ReadOnly);
444 }
445 return;
446 }
447
448 let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
449 .arg("--exact")
450 .arg("tools::spec::tests::process_tool_authority_inherits_into_all_context_constructors")
451 .arg("--nocapture")
452 .env(CHILD_ENV, "1")
453 .output()
454 .expect("spawn isolated authority test child");
455 assert!(
456 output.status.success(),
457 "child failed:\nstdout:\n{}\nstderr:\n{}",
458 String::from_utf8_lossy(&output.stdout),
459 String::from_utf8_lossy(&output.stderr)
460 );
461 }
462
463 #[test]
464 fn test_required_str() {
465 let input = json!({"name": "test", "count": 42});
466 assert_eq!(required_str(&input, "name").unwrap(), "test");
467 assert!(required_str(&input, "missing").is_err());
468 assert!(required_str(&input, "count").is_err()); // not a string
469 }
470
471 #[test]
472 fn test_optional_str() {
473 let input = json!({"name": "test", "count": 7});
474 assert_eq!(optional_str(&input, "name").unwrap(), Some("test"));
475 assert_eq!(optional_str(&input, "missing").unwrap(), None);
476 // An explicit null is the wire spelling of "absent", not a type error.
477 assert_eq!(optional_str(&json!({"name": null}), "name").unwrap(), None);
478 let err = optional_str(&input, "count").expect_err("a number is not a string");
479 let err = err.to_string();
480 assert!(
481 err.contains("count") && err.contains("number") && err.contains("string"),
482 "{err}"
483 );
484 }
485
486 #[test]
487 fn test_required_u64() {
488 let input = json!({"count": 42});
489 assert_eq!(required_u64(&input, "count").unwrap(), 42);
490 assert!(required_u64(&input, "missing").is_err());
491 }
492
493 #[test]
494 fn test_optional_u64() {
495 let input = json!({"count": 42});
496 assert_eq!(optional_u64(&input, "count", 0).unwrap(), 42);
497 assert_eq!(optional_u64(&input, "missing", 100).unwrap(), 100);
498 assert_eq!(
499 optional_u64(&json!({"count": null}), "count", 9).unwrap(),
500 9
501 );
502 // A stringy number keeps its default today only because the harness
503 // never noticed; it must be an error instead.
504 for bad in [json!("42"), json!(-1), json!(2.5), json!([42])] {
505 let err = optional_u64(&json!({"count": bad}), "count", 100)
506 .expect_err("a non-integer must not fall back to the default")
507 .to_string();
508 assert!(
509 err.contains("count") && err.contains("non-negative integer"),
510 "{err}"
511 );
512 }
513 }
514
515 #[test]
516 fn test_optional_bool() {
517 let input = json!({"flag": true});
518 assert!(optional_bool(&input, "flag", false).unwrap());
519 assert!(!optional_bool(&input, "missing", false).unwrap());
520 assert!(optional_bool(&json!({"flag": null}), "flag", true).unwrap());
521 // The whole point: "true" must never become the default `false`.
522 for bad in [json!("true"), json!("false"), json!(1), json!(0), json!([])] {
523 let err = optional_bool(&json!({"flag": bad}), "flag", false)
524 .expect_err("a non-boolean must not fall back to the default")
525 .to_string();
526 assert!(err.contains("flag") && err.contains("boolean"), "{err}");
527 }
528 }
529
530 #[test]
531 fn test_tool_error_display() {
532 let err = ToolError::missing_field("path");
533 assert_eq!(
534 format!("{err}"),
535 "Failed to validate input: missing required field 'path'"
536 );
537
538 let err = ToolError::execution_failed("boom");
539 assert_eq!(format!("{err}"), "Failed to execute tool: boom");
540 }
541
542 #[test]
543 fn test_approval_requirement_default() {
544 let level = ApprovalRequirement::default();
545 assert_eq!(level, ApprovalRequirement::Auto);
546 }
547
548 #[test]
549 fn test_resolve_home_path_exact_prefixes() {
550 let fake_home = PathBuf::from("/fake/user/home");
551
552 // Exact ~ and ~/ prefixes resolve
553 assert_eq!(
554 resolve_home_path_with("~", || Some(fake_home.clone())).unwrap(),
555 Some(fake_home.clone())
556 );
557 assert_eq!(
558 resolve_home_path_with("~/", || Some(fake_home.clone())).unwrap(),
559 Some(fake_home.clone())
560 );
561 assert_eq!(
562 resolve_home_path_with("~//", || Some(fake_home.clone())).unwrap(),
563 Some(fake_home.clone())
564 );
565 assert_eq!(
566 resolve_home_path_with("~/file.txt", || Some(fake_home.clone())).unwrap(),
567 Some(fake_home.join("file.txt"))
568 );
569 assert_eq!(
570 resolve_home_path_with("~/a/b/c.md", || Some(fake_home.clone())).unwrap(),
571 Some(fake_home.join("a/b/c.md"))
572 );
573
574 #[cfg(windows)]
575 {
576 assert_eq!(
577 resolve_home_path_with(r"~\", || Some(fake_home.clone())).unwrap(),
578 Some(fake_home.clone())
579 );
580 assert_eq!(
581 resolve_home_path_with(r"~\file.txt", || Some(fake_home.clone())).unwrap(),
582 Some(fake_home.join("file.txt"))
583 );
584 }
585
586 // Must NOT expand ~otheruser, shell variables, command substitutions, globs, or literals
587 assert_eq!(
588 resolve_home_path_with("~otheruser", || Some(fake_home.clone())).unwrap(),
589 None
590 );
591 assert_eq!(
592 resolve_home_path_with("~otheruser/file", || Some(fake_home.clone())).unwrap(),
593 None
594 );
595 assert_eq!(
596 resolve_home_path_with("./~/file", || Some(fake_home.clone())).unwrap(),
597 None
598 );
599 assert_eq!(
600 resolve_home_path_with("$HOME/file", || Some(fake_home.clone())).unwrap(),
601 None
602 );
603 assert_eq!(
604 resolve_home_path_with("`whoami`/file", || Some(fake_home.clone())).unwrap(),
605 None
606 );
607 assert_eq!(
608 resolve_home_path_with("~*", || Some(fake_home.clone())).unwrap(),
609 None
610 );
611 assert_eq!(
612 resolve_home_path_with("regular/path", || Some(fake_home.clone())).unwrap(),
613 None
614 );
615 assert_eq!(
616 resolve_home_path_with("/absolute/path", || Some(fake_home.clone())).unwrap(),
617 None
618 );
619 }
620
621 #[test]
622 fn test_resolve_home_path_unknown_home_fails_explicitly_without_cwd_guessing() {
623 let err = resolve_home_path_with("~", || None).expect_err("unknown home must fail explicitly");
624 let msg = err.to_string();
625 assert!(
626 msg.contains("user home directory could not be determined"),
627 "error message must be explicit: {msg}"
628 );
629
630 let err = resolve_home_path_with("~/nested/file.txt", || None)
631 .expect_err("unknown home must fail explicitly");
632 let msg = err.to_string();
633 assert!(
634 msg.contains("user home directory could not be determined"),
635 "error message must be explicit: {msg}"
636 );
637 }
638
639 #[test]
640 fn test_tool_context_resolve_path_home_prefix_inside_workspace() {
641 // The fixture lives inside the home, so the home must be the test's own.
642 let _sealed = crate::test_support::SealedHome::new();
643 let real_home = crate::config::effective_home_dir().expect("test home must be available");
644 let home_temp = tempfile::Builder::new()
645 .prefix("cw_spec_test_home_")
646 .tempdir_in(&real_home)
647 .expect("create fixture inside test home");
648
649 let test_file = home_temp.path().join("inside.txt");
650 std::fs::write(&test_file, "inside workspace").expect("write test file");
651
652 let rel = test_file
653 .strip_prefix(&real_home)
654 .expect("fixture is below test home");
655 let tilde_path = format!("~/{}", rel.to_string_lossy());
656
657 let ctx = ToolContext::new(home_temp.path().to_path_buf());
658 let resolved = ctx
659 .resolve_path(&tilde_path)
660 .expect("home path inside workspace should resolve");
661
662 let expected = test_file
663 .canonicalize()
664 .unwrap_or_else(|_| normalize_path(&test_file));
665 assert_eq!(resolved, expected);
666 }
667
668 #[test]
669 fn test_tool_context_resolve_path_home_prefix_restricted_refusal() {
670 let workspace = tempdir().expect("workspace tempdir");
671 let ctx = ToolContext::new(workspace.path().to_path_buf());
672
673 // A home path outside workspace without trusted external path or trust mode must be refused
674 let err = ctx
675 .resolve_path("~/some_untrusted_file_never_present_12345.txt")
676 .expect_err("home path outside workspace must error");
677 assert!(matches!(err, ToolError::PathEscape { .. }));
678 }
679
680 #[test]
681 fn test_tool_context_resolve_path_home_prefix_trusted_external_path() {
682 // The fixture lives inside the home, so the home must be the test's own.
683 let _sealed = crate::test_support::SealedHome::new();
684 let real_home = crate::config::effective_home_dir().expect("test home must be available");
685 let trusted_dir = tempfile::Builder::new()
686 .prefix("cw_spec_trusted_home_")
687 .tempdir_in(&real_home)
688 .expect("create fixture inside test home");
689 let trusted_file = trusted_dir.path().join("shared.md");
690 std::fs::write(&trusted_file, "shared content").expect("write trusted file");
691
692 let rel = trusted_file
693 .strip_prefix(&real_home)
694 .expect("fixture is below test home");
695 let tilde_path = format!("~/{}", rel.to_string_lossy());
696
697 let workspace = tempdir().expect("workspace tempdir");
698 let canonical_trusted = trusted_dir
699 .path()
700 .canonicalize()
701 .unwrap_or_else(|_| trusted_dir.path().to_path_buf());
702 let ctx = ToolContext::new(workspace.path().to_path_buf())
703 .with_trusted_external_paths(vec![canonical_trusted]);
704
705 let resolved = ctx
706 .resolve_path(&tilde_path)
707 .expect("trusted external home path should resolve");
708 assert_eq!(resolved, trusted_file.canonicalize().unwrap());
709 }
710
711 #[test]
712 fn test_tool_context_resolve_path_literal_tilde_in_workspace() {
713 let workspace = tempdir().expect("workspace tempdir");
714 let literal_tilde = workspace.path().join("~");
715 std::fs::create_dir_all(&literal_tilde).expect("create literal ~ dir");
716 let literal_file = literal_tilde.join("nested.txt");
717 std::fs::write(&literal_file, "literal tilde data").expect("write literal");
718
719 let ctx = ToolContext::new(workspace.path().to_path_buf());
720 let resolved = ctx
721 .resolve_path("./~/nested.txt")
722 .expect("literal ./~/ path must resolve inside workspace");
723 assert_eq!(resolved, literal_file.canonicalize().unwrap());
724 }
725
726 #[test]
727 fn test_tool_context_resolve_path_no_shell_expansion() {
728 let workspace = tempdir().expect("workspace tempdir");
729 let ctx = ToolContext::new(workspace.path().to_path_buf());
730
731 // $HOME/file should NOT expand shell env var, but be treated relative to workspace
732 let resolved = ctx
733 .resolve_path("$HOME/file.txt")
734 .expect("should treat as workspace child");
735 assert!(
736 resolved.starts_with(
737 workspace
738 .path()
739 .canonicalize()
740 .unwrap_or_else(|_| workspace.path().to_path_buf())
741 )
742 );
743 assert!(resolved.to_string_lossy().contains("$HOME"));
744
745 // ~otheruser should NOT expand other user home, but be treated relative to workspace
746 let resolved_other = ctx
747 .resolve_path("~otheruser/file.txt")
748 .expect("should treat as workspace child");
749 assert!(resolved_other.to_string_lossy().contains("~otheruser"));
750 }
751
752 #[test]
753 fn delegated_shell_is_refused_outright_when_the_session_lacks_it() {
754 let workspace = tempfile::tempdir().expect("workspace");
755 let mut ctx = ToolContext::new(workspace.path());
756
757 // Leaving the flag out would fall back to the host default, which may
758 // allow shell: a session without full shell must store an explicit "no".
759 ctx.shell_policy = crate::worker_profile::ShellPolicy::None;
760 assert_eq!(ctx.cap_delegated_authority(None, None, None).0, Some(false));
761 assert_eq!(
762 ctx.cap_delegated_authority(Some(true), None, None).0,
763 Some(false)
764 );
765
766 ctx.shell_policy = crate::worker_profile::ShellPolicy::Full;
767 assert_eq!(ctx.cap_delegated_authority(None, None, None).0, None);
768 assert_eq!(
769 ctx.cap_delegated_authority(Some(true), None, None).0,
770 Some(true)
771 );
772 }
773
773 lines RUST