Skip to main content

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}