19 KiB
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-editshould 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
- Security decisions must be enforced in Rust code, not only in the system prompt.
- All mode and path authorization should go through one central policy layer.
- Tools should perform domain work, not own access-control logic.
- UI confirmation should be a generic approval mechanism, not hardcoded inside the shell tool.
- 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.
- 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.
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:
#[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:
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.
#[derive(Debug, Clone)]
pub struct SecurityContext {
pub mode: AccessMode,
pub cwd: PathBuf,
pub workspace_root: PathBuf,
pub docs_root: PathBuf,
pub read_roots: Vec<PathBuf>,
pub blocked_write_roots: Vec<PathBuf>,
}
#[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:
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:
pub fn canonicalize_existing(path: &Path) -> Result<PathBuf>;
pub fn canonicalize_for_create_or_write(path: &Path) -> Result<PathBuf>;
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, andgrepare available.- Read/search/list paths must be under
workspace_rootordocs_root. write,edit, andshellare unavailable and denied.
workspace-edit
ls,read, andgrepare available.- Read/search/list paths must be under
workspace_rootordocs_root. writeandeditare available for paths underworkspace_rootonly.writeandeditare denied underdocs_rootor any blocked write root.writeandeditoutsideworkspace_rootare denied.shellis available but every shell command returnsAskbefore execution.
full-access
- All tools are available.
ls,read, andgrepuse normal OS permissions and are not restricted to workspace by default.writeandedituse normal OS permissions but remain denied underdocs_rootand other blocked write roots.shellis 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:
pub fn available_tool_names(ctx: &SecurityContext) -> Vec<String>;
pub fn specs(ctx: &SecurityContext) -> Vec<ToolSpec>;
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.
pub struct ToolContext {
pub security: SecurityContext,
pub model_result_limit: usize,
pub runtime_tx: Option<mpsc::UnboundedSender<ToolRuntimeEvent>>,
}
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: removeif !ctx.mode.can_write()and trust that the agent/policy gate checked the command before callingshell::run.
To avoid accidental direct tool execution bypasses, tools::execute should remain a second enforcement point:
- Convert the tool name/args to a
ToolAction. - Check policy.
- Only execute on
Allow. - Return a failed
ToolOutputforDenyor unexpectedAskwhen 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:
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:
#[derive(Debug, Clone)]
pub enum AgentCommand {
ApprovalDecision {
request_id: String,
approved: bool,
},
}
Update AgentSettings or run_turn signature to accept the receiver:
pub async fn run_turn(
conversation: Conversation,
user_message: String,
settings: AgentSettings,
tx: mpsc::UnboundedSender<AgentEvent>,
command_rx: mpsc::UnboundedReceiver<AgentCommand>,
) -> Result<Conversation>
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:
- Emit
ToolCallStartedas today. - Build
ToolActionfrom the tool call. - Call
SecurityPolicy::check. - If
Allow, execute the tool. - If
Deny, createToolOutput { ok: false, content: reason }without executing. - If
Ask, emitApprovalRequestedand wait for matchingAgentCommand::ApprovalDecision.- Approved: execute the tool.
- Denied: create failed tool output like
user denied shell command: <reason>. - 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:
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:
yorY: sendAgentCommand::ApprovalDecision { approved: true }.n,N, orEsc: 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:
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:
--workspace-edit
It should conflict with --readonly and --full-access.
Config
Allow:
{
"default_access_mode": "workspace-edit"
}
Config loading should still default to read-only unless project direction changes.
Mode cycling
Shift-Tab should cycle:
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-editin access-mode instructions. - Tell the model that shell commands in
workspace-editrequire 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:
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 onlyRequires 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 nameequal to the requested tool nameok: 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()returnsworkspace-editnext()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-editexposeswrite,edit, andshell- 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-editif using defensivetools::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-Tabonce: status/header showsworkspace-edit. - Press
Shift-Tabagain: status/header showsfull-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-editCLI 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
SecurityContextfrom app/agent config. - Implement tool availability and path decisions.
- Add comprehensive policy tests.
Phase 3: Refactor tool gating
- Update
tools::available_tool_namesandtools::specsto useSecurityContext. - Add policy checks to
tools::executeas 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
AgentCommandchannel. - Add
ApprovalRequestedevent. - Gate
Askdecisions before execution inagent.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.mdstatus 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
.gitinternals 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-askwithout changing tool internals.
Open decisions
- Should
workspace-editbecome the default mode after it is stable, or should default remainread-only? - Should
full-accessshell 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-editallow reading outside workspace with confirmation, or always deny? - Should policy decisions be persisted in an audit log separate from conversation JSONL?