fix: default to workspace-write permissions

This commit is contained in:
bellman
2026-06-04 01:51:21 +09:00
parent 2ab2f44e1d
commit 94579eace5
6 changed files with 397 additions and 59 deletions

View File

@@ -110,6 +110,53 @@ struct ModelProvenance {
/// Environment variable that supplied the model, when source is Env.
env_var: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PermissionModeSource {
Flag,
Env,
Config,
Default,
}
impl PermissionModeSource {
fn as_str(self) -> &'static str {
match self {
Self::Flag => "flag",
Self::Env => "env",
Self::Config => "config",
Self::Default => "default",
}
}
fn is_explicit(self) -> bool {
!matches!(self, Self::Default)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PermissionModeProvenance {
mode: PermissionMode,
source: PermissionModeSource,
env_var: Option<&'static str>,
}
impl PermissionModeProvenance {
fn from_flag(mode: PermissionMode) -> Self {
Self {
mode,
source: PermissionModeSource::Flag,
env_var: None,
}
}
fn default_fallback() -> Self {
Self {
mode: PermissionMode::WorkspaceWrite,
source: PermissionModeSource::Default,
env_var: None,
}
}
}
struct EnvModel {
name: &'static str,
@@ -238,6 +285,7 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[
"--model",
"--output-format",
"--permission-mode",
"--skip-permissions",
"--dangerously-skip-permissions",
"--allowedTools",
"--allowed-tools",
@@ -355,6 +403,8 @@ fn classify_error_kind(message: &str) -> &'static str {
"cli_parse"
} else if message.starts_with("missing_flag_value:") {
"missing_flag_value"
} else if message.starts_with("invalid_permission_mode:") {
"invalid_permission_mode"
} else if message.starts_with("invalid_flag_value:") {
"invalid_flag_value"
} else if message.starts_with("invalid_model:") {
@@ -682,7 +732,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
cli.set_reasoning_effort(reasoning_effort);
cli.run_turn_with_output(&effective_prompt, output_format, compact)?;
}
CliAction::Doctor { output_format } => run_doctor(output_format)?,
CliAction::Doctor {
output_format,
permission_mode,
} => run_doctor(output_format, permission_mode)?,
CliAction::Acp { output_format } => print_acp_status(output_format)?,
CliAction::State { output_format } => run_worker_state(output_format)?,
CliAction::Init { output_format } => run_init(output_format)?,
@@ -794,7 +847,7 @@ enum CliAction {
// None means no flag was supplied; env/config/default fallback is
// resolved inside `print_status_snapshot`.
model_flag_raw: Option<String>,
permission_mode: PermissionMode,
permission_mode: PermissionModeProvenance,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
},
@@ -814,6 +867,7 @@ enum CliAction {
},
Doctor {
output_format: CliOutputFormat,
permission_mode: PermissionModeProvenance,
},
Acp {
output_format: CliOutputFormat,
@@ -983,7 +1037,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"--permission-mode" => {
let value = args
.get(index + 1)
.ok_or_else(|| "missing_flag_value: missing value for --permission-mode.\nUsage: --permission-mode default|acceptEdits|bypassPermissions|dangerFullAccess".to_string())?;
.ok_or_else(|| "missing_flag_value: missing value for --permission-mode.\nUsage: --permission-mode read-only|workspace-write|danger-full-access".to_string())?;
permission_mode_override = Some(parse_permission_mode_arg(value)?);
index += 2;
}
@@ -995,7 +1049,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode_override = Some(parse_permission_mode_arg(&flag[18..])?);
index += 1;
}
"--dangerously-skip-permissions" => {
"--dangerously-skip-permissions" | "--skip-permissions" => {
permission_mode_override = Some(PermissionMode::DangerFullAccess);
index += 1;
}
@@ -1260,6 +1314,11 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// structurally without an earlier default-resolution load writing prose
// warnings to stderr.
let permission_mode = || permission_mode_override.unwrap_or_else(default_permission_mode);
let permission_mode_provenance = || {
permission_mode_override
.map(PermissionModeProvenance::from_flag)
.unwrap_or_else(permission_mode_provenance_for_current_dir)
};
match rest[0].as_str() {
"dump-manifests" => parse_dump_manifests_args(&rest[1..], output_format),
@@ -1511,7 +1570,7 @@ Usage: claw prompt <text> or echo '<text>' | claw prompt".to_string());
model,
output_format,
allowed_tools,
permission_mode(),
permission_mode_provenance(),
compact,
base_commit,
reasoning_effort,
@@ -1742,12 +1801,19 @@ fn parse_single_word_command_alias(
"status" => Some(Ok(CliAction::Status {
model: model.to_string(),
model_flag_raw: model_flag_raw.map(str::to_string), // #148
permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode),
permission_mode: permission_mode_override
.map(PermissionModeProvenance::from_flag)
.unwrap_or_else(permission_mode_provenance_for_current_dir),
output_format,
allowed_tools,
})),
"sandbox" => Some(Ok(CliAction::Sandbox { output_format })),
"doctor" => Some(Ok(CliAction::Doctor { output_format })),
"doctor" => Some(Ok(CliAction::Doctor {
output_format,
permission_mode: permission_mode_override
.map(PermissionModeProvenance::from_flag)
.unwrap_or_else(permission_mode_provenance_for_current_dir),
})),
"state" => Some(Ok(CliAction::State { output_format })),
// #146: let `config` and `diff` fall through to parse_subcommand
// where they are wired as pure-local introspection, instead of
@@ -1853,7 +1919,7 @@ fn parse_direct_slash_cli_action(
model: String,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
permission_mode: PermissionModeProvenance,
compact: bool,
base_commit: Option<String>,
reasoning_effort: Option<String>,
@@ -1872,7 +1938,10 @@ fn parse_direct_slash_cli_action(
Ok(Some(SlashCommand::Sandbox)) => Ok(CliAction::Sandbox { output_format }),
Ok(Some(SlashCommand::Diff)) => Ok(CliAction::Diff { output_format }),
Ok(Some(SlashCommand::Version)) => Ok(CliAction::Version { output_format }),
Ok(Some(SlashCommand::Doctor)) => Ok(CliAction::Doctor { output_format }),
Ok(Some(SlashCommand::Doctor)) => Ok(CliAction::Doctor {
output_format,
permission_mode,
}),
Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents {
args,
output_format,
@@ -1893,7 +1962,7 @@ fn parse_direct_slash_cli_action(
model,
output_format,
allowed_tools,
permission_mode,
permission_mode: permission_mode.mode,
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
@@ -2248,7 +2317,7 @@ fn parse_permission_mode_arg(value: &str) -> Result<PermissionMode, String> {
normalize_permission_mode(value)
.ok_or_else(|| {
format!(
"invalid_flag_value: unsupported permission mode '{value}'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access"
"invalid_permission_mode: unsupported permission mode '{value}'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access"
)
})
.map(permission_mode_from_label)
@@ -2272,13 +2341,32 @@ fn permission_mode_from_resolved(mode: ResolvedPermissionMode) -> PermissionMode
}
fn default_permission_mode() -> PermissionMode {
env::var("RUSTY_CLAUDE_PERMISSION_MODE")
permission_mode_provenance_for_current_dir().mode
}
fn permission_mode_provenance_for_current_dir() -> PermissionModeProvenance {
if let Some(mode) = env::var("RUSTY_CLAUDE_PERMISSION_MODE")
.ok()
.as_deref()
.and_then(normalize_permission_mode)
.map(permission_mode_from_label)
.or_else(config_permission_mode_for_current_dir)
.unwrap_or(PermissionMode::DangerFullAccess)
{
return PermissionModeProvenance {
mode,
source: PermissionModeSource::Env,
env_var: Some("RUSTY_CLAUDE_PERMISSION_MODE"),
};
}
if let Some(mode) = config_permission_mode_for_current_dir() {
return PermissionModeProvenance {
mode,
source: PermissionModeSource::Config,
env_var: None,
};
}
PermissionModeProvenance::default_fallback()
}
fn config_permission_mode_for_current_dir() -> Option<PermissionMode> {
@@ -2311,7 +2399,15 @@ fn print_model_validation_warning_status(
let kind = classify_error_kind(error);
let (short_reason, inline_hint) = split_error_hint(error);
let hint = inline_hint.or_else(|| fallback_hint_for_error_kind(kind).map(String::from));
let mut value = status_json_value(None, usage, permission_mode, context, None, allowed_tools);
let mut value = status_json_value(
None,
usage,
permission_mode,
context,
None,
None,
allowed_tools,
);
let object = value
.as_object_mut()
.expect("status_json_value should render an object");
@@ -2778,6 +2874,7 @@ fn render_diagnostic_check(check: &DiagnosticCheck) -> String {
fn render_doctor_report(
config_warning_mode: ConfigWarningMode,
permission_mode: PermissionModeProvenance,
) -> Result<DoctorReport, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let config_loader = ConfigLoader::default_for(&cwd);
@@ -2829,16 +2926,23 @@ fn render_doctor_report(
check_workspace_health(&context),
check_boot_preflight_health(&context),
check_sandbox_health(&context.sandbox_status),
check_permission_health(permission_mode),
check_system_health(&cwd, config.as_ref().ok()),
],
})
}
fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let report = render_doctor_report(match output_format {
CliOutputFormat::Json => ConfigWarningMode::SuppressStderr,
CliOutputFormat::Text => ConfigWarningMode::EmitStderr,
})?;
fn run_doctor(
output_format: CliOutputFormat,
permission_mode: PermissionModeProvenance,
) -> Result<(), Box<dyn std::error::Error>> {
let report = render_doctor_report(
match output_format {
CliOutputFormat::Json => ConfigWarningMode::SuppressStderr,
CliOutputFormat::Text => ConfigWarningMode::EmitStderr,
},
permission_mode,
)?;
let message = report.render();
match output_format {
CliOutputFormat::Text => println!("{message}"),
@@ -3137,6 +3241,68 @@ fn check_config_health(
}
}
fn check_permission_health(permission_mode: PermissionModeProvenance) -> DiagnosticCheck {
let mode = permission_mode.mode.as_str();
let source = permission_mode.source.as_str();
let explicit = permission_mode.source.is_explicit();
let warning = matches!(permission_mode.mode, PermissionMode::DangerFullAccess) && !explicit;
let message = if warning {
"running with full access without explicit opt-in"
} else if matches!(permission_mode.mode, PermissionMode::DangerFullAccess) {
"danger-full-access was explicitly selected"
} else if matches!(permission_mode.mode, PermissionMode::WorkspaceWrite) && !explicit {
"default permission mode is workspace-write"
} else {
"permission mode is explicitly bounded below danger-full-access"
};
let source_detail = permission_mode.env_var.map_or_else(
|| source.to_string(),
|env_var| format!("{source}:{env_var}"),
);
let specs = mvp_tool_specs();
let tools_satisfied = specs
.iter()
.filter(|spec| permission_mode.mode >= spec.required_permission)
.map(|spec| spec.name)
.collect::<Vec<_>>();
let tools_gated = specs
.iter()
.filter(|spec| permission_mode.mode < spec.required_permission)
.map(|spec| spec.name)
.collect::<Vec<_>>();
DiagnosticCheck::new(
"Permissions",
if warning {
DiagnosticLevel::Warn
} else {
DiagnosticLevel::Ok
},
message,
)
.with_details(vec![
format!("Mode {mode}"),
format!("Source {source_detail}"),
format!("Explicit opt-in {explicit}"),
format!("Tools allowed {}", tools_satisfied.join(", ")),
format!("Tools gated {}", tools_gated.join(", ")),
])
.with_hint(if warning {
"Use the workspace-write default, or pass --permission-mode danger-full-access / --dangerously-skip-permissions only when full filesystem, network, and command access is intentional."
} else {
"Use --permission-mode read-only|workspace-write|danger-full-access to make the runtime permission boundary explicit."
})
.with_data(Map::from_iter([
("mode".to_string(), json!(mode)),
("source".to_string(), json!(source)),
("source_explicit".to_string(), json!(explicit)),
("env_var".to_string(), json!(permission_mode.env_var)),
("message".to_string(), json!(message)),
("tools_satisfied".to_string(), json!(tools_satisfied)),
("tools_gated".to_string(), json!(tools_gated)),
]))
}
fn check_install_source_health() -> DiagnosticCheck {
DiagnosticCheck::new(
"Install source",
@@ -4857,6 +5023,7 @@ fn run_resume_command(
default_permission_mode().as_str(),
&context,
None, // #148: resumed sessions don't have flag provenance
None,
)),
json: Some(status_json_value(
session.model.as_deref(),
@@ -4871,6 +5038,7 @@ fn run_resume_command(
&context,
None, // #148: resumed sessions don't have flag provenance
None,
None,
)),
})
}
@@ -5059,7 +5227,10 @@ fn run_resume_command(
})
}
SlashCommand::Doctor => {
let report = render_doctor_report(ConfigWarningMode::EmitStderr)?;
let report = render_doctor_report(
ConfigWarningMode::EmitStderr,
permission_mode_provenance_for_current_dir(),
)?;
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(report.render()),
@@ -6367,7 +6538,11 @@ impl LiveCli {
SlashCommand::Doctor => {
println!(
"{}",
render_doctor_report(ConfigWarningMode::EmitStderr)?.render()
render_doctor_report(
ConfigWarningMode::EmitStderr,
permission_mode_provenance_for_current_dir(),
)?
.render()
);
false
}
@@ -6452,6 +6627,7 @@ impl LiveCli {
self.permission_mode.as_str(),
&status_context(Some(&self.session.path)).expect("status context should load"),
None, // #148: REPL /status doesn't carry flag provenance
None,
)
);
}
@@ -7642,7 +7818,7 @@ fn render_repl_help() -> String {
fn print_status_snapshot(
model: &str,
model_flag_raw: Option<&str>,
permission_mode: PermissionMode,
permission_mode: PermissionModeProvenance,
output_format: CliOutputFormat,
allowed_tools: Option<&AllowedToolSet>,
) -> Result<(), Box<dyn std::error::Error>> {
@@ -7668,7 +7844,7 @@ fn print_status_snapshot(
return print_model_validation_warning_status(
&error,
usage,
permission_mode.as_str(),
permission_mode.mode.as_str(),
&context,
allowed_tools,
);
@@ -7682,9 +7858,10 @@ fn print_status_snapshot(
format_status_report(
&provenance.resolved,
usage,
permission_mode.as_str(),
permission_mode.mode.as_str(),
&context,
Some(&provenance)
Some(&provenance),
Some(&permission_mode),
)
),
CliOutputFormat::Json => println!(
@@ -7692,9 +7869,10 @@ fn print_status_snapshot(
serde_json::to_string_pretty(&status_json_value(
Some(&provenance.resolved),
usage,
permission_mode.as_str(),
permission_mode.mode.as_str(),
&context,
Some(&provenance),
Some(&permission_mode),
allowed_tools,
))?
),
@@ -7713,6 +7891,7 @@ fn status_json_value(
// that don't have provenance (legacy resume paths) pass None, in which
// case both new fields are omitted.
provenance: Option<&ModelProvenance>,
permission_provenance: Option<&PermissionModeProvenance>,
allowed_tools: Option<&AllowedToolSet>,
) -> serde_json::Value {
// #143: top-level `status` marker so claws can distinguish
@@ -7727,6 +7906,8 @@ fn status_json_value(
let model_raw = provenance.and_then(|p| p.raw.clone());
let model_alias_resolved_to = provenance.and_then(|p| p.alias_resolved_to.clone());
let model_env_var = provenance.and_then(|p| p.env_var.clone());
let permission_mode_source = permission_provenance.map(|p| p.source.as_str());
let permission_mode_env_var = permission_provenance.and_then(|p| p.env_var);
// #732: always emit an array (empty when unrestricted) so callers can do
// `.allowed_tools.entries | length > 0` without a null-check first.
let allowed_tool_entries = allowed_tools
@@ -7744,6 +7925,8 @@ fn status_json_value(
"model_alias_resolved_to": model_alias_resolved_to,
"model_env_var": model_env_var,
"permission_mode": permission_mode,
"permission_mode_source": permission_mode_source,
"permission_mode_env_var": permission_mode_env_var,
"allowed_tools": {
"source": if allowed_tools.is_some() { "flag" } else { "default" },
"restricted": allowed_tools.is_some(),
@@ -7892,6 +8075,7 @@ fn format_status_report(
// Callers without provenance (legacy resume paths) pass None and the
// source line is omitted for backward compat.
provenance: Option<&ModelProvenance>,
permission_provenance: Option<&PermissionModeProvenance>,
) -> String {
// #143: if config failed to parse, surface a degraded banner at the top
// of the text report so humans see the parse error before the body, while
@@ -7932,11 +8116,19 @@ fn format_status_report(
None => format!("\n Model source {}", p.source.as_str()),
})
.unwrap_or_default();
let permission_source_line = permission_provenance
.map(|p| {
let env_suffix = p
.env_var
.map_or(String::new(), |name| format!(" via {name}"));
format!("\n Permission source {}{env_suffix}", p.source.as_str())
})
.unwrap_or_default();
blocks.extend([
format!(
"{status_line}
Model {model}{model_source_line}
Permission mode {permission_mode}
Permission mode {permission_mode}{permission_source_line}
Messages {}
Turns {}
Estimated tokens {}",
@@ -8434,7 +8626,7 @@ fn render_doctor_help_json() -> serde_json::Value {
"command": "doctor",
"schema_version": "1.0",
"usage": "claw doctor [--output-format <format>]",
"purpose": "diagnose local auth, config, workspace, sandbox, boot preflight, and build metadata",
"purpose": "diagnose local auth, config, workspace, permissions, sandbox, boot preflight, and build metadata",
"formats": ["text", "json"],
"local_only": true,
"requires_credentials": false,
@@ -8442,7 +8634,7 @@ fn render_doctor_help_json() -> serde_json::Value {
"requires_session_resume": false,
"mutates_workspace": false,
"output_fields": ["kind", "action", "status", "message", "report", "has_failures", "summary", "checks"],
"check_names": ["auth", "config", "install source", "workspace", "boot preflight", "sandbox", "system"],
"check_names": ["auth", "config", "install source", "workspace", "boot preflight", "sandbox", "permissions", "system"],
"status_values": ["ok", "warn", "fail"],
"options": [
{
@@ -8957,9 +9149,11 @@ fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_jso
fn normalize_permission_mode(mode: &str) -> Option<&'static str> {
match mode.trim() {
"read-only" => Some("read-only"),
"workspace-write" => Some("workspace-write"),
"danger-full-access" => Some("danger-full-access"),
"default" | "plan" | "read-only" => Some("read-only"),
"acceptEdits" | "auto" | "workspace-write" => Some("workspace-write"),
"dontAsk" | "bypassPermissions" | "dangerFullAccess" | "danger-full-access" => {
Some("danger-full-access")
}
_ => None,
}
}
@@ -11889,7 +12083,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
)?;
writeln!(
out,
" --dangerously-skip-permissions Skip all permission checks"
" --dangerously-skip-permissions, --skip-permissions Skip all permission checks"
)?;
writeln!(out, " --allowedTools TOOLS Restrict enabled tools (repeatable; comma-separated aliases supported)")?;
writeln!(
@@ -11999,9 +12193,9 @@ mod tests {
split_error_hint, status_context, status_json_value, summarize_tool_payload_for_markdown,
try_resolve_bare_skill_prompt, validate_no_args, write_mcp_server_fixture, CliAction,
CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, InternalPromptProgressEvent,
InternalPromptProgressState, LiveCli, LocalHelpTopic, PromptHistoryEntry,
SessionLifecycleKind, SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot,
DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
InternalPromptProgressState, LiveCli, LocalHelpTopic, PermissionModeProvenance,
PromptHistoryEntry, SessionLifecycleKind, SessionLifecycleSummary, SlashCommand,
StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
};
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
use plugins::{
@@ -12342,7 +12536,7 @@ mod tests {
CliAction::Repl {
model: DEFAULT_MODEL.to_string(),
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
permission_mode: PermissionMode::WorkspaceWrite,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
@@ -12475,7 +12669,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
permission_mode: PermissionMode::WorkspaceWrite,
compact: false,
base_commit: None,
reasoning_effort: None,
@@ -12566,7 +12760,7 @@ mod tests {
model: "anthropic/claude-opus-4-7".to_string(),
output_format: CliOutputFormat::Json,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
permission_mode: PermissionMode::WorkspaceWrite,
compact: false,
base_commit: None,
reasoning_effort: None,
@@ -12597,7 +12791,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
permission_mode: PermissionMode::WorkspaceWrite,
compact: true,
base_commit: None,
reasoning_effort: None,
@@ -12640,7 +12834,7 @@ mod tests {
model: "anthropic/claude-opus-4-7".to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
permission_mode: PermissionMode::WorkspaceWrite,
compact: false,
base_commit: None,
reasoning_effort: None,
@@ -12807,7 +13001,7 @@ mod tests {
.map(str::to_string)
.collect()
),
permission_mode: PermissionMode::DangerFullAccess,
permission_mode: PermissionMode::WorkspaceWrite,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
@@ -12895,6 +13089,7 @@ mod tests {
parse_args(&["doctor".to_string()]).expect("doctor should parse"),
CliAction::Doctor {
output_format: CliOutputFormat::Text,
permission_mode: PermissionModeProvenance::default_fallback(),
}
);
assert_eq!(
@@ -13515,6 +13710,7 @@ mod tests {
&context,
None,
None,
None,
);
assert_eq!(
json.get("status").and_then(|v| v.as_str()),
@@ -13575,6 +13771,7 @@ mod tests {
"workspace-write",
&context,
None,
None,
Some(&allowed),
);
assert_eq!(
@@ -13607,6 +13804,7 @@ mod tests {
&clean_context,
None,
None,
None,
);
assert_eq!(
clean_json.get("status").and_then(|v| v.as_str()),
@@ -13682,7 +13880,7 @@ mod tests {
CliAction::Status {
model: DEFAULT_MODEL.to_string(),
model_flag_raw: None, // #148: no --model flag passed
permission_mode: PermissionMode::DangerFullAccess,
permission_mode: PermissionModeProvenance::default_fallback(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
}
@@ -13885,8 +14083,8 @@ mod tests {
"missing_flag_value"
);
assert_eq!(
classify_error_kind("invalid_flag_value: unsupported permission mode 'bogus'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access"),
"invalid_flag_value"
classify_error_kind("invalid_permission_mode: unsupported permission mode 'bogus'.\nUsage: --permission-mode read-only|workspace-write|danger-full-access"),
"invalid_permission_mode"
);
assert_eq!(
classify_error_kind("is not yet implemented"),
@@ -14595,7 +14793,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
permission_mode: PermissionMode::WorkspaceWrite,
compact: false,
base_commit: None,
reasoning_effort: None,
@@ -14624,7 +14822,7 @@ mod tests {
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
permission_mode: PermissionMode::WorkspaceWrite,
compact: false,
base_commit: None,
reasoning_effort: None,
@@ -15193,6 +15391,7 @@ mod tests {
config_load_error_kind: None,
},
None, // #148
None,
);
assert!(status.contains("Status"));
assert!(status.contains("Model claude-sonnet"));
@@ -15391,6 +15590,7 @@ mod tests {
&context,
None,
None,
None,
);
assert_eq!(

View File

@@ -434,6 +434,106 @@ fn status_json_surfaces_permission_mode_override_for_security_audit() {
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn default_permission_mode_is_workspace_write_and_audited_428() {
let root = unique_temp_dir("default-permission-mode-428");
let config_home = root.join("config-home");
let home = root.join("home");
fs::create_dir_all(&root).expect("temp dir should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let envs = [
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
("RUSTY_CLAUDE_PERMISSION_MODE", ""),
];
let status = assert_json_command_with_env(&root, &["--output-format", "json", "status"], &envs);
assert_eq!(status["permission_mode"], "workspace-write");
assert_eq!(status["permission_mode_source"], "default");
let doctor = assert_json_command_with_env(&root, &["--output-format", "json", "doctor"], &envs);
let permissions = doctor["checks"]
.as_array()
.expect("doctor checks")
.iter()
.find(|check| check["name"] == "permissions")
.expect("permissions check");
assert_eq!(permissions["status"], "ok");
assert_eq!(permissions["mode"], "workspace-write");
assert_eq!(permissions["source"], "default");
assert_eq!(
permissions["message"],
"default permission mode is workspace-write"
);
}
#[test]
fn explicit_danger_permission_mode_is_audited_and_alias_supported_428() {
let root = unique_temp_dir("danger-permission-mode-428");
fs::create_dir_all(&root).expect("temp dir should exist");
let status = assert_json_command(
&root,
&["--skip-permissions", "--output-format", "json", "status"],
);
assert_eq!(status["permission_mode"], "danger-full-access");
assert_eq!(status["permission_mode_source"], "flag");
let doctor = assert_json_command(
&root,
&[
"--permission-mode",
"danger-full-access",
"--output-format",
"json",
"doctor",
],
);
let permissions = doctor["checks"]
.as_array()
.expect("doctor checks")
.iter()
.find(|check| check["name"] == "permissions")
.expect("permissions check");
assert_eq!(permissions["status"], "ok");
assert_eq!(permissions["mode"], "danger-full-access");
assert_eq!(permissions["source"], "flag");
assert_eq!(permissions["source_explicit"], true);
}
#[test]
fn invalid_permission_mode_json_is_typed_428() {
let root = unique_temp_dir("invalid-permission-mode-428");
fs::create_dir_all(&root).expect("temp dir should exist");
let output = run_claw(
&root,
&[
"--permission-mode",
"bogus-mode",
"status",
"--output-format",
"json",
],
&[],
);
assert_eq!(output.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let parsed: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|_| panic!("invalid permission mode must emit JSON, got: {stdout:?}"));
assert_eq!(parsed["error_kind"], "invalid_permission_mode");
assert_eq!(parsed["kind"], "invalid_permission_mode");
assert!(
stderr.is_empty(),
"JSON error stderr should be empty: {stderr:?}"
);
}
#[test]
fn status_json_accepts_namespaced_model_env_and_surfaces_alias_426() {
let root = unique_temp_dir("status-model-env-426");

View File

@@ -514,7 +514,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["url", "prompt"],
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
required_permission: PermissionMode::DangerFullAccess,
},
ToolSpec {
name: "WebSearch",
@@ -535,7 +535,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"required": ["query"],
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
required_permission: PermissionMode::DangerFullAccess,
},
ToolSpec {
name: "TodoWrite",
@@ -1321,8 +1321,26 @@ fn execute_tool_with_enforcer(
maybe_enforce_permission_check_with_mode(enforcer, name, input, required_mode)?;
run_grep_search(grep_input)
}
"WebFetch" => from_value::<WebFetchInput>(input).and_then(run_web_fetch),
"WebSearch" => from_value::<WebSearchInput>(input).and_then(run_web_search),
"WebFetch" => {
let web_input = from_value::<WebFetchInput>(input)?;
maybe_enforce_permission_check_with_mode(
enforcer,
name,
input,
PermissionMode::DangerFullAccess,
)?;
run_web_fetch(web_input)
}
"WebSearch" => {
let web_input = from_value::<WebSearchInput>(input)?;
maybe_enforce_permission_check_with_mode(
enforcer,
name,
input,
PermissionMode::DangerFullAccess,
)?;
run_web_search(web_input)
}
"TodoWrite" => from_value::<TodoWriteInput>(input).and_then(run_todo_write),
"Skill" => from_value::<SkillInput>(input).and_then(run_skill),
"Agent" => from_value::<AgentInput>(input).and_then(run_agent),
@@ -10264,6 +10282,26 @@ printf 'pwsh:%s' "$1"
);
}
#[test]
fn given_workspace_write_enforcer_when_web_tools_then_denied() {
let registry = workspace_write_registry();
for (tool, input) in [
(
"WebFetch",
json!({"url":"https://example.com", "prompt":"summarize"}),
),
("WebSearch", json!({"query":"rust language"})),
] {
let err = registry
.execute(tool, &input)
.expect_err("network tools should require explicit full access");
assert!(
err.contains("requires 'danger-full-access'"),
"{tool} should require elevated mode: {err}"
);
}
}
#[test]
fn given_workspace_write_enforcer_when_bash_uses_shell_expansion_then_denied() {
let registry = workspace_write_registry();