| 1 | //! Semantic aliases for the model-facing action tools. |
| 2 | //! |
| 3 | //! `Bash`, `File`, `Git`, `Run`, `Web`, and `rlm` deliberately keep their |
| 4 | //! canonical names at the execution and audit boundaries. Presentation and |
| 5 | //! policy consumers, however, still understand the older per-action names. |
| 6 | //! Resolve that semantic name in one place so live calls and saved legacy |
| 7 | //! transcripts receive identical downstream behavior without rewriting the |
| 8 | //! original call. |
| 9 | //! |
| 10 | //! This table is not documentation — it is the **action-policy seam**. A |
| 11 | //! permission check that denies `fetch_url` only reaches `Web{action:"fetch"}` |
| 12 | //! because the pair is listed here. A family that is missing from the table is |
| 13 | //! a family whose actions no deny list can see, which is why `rlm` was added: |
| 14 | //! `rlm{action:"open", url:...}` calls `FetchUrlTool` *inside the process*, |
| 15 | //! under its own name, and a name-keyed deny list never sees that call. |
| 16 | |
| 17 | use serde_json::Value; |
| 18 | |
| 19 | pub(crate) const CANONICAL_ACTION_ALIASES: &[(&str, &str, &str)] = &[ |
| 20 | ("Bash", "run", "exec_shell"), |
| 21 | ("Bash", "wait", "exec_shell_wait"), |
| 22 | ("Bash", "interact", "exec_shell_interact"), |
| 23 | ("Bash", "cancel", "exec_shell_cancel"), |
| 24 | ("File", "read", "read_file"), |
| 25 | ("File", "list", "list_dir"), |
| 26 | ("File", "search_name", "file_search"), |
| 27 | ("File", "search_content", "grep_files"), |
| 28 | ("File", "write", "write_file"), |
| 29 | ("File", "edit", "edit_file"), |
| 30 | ("File", "patch", "apply_patch"), |
| 31 | ("Git", "status", "git_status"), |
| 32 | ("Git", "diff", "git_diff"), |
| 33 | ("Git", "log", "git_log"), |
| 34 | ("Git", "show", "git_show"), |
| 35 | ("Git", "blame", "git_blame"), |
| 36 | ("Run", "tests", "run_tests"), |
| 37 | ("Run", "verifiers", "run_verifiers"), |
| 38 | ("Web", "search", "web_search"), |
| 39 | ("Web", "fetch", "fetch_url"), |
| 40 | ("Web", "wait", "wait_for_dev_server"), |
| 41 | // The RLM session family. `open` reaches the network (it fetches a `url` |
| 42 | // through `FetchUrlTool` in-process) and `eval` runs operator-supplied |
| 43 | // Python against a live kernel — sockets and filesystem both. The other |
| 44 | // three actions are bounded local metadata. Listing every pair is what lets |
| 45 | // a deny list keep the local half and remove the reaching half, instead of |
| 46 | // having to choose between the whole family and nothing. |
| 47 | ("rlm", "session_objects", "rlm_session_objects"), |
| 48 | ("rlm", "open", "rlm_open"), |
| 49 | ("rlm", "eval", "rlm_eval"), |
| 50 | ("rlm", "configure", "rlm_configure"), |
| 51 | ("rlm", "close", "rlm_close"), |
| 52 | // The durable-work families. These were absent for the same reason `rlm` |
| 53 | // was: they are model-visible under one canonical name (`tasks`, |
| 54 | // `automation`, `github`) and their per-action legacy names are registered |
| 55 | // as *hidden* aliases. A deny list naming `task_gate_run` therefore never |
| 56 | // saw `tasks{action:"gate_run"}`, and the action-enum pruner never saw the |
| 57 | // family at all — so an operator ceiling could not express "durable task |
| 58 | // bookkeeping, yes; running a gate command, no". |
| 59 | // |
| 60 | // `gate_run` runs an operator-supplied command, `automation.run` executes a |
| 61 | // stored automation, and the mutating `automation.*` actions schedule agent |
| 62 | // runs with their own cwd. All three are execution primitives spelled as |
| 63 | // bookkeeping, which is exactly the shape |
| 64 | // [`crate::tools::execution_envelope`] classifies from capabilities. |
| 65 | ("tasks", "create", "task_create"), |
| 66 | ("tasks", "list", "task_list"), |
| 67 | ("tasks", "read", "task_read"), |
| 68 | ("tasks", "cancel", "task_cancel"), |
| 69 | ("tasks", "gate_run", "task_gate_run"), |
| 70 | ("tasks", "pr_attempt_record", "pr_attempt_record"), |
| 71 | ("tasks", "pr_attempt_list", "pr_attempt_list"), |
| 72 | ("tasks", "pr_attempt_read", "pr_attempt_read"), |
| 73 | ("tasks", "pr_attempt_preflight", "pr_attempt_preflight"), |
| 74 | ("automation", "create", "automation_create"), |
| 75 | ("automation", "list", "automation_list"), |
| 76 | ("automation", "read", "automation_read"), |
| 77 | ("automation", "update", "automation_update"), |
| 78 | ("automation", "pause", "automation_pause"), |
| 79 | ("automation", "resume", "automation_resume"), |
| 80 | ("automation", "delete", "automation_delete"), |
| 81 | ("automation", "run", "automation_run"), |
| 82 | ("github", "issue_context", "github_issue_context"), |
| 83 | ("github", "pr_context", "github_pr_context"), |
| 84 | ("github", "comment", "github_comment"), |
| 85 | ("github", "close_issue", "github_close_issue"), |
| 86 | ("github", "close_pr", "github_close_pr"), |
| 87 | ]; |
| 88 | |
| 89 | /// The conservative action label policy uses when the model omits `action`. |
| 90 | /// |
| 91 | /// This is a *policy* fallback only. Execution rejects an actionless call in |
| 92 | /// every family (see [`required_action`]); approval and parallel-safety |
| 93 | /// predicates cannot return an error, so they still need a label, and it must |
| 94 | /// be the family's least dangerous action. |
| 95 | /// |
| 96 | /// `None` means the family never had even a policy default — `rlm`'s contract: |
| 97 | /// [`crate::tools::rlm::RlmTool::resolve_action`] errors rather than guessing. |
| 98 | /// Policy still resolves an *explicit* action for such a family — see |
| 99 | /// [`canonical_action_alias`]. |
| 100 | #[must_use] |
| 101 | pub(crate) fn action_family_default(tool_name: &str) -> Option<Option<&'static str>> { |
| 102 | match tool_name { |
| 103 | "Bash" => Some(Some("run")), |
| 104 | "File" => Some(Some("read")), |
| 105 | "Git" => Some(Some("status")), |
| 106 | "Run" => Some(Some("tests")), |
| 107 | "Web" => Some(Some("search")), |
| 108 | // Families whose wrappers reject an actionless call rather than |
| 109 | // guessing. Policy still resolves an *explicit* action for them. |
| 110 | "rlm" | "tasks" | "automation" | "github" => Some(None), |
| 111 | _ => None, |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | /// Whether `tool_name` is a model-facing action family whose `action` enum |
| 116 | /// policy may prune. |
| 117 | /// |
| 118 | /// Derived from [`action_family_default`] rather than spelled out at each call |
| 119 | /// site: a hard-coded family list that falls behind the alias table is a family |
| 120 | /// whose actions stay visible after policy removed them. |
| 121 | #[must_use] |
| 122 | pub(crate) fn is_action_family(tool_name: &str) -> bool { |
| 123 | action_family_default(tool_name).is_some() |
| 124 | } |
| 125 | |
| 126 | /// Require the `action` discriminator on a canonical action-family call. |
| 127 | /// |
| 128 | /// Every family schema marks `action` required, but the wrappers used to |
| 129 | /// default a missing one (`File` → read, `Git` → status, `Web` → search, |
| 130 | /// `Run` → tests). A call that merely omitted or misspelled the discriminator |
| 131 | /// therefore ran a *different* operation and returned that operation's success |
| 132 | /// receipt: `File{path, content}` answered an intended write with the file's |
| 133 | /// current contents, so the write silently never happened. Same shape as |
| 134 | /// #5209 — refuse, and name the values that actually dispatch. |
| 135 | /// |
| 136 | /// `actions` must be the set this tool instance can really run, so a mode that |
| 137 | /// hides `write` never suggests it. |
| 138 | pub(crate) fn required_action( |
| 139 | input: &Value, |
| 140 | tool: &str, |
| 141 | actions: &[&str], |
| 142 | ) -> Result<String, crate::tools::spec::ToolError> { |
| 143 | use crate::tools::spec::ToolError; |
| 144 | match input.get("action") { |
| 145 | Some(Value::String(action)) => Ok(action.clone()), |
| 146 | Some(other) => Err(ToolError::invalid_input(format!( |
| 147 | "{tool} requires `action` to be a string, got {other}; nothing was run. Pass one of: {}.", |
| 148 | actions.join(", ") |
| 149 | ))), |
| 150 | None => Err(ToolError::invalid_input(format!( |
| 151 | "{tool} requires an `action` parameter; nothing was run. Pass one of: {}.", |
| 152 | actions.join(", ") |
| 153 | ))), |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | /// Resolve a canonical action tool to the legacy name for that exact action. |
| 158 | /// |
| 159 | /// A missing action falls back to the family's conservative default so the |
| 160 | /// *policy* label is never absent; execution itself refuses the call (see |
| 161 | /// `required_action`). Unknown actions stay canonical so policy remains |
| 162 | /// conservative and the eventual tool error is attributed to the call the |
| 163 | /// model actually made. |
| 164 | /// |
| 165 | /// A family with no default (`rlm`) still resolves an **explicit** action. The |
| 166 | /// earlier shape returned the family name for any such call, which meant |
| 167 | /// `rlm{action:"eval"}` never resolved to `rlm_eval` and therefore never met a |
| 168 | /// deny list entry naming it. |
| 169 | #[must_use] |
| 170 | pub(crate) fn canonical_action_alias<'a>(tool_name: &'a str, input: &Value) -> &'a str { |
| 171 | let Some(default_action) = action_family_default(tool_name) else { |
| 172 | return tool_name; |
| 173 | }; |
| 174 | let Some(action) = input |
| 175 | .get("action") |
| 176 | .and_then(Value::as_str) |
| 177 | .or(default_action) |
| 178 | else { |
| 179 | return tool_name; |
| 180 | }; |
| 181 | |
| 182 | CANONICAL_ACTION_ALIASES |
| 183 | .iter() |
| 184 | .find_map(|(family, candidate_action, alias)| { |
| 185 | (*family == tool_name && *candidate_action == action).then_some(*alias) |
| 186 | }) |
| 187 | .unwrap_or(tool_name) |
| 188 | } |
| 189 | |
| 190 | #[cfg(test)] |
| 191 | mod tests { |
| 192 | use super::*; |
| 193 | use serde_json::json; |
| 194 | |
| 195 | /// Names the v0.9.3 consolidation retired. None of them can dispatch — |
| 196 | /// `ToolRegistry::resolve` has no fuzzy step — so any one of them inside a |
| 197 | /// model-visible description or schema teaches a call that cannot work. |
| 198 | const RETIRED_TOOL_NAMES: &[&str] = &[ |
| 199 | "read_file", |
| 200 | "write_file", |
| 201 | "edit_file", |
| 202 | "list_dir", |
| 203 | "file_search", |
| 204 | "grep_files", |
| 205 | "git_status", |
| 206 | "git_diff", |
| 207 | "git_log", |
| 208 | "git_show", |
| 209 | "git_blame", |
| 210 | "run_tests", |
| 211 | "run_verifiers", |
| 212 | "web_search", |
| 213 | "fetch_url", |
| 214 | "wait_for_dev_server", |
| 215 | "exec_shell", |
| 216 | "exec_shell_wait", |
| 217 | "exec_shell_interact", |
| 218 | "exec_shell_cancel", |
| 219 | ]; |
| 220 | |
| 221 | /// The catalog is re-sent on every request, so a retired name in it is a |
| 222 | /// per-turn lie to every model. `verifier.rs` already guarded one such |
| 223 | /// description by hand; this covers the whole advertised surface at once. |
| 224 | #[test] |
| 225 | fn no_advertised_tool_teaches_a_retired_name() { |
| 226 | use crate::tools::registry::ToolRegistryBuilder; |
| 227 | use crate::tools::spec::ToolContext; |
| 228 | |
| 229 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 230 | let registry = ToolRegistryBuilder::new() |
| 231 | .with_file_tools() |
| 232 | .with_search_tools() |
| 233 | .with_git_tools() |
| 234 | .with_git_history_tools() |
| 235 | .with_test_runner_tool() |
| 236 | .with_web_tools() |
| 237 | .with_patch_tools() |
| 238 | .build(ToolContext::new(tmp.path().to_path_buf())); |
| 239 | |
| 240 | for tool in registry.to_api_tools() { |
| 241 | let advertised = format!("{} {}", tool.description, tool.input_schema); |
| 242 | for retired in RETIRED_TOOL_NAMES { |
| 243 | assert!( |
| 244 | !advertised.contains(retired), |
| 245 | "tool `{}` advertises the retired name `{retired}`; \ |
| 246 | name the canonical action form instead", |
| 247 | tool.name |
| 248 | ); |
| 249 | } |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | #[test] |
| 254 | fn every_canonical_action_resolves_to_its_legacy_semantic_alias() { |
| 255 | for (family, action, alias) in CANONICAL_ACTION_ALIASES { |
| 256 | assert_eq!( |
| 257 | canonical_action_alias(family, &json!({"action": action})), |
| 258 | *alias, |
| 259 | "{family}.{action}" |
| 260 | ); |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | /// Execution refuses an actionless call; policy still needs a label for |
| 265 | /// it, and that label must stay the family's most conservative action. |
| 266 | #[test] |
| 267 | fn actionless_calls_keep_a_conservative_policy_label() { |
| 268 | for (family, alias) in [ |
| 269 | ("Bash", "exec_shell"), |
| 270 | ("File", "read_file"), |
| 271 | ("Git", "git_status"), |
| 272 | ("Run", "run_tests"), |
| 273 | ("Web", "web_search"), |
| 274 | ] { |
| 275 | assert_eq!( |
| 276 | canonical_action_alias(family, &json!({})), |
| 277 | alias, |
| 278 | "{family}" |
| 279 | ); |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | #[test] |
| 284 | fn legacy_unknown_and_invalid_calls_keep_their_original_names() { |
| 285 | for name in ["exec_shell", "read_file", "future_tool"] { |
| 286 | assert_eq!(canonical_action_alias(name, &json!({})), name); |
| 287 | } |
| 288 | assert_eq!( |
| 289 | canonical_action_alias("File", &json!({"action": "delete"})), |
| 290 | "File" |
| 291 | ); |
| 292 | assert_eq!( |
| 293 | canonical_action_alias("Bash", &json!({"action": 42})), |
| 294 | "exec_shell" |
| 295 | ); |
| 296 | } |
| 297 | |
| 298 | /// A family with no execution default still resolves an explicit action. |
| 299 | /// Without this, `rlm{action:"eval"}` resolves to `rlm` and slips past every |
| 300 | /// deny list entry that names `rlm_eval`. |
| 301 | #[test] |
| 302 | fn a_family_without_a_default_still_resolves_an_explicit_action() { |
| 303 | assert_eq!( |
| 304 | canonical_action_alias("rlm", &json!({"action": "eval"})), |
| 305 | "rlm_eval" |
| 306 | ); |
| 307 | assert_eq!( |
| 308 | canonical_action_alias("rlm", &json!({"action": "open", "url": "https://x.test/a"})), |
| 309 | "rlm_open" |
| 310 | ); |
| 311 | // No action, no default: nothing to resolve, and `RlmTool` will reject |
| 312 | // the call on its own terms. |
| 313 | assert_eq!(canonical_action_alias("rlm", &json!({})), "rlm"); |
| 314 | assert_eq!( |
| 315 | canonical_action_alias("rlm", &json!({"action": "nope"})), |
| 316 | "rlm" |
| 317 | ); |
| 318 | } |
| 319 | |
| 320 | /// Every family named in the alias table must be recognised as a family, or |
| 321 | /// its actions are unprunable by the visibility filter. |
| 322 | #[test] |
| 323 | fn every_aliased_family_is_a_known_action_family() { |
| 324 | for (family, _, _) in CANONICAL_ACTION_ALIASES { |
| 325 | assert!( |
| 326 | is_action_family(family), |
| 327 | "{family} is aliased but not registered as an action family" |
| 328 | ); |
| 329 | } |
| 330 | assert!(!is_action_family("read_file")); |
| 331 | assert!(!is_action_family("future_tool")); |
| 332 | } |
| 333 | } |
| 334 |