bash_ast/wrappers.rs
1//! Shared wrapper-command set + peel logic (R155-T9).
2//!
3//! "Wrappers" are commands like `timeout`, `env`, `nohup` that take a command
4//! as their final positional argument. The approval gate and the FE both need
5//! to look past them when reasoning about a user's intent — granting
6//! `BashCmdPattern { cmd: "git", … }` should match `timeout 30 git push`.
7//!
8//! This module owns the canonical wrapper set and the peel routine in Rust.
9//! The TS-side renderer (`packages/yah/ui/src/components/forms/peelWrappers.ts`)
10//! keeps a parallel list — keep them in sync.
11//!
12//! @yah:ticket(R155-T9, "bash_ast::wrappers shared static set; agent-tools + FE consume the same list to keep peel semantics in sync")
13//! @yah:at(2026-05-14T17:22:09Z)
14//! @yah:assignee(agent:claude)
15//! @yah:status(review)
16//! @yah:phase(P2)
17//! @yah:parent(R155)
18//! @yah:handoff("Promoted the wrapper-name list to a JSON sidecar at crates/yah/bash-ast/data/wrappers.json — single source of truth shared with the FE. Rust keeps the WRAPPERS &[&str] const for ergonomic use (and runtime no-alloc); exposed WRAPPERS_JSON via include_str! for downstream re-export. New wrappers_const_matches_json_sidecar test (cargo test -p bash-ast 37/37 green) parses the JSON and asserts equality with the const, so drift fails CI. FE peelWrappers.ts now builds WRAPPER_COMMANDS from the same JSON via a relative import (../../../../../../crates/yah/bash-ast/data/wrappers.json) — Bun's tsc + builder both resolve it across the workspace boundary (resolveJsonModule:true). agent-tools already consumes bash_ast::wrappers::WRAPPERS and peel_command through R155-F6, so no change there. typecheck clean, bun test peelWrappers 12/12 green, bun run build:js succeeds, cargo check --workspace clean.")
19//! @yah:verify("Edit crates/yah/bash-ast/data/wrappers.json (e.g. add `\"perfwrap\"`); cargo test -p bash-ast wrappers_const_matches_json_sidecar — fails until WRAPPERS in src/wrappers.rs is updated to match. Revert the JSON edit. FE: peelWrappers test still sees all 8 wrappers; sudo still rejected.")
20//! @arch:see(.yah/docs/working/W113-yah-bash-snapshot-and-permissions.md)
21//!
22//! @yah:ticket(R295-F1, "ToolInvocation extraction + ToolRegistry skeleton (bash-ast + agent-tools)")
23//! @yah:assignee(agent:claude)
24//! @yah:at(2026-05-23T00:01:16Z)
25//! @yah:status(review)
26//! @yah:parent(R295)
27//! @arch:see(.yah/docs/working/W086-git-subcommand-gate.md)
28//! @yah:next("Generic helper exposing `tool_invocation_of(&PeeledCommand, &ToolRegistry) -> Option<ToolInvocation>` per design doc section 1. Lives in crates/yah/bash-ast/src/wrappers.rs (or sibling tool_invocation.rs if it grows). Walks the peeled command's argv: matches primary basename against registry, peels each tool's global flags per its ToolSchema, consumes subcommand chain up to ToolSchema.max_depth.")
29//! @yah:next("Define the ToolSchema struct + ToolRegistry container in agent-tools (the consumer crate). ToolSchema fields: tool name, global_flags (bare / attached-value / `=`-value / eats-N), max_depth, policy table keyed on SubcommandPath, default_tier, leaf_flag_escalation rules. ApprovalTier enum has AutoAllow / StandardPrompt / TypedPrompt / HardDeny.")
30//! @yah:next("Empty default registry at this stage — schemas land in F3. Tests use a mock schema for an imaginary tool to exercise the generic extractor end-to-end without committing to any policy table yet.")
31//! @yah:next("Return ToolInvocation { tool, subcommand: Vec<&str>, rest, global_flags, leaf_flags }. The leaf_flags field lets F2 implement flag-based escalation (e.g. `git reset --hard` vs `git reset`).")
32//! @yah:verify("cargo test -p bash-ast tool_invocation::tests # generic extraction (mock schema) covers: bare, dash-flag-with-attached-value, dash-dash-flag-equals, eats-N variant, absolute path, deepest chain the mock schema declares, unknown primary returns None, variable primary returns None.")
33//! @yah:verify("cargo test -p agent-tools --lib tool_schema::tests # ToolSchema + ToolRegistry serde round-trip; loading TOML + matching default_tier on unknown subcommand.")
34//! @yah:handoff("Shipped. (1) bash-ast/src/tool_invocation.rs (new): GlobalFlagSpec {Bare,TakesValue}, ToolSchemaInfo, ToolLookup trait, ToolInvocation<'a>, tool_invocation_of<'a>. Private helpers: tool_basename (strips path prefix), find_global_flag (exact + flag= prefix matching). 11 tests in bash-ast::tool_invocation::tests cover all verify items: bare invocation, dash-flag-with-attached-value, dash-dash-flag-equals, short flag TakesValue, bare global flag, deepest chain (max_depth=2), leaf flags after subcommand, absolute path, unknown primary returns None, variable primary returns None (via peel_command None), global flag stops at unknown flag. (2) agent-tools/src/tool_schema.rs (new): ApprovalTier {AutoAllow,StandardPrompt,TypedPrompt,HardDeny} with serde + max_with helper; LeafFlagRule; PolicyEntry; ToolSchema {tier_for (longest-prefix), apply_leaf_flags}; ToolRegistry (empty default, register/get/len/is_empty) + impl ToolLookup (case-insensitive lookup). 11 tests: json_round_trip, toml_round_trip, default_tier, known_subcommand, longest_prefix_matching, leaf_flag_escalation, tier_max_with, registry_lookup_case_insensitive, empty_registry_returns_none, register_and_get. (3) bash-ast/src/lib.rs: pub mod tool_invocation added. (4) agent-tools/src/lib.rs: pub mod tool_schema added. cargo test -p bash-ast: 68/68. cargo test -p agent-tools --lib tool_schema: 11/11. cargo check --workspace clean (pre-existing warnings only).")
35//! @yah:verify("cargo test -p bash-ast tool_invocation (11/11)")
36//! @yah:verify("cargo test -p agent-tools --lib tool_schema (11/11)")
37//! @yah:verify("cargo check -p bash-ast && cargo check -p agent-tools")
38
39use crate::ast::{Command, CommandArgument, CommandNameInner, PrimaryExpression, Program, Statement, StringPart};
40
41/// Per-wrapper peel rules. `fixed_positionals` is the count of positional
42/// arguments the wrapper eats *after* its own name and any flag args (e.g.
43/// `timeout` eats one positional duration before the wrapped command).
44/// `eats_kv` is the `env`-style behaviour of consuming `KEY=VAL` leading args.
45#[derive(Debug, Clone, Copy)]
46struct WrapperSpec {
47 name: &'static str,
48 fixed_positionals: usize,
49 eats_kv: bool,
50}
51
52const WRAPPER_SPECS: &[WrapperSpec] = &[
53 WrapperSpec { name: "timeout", fixed_positionals: 1, eats_kv: false },
54 WrapperSpec { name: "time", fixed_positionals: 0, eats_kv: false },
55 WrapperSpec { name: "env", fixed_positionals: 0, eats_kv: true },
56 WrapperSpec { name: "nohup", fixed_positionals: 0, eats_kv: false },
57 WrapperSpec { name: "xargs", fixed_positionals: 0, eats_kv: false },
58 WrapperSpec { name: "nice", fixed_positionals: 0, eats_kv: false },
59 WrapperSpec { name: "ionice", fixed_positionals: 0, eats_kv: false },
60 WrapperSpec { name: "stdbuf", fixed_positionals: 0, eats_kv: false },
61];
62
63/// Canonical wrapper command names.
64///
65/// The authoritative list lives in [`data/wrappers.json`] at the crate root;
66/// the FE reads the same file (`peelWrappers.ts`) to keep peel semantics in
67/// sync (R155-T9). The Rust const below is a hand-mirrored copy — the
68/// `wrappers_const_matches_json_sidecar` test asserts the two never drift.
69///
70/// `sudo` is intentionally excluded: it changes privilege and the icon
71/// should reflect that. It is its own destructive primary.
72pub const WRAPPERS: &[&str] = &[
73 "timeout", "time", "env", "nohup",
74 "xargs", "nice", "ionice", "stdbuf",
75];
76
77/// Raw bytes of the canonical wrapper-name JSON sidecar (single source of
78/// truth shared with the FE). Exposed so consumers in other crates can
79/// re-publish it without re-reading the file at runtime.
80pub const WRAPPERS_JSON: &str = include_str!("../data/wrappers.json");
81
82/// Result of peeling wrapper commands off a single simple bash command.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct PeeledCommand {
85 /// First non-wrapper command name reached after stripping leading
86 /// wrappers. Equal to the original command name when no peel applied.
87 pub primary: String,
88 /// Positional arguments to `primary`, in invocation order (text only).
89 /// Empty when the wrapped command was bare (`nohup git`).
90 pub args: Vec<String>,
91 /// Wrapper names that were stripped, in invocation order. Empty when
92 /// the command was not wrapped.
93 pub wrappers: Vec<String>,
94}
95
96/// Peel wrappers from a single simple-command program.
97///
98/// Returns `None` if `program` is anything other than a single
99/// `Statement::Command` — pipelines, lists (`&&` / `||` / `;`), redirected
100/// statements, subshells, control flow all route through the
101/// `BashCompound` rule (R155-F7) instead.
102///
103/// Returns `None` if the command has redirects (`> file`) or leading
104/// variable assignments (`FOO=bar cmd …`) — those are also compound shapes
105/// that don't match a flat `BashCmdPattern`.
106pub fn peel_command(program: &Program) -> Option<PeeledCommand> {
107 if program.statements.len() != 1 {
108 return None;
109 }
110 let cmd = match &program.statements[0] {
111 Statement::Command(cmd) => cmd,
112 _ => return None,
113 };
114 peel_simple_command(cmd)
115}
116
117/// Peel wrappers from a single `Command` node (one pipeline stage / list operand).
118///
119/// Returns `None` if the command carries shape that can't match a flat
120/// `BashCmdPattern` — redirects, leading variable assignments, embedded
121/// subshells, or arguments that aren't plain text.
122pub fn peel_simple_command(cmd: &Command) -> Option<PeeledCommand> {
123 if !cmd.redirects.is_empty() || !cmd.leading_assignments.is_empty() || !cmd.subshells.is_empty() {
124 return None;
125 }
126 peel_simple_command_loose(cmd)
127}
128
129/// Looser cousin of [`peel_simple_command`] that ignores per-command
130/// redirects, leading variable assignments, and embedded subshells. Used
131/// by [`crate`]'s downstream consumers (`agent-tools::approval`) when the
132/// containing rule has already opted into those shapes via flags like
133/// `allow_redirects` — at that layer we still want to extract the primary
134/// command name so each pipeline stage can be matched.
135pub fn peel_simple_command_loose(cmd: &Command) -> Option<PeeledCommand> {
136 let name = cmd_name_text(cmd)?;
137 let args: Vec<String> = cmd.arguments.iter().map(arg_text).collect::<Option<Vec<_>>>()?;
138 Some(peel_recursive(name, args))
139}
140
141fn peel_recursive(name: String, args: Vec<String>) -> PeeledCommand {
142 let mut wrappers: Vec<String> = Vec::new();
143 let mut cur_name = name;
144 let mut cur_args = args;
145
146 loop {
147 let Some(spec) = WRAPPER_SPECS.iter().find(|w| w.name == cur_name) else {
148 break;
149 };
150 // Walk cur_args, skipping leading flags + (optionally) KEY=VAL,
151 // then `spec.fixed_positionals` non-flag positional args, to find
152 // the wrapped command.
153 let mut i = 0;
154 let mut positionals_eaten = 0usize;
155 let wrapped_idx = loop {
156 if i >= cur_args.len() {
157 break None;
158 }
159 let arg = &cur_args[i];
160 if is_flag(arg) {
161 i += 1;
162 continue;
163 }
164 if spec.eats_kv && is_kv_assignment(arg) {
165 i += 1;
166 continue;
167 }
168 if positionals_eaten < spec.fixed_positionals {
169 positionals_eaten += 1;
170 i += 1;
171 continue;
172 }
173 break Some(i);
174 };
175 let Some(idx) = wrapped_idx else {
176 // No wrapped command found — wrapper invoked alone (e.g. `env`
177 // to print env vars). Keep cur_name as-is, don't peel further.
178 break;
179 };
180 wrappers.push(cur_name);
181 cur_name = cur_args[idx].clone();
182 cur_args = cur_args[idx + 1..].to_vec();
183 }
184
185 PeeledCommand {
186 primary: cur_name,
187 args: cur_args,
188 wrappers,
189 }
190}
191
192fn is_flag(arg: &str) -> bool {
193 arg.starts_with('-') && arg.len() > 1
194}
195
196fn is_kv_assignment(arg: &str) -> bool {
197 // KEY=VAL where KEY is shell-identifier-ish.
198 let Some(eq) = arg.find('=') else { return false };
199 if eq == 0 {
200 return false;
201 }
202 let key = &arg[..eq];
203 let first = key.as_bytes()[0];
204 if !(first.is_ascii_alphabetic() || first == b'_') {
205 return false;
206 }
207 key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
208}
209
210// --- text helpers (duplicated trivially from summary.rs to avoid a circular
211// pub-export; both are <10 lines and stable) ---
212
213fn cmd_name_text(cmd: &Command) -> Option<String> {
214 match &cmd.name.inner {
215 CommandNameInner::Primary(p) => primary_text(p),
216 CommandNameInner::Concatenation(c) => {
217 let s: String = c.parts.iter().filter_map(primary_text).collect();
218 if s.is_empty() { None } else { Some(s) }
219 }
220 }
221}
222
223fn arg_text(arg: &CommandArgument) -> Option<String> {
224 match arg {
225 CommandArgument::Primary(p) => primary_text(p),
226 CommandArgument::Concatenation(c) => {
227 let s: String = c.parts.iter().filter_map(primary_text).collect();
228 if s.is_empty() { None } else { Some(s) }
229 }
230 CommandArgument::Regex(r) => Some(r.text.clone()),
231 CommandArgument::Operator { text, .. } => Some(text.clone()),
232 }
233}
234
235fn primary_text(p: &PrimaryExpression) -> Option<String> {
236 match p {
237 PrimaryExpression::Word(w) => Some(w.text.clone()),
238 PrimaryExpression::RawString(r) => Some(r.text.trim_matches('\'').to_owned()),
239 PrimaryExpression::StringNode(s) => {
240 let text: String = s.parts.iter().filter_map(|part| match part {
241 StringPart::Content(c) => Some(c.text.clone()),
242 _ => None,
243 }).collect();
244 if text.is_empty() { None } else { Some(text) }
245 }
246 PrimaryExpression::AnsiCString(a) => Some(a.text.clone()),
247 PrimaryExpression::Number(n) => Some(n.text.clone()),
248 PrimaryExpression::SimpleExpansion(_)
249 | PrimaryExpression::Expansion(_)
250 | PrimaryExpression::CommandSubstitution(_)
251 | PrimaryExpression::ArithmeticExpansion(_)
252 | PrimaryExpression::BraceExpression(_)
253 | PrimaryExpression::ProcessSubstitution(_)
254 | PrimaryExpression::TranslatedString(_) => None,
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::parse_to_ast;
262
263 fn peel(src: &str) -> Option<PeeledCommand> {
264 let prog = parse_to_ast(src).expect("parse failed");
265 peel_command(&prog)
266 }
267
268 #[test]
269 fn unwrapped_command_passes_through() {
270 let p = peel("git push origin main").unwrap();
271 assert_eq!(p.primary, "git");
272 assert_eq!(p.args, vec!["push", "origin", "main"]);
273 assert!(p.wrappers.is_empty());
274 }
275
276 #[test]
277 fn timeout_peeled() {
278 let p = peel("timeout 30 git push --force origin main").unwrap();
279 assert_eq!(p.primary, "git");
280 assert_eq!(p.args, vec!["push", "--force", "origin", "main"]);
281 assert_eq!(p.wrappers, vec!["timeout"]);
282 }
283
284 #[test]
285 fn env_with_kv_peeled() {
286 let p = peel("env FOO=bar cargo test").unwrap();
287 assert_eq!(p.primary, "cargo");
288 assert_eq!(p.args, vec!["test"]);
289 assert_eq!(p.wrappers, vec!["env"]);
290 }
291
292 #[test]
293 fn nested_wrappers_recurse() {
294 let p = peel("timeout 30 env FOO=bar git status").unwrap();
295 assert_eq!(p.primary, "git");
296 assert_eq!(p.args, vec!["status"]);
297 assert_eq!(p.wrappers, vec!["timeout", "env"]);
298 }
299
300 #[test]
301 fn pipeline_refuses_peel() {
302 assert!(peel("head -n 20 log | tail -3").is_none());
303 }
304
305 #[test]
306 fn list_refuses_peel() {
307 assert!(peel("cd /tmp && cargo build").is_none());
308 }
309
310 #[test]
311 fn redirect_refuses_peel() {
312 assert!(peel("echo hi > out.txt").is_none());
313 }
314
315 #[test]
316 fn leading_assignment_refuses_peel() {
317 // `RUST_LOG=debug cargo test` is a Command with leading_assignments,
318 // not a wrapper. Refuses peel — the caller falls back to the
319 // existing parse_bash flat-form path.
320 assert!(peel("RUST_LOG=debug cargo test").is_none());
321 }
322
323 #[test]
324 fn wrapper_alone_doesnt_peel_past_end() {
325 // `env` with no args prints env — primary stays as "env".
326 let p = peel("env").unwrap();
327 assert_eq!(p.primary, "env");
328 assert!(p.args.is_empty());
329 assert!(p.wrappers.is_empty());
330 }
331
332 #[test]
333 fn wrappers_const_matches_json_sidecar() {
334 // R155-T9: the JSON sidecar at `data/wrappers.json` is the canonical
335 // wrapper set the FE consumes. Keep the Rust const in lockstep so
336 // approval-gate matching and FE chip rendering agree on which
337 // commands are "wrappers".
338 let from_json: Vec<String> =
339 serde_json::from_str(super::WRAPPERS_JSON).expect("wrappers.json must be valid JSON");
340 let from_const: Vec<String> = super::WRAPPERS.iter().map(|s| s.to_string()).collect();
341 assert_eq!(from_const, from_json,
342 "bash_ast::wrappers::WRAPPERS drifted from data/wrappers.json — update one or both");
343 }
344
345 #[test]
346 fn sudo_not_a_wrapper() {
347 // sudo intentionally not in WRAPPERS — privilege change is its own
348 // primary semantic.
349 let p = peel("sudo apt install foo").unwrap();
350 assert_eq!(p.primary, "sudo");
351 assert_eq!(p.args, vec!["apt", "install", "foo"]);
352 }
353}