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