diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2103c4b..bcfbcaf 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "conn", "source": "./plugin", "description": "Conn: MCP server + working-etiquette skill.", - "version": "0.8.9" + "version": "0.9.0" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b2092a..93c69fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +## 0.9.0 — Preview · 2026-10-26 + +- Add **Reduce environment checks**, off by default. The local owner can explicitly reduce environment-only review for one exact connection in the current session for 15 minutes, with a visible target-unverified countdown and Stop control. The decision stays in memory and cannot be created or extended by an agent. +- Limit reduction to documented simple query commands; other commands and unsupported syntax keep review. Preserve risk confirmation, deny rules, Privacy, participation, collaboration modes, control leases and existing input recovery. Pending approval cards are never automatically approved. Existing category allowances do not turn this option into blanket command approval. +- Recheck queued commands before PTY input. Expiry, stop, disconnect/reconnect, sharing, profile/policy or observed transport changes and human takeover revoke the decision; ordinary lease expiry preserves the decision but still blocks input without a lease. Running processes are not killed. +- Final execution targets and internal transitions remain unverified. Nested remote host changes may be undetectable; this feature does not verify target identity. + +한국어: **환경 확인 줄이기**를 추가했습니다. 기본 OFF이며 로컬 소유자가 직접 동의하면 현재 세션·정확한 연결의 지원되는 단순 조회 명령에 한해 환경 확인만 15분 동안 줄입니다. 대상 미검증·남은 시간·종료 버튼을 표시하고 메모리에만 유지하며, 에이전트는 생성·연장할 수 없습니다. 위험 명령·명시 deny·해석 불가 문법·Privacy·참여 권한·기존 모드·제어 lease·입력 복구는 유지하고, 대기 승인 카드를 자동 승인하지 않습니다. 큐 명령을 실제 입력 직전에 재검증하며 만료·철회·문맥 변경·사용자 takeover 시 실행하지 않습니다. lease 자연 만료는 grant를 보존하지만 입력에는 유효 lease가 필요합니다. 중첩 원격 연결의 최종 호스트 전환을 감지하지 못할 수 있으며 대상 신원 검증을 보장하지 않습니다. + +Validation and limits: automated core regressions cover fixed monotonic expiry, connection scope, nonrenewing reads, owner-only creation, command risk and syntax, pending approvals, retained input, queued-command revalidation, policy/profile/sharing changes, and lease expiry versus human takeover. Production Linux Chromium UI/PTY/MCP flows verify acknowledgement, countdown, stop, reload and reconnect in disposable sessions. Native packaging, signing and exact-release artifact checks are recorded separately. macOS/Windows interactive behavior and installed-app upgrades were not exercised locally. Local native compilation requires GTK development libraries that are unavailable in this execution environment; full-repository fmt and strict Clippy also report pre-existing formatting/lint issues. + +한국어: 고정 단조시계 만료·연결 범위·조회 비갱신·소유자 전용 생성·위험/문법 확인·대기 승인·입력 잔존·큐 재검증·정책/프로필/공유 변경·lease 자연 만료와 인간 takeover 차이를 자동 검증했습니다. 격리된 Linux Chromium 실제 UI/PTY/MCP에서 동의·남은 시간·종료·재진입·재접속을 검증하며, 배포 산출물·서명 검증은 별도 기록합니다. Mac/Windows 대화형 검증·설치 앱 업그레이드는 로컬에서 수행하지 않았습니다. 실행 환경에 GTK 개발 라이브러리가 없어 로컬 네이티브 빌드는 제한되며, 전체 fmt·엄격 Clippy에는 기존 포맷/린트 문제가 남아 있습니다. + ## 0.8.9 — Preview · 2026-09-30 - Keep agent connections accessible through a compact session-header summary and a keyboard-accessible list. Preserve distinct connections with repeated names, show full long names, and keep the close control visible. diff --git a/Cargo.lock b/Cargo.lock index f165169..f46db62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,7 +696,7 @@ dependencies = [ [[package]] name = "conn" -version = "0.8.9" +version = "0.9.0" dependencies = [ "clap", "conn-core", @@ -706,7 +706,7 @@ dependencies = [ [[package]] name = "conn-core" -version = "0.8.9" +version = "0.9.0" dependencies = [ "anyhow", "chrono", @@ -730,7 +730,7 @@ dependencies = [ [[package]] name = "conn-desktop" -version = "0.8.9" +version = "0.9.0" dependencies = [ "base64 0.22.1", "cc", @@ -752,7 +752,7 @@ dependencies = [ [[package]] name = "conn-frontend" -version = "0.8.9" +version = "0.9.0" dependencies = [ "base64 0.22.1", "conn-core", @@ -769,7 +769,7 @@ dependencies = [ [[package]] name = "conn-web" -version = "0.8.9" +version = "0.9.0" dependencies = [ "axum", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 9f82809..340caab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,14 +6,14 @@ members = ["crates/core", "crates/cli", "crates/frontend", "crates/web", "fronte default-members = ["crates/core", "crates/cli", "crates/frontend", "crates/web"] [workspace.package] -version = "0.8.9" +version = "0.9.0" edition = "2021" license = "MIT" repository = "https://github.com/eggp-dev/conn" [workspace.dependencies] -conn-core = { path = "crates/core", version = "0.8.9" } -conn-frontend = { path = "crates/frontend", version = "0.8.9" } +conn-core = { path = "crates/core", version = "0.9.0" } +conn-frontend = { path = "crates/frontend", version = "0.9.0" } tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "sync", "time", "macros", "signal"] } portable-pty = "0.8" vt100 = { path = "vendor/vt100", version = "0.15.2" } diff --git a/README.ko.md b/README.ko.md index b3234f6..aae0f81 100644 --- a/README.ko.md +++ b/README.ko.md @@ -17,6 +17,8 @@ Conn은 지금 쓰는 AI 에이전트와 함께 쓰는 터미널입니다. 에 *내 서버에는 내가 로그인합니다. 에이전트가 캐시를 통째로 지우겠다고 합니다. 안 된다고 하고 내가 직접 고치면, 에이전트가 나머지를 끝냅니다.* [전체 영상 보기](https://conn.eggp.dev/ko/#film) ([파일](docs/assets/conn-remote-ko.mp4)) · [English video](https://conn.eggp.dev/#film) · [한 번에 이어서 찍었습니다: 촬영 기록](media/demo/RECORDING-REMOTE.md) +판별할 수 없는 실행 환경의 기본 매명령 확인은 유지됩니다. 로컬 소유자가 **환경 확인 줄이기**를 명시적으로 켜면 현재 세션·정확한 연결의 지원 조회 명령에만 15분 동안 적용하며, 다른 명령과 해석 불가 문법은 계속 확인합니다. [범위와 한계](docs/faq.ko.md#환경-확인-줄이기는-무엇인가요). + ## 설치 ```sh @@ -29,13 +31,13 @@ curl -fsSL https://conn.eggp.dev/install.sh | sh Rust나 Node.js는 필요 없습니다. 에이전트 연결 도구가 함께 들어 있습니다. 두 방법 모두 아래 다운로드와 같은 서명된 릴리스 파일을 설치하며, 이후에는 앱 안에서 업데이트됩니다. Windows는 설치 파일을 사용하세요. -| 운영체제 | v0.8.9 프리뷰 | +| 운영체제 | v0.9.0 프리뷰 | |---|---| -| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.AppImage) | -Mac 배포 파일은 서명·공증되어 있습니다. Windows 프리뷰는 무서명이며, Linux는 Ubuntu 24.04에서 빌드하며, AppImage는 앱 안에서 업데이트되고 `.deb`는 새 패키지를 설치해 업데이트합니다. Intel Mac 배포는 잠시 중단했습니다. [설치·업데이트 안내](docs/getting-started.ko.md) · [전체 파일·체크섬](https://github.com/eggp-dev/conn/releases/tag/v0.8.9) +Mac 배포 파일은 서명·공증되어 있습니다. Windows 프리뷰는 무서명이며, Linux는 Ubuntu 24.04에서 빌드하며, AppImage는 앱 안에서 업데이트되고 `.deb`는 새 패키지를 설치해 업데이트합니다. Intel Mac 배포는 잠시 중단했습니다. [설치·업데이트 안내](docs/getting-started.ko.md) · [전체 파일·체크섬](https://github.com/eggp-dev/conn/releases/tag/v0.9.0) ## 처음 5분 diff --git a/README.md b/README.md index 96f6bbc..9aecefc 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ Conn is a terminal you share with the AI agent you already use. The agent picks *You log in to your server. Your agent asks to delete the whole cache. You say no, type the fix yourself, and it finishes the job.* [Watch the full film](https://conn.eggp.dev/#film) ([file](docs/assets/conn-remote-en.mp4)) · [한국어 영상](https://conn.eggp.dev/ko/#film) · [One continuous take: how it was recorded](media/demo/RECORDING-REMOTE.md) +The default per-command review in unverified environments is unchanged. The local owner can opt into **Reduce environment checks** for supported simple queries on one exact session/connection for 15 minutes; other commands, risks and unsupported syntax still require review. [Scope and limits](docs/policy.md#reduce-environment-checks). + ## Install ```sh @@ -29,13 +31,13 @@ curl -fsSL https://conn.eggp.dev/install.sh | sh No Rust or Node.js required; the agent connector is included. Both install the same signed release files as the downloads below, and the app updates itself afterwards. Windows: use the installer. -| Platform | v0.8.9 preview | +| Platform | v0.9.0 preview | |---|---| -| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.AppImage) | -Mac downloads are signed and notarized. Windows previews are unsigned. Linux builds use Ubuntu 24.04, and the AppImage updates in-app while the `.deb` updates by installing a new package; Intel Mac distribution is paused. [Installation and updates](docs/getting-started.md) · [All assets and checksums](https://github.com/eggp-dev/conn/releases/tag/v0.8.9) +Mac downloads are signed and notarized. Windows previews are unsigned. Linux builds use Ubuntu 24.04, and the AppImage updates in-app while the `.deb` updates by installing a new package; Intel Mac distribution is paused. [Installation and updates](docs/getting-started.md) · [All assets and checksums](https://github.com/eggp-dev/conn/releases/tag/v0.9.0) ## First five minutes diff --git a/crates/core/src/agent_commands.rs b/crates/core/src/agent_commands.rs index 1550042..6b43894 100644 --- a/crates/core/src/agent_commands.rs +++ b/crates/core/src/agent_commands.rs @@ -104,7 +104,7 @@ pub(crate) fn dispatch_locked( } if method == "status" { return Ok( - json!({"controller": s.status().controller, "mode": s.mode(), "effectiveMode": s.effective_mode(), "attended": s.attended(), "shared": !s.is_private(), "surfaceAvailable": s.status().surface_available }), + json!({"environmentReview": s.environment_review_status(Some(conn), std::time::Instant::now()), "controller": s.status().controller, "mode": s.mode(), "effectiveMode": s.effective_mode(), "attended": s.attended(), "shared": !s.is_private(), "surfaceAvailable": s.status().surface_available }), ); } s.note_connection_activity(conn); diff --git a/crates/core/src/policy.rs b/crates/core/src/policy.rs index 6c3eca2..2ef98db 100644 --- a/crates/core/src/policy.rs +++ b/crates/core/src/policy.rs @@ -326,6 +326,9 @@ pub struct LineAnalysis { /// Set when the line was denied for mixing a dangerous segment with others. #[serde(rename = "isolationViolation")] pub isolation_violation: bool, + /// Environment uncertainty alone would require confirmation for this line. + #[serde(rename = "blanketConfirm")] + pub blanket_confirm: bool, } fn inspect_target(path: &std::path::Path, protected: bool) -> TargetInfo { @@ -483,7 +486,7 @@ impl PolicyStore { isolation_violation = true; worst = Decision::Deny { label: "run dangerous commands alone".into() }; } - LineAnalysis { cwd: cwd.map(|c| c.display().to_string()), segments: verdicts, decision: worst, isolation_violation } + LineAnalysis { cwd: cwd.map(|c| c.display().to_string()), segments: verdicts, decision: worst, isolation_violation, blanket_confirm: false } } pub fn require_intent(&self) -> bool { @@ -494,11 +497,21 @@ impl PolicyStore { /// Preserve deny rules, but never claim these contexts are automatically verified. /// This review is per command and cannot be lifted by a session-wide allowance. pub fn analyse_external(&self, line: &str, shell: crate::backend::ShellKind) -> LineAnalysis { + self.analyse_external_with_environment(line, shell, false) + } + + /// Session grants may remove only the environment overlay. Risk rules and + /// unsupported grammar never use category allowances here. + pub fn analyse_external_with_environment(&self, line: &str, shell: crate::backend::ShellKind, reduce: bool) -> LineAnalysis { use crate::backend::ShellKind; let segments = if shell == ShellKind::Posix { crate::analysis::analyse_line(line) } else { external_segments(line, shell) }; let mut verdicts = Vec::new(); - let mut worst = Decision::Confirm { label: "Review command in this shell/remote environment".into() }; + let mut worst = Decision::Allow; + let analysable = shell == ShellKind::Posix && !segments.is_empty() + && simple_external_line(line) + && segments.iter().all(|s| s.command.is_some() && s.opaque.is_none() + && reducible_query(s)); for segment in &segments { let mut decision = self.policy.evaluate_segment(segment); // PowerShell and cmd executable names are case insensitive. @@ -514,16 +527,142 @@ impl PolicyStore { match &decision { Decision::Deny { .. } => worst = decision.clone(), Decision::Confirm { .. } if !matches!(worst, Decision::Deny { .. }) => worst = decision.clone(), - Decision::Allow => decision = Decision::Confirm { label: "Review command in this shell/remote environment".into() }, + Decision::Allow => {}, _ => {} } verdicts.push(SegmentVerdict {text:segment.text.clone(),command:segment.command.clone(),opaque:Some("Target syntax or filesystem requires human review".into()),decision,targets:vec![]}); } let isolation_violation = self.policy.isolate_dangerous && segments.len()>1; if isolation_violation { worst=Decision::Deny {label:"Run commands separately in this shell/remote environment".into()}; } - LineAnalysis {cwd:None,segments:verdicts,decision:worst,isolation_violation} + let command_allowed = matches!(worst, Decision::Allow); + let blanket_confirm = command_allowed && analysable; + if command_allowed && (!reduce || !analysable) { + worst = Decision::Confirm { label: "Review command in this shell/remote environment".into() }; + } + LineAnalysis {cwd:None,segments:verdicts,decision:worst,isolation_violation,blanket_confirm} + } + +} + +/// Fail closed on unknown executables and option grammars. Remote protected +/// targets cannot be checked against the host, so mutating/path-sensitive commands +/// and arbitrary launchers never qualify merely because a risk rule missed them. +fn reducible_query(segment: &crate::analysis::Segment) -> bool { + const QUERIES: &[&str] = &[ + ":", "true", "false", "pwd", "echo", "printf", "ls", "cat", "head", "tail", "wc", "grep", + "fgrep", "egrep", "cut", "tr", "cmp", "readlink", "realpath", "basename", "dirname", + "uname", "id", "whoami", "uptime", "df", "du", "free", "ps", + ]; + let Some(command) = segment.command.as_deref() else { + return false; + }; + if !segment.wrappers.is_empty() || !QUERIES.contains(&command) { + return false; + } + // Leading assignments can replace command lookup or install shell hooks. + if segment + .argv + .first() + .and_then(|word| word.split_once('=')) + .is_some_and(|(key, _)| { + !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + }) + { + return false; + } + if command != "printf" { + return true; + } + // printf can assign variables (-v/%n) and evaluate arithmetic conversions. + // Only literal text, %s and %% are supported query formats. + if segment + .args + .first() + .is_some_and(|arg| arg.starts_with("-v")) + { + return false; } + let index = usize::from(segment.args.first().is_some_and(|arg| arg == "--")); + let Some(format) = segment.args.get(index) else { + return true; + }; + let mut conversion = false; + for ch in format.chars() { + if conversion { + if !matches!(ch, 's' | '%') { + return false; + } + conversion = false; + } else if ch == '%' { + conversion = true; + } + } + !conversion +} +/// Deliberately smaller than a shell grammar: incomplete quoting, expansion, +/// compound syntax and reserved words always keep their command review. +fn simple_external_line(line: &str) -> bool { + let mut quote = None; + let mut escaped = false; + for c in line.chars() { + if escaped { + escaped = false; + continue; + } + if quote == Some('\'') { + if c == '\'' { + quote = None; + } + continue; + } + if c == '\\' { + escaped = true; + continue; + } + if quote == Some('"') { + if c == '"' { + quote = None; + } else if matches!(c, '$' | '`' | '!' | '\n' | '\r') { + return false; + } + continue; + } + if matches!(c, '\'' | '"') { + quote = Some(c); + } else if ";|&(){}<>`$#*?[]!~^%\n\r".contains(c) { + return false; + } + } + let reserved = [ + "if", + "then", + "else", + "elif", + "fi", + "for", + "while", + "until", + "do", + "done", + "case", + "esac", + "function", + "select", + "in", + "coproc", + "repeat", + "foreach", + "end", + "switch", + "endsw", + "nocorrect", + "noglob", + "!", + "[[", + "]]", + ]; + quote.is_none() && !escaped && !line.split_whitespace().any(|word| reserved.contains(&word)) } /// Small dialect-aware lexer for explicit command rules. It does not certify scripts. diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index 911d9ae..f705d71 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -9,6 +9,7 @@ //! `EventSink`s registered per connection. mod participation; +mod environment_review; mod pending_work; use participation::Participation; @@ -88,6 +89,7 @@ pub enum ServerEvent { /// The human explicitly returned control to the previous agent. ControlHandedBack { lease: String, #[serde(rename = "agentId")] agent_id: String, #[serde(rename = "lastCmd")] last_cmd: Option }, SessionAllowsChanged { allows: Vec }, + EnvironmentReviewChanged { grants: Vec }, } /// How much an agent may do. Set by the frontend; narrows the state-derived table. @@ -239,6 +241,8 @@ pub enum ControllerInfo { #[derive(Debug, Clone, Serialize)] pub struct Snapshot { + #[serde(rename = "environmentReview")] + pub environment_review: Vec, pub mode: AgentMode, #[serde(rename = "effectiveMode")] pub effective_mode: AgentMode, @@ -302,6 +306,10 @@ pub struct ScheduledExec { pub state: ExecState, #[serde(skip)] pub cancel_reason: Option, + #[serde(skip)] + environment_grant: Option, + #[serde(skip)] + environment_context: environment_review::QueuedEnvironmentContext, } #[derive(Debug, Clone, Serialize)] @@ -348,6 +356,8 @@ pub struct Status { pub profile_name: Option, #[serde(rename = "reviewRequired")] pub review_required: bool, + #[serde(rename = "environmentReview")] + pub environment_review: Vec, pub controller: ControllerInfo, #[serde(rename = "processAlive")] pub process_alive: bool, @@ -444,6 +454,8 @@ pub struct Session { external_writer_active: bool, execution_profile: Option, profile_revision: u64, + environment_grants: HashMap, + environment_revision: u64, ssh_transport: bool, ssh_foreground_sequence: Option, authority: Authority, @@ -586,6 +598,8 @@ impl Session { last_agent_cmd: None, execution_profile: None, profile_revision: 0, + environment_grants: HashMap::new(), + environment_revision: 0, shell_pid: cfg.shell_pid, cwd_override: None, attended: true, @@ -663,7 +677,7 @@ impl Session { None } - pub(crate) fn set_ssh_transport(&mut self, ssh: bool) { self.ssh_transport = ssh; } + pub(crate) fn set_ssh_transport(&mut self, ssh: bool) { if self.ssh_transport != ssh { self.invalidate_environment_review("transport_changed"); } self.ssh_transport = ssh; } pub fn completion_prompt_ready(&self) -> bool { self.participation.shared && self.process_alive && self.shell_integration.state == "active" && self.shell_command.is_none() @@ -764,6 +778,7 @@ impl Session { } _ => {} } + self.refresh_environment_review(Instant::now()); if self.is_private() { return; } match kind { "ready" => self.broadcast(ServerEvent::ShellIntegrationChanged { status: self.shell_integration.clone() }), @@ -866,7 +881,7 @@ impl Session { /// The shell's current directory, read from the process (`lsof -d cwd`) so /// `cd` history does not have to be tracked. None when unknown. - pub fn set_execution_profile(&mut self, profile: crate::backend::Profile) { self.profile_revision = self.profile_revision.wrapping_add(1); self.execution_profile = Some(profile); } + pub fn set_execution_profile(&mut self, profile: crate::backend::Profile) { self.invalidate_environment_review("profile_changed"); self.profile_revision = self.profile_revision.wrapping_add(1); self.execution_profile = Some(profile); } pub fn shell_cwd(&self) -> Option { if self.execution_profile.as_ref().is_some_and(|p|p.backend != crate::backend::BackendKind::Local) { return None; } @@ -1016,6 +1031,7 @@ impl Session { } pub fn connection_closed(&mut self, conn: ConnId) { + self.stop_environment_review(conn, "disconnected"); let name = self.agent_name(conn); if let Some(lease) = self.authority.revoke_if_held_by(conn) { self.audit.record(&name, "disconnect", json!({ "revoked": lease.label() })); @@ -1034,7 +1050,7 @@ impl Session { /// Bytes from the human. This is the preemptive takeover path. pub fn human_input(&mut self, bytes: &[u8]) -> Result<(), SessionError> { - if !bytes.is_empty() { self.shell_human_input = true; } + if !bytes.is_empty() { self.invalidate_environment_review("human_input"); self.shell_human_input = true; } if self.is_private() { if !bytes.is_empty() { self.revoke_external(); @@ -1087,6 +1103,7 @@ impl Session { /// Revoke the agent lease without typing anything (`conn take`, frontend button). pub fn human_take(&mut self) -> Option { + self.invalidate_environment_review("human_takeover"); self.revoke_external(); self.cancel_pending_work(None, "taken"); let lease = self.authority.revoke()?; @@ -1126,6 +1143,7 @@ impl Session { } pub fn process_exited(&mut self, exit_code: Option) { + self.invalidate_environment_review("session_closed"); self.finish_shell_command(None); self.revoke_external(); self.process_alive = false; @@ -1356,7 +1374,7 @@ impl Session { if let Actor::Agent { conn } = actor { self.audit.record(&self.agent_name(conn), "observe", json!({ "rev": projection.revision })); } - Ok(Snapshot { mode: self.mode, effective_mode: self.effective_mode(), projection, controller: self.controller_info(), process_alive: self.process_alive }) + Ok(Snapshot { environment_review: self.environment_review_status(match actor { Actor::Agent {conn} => Some(conn), Actor::Human => None }, Instant::now()), mode: self.mode, effective_mode: self.effective_mode(), projection, controller: self.controller_info(), process_alive: self.process_alive }) } /// `request_control`. With the control gate on, the request is held for the human @@ -1527,6 +1545,7 @@ impl Session { } pub fn revoke_session_allow(&mut self, label: &str) -> bool { + self.invalidate_environment_review("policy_changed"); let removed = self.policy.disallow_for_session(label); if removed { self.audit.record("human", "session_allow_revoked", json!({ "label": label })); @@ -1792,7 +1811,10 @@ impl Session { let cmd = input::resolve_command(&self.input.line(), self.input.is_dirty()) .ok_or(SessionError::InputUnverified)?; self.last_agent_write = Some(Instant::now()); - let analysis = self.analyse(&cmd); + self.refresh_environment_review(Instant::now()); + let admission_context = self.queued_environment_context(); + let admission_grant = self.valid_environment_grant(conn, Instant::now()).map(|g| g.id.clone()); + let analysis = self.analyse_for_connection(&cmd, conn, Instant::now()); match analysis.decision.clone() { PolicyDecision::Allow => { if self.pacing.enter_grace_ms > 0 { @@ -1808,6 +1830,8 @@ impl Session { due: Instant::now() + Duration::from_millis(grace_ms), state: ExecState::Scheduled, cancel_reason: None, + environment_grant: admission_grant, + environment_context: admission_context, }); self.audit.record(&agent_id, "exec_scheduled", json!({ "cmd": cmd, "exec": exec_id, "graceMs": grace_ms, "intent": intent })); self.broadcast(ServerEvent::ExecScheduled { exec_id: exec_id.clone(), agent_id: agent_id.clone(), cmd: cmd.clone(), intent, grace_ms }); @@ -1819,6 +1843,11 @@ impl Session { } return Ok(KeyResult::Scheduled { exec_id, cmd, grace_ms }); } + self.refresh_environment_review(Instant::now()); + if !self.execution_admission_valid(conn, admission_grant.as_deref(), &admission_context, Instant::now()) { + self.cancelled_input = self.input.has_pending(); + return Ok(KeyResult::Cancelled { cmd, reason: "execution_context_changed".into() }); + } self.execute_with_source(&agent_id, &cmd, intent, None)?; Ok(KeyResult::Executed { cmd }) } @@ -1877,23 +1906,36 @@ impl Session { /// Frontend/human: run a scheduled execution right now (co-sign during the grace window). pub fn execute_now_scheduled(&mut self, exec_id: &str) -> Result<(), SessionError> { + self.execute_now_scheduled_with_clock(exec_id, Instant::now) + } + + fn execute_now_scheduled_with_clock(&mut self, exec_id: &str, clock: impl Fn() -> Instant) -> Result<(), SessionError> { self.require_shared()?; - match &mut self.scheduled { - Some(s) if s.exec_id == exec_id && s.state == ExecState::Scheduled => { - s.state = ExecState::Executed; - let (agent, cmd, intent) = (s.agent_id.clone(), s.cmd.clone(), s.intent.clone()); - self.audit.record("human", "exec_cosigned", json!({ "exec": exec_id, "cmd": cmd, "agentId": agent })); - self.broadcast(ServerEvent::ExecCosigned { exec_id: exec_id.into(), agent_id: agent.clone() }); - if let Err(error) = self.execute_with_source(&agent, &cmd, intent, Some("human_cosign")) { - if let Some(s) = &mut self.scheduled { s.state = ExecState::Cancelled; s.cancel_reason = Some("input_outcome_unknown".into()); } - self.broadcast(ServerEvent::ExecCancelled { exec_id: exec_id.into(), reason: "input_outcome_unknown".into() }); - return Err(error); - } - Ok(()) - } - Some(s) if s.exec_id == exec_id => Err(SessionError::InvalidInput(format!("{exec_id} is not scheduled"))), - _ => Err(SessionError::NotFound(exec_id.to_string())), + let now = clock(); + self.refresh_environment_review(now); + self.validate_scheduled_environment(now); + let (agent, cmd, intent) = match &self.scheduled { + Some(s) if s.exec_id == exec_id && s.state == ExecState::Scheduled => + (s.agent_id.clone(), s.cmd.clone(), s.intent.clone()), + Some(s) if s.exec_id == exec_id => return Err(SessionError::InvalidInput(format!("{exec_id} is not scheduled"))), + _ => return Err(SessionError::NotFound(exec_id.to_string())), + }; + self.audit.record("human", "exec_cosigned", json!({ "exec": exec_id, "cmd": cmd, "agentId": agent })); + self.broadcast(ServerEvent::ExecCosigned { exec_id: exec_id.into(), agent_id: agent.clone() }); + // Owner event delivery and audit I/O can take time. They are consent, + // not execution evidence; recheck after them and immediately before ENTER. + let now = clock(); + self.refresh_environment_review(now); + self.validate_scheduled_environment(now); + let scheduled = self.scheduled.as_mut().filter(|s| s.exec_id == exec_id && s.state == ExecState::Scheduled) + .ok_or_else(|| SessionError::InvalidInput("execution_context_changed: submit the command for a new review".into()))?; + scheduled.state = ExecState::Executed; + if let Err(error) = self.execute_with_source(&agent, &cmd, intent, Some("human_cosign")) { + if let Some(s) = &mut self.scheduled { s.state = ExecState::Cancelled; s.cancel_reason = Some("input_outcome_unknown".into()); } + self.broadcast(ServerEvent::ExecCancelled { exec_id: exec_id.into(), reason: "input_outcome_unknown".into() }); + return Err(error); } + Ok(()) } /// Frontend/human: cancel a scheduled execution during its grace window. @@ -2007,6 +2049,7 @@ impl Session { .finish_approval(id, state, by) .ok_or_else(|| if self.cancelled_input { SessionError::InputDeliveryUnknown } else { SessionError::InvalidInput(format!("{id} is not pending")) })?; if decision == ApprovalDecision::AllowSession { + self.invalidate_environment_review("policy_changed"); self.policy.allow_for_session(&req.label); self.audit.record("human", "session_allow", json!({ "label": req.label })); self.broadcast(ServerEvent::SessionAllowsChanged { allows: self.policy.session_allows() }); @@ -2058,6 +2101,8 @@ impl Session { /// Periodic maintenance. Call often (the engine uses 50 ms): lease expiry, approval /// expiry, scheduled executions, screen-change coalescing, policy reload. pub fn tick(&mut self, now: Instant) { + self.refresh_environment_review(now); + self.validate_scheduled_environment(now); if self.is_private() { if self.screen_dirty { self.screen_dirty = false; @@ -2087,6 +2132,11 @@ impl Session { self.finish_control_request(&id, ControlRequestState::Expired); } let surface_ready = self.participation.shared; + if self.scheduled.as_ref().is_some_and(|s| surface_ready && s.state == ExecState::Scheduled && s.due <= now) { + let input_now = Instant::now(); + self.refresh_environment_review(input_now); + self.validate_scheduled_environment(input_now); + } if let Some(s) = &mut self.scheduled { if surface_ready && s.state == ExecState::Scheduled && s.due <= now { s.state = ExecState::Executed; @@ -2101,15 +2151,7 @@ impl Session { self.screen_dirty = false; self.broadcast(ServerEvent::ScreenChanged { revision: self.screen.revision() }); } - match self.policy.maybe_reload() { - Reload::Unchanged => {} - Reload::Reloaded => self.audit.record("system", "policy_reloaded", json!({})), - Reload::Failed(e) => { - self.audit.record("system", "policy_reload_failed", json!({ "error": e.to_string() })); - let msg = format!("\r\n\x1b[33mconn: policy reload failed ({e}); keeping previous policy\x1b[0m\r\n"); - self.write_output(msg.as_bytes()); - } - } + } pub fn status(&self) -> Status { @@ -2131,6 +2173,7 @@ impl Session { profile_id: self.execution_profile.as_ref().map(|p|p.id.clone()), profile_name: self.execution_profile.as_ref().map(|p|p.name.clone()), review_required: self.review_required(), + environment_review: self.environment_review_status(None, Instant::now()), controller: self.controller_info(), process_alive: self.process_alive, revision: self.screen.revision(), diff --git a/crates/core/src/session/environment_review.rs b/crates/core/src/session/environment_review.rs new file mode 100644 index 0000000..cfed8e1 --- /dev/null +++ b/crates/core/src/session/environment_review.rs @@ -0,0 +1,408 @@ +//! Ephemeral owner decisions. Never persisted or writable through agent dispatch. +use super::*; + +pub(super) struct EnvironmentReviewGrant { + pub id: String, + conn: ConnId, + expires: Instant, + revision: u64, + profile_revision: u64, + foreground: Option, + transport_sequence: Option, + remote: bool, +} + +/// A queue decision belongs to its admission context, even without a grant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct QueuedEnvironmentContext { + revision: u64, + profile_revision: u64, + foreground: Option, + transport_sequence: Option, + remote: bool, + review_required: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentReviewInfo { + pub grant_id: String, + pub conn_id: ConnId, + pub agent_id: String, + pub remaining_ms: u64, + pub context_revision: u64, +} + +impl Session { + /// Local owner only. A repeated click cannot extend an existing decision. + pub fn start_environment_review(&mut self, conn: ConnId) -> Result<(), SessionError> { + self.start_environment_review_at(conn, Instant::now()) + } + + fn start_environment_review_at( + &mut self, + conn: ConnId, + now: Instant, + ) -> Result<(), SessionError> { + self.refresh_environment_review(now); + self.require_shared()?; + self.require_participant(conn)?; + if !self.process_alive { + return Err(SessionError::ProcessExited); + } + if self.conn_kind(conn) != Some(ConnKind::Agent) || !self.review_required() { + return Err(SessionError::InvalidInput( + "environment_review_unavailable".into(), + )); + } + if self.environment_grants.contains_key(&conn) { + return Err(SessionError::InvalidInput( + "environment_review_already_active".into(), + )); + } + let grant = EnvironmentReviewGrant { + id: uuid::Uuid::new_v4().to_string(), + conn, + expires: now + Duration::from_secs(15 * 60), + revision: self.environment_revision, + profile_revision: self.profile_revision, + foreground: self.foreground_id(), + transport_sequence: self.ssh_foreground_sequence, + remote: self.remote_policy(), + }; + self.audit.record( + "human", + "environment_review_started", + json!({ + "grantID": grant.id, "sessionID": self.surface_id, "connID": conn, + "contextRevision": grant.revision, "durationSecs": 900, + }), + ); + self.environment_grants.insert(conn, grant); + self.broadcast(ServerEvent::EnvironmentReviewChanged { + grants: self.environment_review_status(None, now), + }); + Ok(()) + } + + pub fn stop_environment_review(&mut self, conn: ConnId, reason: &str) -> bool { + let Some(grant) = self.environment_grants.remove(&conn) else { + return false; + }; + self.audit.record( + "system", + "environment_review_ended", + json!({ + "grantID": grant.id, "sessionID": self.surface_id, "connID": conn, + "contextRevision": grant.revision, "reason": reason, + }), + ); + self.broadcast(ServerEvent::EnvironmentReviewChanged { + grants: self.environment_review_status(None, Instant::now()), + }); + self.cancel_scheduled_if( + |s| s.environment_grant.as_deref() == Some(&grant.id), + "environment_review_changed", + ); + true + } + + pub(super) fn invalidate_environment_review(&mut self, reason: &str) { + self.environment_revision = self.environment_revision.wrapping_add(1); + for conn in self.environment_grants.keys().copied().collect::>() { + self.stop_environment_review(conn, reason); + } + } + + pub(super) fn valid_environment_grant( + &self, + conn: ConnId, + now: Instant, + ) -> Option<&EnvironmentReviewGrant> { + self.environment_grants.get(&conn).filter(|g| { + g.conn == conn + && now < g.expires + && g.revision == self.environment_revision + && g.profile_revision == self.profile_revision + && g.foreground == self.foreground_id() + && g.transport_sequence == self.ssh_foreground_sequence + && g.remote == self.remote_policy() + && self.process_alive + && self.participant_allowed(conn) + && self.conn_kind(conn) == Some(ConnKind::Agent) + }) + } + + /// Read-only, scoped to the caller on public IPC. Reading never renews it. + pub fn environment_review_status( + &self, + conn: Option, + now: Instant, + ) -> Vec { + self.environment_grants + .keys() + .filter(|id| conn.is_none_or(|c| c == **id)) + .filter_map(|id| self.valid_environment_grant(*id, now)) + .map(|g| EnvironmentReviewInfo { + grant_id: g.id.clone(), + conn_id: g.conn, + agent_id: self.agent_name(g.conn), + remaining_ms: g.expires.saturating_duration_since(now).as_millis() as u64, + context_revision: g.revision, + }) + .collect() + } + + pub(super) fn refresh_environment_review(&mut self, now: Instant) { + match self.policy.maybe_reload() { + Reload::Unchanged => {} + Reload::Reloaded => { + self.invalidate_environment_review("policy_changed"); + self.audit.record("system", "policy_reloaded", json!({})); + } + Reload::Failed(e) => { + self.invalidate_environment_review("policy_changed"); + self.audit.record( + "system", + "policy_reload_failed", + json!({"error":e.to_string()}), + ); + self.write_output(format!("\r\n\x1b[33mconn: policy reload failed ({e}); keeping previous policy\x1b[0m\r\n").as_bytes()); + } + } + let invalid: Vec<_> = self + .environment_grants + .iter() + .filter_map(|(conn, g)| { + if now >= g.expires { + Some((*conn, "expired")) + } else if self.valid_environment_grant(*conn, now).is_none() { + Some((*conn, "context_changed")) + } else { + None + } + }) + .collect(); + for (conn, reason) in invalid { + self.stop_environment_review(conn, reason); + } + } + + fn environment_shell(&self) -> crate::backend::ShellKind { + // Only an explicit target dialect can certify grammar. A locally selected + // POSIX shell does not establish the dialect of an unknown foreground app. + self.execution_profile + .as_ref() + .filter(|p| p.needs_review()) + .map_or(crate::backend::ShellKind::Custom, |p| p.shell) + } + + pub(super) fn analyse_for_connection( + &self, + cmd: &str, + conn: ConnId, + now: Instant, + ) -> crate::policy::LineAnalysis { + if self.review_required() { + return self.policy.analyse_external_with_environment( + cmd, + self.environment_shell(), + self.valid_environment_grant(conn, now).is_some(), + ); + } + self.analyse(cmd) + } + + pub(super) fn queued_environment_context(&self) -> QueuedEnvironmentContext { + QueuedEnvironmentContext { + revision: self.environment_revision, + profile_revision: self.profile_revision, + foreground: self.foreground_id(), + transport_sequence: self.ssh_foreground_sequence, + remote: self.remote_policy(), + review_required: self.review_required(), + } + } + + pub(super) fn execution_admission_valid( + &self, + conn: ConnId, + grant: Option<&str>, + context: &QueuedEnvironmentContext, + now: Instant, + ) -> bool { + context == &self.queued_environment_context() + && (!context.review_required || grant.is_some()) + && self.participant_allowed(conn) + && self.process_alive + && self.mode == AgentMode::Autopilot + && self.check_mask(Affordance::SendKey).is_ok() + && self + .authority + .controller() + .lease() + .is_some_and(|l| l.conn == conn && now < l.expires_at) + && grant.is_none_or(|id| { + self.valid_environment_grant(conn, now) + .is_some_and(|g| g.id == id) + }) + } + + pub(super) fn validate_scheduled_environment(&mut self, now: Instant) { + let invalid = self + .scheduled + .as_ref() + .filter(|s| s.state == ExecState::Scheduled) + .is_some_and(|s| { + // A later owner decision never authorizes an older queued request. + let analysis = if s.environment_grant.is_some() { + self.analyse_for_connection(&s.cmd, s.conn, now) + } else { + self.analyse(&s.cmd) + }; + // Policy inspection may do filesystem work. Check real monotonic + // time after it, while preserving a later injected test clock. + let checked_at = now.max(Instant::now()); + !self.execution_admission_valid( + s.conn, + s.environment_grant.as_deref(), + &s.environment_context, + checked_at, + ) || !matches!(analysis.decision, PolicyDecision::Allow) + }); + if invalid { + self.cancel_scheduled_if(|_| true, "execution_context_changed"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn session() -> Session { + let (audit, _) = Audit::memory(); + let mut s = Session::new(SessionConfig { + rows: 24, + cols: 80, + audit, + policy: PolicyStore::from_policy( + crate::policy::Policy::parse(crate::policy::EXAMPLE_POLICY).unwrap(), + ), + pty_writer: Box::new(Vec::::new()), + master: None, + pacing: Pacing::default(), + shell_pid: None, + }); + let mut p = crate::backend::Profile::local("test".into(), "bash".into()); + p.backend = crate::backend::BackendKind::Docker; + s.set_execution_profile(p); + s.register_conn(1, ConnKind::Agent, "agent", Box::new(|_| {})); + s + } + #[test] + fn monotonic_boundary_and_polling_do_not_extend_grant() { + let mut s = session(); + let now = Instant::now(); + s.start_environment_review_at(1, now).unwrap(); + assert_eq!( + s.environment_review_status(Some(1), now)[0].remaining_ms, + 900_000 + ); + assert_eq!( + s.environment_review_status(Some(1), now + Duration::from_secs(899))[0].remaining_ms, + 1000 + ); + assert!(s + .environment_review_status(Some(1), now + Duration::from_secs(900)) + .is_empty()); + s.refresh_environment_review(now + Duration::from_secs(900)); + assert!(s.environment_grants.is_empty()); + } + #[test] + fn observed_transport_change_revokes_without_waiting_for_a_command() { + let mut s = session(); + s.start_environment_review(1).unwrap(); + s.set_ssh_transport(true); + assert!(s.environment_grants.is_empty()); + } + #[test] + fn new_grant_never_authorizes_a_request_queued_without_one() { + let mut s = session(); + s.set_ssh_transport(true); + s.pacing.enter_grace_ms = 1000; + s.authority.request("agent", 1, Instant::now()).unwrap(); + s.agent_type(1, "printf queued").unwrap(); + let KeyResult::Scheduled { exec_id, .. } = s + .agent_send_key_with(1, "ENTER", Some("Print a marker".into())) + .unwrap() + else { + panic!() + }; + assert!(s.scheduled.as_ref().unwrap().environment_grant.is_none()); + s.set_ssh_transport(false); + s.start_environment_review(1).unwrap(); + assert!(s.execute_now_scheduled(&exec_id).is_err()); + assert_eq!(s.input_line(), "printf queued"); + assert!(matches!( + s.exec_state(&exec_id), + Some((ExecState::Cancelled, _)) + )); + } + + #[test] + fn observed_context_round_trip_still_invalidates_a_queued_request() { + let mut s = session(); + s.set_ssh_transport(true); + s.pacing.enter_grace_ms = 1000; + s.authority.request("agent", 1, Instant::now()).unwrap(); + s.agent_type(1, "printf queued").unwrap(); + let KeyResult::Scheduled { exec_id, .. } = s + .agent_send_key_with(1, "ENTER", Some("Print a marker".into())) + .unwrap() + else { + panic!() + }; + s.set_ssh_transport(false); + s.set_ssh_transport(true); + s.tick(Instant::now() + Duration::from_secs(2)); + assert_eq!(s.input_line(), "printf queued"); + assert!(matches!( + s.exec_state(&exec_id), + Some((ExecState::Cancelled, _)) + )); + } + #[test] + fn expiry_during_owner_cosign_is_rechecked_before_pty_input() { + let mut s = session(); + let now = Instant::now(); + s.pacing.enter_grace_ms = 1000; + s.authority.set_ttl(Duration::from_secs(2000)); + s.authority.request("agent", 1, now).unwrap(); + s.start_environment_review_at(1, now).unwrap(); + s.agent_type(1, "printf queued").unwrap(); + let KeyResult::Scheduled { exec_id, .. } = s + .agent_send_key_with(1, "ENTER", Some("Print a marker".into())) + .unwrap() + else { + panic!() + }; + let calls = std::cell::Cell::new(0); + let result = s.execute_now_scheduled_with_clock(&exec_id, || { + let count = calls.get(); + calls.set(count + 1); + if count == 0 { + now + } else { + now + Duration::from_secs(901) + } + }); + assert!(result.is_err()); + assert_eq!(calls.get(), 2); + assert_eq!(s.input_line(), "printf queued"); + assert!(s.environment_grants.is_empty()); + assert!(matches!( + s.exec_state(&exec_id), + Some((ExecState::Cancelled, _)) + )); + } +} diff --git a/crates/core/src/session/participation.rs b/crates/core/src/session/participation.rs index 23d009c..88e2130 100644 --- a/crates/core/src/session/participation.rs +++ b/crates/core/src/session/participation.rs @@ -85,6 +85,7 @@ impl Session { // physical input for the owner instead of guessing a clear key in a TUI. let unfinished_agent_input = self.input.has_pending(); self.revoke_external(); + self.invalidate_environment_review("sharing_changed"); self.cancel_agent_work("sharing_changed"); self.human_input.pending |= unfinished_agent_input && self.input.has_pending(); if !shared { diff --git a/crates/core/tests/environment_review.rs b/crates/core/tests/environment_review.rs new file mode 100644 index 0000000..4c164c6 --- /dev/null +++ b/crates/core/tests/environment_review.rs @@ -0,0 +1,432 @@ +mod common; +use common::{Harness, SessionExt}; +use conn_core::{ + approval::{ApprovalState, Decision}, + backend::{BackendKind, Profile, ShellKind}, + policy::{Policy, PolicyStore}, + session::{AgentMode, KeyResult, SessionError}, +}; +use std::time::{Duration, Instant}; +fn profile() -> Profile { + let mut p = Profile::local("target".into(), "/bin/bash".into()); + p.backend = BackendKind::Docker; + p +} +fn harness() -> Harness { + let mut h = Harness::new(); + h.session.set_execution_profile(profile()); + h.session + .set_ttls(Duration::from_secs(2000), Duration::from_secs(300)); + h.agent(1, "same-name"); + h.session.agent_request_control(1).unwrap(); + h +} +fn submit(h: &mut Harness, cmd: &str) -> KeyResult { + h.session.agent_type(1, cmd).unwrap(); + h.session.agent_send_key(1, "ENTER").unwrap() +} +#[test] +fn default_still_reviews_every_command() { + let mut h = harness(); + assert!(matches!( + submit(&mut h, "printf hello"), + KeyResult::Pending { .. } + )); + assert!(!h.pty_str().contains('\r')); +} +#[test] +fn only_ordinary_known_dialect_commands_skip_environment_review() { + let mut h = harness(); + h.session.start_environment_review(1).unwrap(); + assert!(matches!( + submit(&mut h, "printf 'hello world'"), + KeyResult::Executed { .. } + )); + for cmd in [ + r#"printf '%s' '[!*?~^]'"#, + r#"printf "%s" "glob*""#, + r#"printf '%s' '!!'"#, + ] { + assert!( + matches!(submit(&mut h, cmd), KeyResult::Executed { .. }), + "{cmd}" + ); + } + assert!(h.session.status().session_allows.is_empty()); +} +#[test] +fn risks_deny_and_unsupported_grammar_keep_review() { + for cmd in [ + "rm file", + "sudo id", + "eval 'echo hi'", + "echo $HOME", + "printf 'unfinished", + "if true", + "echo ok > file", + "echo $(id)", + "env -u UNUSED rm file", + "env -S 'rm file'", + "timeout 5 rm file", + "busybox rm file", + "python3.12 -c 'print(1)'", + "command printf hello", + "node --eval 'process.exit(0)'", + "perl -pe 'print' file", + "awk 'BEGIN {print 1}'", + "sed -i 's/a/b/' file", + "/bin/r[m] file", + "/bin/r? file", + "ls *", + "!!", + "!rm", + "printf \"!!\"", + "^ls^rm file", + "coproc rm file", + "repeat 1 rm file", + "noglob rm file", + "=rm file", + "%1", + "fc -s", + "hash -p /bin/rm other", + "alias other='rm file'", + "trap 'rm file' EXIT", + "prlimit --core=0 rm file", + "chrt 0 rm file", + "truncate -s 0 /srv/protected/data", + "touch /srv/protected/data", + "PATH=/tmp ls", + "printf -v PROMPT_COMMAND 'rm file'", + "printf '%n' PROMPT_COMMAND", + "printf '%d' 'array[$(rm file)]'", + ] { + let mut h = harness(); + h.session.start_environment_review(1).unwrap(); + assert!( + matches!(submit(&mut h, cmd), KeyResult::Pending { .. }), + "{cmd}" + ); + assert!(!h.pty_str().contains('\r'), "{cmd}"); + } + for cmd in ["rm -rf /", "printf hello; rm file", "echo hi && echo there"] { + let mut h = harness(); + h.session.start_environment_review(1).unwrap(); + assert!( + matches!(submit(&mut h, cmd), KeyResult::Denied { .. }), + "{cmd}" + ); + } +} +#[test] +fn unknown_shell_is_never_assumed_to_be_posix() { + for shell in [ + ShellKind::Custom, + ShellKind::PowerShell, + ShellKind::Cmd, + ShellKind::Fish, + ] { + let mut h = harness(); + let mut p = profile(); + p.shell = shell; + h.session.set_execution_profile(p); + h.session.start_environment_review(1).unwrap(); + assert!(matches!( + submit(&mut h, "echo hello"), + KeyResult::Pending { .. } + )); + } +} +#[test] +fn same_name_new_connection_does_not_inherit_grant() { + let mut h = harness(); + h.session.start_environment_review(1).unwrap(); + h.agent(2, "same-name"); + assert!(h + .session + .environment_review_status(Some(2), Instant::now()) + .is_empty()); + h.session.connection_closed(1); + h.agent(3, "same-name"); + assert!(h.session.status().environment_review.is_empty()); + h.session.agent_request_control(3).unwrap(); + h.session.agent_type(3, "echo hi").unwrap(); + assert!(matches!( + h.session.agent_send_key(3, "ENTER").unwrap(), + KeyResult::Pending { .. } + )); +} +#[test] +fn enabling_does_not_resolve_waiting_approval_or_expand_category_allow() { + let mut h = harness(); + let KeyResult::Pending { approval_id, .. } = submit(&mut h, "echo hello") else { + panic!() + }; + h.session.start_environment_review(1).unwrap(); + assert_eq!( + h.session.check_approval(&approval_id).unwrap().state, + ApprovalState::Pending + ); + assert!(h + .session + .resolve_approval(&approval_id, Decision::AllowSession, "test") + .is_err()); + assert!(!h.pty_str().contains('\r')); + h.session + .resolve_approval(&approval_id, Decision::Grant, "test") + .unwrap(); + assert!(h.pty_str().ends_with('\r')); +} +#[test] +fn clock_expiry_and_stop_cancel_queued_input_without_clearing_it() { + for expire in [false, true] { + let mut h = harness(); + let mut pacing = h.session.pacing().clone(); + pacing.enter_grace_ms = 1_000_000; + pacing.lease_ttl_secs = 2000; + h.session.set_pacing(pacing); + h.session.start_environment_review(1).unwrap(); + let KeyResult::Scheduled { exec_id, .. } = submit(&mut h, "echo queued") else { + panic!() + }; + if expire { + h.session.tick(Instant::now() + Duration::from_secs(901)); + } else { + assert!(h.session.stop_environment_review(1, "owner_stopped")); + } + assert!(h.session.execute_now_scheduled(&exec_id).is_err()); + assert!(!h.pty_str().contains('\r')); + assert_eq!(h.session.input_line(), "echo queued"); + assert!(matches!( + h.session.agent_type(1, "more"), + Err(SessionError::InputUnverified) + )); + assert!(h.session.status().environment_review.is_empty()); + } +} +#[test] +fn lease_expiry_preserves_grant_but_human_takeover_revokes_it() { + let mut h = harness(); + h.session + .set_ttls(Duration::from_secs(1), Duration::from_secs(300)); + h.session.agent_request_control(1).unwrap(); + h.session.start_environment_review(1).unwrap(); + h.session.tick(Instant::now() + Duration::from_secs(2)); + assert!(h.session.controller().is_human()); + assert_eq!(h.session.status().environment_review.len(), 1); + assert!(h.session.agent_type(1, "echo blocked").is_err()); + h.session.human_take(); + assert!(h.session.status().environment_review.is_empty()); +} +#[test] +fn profile_sharing_close_and_human_input_invalidate() { + for action in 0..4 { + let mut h = harness(); + h.session.start_environment_review(1).unwrap(); + match action { + 0 => h.session.set_execution_profile(profile()), + 1 => h.session.set_shared(false).unwrap(), + 2 => h.session.process_exited(None), + _ => h.session.human_input(b"x").unwrap(), + } + assert!(h.session.status().environment_review.is_empty()); + } +} +#[test] +fn mode_and_privacy_controls_are_preserved() { + let mut h = harness(); + h.session.start_environment_review(1).unwrap(); + h.session.set_mode(AgentMode::Copilot).unwrap(); + h.session.agent_request_control(1).unwrap(); + assert!(matches!( + submit(&mut h, "echo hello"), + KeyResult::Proposed { .. } + )); + h.session.set_mode(AgentMode::Observe).unwrap(); + assert!(h.session.agent_type(1, "echo hi").is_err()); + h.session.set_shared(false).unwrap(); + assert!(h.session.start_environment_review(1).is_err()); +} +#[test] +fn duplicate_enable_and_queries_never_extend_deadline() { + let mut h = harness(); + h.session.start_environment_review(1).unwrap(); + let before = h.session.status().environment_review[0].remaining_ms; + assert!(h.session.start_environment_review(1).is_err()); + let after = h.session.environment_review_status(Some(1), Instant::now()); + assert!(after[0].remaining_ms <= before); + h.session.tick(Instant::now() + Duration::from_secs(900)); + assert!(h.session.status().environment_review.is_empty()); +} +#[test] +fn public_agent_dispatch_can_only_read_its_own_state() { + let mut h = harness(); + h.agent(2, "same-name"); + h.session.start_environment_review(1).unwrap(); + let s = std::sync::Arc::new(parking_lot::Mutex::new(h.session)); + for method in [ + "start_environment_review", + "stop_environment_review", + "approve", + ] { + assert_eq!( + conn_core::agent_commands::dispatch(&s, 1, method, &serde_json::json!({"connId":1})) + .unwrap_err() + .code, + "owner_required" + ); + } + let own = conn_core::agent_commands::dispatch(&s, 1, "status", &serde_json::json!({})).unwrap(); + let other = + conn_core::agent_commands::dispatch(&s, 2, "status", &serde_json::json!({})).unwrap(); + assert_eq!(own["environmentReview"].as_array().unwrap().len(), 1); + assert_eq!(other["environmentReview"].as_array().unwrap().len(), 0); +} +#[test] +fn existing_category_allows_do_not_lift_risk_in_uncertain_environment() { + let mut store = PolicyStore::from_policy(Policy::parse(&Harness::test_policy()).unwrap()); + store.allow_for_session("privilege escalation"); + assert!(matches!( + store + .analyse_external_with_environment("sudo id", ShellKind::Posix, true) + .decision, + conn_core::policy::Decision::Confirm { .. } + )); +} +#[test] +fn policy_reload_cancels_queued_command_before_any_enter() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("policy.yaml"); + std::fs::write(&path, Harness::test_policy()).unwrap(); + let mut h = harness(); + let (audit, _) = conn_core::audit::Audit::memory(); + h.session = conn_core::session::Session::new(conn_core::session::SessionConfig { + rows: 24, + cols: 80, + audit, + policy: PolicyStore::open(&path).unwrap(), + pty_writer: Box::new(common::SharedBuf(h.pty.clone())), + master: None, + pacing: conn_core::Pacing { + enter_grace_ms: 10000, + lease_ttl_secs: 2000, + ..Default::default() + }, + shell_pid: None, + }); + h.session.set_execution_profile(profile()); + h.agent(1, "agent"); + h.session.agent_request_control(1).unwrap(); + h.session.start_environment_review(1).unwrap(); + let KeyResult::Scheduled { exec_id, .. } = submit(&mut h, "echo queued") else { + panic!() + }; + std::fs::write(&path, "deny:\n - command: echo\n label: changed policy\ndefault: allow\nrequire_intent: false\n").unwrap(); + h.session.tick(Instant::now() + Duration::from_secs(11)); + assert!(h.session.execute_now_scheduled(&exec_id).is_err()); + assert!(!h.pty_str().contains('\r')); + assert!(h.session.status().environment_review.is_empty()); +} +#[test] +fn queued_profile_change_and_participant_removal_retain_input_without_execution() { + for profile_change in [false, true] { + let mut h = harness(); + let mut pacing = h.session.pacing().clone(); + pacing.enter_grace_ms = 100; + pacing.lease_ttl_secs = 2000; + h.session.set_pacing(pacing); + h.session.start_environment_review(1).unwrap(); + let KeyResult::Scheduled { exec_id, .. } = submit(&mut h, "echo queued") else { + panic!() + }; + if profile_change { + h.session.set_execution_profile(profile()); + } else { + h.session.set_shared_with_agents(false, vec![]).unwrap(); + } + h.session.tick(Instant::now() + Duration::from_secs(1)); + assert!(h.session.execute_now_scheduled(&exec_id).is_err()); + assert!(!h.pty_str().contains('\r')); + } +} + +#[test] +fn environment_overlay_metadata_excludes_unanalysable_and_risk_reviews() { + let store = PolicyStore::from_policy(Policy::parse(&Harness::test_policy()).unwrap()); + assert!( + store + .analyse_external_with_environment("printf hello", ShellKind::Posix, false) + .blanket_confirm + ); + assert!( + store + .analyse_external_with_environment("printf hello", ShellKind::Posix, true) + .blanket_confirm + ); + for (cmd, shell) in [ + ("sudo id", ShellKind::Posix), + ("/bin/r[m] file", ShellKind::Posix), + ("!!", ShellKind::Posix), + ("coproc rm file", ShellKind::Posix), + ("env -u UNUSED rm file", ShellKind::Posix), + ("node --eval 'process.exit(0)'", ShellKind::Posix), + ("printf hello", ShellKind::Custom), + ] { + let analysis = store.analyse_external_with_environment(cmd, shell, true); + assert!(!analysis.blanket_confirm, "{cmd}"); + assert!( + matches!( + analysis.decision, + conn_core::policy::Decision::Confirm { .. } + ), + "{cmd}" + ); + } +} + +#[test] +fn unknown_commands_and_unchecked_protected_targets_never_reduce() { + let policy = + Harness::test_policy().replace("protected:\n", "protected:\n - '/srv/protected/**'\n"); + let store = PolicyStore::from_policy(Policy::parse(&policy).unwrap()); + for cmd in [ + "truncate -s 0 /srv/protected/data", + "prlimit --core=0 rm file", + "unknown-command harmless", + ] { + let analysis = store.analyse_external_with_environment(cmd, ShellKind::Posix, true); + assert!( + matches!( + analysis.decision, + conn_core::policy::Decision::Confirm { .. } + ), + "{cmd}" + ); + assert!(!analysis.blanket_confirm, "{cmd}"); + assert!(analysis.cwd.is_none()); + assert!(analysis.segments.iter().all(|s| s.targets.is_empty())); + } +} + +#[test] +fn eligible_queries_still_obey_risk_deny_and_independent_category_allows() { + let policy = Policy::parse("deny:\n - command: ls\n args: '--blocked'\n label: blocked query\nconfirm:\n - command: cat\n label: review read\ndefault: allow\n").unwrap(); + let mut store = PolicyStore::from_policy(policy); + store.allow_for_session("review read"); + let risk = store.analyse_external_with_environment("cat file", ShellKind::Posix, true); + assert!( + matches!(risk.decision, conn_core::policy::Decision::Confirm {ref label} if label == "review read") + ); + assert!(!risk.blanket_confirm); + assert!(matches!( + store + .analyse_external_with_environment("ls --blocked", ShellKind::Posix, true) + .decision, + conn_core::policy::Decision::Deny { .. } + )); + assert!(matches!( + store + .analyse_external_with_environment("ls -l", ShellKind::Posix, true) + .decision, + conn_core::policy::Decision::Allow + )); +} diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 991f0ef..26416b7 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -687,6 +687,8 @@ fn dispatch(app: &AppHandle, state: &AppState, name: &str, args: Value) -> Resul "accept_proposal" => out(accept_proposal(state, arg::(&args, "session")?, arg::(&args, "proposalId")?)?), "reject_proposal" => out(reject_proposal(state, arg::(&args, "session")?, arg::(&args, "proposalId")?)?), "hand_back" => out(hand_back(state, arg::(&args, "session")?)?), + "start_environment_review" => { engine(state, &arg::(&args, "session")?)?.session().lock().start_environment_review(arg::(&args, "connId")?).map_err(|e| e.to_string())?; Ok(Value::Null) }, + "stop_environment_review" => Ok(json!(engine(state, &arg::(&args, "session")?)?.session().lock().stop_environment_review(arg::(&args, "connId")?, "owner_stopped"))), "revoke_session_allow" => out(revoke_session_allow(state, arg::(&args, "session")?, arg::(&args, "label")?)?), "set_pacing" => out(set_pacing(state, arg::(&args, "session")?, arg::(&args, "patch")?)?), "set_affordances" => out(set_affordances(state, arg::(&args, "session")?, arg::>>(&args, "allow")?)?), diff --git a/docs/faq.ko.md b/docs/faq.ko.md index 76310f2..b8afdde 100644 --- a/docs/faq.ko.md +++ b/docs/faq.ko.md @@ -63,3 +63,13 @@ v0.6.0부터 백그라운드에서 서명된 업데이트를 확인하고 내려 ## 어떻게 도움을 줄 수 있나요? 첫 협업을 해보고 막힌 단계나 직접 개입하고 싶었던 순간을 알려주세요. Conn 버전·OS·셸·클라이언트와 공개 가능한 작은 재현 예제가 도움이 됩니다. 화면과 요청 원문은 공유 전에 검토하세요. 터미널 전체 덤프가 필요한 경우는 드뭅니다. [문제 제보](https://github.com/eggp-dev/conn/issues/new/choose) · [기여하기](../CONTRIBUTING.ko.md) + +## 환경 확인 줄이기는 무엇인가요? + +기본값은 OFF이며, 판별할 수 없는 실행 환경에서는 기존처럼 매명령 확인합니다. 실행 확인 카드가 뜨면 로컬 소유자 UI의 **환경 확인 줄이기**에서 현재 세션과 정확한 연결 ID를 선택하고 경고에 직접 동의하면 환경 확인만 15분 동안 줄입니다. 화면에 **대상 미검증 · 확인 줄임 · 남은 시간**과 대상 세션·연결 및 **종료** 버튼이 표시됩니다. 자동 갱신·기본값 저장·디스크 지속은 없으며, 같은 이름의 새 연결로 승계하지 않습니다. 에이전트 IPC/MCP는 생성·연장·승인할 수 없고 자기 연결 상태만 조회할 수 있습니다. 조회와 UI 닫기·재진입은 시간을 연장하지 않습니다. + +최종 실행 대상과 내부 전환을 확인할 수 없습니다. 실행 대상을 직접 확인하며 사용합니다. 위험 명령은 계속 확인합니다. 중첩 원격 연결의 최종 호스트 전환은 감지하지 못할 수 있으며 대상 신원 검증을 보장하지 않습니다. 첫 버전은 대상 셸이 POSIX로 명시된 경우 지원되는 단순 조회 명령(`pwd`, `ls`, `cat`, `grep`, `printf` 등)에만 적용합니다. `printf`는 리터럴·`%s`·`%%` 출력 형식에 한정합니다. 전체 목록은 [정책 안내](policy.md#reduce-environment-checks)에 있습니다. 그 밖의 명령과 원격 보호 경로를 검사할 수 없는 파일 변경 명령은 계속 확인합니다. 알 수 없는 셸을 POSIX로 가정하지 않으며, 불완전한 인용·변수/경로/history 확장·빠른 history 치환·불투명 실행·해석 불가 복합 문법은 계속 확인합니다. 옵션 문법을 완전히 해석하지 못하는 wrapper 명령과 스크립트 인터프리터도 확인을 유지합니다. 기존 위험유형의 세션 허용도 이 환경의 위험 명령 확인을 없애지 않습니다. + +Privacy·참여 권한·Observe 쓰기 금지·Co-pilot 수동 실행·제어 lease·명시 deny·위험 명령 확인·명령 분리·OS 인증은 유지합니다. 이미 대기 중인 승인 카드는 자동 승인하지 않습니다. 비밀번호 종료를 프롬프트나 ECHO로 추정하거나 자동 공유하지 않습니다. + +사용자 종료·15분 만료·연결 종료/재접속·세션 종료·공유 변경/참여자 제외·정책/프로필/관측 가능한 전송 경로 변경·사용자의 직접 제어권 회수 시 해제됩니다. 일반 lease 자연 만료만으로는 해제하지 않지만 실제 입력에는 유효한 lease가 필요합니다. 큐 명령도 실제 PTY 입력 직전에 재검증하며, 만료·철회·문맥 변경이면 실행하지 않고 기존 입력 잔존 복구 절차를 유지합니다. 새 옵션을 켜서 소급 승인할 수 없으며 이미 실행된 프로세스를 자동 종료하지 않습니다. 새 감사 기록에는 grant/session/connection ID·문맥 revision·지속시간·시각·종료 사유만 남기며 비밀·전체 환경·원시 인간 입력·터미널 내용을 추가 수집하지 않습니다. diff --git a/docs/faq.md b/docs/faq.md index cc3b2e7..312995e 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -65,3 +65,7 @@ In-app installation supports Apple Silicon macOS, Windows x64 and Linux AppImage ## How can I help? Try the first collaboration and tell us where you got stuck or when you wanted to intervene. Include the Conn version, OS, shell and client, plus a small public example. Review screenshots and request details before sharing; a full terminal dump is rarely needed. [Report a problem](https://github.com/eggp-dev/conn/issues/new/choose) · [Contribute](../CONTRIBUTING.md) + +## Can I temporarily reduce environment checks? + +Yes. The local owner UI can reduce the environment-only review for one exact connection in the current session for 15 minutes. It is off by default, never auto-renews and does not approve pending cards. Risk reviews and unsupported syntax keep their confirmation. The target remains unverified, including undetectable changes inside nested remote connections. Use the visible Stop control to end it. See [Reduce environment checks](policy.md#reduce-environment-checks) for scope, invalidation and queued-command behavior. diff --git a/docs/getting-started.ko.md b/docs/getting-started.ko.md index b18c8b4..89b57cc 100644 --- a/docs/getting-started.ko.md +++ b/docs/getting-started.ko.md @@ -13,37 +13,37 @@ brew install --cask eggp-dev/tap/conn curl -fsSL https://conn.eggp.dev/install.sh | sh # Linux x86_64 ``` -cask는 서명·공증된 DMG를 설치하며 릴리스와 함께 공개된 SHA-256으로 확인합니다. Linux 스크립트는 AppImage를 내려받아 릴리스의 `SHA256SUMS`와 대조한 뒤 내 계정에만 설치합니다(`~/.local/bin`, 앱 메뉴 항목, sudo 불필요). 체크섬이 맞지 않으면 설치하지 않습니다. `CONN_VERSION=v0.8.9`로 버전을 고정할 수 있습니다. AppImage는 FUSE 2가 필요합니다. Ubuntu 24.04 이상에서는 `sudo apt install libfuse2t64`를 실행하세요. 두 방법 모두 이후에는 앱 안에서 업데이트됩니다. 셸에 넘기기 전에 스크립트를 읽어 보셔도 좋습니다: [`scripts/install.sh`](../scripts/install.sh). +cask는 서명·공증된 DMG를 설치하며 릴리스와 함께 공개된 SHA-256으로 확인합니다. Linux 스크립트는 AppImage를 내려받아 릴리스의 `SHA256SUMS`와 대조한 뒤 내 계정에만 설치합니다(`~/.local/bin`, 앱 메뉴 항목, sudo 불필요). 체크섬이 맞지 않으면 설치하지 않습니다. `CONN_VERSION=v0.9.0`로 버전을 고정할 수 있습니다. AppImage는 FUSE 2가 필요합니다. Ubuntu 24.04 이상에서는 `sudo apt install libfuse2t64`를 실행하세요. 두 방법 모두 이후에는 앱 안에서 업데이트됩니다. 셸에 넘기기 전에 스크립트를 읽어 보셔도 좋습니다: [`scripts/install.sh`](../scripts/install.sh). 직접 파일을 받으려면: -OS와 CPU에 맞는 [v0.8.9 프리뷰](https://github.com/eggp-dev/conn/releases/tag/v0.8.9)를 받으세요. 실제 서명 상태와 실행 검증 결과는 릴리스 안내에서 확인할 수 있습니다. +OS와 CPU에 맞는 [v0.9.0 프리뷰](https://github.com/eggp-dev/conn/releases/tag/v0.9.0)를 받으세요. 실제 서명 상태와 실행 검증 결과는 릴리스 안내에서 확인할 수 있습니다. | 플랫폼 | 다운로드 | 설치 | |---|---|---| -| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.deb) | 아래 명령으로 설치한 뒤 앱 목록에서 Conn을 엽니다. | -| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.AppImage) | 실행 권한을 준 뒤 파일을 엽니다. | -| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-aarch64-apple-darwin-desktop.dmg) | DMG를 열고 Conn을 Applications로 옮긴 뒤 그곳에서 실행합니다. | -| Windows x64 | [설치 `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-pc-windows-msvc-setup.exe) | 설치 파일을 실행하고 시작 메뉴에서 Conn을 엽니다. | +| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.deb) | 아래 명령으로 설치한 뒤 앱 목록에서 Conn을 엽니다. | +| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.AppImage) | 실행 권한을 준 뒤 파일을 엽니다. | +| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-aarch64-apple-darwin-desktop.dmg) | DMG를 열고 Conn을 Applications로 옮긴 뒤 그곳에서 실행합니다. | +| Windows x64 | [설치 `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-pc-windows-msvc-setup.exe) | 설치 파일을 실행하고 시작 메뉴에서 Conn을 엽니다. | 새 릴리스의 Intel Mac 배포는 잠시 중단합니다. 기존 Intel 파일은 [이전 릴리스](https://github.com/eggp-dev/conn/releases)에 유지하며 Intel Mac에 Apple Silicon 파일을 설치하지 마세요. Ubuntu에서는 다운로드한 폴더에서 실행합니다. ```sh -sudo apt install ./conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.deb +sudo apt install ./conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.deb ``` AppImage를 선택했다면 다음과 같이 실행합니다. ```sh -chmod +x ./conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.AppImage -./conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.AppImage +chmod +x ./conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.AppImage +./conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.AppImage ``` Linux 빌드 대상은 Ubuntu 24.04·26.04 x64이며 AppImage에도 시스템 라이브러리가 필요합니다. Windows 프리뷰는 의도적으로 무서명 배포하므로 SmartScreen·알 수 없는 배포자 경고가 나올 수 있고 관리되는 PC에서는 설치가 차단될 수 있습니다. Mac 공개 파일은 릴리스의 Developer ID 서명·공증 검사를 통과해야 합니다. 각 대상의 범위는 [플랫폼 지원](platform-support.ko.md)을 참고하세요. -원하면 파일과 함께 [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.9/SHA256SUMS)를 받으세요. Linux는 `sha256sum --ignore-missing -c SHA256SUMS`, macOS는 `shasum -a 256 <파일>` 결과와 해당 줄 비교, Windows는 `Get-FileHash <파일> -Algorithm SHA256`을 사용합니다. 체크섬은 파일 손상을 확인하며 코드 서명과는 별개입니다. +원하면 파일과 함께 [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.9.0/SHA256SUMS)를 받으세요. Linux는 `sha256sum --ignore-missing -c SHA256SUMS`, macOS는 `shasum -a 256 <파일>` 결과와 해당 줄 비교, Windows는 `Get-FileHash <파일> -Algorithm SHA256`을 사용합니다. 체크섬은 파일 손상을 확인하며 코드 서명과는 별개입니다. ## 2. 터미널 설정 diff --git a/docs/getting-started.md b/docs/getting-started.md index ec3ed72..dd94cec 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,37 +13,37 @@ brew install --cask eggp-dev/tap/conn curl -fsSL https://conn.eggp.dev/install.sh | sh # Linux x86_64 ``` -The cask installs the signed and notarized DMG and checks it against the SHA-256 published with the release. The Linux script downloads the AppImage, verifies it against the release's `SHA256SUMS`, and installs it for your user only (`~/.local/bin`, an app-menu entry, no sudo); it refuses to install on a checksum mismatch. `CONN_VERSION=v0.8.9` pins a release. AppImages need FUSE 2: on Ubuntu 24.04 and newer, `sudo apt install libfuse2t64`. Both keep updating from inside the app. Read the script before piping it to a shell if you like: [`scripts/install.sh`](../scripts/install.sh). +The cask installs the signed and notarized DMG and checks it against the SHA-256 published with the release. The Linux script downloads the AppImage, verifies it against the release's `SHA256SUMS`, and installs it for your user only (`~/.local/bin`, an app-menu entry, no sudo); it refuses to install on a checksum mismatch. `CONN_VERSION=v0.9.0` pins a release. AppImages need FUSE 2: on Ubuntu 24.04 and newer, `sudo apt install libfuse2t64`. Both keep updating from inside the app. Read the script before piping it to a shell if you like: [`scripts/install.sh`](../scripts/install.sh). Or download a file yourself: -Download the [v0.8.9 preview](https://github.com/eggp-dev/conn/releases/tag/v0.8.9) for your OS and CPU. See the release notes for the exact signing and runtime verification results. +Download the [v0.9.0 preview](https://github.com/eggp-dev/conn/releases/tag/v0.9.0) for your OS and CPU. See the release notes for the exact signing and runtime verification results. | Platform | Download | Install | |---|---|---| -| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.deb) | Run the command below, then open Conn from your applications. | -| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.AppImage) | Make the file executable, then open it. | -| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-aarch64-apple-darwin-desktop.dmg) | Open the DMG, drag Conn into Applications, then launch it there. | -| Windows x64 | [Installer `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.9/conn-v0.8.9-x86_64-pc-windows-msvc-setup.exe) | Run the installer, then open Conn from the Start menu. | +| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.deb) | Run the command below, then open Conn from your applications. | +| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.AppImage) | Make the file executable, then open it. | +| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-aarch64-apple-darwin-desktop.dmg) | Open the DMG, drag Conn into Applications, then launch it there. | +| Windows x64 | [Installer `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.9.0/conn-v0.9.0-x86_64-pc-windows-msvc-setup.exe) | Run the installer, then open Conn from the Start menu. | Intel Mac distribution is paused for future releases. Previously published Intel packages remain in [past releases](https://github.com/eggp-dev/conn/releases); do not install the Apple Silicon package on an Intel Mac. For Ubuntu, run the matching command in your download directory: ```sh -sudo apt install ./conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.deb +sudo apt install ./conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.deb ``` Or, for AppImage: ```sh -chmod +x ./conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.AppImage -./conn-v0.8.9-x86_64-unknown-linux-gnu-desktop.AppImage +chmod +x ./conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.AppImage +./conn-v0.9.0-x86_64-unknown-linux-gnu-desktop.AppImage ``` The Linux build targets Ubuntu 24.04 and 26.04 x64; AppImage still depends on system libraries. Windows previews are intentionally unsigned, so SmartScreen or an unknown-publisher prompt may appear; managed PCs may block installation. Public Mac assets must pass the release's Developer ID signing and notarization checks. See [platform support](platform-support.md) for the limits of each target. -Optional: download [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.9/SHA256SUMS) alongside your file. On Linux, run `sha256sum --ignore-missing -c SHA256SUMS`; on macOS, compare `shasum -a 256 ` with its line; on Windows, use `Get-FileHash -Algorithm SHA256`. Checksums detect corruption and are separate from code signing. +Optional: download [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.9.0/SHA256SUMS) alongside your file. On Linux, run `sha256sum --ignore-missing -c SHA256SUMS`; on macOS, compare `shasum -a 256 ` with its line; on Windows, use `Get-FileHash -Algorithm SHA256`. Checksums detect corruption and are separate from code signing. ## 2. Set up your terminal diff --git a/docs/platform-support.ko.md b/docs/platform-support.ko.md index 0f86845..12f5177 100644 --- a/docs/platform-support.ko.md +++ b/docs/platform-support.ko.md @@ -2,7 +2,7 @@ [English](platform-support.md) · 한국어 · [설치](getting-started.ko.md) · [릴리스](releasing.ko.md) -Conn은 MIT 라이선스 소스와 네이티브 바이너리를 [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.9)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. +Conn은 MIT 라이선스 소스와 네이티브 바이너리를 [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.9.0)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. ## 다음 프리뷰 릴리스 대상 diff --git a/docs/platform-support.md b/docs/platform-support.md index 36a01de..ebb5d1a 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -2,7 +2,7 @@ English · [한국어](platform-support.ko.md) · [Install](getting-started.md) · [Release](releasing.md) -Conn provides its MIT-licensed source and native binaries through [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.9). All desktop packages use the same Rust engine and Svelte UI. The browser harness is a development adapter. +Conn provides its MIT-licensed source and native binaries through [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.9.0). All desktop packages use the same Rust engine and Svelte UI. The browser harness is a development adapter. ## Targets for upcoming preview releases diff --git a/docs/policy.md b/docs/policy.md index 7dd34d6..db7cb8a 100644 --- a/docs/policy.md +++ b/docs/policy.md @@ -91,3 +91,19 @@ The structural analysis is deterministic and conservative. It catches or questio ## Command extraction The string that is checked is the input line at ENTER. conn tracks the line from the bytes written to the PTY, and after tab completion or history navigation it uses the VT model's cursor row with the prompt removed. The `cmd` in the audit log is this value. + +## Reduce environment checks + +The default remains one review per command in an unverified execution environment. When an environment review is pending in the local owner UI, **Reduce environment checks** lets you select one exact agent connection in the current session, read the warning, and acknowledge that you will check the execution target yourself. The decision lasts exactly 15 minutes in memory. It cannot be saved as a default, extended by polling, inherited by another connection with the same name, or created through agent IPC/MCP. Closing and reopening the controls does not grant anything. The visible banner shows the session, connection ID, remaining time and **Stop**. + +This first version reduces only the environment overlay for supported simple queries in an explicit POSIX target dialect: `:`, `true`, `false`, `pwd`, `echo`, `printf`, `ls`, `cat`, `head`, `tail`, `wc`, `grep`, `fgrep`, `egrep`, `cut`, `tr`, `cmp`, `readlink`, `realpath`, `basename`, `dirname`, `uname`, `id`, `whoami`, `uptime`, `df`, `du`, `free` and `ps`. Policy deny and risk-confirm rules still apply first, including any custom rules for these queries. Existing command-category allowances do not remove those risk reviews in an unverified environment. + +All other commands keep review, even when the policy default is allow. Leading assignments, wrappers, `printf -v`, incomplete quoting, parameter/pathname/history expansion, quick history substitution and unsupported compound grammar also keep review. Quoted literal text remains supported. `printf` supports literal text, `%s` and `%%`; variable assignments and other format conversions keep review. Remote protected file targets are never checked against the host filesystem, so arbitrary file-changing commands and launchers do not qualify for reduction. Conn keeps its existing structural policy model; aliases, functions and executable identity remain unverified. + +Deny rules, command separation, dangerous-command confirmation, Observe, Co-pilot's manual commit, participation, Privacy, control leases and operating-system authentication retain their requirements. Pending approval cards are never approved by enabling this option. + +Conn cannot verify the final execution target or internal transitions. Nested remote connections may change the final host without detection. This feature does not verify target identity. Conn does not infer the end of password entry from prompts or ECHO and never automatically shares private input. + +Stop, expiry, disconnect/reconnect, session exit, sharing changes, participant removal, profile/policy changes, an observed transport change and direct human takeover revoke the decision. Natural control-lease expiry does not revoke it, but actual input still requires a current lease. A queued command is checked again immediately before PTY submission; an expired, revoked or changed context cancels it and retains the existing explicit input-recovery requirement. A new decision cannot retroactively authorize it. Already running processes are not killed. + +Audit records for this decision contain only grant/session/connection IDs, context revision, duration, timestamps and end reason; no terminal content, raw human input, secrets or environment dump is added. diff --git a/docs/release-0.9.0-validation.md b/docs/release-0.9.0-validation.md new file mode 100644 index 0000000..720f1b3 --- /dev/null +++ b/docs/release-0.9.0-validation.md @@ -0,0 +1,25 @@ +# v0.9.0 validation + +Implementation base: `744fa22f4396a3d4e762a29d6156ba4e12fcdb8b`, independently checked against upstream main before work. Existing checkouts were clean and were left untouched. All runtime tests below use disposable sessions; no existing user terminal, installed app or actual remote work environment was changed. + +## Local evidence + +- `cargo test --locked --offline`: 315 passed, one existing ignored test. Includes 18 new integration regressions and five monotonic/transport/queue unit regressions. +- New coverage: default per-command review; explicit POSIX-only ordinary-command reduction; risk/deny/unsupported syntax; unknown dialects; distinct same-name connections; no retroactive approval or category promotion; expiry/stop/profile/policy/sharing queue cancellation; retained-input recovery; natural lease expiry versus human takeover; nonrenewing reads/duplicate clicks; owner-only mutation and connection-scoped public state. +- `npm run test:ui`: all nine test files passed. +- `npm run build -w conn-desktop`, `npm run build:web`, `npm run build -w conn-site`, `npm run check -w conn-demo`: passed. Svelte check: zero errors; existing warnings remain. +- `npm run test:screen -w @conn/ui`: 85 grid/cursor comparisons passed. +- `npm run test:checkpoint -w @conn/ui`: 984/984 checkpoint/xterm cases passed. +- `npm run test:app-lifetime -w @conn/ui`: passed app/language/request/host-link/disposal isolation. +- Retained-input/header/connection production regressions: all 15 cases passed twice after keeping inactive environment controls inside a pending-review flow. This prevents a transient shell-status update from changing idle terminal geometry. +- Production browser/MCP: ten existing collaboration scenarios passed. Five new environment-controls scenarios passed using actual local owner UI, server, PTY and persistent MCP: acknowledgement/close/re-entry, pending-card nonapproval, countdown/reload, stop/disconnect/same-name reconnect, and unknown-dialect review. The unknown-dialect fixture deliberately keeps per-command review; known explicit POSIX behavior is covered in core tests. +- `node scripts/check-ui-boundaries.mjs`, `node scripts/generate-owner-contract.mjs --check`: passed. +- `python3 scripts/release.py check --tag v0.9.0`, repository hygiene/relative links, and 81 release-tooling tests: passed. +- `cargo clippy --locked --offline --all-targets`: passed with existing warnings. Strict `-D warnings` fails on existing `backend.rs`, `analysis.rs`, `session.rs` and `human_input.rs` lint findings. `cargo fmt --all --check` reports existing repository formatting drift; the original base `session.rs` independently fails rustfmt. New Rust files were formatted. No repository-wide formatting churn was introduced. +- Local native debug build was attempted and stopped at missing `gdk-3.0.pc`/GTK development libraries. No system dependency installation was performed. CI native builds and release packaging remain distinct evidence. + +## Limits + +Linux Chromium evidence does not establish native macOS/Windows interaction, Wayland, installed-app upgrades or all nested remote transitions. Unknown final host changes remain undetectable. No live user grant was enabled. Audit additions contain only decision identifiers, duration/revision and end reason. + +Release workflow, signing, checksums, exact-release package runtime and website publication are verified after the tagged pipeline; final status is recorded in the delivery report rather than inferred from local builds. diff --git a/frontends/tauri/package.json b/frontends/tauri/package.json index 29811e9..a766f24 100644 --- a/frontends/tauri/package.json +++ b/frontends/tauri/package.json @@ -1,7 +1,7 @@ { "name": "conn-desktop", "private": true, - "version": "0.8.9", + "version": "0.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontends/tauri/src-tauri/tauri.conf.json b/frontends/tauri/src-tauri/tauri.conf.json index 2e606cf..d2bd474 100644 --- a/frontends/tauri/src-tauri/tauri.conf.json +++ b/frontends/tauri/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Conn", - "version": "0.8.9", + "version": "0.9.0", "identifier": "dev.eggp.conn", "build": { "beforeDevCommand": "npm run dev", diff --git a/frontends/web/package.json b/frontends/web/package.json index 0d86b47..36ea8f1 100644 --- a/frontends/web/package.json +++ b/frontends/web/package.json @@ -1,6 +1,6 @@ { "name": "@conn/web", - "version": "0.8.9", + "version": "0.9.0", "private": true, "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index 5ea3f66..48f8d85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ }, "frontends/tauri": { "name": "conn-desktop", - "version": "0.8.9", + "version": "0.9.0", "dependencies": { "@conn/ui": "*", "@tauri-apps/api": "^2" @@ -1860,7 +1860,7 @@ }, "frontends/web": { "name": "@conn/web", - "version": "0.8.9", + "version": "0.9.0", "dependencies": { "@conn/ui": "*", "svelte": "^5.57.0" @@ -11524,7 +11524,7 @@ }, "packages/ui": { "name": "@conn/ui", - "version": "0.8.9", + "version": "0.9.0", "dependencies": { "@conn/brand": "*", "@conn/themes": "*", diff --git a/packages/ui/package.json b/packages/ui/package.json index 150a7f3..a369fb6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@conn/ui", - "version": "0.8.9", + "version": "0.9.0", "private": true, "type": "module", "exports": { diff --git a/packages/ui/src/ConnApp.svelte b/packages/ui/src/ConnApp.svelte index 554cbf6..1a0367c 100644 --- a/packages/ui/src/ConnApp.svelte +++ b/packages/ui/src/ConnApp.svelte @@ -27,6 +27,7 @@ import Center from "./components/Center.svelte"; import SettingsSheet from "./components/SettingsSheet.svelte"; import Ghost from "./components/Ghost.svelte"; + import EnvironmentReview from "./components/EnvironmentReview.svelte"; import Hold from "./components/Hold.svelte"; import GraceBar from "./components/GraceBar.svelte"; let island = $state(); @@ -451,7 +452,7 @@ openSettings(st.settingsTab)} onpalette={() => { st.settingsOpen = false; st.centerOpen = false; st.paletteOpen = true; }} ontimeline={() => { st.timelineOpen = !st.timelineOpen; }} /> {#if st.active} { st.paletteOpen = false; st.centerOpen = !st.centerOpen; }} />{/if}
{#if !st.settingsOpen}{/if}
-
void navigateNotice({session})} />{#if cur().shared}{/if}
+
void navigateNotice({session})} />{#if cur().shared}{#key st.active}{/key}{/if}
{#if !!cur().shared} {/if} diff --git a/packages/ui/src/components/EnvironmentReview.svelte b/packages/ui/src/components/EnvironmentReview.svelte new file mode 100644 index 0000000..46378ab --- /dev/null +++ b/packages/ui/src/components/EnvironmentReview.svelte @@ -0,0 +1,76 @@ + + +{#if active.length || (available && cur().agentConnections.length)} +
+ {#each active as grant (grant.grantId)} +
+ {t('environment.active', {time: remaining(grant.until)})} + {t('environment.target', {session: `${cur().title} (${cur().id})`, agent: grant.agentId, conn: grant.connId})} + +
+ {/each} + {#if available} +
+ {t('environment.title')} + {#each cur().agentConnections.filter(c => !active.some(g => g.connId === c.connId)) as c (c.connId)} + + {/each} + {#if connection} +
+ {t('environment.target', {session: `${cur().title} (${cur().id})`, agent: connection.agentId, conn: connection.connId})} +

{t('environment.warning')}

+

{t('environment.limit')}

+ +
+
+
+ {/if} +
+ {/if} + {#if error}

{error}

{/if} +
+{/if} + + diff --git a/packages/ui/src/lib/collaboration/events.ts b/packages/ui/src/lib/collaboration/events.ts index a03711d..8b8ada2 100644 --- a/packages/ui/src/lib/collaboration/events.ts +++ b/packages/ui/src/lib/collaboration/events.ts @@ -35,6 +35,7 @@ export type SessionEvent = { session: string } & ( | { event: 'attention_requested' | 'tab_opened'; agentId: string; reason?: string | null } | { event: 'agent_switched_tab'; agentId: string; from: string; to: string } | { event: 'session_allows_changed'; allows: string[] } + | { event: 'environment_review_changed'; grants: Omit[] } | { event: 'shell_integration_changed'; status: { state: string; shell?: string | null; reason?: string | null } } | { event: 'shell_command_started'; commandId: string; submissionId: string | null; actor: string; cmd: string; cwd: string } | { event: 'shell_command_finished'; commandId: string; exitCode: number | null; durationMs: number } diff --git a/packages/ui/src/lib/collaboration/session.ts b/packages/ui/src/lib/collaboration/session.ts index 8e2777f..a1f9d8c 100644 --- a/packages/ui/src/lib/collaboration/session.ts +++ b/packages/ui/src/lib/collaboration/session.ts @@ -5,6 +5,8 @@ export type SessionSnapshot = { shared: boolean; externalOrigin: boolean; surfaceAvailable: boolean; inputPending: boolean; externalStarting?: boolean; externalInputAvailable: boolean; processAlive: boolean; attended: boolean; profileId: string | null; profileName: string | null; reviewRequired: boolean; + environmentReview?: Omit[]; + agentConnections?: TabState["agentConnections"]; mode: Mode; effectiveMode: Mode; controlGate: boolean; pacing: Pacing; sessionAllows: string[]; affordanceMask: string[] | null; connectedAgents: string[]; controller: TabState['controller'] & {expiresInSecs?:number}; attentionRequest: {agentId: string; reason?: string | null} | null; openedBy: string | null; @@ -30,12 +32,12 @@ export function applyController(t: TabState, controller: TabState['controller']) export function applySessionEvent(t: TabState, ev: SessionEvent) { if (ev.session !== t.id) return; if (ev.event === 'sharing_changed') { - t.shared = ev.shared; t.surfaceAvailable = false; clearDecisions(t); + t.shared = ev.shared; t.environmentReview = []; t.surfaceAvailable = false; clearDecisions(t); t.timeline.recording = ev.shared; t.agents = []; t.lastAgent = null; t.attention = null; t.typing = false; applyController(t, { type: 'human' }); return; } - if (ev.event === 'process_exited') { t.processAlive = false; clearDecisions(t); applyController(t, { type: 'human' }); } + if (ev.event === 'process_exited') { t.processAlive = false; t.environmentReview = []; clearDecisions(t); applyController(t, { type: 'human' }); } if (ev.event === 'attention_changed') { t.attended = ev.attended; if (ev.attended) t.attention = null; } if (!t.shared) return; switch (ev.event) { @@ -56,6 +58,7 @@ export function applySessionEvent(t: TabState, ev: SessionEvent) { break; case 'approval_requested': t.approval = approvalState(ev.request, t.approval?.id === ev.request.id ? t.approval.at : undefined); break; case 'approval_resolved': if (t.approval?.id === ev.approvalId) t.approval = null; break; + case 'environment_review_changed': t.environmentReview = ev.grants.map(g => ({...g, until: performance.now() + g.remainingMs})); break; case 'session_allows_changed': t.allows = ev.allows; break; case 'exec_scheduled': t.grace = { execId: ev.execId, cmd: ev.cmd, ms: ev.graceMs, start: performance.now(), intent: ev.intent ?? undefined }; break; case 'exec_cancelled': if (t.grace?.execId === ev.execId) t.grace = null; break; @@ -67,6 +70,8 @@ export function applySessionEvent(t: TabState, ev: SessionEvent) { } } export function applySessionSnapshot(t: TabState, s: SessionSnapshot, privateTitle: string) { + t.environmentReview = (s.environmentReview ?? []).map(g => ({...g, until: performance.now() + g.remainingMs})); + t.agentConnections = s.agentConnections ?? []; t.shared = s.shared === true; t.externalOrigin = s.externalOrigin === true; t.surfaceAvailable = s.surfaceAvailable === true; diff --git a/packages/ui/src/lib/i18n.svelte.ts b/packages/ui/src/lib/i18n.svelte.ts index 4e73bac..c7739f9 100644 --- a/packages/ui/src/lib/i18n.svelte.ts +++ b/packages/ui/src/lib/i18n.svelte.ts @@ -11,6 +11,17 @@ export const LANGS: { id: Lang; name: string }[] = [ type Dict = Record; const en: Dict = { + "environment.title": "Reduce environment checks", + "environment.warning": "Conn cannot verify the final execution target or internal transitions. I will check the execution target myself. Risky commands still require confirmation.", + "environment.limit": "Supported simple POSIX query commands only; all other commands still require review. Only this session and exact connection, for 15 minutes. Pending approval cards still need your decision. Nested remote connections may change the final host without detection. This does not verify target identity. Unsupported syntax still requires review.", + "environment.ack": "I will check the execution target myself.", + "environment.start": "Reduce checks for 15 minutes", + "environment.stop": "Stop", + "environment.close": "Close", + "environment.active": "Target unverified · Checks reduced · {time} remaining", + "environment.target": "Session: {session} · Connection: {agent} (ID {conn})", + "environment.failed": "Could not change environment checks. Refresh the session state before retrying.", + "hold.input_retained": "Cancelled. The typed line remains in this program; inspect or clear it before continuing.", "terminal.syncing": "Restoring this terminal… Input is paused.", "terminal.restore": "Restore screen", @@ -643,6 +654,17 @@ const en: Dict = { }; const ko: Dict = { + "environment.title": "환경 확인 줄이기", + "environment.warning": "최종 실행 대상과 내부 전환을 확인할 수 없습니다. 실행 대상을 직접 확인하며 사용합니다. 위험 명령은 계속 확인합니다.", + "environment.limit": "지원되는 단순 POSIX 조회 명령에만 적용하며, 다른 명령은 계속 확인합니다. 현재 세션과 정확한 연결에만 15분 동안 적용합니다. 대기 승인 카드는 별도로 결정해야 합니다. 중첩 원격 연결의 최종 호스트 전환은 감지하지 못할 수 있으며, 대상 신원 검증을 보장하지 않습니다. 해석할 수 없는 문법은 계속 확인합니다.", + "environment.ack": "실행 대상을 직접 확인하며 사용합니다.", + "environment.start": "15분 동안 확인 줄이기", + "environment.stop": "종료", + "environment.close": "닫기", + "environment.active": "대상 미검증 · 확인 줄임 · 남은 시간 {time}", + "environment.target": "세션: {session} · 연결: {agent} (ID {conn})", + "environment.failed": "환경 확인을 변경하지 못했습니다. 세션 상태를 새로 확인한 후 다시 시도하세요.", + "hold.input_retained": "요청을 취소했습니다. 입력한 줄은 남아 있으니 확인하거나 지운 뒤 계속하세요.", "terminal.syncing": "터미널 화면을 복원하고 있습니다. 입력은 잠시 대기합니다.", "terminal.restore": "화면 다시 복원", diff --git a/packages/ui/src/lib/store.svelte.ts b/packages/ui/src/lib/store.svelte.ts index 7ae242b..32eac56 100644 --- a/packages/ui/src/lib/store.svelte.ts +++ b/packages/ui/src/lib/store.svelte.ts @@ -26,6 +26,8 @@ export type TabState = { profileId: string | null; profileName: string | null; reviewRequired: boolean; + environmentReview: { grantId: string; connId: number; agentId: string; remainingMs: number; contextRevision: number; until: number }[]; + agentConnections: {connId: number; agentId: string}[]; processAlive: boolean; policyBlockedUntil: number; attended: boolean; @@ -55,7 +57,7 @@ export type TabState = { export function newTab(id: string, n: number): TabState { return { - id, title: `Terminal ${n}`, statusReady: false, shared: false, externalOrigin: false, surfaceAvailable: false, inputPending: false, externalStarting: false, externalInputAvailable: false, profileId: null, profileName: null, reviewRequired: false, processAlive: true, policyBlockedUntil: 0, attended: false, attention: null, openedBy: null, + id, title: `Terminal ${n}`, statusReady: false, shared: false, externalOrigin: false, surfaceAvailable: false, inputPending: false, externalStarting: false, externalInputAvailable: false, profileId: null, profileName: null, reviewRequired: false, environmentReview: [], agentConnections: [], processAlive: true, policyBlockedUntil: 0, attended: false, attention: null, openedBy: null, controller: { type: "human" }, mode: "autopilot", effectiveMode: "autopilot", gate: false, pacing: { minWriteIntervalMs: 0, enterGraceMs: 0, leaseTtlSecs: 60, approvalTtlSecs: 300 }, mask: null, allows: [], agents: [], typing: false, proposal: null, approval: null, grace: null, ctlReq: null, diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 1c54201..287d1fc 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "conn", "description": "Conn — one shell, one hand on it. Registers the conn MCP server and a skill that teaches the agent how to work in a shell a human is watching.", - "version": "0.8.9", + "version": "0.9.0", "author": { "name": "eggp" }, diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index 5af9ba2..86a8c9d 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "conn", - "version": "0.8.9", + "version": "0.9.0", "description": "Conn — one shell, one hand on it. Registers the conn MCP server and a skill that teaches the agent how to work in a shell a human is watching.", "author": { "name": "eggp" diff --git a/tests/collaboration/package.json b/tests/collaboration/package.json index f9e5425..04f20e6 100644 --- a/tests/collaboration/package.json +++ b/tests/collaboration/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "node scripts/web-mcp.mjs", + "test": "node scripts/web-mcp.mjs && node scripts/environment-review.mjs", "test:input": "node scripts/input-rendering.mjs", "test:editor": "node scripts/editor-input.mjs", "test:regressions": "node scripts/header-input-regressions.mjs" diff --git a/tests/collaboration/scripts/environment-review.mjs b/tests/collaboration/scripts/environment-review.mjs new file mode 100644 index 0000000..d19662b --- /dev/null +++ b/tests/collaboration/scripts/environment-review.mjs @@ -0,0 +1,156 @@ +// Production web bundle + real Rust host + one persistent, matching MCP process. +// All owner actions use the rendered UI. No replacement app, runtime or store hook. +import { chromium } from 'playwright'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, mkdirSync, openSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createInterface } from 'node:readline'; +import { strict as assert } from 'node:assert'; + +const root = fileURLToPath(new URL('../../../', import.meta.url)); +const state = mkdtempSync(join(tmpdir(), 'conn-environment-ui-')); +const evidence = join(state, 'evidence'); mkdirSync(evidence); +// Point the unchanged production flow at an extracted release to verify the +// shipped UI/server/MCP together, without rebuilding any of those components. +const bundle = process.env.CONN_TEST_WEB_BUNDLE; +const binary = name => resolve(bundle ?? join(root, 'target/debug'), name + (process.platform === 'win32' ? '.exe' : '')); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +async function until(run, message, ms = 12_000) { + const start = Date.now(); let value; + do { value = await run(); if (value) return value; await sleep(80); } while (Date.now() - start < ms); + throw new Error(message); +} +writeFileSync(join(state, 'profiles.json'), JSON.stringify({ version: 1, revision: 0, defaultProfile: 'test', profiles: [{ id: 'test', name: 'Local collaboration test', backend: 'local', shell: 'custom', program: '/bin/bash', args: ['--noprofile', '--norc', '-i'], cwd: state, env: { PS1: 'conn-test$ ', HISTFILE: '/dev/null', HISTCONTROL: '' } }] })); +writeFileSync(join(state, 'app.json'), JSON.stringify({ mode: 'autopilot', gate: false, pacing: { enterGraceMs: 0, minWriteIntervalMs: 0, leaseTtlSecs: 120, approvalTtlSecs: 120 } })); +const log = openSync(join(state, 'server.log'), 'w'); +const server = spawn(binary('conn-web'), ['serve', '--state-dir', state, '--port', '0', '--ui-dir', bundle ? resolve(bundle, 'ui') : resolve(root, 'frontends/web/dist'), '--setup-home', join(state, 'setup-home')], { stdio: ['ignore', log, log] }); +let browser, context, page; +const agents = [], failures = [], cases = [], frames = []; +let rejectNextInput = false, rejectedInput = false; +class MCP { + constructor(name) { + this.pending = new Map(); this.id = 0; + this.child = spawn(binary('conn'), ['--socket', join(state, 'conn.sock'), 'mcp', '--agent-id', name, '--tools', 'static'], { stdio: ['pipe', 'pipe', log] }); + createInterface({ input: this.child.stdout }).on('line', line => { + const message = JSON.parse(line), pending = this.pending.get(message.id); + if (!pending) return; this.pending.delete(message.id); clearTimeout(pending.timer); + if (message.error) pending.reject(new Error(JSON.stringify(message.error))); else pending.resolve(message.result); + }); + agents.push(this); + } + call(method, params = {}) { + const id = ++this.id; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`MCP timeout: ${method} ${params.name ?? ''}`)); }, 18_000); + this.pending.set(id, { resolve, reject, timer }); + this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + }); + } + async start() { + await this.call('initialize', { protocolVersion: '2025-06-18', clientInfo: { name: 'Conn integration test', version: '1' }, capabilities: {} }); + await this.call('tools/list'); + } + async tool(name, args = {}) { + const result = await this.call('tools/call', { name, arguments: args }); + const text = result.content?.find(c => c.type === 'text')?.text; + if (result.isError) throw new Error(`${name}: ${text}`); + return JSON.parse(text); + } + close() { this.child.stdin.end(); for (const p of this.pending.values()) clearTimeout(p.timer); this.pending.clear(); } +} +const ready = () => page.locator('.host:not([hidden])[data-terminal-ready="true"]').waitFor(); +async function decided(agent, approvalId, expected) { + const result = await until(async () => { + const current = await agent.tool('terminal_check_approval', {approvalId}); + return current.state === 'pending' ? false : current; + }, `Approval ${approvalId} did not settle after the owner action`); + assert.ok(expected.includes(result.state), JSON.stringify(result)); + return result; +} +const input = () => page.locator('.host:not([hidden]) .xterm-helper-textarea'); +async function human(text, enter = true) { await ready(); await input().focus(); await page.keyboard.insertText(text); if (enter) await page.keyboard.press('Enter'); } +async function sharing(stop = false) { + const preparation = page.locator('[data-request-key^="preparation:"] .primary'); + if (!stop && await preparation.isVisible()) await preparation.click(); + else { + const trigger = page.locator('.sharing-trigger'); + if (await trigger.isVisible()) await trigger.click(); + else await page.locator('button.dot:not(.hidden)').click(); + await page.locator('.sharing-row button').click(); + } + await page.locator('.sharing').waitFor(); + if (stop) await page.locator('.sharing footer .ghost').click(); + else { await page.locator('.sharing input[type="checkbox"]').first().check(); await page.locator('.sharing footer .btn:not(.ghost)').click(); } + await page.locator('.sharing').waitFor({ state: 'hidden' }); +} + +try { + await until(() => existsSync(join(state, 'connection.json')), 'No connection file'); + const metadata = JSON.parse(readFileSync(join(state,'connection.json'),'utf8')); + browser = await chromium.launch({headless:true,args:['--no-sandbox']}); + context = await browser.newContext({viewport:{width:1280,height:800},locale:'en-US'}); + page = await context.newPage(); + page.on('pageerror',e=>failures.push(e.message)); + await page.goto(metadata.url+'/#token='+encodeURIComponent(metadata.bootstrapToken)); + await ready(); + await sharing(true); + const agent = new MCP('environment-ui'); await agent.start(); + await page.locator('[data-request-key^="connection:"] .primary').click(); + await agent.tool('terminal_request_attention',{reason:'Test environment check controls in an isolated shell.'}); + await sharing(); + await until(async()=> (await agent.tool('terminal_list_tabs')).tabs === 1,'No shared shell'); + await agent.tool('terminal_request_control',{reason:'Inspect isolated environment review controls.'}); + await agent.tool('terminal_type',{text:'printf UI_REVIEW'}); + const pending=await agent.tool('terminal_send_key',{key:'ENTER',intent:'Print a marker in the disposable shell.'}); + assert.equal(pending.status,'pending'); + const section=page.locator('.environment-review'); + await section.locator('summary').click(); + await section.getByRole('button',{name:/Session:/}).click(); + assert.equal(await section.getByRole('button',{name:'Reduce checks for 15 minutes',exact:true}).isDisabled(),true); + await section.getByRole('button',{name:'Close',exact:true}).click(); + await section.getByRole('button',{name:/Session:/}).click(); + assert.equal(await section.locator('input[type=checkbox]').isChecked(),false); + await section.locator('input[type=checkbox]').check(); + await section.getByRole('button',{name:'Reduce checks for 15 minutes',exact:true}).click(); + await section.locator('.active strong').waitFor(); + assert.equal((await agent.tool('terminal_check_approval',{approvalId:pending.approvalId})).state,'pending'); + const initial=await section.locator('.active strong').innerText(); + await sleep(1200); + assert.notEqual(await section.locator('.active strong').innerText(),initial,'countdown must advance'); + await page.screenshot({path:join(evidence,'environment-active.png')}); + await page.reload(); await ready(); + await page.locator('.environment-review .active strong').waitFor(); + await page.locator('.environment-review .active button').click(); + await page.locator('.environment-review .active').waitFor({state:'hidden'}); + assert.equal((await agent.tool('terminal_check_approval',{approvalId:pending.approvalId})).state,'pending'); + await page.locator('[data-request-key*="review:"]').getByRole('button',{name:'Run once',exact:true}).click(); + await decided(agent,pending.approvalId,['granted']); + await page.locator('.environment-review').waitFor({state:'hidden'}); + await agent.tool('terminal_type',{text:'printf UI_REVIEW_AGAIN'}); + const second=await agent.tool('terminal_send_key',{key:'ENTER',intent:'Print a second disposable marker.'}); + assert.equal(second.status,'pending'); + await page.locator('.environment-review summary').click(); + await page.locator('.environment-review').getByRole('button',{name:/Session:/}).click(); + await page.locator('.environment-review input[type=checkbox]').check(); + await page.locator('.environment-review').getByRole('button',{name:'Reduce checks for 15 minutes',exact:true}).click(); + await page.locator('.environment-review .active').waitFor(); + agent.close(); + await page.locator('.environment-review .active').waitFor({state:'hidden'}); + const replacement=new MCP('environment-ui'); await replacement.start(); + await page.locator('[data-request-key^="connection:"] .primary').click(); + assert.equal(await page.locator('.environment-review .active').count(),0); + assert.deepEqual(failures,[]); + cases.push('explicit acknowledgement and close/re-entry reset','pending approval is not approved by enabling or stopping','countdown and owner reload preserve fixed deadline','stop, disconnect and same-name reconnect revoke grant','unknown dialect remains under per-command review'); + writeFileSync(join(evidence,'results.json'),JSON.stringify({cases,pageErrors:failures},null,2)); + console.log(`PASS ${cases.length} environment review UI scenarios\nEvidence: ${evidence}`); +} catch (error) { + writeFileSync(join(evidence, 'frames.json'), JSON.stringify(frames, null, 2)); + if (page) { await page.screenshot({ path: join(evidence, 'failure.png') }).catch(() => {}); writeFileSync(join(evidence, 'failure-dom.txt'), await page.locator('body').innerText().catch(() => '')); } + console.error(`Evidence: ${evidence}`); throw error; +} finally { + for (const agent of agents) { agent.close(); agent.child.kill(); } + await browser?.close(); server.kill(); + if (server.exitCode === null) await Promise.race([new Promise(resolve => server.once('exit', resolve)), sleep(5000).then(() => server.kill('SIGKILL'))]); +}