fix(acp): reject a relative cwd when a session starts

ACP requires cwd and every additionalDirectories entry to be absolute. session/new, session/load and session/fork took whatever they were given, and the later set_current_dir resolved a relative path against the directory the editor spawned the process in. All three now return -32602 before any session state is created. Fixes #136 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 4, 2026 at 23:59 UTC a3613f888372b0cdb4fc7c6510544024d7d76872
2 files changed +62
CHANGELOG.md
+8
@@ -9,6 +9,14 @@
9 the wire. The `unstable_auth_methods` feature is gone because agent auth
10 methods are stable in 2.x.
11
12 +### Fixed
13 +
14 +- **A relative `cwd` is rejected when a session starts.** ACP requires the
15 + working directory and every `additionalDirectories` entry to be absolute.
16 + `session/new`, `session/load` and `session/fork` used to accept a relative
17 + one and resolve it against wherever the editor spawned the process. They now
18 + answer with an invalid-params error (#136).
19 +
20 ## 1.5.13
21
22 ### Added
src/main.rs
+54
@@ -1472,6 +1472,33 @@ impl SiGitAgent {
1472 }
1473 }
1474
1475 +/// Reject session roots that are not absolute paths.
1476 +///
1477 +/// ACP requires `cwd` and every `additionalDirectories` entry to be absolute.
1478 +/// A relative one would otherwise be resolved against wherever the editor
1479 +/// happened to spawn this process, which is not the project the user opened.
1480 +fn validate_session_roots(
1481 + cwd: &std::path::Path,
1482 + additional: &[PathBuf],
1483 +) -> agent_client_protocol::Result<()> {
1484 + if !cwd.is_absolute() {
1485 + return Err(agent_client_protocol::Error::new(
1486 + -32602,
1487 + format!("cwd must be an absolute path, got {}", cwd.display()),
1488 + ));
1489 + }
1490 + if let Some(relative) = additional.iter().find(|dir| !dir.is_absolute()) {
1491 + return Err(agent_client_protocol::Error::new(
1492 + -32602,
1493 + format!(
1494 + "additionalDirectories entries must be absolute paths, got {}",
1495 + relative.display()
1496 + ),
1497 + ));
1498 + }
1499 + Ok(())
1500 +}
1501 +
1502 // ── ACP handler implementations ───────────────────────────────────────────────
1503
1504 impl SiGitAgent {
@@ -1849,6 +1876,8 @@ impl SiGitAgent {
1876 cx: &ConnectionTo<Client>,
1877 args: LoadSessionRequest,
1878 ) -> agent_client_protocol::Result<LoadSessionResponse> {
1879 + validate_session_roots(&args.cwd, &args.additional_directories)?;
1880 +
1881 log::info!(
1882 "load_session: id={}, cwd={}, additional_directories={:?}",
1883 args.session_id,
@@ -1919,6 +1948,8 @@ impl SiGitAgent {
1948 cx: &ConnectionTo<Client>,
1949 args: ForkSessionRequest,
1950 ) -> agent_client_protocol::Result<ForkSessionResponse> {
1951 + validate_session_roots(&args.cwd, &args.additional_directories)?;
1952 +
1953 let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1954 log::info!(
1955 "fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
@@ -1986,6 +2017,8 @@ impl SiGitAgent {
2017 cx: &ConnectionTo<Client>,
2018 args: NewSessionRequest,
2019 ) -> agent_client_protocol::Result<NewSessionResponse> {
2020 + validate_session_roots(&args.cwd, &args.additional_directories)?;
2021 +
2022 let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
2023 log::info!(
2024 "new_session: id={session_id}, cwd={}, additional_directories={:?}",
@@ -5493,6 +5526,27 @@ mod tests {
5526 assert_eq!(longest_backtick_run("one ` two `` three ``` four"), 3);
5527 }
5528
5529 + #[test]
5530 + fn session_roots_must_be_absolute() {
5531 + let absolute = std::env::temp_dir();
5532 + let relative = PathBuf::from("relative").join("dir");
5533 +
5534 + assert!(validate_session_roots(&absolute, &[]).is_ok());
5535 + assert!(validate_session_roots(&absolute, std::slice::from_ref(&absolute)).is_ok());
5536 +
5537 + let error = validate_session_roots(&relative, &[]).unwrap_err();
5538 + assert_eq!(i32::from(error.code), -32602);
5539 + assert!(error.message.contains("cwd"), "{}", error.message);
5540 +
5541 + let error = validate_session_roots(&absolute, &[absolute.clone(), relative]).unwrap_err();
5542 + assert_eq!(i32::from(error.code), -32602);
5543 + assert!(
5544 + error.message.contains("additionalDirectories"),
5545 + "{}",
5546 + error.message
5547 + );
5548 + }
5549 +
5550 #[test]
5551 fn tool_call_locations_require_a_known_tool_and_string_path() {
5552 assert!(tool_call_locations("read_file", "{not json").is_empty());