fix: default to workspace-write permissions
This commit is contained in:
parent
2ab2f44e1d
commit
94579eace5
|
|
@ -6360,7 +6360,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
|||
427. **DONE — resume/session/compact help short-circuits locally and missing resume sessions report the session store** — fixed 2026-06-03 in `fix: keep session help local`. `claw resume --help`, `claw --resume --help`, `claw session --help`, and `claw compact --help` now route through static `LocalHelpTopic` output before config loading, session resolution, credential checks, provider startup, or slash-command interactive-only fallthrough. The direct `claw resume <session>` alias now shares the existing `--resume` restore parser, so `claw resume <missing> --output-format json` returns a local `session_not_found` restore envelope with `sessions_dir` and exit code 1 instead of reaching provider credentials. Regression coverage: `resume_session_compact_help_short_circuits_before_config_or_auth_427`, `resume_missing_session_json_reports_local_store_before_auth_427`, local help parser tests, and resume parser tests.
|
||||
|
||||
|
||||
428. **Default `permission_mode` is `danger-full-access` — claw runs with FULL filesystem + network + tool access out of the box, with no opt-in flag and no warning from `doctor`** — dogfooded 2026-05-11 by Jobdori on `72048449` in response to Clawhip pinpoint nudge at `1503260393622212628`. Reproduction (no env vars, isolated `CLAW_CONFIG_HOME`, no config files, no CLI flags): `claw status --output-format json` returns `permission_mode:"danger-full-access"` as the default. The three supported modes per the validator error message are `read-only`, `workspace-write`, `danger-full-access` — and `danger-full-access` is chosen with zero user opt-in. `claw doctor --output-format json` produces a `sandbox` check with `status:"warn", summary:"sandbox was requested but is not currently active"` (because macOS lacks Linux `unshare`), but **emits no warning, info, or summary about the permission_mode itself being danger-full-access**. There is no `permissions` check in `doctor` output at all. **Required fix shape:** (a) change default `permission_mode` to `workspace-write` (safe-by-default: filesystem write limited to cwd, network limited to LLM endpoints, no arbitrary command exec); (b) require explicit `--permission-mode danger-full-access` or `--dangerously-skip-permissions` to opt into full access; (c) add a `permissions` check to `doctor --output-format json` that emits `status:"warn"` when `permission_mode == "danger-full-access"` without explicit source (flag/env/config), with details like `mode:"danger-full-access", source:"default", message:"running with full access without explicit opt-in"`; (d) document the three modes and the default in USAGE.md with one-paragraph descriptions of what each mode allows. **Sibling typed-error bug:** `claw --permission-mode bogus-mode status --output-format json` returns `kind:"unknown"` instead of `kind:"invalid_permission_mode"` — same catch-all problem as #424, #426. **Sibling flag-name asymmetry:** `--dangerously-skip-permissions` works but `--skip-permissions` (Claude Code's flag) returns `kind:"cli_parse"` `unknown option`. Users migrating from Claude Code lose the short flag name. **Why this matters:** every other security-conscious CLI (Docker, kubectl, terraform) requires explicit opt-in for dangerous modes. Defaulting to `danger-full-access` is a footgun for first-time users who pipe `curl install.sh | sh` and immediately get a tool with full filesystem write and arbitrary command exec. The doctor surface is the only diagnostic users consult before trusting the tool, and it stays silent about the most permissive setting. Cross-references #50, #87, #91, #94, #97, #101, #106, #115, #123 (permission-audit sweep) — those all cover permission *rule* and *list* surfaces; #428 covers the *mode default* itself. Source: Jobdori live dogfood, `72048449`, 2026-05-11.
|
||||
428. **DONE — default permission mode is workspace-write with auditable permission provenance** — fixed 2026-06-03 in `fix: default to workspace-write permissions`. Fresh invocations now resolve the fallback permission mode to `workspace-write` instead of `danger-full-access`; `danger-full-access` requires an explicit `--permission-mode danger-full-access`, `--dangerously-skip-permissions`, `--skip-permissions`, env, or config opt-in. `status --output-format json` includes `permission_mode_source` and `permission_mode_env_var`, and `doctor --output-format json` includes a `permissions` check with `mode`, `source`, `source_explicit`, `message`, and tool allow/gate lists. Invalid CLI permission modes now emit typed `invalid_permission_mode` JSON errors, and docs describe the three modes plus the safe default. Regression coverage: `default_permission_mode_is_workspace_write_and_audited_428`, `explicit_danger_permission_mode_is_audited_and_alias_supported_428`, `invalid_permission_mode_json_is_typed_428`, parser default tests, classifier coverage, and `given_workspace_write_enforcer_when_web_tools_then_denied`.
|
||||
|
||||
|
||||
429. **No global `--cwd`/`-C`/`--directory` flag — `claw` cannot be invoked against an arbitrary working directory without first `cd`-ing into it; `--cwd` only exists as a subcommand option for `system-prompt`, and the `cli_parse` "Did you mean --acp?" suggestion is misleading (the `--acp` flag is unrelated to directory selection)** — dogfooded 2026-05-11 by Jobdori on `ec882f4c` in response to Clawhip pinpoint nudge at `1503267943285264394`. Reproduction: `claw --cwd /tmp/claw-dog-cwd status --output-format json` → `{"error":"unknown option: --cwd","hint":"Did you mean --acp?\nRun `claw --help` for usage.","kind":"cli_parse"}`. Same error for `--cwd <relative>`, `--cwd <nonexistent>`, `--cwd <file-not-dir>`, `--cwd ""`. Inspecting `claw --help`: `--cwd PATH` appears ONLY in the usage line `claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — it is not a global flag and is not accepted by `status`, `doctor`, `mcp list`, `init`, or any other subcommand. Users programmatically running claw against multiple workspaces must `cd` into each one before invoking, breaking the `subprocess.run(['claw', 'status', '--cwd', ws], cwd=other_dir)` pattern that every other major CLI (cargo `-C`, git `-C`, npm `--prefix`, gh `--repo` semantically, kubectl `--kubeconfig`+`--context`) supports. **Sibling misleading-suggestion bug:** the `cli_parse` error's `hint` field suggests `Did you mean --acp?` for `--cwd`. `--acp` is the alias for ACP/Zed editor integration (entirely unrelated to working directory). The Levenshtein-distance auto-complete is matching on first-character similarity without considering semantic relatedness. Users following the hint get a totally orthogonal feature. **Required fix shape:** (a) add a global `--cwd PATH` / `-C PATH` flag accepted before any subcommand, parsed in the global flag pre-pass; (b) validate the path exists and is a directory; emit `kind:"invalid_cwd"` with `path:` and `reason:` (`"not_found"`/`"not_a_directory"`/`"empty"`) when validation fails; (c) document the precedence: `--cwd` flag > `$PWD` > `env::current_dir()`; (d) fix the "Did you mean" hint algorithm to filter suggestions by semantic category (don't suggest `--acp` for `--cwd`; suggest `claw system-prompt --cwd PATH` if the user clearly wants `cwd` override but used the wrong scope); (e) regression test: `claw --cwd /tmp status --output-format json` from any `$PWD` returns `workspace.cwd:"/private/tmp"` (or `cwd:"/tmp"` after #421 fix). **Why this matters:** every claw automation orchestrator runs claw against multiple workspaces from a single parent process. Forcing `cd` before each invocation breaks parallelism (can't use shared cwd across concurrent invocations), breaks subprocess wrappers that want to pass cwd explicitly, and breaks `xargs`/`parallel`-style pipelines. Cross-references #421 (cwd canonicalization leak — fix should canonicalize but report user-input via `--cwd`). Source: Jobdori live dogfood, `ec882f4c`, 2026-05-11.
|
||||
|
|
|
|||
8
USAGE.md
8
USAGE.md
|
|
@ -195,11 +195,11 @@ cd rust
|
|||
./target/debug/claw --allowedTools read,glob "inspect the runtime crate"
|
||||
```
|
||||
|
||||
Supported permission modes:
|
||||
Supported permission modes (default: `workspace-write`):
|
||||
|
||||
- `read-only`
|
||||
- `workspace-write`
|
||||
- `danger-full-access`
|
||||
- `read-only` allows inspection-only local tools such as file reads, glob/grep searches, local skills, and status-style reporting. It does not allow workspace mutation, network-fetch/search tools, or arbitrary command execution.
|
||||
- `workspace-write` is the safe default. It allows reads plus direct file-editing tools inside the current workspace, including write/edit/notebook/config/plan-mode updates, while still gating network-fetch/search tools, arbitrary shell execution, subagent launches, REPL subprocesses, and other full-access tools behind an explicit escalation.
|
||||
- `danger-full-access` allows every registered tool requirement, including arbitrary command execution, web fetch/search, subagent launches, subprocess REPLs, and unrestricted tool access. Select it only with an explicit `--permission-mode danger-full-access`, `--dangerously-skip-permissions`, `--skip-permissions`, env, or config opt-in.
|
||||
|
||||
Model aliases currently supported by the CLI:
|
||||
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ Flags:
|
|||
--model MODEL
|
||||
--output-format text|json
|
||||
--permission-mode MODE
|
||||
--dangerously-skip-permissions
|
||||
--dangerously-skip-permissions, --skip-permissions
|
||||
--allowedTools TOOLS
|
||||
--resume [SESSION.jsonl|session-id|latest]
|
||||
--version, -V
|
||||
|
|
@ -211,7 +211,7 @@ rust/
|
|||
- **9 crates** in workspace
|
||||
- **Binary name:** `claw`
|
||||
- **Default model:** `claude-opus-4-7`
|
||||
- **Default permissions:** `danger-full-access`
|
||||
- **Default permissions:** `workspace-write`
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue