Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ It works (almost) exactly like OpenCode. Just opens faster, with some intuitive
- When switching models, you can press `⇆` to change thinking efforts.
- Copy on select is disabled by default. Copy is two-step in crabcode. Gets annoying in OpenCode, especially w/ clipboard history.

### Auto-approve Permissions

Use `--auto` to run tools without permission prompts in the terminal, print mode, or an ACP editor session:

```sh
crabcode --auto
crabcode --auto -p "Refactor this module"
crabcode acp --auto
```

Only enable this for trusted tasks: commands and file changes run without approval. Explicit permission denies, disabled tools, and agent restrictions still apply. Without `--auto`, permission prompts work normally. `--dangerously-skip-permissions` and `--yolo` are equivalent aliases.

### Shell Completion

```sh
Expand Down
4 changes: 2 additions & 2 deletions src/acp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ mod service;
use anyhow::Result;
use std::path::PathBuf;

pub async fn run(cwd: Option<PathBuf>) -> Result<()> {
server::run(cwd).await
pub async fn run(cwd: Option<PathBuf>, dangerously_skip_permissions: bool) -> Result<()> {
server::run(cwd, dangerously_skip_permissions).await
}

pub async fn login(cwd: Option<PathBuf>) -> Result<()> {
Expand Down
4 changes: 2 additions & 2 deletions src/acp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use agent_client_protocol::{Agent, Stdio};
use anyhow::{Context, Result};
use std::path::PathBuf;

pub async fn run(cwd: Option<PathBuf>) -> Result<()> {
pub async fn run(cwd: Option<PathBuf>, dangerously_skip_permissions: bool) -> Result<()> {
let workspace = match cwd {
Some(path) => path,
None => std::env::current_dir().context("failed to determine current directory")?,
Expand All @@ -30,7 +30,7 @@ pub async fn run(cwd: Option<PathBuf>) -> Result<()> {
workspace.display()
)
})?;
let service = crate::acp::service::AcpService::new(&workspace)
let service = crate::acp::service::AcpService::new(&workspace, dangerously_skip_permissions)
.map_err(|_| anyhow::anyhow!("failed to initialize ACP session storage"))?;
let initialize_service = service.clone();

Expand Down
107 changes: 100 additions & 7 deletions src/acp/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub struct AcpService {
sessions: Arc<AsyncMutex<AcpSessions>>,
session_manager: Arc<Mutex<SessionManager>>,
client_capabilities: Arc<Mutex<agent_client_protocol::schema::v1::ClientCapabilities>>,
dangerously_skip_permissions: bool,
}

// Attachments detach immediately on close, but a turn retains ownership until all
Expand Down Expand Up @@ -847,14 +848,18 @@ struct AcpSession {
}

impl AcpService {
pub fn new(initial_workspace: &Path) -> Result<Self, Error> {
pub fn new(
initial_workspace: &Path,
dangerously_skip_permissions: bool,
) -> Result<Self, Error> {
let session_manager = SessionManager::new()
.with_history_for_workspace(initial_workspace)
.map_err(|_| internal_error())?;
Ok(Self {
sessions: Arc::new(AsyncMutex::new(AcpSessions::default())),
session_manager: Arc::new(Mutex::new(session_manager)),
client_capabilities: Arc::new(Mutex::new(Default::default())),
dangerously_skip_permissions,
})
}

Expand Down Expand Up @@ -934,7 +939,8 @@ impl AcpService {
};

let skills = crate::skill::SkillStore::load(&config.xdg_config_home, &config.project_root);
let tool_permissions = configured_tool_permissions(&cwd, &config);
let tool_permissions =
configured_tool_permissions(&cwd, &config, self.dangerously_skip_permissions);
self.sessions.lock().await.attachments.insert(
session_id.clone(),
AcpSession {
Expand Down Expand Up @@ -1059,7 +1065,11 @@ impl AcpService {
self.sessions.lock().await.attachments.insert(
fork_id.clone(),
AcpSession {
tool_permissions: configured_tool_permissions(&source.cwd, &source.config),
tool_permissions: configured_tool_permissions(
&source.cwd,
&source.config,
self.dangerously_skip_permissions,
),
..source.clone()
},
);
Expand Down Expand Up @@ -1232,7 +1242,8 @@ impl AcpService {
reasoning.unwrap_or(crate::model::reasoning::ReasoningEffort::None);
let context_window = model_context_window(&config, &models, &provider, &model);
let skills = crate::skill::SkillStore::load(&config.xdg_config_home, &config.project_root);
let tool_permissions = configured_tool_permissions(&cwd, &config);
let tool_permissions =
configured_tool_permissions(&cwd, &config, self.dangerously_skip_permissions);
let session = AcpSession {
cwd,
config,
Expand Down Expand Up @@ -2131,15 +2142,21 @@ fn resolve_model(config: &LoadedConfig) -> (String, String) {
.unwrap_or_else(|| ("opencode".to_string(), "big-pickle".to_string()))
}

fn configured_tool_permissions(cwd: &Path, config: &LoadedConfig) -> crate::tools::ToolPermissions {
fn configured_tool_permissions(
cwd: &Path,
config: &LoadedConfig,
dangerously_skip_permissions: bool,
) -> crate::tools::ToolPermissions {
let mut policies = crate::tools::AgentToolPolicies::default();
for (mode, tools) in config.merged_config.agent_registry.tool_policy_map() {
policies = policies.with_custom_tools(mode, tools);
}
crate::tools::ToolPermissions::new(cwd)
.with_agent_policies(policies)
.with_global_tool_config(config.merged_config.tools.clone())
.with_permission_rules(config.merged_config.permission_rules.clone())
.with_agent_permission_rules(config.merged_config.agent_registry.permission_rules_map())
.dangerously_skip_permissions(dangerously_skip_permissions)
}

fn tool_permissions(session: &AcpSession) -> crate::tools::ToolPermissions {
Expand Down Expand Up @@ -3571,7 +3588,7 @@ mod tests {

#[test]
fn terminal_support_tracks_client_capability() {
let service = AcpService::new(Path::new("/tmp")).unwrap();
let service = AcpService::new(Path::new("/tmp"), false).unwrap();
assert!(!service.supports_terminals());

service.set_client_capabilities(
Expand Down Expand Up @@ -3703,6 +3720,81 @@ mod tests {
assert_eq!(always.kind, PermissionOptionKind::AllowAlways);
}

#[tokio::test]
async fn acp_auto_bypasses_prompts_but_preserves_restrictions() {
use crate::tools::{PermissionPolicyAction, PermissionRule};
let mut config = empty_config();
config.merged_config.tools.insert("bash".into(), false);
config.merged_config.permission_rules.push(PermissionRule {
permission: "*".into(),
pattern: "*".into(),
action: PermissionPolicyAction::Ask,
});
let permissions = configured_tool_permissions(Path::new("/tmp/workspace"), &config, true);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
for _ in 0..4 {
for (tool, params) in [
(
"read",
serde_json::json!({"file_path": "/tmp/external/.env"}),
),
(
"write",
serde_json::json!({"file_path": "/tmp/external/output.txt", "content": "test"}),
),
] {
assert!(permissions
.preflight("build", tool, &params, Some(&tx))
.await
.is_ok());
}
}
assert!(permissions
.preflight(
"build",
"bash",
&serde_json::json!({"command": "echo test"}),
Some(&tx)
)
.await
.is_err());
assert!(permissions
.preflight(
"plan",
"write",
&serde_json::json!({"file_path": "/tmp/workspace/output.txt", "content": "test"}),
Some(&tx)
)
.await
.is_err());
for permission in ["read", "external_directory"] {
let mut denied_config = config.clone();
denied_config
.merged_config
.permission_rules
.push(PermissionRule {
permission: permission.into(),
pattern: "*".into(),
action: PermissionPolicyAction::Deny,
});
let denied =
configured_tool_permissions(Path::new("/tmp/workspace"), &denied_config, true);
assert!(denied
.preflight(
"build",
"read",
&serde_json::json!({"file_path": "/tmp/external/file.txt"}),
Some(&tx)
)
.await
.is_err());
}
assert!(
rx.try_recv().is_err(),
"auto mode emitted a permission prompt"
);
}

#[tokio::test]
async fn acp_session_reuses_permission_grants_across_turns() {
let temp = tempfile::tempdir().expect("temp dir");
Expand Down Expand Up @@ -4210,7 +4302,7 @@ mod tests {
let skills = crate::skill::SkillStore::load(&config.xdg_config_home, &config.project_root);
AcpSession {
cwd: config.cwd.clone(),
tool_permissions: configured_tool_permissions(&config.cwd, &config),
tool_permissions: configured_tool_permissions(&config.cwd, &config, false),
config,
skills,
models: Vec::new(),
Expand Down Expand Up @@ -4257,6 +4349,7 @@ mod tests {
sessions: Arc::new(AsyncMutex::new(AcpSessions::default())),
session_manager: Arc::new(Mutex::new(manager)),
client_capabilities: Arc::new(Mutex::new(Default::default())),
dangerously_skip_permissions: false,
};
let session = session_with_config(empty_config());
service.sessions.lock().await.attachments.insert(session_id.clone(), session.clone());
Expand Down
2 changes: 1 addition & 1 deletion src/config/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::tools::{
pub struct ConfigRuntimeOptions {
/// When true, deny interactive-only tools (`question`, `update_plan`).
pub print_mode: bool,
/// Skip permission prompts (`--dangerously-skip-permissions` / `--yolo`).
/// Skip permission prompts (`--auto` / `--dangerously-skip-permissions` / `--yolo`).
pub dangerously_skip_permissions: bool,
}

Expand Down
31 changes: 27 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -781,8 +781,12 @@ pub(crate) struct Args {
#[arg(long = "reasoning-effort", value_parser = parse_reasoning_effort_arg)]
reasoning_effort: Option<crate::model::reasoning::ReasoningEffort>,

/// Skip permission prompts in local interactive/print mode (dangerous). Explicit denies still apply.
#[arg(long = "dangerously-skip-permissions", visible_alias = "yolo")]
/// Automatically approve permission requests in interactive, print, and ACP modes (dangerous). Explicit denies still apply.
#[arg(
long = "dangerously-skip-permissions",
visible_aliases = ["auto", "yolo"],
global = true
)]
dangerously_skip_permissions: bool,

#[arg(long = "emit-logs", hide = true)]
Expand Down Expand Up @@ -1067,7 +1071,7 @@ async fn main() -> Result<()> {
return if *login {
crate::acp::login(cwd.clone()).await
} else {
crate::acp::run(cwd.clone()).await
crate::acp::run(cwd.clone(), args.dangerously_skip_permissions).await
};
}
Some(Command::Completion { shell, install }) => {
Expand Down Expand Up @@ -1336,7 +1340,7 @@ mod tests {

#[test]
fn parses_permission_bypass_in_interactive_and_print_modes() {
for flag in ["--dangerously-skip-permissions", "--yolo"] {
for flag in ["--dangerously-skip-permissions", "--auto", "--yolo"] {
let args = Args::try_parse_from(["crabcode", flag]).unwrap();
assert!(args.dangerously_skip_permissions);
assert!(!args.print_mode);
Expand All @@ -1348,12 +1352,31 @@ mod tests {
}
}

#[test]
fn parses_permission_bypass_before_and_after_acp() {
for flag in ["--auto", "--dangerously-skip-permissions", "--yolo"] {
for argv in [["crabcode", flag, "acp"], ["crabcode", "acp", flag]] {
let args = Args::try_parse_from(argv).unwrap();
assert!(args.dangerously_skip_permissions);
assert!(matches!(args.command, Some(Command::Acp { .. })));
}
}
let args = Args::try_parse_from(["crabcode", "acp"]).unwrap();
assert!(!args.dangerously_skip_permissions);
}

#[test]
fn help_documents_permission_bypass_and_alias() {
let help = root_help().unwrap();
assert!(help.contains("--dangerously-skip-permissions"));
assert!(help.contains("auto"));
assert!(help.contains("yolo"));
assert!(help.contains("Explicit denies still apply"));
let help = Args::try_parse_from(["crabcode", "acp", "--help"])
.unwrap_err()
.to_string();
assert!(help.contains("auto"));
assert!(help.contains("Explicit denies still apply"));
}

#[test]
Expand Down
3 changes: 2 additions & 1 deletion src/ui/components/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9074,11 +9074,12 @@ mod tests {
#[test]
fn test_edit_tool_renders_codex_style_diff_summary() {
let chat = Chat::new();
let path = std::env::current_dir().unwrap().join("README.md");
let content = serde_json::json!({
"name": "edit",
"status": "ok",
"args": {
"file_path": "/Users/carlo/Desktop/Projects/crabcode/README.md",
"file_path": path,
"old_string": "alpha\nbeta\nomega",
"new_string": "alpha\nbravo\nomega",
},
Expand Down
26 changes: 21 additions & 5 deletions tests/acp_stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,29 @@ struct AcpProcess {
_state: tempfile::TempDir,
}

impl Drop for AcpProcess {
fn drop(&mut self) {
self.stdin.take();
let _ = self.child.kill();
let _ = self.child.wait();
}
}

impl AcpProcess {
fn spawn(workspace: &std::path::Path) -> Self {
let home = tempfile::tempdir().expect("home");
let config = tempfile::tempdir().expect("config");
let state = tempfile::tempdir().expect("state");
// Valid session setup must not depend on live models.dev availability.
// A published catalog keeps session setup on the warm, offline path.
// A models.dev cache alone still triggers runtime discovery (ollama ls)
// when no providers are connected, and fails if Ollama is unavailable.
let cache = state.path().join("crabcode/cache");
std::fs::create_dir_all(&cache).expect("cache directory");
std::fs::write(
cache.join("models_dev_cache.json"),
cache.join("effective_catalog.json"),
serde_json::to_vec(&serde_json::json!({
"data": {}, "schema_version": 5,
"timestamp": std::time::SystemTime::now()
"models": [], "schema_version": 2, "revision": 1,
"updated_at": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
}))
.unwrap(),
Expand All @@ -33,6 +43,10 @@ impl AcpProcess {
let mut child = Command::new(env!("CARGO_BIN_EXE_crabcode"))
.args(["acp", "--cwd"])
.arg(workspace)
.current_dir(workspace)
// Test mode redirects auth/discovery to shared /tmp fixtures;
// these subprocesses must use only their private HOME/XDG dirs.
.env_remove("CRABCODE_TEST_MODE")
.env("HOME", home.path())
.env("XDG_CONFIG_HOME", config.path())
.env("XDG_STATE_HOME", state.path())
Expand Down Expand Up @@ -241,6 +255,8 @@ fn login_with_piped_stdin_fails_promptly_without_protocol_output_or_secrets() {
let mut child = Command::new(env!("CARGO_BIN_EXE_crabcode"))
.args(["acp", "--login", "--cwd"])
.arg(workspace.path())
.current_dir(workspace.path())
.env_remove("CRABCODE_TEST_MODE")
.env("HOME", home.path())
.env("XDG_CONFIG_HOME", config.path())
.env("XDG_STATE_HOME", state.path())
Expand Down Expand Up @@ -359,7 +375,7 @@ Wire skill instructions.
let (new_session, mut notifications) = process.recv_response(2);
let session_id = new_session["result"]["sessionId"]
.as_str()
.expect("session id")
.unwrap_or_else(|| panic!("session/new did not return a session id: {new_session}"))
.to_string();
while !notifications
.iter()
Expand Down
Loading
Loading