diff --git a/.github/AUTHOR_MAP b/.github/AUTHOR_MAP index a066a9404..ce778abb7 100644 --- a/.github/AUTHOR_MAP +++ b/.github/AUTHOR_MAP @@ -77,6 +77,9 @@ DarrellThomas = Darrell Thomas <10118469+DarrellThomas@users.noreply.github.com> darrell@redshed.ai = Darrell Thomas <10118469+DarrellThomas@users.noreply.github.com> taixinguo = Taixin Guo <188038323+taixinguo@users.noreply.github.com> Taixin Guo = Taixin Guo <188038323+taixinguo@users.noreply.github.com> +JayBeest = JayBeest <67948678+JayBeest@users.noreply.github.com> +jcorneli = JayBeest <67948678+JayBeest@users.noreply.github.com> +jaybart@protonmail.com = JayBeest <67948678+JayBeest@users.noreply.github.com> lucaszhu-hue = lucaszhu-hue <278269343+lucaszhu-hue@users.noreply.github.com> zhuangbiaowei = zhuangbiaowei <93194+zhuangbiaowei@users.noreply.github.com> yuanchenglu = yuanchenglu <4088730+yuanchenglu@users.noreply.github.com> diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 6988840f5..5d8278b75 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -869,6 +869,9 @@ fn glibc_check_disabled() -> bool { } fn preflight_downloaded_binary(asset_name: &str, bytes: &[u8]) -> Result<()> { + // GNU libc preflight is Linux-only (#4241). Rust treats `target_os = "android"` + // as distinct from `"linux"`, so Termux/Android builds skip this check entirely + // — Android uses Bionic libc, not glibc. if !cfg!(target_os = "linux") || glibc_check_disabled() { return Ok(()); } @@ -1521,6 +1524,42 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win assert_eq!(asset.name, "codewhale-tui-macos-arm64"); } + #[test] + fn android_arm64_maps_to_android_release_assets() { + // The generic format!("{prefix}-{os}-{arch}") path naturally produces + // Android asset stems. Verify the full stem for both dispatcher and TUI + // binaries so `codewhale update` on Termux requests Android assets, not + // linux-arm64 (#4241). + assert_eq!( + release_asset_stem_for_prefix("codewhale", "android", "aarch64"), + "codewhale-android-arm64" + ); + assert_eq!( + release_asset_stem_for_prefix("codewhale-tui", "android", "aarch64"), + "codewhale-tui-android-arm64" + ); + assert_eq!( + release_asset_stem_for_prefix("codew", "android", "aarch64"), + "codew-android-arm64" + ); + } + + #[test] + fn ensure_supported_release_target_accepts_android() { + // Android/Termux is a supported release target (#4241). + assert!(ensure_supported_release_target("android", "aarch64").is_ok()); + } + + #[test] + fn android_release_assets_never_select_linux_arm64() { + // Sanity: the stem formatter must never produce a linux-* stem for android. + let stem = release_asset_stem_for_prefix("codewhale", "android", "aarch64"); + assert!( + !stem.contains("linux"), + "android stem must not contain linux: {stem}" + ); + } + #[test] fn mirror_release_uses_base_url_and_platform_assets() { let release = release_from_mirror_base_url( diff --git a/crates/config/src/route/ids.rs b/crates/config/src/route/ids.rs index 802b8c819..1df754dd2 100644 --- a/crates/config/src/route/ids.rs +++ b/crates/config/src/route/ids.rs @@ -24,6 +24,13 @@ use std::fmt; use serde::{Deserialize, Serialize}; +/// The `"auto"` router sentinel for [`LogicalModelRef`]. +/// +/// `auto` is an opt-in router sentinel — it never refers to a literal model +/// named "auto". Centralized here so every comparison site uses the same +/// spelling (#4158). +pub const AUTO_SENTINEL: &str = "auto"; + use crate::ProviderKind; macro_rules! string_newtype { @@ -137,7 +144,7 @@ impl LogicalModelRef { /// `auto` is an opt-in router sentinel, never a literal model id. #[must_use] pub fn is_auto(&self) -> bool { - self.raw() == "auto" + self.raw() == AUTO_SENTINEL } /// Parse the leading namespace prefix, if any. diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index b09f23217..4bc118fb8 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1506,6 +1506,10 @@ impl Engine { .with_todos(self.config.todos.clone()) .with_parent_mode(self.current_mode) .background_runtime(); + // #4042: thread the session's --disallowed-tools into + // the child so tool restrictions flow down to sub-agents. + runtime.worker_profile.denied_tools = + self.config.disallowed_tools.clone().unwrap_or_default(); let route = resolve_subagent_assignment_route( &runtime, None, @@ -2623,6 +2627,11 @@ impl Engine { if matches!(input_policy.mode, AppMode::Plan) { rt.worker_profile = WorkerRuntimeProfile::for_role(SubAgentType::Plan); } + // #4042: stamp the session's --disallowed-tools onto the parent + // runtime so every model-spawned sub-agent inherits the deny-list + // (plan-mode role override above is intentionally before this). + rt.worker_profile.denied_tools = + self.config.disallowed_tools.clone().unwrap_or_default(); if let Some(context) = fork_context_for_runtime.clone() { rt = rt.with_fork_context(context); } diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index ce5b20d7a..a4203f3fc 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -3171,6 +3171,10 @@ fn should_emit_thinking_only_status( tool_uses_empty && turn_error_is_none && !cancelled && !steers_pending && !holding_for_subagents } +/// Sentinel reasoning-effort value meaning "let the auto-reasoning system +/// decide" (#4158). +const REASONING_EFFORT_AUTO: &str = "auto"; + /// Resolve an `"auto"` reasoning-effort tier to a concrete value. /// /// When the configured effort is `"auto"`, inspects the last user message @@ -3182,7 +3186,7 @@ fn resolve_auto_effort( provider: crate::config::ApiProvider, ) -> Option { match reasoning_effort { - Some("auto") => { + Some(effort) if effort == REASONING_EFFORT_AUTO => { // Find the last user message in the conversation. let last_msg = messages .iter() diff --git a/crates/tui/src/fleet/worker_runtime.rs b/crates/tui/src/fleet/worker_runtime.rs index 00052928a..618e6d725 100644 --- a/crates/tui/src/fleet/worker_runtime.rs +++ b/crates/tui/src/fleet/worker_runtime.rs @@ -698,6 +698,16 @@ pub fn apply_exec_hardening( AgentWorkerToolProfile::Explicit(tools) => ToolScope::Explicit(tools.clone()), }; } + // #4042: thread `FleetExecConfig.disallowed_tools` into the runtime profile's + // deny-list so it is enforced at run time even for `Inherited` tool profiles, + // which `filter_tool_profile` cannot narrow at spec time. Union with any + // already-inherited entries (deny never relaxes). The subprocess Fleet exec + // path separately passes `--disallowed-tools` on the CLI. + for rule in &exec.disallowed_tools { + if !spec.runtime_profile.denied_tools.contains(rule) { + spec.runtime_profile.denied_tools.push(rule.clone()); + } + } // Append system prompt if !exec.append_system_prompt.is_empty() { diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 7dbec2bc6..1f1bc8b86 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -1175,6 +1175,13 @@ struct SpawnRequest { /// When unset, the child inherits the parent's budget pool or the /// configured root default. token_budget: Option, + /// Extra tool deny-list from the caller, unioned with the parent runtime's + /// inherited deny-list. Deny always wins over allow (#4042). + disallowed_tools: Option>, + /// When true (default), the child inherits the parent runtime's + /// `disallowed_tools`. Set `false` to start the child with a clean slate + /// (only the explicit `disallowed_tools` above, if any, then apply). + inherit_disallowed_tools: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -4262,6 +4269,27 @@ async fn spawn_subagent_from_input( if let Some(workspace) = child_workspace { child_runtime.context.workspace = workspace; } + // #4042: merge the parent runtime's inherited deny-list with the caller's + // explicit `disallowed_tools`. `background_runtime()` already cloned the + // parent's `worker_profile.denied_tools` (the session `--disallowed-tools`), + // so by default the child inherits it. `inherit_disallowed_tools: false` + // drops *only* the inherited list; an explicit caller `disallowed_tools` + // always applies (union, deny never relaxes). + if !spawn_request.inherit_disallowed_tools { + child_runtime.worker_profile.denied_tools.clear(); + } + if let Some(ref caller_deny) = spawn_request.disallowed_tools { + for tool in caller_deny { + if !child_runtime + .worker_profile + .denied_tools + .iter() + .any(|existing| existing == tool) + { + child_runtime.worker_profile.denied_tools.push(tool.clone()); + } + } + } // #4193 seam 2: normalize/validate the requested model against the CHILD's // (pinned) provider, not the session provider. `child_runtime` carries the // provider-B client set above, so a profile-less/`inherit` member still @@ -6303,6 +6331,15 @@ fn parse_spawn_request(input: &Value) -> Result { let token_budget = parse_optional_positive_u64(input, &["token_budget", "tokenBudget", "max_tokens"])?; + // #4042: optional caller-supplied tool deny-list (unioned with the parent's + // inherited deny-list) and the inheritance opt-out flag (default inherits). + let disallowed_tools = parse_disallowed_tools(input)?; + let inherit_disallowed_tools = parse_optional_bool( + input, + &["inherit_disallowed_tools", "inheritDisallowedTools"], + ) + .unwrap_or(true); + Ok(SpawnRequest { session_name, prompt: prompt.clone(), @@ -6321,6 +6358,8 @@ fn parse_spawn_request(input: &Value) -> Result { fork_context, max_depth, token_budget, + disallowed_tools, + inherit_disallowed_tools, }) } @@ -6513,6 +6552,31 @@ fn parse_optional_bool(input: &Value, names: &[&str]) -> Option { .and_then(Value::as_bool) } +/// Parse an optional caller-supplied `disallowed_tools` array (#4042). Mirrors +/// the `allowed_tools` parsing: trimmed, de-duplicated, non-empty-only. Returns +/// `None` when the key is absent or yields no usable entries so the union merge +/// in `spawn_subagent_from_input` only runs when there is something to add. +fn parse_disallowed_tools(input: &Value) -> Result>, ToolError> { + let Some(array) = input.get("disallowed_tools").and_then(Value::as_array) else { + return Ok(None); + }; + let mut tools = Vec::new(); + for item in array { + let Some(tool) = item.as_str() else { + continue; + }; + let trimmed = tool.trim(); + if !trimmed.is_empty() && !tools.iter().any(|existing: &String| existing == trimmed) { + tools.push(trimmed.to_string()); + } + } + if tools.is_empty() { + Ok(None) + } else { + Ok(Some(tools)) + } +} + fn parse_optional_positive_u64(input: &Value, names: &[&str]) -> Result, ToolError> { for name in names { let Some(value) = input.get(*name) else { @@ -7448,6 +7512,11 @@ struct SubAgentToolRegistry { /// `None` → full inheritance (no allowlist filter applied). `Some(list)` → /// only the listed tools are visible to the model and callable. allowed_tools: Option>, + /// Tool deny-list inherited from the parent runtime's `worker_profile` + /// (#4042). Deny always wins over allow, even when a tool is in both the + /// allowlist and this list. Wildcard matching mirrors the session-side + /// `command_denies_tool` (exact + `prefix*`, case-insensitive). + disallowed_tools: Vec, auto_approve: bool, /// The role/type of the sub-agent that this registry belongs to. Used to /// decide whether `Suggest`-level tools (write/edit/patch) may run inside @@ -7518,6 +7587,7 @@ impl SubAgentToolRegistry { Self { allowed_tools: explicit_allowed_tools, + disallowed_tools: runtime.worker_profile.denied_tools.clone(), auto_approve: runtime.context.auto_approve, agent_type, runtime_profile: runtime.worker_profile, @@ -7566,12 +7636,35 @@ impl SubAgentToolRegistry { } } + /// Check whether a tool name is denied by the `disallowed_tools` list, using + /// the same matching logic as the session-side `command_denies_tool`: exact + /// match + `prefix*` wildcard, case-insensitive (#4042, #3027). + fn is_tool_denied(&self, name: &str) -> bool { + if self.disallowed_tools.is_empty() { + return false; + } + let tool_name = name.to_ascii_lowercase(); + self.disallowed_tools.iter().any(|rule| { + let rule = rule.to_ascii_lowercase(); + if let Some(prefix) = rule.strip_suffix('*') { + tool_name.starts_with(prefix) + } else { + tool_name == rule + } + }) + } + /// Whether a given tool name is permitted under this child's filter. /// `None` filter = everything permitted. fn is_tool_allowed(&self, name: &str) -> bool { if name == "agent" && !self.can_spawn_child { return false; } + // Deny always wins over allow — check the deny-list first so a tool in + // both the allowlist and the deny-list is still blocked (#4042). + if self.is_tool_denied(name) { + return false; + } match &self.allowed_tools { None => true, Some(list) => list.iter().any(|t| t == name), @@ -7591,6 +7684,10 @@ impl SubAgentToolRegistry { filtered .into_iter() .filter(|tool| tool.name != "agent" || self.can_spawn_child) + // #4042: hide explicitly disallowed tools so the model never sees + // them in the function-calling schema (defense-in-depth with the + // `is_tool_allowed` / `execute` guards). + .filter(|tool| !self.is_tool_denied(&tool.name)) // #3217: hide tools the role posture forbids so the model never // even sees write/edit/patch (read-only roles) or shell (no-shell // roles). Defense-in-depth with the `execute` guard below. diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index a5a1f5074..f920a9674 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -6876,3 +6876,385 @@ fn agent_action_parses_wait_aliases() { ); } } + +// =========================================================================== +// #4042 — sub-agent tool restriction inheritance (Phase 1, harvested from +// PR #4096 by @JayBeest). +// +// These tests verify that the parent session's `--disallowed-tools` flows into +// spawned sub-agents through `SubAgentRuntime` → `SubAgentToolRegistry`. The +// deny-list is stamped onto `worker_profile.denied_tools` by the engine and +// cloned through `child_runtime()`/`background_runtime()`, so a registry built +// from a child runtime enforces it in `is_tool_allowed()`, `tools_for_model()`, +// and `execute()`. +// +// Deny always wins over allow. Wildcards (`prefix*`) and case-insensitive +// matching mirror the session-side `command_denies_tool()`. +// =========================================================================== + +/// Build a stub runtime with the parent's `disallowed_tools` set on the +/// `WorkerRuntimeProfile`. The registry reads deny lists from the profile at +/// construction, and `child_runtime()` clones the profile so the list +/// propagates across generations. +fn stub_runtime_with_disallowed(disallowed: Vec) -> SubAgentRuntime { + let mut rt = stub_runtime(); + rt.worker_profile.denied_tools = disallowed; + rt +} + +/// Build a `SubAgentToolRegistry` wired with `disallowed_tools`. Passes the +/// runtime through `SubAgentToolRegistry::new()` so the constructor picks up +/// `worker_profile.denied_tools`. `allowed_tools` is forwarded directly. +fn new_registry_with_disallowed( + runtime: SubAgentRuntime, + allowed_tools: Option>, +) -> SubAgentToolRegistry { + SubAgentToolRegistry::new( + runtime, + SubAgentType::General, + allowed_tools, + Arc::new(Mutex::new(TodoList::new())), + Arc::new(Mutex::new(PlanState::default())), + ) +} + +#[test] +fn test_disallowed_tools_inheritance_denies_tool() { + let tmp = tempdir().expect("tempdir"); + let mut runtime = + stub_runtime_with_disallowed(vec!["exec_shell".to_string(), "write_file".to_string()]); + runtime.context = ToolContext::new(tmp.path().to_path_buf()); + let registry = new_registry_with_disallowed(runtime, None); + + assert!( + !registry.is_tool_allowed("exec_shell"), + "exec_shell should be denied" + ); + assert!( + !registry.is_tool_allowed("write_file"), + "write_file should be denied" + ); + assert!( + registry.is_tool_allowed("read_file"), + "read_file should still be allowed" + ); + assert!( + registry.is_tool_allowed("grep_files"), + "unrelated tools should be allowed" + ); + + let tools = registry.tools_for_model(&SubAgentType::General); + let names: HashSet<_> = tools.into_iter().map(|t| t.name).collect(); + assert!(!names.contains("exec_shell"), "catalog excludes exec_shell"); + assert!(!names.contains("write_file"), "catalog excludes write_file"); + assert!(names.contains("read_file"), "catalog includes read_file"); +} + +#[test] +fn test_disallowed_tools_deny_wins_over_allow() { + let tmp = tempdir().expect("tempdir"); + let mut runtime = stub_runtime_with_disallowed(vec!["exec_shell".to_string()]); + runtime.context = ToolContext::new(tmp.path().to_path_buf()); + // exec_shell is in BOTH the allowlist AND the deny list — deny must win. + let registry = new_registry_with_disallowed( + runtime, + Some(vec!["exec_shell".to_string(), "read_file".to_string()]), + ); + + assert!( + !registry.is_tool_allowed("exec_shell"), + "deny must win over allow" + ); + assert!( + registry.is_tool_allowed("read_file"), + "read_file is allowed and not denied" + ); + + let tools = registry.tools_for_model(&SubAgentType::General); + let names: HashSet<_> = tools.into_iter().map(|t| t.name).collect(); + assert!( + !names.contains("exec_shell"), + "catalog must exclude denied tool even when allowlisted" + ); + assert!(names.contains("read_file"), "catalog includes allowed tool"); +} + +#[test] +fn test_disallowed_tools_wildcard_matching() { + let tmp = tempdir().expect("tempdir"); + let mut runtime = stub_runtime_with_disallowed(vec!["mcp_*".to_string()]); + runtime.context = ToolContext::new(tmp.path().to_path_buf()); + let registry = new_registry_with_disallowed(runtime, None); + + assert!( + !registry.is_tool_allowed("mcp_github_list_prs"), + "mcp_* wildcard should deny all MCP tools" + ); + assert!( + !registry.is_tool_allowed("mcp_database_query"), + "mcp_* wildcard denies any server prefix" + ); + assert!( + registry.is_tool_allowed("read_file"), + "non-MCP tools are unaffected by mcp_* deny" + ); +} + +#[test] +fn test_disallowed_tools_case_insensitive_match() { + let tmp = tempdir().expect("tempdir"); + let mut runtime = stub_runtime_with_disallowed(vec!["Exec_Shell".to_string()]); + runtime.context = ToolContext::new(tmp.path().to_path_buf()); + let registry = new_registry_with_disallowed(runtime, None); + + assert!( + !registry.is_tool_allowed("exec_shell"), + "case-insensitive: Exec_Shell denies exec_shell" + ); + assert!( + !registry.is_tool_allowed("EXEC_SHELL"), + "case-insensitive: Exec_Shell denies EXEC_SHELL" + ); + assert!( + registry.is_tool_allowed("read_file"), + "unrelated tool unaffected" + ); +} + +#[test] +fn test_disallowed_tools_specific_server_wildcard() { + let tmp = tempdir().expect("tempdir"); + let mut runtime = stub_runtime_with_disallowed(vec!["mcp_dangerous_*".to_string()]); + runtime.context = ToolContext::new(tmp.path().to_path_buf()); + let registry = new_registry_with_disallowed(runtime, None); + + assert!( + !registry.is_tool_allowed("mcp_dangerous_read"), + "specific server wildcard denies its tools" + ); + assert!( + registry.is_tool_allowed("mcp_safe_query"), + "different server prefix is not denied" + ); +} + +#[test] +fn test_disallowed_tools_tools_for_model_excludes_denied() { + let tmp = tempdir().expect("tempdir"); + let mut runtime = stub_runtime_with_disallowed(vec![ + "exec_shell".to_string(), + "write_file".to_string(), + "apply_patch".to_string(), + ]); + runtime.context = ToolContext::new(tmp.path().to_path_buf()); + let registry = new_registry_with_disallowed(runtime, None); + + let tools = registry.tools_for_model(&SubAgentType::General); + let names: HashSet<_> = tools.into_iter().map(|t| t.name).collect(); + + assert!(!names.contains("exec_shell"), "catalog excludes exec_shell"); + assert!(!names.contains("write_file"), "catalog excludes write_file"); + assert!( + !names.contains("apply_patch"), + "catalog excludes apply_patch" + ); + assert!(names.contains("read_file"), "catalog includes read_file"); + assert!(names.contains("grep_files"), "catalog includes grep_files"); +} + +#[tokio::test] +async fn test_disallowed_tools_execute_rejects_denied_tool() { + let tmp = tempdir().expect("tempdir"); + let mut runtime = stub_runtime_with_disallowed(vec!["exec_shell".to_string()]); + runtime.context = ToolContext::new(tmp.path().to_path_buf()); + runtime.allow_shell = true; // remove posture as a confound + let registry = new_registry_with_disallowed(runtime, None); + + let result = registry + .execute("agent_test", "exec_shell", json!({"command": "echo hi"})) + .await; + assert!( + result.is_err(), + "execute must reject a tool denied by disallowed_tools" + ); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("not allowed") || err.contains("denied"), + "error should mention denial: {err}" + ); +} + +// === deny-list propagation through runtime cloning === + +#[test] +fn test_disallowed_tools_propagates_through_child_runtime() { + let runtime = stub_runtime_with_disallowed(vec!["exec_shell".to_string()]); + let child = runtime.child_runtime(); + assert_eq!( + child.worker_profile.denied_tools, + vec!["exec_shell".to_string()], + "child_runtime() must preserve parent's denied_tools" + ); +} + +#[test] +fn test_disallowed_tools_propagates_through_background_runtime() { + let runtime = stub_runtime_with_disallowed(vec!["write_file".to_string()]); + let bg = runtime.background_runtime(); + assert_eq!( + bg.worker_profile.denied_tools, + vec!["write_file".to_string()], + "background_runtime() must preserve parent's denied_tools" + ); +} + +#[test] +fn test_disallowed_tools_across_two_generations() { + let tmp = tempdir().expect("tempdir"); + let mut parent = stub_runtime_with_disallowed(vec!["exec_shell".to_string()]); + parent.context = ToolContext::new(tmp.path().to_path_buf()); + let parent_registry = new_registry_with_disallowed(parent.clone(), None); + assert!(!parent_registry.is_tool_allowed("exec_shell")); + + // Child A inherits from parent. + let child_a = parent.child_runtime(); + assert_eq!( + child_a.worker_profile.denied_tools, + vec!["exec_shell".to_string()] + ); + + // Child B inherits from child A — same deny list. + let mut child_b = child_a.child_runtime(); + child_b.context = ToolContext::new(tmp.path().to_path_buf()); + let b_registry = new_registry_with_disallowed(child_b, None); + assert!( + !b_registry.is_tool_allowed("exec_shell"), + "third-generation sub-agent still inherits deny list" + ); + assert!(b_registry.is_tool_allowed("read_file")); +} + +// === spawn-path opt-out simulation === + +#[test] +fn test_disallowed_tools_opt_out_clears_inherited_denies() { + // Simulate the spawn-path merge: parent runtime has denies, child sets + // inherit_disallowed_tools = false — the inherited denies are cleared. + let tmp = tempdir().expect("tempdir"); + let runtime = + stub_runtime_with_disallowed(vec!["exec_shell".to_string(), "write_file".to_string()]); + let mut child_runtime = runtime.child_runtime(); + child_runtime.context = ToolContext::new(tmp.path().to_path_buf()); + assert!( + !child_runtime.worker_profile.denied_tools.is_empty(), + "child starts with parent's denies" + ); + + // Simulate spawn merge: inherit_disallowed_tools = false, no caller deny. + child_runtime.worker_profile.denied_tools.clear(); + + let registry = new_registry_with_disallowed(child_runtime, None); + assert!( + registry.is_tool_allowed("exec_shell"), + "exec_shell allowed after opt-out cleared parent denies" + ); + assert!( + registry.is_tool_allowed("write_file"), + "write_file allowed after opt-out cleared parent denies" + ); + assert!(registry.is_tool_allowed("read_file")); +} + +#[test] +fn test_disallowed_tools_opt_out_keeps_explicit_caller_deny() { + // Opt-out clears inherited denies, but explicit caller disallowed_tools + // still apply (the union merge — caller deny always applies). + let tmp = tempdir().expect("tempdir"); + let runtime = + stub_runtime_with_disallowed(vec!["exec_shell".to_string(), "write_file".to_string()]); + let mut child_runtime = runtime.child_runtime(); + child_runtime.context = ToolContext::new(tmp.path().to_path_buf()); + + // Simulate spawn merge: inherit_disallowed_tools = false, then caller adds + // ["write_file"]. + child_runtime.worker_profile.denied_tools.clear(); + child_runtime + .worker_profile + .denied_tools + .push("write_file".to_string()); + + let registry = new_registry_with_disallowed(child_runtime, None); + // Parent denied exec_shell, but opt-out cleared it → allowed. + assert!( + registry.is_tool_allowed("exec_shell"), + "exec_shell allowed (parent deny cleared by opt-out)" + ); + // Caller explicitly denied write_file → still denied. + assert!( + !registry.is_tool_allowed("write_file"), + "write_file denied by caller's explicit list" + ); + assert!(registry.is_tool_allowed("read_file")); +} + +// === parse_spawn_request disallowed_tools === + +#[test] +fn test_parse_spawn_request_reads_disallowed_tools() { + let input = json!({ + "prompt": "do something", + "disallowed_tools": ["exec_shell", "write_file"] + }); + let req = parse_spawn_request(&input).expect("parse"); + assert_eq!( + req.disallowed_tools, + Some(vec!["exec_shell".to_string(), "write_file".to_string()]) + ); +} + +#[test] +fn test_parse_spawn_request_disallowed_tools_dedupes_and_trims() { + let input = json!({ + "prompt": "do something", + "disallowed_tools": [" exec_shell ", "exec_shell", "", " ", "write_file"] + }); + let req = parse_spawn_request(&input).expect("parse"); + assert_eq!( + req.disallowed_tools, + Some(vec!["exec_shell".to_string(), "write_file".to_string()]), + "blanks and duplicates are dropped" + ); +} + +#[test] +fn test_parse_spawn_request_disallowed_tools_defaults_to_none() { + let input = json!({"prompt": "do something"}); + let req = parse_spawn_request(&input).expect("parse"); + assert!( + req.disallowed_tools.is_none(), + "disallowed_tools should be None when not provided" + ); +} + +#[test] +fn test_parse_spawn_request_inherit_disallowed_tools_defaults_true() { + let input = json!({"prompt": "do something"}); + let req = parse_spawn_request(&input).expect("parse"); + assert!( + req.inherit_disallowed_tools, + "inherit_disallowed_tools should default to true" + ); +} + +#[test] +fn test_parse_spawn_request_inherit_disallowed_tools_explicit_false() { + let input = json!({ + "prompt": "do something", + "inherit_disallowed_tools": false + }); + let req = parse_spawn_request(&input).expect("parse"); + assert!( + !req.inherit_disallowed_tools, + "inherit_disallowed_tools should parse an explicit false" + ); +} diff --git a/crates/tui/src/worker_profile.rs b/crates/tui/src/worker_profile.rs index c765d9cde..1ba6fd57f 100644 --- a/crates/tui/src/worker_profile.rs +++ b/crates/tui/src/worker_profile.rs @@ -136,6 +136,18 @@ pub struct WorkerRuntimeProfile { /// Explicit reasoning/thinking tier; `None` inherits the parent/session tier. #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, + /// Tool deny-list inherited from the parent session's `--disallowed-tools` + /// (#4042). Deny always wins over allow, even over the explicit allowlist + /// and the role posture. Entries support wildcard matching: an exact name + /// (`exec_shell`) or a `prefix*` glob (`mcp_*`), compared case-insensitively. + /// + /// A child can only ever *add* entries — `derive_child()` takes the union of + /// the parent's and the child's deny lists, so a descendant can never drop a + /// restriction an ancestor imposed. The only way to start without the + /// parent's list is an explicit `inherit_disallowed_tools: false` at spawn, + /// which clears the cloned runtime's list before the registry reads it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub denied_tools: Vec, /// Remaining nested-delegation budget. A worker may spawn children while /// `max_spawn_depth > 0`; each level decrements it. Clamped to the workspace /// ceiling. @@ -174,6 +186,7 @@ impl WorkerRuntimeProfile { model: ModelRoute::Inherit, provider: None, reasoning_effort: None, + denied_tools: Vec::new(), max_spawn_depth: codewhale_config::DEFAULT_SPAWN_DEPTH, background: true, } @@ -186,7 +199,9 @@ impl WorkerRuntimeProfile { /// - permissions are AND-ed, /// - shell takes the more restrictive policy, /// - an explicit parent tool set bounds the child's tool set, - /// - the spawn-depth budget decrements by one level and clamps to the ceiling. + /// - the spawn-depth budget decrements by one level and clamps to the ceiling, + /// - the tool deny-list is the **union** of the two — a child may add + /// restrictions but never drop one an ancestor imposed (#4042). /// /// The child keeps its own requested role, model route, and /// foreground/background preference (these don't grant capability), but its @@ -195,6 +210,14 @@ impl WorkerRuntimeProfile { pub fn derive_child(&self, requested: &WorkerRuntimeProfile) -> WorkerRuntimeProfile { let permissions = self.permissions.intersect(requested.permissions); let shell = self.shell.min_with(requested.shell); + // Deny-lists union: a child can never drop a restriction an ancestor + // imposed. Wildcard entries are merged verbatim (no expansion). + let mut denied_tools = self.denied_tools.clone(); + for rule in &requested.denied_tools { + if !denied_tools.contains(rule) { + denied_tools.push(rule.clone()); + } + } let tools = match (&self.tools, &requested.tools) { // Parent restricts to a set → the child can only narrow within it. (ToolScope::Explicit(parent), ToolScope::Explicit(child)) => ToolScope::Explicit( @@ -227,6 +250,7 @@ impl WorkerRuntimeProfile { .reasoning_effort .clone() .or_else(|| self.reasoning_effort.clone()), + denied_tools, max_spawn_depth, background: requested.background, } @@ -383,4 +407,22 @@ mod tests { let overridden = parent.derive_child(&requested); assert_eq!(overridden.reasoning_effort.as_deref(), Some("max")); } + + #[test] + fn child_denied_tools_union_never_drops_parent_restriction() { + // A child may only *add* deny entries; it can never drop a restriction + // an ancestor imposed (#4042 non-escalation invariant). + let mut parent = WorkerRuntimeProfile::for_role(SubAgentType::General); + parent.denied_tools = vec!["exec_shell".into(), "mcp_*".into()]; + + // Child asks for its own deny list and (tryingly) tries to omit the + // parent's exec_shell — the union keeps both. + let mut requested = WorkerRuntimeProfile::for_role(SubAgentType::Implementer); + requested.denied_tools = vec!["write_file".into()]; + + let child = parent.derive_child(&requested); + assert!(child.denied_tools.contains(&"exec_shell".to_string())); + assert!(child.denied_tools.contains(&"mcp_*".to_string())); + assert!(child.denied_tools.contains(&"write_file".to_string())); + } } diff --git a/docs/TERMUX.md b/docs/TERMUX.md new file mode 100644 index 000000000..8517982ee --- /dev/null +++ b/docs/TERMUX.md @@ -0,0 +1,88 @@ +# Termux / Android arm64 Support + +CodeWhale runs natively on Android arm64 via [Termux](https://termux.dev). +This document covers the install path and the platform-specific behavior +differences you should know about. + +## Installation + +See [`INSTALL.md`](./INSTALL.md) → "Android / Termux arm64" for the current +install steps. The short version: + +```sh +# Inside Termux (pkg install rust git ...) +cargo install codewhale-cli --locked +cargo install codewhale-tui --locked +``` + +Or, when a release includes `codewhale-android-arm64.tar.gz`, extract it +into `$PREFIX/bin`. + +> **Do not** install the GNU libc `codewhale-linux-arm64` archive in Termux. +> Android uses Bionic libc, not glibc — the Linux binary will not run. + +## Platform behavior on Android + +CodeWhale's security model has two independent layers: + +1. **OS filesystem sandbox** — Seatbelt (macOS), Landlock (Linux), or + nothing. This layer restricts what *shell commands* can access at the + kernel level. +2. **CodeWhale's own gates** — workspace trust, approval prompts, + `allow_shell`/`disallowed-tools`, and the file-tool permission system. + These are application-level and work identically on every platform. + +### Sandbox: unavailable (type = none) + +Android does not expose Landlock, Seatbelt, or any equivalent mandatory +access control API that CodeWhale can use. On Android, +`codewhale doctor` reports **sandbox type: none**. + +- `get_platform_sandbox()` returns `None` on Android. +- No Linux-only sandbox modules (Landlock, bwrap) are compiled into the + Android build — they are `#[cfg(target_os = "linux")]`-gated and Rust + treats `android` as a distinct target from `linux`. +- Shell commands run without OS-level filesystem containment. Rely on + CodeWhale's approval gates and workspace trust for safety. + +### Approvals: still apply + +CodeWhale's approval system (interactive prompts for risky actions, +`allow_shell`, `--disallowed-tools`) is entirely application-level. It works +identically on Android — the absence of an OS sandbox does not weaken it. + +### Secret storage: file-backed + +Android has no OS keyring (no Secret Service / dbus). CodeWhale falls back +to **file-backed secret storage**: encrypted-at-rest JSON files under +`~/.codewhale/secrets/` (Termux home directory), with `0600` permissions. + +- API keys set via `codewhale setup` or `/provider` are stored in these + files, never in plaintext beyond the file the user chose. +- `codewhale doctor` reports which secret backend is active. + +### Self-update + +`codewhale update` on Android requests `codewhale-android-arm64` and +`codewhale-tui-android-arm64` release assets — never the Linux arm64 +assets. The GNU libc (glibc) compatibility preflight is Linux-only and is +skipped entirely on Android (Bionic libc). + +## Known limitations (first Termux release) + +| Feature | Status | Notes | +|---------|--------|-------| +| OS sandbox | ❌ unavailable | No Landlock/bwrap/Seatbelt on Android | +| OS keyring | ❌ unavailable | Falls back to file-backed secrets | +| Approvals / gates | ✅ full | Application-level, platform-independent | +| File tools | ✅ full | Governed by workspace trust | +| Self-update | ✅ full | Selects Android assets | +| Shell execution | ⚠️ no containment | Runs without OS sandbox; rely on approvals | + +## Related issues + +- #4236 — Epic: official Termux / Android arm64 support +- #4238 — Make Android sandbox and secret-store behavior explicit +- #4240 — Build and bundle Android arm64 release assets +- #4241 — Teach updater to select Android assets on Termux +- #4242 — Run Termux runtime QA