From 34ac0ac446eba0222ff05704ea01302299c23e09 Mon Sep 17 00:00:00 2001 From: Son Seong Jun <166786347+SonAIengine@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:07:05 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=A0=EB=A2=B0=20=EB=AA=A8=EB=93=9C=20goal?= =?UTF-8?q?=EC=97=90=20workspace=20=EA=B2=BD=EA=B3=84=EC=99=80=20shell=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=95=88=EB=82=B4=EB=A5=BC=20=EB=B6=99?= =?UTF-8?q?=EC=98=80=EB=8B=A4=20(ADR-0063)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 파일 capability는 workspace 상대 경로만 받는데 모델은 workspace가 어디인지 몰라서, ~/projects/app 같은 밖의 경로를 workspace 안 경로로 해석해 not_found를 되풀이했다. 24 turn 2회는 shell을 한 번도 안 썼고, 96 turn 1회는 call 96개 중 93개가 같은 list-directory였다. - 신뢰 모드에서 shell을 catalog에 넣을 때 goal 뒤에 workspace 절대 경로, home, workspace 밖은 shell(-c 또는 -Command)로 실행하라는 안내 한 단락을 붙인다. - 경로는 JSON 문자열로 인용하고, control character가 있거나 goal 상한을 넘으면 붙이지 않는다. - 저장소 조회(설계): shell 사용 0/3 -> 2/2, 답 0/3 -> 1/2(16/24, worktree 누락). - 검증용 /etc/os-release 1/2 -> 2/2, ~/.config 항목 수 2/2 -> 2/2. - 같은 명령을 96번 되풀이한 1회는 안내 전에도 있던 반복 문제라 별도로 다룬다. Co-Authored-By: Claude Opus 5.5 --- crates/xgen-cli/src/main.rs | 17 ++++++- crates/xgen-cli/src/repl.rs | 53 +++++++++++++++++++ crates/xgen-cli/tests/interactive_repl.rs | 14 +++++ docs/adr/0063-trusted-environment-note.md | 62 +++++++++++++++++++++++ docs/development/interactive-repl.md | 2 + 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0063-trusted-environment-note.md diff --git a/crates/xgen-cli/src/main.rs b/crates/xgen-cli/src/main.rs index 2b6f1a7..4224921 100644 --- a/crates/xgen-cli/src/main.rs +++ b/crates/xgen-cli/src/main.rs @@ -546,6 +546,8 @@ struct InteractiveHost { executable_specs: Vec, executable_ids: Vec, process_session: Option, + /// ADR-0063: where the file tools stop and how to reach the rest, appended to trusted goals. + environment_note: Option, } impl InteractiveHost { @@ -557,6 +559,7 @@ impl InteractiveHost { let workspace = env::current_dir() .map_err(|_| repl::ReplFailure::new(PublicRunError::Configuration.code()))?; let mut executables = discovered_executables(); + let mut environment_note = None; // ADR-0060: a trusted session also gets the user's shell, so pipelines and any // installed command run through one catalogued executable. if trust @@ -564,9 +567,12 @@ impl InteractiveHost { && let Some(path) = path .to_str() .filter(|path| !path.chars().any(char::is_control)) - && !executables.iter().any(|(existing, _)| *existing == id) { - executables.push((id, path.to_owned())); + environment_note = + repl::trusted_environment_note(&workspace, env::home_dir().as_deref(), &id); + if !executables.iter().any(|(existing, _)| *existing == id) { + executables.push((id, path.to_owned())); + } } let (executable_ids, executable_specs) = executables .into_iter() @@ -579,6 +585,7 @@ impl InteractiveHost { executable_specs, executable_ids, process_session: None, + environment_note, }) } @@ -653,6 +660,12 @@ impl repl::ReplHost for InteractiveHost { let model = resolve_model(None, None, None, None, false, RequestOptionArgs::default()) .map_err(|error| repl::ReplFailure::new(error.code()))?; let process_session = self.process_session()?; + let goal = match &self.environment_note { + Some(note) if goal.len() + note.len() + 2 <= xgen_cli::MAX_GOAL_BYTES => { + format!("{goal}\n\n{note}") + } + _ => goal, + }; run_interactive_with_process_session_progress( LocalRunRequest { goal, diff --git a/crates/xgen-cli/src/repl.rs b/crates/xgen-cli/src/repl.rs index d421d75..ee1652b 100644 --- a/crates/xgen-cli/src/repl.rs +++ b/crates/xgen-cli/src/repl.rs @@ -1855,6 +1855,39 @@ const SHELL_EXECUTABLES: &[&str] = &["bash", "zsh", "sh"]; const SHELL_EXECUTABLES: &[&str] = &["pwsh", "powershell"]; /// The user's own shell when `SHELL` names a known one, otherwise the first known shell on PATH. +/// Host note for trusted goals (ADR-0063): file tools take workspace-relative paths only, so a +/// model told about `~/x` or `/etc` otherwise retries `not_found` instead of using the shell. +/// +/// Returns `None` when a path is not plain UTF-8 text the note can quote safely. +pub(crate) fn trusted_environment_note( + workspace: &Path, + home: Option<&Path>, + shell_id: &str, +) -> Option { + let quoted = |path: &Path| { + path.to_str() + .filter(|text| !text.chars().any(char::is_control)) + .map(|text| serde_json::to_string(text).expect("text serializes")) + }; + let workspace = quoted(workspace)?; + let command_flag = if matches!(shell_id, "pwsh" | "powershell") { + "-Command" + } else { + "-c" + }; + let home = home + .and_then(quoted) + .map(|home| format!(" The user's home directory (~) is {home}.")) + .unwrap_or_default(); + Some(format!( + "Host environment (trusted local session): the workspace root is {workspace}. The \ + xgeny.fs capabilities accept only paths relative to it and cannot reach anything \ + outside it; an absolute path, a ~ path, or a path leaving the workspace fails with \ + not_found there. For locations outside the workspace, run the `{shell_id}` executable \ + with arguments [\"{command_flag}\", \"\"].{home}" + )) +} + pub(crate) fn discover_shell_executable() -> Option<(String, PathBuf)> { let path = env::var_os("PATH").unwrap_or_default(); #[cfg(windows)] @@ -2447,6 +2480,26 @@ mod tests { assert_eq!(output.matches("Allow execute").count(), 1); } + #[test] + fn trusted_environment_note_names_the_boundary_and_the_shell_route() { + let note = + trusted_environment_note(Path::new("/work/repo"), Some(Path::new("/home/me")), "bash") + .unwrap(); + assert!(note.contains(r#"workspace root is "/work/repo""#), "{note}"); + assert!(note.contains("not_found"), "{note}"); + assert!(note.contains(r#"`bash` executable with arguments ["-c", ""]"#)); + assert!( + note.contains(r#"home directory (~) is "/home/me""#), + "{note}" + ); + + let note = trusted_environment_note(Path::new("/work/repo"), None, "pwsh").unwrap(); + assert!(note.contains(r#"["-Command", ""]"#), "{note}"); + assert!(!note.contains("home directory")); + + assert!(trusted_environment_note(Path::new("/work/re\npo"), None, "bash").is_none()); + } + #[cfg(unix)] #[test] fn shell_selection_prefers_a_known_login_shell_and_falls_back_to_path() { diff --git a/crates/xgen-cli/tests/interactive_repl.rs b/crates/xgen-cli/tests/interactive_repl.rs index 8eb7862..986ac49 100644 --- a/crates/xgen-cli/tests/interactive_repl.rs +++ b/crates/xgen-cli/tests/interactive_repl.rs @@ -551,6 +551,20 @@ fn trust_flag_runs_a_tool_goal_without_any_approval_prompt() { .any(|id| matches!(id, "bash" | "zsh" | "sh"))), "trust mode should catalogue a shell: {stdout}" ); + // ADR-0063: the trusted goal carries the workspace boundary and the shell route. + #[cfg(unix)] + { + let request = server.requests.recv_timeout(TEST_TIMEOUT).unwrap(); + let goal = planning_context(&request)["goal"] + .as_str() + .unwrap() + .to_owned(); + assert!(goal.starts_with("Read README.md and report it."), "{goal}"); + assert!( + goal.contains("Host environment (trusted local session): the workspace root is"), + "{goal}" + ); + } assert!(stdout.contains("status: completed"), "{stdout}"); let run_id = extract_run_id(&stderr(&output)); let store = diff --git a/docs/adr/0063-trusted-environment-note.md b/docs/adr/0063-trusted-environment-note.md new file mode 100644 index 0000000..6d455b1 --- /dev/null +++ b/docs/adr/0063-trusted-environment-note.md @@ -0,0 +1,62 @@ +# ADR-0063: 신뢰 모드 goal에 workspace 경계 안내 붙이기 + +- 상태: Accepted +- 기준일: 2026-10-26 +- 변경 분류: 범용 REPL host 안내. 특정 경로·명령·업무 규칙 없음. +- 기존 경계: ADR-0059(신뢰 모드), ADR-0060(신뢰 모드 shell) +- protocol·SQLite schema·journal·planner prompt 변경: 없음. 안내는 goal 텍스트 뒤에 붙는다. + +## 문제 + +`xgeny.fs/*` capability는 workspace 상대 경로만 받는다. 모델은 workspace가 어디인지, 그 밖은 shell로 +가야 한다는 것을 모른다. 그래서 goal이 `~/projects/app`처럼 workspace 밖을 가리키면 그 경로를 workspace +안의 상대 경로로 해석해 `not_found`를 받고 같은 시도를 되풀이했다. + +Claude Code 비교 실험의 저장소 상태 조회 작업(`~/projects/app` 아래 저장소 24개)에서: + +- 24 turn 예산 2회: shell을 한 번도 쓰지 않았다. `list-directory` 24회 중 17회가 `not_found`였다. +- 96 turn 예산 1회(ADR-0062 뒤): call 96개 중 93개가 같은 `list-directory projects`였다. + +## 결정 + +신뢰 모드 REPL은 shell을 catalog에 넣을 때 goal 뒤에 host 안내 한 단락을 붙인다. + +```text +Host environment (trusted local session): the workspace root is "<절대 경로>". The xgeny.fs +capabilities accept only paths relative to it and cannot reach anything outside it; an absolute +path, a ~ path, or a path leaving the workspace fails with not_found there. For locations outside +the workspace, run the `` executable with arguments ["-c", ""]. The user's home +directory (~) is "". +``` + +- shell 인자는 bash·zsh·sh면 `-c`, pwsh·powershell이면 `-Command`다. +- 경로는 JSON 문자열로 인용한다. UTF-8이 아니거나 control character가 있으면 안내를 붙이지 않는다. + home을 모르면 home 문장만 뺀다. +- goal과 안내를 합쳐 `MAX_GOAL_BYTES`를 넘으면 붙이지 않는다. +- 승인 모드에는 붙이지 않는다. shell이 catalog에 없고, workspace 절대 경로를 모델에 보내는 것은 사람이 + 단계마다 보는 모드의 기본값으로 두지 않는다. +- 안내는 goal의 일부라 Run manifest·journal에 goal과 함께 남는다. 별도 저장은 없다. + +## 대안 + +- **planner system prompt에 넣기:** 모든 provider·모드·headless Run에 영향을 준다. 이 안내는 신뢰 모드의 + host 사실이라 host가 붙인다. +- **filesystem capability가 workspace 밖을 읽게 하기:** capability 계약과 scope 검사를 바꿔야 한다. + shell이 이미 있으므로 안내로 충분한지 먼저 본다. +- **`not_found` 결과에 안내 추가:** tool output 계약 변경이다. 실패를 본 뒤에야 알게 된다. + +## 검증 + +- 단위: 안내에 workspace 인용, `not_found`, shell별 인자, home이 들어가고 control character 경로는 + 안내를 만들지 않는다. +- 실사용(deepseek flash, `--trust`, 96 turn): + + | 작업 | 안내 없음 | 안내 있음 | + | --- | --- | --- | + | 저장소 상태 조회 `~/projects/app` (설계) | shell 사용 0/3, 답 0/3 | shell 사용 2/2(첫 step부터), 답 1/2 | + | `/etc/os-release` 이름·버전 (검증용) | 1/2 (1회는 절대 경로를 파일 도구로 보내 `invocation_invalid` 3연속) | 2/2 | + | `~/.config` 항목 수 (검증용) | 2/2 | 2/2 | + + 저장소 조회의 답한 1회는 16개를 맞혔고 `.git`이 파일인 worktree 8개를 빠뜨렸다. 답하지 못한 1회는 + 첫 명령에서 같은 결과를 받고도 비슷한 명령을 96번 되풀이했다. 안내 전에도 같은 `list-directory`를 + 93번 되풀이했으므로 이 반복은 안내와 별개의 문제로 남긴다. diff --git a/docs/development/interactive-repl.md b/docs/development/interactive-repl.md index 12c6f7e..decc132 100644 --- a/docs/development/interactive-repl.md +++ b/docs/development/interactive-repl.md @@ -33,6 +33,8 @@ pwsh→powershell)도 catalog에 넣는다([ADR-0060](../adr/0060-trusted-shell- 파이프와 catalog에 없는 명령을 실행한다. 신뢰 모드의 goal당 model turn 기본값은 24가 아니라 96이다([ADR-0062](../adr/0062-trusted-turn-budget.md)). `--max-model-turns`를 주면 그 값이 우선한다. +신뢰 모드는 goal 뒤에 workspace 절대 경로, home, workspace 밖은 shell로 가라는 안내 한 단락을 붙인다 +([ADR-0063](../adr/0063-trusted-environment-note.md)). ```text xgen> 프로젝트 구조를 보고 테스트 실패를 수정해줘.