diff --git a/ROADMAP.md b/ROADMAP.md index ce1fa3b..b5722a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,24 +25,21 @@ This release focuses on making Cass easier to interrupt, easier to audit, and sa - Replace superseded model-context output with a short note indicating it was omitted because a newer read exists. - Be careful with partial reads: a later read of the same file does not always supersede a different line range. -### Usage and Cost Visibility - -- [ ] **Track provider-reported usage.** Capture input/output token usage returned by providers when available. - - Support streaming usage metadata for OpenAI-compatible providers where supported. - - Persist usage information with the conversation when possible. - - Show useful per-turn or session usage in the UI/status output. - -- [ ] **Track cost when available.** Display cost in the footer or status when the provider returns cost directly. - - If provider cost is unavailable, show token usage rather than estimating silently. - - Add explicit pricing metadata later if estimated costs are needed. - ### Safety and Reviewability -- [ ] **Optional destructive-operation confirmation.** Add a configurable confirmation prompt for risky operations in full-access mode. +- [x] **Policy-based access control and workspace-edit mode.** Add a central security policy layer and a new `workspace-edit` mode. See `plans/SECURITY_ACCESS_MODES_PLAN.md`. + - `read-only`: inspect only inside the launch workspace and bundled Cass docs. + - `workspace-edit`: read and edit files inside the launch workspace without confirmation; bundled Cass docs remain read-only. + - `workspace-edit`: expose `shell`, but require explicit user confirmation before any command is spawned. + - `full-access`: preserve broad access while routing decisions through the same policy layer for future restrictions. + - Refactor tool gating, path authorization, prompt/tool availability, and shell approval through centralized policy decisions instead of scattered `mode.can_write()` checks. + - Add symlink-aware workspace boundary checks and tests for denied writes outside the workspace. + +- [x] **Optional destructive-operation confirmation.** Add a configurable confirmation prompt for risky operations in full-access mode, built on the new policy/approval layer. - Cover `write`, `edit`, and `shell` operations that overwrite files or appear destructive. - Keep the first version conservative and explicit rather than trying to perfectly classify every shell command. - Do not block normal read-only tools. -- [ ] **Edit diff output.** Make `edit` changes reviewable in the transcript. +- [x] **Edit diff output.** Make `edit` changes reviewable in the transcript. - First version: show a unified before/after diff after the edit is applied. - Later versions may add pre-apply approval, but that requires a confirmation flow between tools and the TUI. diff --git a/plans/SECURITY_ACCESS_MODES_PLAN.md b/plans/SECURITY_ACCESS_MODES_PLAN.md new file mode 100644 index 0000000..4e4236a --- /dev/null +++ b/plans/SECURITY_ACCESS_MODES_PLAN.md @@ -0,0 +1,538 @@ +# Security Policy and Workspace Edit Mode Implementation Plan + +## Goal + +Implement a new access mode that lets the agent inspect and modify files inside the launch workspace without per-file confirmation, while requiring explicit user confirmation before any shell command executes. At the same time, refactor Cass's security and safety model so future restrictions can be added centrally and safely instead of scattering `AccessMode` checks throughout tools, path helpers, prompts, and UI code. + +Working name for the new mode: `workspace-edit`. + +## Desired user-facing behavior + +Cass should support three access modes: + +| Mode | Read/list/search | Write/edit | Shell | +| --- | --- | --- | --- | +| `read-only` | Allowed only inside workspace and bundled Cass docs | Denied | Denied | +| `workspace-edit` | Allowed only inside workspace and bundled Cass docs | Allowed inside workspace only; bundled Cass docs remain read-only | Requires confirmation before execution | +| `full-access` | Allowed using normal OS permissions, except policy-specific blocked roots | Allowed using normal OS permissions, except policy-specific blocked roots | Allowed without confirmation by default | + +Notes: + +- The launch workspace is the process cwd or the path supplied by `--cwd`. +- Bundled Cass docs remain readable but never writable in all modes. +- `workspace-edit` should be safe enough to use as a normal coding mode. +- Shell approval must happen before process spawn; denied shell calls should not execute at all. +- If a tool call is denied or rejected by the user, Cass should append a normal failed tool result so provider tool-call structure remains valid. + +## Design principles + +1. Security decisions must be enforced in Rust code, not only in the system prompt. +2. All mode and path authorization should go through one central policy layer. +3. Tools should perform domain work, not own access-control logic. +4. UI confirmation should be a generic approval mechanism, not hardcoded inside the shell tool. +5. The policy model should support future rules such as destructive write confirmation, protected path deny-lists, file-size limits, env-file confirmation, and shell command classification. +6. Path checks must be symlink-aware and should avoid time-of-check/time-of-use gaps where practical. + +## High-level architecture + +Add a new security/policy layer that sits between the agent loop and tool execution. + +```text +model tool call + | + v +agent builds ToolRequest / ToolAction + | + v +SecurityPolicy::check(...) + | + +-- Allow --------> execute tool + | + +-- Ask ----------> emit ApprovalRequested, wait for UI decision + | + approve -> execute tool + | + deny ----> failed tool result + | + +-- Deny ---------> failed tool result +``` + +The tool implementations should still validate their inputs and operate defensively, but they should rely on pre-resolved policy decisions rather than checking `mode.can_write()` directly. + +## Proposed modules and types + +### `src/access.rs` + +Extend `AccessMode`: + +```rust +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AccessMode { + #[default] + ReadOnly, + WorkspaceEdit, + FullAccess, +} +``` + +Replace the binary `toggle()` with a cycle-friendly method: + +```rust +impl AccessMode { + pub fn next(self) -> Self { + match self { + Self::ReadOnly => Self::WorkspaceEdit, + Self::WorkspaceEdit => Self::FullAccess, + Self::FullAccess => Self::ReadOnly, + } + } + + pub fn as_str(self) -> &'static str { ... } +} +``` + +Avoid adding broader helpers like `can_write()` unless they delegate to the new policy layer or are clearly display-only. The current `can_write()` encourages bypassing the centralized policy. + +### New `src/security.rs` + +Add a policy module with explicit action and decision types. + +```rust +#[derive(Debug, Clone)] +pub struct SecurityContext { + pub mode: AccessMode, + pub cwd: PathBuf, + pub workspace_root: PathBuf, + pub docs_root: PathBuf, + pub read_roots: Vec, + pub blocked_write_roots: Vec, +} + +#[derive(Debug, Clone)] +pub enum ToolAction { + List { path: PathBuf }, + Read { path: PathBuf }, + Search { path: PathBuf }, + Write { path: PathBuf, creates: bool, overwrites: bool }, + Edit { path: PathBuf }, + Shell { command: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyDecision { + Allow, + Ask { reason: String }, + Deny { reason: String }, +} + +pub struct SecurityPolicy; +``` + +Suggested public methods: + +```rust +impl SecurityPolicy { + pub fn check(ctx: &SecurityContext, action: &ToolAction) -> PolicyDecision; + pub fn tool_availability(ctx: &SecurityContext, tool: &str) -> ToolAvailability; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolAvailability { + Unavailable, + Available, + RequiresApproval, +} +``` + +`ToolAvailability` is used for tool spec exposure and prompt text; `PolicyDecision` is used for specific tool calls. + +### Path policy helpers + +Move authorization-oriented path logic out of `src/tools/path.rs` into the policy layer or into a lower-level `security::paths` submodule. + +Needed helpers: + +```rust +pub fn canonicalize_existing(path: &Path) -> Result; +pub fn canonicalize_for_create_or_write(path: &Path) -> Result; +pub fn normalize_lexical(path: &Path) -> PathBuf; +pub fn is_under_root(path: &Path, root: &Path) -> bool; +pub fn is_under_any_root(path: &Path, roots: &[PathBuf]) -> bool; +``` + +Important details: + +- Existing read/list/search paths should be fully canonicalized before root checks. +- Write paths may not exist, so canonicalize the nearest existing ancestor and append missing components. +- The workspace root, docs root, and blocked write roots should be canonicalized once when the tool/security context is built. +- For writes, if the final path exists, canonicalize it and confirm it is within the allowed root for `workspace-edit`. +- For writes to new paths, canonicalize the nearest existing parent and ensure that parent is within the workspace root and not under a blocked write root. +- Reject paths with no existing ancestor. + +## Mode-specific policy rules + +### `read-only` + +- `ls`, `read`, and `grep` are available. +- Read/search/list paths must be under `workspace_root` or `docs_root`. +- `write`, `edit`, and `shell` are unavailable and denied. + +### `workspace-edit` + +- `ls`, `read`, and `grep` are available. +- Read/search/list paths must be under `workspace_root` or `docs_root`. +- `write` and `edit` are available for paths under `workspace_root` only. +- `write` and `edit` are denied under `docs_root` or any blocked write root. +- `write` and `edit` outside `workspace_root` are denied. +- `shell` is available but every shell command returns `Ask` before execution. + +### `full-access` + +- All tools are available. +- `ls`, `read`, and `grep` use normal OS permissions and are not restricted to workspace by default. +- `write` and `edit` use normal OS permissions but remain denied under `docs_root` and other blocked write roots. +- `shell` is allowed by default. +- Future config may add confirmation for destructive operations in this mode without changing tool implementations. + +## Tool API changes + +### `src/tools/mod.rs` + +Replace mode-based availability with policy-based availability: + +```rust +pub fn available_tool_names(ctx: &SecurityContext) -> Vec; +pub fn specs(ctx: &SecurityContext) -> Vec; +``` + +`workspace-edit` should include `shell` in the tool specs because the model may request shell commands, but the prompt/tool description must state that shell requires user approval. + +Update `ToolContext` so it includes a `SecurityContext` or has enough fields to construct one without duplicating policy data. + +```rust +pub struct ToolContext { + pub security: SecurityContext, + pub model_result_limit: usize, + pub runtime_tx: Option>, +} +``` + +If a smaller migration is desired, keep existing fields temporarily but add `security` and gradually remove direct `mode`, `read_roots`, and `blocked_write_roots` dependencies. + +### Individual tools + +Refactor tools so they no longer call `ctx.mode.can_write()`. + +- `ls`, `read`, `grep`: resolve target path, then rely on prior or local policy validation for list/read/search action. +- `write`, `edit`: resolve target path through the policy-aware resolver and do not contain mode-specific checks. +- `shell`: remove `if !ctx.mode.can_write()` and trust that the agent/policy gate checked the command before calling `shell::run`. + +To avoid accidental direct tool execution bypasses, `tools::execute` should remain a second enforcement point: + +1. Convert the tool name/args to a `ToolAction`. +2. Check policy. +3. Only execute on `Allow`. +4. Return a failed `ToolOutput` for `Deny` or unexpected `Ask` when no approval has been provided. + +The agent should normally perform this check before approval, but `tools::execute` should defensively enforce it too. + +## Approval flow + +### Agent event changes + +Extend `AgentEvent`: + +```rust +pub enum AgentEvent { + ... + ApprovalRequested { + request_id: String, + tool_call_id: String, + name: String, + arguments: Value, + reason: String, + }, + ApprovalResolved { + request_id: String, + approved: bool, + }, +} +``` + +Add an input channel from UI to agent: + +```rust +#[derive(Debug, Clone)] +pub enum AgentCommand { + ApprovalDecision { + request_id: String, + approved: bool, + }, +} +``` + +Update `AgentSettings` or `run_turn` signature to accept the receiver: + +```rust +pub async fn run_turn( + conversation: Conversation, + user_message: String, + settings: AgentSettings, + tx: mpsc::UnboundedSender, + command_rx: mpsc::UnboundedReceiver, +) -> Result +``` + +Alternative: pass a single-use approval responder through a dedicated channel stored in `AgentSettings`. The explicit command channel is more extensible for future interactive controls. + +### Agent execution logic + +Before executing each tool call: + +1. Emit `ToolCallStarted` as today. +2. Build `ToolAction` from the tool call. +3. Call `SecurityPolicy::check`. +4. If `Allow`, execute the tool. +5. If `Deny`, create `ToolOutput { ok: false, content: reason }` without executing. +6. If `Ask`, emit `ApprovalRequested` and wait for matching `AgentCommand::ApprovalDecision`. + - Approved: execute the tool. + - Denied: create failed tool output like `user denied shell command: `. + - Turn cancelled while waiting: abort cleanly through existing cancellation path. + +Approval wait should be cancellable when the turn task is aborted. Since the agent currently runs inside a Tokio task and cancellation aborts the task, this is mostly automatic, but avoid spawning detached execution before approval. + +### UI behavior in `src/app.rs` + +Add pending approval state: + +```rust +struct PendingApproval { + request_id: String, + tool_call_id: String, + name: String, + arguments: Value, + reason: String, +} +``` + +When `ApprovalRequested` arrives: + +- Store it as pending. +- Add or update a transcript/status block showing the request. +- Set status to `approval required: press y to approve, n to deny`. + +Key handling while pending approval: + +- `y` or `Y`: send `AgentCommand::ApprovalDecision { approved: true }`. +- `n`, `N`, or `Esc`: send denied decision. +- `Ctrl-C`: preserve existing turn cancellation behavior. +- Other text input should either be ignored with a status hint or continue editing the input box but not submitted until approval resolves. Simpler first version: ignore non-approval keys except cancellation and scrolling. + +Render the approval content clearly, especially for shell: + +```text +Shell command requires approval in workspace-edit mode: + +cargo test + +Press y to approve, n to deny, Esc to deny, Ctrl-C to cancel the turn. +``` + +Do not execute the shell command until the approve decision reaches the agent. + +## CLI and config changes + +### CLI + +Add: + +```text +--workspace-edit +``` + +It should conflict with `--readonly` and `--full-access`. + +### Config + +Allow: + +```json +{ + "default_access_mode": "workspace-edit" +} +``` + +Config loading should still default to `read-only` unless project direction changes. + +### Mode cycling + +`Shift-Tab` should cycle: + +```text +read-only -> workspace-edit -> full-access -> read-only +``` + +Only while idle, preserving current behavior. + +## Prompt updates + +Update `src/prompt.rs` to describe each mode accurately. + +Key requirements: + +- Include `workspace-edit` in access-mode instructions. +- Tell the model that shell commands in `workspace-edit` require user confirmation and should be requested only when useful. +- Do not promise that prompt rules are the only enforcement. +- Update allowed tool list generation to use policy availability. + +Example text for the new mode: + +```text +In workspace-edit mode, you may inspect files with ls, read, and grep only inside the launch working directory or bundled Cass docs directory. You may write and edit files only inside the launch working directory. Bundled Cass docs are read-only. You may request shell when needed, but Cass will ask the user for confirmation before executing the command. +``` + +Update tool descriptions: + +- `write`: no longer says only `Requires full-access mode`; say it requires an access mode that permits writes and is subject to policy path restrictions. +- `edit`: same. +- `shell`: say it may require user confirmation depending on active access mode. + +## Conversation and provider correctness + +If an approval is denied, append a `Record::Tool` with: + +- matching `tool_call_id` +- `name` equal to the requested tool name +- `ok: false` +- content explaining that the user denied or policy denied the tool + +This is important so the next provider request has valid assistant-tool message structure. Do not drop denied tool calls. + +Approval events are UI/runtime-only and should not be persisted as separate conversation records in v1. The failed or successful tool result is sufficient persistent state. + +## Testing plan + +### Unit tests + +Add or update tests for `AccessMode`: + +- serde supports `workspace-edit` +- `as_str()` returns `workspace-edit` +- `next()` cycles through all three modes + +Add security policy tests: + +- read-only allows read under workspace +- read-only allows read under docs +- read-only denies write/edit/shell +- read-only denies read outside workspace/docs +- workspace-edit allows write under workspace +- workspace-edit denies write outside workspace +- workspace-edit denies write under docs +- workspace-edit asks for shell +- full-access allows shell +- full-access still denies writes under docs +- symlink escape writes are denied in workspace-edit +- new file under symlinked parent outside workspace is denied + +### Tool tests + +Update `tests/tool_tests.rs`: + +- `workspace-edit` exposes `write`, `edit`, and `shell` +- write inside workspace succeeds in `workspace-edit` +- edit inside workspace succeeds in `workspace-edit` +- write outside workspace fails in `workspace-edit` +- edit outside workspace fails in `workspace-edit` +- shell without prior approval does not execute in `workspace-edit` if using defensive `tools::execute` +- read-only behavior is unchanged + +### Agent tests + +Add tests with a mock provider/tool-call sequence: + +- shell approval request is emitted in `workspace-edit` +- no shell output occurs before approval +- approved shell executes and appends successful tool result +- denied shell appends failed tool result and continues model loop +- policy-denied write appends failed tool result without executing +- cancellation while approval is pending leaves conversation resumable + +### UI/manual tests + +Manual checklist: + +- Start Cass in default read-only mode. +- Press `Shift-Tab` once: status/header shows `workspace-edit`. +- Press `Shift-Tab` again: status/header shows `full-access`. +- In workspace-edit, ask Cass to edit a file under cwd; no confirmation appears and file changes. +- In workspace-edit, ask Cass to run `pwd`; approval prompt appears before execution. +- Press `n`; command does not run and model receives denial result. +- Ask again and press `y`; command runs and output streams. +- Try to edit bundled Cass docs; denied. +- Try a symlink escape write; denied. + +## Migration strategy + +Implement in small, testable phases. + +### Phase 1: Mode and config plumbing + +- Add `AccessMode::WorkspaceEdit`. +- Add `--workspace-edit` CLI flag. +- Update config parsing and defaults. +- Update UI mode cycle and status/header rendering. +- Add serde/cycle tests. + +This phase may temporarily map `workspace-edit` to existing full-access behavior only in code behind tests, but should not ship until policy enforcement is complete. + +### Phase 2: Central policy layer + +- Add `src/security.rs`. +- Build `SecurityContext` from app/agent config. +- Implement tool availability and path decisions. +- Add comprehensive policy tests. + +### Phase 3: Refactor tool gating + +- Update `tools::available_tool_names` and `tools::specs` to use `SecurityContext`. +- Add policy checks to `tools::execute` as a defensive enforcement point. +- Remove direct `ctx.mode.can_write()` checks from tools. +- Update path resolution to use policy helpers. +- Update existing tool tests. + +### Phase 4: Approval channel and UI + +- Add `AgentCommand` channel. +- Add `ApprovalRequested` event. +- Gate `Ask` decisions before execution in `agent.rs`. +- Add pending approval UI handling in `app.rs`. +- Add agent approval tests. + +### Phase 5: Prompt, docs, and polish + +- Update `src/prompt.rs`. +- Update tool descriptions. +- Update README/config docs as needed. +- Update `ROADMAP.md` status and any manual testing instructions. +- Run `cargo fmt`, `cargo test`, and a manual TUI smoke test. + +## Future extensions enabled by this refactor + +Once the policy layer exists, additional restrictions can be added without rewriting every tool: + +- Configurable confirmation for destructive full-access operations. +- Deny or ask before touching `.env`, SSH keys, credentials, or lockfiles. +- Protect `.git` internals while allowing normal git commands through approved shell. +- Add per-project policy config. +- Add temporary approvals for a single turn or command pattern. +- Add audit logging for approved/denied actions. +- Add sandboxed shell execution in a later release. +- Add fine-grained modes such as `workspace-read-global-shell-ask` without changing tool internals. + +## Open decisions + +- Should `workspace-edit` become the default mode after it is stable, or should default remain `read-only`? +- Should `full-access` shell remain auto-allowed, or should shell confirmation be configurable globally? +- Should approvals support `approve once`, `approve all shell for this turn`, or only one-command approval in the first implementation? +- Should `workspace-edit` allow reading outside workspace with confirmation, or always deny? +- Should policy decisions be persisted in an audit log separate from conversation JSONL? diff --git a/src/access.rs b/src/access.rs index 4097500..5b44de2 100644 --- a/src/access.rs +++ b/src/access.rs @@ -6,26 +6,33 @@ use std::fmt; pub enum AccessMode { #[default] ReadOnly, + WorkspaceEdit, FullAccess, } impl AccessMode { - pub fn toggle(self) -> Self { + pub fn next(self) -> Self { match self { - Self::ReadOnly => Self::FullAccess, + Self::ReadOnly => Self::WorkspaceEdit, + Self::WorkspaceEdit => Self::FullAccess, Self::FullAccess => Self::ReadOnly, } } + pub fn toggle(self) -> Self { + self.next() + } + pub fn as_str(self) -> &'static str { match self { Self::ReadOnly => "read-only", + Self::WorkspaceEdit => "workspace-edit", Self::FullAccess => "full-access", } } pub fn can_write(self) -> bool { - matches!(self, Self::FullAccess) + matches!(self, Self::WorkspaceEdit | Self::FullAccess) } } diff --git a/src/agent.rs b/src/agent.rs index 4b1f2a4..3ab5f9a 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -4,11 +4,12 @@ use crate::conversation::{now_ts, Conversation, Record, StoredToolCall}; use crate::prompt; use crate::providers::openai_compatible::{OpenAiCompatibleProvider, OpenAiCompatibleSettings}; use crate::providers::types::ModelMessage; +use crate::security::PolicyDecision; use crate::tools::{self, ToolContext, ToolRuntimeEvent}; use anyhow::Result; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tokio::sync::mpsc; #[derive(Debug, Clone)] @@ -32,10 +33,26 @@ pub enum AgentEvent { ok: bool, content: String, }, + ApprovalRequested { + request_id: String, + tool_call_id: String, + name: String, + arguments: Value, + reason: String, + }, + ApprovalResolved { + request_id: String, + approved: bool, + }, Status(String), TurnFinished, } +#[derive(Debug, Clone)] +pub enum AgentCommand { + ApprovalDecision { request_id: String, approved: bool }, +} + #[derive(Debug, Clone)] pub struct AgentSettings { pub config: Config, @@ -47,10 +64,21 @@ pub struct AgentSettings { const EMPTY_FINAL_RETRY_PROMPT: &str = "The previous response contained no user-facing text. Provide a concise final user-facing response summarizing the outcome. Do not call tools unless absolutely necessary."; pub async fn run_turn( + conversation: Conversation, + user_message: String, + settings: AgentSettings, + tx: mpsc::UnboundedSender, +) -> Result { + let (_command_tx, command_rx) = mpsc::unbounded_channel(); + run_turn_with_commands(conversation, user_message, settings, tx, command_rx).await +} + +pub async fn run_turn_with_commands( mut conversation: Conversation, user_message: String, settings: AgentSettings, tx: mpsc::UnboundedSender, + mut command_rx: mpsc::UnboundedReceiver, ) -> Result { conversation.append(Record::User { content: user_message, @@ -114,7 +142,11 @@ pub async fn run_turn( }); } let completion = match provider - .complete(messages, tools::specs(settings.mode), &tx) + .complete( + messages, + tools::specs_for_context(&tool_ctx.security_context()), + &tx, + ) .await { Ok(c) => c, @@ -166,11 +198,119 @@ pub async fn run_turn( name: call_name.clone(), arguments: call_arguments.clone(), }); + let mut approved = + match tools::policy_decision_for_call(&call_name, &call_arguments, &tool_ctx) { + Some(PolicyDecision::Allow) | None => false, + Some(PolicyDecision::Deny { reason }) => { + let output = tools::ToolOutput { + ok: false, + content: reason, + }; + let _ = tx.send(AgentEvent::ToolResult { + id: call_id.clone(), + name: call_name.clone(), + ok: output.ok, + content: output.content.clone(), + }); + conversation.append(Record::Tool { + tool_call_id: call_id, + name: call_name, + ok: output.ok, + content: output.content, + ts: now_ts(), + })?; + continue; + } + Some(PolicyDecision::Ask { reason }) => { + let request_id = format!("approval_{}", nanoid::nanoid!(8)); + let _ = tx.send(AgentEvent::ApprovalRequested { + request_id: request_id.clone(), + tool_call_id: call_id.clone(), + name: call_name.clone(), + arguments: call_arguments.clone(), + reason, + }); + let approved = wait_for_approval(&mut command_rx, &request_id).await; + let _ = tx.send(AgentEvent::ApprovalResolved { + request_id: request_id.clone(), + approved, + }); + if !approved { + let output = tools::ToolOutput { + ok: false, + content: "user denied approval for this tool call".into(), + }; + let _ = tx.send(AgentEvent::ToolResult { + id: call_id.clone(), + name: call_name.clone(), + ok: output.ok, + content: output.content.clone(), + }); + conversation.append(Record::Tool { + tool_call_id: call_id, + name: call_name, + ok: output.ok, + content: output.content, + ts: now_ts(), + })?; + continue; + } + true + } + }; + if !approved { + if let Some(reason) = destructive_confirmation_reason( + settings.mode, + settings.config.confirm_destructive_operations, + &call_name, + &call_arguments, + &settings.cwd, + ) { + let request_id = format!("approval_{}", nanoid::nanoid!(8)); + let _ = tx.send(AgentEvent::ApprovalRequested { + request_id: request_id.clone(), + tool_call_id: call_id.clone(), + name: call_name.clone(), + arguments: call_arguments.clone(), + reason, + }); + approved = wait_for_approval(&mut command_rx, &request_id).await; + let _ = tx.send(AgentEvent::ApprovalResolved { + request_id, + approved, + }); + if !approved { + let output = tools::ToolOutput { + ok: false, + content: "user denied approval for this destructive tool call".into(), + }; + let _ = tx.send(AgentEvent::ToolResult { + id: call_id.clone(), + name: call_name.clone(), + ok: output.ok, + content: output.content.clone(), + }); + conversation.append(Record::Tool { + tool_call_id: call_id, + name: call_name, + ok: output.ok, + content: output.content, + ts: now_ts(), + })?; + continue; + } + } + } let (runtime_tx, mut runtime_rx) = mpsc::unbounded_channel::(); let mut call_tool_ctx = tool_ctx.clone(); call_tool_ctx.runtime_tx = Some(runtime_tx); let output = { - let execute = tools::execute(&call_name, call_arguments, &call_tool_ctx); + let execute = tools::execute_with_approval( + &call_name, + call_arguments, + &call_tool_ctx, + approved, + ); tokio::pin!(execute); let output = loop { tokio::select! { @@ -204,6 +344,87 @@ pub async fn run_turn( Ok(conversation) } +async fn wait_for_approval( + command_rx: &mut mpsc::UnboundedReceiver, + request_id: &str, +) -> bool { + while let Some(command) = command_rx.recv().await { + match command { + AgentCommand::ApprovalDecision { + request_id: id, + approved, + } if id == request_id => return approved, + _ => {} + } + } + false +} + +fn destructive_confirmation_reason( + mode: AccessMode, + enabled: bool, + name: &str, + arguments: &Value, + cwd: &Path, +) -> Option { + if !enabled || mode != AccessMode::FullAccess { + return None; + } + match name { + "write" => { + let path = arguments.get("path")?.as_str()?; + let p = crate::tools::path::expand_tilde(path); + let abs = if p.is_absolute() { p } else { cwd.join(p) }; + if abs.exists() { + Some(format!( + "write will overwrite an existing file in full-access mode: {}", + abs.display() + )) + } else { + None + } + } + "edit" => arguments + .get("path") + .and_then(Value::as_str) + .map(|path| format!("edit will modify a file in full-access mode: {path}")), + "shell" => { + let command = arguments.get("command")?.as_str()?; + if shell_command_looks_destructive(command) { + Some(format!( + "shell command appears destructive and requires confirmation: {command}" + )) + } else { + None + } + } + _ => None, + } +} + +fn shell_command_looks_destructive(command: &str) -> bool { + let lowered = command.to_ascii_lowercase(); + let risky = [ + "rm ", + "rm -", + "rmdir", + "mv ", + "chmod ", + "chown ", + "dd ", + "mkfs", + "truncate ", + "shred", + ">", + "tee ", + "git reset", + "git clean", + "drop table", + "delete from", + ]; + risky.iter().any(|needle| lowered.contains(needle)) +} + fn forward_tool_runtime_event( tx: &mpsc::UnboundedSender, id: &str, diff --git a/src/app.rs b/src/app.rs index 4bf9cdc..0d059f4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use crate::agent::{self, AgentEvent, AgentSettings}; +use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings}; use crate::cli::{self, Command}; use crate::config::{Config, ModelDefinition, ReasoningEffort}; use crate::conversation::{self, Conversation, Record}; @@ -159,6 +159,7 @@ async fn run_tui( transcript.extend(blocks_from_conversation(&conversation)); let (tx, mut rx) = mpsc::unbounded_channel::(); + let mut agent_command_tx: Option> = None; let mut input = String::new(); let mut mode = config.default_access_mode; let mut status = String::new(); @@ -177,6 +178,7 @@ async fn run_tui( let mut stick_to_bottom = true; let mut chat_id = conversation.id.clone(); let mut autofill_selected = 0usize; + let mut pending_approval: Option = None; loop { drain_agent_events( @@ -188,6 +190,7 @@ async fn run_tui( active_assistant: &mut active_assistant, active_reasoning: &mut active_reasoning, active_tools: &mut active_tools, + pending_approval: &mut pending_approval, status: &mut status, stick_to_bottom, show_full_tools, @@ -208,6 +211,7 @@ async fn run_tui( active_assistant: &mut active_assistant, active_reasoning: &mut active_reasoning, active_tools: &mut active_tools, + pending_approval: &mut pending_approval, status: &mut status, stick_to_bottom, show_full_tools, @@ -262,6 +266,8 @@ async fn run_tui( cancel_requested = false; current_turn_start_len = None; current_turn_message = None; + agent_command_tx = None; + pending_approval = None; status = finished_status; } @@ -312,6 +318,47 @@ async fn run_tui( match event { Event::Key(key) if key.kind == KeyEventKind::Press => { let busy = handle.is_some(); + if busy { + if let Some(pending) = pending_approval.clone() { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + if let Some(tx) = &agent_command_tx { + let _ = tx.send(AgentCommand::ApprovalDecision { + request_id: pending.request_id, + approved: true, + }); + } + hide_pending_approval( + &mut pending_approval, + &mut transcript, + &mut active_assistant, + &mut active_reasoning, + &mut active_tools, + ); + status = "approval sent".into(); + continue; + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + if let Some(tx) = &agent_command_tx { + let _ = tx.send(AgentCommand::ApprovalDecision { + request_id: pending.request_id, + approved: false, + }); + } + hide_pending_approval( + &mut pending_approval, + &mut transcript, + &mut active_assistant, + &mut active_reasoning, + &mut active_tools, + ); + status = "approval denied".into(); + continue; + } + _ => {} + } + } + } match (key.code, key.modifiers) { (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { let now = Instant::now(); @@ -362,7 +409,7 @@ async fn run_tui( if busy { status = "mode can be changed when idle".into(); } else { - mode = mode.toggle(); + mode = mode.next(); status = format!("mode: {mode}"); } } @@ -680,8 +727,11 @@ async fn run_tui( }; let convo = conversation.clone(); let tx2 = tx.clone(); + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::(); + agent_command_tx = Some(cmd_tx); handle = Some(tokio::spawn(async move { - agent::run_turn(convo, msg, settings, tx2).await + agent::run_turn_with_commands(convo, msg, settings, tx2, cmd_rx) + .await })); stick_to_bottom = true; scroll = bottom_scroll( @@ -776,6 +826,12 @@ async fn run_tui( } } +#[derive(Debug, Clone)] +struct PendingApproval { + request_id: String, + block_index: usize, +} + struct AgentEventContext<'a> { terminal: &'a terminal::CassTerminal, input: &'a str, @@ -783,6 +839,7 @@ struct AgentEventContext<'a> { active_assistant: &'a mut Option, active_reasoning: &'a mut Option, active_tools: &'a mut HashMap, + pending_approval: &'a mut Option, status: &'a mut String, stick_to_bottom: bool, show_full_tools: bool, @@ -909,6 +966,56 @@ fn apply_agent_event(event: AgentEvent, ctx: &mut AgentEventContext<'_>) -> Resu }); update_bottom_scroll(ctx)?; } + AgentEvent::ApprovalRequested { + request_id, + tool_call_id, + name, + arguments, + reason, + } => { + *ctx.active_assistant = None; + *ctx.active_reasoning = None; + let args = + serde_json::to_string_pretty(&arguments).unwrap_or_else(|_| arguments.to_string()); + let block_index = ctx.transcript.len(); + *ctx.pending_approval = Some(PendingApproval { + request_id: request_id.clone(), + block_index, + }); + ctx.transcript.push(TranscriptBlock { + kind: TranscriptKind::Status, + title: format!("approval required ({})", short_call_id(&tool_call_id)), + content: format!( + "{name} requires approval before execution.\n\nReason: {reason}\n\nArguments:\n{args}\n\nPress y to approve, n or Esc to deny, Ctrl-C to cancel the turn." + ), + }); + *ctx.status = "approval required: press y to approve, n to deny".into(); + update_bottom_scroll(ctx)?; + } + AgentEvent::ApprovalResolved { + request_id, + approved, + } => { + if ctx + .pending_approval + .as_ref() + .is_some_and(|pending| pending.request_id == request_id) + { + hide_pending_approval( + ctx.pending_approval, + ctx.transcript, + ctx.active_assistant, + ctx.active_reasoning, + ctx.active_tools, + ); + } + *ctx.status = if approved { + "approval accepted" + } else { + "approval denied" + } + .into(); + } AgentEvent::Status(s) => { ctx.transcript.push(TranscriptBlock { kind: TranscriptKind::Status, @@ -941,6 +1048,45 @@ fn active_tool_block(ctx: &mut AgentEventContext<'_>, id: &str, name: &str) -> u idx } +fn hide_pending_approval( + pending_approval: &mut Option, + transcript: &mut Vec, + active_assistant: &mut Option, + active_reasoning: &mut Option, + active_tools: &mut HashMap, +) { + let Some(pending) = pending_approval.take() else { + return; + }; + let idx = pending.block_index; + if idx >= transcript.len() { + return; + } + transcript.remove(idx); + adjust_index_after_remove(active_assistant, idx); + adjust_index_after_remove(active_reasoning, idx); + active_tools.retain(|_, tool_idx| { + if *tool_idx == idx { + false + } else { + if *tool_idx > idx { + *tool_idx -= 1; + } + true + } + }); +} + +fn adjust_index_after_remove(index: &mut Option, removed: usize) { + if let Some(value) = index { + if *value == removed { + *index = None; + } else if *value > removed { + *value -= 1; + } + } +} + fn mark_active_tool_blocks_cancelled( transcript: &mut [TranscriptBlock], active_tools: &HashMap, diff --git a/src/cli.rs b/src/cli.rs index 5400218..8e00200 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -28,11 +28,15 @@ pub struct Cli { pub cwd: Option, /// Force read-only mode. - #[arg(long, conflicts_with = "full_access")] + #[arg(long, conflicts_with_all = ["workspace_edit", "full_access"])] pub readonly: bool, + /// Force workspace-edit mode: edit workspace files directly, ask before shell. + #[arg(long, conflicts_with_all = ["readonly", "full_access"])] + pub workspace_edit: bool, + /// Force full-access mode. - #[arg(long, conflicts_with = "readonly")] + #[arg(long, conflicts_with_all = ["readonly", "workspace_edit"])] pub full_access: bool, } diff --git a/src/config.rs b/src/config.rs index ff0405b..0a3c489 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,6 +43,8 @@ pub struct ConfigFile { pub ui_tool_result_limit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub show_reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub confirm_destructive_operations: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -144,6 +146,7 @@ pub struct Config { pub model_tool_result_limit: usize, pub ui_tool_result_limit: usize, pub show_reasoning: bool, + pub confirm_destructive_operations: bool, pub root: PathBuf, pub docs_dir: PathBuf, } @@ -257,6 +260,7 @@ impl Default for Config { model_tool_result_limit: 24_000, ui_tool_result_limit: 4_000, show_reasoning: false, + confirm_destructive_operations: false, root, docs_dir, } @@ -321,11 +325,17 @@ impl Config { if let Some(v) = file.show_reasoning { cfg.show_reasoning = v; } + if let Some(v) = file.confirm_destructive_operations { + cfg.confirm_destructive_operations = v; + } } if cli.readonly { cfg.default_access_mode = AccessMode::ReadOnly; } + if cli.workspace_edit { + cfg.default_access_mode = AccessMode::WorkspaceEdit; + } if cli.full_access { cfg.default_access_mode = AccessMode::FullAccess; } diff --git a/src/lib.rs b/src/lib.rs index 980903e..41c952b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod docs; pub mod error; pub mod prompt; pub mod providers; +pub mod security; pub mod tools; pub mod ui; diff --git a/src/prompt.rs b/src/prompt.rs index 5a70d60..24c3e53 100644 --- a/src/prompt.rs +++ b/src/prompt.rs @@ -14,7 +14,7 @@ pub fn build_base_system_prompt(global: Option<&str>) -> String { } prompt.push_str("3. Tool-use style\n\n"); - prompt.push_str("Use tools when you need current filesystem context. Prefer targeted inspection over guessing. Batch related reads into one read call when possible. Use grep before read when a directory or file may be too large to inspect directly.\n\n"); + prompt.push_str("Use tools when you need current filesystem context. Prefer targeted inspection over guessing. Batch related reads into one read call when possible. Use grep before read when a directory or file may be too large to inspect directly. Do not ask the user in chat for permission before making a tool call; request the tool directly when it is the right next step. Cass enforces access policy at runtime and will allow, deny, or show a separate approval UI as needed.\n\n"); prompt.push_str("4. Editing style\n\n"); prompt.push_str("Use edit for targeted changes. Each edit must identify exact old text that appears uniquely in the file and replacement text. Do not use write to make small modifications to existing files unless a full rewrite is intentionally safer.\n"); prompt @@ -43,7 +43,8 @@ pub fn build_effective_system_prompt( allowed_tools.join(", ") )); match mode { - AccessMode::ReadOnly => prompt.push_str("In read-only mode, you may inspect files with ls, read, and grep only inside the launch working directory or bundled Cass docs directory. Do not request write or edit. If a task requires modification, explain what needs full-access mode.\n\n"), + AccessMode::ReadOnly => prompt.push_str("In read-only mode, you may inspect files with ls, read, and grep only inside the launch working directory or bundled Cass docs directory. Do not request write, edit, or shell. If a task requires modification, explain that a more permissive mode is needed.\n\n"), + AccessMode::WorkspaceEdit => prompt.push_str("In workspace-edit mode, you may inspect files with ls, read, and grep only inside the launch working directory or bundled Cass docs directory. You may write and edit files only inside the launch working directory. Bundled Cass docs are read-only. You may request shell when useful. Do not ask the user for shell permission in chat; call the shell tool directly and Cass will handle any required approval separately before execution.\n\n"), AccessMode::FullAccess => prompt.push_str("In full-access mode, you may request ls, read, grep, write, edit, and shell when needed. The shell tool runs commands in the launch working directory. Cass does not restrict read paths to the launch directory, but normal operating-system permissions still apply. write and edit are still blocked under the bundled Cass docs directory.\n\n"), } prompt.push_str("6. Response behavior\n\nAssistant output is streamed to the user. Keep user-facing text direct and useful. Tool calls and results are visible to the user, so avoid claiming work happened until the relevant tool result confirms it. After using tools or completing requested work, always end the turn with a concise final user-facing response. Do not finish a turn with only tool calls.\n"); diff --git a/src/security.rs b/src/security.rs new file mode 100644 index 0000000..ca87203 --- /dev/null +++ b/src/security.rs @@ -0,0 +1,302 @@ +use crate::access::AccessMode; +use anyhow::{bail, Context, Result}; +use std::ffi::OsString; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, Clone)] +pub struct SecurityContext { + pub mode: AccessMode, + pub cwd: PathBuf, + pub read_roots: Vec, + pub blocked_write_roots: Vec, +} + +#[derive(Debug, Clone)] +pub enum ToolAction { + List { path: PathBuf }, + Read { path: PathBuf }, + Search { path: PathBuf }, + Write { path: PathBuf }, + Edit { path: PathBuf }, + Shell { command: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyDecision { + Allow, + Ask { reason: String }, + Deny { reason: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolAvailability { + Unavailable, + Available, + RequiresApproval, +} + +pub struct SecurityPolicy; + +impl SecurityPolicy { + pub fn tool_availability(ctx: &SecurityContext, tool: &str) -> ToolAvailability { + match tool { + "ls" | "read" | "grep" => ToolAvailability::Available, + "write" | "edit" => match ctx.mode { + AccessMode::ReadOnly => ToolAvailability::Unavailable, + AccessMode::WorkspaceEdit | AccessMode::FullAccess => ToolAvailability::Available, + }, + "shell" => match ctx.mode { + AccessMode::ReadOnly => ToolAvailability::Unavailable, + AccessMode::WorkspaceEdit => ToolAvailability::RequiresApproval, + AccessMode::FullAccess => ToolAvailability::Available, + }, + _ => ToolAvailability::Unavailable, + } + } + + pub fn check(ctx: &SecurityContext, action: &ToolAction) -> PolicyDecision { + match action { + ToolAction::List { path } | ToolAction::Read { path } | ToolAction::Search { path } => { + check_read(ctx, path) + } + ToolAction::Write { path } | ToolAction::Edit { path } => check_write(ctx, path), + ToolAction::Shell { command } => match ctx.mode { + AccessMode::ReadOnly => PolicyDecision::Deny { + reason: "shell is unavailable in read-only mode".into(), + }, + AccessMode::WorkspaceEdit => PolicyDecision::Ask { + reason: format!( + "shell commands require user approval in workspace-edit mode: {command}" + ), + }, + AccessMode::FullAccess => PolicyDecision::Allow, + }, + } + } +} + +fn check_read(ctx: &SecurityContext, path: &Path) -> PolicyDecision { + match ctx.mode { + AccessMode::ReadOnly | AccessMode::WorkspaceEdit => match canonicalize_existing(path) { + Ok(path) if is_under_any_root(&path, &canonical_roots(&ctx.read_roots)) => { + PolicyDecision::Allow + } + Ok(path) => PolicyDecision::Deny { + reason: format!( + "path escapes read-only roots: {} (allowed roots: {})", + path.display(), + roots_display(&ctx.read_roots) + ), + }, + Err(err) => PolicyDecision::Deny { + reason: err.to_string(), + }, + }, + AccessMode::FullAccess => PolicyDecision::Allow, + } +} + +fn check_write(ctx: &SecurityContext, path: &Path) -> PolicyDecision { + if matches!(ctx.mode, AccessMode::ReadOnly) { + return PolicyDecision::Deny { + reason: "write/edit tools are unavailable in read-only mode".into(), + }; + } + + let canonical = match canonicalize_for_create_or_write(path) { + Ok(path) => path, + Err(err) => { + return PolicyDecision::Deny { + reason: err.to_string(), + } + } + }; + + let blocked = canonical_roots(&ctx.blocked_write_roots); + if is_under_any_root(&canonical, &blocked) + || is_under_any_root(&normalize_lexical(path), &ctx.blocked_write_roots) + { + return PolicyDecision::Deny { + reason: format!( + "writes are blocked under read-only docs directory: {}", + canonical.display() + ), + }; + } + + if matches!(ctx.mode, AccessMode::WorkspaceEdit) { + let workspace = canonical_roots(std::slice::from_ref(&ctx.cwd)); + if !is_under_any_root(&canonical, &workspace) { + return PolicyDecision::Deny { + reason: format!( + "write path escapes workspace-edit root: {} (workspace root: {})", + canonical.display(), + ctx.cwd.display() + ), + }; + } + } + + PolicyDecision::Allow +} + +pub fn canonicalize_existing(path: &Path) -> Result { + fs::canonicalize(path).with_context(|| format!("resolving {}", path.display())) +} + +pub fn canonicalize_for_create_or_write(path: &Path) -> Result { + if path.exists() { + return canonicalize_existing(path); + } + + let mut missing = Vec::::new(); + let mut ancestor = path; + loop { + if ancestor.exists() { + let mut out = fs::canonicalize(ancestor) + .with_context(|| format!("resolving {}", ancestor.display()))?; + for component in missing.iter().rev() { + out.push(component); + } + return Ok(normalize_lexical(&out)); + } + + let Some(name) = ancestor.file_name() else { + bail!("no existing ancestor for {}", path.display()); + }; + missing.push(name.to_os_string()); + ancestor = ancestor + .parent() + .ok_or_else(|| anyhow::anyhow!("no existing ancestor for {}", path.display()))?; + } +} + +pub fn normalize_lexical(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +pub fn is_under_root(path: &Path, root: &Path) -> bool { + path == root || path.starts_with(root) +} + +pub fn is_under_any_root(path: &Path, roots: &[PathBuf]) -> bool { + roots.iter().any(|root| is_under_root(path, root)) +} + +fn canonical_roots(roots: &[PathBuf]) -> Vec { + roots + .iter() + .filter_map(|root| fs::canonicalize(root).ok()) + .collect() +} + +fn roots_display(roots: &[PathBuf]) -> String { + if roots.is_empty() { + return "".into(); + } + roots + .iter() + .map(|root| root.display().to_string()) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn ctx(root: &Path, docs: &Path, mode: AccessMode) -> SecurityContext { + SecurityContext { + mode, + cwd: root.to_path_buf(), + read_roots: vec![root.to_path_buf(), docs.to_path_buf()], + blocked_write_roots: vec![docs.to_path_buf()], + } + } + + #[test] + fn workspace_edit_asks_for_shell_and_allows_workspace_writes() { + let root = tempdir().unwrap(); + let docs = tempdir().unwrap(); + let ctx = ctx(root.path(), docs.path(), AccessMode::WorkspaceEdit); + assert_eq!( + SecurityPolicy::tool_availability(&ctx, "shell"), + ToolAvailability::RequiresApproval + ); + assert!(matches!( + SecurityPolicy::check( + &ctx, + &ToolAction::Shell { + command: "pwd".into() + } + ), + PolicyDecision::Ask { .. } + )); + assert_eq!( + SecurityPolicy::check( + &ctx, + &ToolAction::Write { + path: root.path().join("x") + } + ), + PolicyDecision::Allow + ); + } + + #[test] + fn workspace_edit_denies_writes_outside_workspace_and_under_docs() { + let root = tempdir().unwrap(); + let docs = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let ctx = ctx(root.path(), docs.path(), AccessMode::WorkspaceEdit); + assert!(matches!( + SecurityPolicy::check( + &ctx, + &ToolAction::Write { + path: outside.path().join("x") + } + ), + PolicyDecision::Deny { .. } + )); + assert!(matches!( + SecurityPolicy::check( + &ctx, + &ToolAction::Write { + path: docs.path().join("x") + } + ), + PolicyDecision::Deny { .. } + )); + } + + #[cfg(unix)] + #[test] + fn workspace_edit_denies_symlink_escape_writes() { + let root = tempdir().unwrap(); + let docs = tempdir().unwrap(); + let outside = tempdir().unwrap(); + std::os::unix::fs::symlink(outside.path(), root.path().join("outside_link")).unwrap(); + let ctx = ctx(root.path(), docs.path(), AccessMode::WorkspaceEdit); + assert!(matches!( + SecurityPolicy::check( + &ctx, + &ToolAction::Write { + path: root.path().join("outside_link/new") + } + ), + PolicyDecision::Deny { .. } + )); + } +} diff --git a/src/tools/edit.rs b/src/tools/edit.rs index 4e9cc06..22e2985 100644 --- a/src/tools/edit.rs +++ b/src/tools/edit.rs @@ -19,7 +19,7 @@ struct EditArg { pub fn spec() -> ToolSpec { ToolSpec { name: "edit".into(), - description: "Safely edit a file using exact old_text/new_text replacements. Every old_text must match exactly once in the original file. Requires full-access mode. Writes under Cass bundled docs are blocked.".into(), + description: "Safely edit a file using exact old_text/new_text replacements when the active access policy permits writes. Every old_text must match exactly once in the original file. In workspace-edit mode, edits must stay inside the launch workspace. Writes under Cass bundled docs are blocked.".into(), parameters: schema::object(json!({ "path": {"type":"string"}, "edits": {"type":"array", "items": { @@ -74,12 +74,36 @@ pub fn run(args: Value, ctx: &ToolContext) -> Result { out.push_str(&original[cursor..]); super::write::atomic_write(&path, out.as_bytes())?; Ok(format!( - "applied {} edit(s) to {}", + "applied {} edit(s) to {}\n\n{}", args.edits.len(), - path.display() + path.display(), + unified_diff(&path.display().to_string(), &original, &out) )) } +fn unified_diff(path: &str, before: &str, after: &str) -> String { + let before_lines: Vec<&str> = before.lines().collect(); + let after_lines: Vec<&str> = after.lines().collect(); + let mut out = String::new(); + out.push_str(&format!("--- {path} before\n")); + out.push_str(&format!("+++ {path} after\n")); + out.push_str("@@\n"); + let max = before_lines.len().max(after_lines.len()); + for idx in 0..max { + match (before_lines.get(idx), after_lines.get(idx)) { + (Some(a), Some(b)) if a == b => out.push_str(&format!(" {a}\n")), + (Some(a), Some(b)) => { + out.push_str(&format!("-{a}\n")); + out.push_str(&format!("+{b}\n")); + } + (Some(a), None) => out.push_str(&format!("-{a}\n")), + (None, Some(b)) => out.push_str(&format!("+{b}\n")), + (None, None) => {} + } + } + out +} + fn preview(s: &str) -> String { s.chars().take(80).collect() } diff --git a/src/tools/grep.rs b/src/tools/grep.rs index 2f07e88..07eb8d6 100644 --- a/src/tools/grep.rs +++ b/src/tools/grep.rs @@ -31,7 +31,7 @@ fn default_max() -> usize { pub fn spec() -> ToolSpec { ToolSpec { name: "grep".into(), - description: "Search files or directories for literal text or regex matches. Use before read for large inputs. In read-only mode, paths must stay inside the launch cwd or bundled docs directory.".into(), + description: "Search files or directories for literal text or regex matches. Use before read for large inputs. In read-only and workspace-edit modes, paths must stay inside the launch cwd or bundled docs directory.".into(), parameters: schema::object(json!({ "query": {"type":"string"}, "paths": {"type":"array", "items":{"type":"string"}, "default":["."]}, diff --git a/src/tools/ls.rs b/src/tools/ls.rs index 6134b6a..485317a 100644 --- a/src/tools/ls.rs +++ b/src/tools/ls.rs @@ -16,7 +16,7 @@ fn default_path() -> String { pub fn spec() -> ToolSpec { ToolSpec { name: "ls".into(), - description: "List a directory. In read-only mode, paths must stay inside the launch cwd or bundled docs directory." + description: "List a directory. In read-only and workspace-edit modes, paths must stay inside the launch cwd or bundled docs directory." .into(), parameters: schema::object( json!({ diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 0763fbe..a8cd388 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -8,6 +8,9 @@ pub mod shell; pub mod write; use crate::access::AccessMode; +use crate::security::{ + PolicyDecision, SecurityContext, SecurityPolicy, ToolAction, ToolAvailability, +}; use anyhow::Result; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -42,60 +45,171 @@ pub struct ToolOutput { pub content: String, } -pub fn available_tool_names(mode: AccessMode) -> Vec { - match mode { - AccessMode::ReadOnly => vec!["ls".into(), "read".into(), "grep".into()], - AccessMode::FullAccess => vec![ - "ls".into(), - "read".into(), - "grep".into(), - "write".into(), - "edit".into(), - "shell".into(), - ], +impl ToolContext { + pub fn security_context(&self) -> SecurityContext { + SecurityContext { + mode: self.mode, + cwd: self.cwd.clone(), + read_roots: self.read_roots.clone(), + blocked_write_roots: self.blocked_write_roots.clone(), + } } } +pub fn available_tool_names(mode: AccessMode) -> Vec { + let ctx = SecurityContext { + mode, + cwd: PathBuf::new(), + read_roots: Vec::new(), + blocked_write_roots: Vec::new(), + }; + all_tool_names() + .into_iter() + .filter(|name| { + SecurityPolicy::tool_availability(&ctx, name) != ToolAvailability::Unavailable + }) + .map(str::to_string) + .collect() +} + pub fn specs(mode: AccessMode) -> Vec { - let mut specs = vec![ls::spec(), read::spec(), grep::spec()]; - if mode.can_write() { - specs.push(write::spec()); - specs.push(edit::spec()); - specs.push(shell::spec()); + let ctx = SecurityContext { + mode, + cwd: PathBuf::new(), + read_roots: Vec::new(), + blocked_write_roots: Vec::new(), + }; + specs_for_context(&ctx) +} + +pub fn specs_for_context(ctx: &SecurityContext) -> Vec { + let mut out = Vec::new(); + for name in all_tool_names() { + if SecurityPolicy::tool_availability(ctx, name) == ToolAvailability::Unavailable { + continue; + } + out.push(match name { + "ls" => ls::spec(), + "read" => read::spec(), + "grep" => grep::spec(), + "write" => write::spec(), + "edit" => edit::spec(), + "shell" => shell::spec(), + _ => continue, + }); } - specs + out +} + +fn all_tool_names() -> [&'static str; 6] { + ["ls", "read", "grep", "write", "edit", "shell"] } pub async fn execute(name: &str, args: Value, ctx: &ToolContext) -> ToolOutput { - // Async tools - if name == "shell" { - let result = if ctx.mode.can_write() { - shell::run(args, ctx).await - } else { - Err(anyhow::anyhow!( - "tool `{name}` is unavailable in {} mode", - ctx.mode - )) - }; - return result_to_output(result, ctx); + execute_with_approval(name, args, ctx, false).await +} + +pub async fn execute_with_approval( + name: &str, + args: Value, + ctx: &ToolContext, + approved: bool, +) -> ToolOutput { + if let Some(decision) = policy_decision_for_call(name, &args, ctx) { + match decision { + PolicyDecision::Allow => {} + PolicyDecision::Ask { reason: _ } if approved => {} + PolicyDecision::Ask { reason } => { + return ToolOutput { + ok: false, + content: format!("approval required before executing `{name}`: {reason}"), + }; + } + PolicyDecision::Deny { reason } => { + return ToolOutput { + ok: false, + content: reason, + }; + } + } } - // Sync tools let result: Result = match name { + "shell" => shell::run(args, ctx).await, "ls" => ls::run(args, ctx), "read" => read::run(args, ctx), "grep" => grep::run(args, ctx), - "write" if ctx.mode.can_write() => write::run(args, ctx), - "edit" if ctx.mode.can_write() => edit::run(args, ctx), - "write" | "edit" => Err(anyhow::anyhow!( - "tool `{name}` is unavailable in {} mode", - ctx.mode - )), + "write" => write::run(args, ctx), + "edit" => edit::run(args, ctx), _ => Err(anyhow::anyhow!("unknown tool `{name}`")), }; result_to_output(result, ctx) } +pub fn policy_decision_for_call( + name: &str, + args: &Value, + ctx: &ToolContext, +) -> Option { + Some(SecurityPolicy::check( + &ctx.security_context(), + &tool_action(name, args, ctx)?, + )) +} + +fn tool_action(name: &str, args: &Value, ctx: &ToolContext) -> Option { + let resolve = |path: &str| { + let p = path::expand_tilde(path); + if p.is_absolute() { + p + } else { + ctx.cwd.join(p) + } + }; + match name { + "ls" => Some(ToolAction::List { + path: resolve(args.get("path").and_then(Value::as_str).unwrap_or(".")), + }), + "read" => args + .get("files") + .and_then(Value::as_array) + .and_then(|files| files.first()) + .and_then(|file| file.get("path")) + .and_then(Value::as_str) + .map(|path| ToolAction::Read { + path: resolve(path), + }), + "grep" => args + .get("paths") + .and_then(Value::as_array) + .and_then(|paths| paths.first()) + .and_then(Value::as_str) + .or(Some(".")) + .map(|path| ToolAction::Search { + path: resolve(path), + }), + "write" => args + .get("path") + .and_then(Value::as_str) + .map(|path| ToolAction::Write { + path: resolve(path), + }), + "edit" => args + .get("path") + .and_then(Value::as_str) + .map(|path| ToolAction::Edit { + path: resolve(path), + }), + "shell" => args + .get("command") + .and_then(Value::as_str) + .map(|command| ToolAction::Shell { + command: command.to_string(), + }), + _ => None, + } +} + fn result_to_output(result: Result, ctx: &ToolContext) -> ToolOutput { match result { Ok(content) => ToolOutput { diff --git a/src/tools/path.rs b/src/tools/path.rs index 48e1cb1..b197139 100644 --- a/src/tools/path.rs +++ b/src/tools/path.rs @@ -1,8 +1,9 @@ use crate::access::AccessMode; -use anyhow::{bail, Context, Result}; -use std::ffi::OsString; -use std::fs; -use std::path::{Component, Path, PathBuf}; +use crate::security::{ + canonicalize_existing, canonicalize_for_create_or_write, is_under_any_root, normalize_lexical, +}; +use anyhow::{bail, Result}; +use std::path::{Path, PathBuf}; pub fn expand_tilde(path: &str) -> PathBuf { if path == "~" { @@ -24,8 +25,8 @@ pub fn resolve_existing( ) -> Result { let p = expand_tilde(input); let abs = if p.is_absolute() { p } else { cwd.join(p) }; - let canon = fs::canonicalize(&abs).with_context(|| format!("resolving {}", abs.display()))?; - if matches!(mode, AccessMode::ReadOnly) { + let canon = canonicalize_existing(&abs)?; + if matches!(mode, AccessMode::ReadOnly | AccessMode::WorkspaceEdit) { ensure_under_any_root(&canon, read_roots)?; } Ok(canon) @@ -37,20 +38,30 @@ pub fn resolve_for_write( mode: AccessMode, blocked_write_roots: &[PathBuf], ) -> Result { - if !mode.can_write() { - bail!("write access requires full-access mode"); + if matches!(mode, AccessMode::ReadOnly) { + bail!("write access is unavailable in read-only mode"); } let p = expand_tilde(input); let abs = if p.is_absolute() { p } else { cwd.join(p) }; let normalized = normalize_lexical(&abs); ensure_not_under_any_root(&normalized, blocked_write_roots)?; + if matches!(mode, AccessMode::WorkspaceEdit) { + let canon = canonicalize_for_create_or_write(&normalized)?; + let workspace = canonicalize_existing(cwd)?; + if !canon.starts_with(&workspace) { + bail!( + "write path escapes workspace-edit root: {} (workspace root: {})", + canon.display(), + workspace.display() + ); + } + } Ok(normalized) } fn ensure_under_any_root(path: &Path, roots: &[PathBuf]) -> Result<()> { for root in roots { - let root_canon = fs::canonicalize(root) - .with_context(|| format!("resolving read root {}", root.display()))?; + let root_canon = canonicalize_existing(root)?; if path.starts_with(&root_canon) { return Ok(()); } @@ -88,61 +99,20 @@ fn ensure_not_under_any_root(path: &Path, roots: &[PathBuf]) -> Result<()> { } } - let canon = canonicalize_for_policy(path)?; - for root in roots { - let root_canon = canonicalize_for_policy(root) - .with_context(|| format!("resolving blocked write root {}", root.display()))?; - if canon.starts_with(&root_canon) { - bail!( - "writes are blocked under read-only docs directory: {}", - root_canon.display() - ); - } + let canon = canonicalize_for_create_or_write(path)?; + let canon_roots: Vec = roots + .iter() + .filter_map(|root| canonicalize_for_create_or_write(root).ok()) + .collect(); + if is_under_any_root(&canon, &canon_roots) { + bail!( + "writes are blocked under read-only docs directory: {}", + canon.display() + ); } Ok(()) } -fn canonicalize_for_policy(path: &Path) -> Result { - if path.exists() { - return fs::canonicalize(path).with_context(|| format!("resolving {}", path.display())); - } - - let mut missing = Vec::::new(); - let mut ancestor = path; - loop { - if ancestor.exists() { - let mut out = fs::canonicalize(ancestor) - .with_context(|| format!("resolving {}", ancestor.display()))?; - for component in missing.iter().rev() { - out.push(component); - } - return Ok(normalize_lexical(&out)); - } - - let Some(name) = ancestor.file_name() else { - bail!("no existing ancestor for {}", path.display()); - }; - missing.push(name.to_os_string()); - ancestor = ancestor - .parent() - .ok_or_else(|| anyhow::anyhow!("no existing ancestor for {}", path.display()))?; - } -} - -fn normalize_lexical(path: &Path) -> PathBuf { - let mut out = PathBuf::new(); - for c in path.components() { - match c { - Component::CurDir => {} - Component::ParentDir => { - out.pop(); - } - other => out.push(other.as_os_str()), - } - } - out -} - pub fn is_probably_binary(bytes: &[u8]) -> bool { bytes.iter().take(8192).any(|b| *b == 0) } diff --git a/src/tools/read.rs b/src/tools/read.rs index c2edf34..5533e7b 100644 --- a/src/tools/read.rs +++ b/src/tools/read.rs @@ -18,7 +18,7 @@ struct FileArg { pub fn spec() -> ToolSpec { ToolSpec { name: "read".into(), - description: "Read one or more text files, optionally with 1-indexed line ranges like 35-60, 35-, or -60. In read-only mode, paths must stay inside the launch cwd or bundled docs directory.".into(), + description: "Read one or more text files, optionally with 1-indexed line ranges like 35-60, 35-, or -60. In read-only and workspace-edit modes, paths must stay inside the launch cwd or bundled docs directory.".into(), parameters: schema::object(json!({ "files": { "type":"array", diff --git a/src/tools/shell.rs b/src/tools/shell.rs index 2a38862..38697a2 100644 --- a/src/tools/shell.rs +++ b/src/tools/shell.rs @@ -16,7 +16,7 @@ struct Args { pub fn spec() -> ToolSpec { ToolSpec { name: "shell".into(), - description: "Run a shell command in the launch cwd. Requires full-access mode. Streams stdout/stderr while running, then returns stdout, stderr, and exit code. Use timeout (seconds) to limit runtime." + description: "Run a shell command in the launch cwd. Request this tool directly when shell is useful; do not ask the user for permission in chat. Cass may show a separate approval UI before execution depending on the active access mode. Streams stdout/stderr while running, then returns stdout, stderr, and exit code. Use timeout (seconds) to limit runtime." .into(), parameters: schema::object( json!({ @@ -30,9 +30,6 @@ pub fn spec() -> ToolSpec { pub async fn run(args: Value, ctx: &ToolContext) -> Result { let args: Args = serde_json::from_value(args)?; - if !ctx.mode.can_write() { - bail!("shell tool requires full-access mode"); - } let mut cmd = tokio::process::Command::new("sh"); cmd.arg("-c").arg(&args.command); diff --git a/src/tools/write.rs b/src/tools/write.rs index fd6e0d8..7ba148b 100644 --- a/src/tools/write.rs +++ b/src/tools/write.rs @@ -15,7 +15,7 @@ struct Args { pub fn spec() -> ToolSpec { ToolSpec { name: "write".into(), - description: "Create or overwrite a text file. Requires full-access mode. Writes under Cass bundled docs are blocked. Uses atomic temp-file-and-rename where practical.".into(), + description: "Create or overwrite a text file when the active access policy permits writes. In workspace-edit mode, writes must stay inside the launch workspace. Writes under Cass bundled docs are blocked. Uses atomic temp-file-and-rename where practical.".into(), parameters: schema::object(json!({ "path": {"type":"string"}, "content": {"type":"string"} diff --git a/src/ui/render.rs b/src/ui/render.rs index 23f291e..cd65ba1 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -348,6 +348,7 @@ fn footer_text(state: &RenderState<'_>) -> String { let busy = if state.busy { "running" } else { "idle" }; let mode = match state.mode { AccessMode::ReadOnly => "read-only", + AccessMode::WorkspaceEdit => "workspace-edit", AccessMode::FullAccess => "full-access", }; let mut parts = vec![ diff --git a/tests/agent_tests.rs b/tests/agent_tests.rs index 83f4d84..3964842 100644 --- a/tests/agent_tests.rs +++ b/tests/agent_tests.rs @@ -1,5 +1,5 @@ use cassady::access::AccessMode; -use cassady::agent::{run_turn, AgentEvent, AgentSettings}; +use cassady::agent::{run_turn, run_turn_with_commands, AgentCommand, AgentEvent, AgentSettings}; use cassady::config::{Config, ReasoningEffort, ReasoningRequestFormat}; use cassady::conversation::{Conversation, Record}; use tempfile::tempdir; @@ -11,6 +11,13 @@ fn sse(body: &str) -> ResponseTemplate { ResponseTemplate::new(200).set_body_raw(body.as_bytes().to_vec(), "text/event-stream") } +fn tool_call_sse(id: &str, name: &str, arguments: &str) -> ResponseTemplate { + sse(&format!( + "data: {{\"choices\":[{{\"index\":0,\"delta\":{{\"tool_calls\":[{{\"index\":0,\"id\":\"{id}\",\"type\":\"function\",\"function\":{{\"name\":\"{name}\",\"arguments\":{}}}}}]}}}}]}}\r\n\r\ndata: [DONE]\r\n\r\n", + serde_json::to_string(arguments).unwrap() + )) +} + #[tokio::test] async fn reasoning_effort_is_sent_as_top_level_field() { let server = MockServer::start().await; @@ -304,3 +311,180 @@ async fn empty_final_response_is_reprompted_and_persisted() { if content == "Done." && tool_calls.is_empty() )); } + +#[tokio::test] +async fn workspace_edit_shell_does_not_execute_until_approved() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(tool_call_sse( + "call_shell", + "shell", + r#"{"command":"touch marker"}"#, + )) + .with_priority(10) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .and(body_string_contains("exit code: 0")) + .respond_with(sse( + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Approved.\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n", + )) + .with_priority(1) + .expect(1) + .mount(&server) + .await; + + let root = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + let docs = tempdir().unwrap(); + let config = Config { + root: root.path().to_path_buf(), + docs_dir: docs.path().to_path_buf(), + model: "test-model".into(), + active_provider: cassady::config::ResolvedProviderConfig { + base_url: server.uri(), + api_key: "test-key".into(), + ..Config::default().active_provider + }, + ..Config::default() + }; + let conversation = Conversation::create( + &config.conversations_dir(), + &config.model, + cwd.path(), + "base prompt".into(), + ) + .unwrap(); + let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); + let (command_tx, command_rx) = mpsc::unbounded_channel::(); + let marker = cwd.path().join("marker"); + + let handle = tokio::spawn(run_turn_with_commands( + conversation, + "run shell".into(), + AgentSettings { + config, + cwd: cwd.path().to_path_buf(), + mode: AccessMode::WorkspaceEdit, + reasoning_effort: ReasoningEffort::Off, + }, + event_tx, + command_rx, + )); + + let request_id = loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(2), event_rx.recv()) + .await + .unwrap() + .unwrap(); + if let AgentEvent::ApprovalRequested { request_id, .. } = event { + break request_id; + } + }; + assert!(!marker.exists()); + command_tx + .send(AgentCommand::ApprovalDecision { + request_id, + approved: true, + }) + .unwrap(); + + let updated = handle.await.unwrap().unwrap(); + assert!(marker.exists()); + assert!(updated.records.iter().any(|record| matches!( + record, + Record::Tool { name, ok, content, .. } + if name == "shell" && *ok && content.contains("exit code: 0") + ))); +} + +#[tokio::test] +async fn workspace_edit_denied_shell_appends_failed_tool_result_without_execution() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .respond_with(tool_call_sse( + "call_shell", + "shell", + r#"{"command":"touch marker"}"#, + )) + .with_priority(10) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/chat/completions")) + .and(body_string_contains("user denied approval")) + .respond_with(sse( + "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Denied.\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n", + )) + .with_priority(1) + .expect(1) + .mount(&server) + .await; + + let root = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + let docs = tempdir().unwrap(); + let config = Config { + root: root.path().to_path_buf(), + docs_dir: docs.path().to_path_buf(), + model: "test-model".into(), + active_provider: cassady::config::ResolvedProviderConfig { + base_url: server.uri(), + api_key: "test-key".into(), + ..Config::default().active_provider + }, + ..Config::default() + }; + let conversation = Conversation::create( + &config.conversations_dir(), + &config.model, + cwd.path(), + "base prompt".into(), + ) + .unwrap(); + let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); + let (command_tx, command_rx) = mpsc::unbounded_channel::(); + let marker = cwd.path().join("marker"); + + let handle = tokio::spawn(run_turn_with_commands( + conversation, + "run shell".into(), + AgentSettings { + config, + cwd: cwd.path().to_path_buf(), + mode: AccessMode::WorkspaceEdit, + reasoning_effort: ReasoningEffort::Off, + }, + event_tx, + command_rx, + )); + + let request_id = loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(2), event_rx.recv()) + .await + .unwrap() + .unwrap(); + if let AgentEvent::ApprovalRequested { request_id, .. } = event { + break request_id; + } + }; + command_tx + .send(AgentCommand::ApprovalDecision { + request_id, + approved: false, + }) + .unwrap(); + + let updated = handle.await.unwrap().unwrap(); + assert!(!marker.exists()); + assert!(updated.records.iter().any(|record| matches!( + record, + Record::Tool { name, ok, content, .. } + if name == "shell" && !*ok && content.contains("user denied approval") + ))); +} diff --git a/tests/config_tests.rs b/tests/config_tests.rs index 12a7984..d0d2175 100644 --- a/tests/config_tests.rs +++ b/tests/config_tests.rs @@ -15,6 +15,7 @@ fn cli() -> Cli { api_key_env: None, cwd: None, readonly: false, + workspace_edit: false, full_access: false, } } diff --git a/tests/tool_tests.rs b/tests/tool_tests.rs index b9dd4a3..5fa2991 100644 --- a/tests/tool_tests.rs +++ b/tests/tool_tests.rs @@ -112,6 +112,62 @@ async fn read_only_can_read_and_search_docs_root() { assert!(outside_read.content.contains("escapes read-only root")); } +#[tokio::test] +async fn workspace_edit_allows_workspace_edits_but_blocks_shell_without_approval() { + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "alpha\nbeta\n").unwrap(); + let context = ctx(dir.path(), AccessMode::WorkspaceEdit); + + let edit = tools::execute( + "edit", + json!({"path":"a.txt", "edits":[{"old_text":"beta", "new_text":"BETA"}]}), + &context, + ) + .await; + assert!(edit.ok, "{}", edit.content); + assert_eq!( + std::fs::read_to_string(dir.path().join("a.txt")).unwrap(), + "alpha\nBETA\n" + ); + + let outside_write = tools::execute( + "write", + json!({"path": outside.path().join("x.txt").display().to_string(), "content":"nope"}), + &context, + ) + .await; + assert!(!outside_write.ok); + assert!(outside_write + .content + .contains("escapes workspace-edit root")); + + let shell = tools::execute("shell", json!({"command":"touch marker"}), &context).await; + assert!(!shell.ok); + assert!(shell.content.contains("approval required")); + assert!(!dir.path().join("marker").exists()); +} + +#[tokio::test] +async fn workspace_edit_blocks_symlink_escape_write() { + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + #[cfg(unix)] + { + std::os::unix::fs::symlink(outside.path(), dir.path().join("outside_link")).unwrap(); + let context = ctx(dir.path(), AccessMode::WorkspaceEdit); + let write = tools::execute( + "write", + json!({"path":"outside_link/new.txt", "content":"nope"}), + &context, + ) + .await; + assert!(!write.ok); + assert!(write.content.contains("escapes workspace-edit root")); + assert!(!outside.path().join("new.txt").exists()); + } +} + #[tokio::test] async fn edit_is_exact_and_atomic_on_validation_failure() { let dir = tempdir().unwrap(); @@ -125,6 +181,9 @@ async fn edit_is_exact_and_atomic_on_validation_failure() { ) .await; assert!(ok.ok); + assert!(ok.content.contains("--- ")); + assert!(ok.content.contains("-beta")); + assert!(ok.content.contains("+BETA")); assert_eq!( std::fs::read_to_string(dir.path().join("a.txt")).unwrap(), "alpha\nBETA\ngamma\n"