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
22 changes: 19 additions & 3 deletions crates/xgen-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,10 +531,26 @@ struct InteractiveHost {
}

impl InteractiveHost {
fn new(detailed_progress: bool, max_model_turns: u32) -> Result<Self, repl::ReplFailure> {
fn new(
detailed_progress: bool,
max_model_turns: u32,
trust: bool,
) -> Result<Self, repl::ReplFailure> {
let workspace = env::current_dir()
.map_err(|_| repl::ReplFailure::new(PublicRunError::Configuration.code()))?;
let (executable_ids, executable_specs) = discovered_executables()
let mut executables = discovered_executables();
// 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()));
}
let (executable_ids, executable_specs) = executables
.into_iter()
.map(|(id, path)| (id.clone(), format!("{id}={path}")))
.unzip();
Expand Down Expand Up @@ -727,7 +743,7 @@ fn interactive_command(debug: bool, max_model_turns: u32, trust: bool) -> ExitCo
eprintln!("XGEN_ERROR code={}", PublicRunError::Internal.code());
return ExitCode::from(PublicRunError::Internal.exit_code());
}
let Ok(mut host) = InteractiveHost::new(!terminal || debug, max_model_turns) else {
let Ok(mut host) = InteractiveHost::new(!terminal || debug, max_model_turns, trust) else {
eprintln!("XGEN_ERROR code={}", PublicRunError::Configuration.code());
return ExitCode::from(PublicRunError::Configuration.exit_code());
};
Expand Down
68 changes: 68 additions & 0 deletions crates/xgen-cli/src/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,7 @@ pub(crate) fn run_with_display<R: BufRead, W: Write + Send, H: ReplHost>(
output,
"trust mode: model/read/write/execute run without asking (/permissions KIND ask to restore)"
)?;
print_executables(output, host.executable_ids())?;
PermissionSettings::trusted()
} else {
PermissionSettings::default()
Expand Down Expand Up @@ -1847,6 +1848,39 @@ fn write_terminal_text(output: &mut impl Write, value: &str) -> io::Result<()> {
Ok(())
}

/// Shells trust mode may catalogue (ADR-0060), so one approved call can run a pipeline.
#[cfg(not(windows))]
const SHELL_EXECUTABLES: &[&str] = &["bash", "zsh", "sh"];
#[cfg(windows)]
const SHELL_EXECUTABLES: &[&str] = &["pwsh", "powershell"];

/// The user's own shell when `SHELL` names a known one, otherwise the first known shell on PATH.
pub(crate) fn discover_shell_executable() -> Option<(String, PathBuf)> {
let path = env::var_os("PATH").unwrap_or_default();
#[cfg(windows)]
let extensions = windows_executable_extensions();
#[cfg(not(windows))]
let extensions = vec![String::new()];
select_shell_executable(env::var_os("SHELL").map(PathBuf::from), &path, &extensions)
}

fn select_shell_executable(
login_shell: Option<PathBuf>,
search_path: &std::ffi::OsStr,
extensions: &[String],
) -> Option<(String, PathBuf)> {
let login = login_shell.filter(|shell| shell.is_absolute() && is_executable_file(shell));
if let Some(shell) = login
&& let Some(id) = shell.file_name().and_then(|name| name.to_str())
&& SHELL_EXECUTABLES.contains(&id)
{
return Some((id.to_owned(), shell));
}
discover_developer_executables_in(search_path, extensions, SHELL_EXECUTABLES)
.into_iter()
.next()
}

pub(crate) fn discover_developer_executables() -> Vec<(String, PathBuf)> {
let Some(path) = env::var_os("PATH") else {
return Vec::new();
Expand Down Expand Up @@ -2413,6 +2447,40 @@ mod tests {
assert_eq!(output.matches("Allow execute").count(), 1);
}

#[cfg(unix)]
#[test]
fn shell_selection_prefers_a_known_login_shell_and_falls_back_to_path() {
use std::os::unix::fs::PermissionsExt as _;
let directory = tempdir().unwrap();
let make = |name: &str| {
let path = directory.path().join(name);
fs::write(&path, "").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
path
};
let zsh = make("zsh");
let bash = make("bash");
let fish = make("fish");
let search = directory.path().as_os_str();
let none = [String::new()];
assert_eq!(
select_shell_executable(Some(zsh.clone()), search, &none),
Some(("zsh".to_owned(), zsh))
);
assert_eq!(
select_shell_executable(Some(fish), search, &none),
Some(("bash".to_owned(), bash.clone()))
);
assert_eq!(
select_shell_executable(Some(PathBuf::from("bash")), search, &none),
Some(("bash".to_owned(), bash))
);
assert_eq!(
select_shell_executable(None, std::ffi::OsStr::new(""), &none),
None
);
}

#[test]
fn trust_mode_starts_with_every_kind_allowed_and_can_be_narrowed() {
let first = "run-11111111111111111111111111111111".to_owned();
Expand Down
8 changes: 8 additions & 0 deletions crates/xgen-cli/tests/interactive_repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,14 @@ fn trust_flag_runs_a_tool_goal_without_any_approval_prompt() {
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("trust mode: model/read/write/execute run without asking"));
assert!(!stdout.contains("[y/a/N]"), "{stdout}");
#[cfg(unix)]
assert!(
stdout.lines().any(|line| line.starts_with("executables:")
&& line
.split_whitespace()
.any(|id| matches!(id, "bash" | "zsh" | "sh"))),
"trust mode should catalogue a shell: {stdout}"
);
assert!(stdout.contains("status: completed"), "{stdout}");
let run_id = extract_run_id(&stderr(&output));
let store =
Expand Down
3 changes: 3 additions & 0 deletions docs/adr/0028-shell-free-process-execute.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
- 공개 protocol v0.1 schema 변경: `xgeny.process/execute@1.0.0` Definition 추가
- local store schema 변경: 없음(schema 8 유지)

> 신뢰 모드에서 사용자 shell을 catalog에 넣는 예외는 [ADR-0060](0060-trusted-shell-executable.md)이 정한다.
> adapter는 여전히 shell 없이 argv로 실행하며, shell은 catalog의 한 실행 파일일 뿐이다.

## 문맥

Workspace 탐색·읽기·쓰기·패치만으로는 실제 개발 작업을 완결할 수 없다. 에이전트가 변경 뒤 test,
Expand Down
63 changes: 63 additions & 0 deletions docs/adr/0060-trusted-shell-executable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# ADR-0060: 신뢰 모드의 shell catalog

- 상태: Accepted
- 기준일: 2026-10-26
- 변경 분류: 범용 실행 파일 catalog 정책. 특정 명령·업무 규칙 없음.
- 기존 경계: ADR-0028(shell 없는 process 실행), ADR-0033(개발 도구 allowlist), ADR-0059(신뢰 모드)
- Capability 계약·protocol·SQLite schema·journal 변경: 없음.

## 문제

REPL의 catalog는 PATH에서 찾은 개발 도구 25종 고정 목록이다. 개인 작업(디스크·메모리 확인, 프로세스
조회, HTTP 요청, 파일 집계)에 필요한 `df`, `ps`, `curl`, `find` 같은 명령은 없고 파이프도 쓸 수 없다.
세션 중 `/allow-executable bash=/bin/bash`로 shell을 넣으면 되지만 매번 입력해야 한다.

먼저 이 수동 방식으로 확인했다(main cf60d42, `--trust`, deepseek). 디스크·메모리 요약, 디렉터리 집계,
`curl` 요청 3개 모두 모델이 스스로 `bash -c`로 `df -h`, `find ... | wc -l`, `curl -sS ...`를 실행해
끝냈다. 막힌 것은 shell을 직접 등록해야 한다는 점뿐이었다.

## 결정

`xgen --trust` session은 시작할 때 사용자 shell 하나를 catalog에 더한다.

- 선택: `SHELL`이 절대 경로의 실행 파일이고 이름이 `bash`, `zsh`, `sh`면 그것. 아니면 PATH에서
`bash`, `zsh`, `sh` 순서로 처음 찾은 것. Windows는 `pwsh`, `powershell`.
- logical id는 파일 이름(`bash` 등)이다. 같은 id가 이미 있으면 더하지 않는다.
- 시작 화면에 catalog(`executables: ... bash`)를 보여 준다.
- 신뢰 모드가 아니면 바뀌지 않는다. 그때는 기존처럼 `/allow-executable`을 쓴다.

바뀌지 않는 것:

- adapter는 ADR-0028대로 shell 없이 argv를 실행한다. shell은 catalog에 있는 실행 파일 하나이고, 모델이
`bash -c "..."`처럼 인자를 넘긴다.
- 실행 직전 실행 파일 내용 재검증, timeout(최대 10분), 출력 상한(stream마다 32 KiB), no-replay 복구.
- 실행한 명령은 끝난 뒤 `Changes`에 `ran bash -c ... (file changes not tracked)`로 보인다(ADR-0057).

## 대안

- **PATH 전체를 catalog에 넣기:** 수천 개 실행 파일의 내용 digest를 매 session 계산해야 하고 planning
context도 커진다. shell 하나로 같은 범위를 얻는다.
- **새 `shell/run` capability:** 계약·verifier·fixture를 새로 만들어야 하지만 얻는 것이 기존
`process/execute` + shell과 같다.

## 결과

- 신뢰 모드의 모델은 사용자 OS 권한으로 무엇이든 실행할 수 있다. 사용자가 `--trust`로 이를 명시한다.
- 명령이 바꾼 파일은 `Changes`에 줄 단위로 나오지 않는다. 실행한 명령만 나온다.

## 검증

- 단위: 알려진 `SHELL`을 우선하고, 모르는 shell(`fish`)이나 상대 경로면 PATH의 `bash`로 돌아가며,
없으면 넣지 않는다.
- e2e: 실제 binary `--trust` 시작 화면의 catalog에 shell이 있다.
- 실사용(deepseek, `--trust`, `/allow-executable` 없음): 한 session에 4개 요청.

| 요청 | 결과 |
| --- | --- |
| 디스크·메모리 요약 (설계) | `bash -c df -h`, `bash -c free -h`, 정확 |
| `~/projects/app` 바로 아래 디렉터리 개수 (설계) | `find -maxdepth 1 ...`, 148개로 실제와 같음 |
| `curl`로 HTTP 요청 (설계) | 1회 `invocation_invalid`로 거절. 새 session 5회 반복은 5/5 성공 |
| 메모리 상위 프로세스 5개 (검증용) | `ps ... --sort=-rss | head`, 정확 |

거절 1회는 대화 문맥이 있는 세 번째 요청에서만 나왔고 거절된 제안 내용은 남지 않아 원인을 확인하지
못했다. 재현되면 planner 안내를 다시 본다.
3 changes: 3 additions & 0 deletions docs/development/interactive-repl.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ Session 허용은 파일에 저장하지 않고 process가 끝나면 사라진
`xgen --trust`는 네 종류를 모두 `allow`로 둔 채 session을 시작해 승인을 묻지 않는다
([ADR-0059](../adr/0059-trust-mode.md)). 시작할 때 한 줄로 알리고, `/permissions KIND ask`로 일부를 다시
묻게 할 수 있다. 기록, `Changes` 표시, Ctrl+C, `/resume`은 같다.
신뢰 모드는 사용자의 shell(`SHELL`이 bash·zsh·sh면 그것, 아니면 PATH의 bash→zsh→sh, Windows는
pwsh→powershell)도 catalog에 넣는다([ADR-0060](../adr/0060-trusted-shell-executable.md)). 모델은 이것으로
파이프와 catalog에 없는 명령을 실행한다.

```text
xgen> 프로젝트 구조를 보고 테스트 실패를 수정해줘.
Expand Down
Loading