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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/AUTHOR_MAP
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
39 changes: 39 additions & 0 deletions crates/cli/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion crates/config/src/route/ids.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
6 changes: 5 additions & 1 deletion crates/tui/src/core/engine/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -3182,7 +3186,7 @@ fn resolve_auto_effort(
provider: crate::config::ApiProvider,
) -> Option<String> {
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()
Expand Down
10 changes: 10 additions & 0 deletions crates/tui/src/fleet/worker_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
97 changes: 97 additions & 0 deletions crates/tui/src/tools/subagent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1175,6 +1175,13 @@ struct SpawnRequest {
/// When unset, the child inherits the parent's budget pool or the
/// configured root default.
token_budget: Option<u64>,
/// 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<Vec<String>>,
/// 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)]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -6303,6 +6331,15 @@ fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
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(),
Expand All @@ -6321,6 +6358,8 @@ fn parse_spawn_request(input: &Value) -> Result<SpawnRequest, ToolError> {
fork_context,
max_depth,
token_budget,
disallowed_tools,
inherit_disallowed_tools,
})
}

Expand Down Expand Up @@ -6513,6 +6552,31 @@ fn parse_optional_bool(input: &Value, names: &[&str]) -> Option<bool> {
.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<Option<Vec<String>>, 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<Option<u64>, ToolError> {
for name in names {
let Some(value) = input.get(*name) else {
Expand Down Expand Up @@ -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<Vec<String>>,
/// 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<String>,
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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.
Expand Down
Loading
Loading