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
17 changes: 15 additions & 2 deletions crates/xgen-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,8 @@ struct InteractiveHost {
executable_specs: Vec<String>,
executable_ids: Vec<String>,
process_session: Option<LocalProcessSession>,
/// ADR-0063: where the file tools stop and how to reach the rest, appended to trusted goals.
environment_note: Option<String>,
}

impl InteractiveHost {
Expand All @@ -557,16 +559,20 @@ 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
&& let Some((id, path)) = repl::discover_shell_executable()
&& 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()
Expand All @@ -579,6 +585,7 @@ impl InteractiveHost {
executable_specs,
executable_ids,
process_session: None,
environment_note,
})
}

Expand Down Expand Up @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions crates/xgen-cli/src/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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}\", \"<command>\"].{home}"
))
}

pub(crate) fn discover_shell_executable() -> Option<(String, PathBuf)> {
let path = env::var_os("PATH").unwrap_or_default();
#[cfg(windows)]
Expand Down Expand Up @@ -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", "<command>"]"#));
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", "<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() {
Expand Down
14 changes: 14 additions & 0 deletions crates/xgen-cli/tests/interactive_repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
62 changes: 62 additions & 0 deletions docs/adr/0063-trusted-environment-note.md
Original file line number Diff line number Diff line change
@@ -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 `<shell>` executable with arguments ["-c", "<command>"]. The user's home
directory (~) is "<home>".
```

- 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번 되풀이했으므로 이 반복은 안내와 별개의 문제로 남긴다.
2 changes: 2 additions & 0 deletions docs/development/interactive-repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> 프로젝트 구조를 보고 테스트 실패를 수정해줘.
Expand Down