//! Shared wrapper-command set + peel logic (R155-T9).
//!
//! "Wrappers" are commands like `timeout`, `env`, `nohup` that take a command
//! as their final positional argument. The approval gate and the FE both need
//! to look past them when reasoning about a user's intent — granting
//! `BashCmdPattern { cmd: "git", … }` should match `timeout 30 git push`.
//!
//! This module owns the canonical wrapper set and the peel routine in Rust.
//! The TS-side renderer (`packages/yah/ui/src/components/forms/peelWrappers.ts`)
//! keeps a parallel list — keep them in sync.
//!
//! @yah:ticket(R155-T9, "bash_ast::wrappers shared static set; agent-tools + FE consume the same list to keep peel semantics in sync")
//! @yah:at(2026-05-14T17:22:09Z)
//! @yah:assignee(agent:claude)
//! @yah:status(review)
//! @yah:phase(P2)
//! @yah:parent(R155)
//! @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.")
//! @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.")
//! @arch:see(.yah/docs/working/W113-yah-bash-snapshot-and-permissions.md)
//!
//! @yah:ticket(R295-F1, "ToolInvocation extraction + ToolRegistry skeleton (bash-ast + agent-tools)")
//! @yah:assignee(agent:claude)
//! @yah:at(2026-05-23T00:01:16Z)
//! @yah:status(review)
//! @yah:parent(R295)
//! @arch:see(.yah/docs/working/W086-git-subcommand-gate.md)
//! @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.")
//! @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.")
//! @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.")
//! @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`).")
//! @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.")
//! @yah:verify("cargo test -p agent-tools --lib tool_schema::tests # ToolSchema + ToolRegistry serde round-trip; loading TOML + matching default_tier on unknown subcommand.")
//! @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).")
//! @yah:verify("cargo test -p bash-ast tool_invocation (11/11)")
//! @yah:verify("cargo test -p agent-tools --lib tool_schema (11/11)")
//! @yah:verify("cargo check -p bash-ast && cargo check -p agent-tools")
use crate::ast::{Command, CommandArgument, CommandNameInner, PrimaryExpression, Program, Statement, StringPart};
/// Per-wrapper peel rules. `fixed_positionals` is the count of positional
/// arguments the wrapper eats *after* its own name and any flag args (e.g.
/// `timeout` eats one positional duration before the wrapped command).
/// `eats_kv` is the `env`-style behaviour of consuming `KEY=VAL` leading args.
#[derive(Debug, Clone, Copy)]
struct WrapperSpec {
name: &'static str,
fixed_positionals: usize,
eats_kv: bool,
}
const WRAPPER_SPECS: &[WrapperSpec] = &[
WrapperSpec { name: "timeout", fixed_positionals: 1, eats_kv: false },
WrapperSpec { name: "time", fixed_positionals: 0, eats_kv: false },
WrapperSpec { name: "env", fixed_positionals: 0, eats_kv: true },
WrapperSpec { name: "nohup", fixed_positionals: 0, eats_kv: false },
WrapperSpec { name: "xargs", fixed_positionals: 0, eats_kv: false },
WrapperSpec { name: "nice", fixed_positionals: 0, eats_kv: false },
WrapperSpec { name: "ionice", fixed_positionals: 0, eats_kv: false },
WrapperSpec { name: "stdbuf", fixed_positionals: 0, eats_kv: false },
];
/// Canonical wrapper command names.
///
/// The authoritative list lives in [`data/wrappers.json`] at the crate root;
/// the FE reads the same file (`peelWrappers.ts`) to keep peel semantics in
/// sync (R155-T9). The Rust const below is a hand-mirrored copy — the
/// `wrappers_const_matches_json_sidecar` test asserts the two never drift.
///
/// `sudo` is intentionally excluded: it changes privilege and the icon
/// should reflect that. It is its own destructive primary.
pub const WRAPPERS: &[&str] = &[
"timeout", "time", "env", "nohup",
"xargs", "nice", "ionice", "stdbuf",
];
/// Raw bytes of the canonical wrapper-name JSON sidecar (single source of
/// truth shared with the FE). Exposed so consumers in other crates can
/// re-publish it without re-reading the file at runtime.
pub const WRAPPERS_JSON: &str = include_str!("../data/wrappers.json");
/// Result of peeling wrapper commands off a single simple bash command.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeeledCommand {
/// First non-wrapper command name reached after stripping leading
/// wrappers. Equal to the original command name when no peel applied.
pub primary: String,
/// Positional arguments to `primary`, in invocation order (text only).
/// Empty when the wrapped command was bare (`nohup git`).
pub args: Vec<String>,
/// Wrapper names that were stripped, in invocation order. Empty when
/// the command was not wrapped.
pub wrappers: Vec<String>,
}
/// Peel wrappers from a single simple-command program.
///
/// Returns `None` if `program` is anything other than a single
/// `Statement::Command` — pipelines, lists (`&&` / `||` / `;`), redirected
/// statements, subshells, control flow all route through the
/// `BashCompound` rule (R155-F7) instead.
///
/// Returns `None` if the command has redirects (`> file`) or leading
/// variable assignments (`FOO=bar cmd …`) — those are also compound shapes
/// that don't match a flat `BashCmdPattern`.
pub fn peel_command(program: &Program) -> Option<PeeledCommand> {
if program.statements.len() != 1 {
return None;
}
let cmd = match &program.statements[0] {
Statement::Command(cmd) => cmd,
_ => return None,
};
peel_simple_command(cmd)
}
/// Peel wrappers from a single `Command` node (one pipeline stage / list operand).
///
/// Returns `None` if the command carries shape that can't match a flat
/// `BashCmdPattern` — redirects, leading variable assignments, embedded
/// subshells, or arguments that aren't plain text.
pub fn peel_simple_command(cmd: &Command) -> Option<PeeledCommand> {
if !cmd.redirects.is_empty() || !cmd.leading_assignments.is_empty() || !cmd.subshells.is_empty() {
return None;
}
peel_simple_command_loose(cmd)
}
/// Looser cousin of [`peel_simple_command`] that ignores per-command
/// redirects, leading variable assignments, and embedded subshells. Used
/// by [`crate`]'s downstream consumers (`agent-tools::approval`) when the
/// containing rule has already opted into those shapes via flags like
/// `allow_redirects` — at that layer we still want to extract the primary
/// command name so each pipeline stage can be matched.
pub fn peel_simple_command_loose(cmd: &Command) -> Option<PeeledCommand> {
let name = cmd_name_text(cmd)?;
let args: Vec<String> = cmd.arguments.iter().map(arg_text).collect::<Option<Vec<_>>>()?;
Some(peel_recursive(name, args))
}
fn peel_recursive(name: String, args: Vec<String>) -> PeeledCommand {
let mut wrappers: Vec<String> = Vec::new();
let mut cur_name = name;
let mut cur_args = args;
loop {
let Some(spec) = WRAPPER_SPECS.iter().find(|w| w.name == cur_name) else {
break;
};
// Walk cur_args, skipping leading flags + (optionally) KEY=VAL,
// then `spec.fixed_positionals` non-flag positional args, to find
// the wrapped command.
let mut i = 0;
let mut positionals_eaten = 0usize;
let wrapped_idx = loop {
if i >= cur_args.len() {
break None;
}
let arg = &cur_args[i];
if is_flag(arg) {
i += 1;
continue;
}
if spec.eats_kv && is_kv_assignment(arg) {
i += 1;
continue;
}
if positionals_eaten < spec.fixed_positionals {
positionals_eaten += 1;
i += 1;
continue;
}
break Some(i);
};
let Some(idx) = wrapped_idx else {
// No wrapped command found — wrapper invoked alone (e.g. `env`
// to print env vars). Keep cur_name as-is, don't peel further.
break;
};
wrappers.push(cur_name);
cur_name = cur_args[idx].clone();
cur_args = cur_args[idx + 1..].to_vec();
}
PeeledCommand {
primary: cur_name,
args: cur_args,
wrappers,
}
}
fn is_flag(arg: &str) -> bool {
arg.starts_with('-') && arg.len() > 1
}
fn is_kv_assignment(arg: &str) -> bool {
// KEY=VAL where KEY is shell-identifier-ish.
let Some(eq) = arg.find('=') else { return false };
if eq == 0 {
return false;
}
let key = &arg[..eq];
let first = key.as_bytes()[0];
if !(first.is_ascii_alphabetic() || first == b'_') {
return false;
}
key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
// --- text helpers (duplicated trivially from summary.rs to avoid a circular
// pub-export; both are <10 lines and stable) ---
fn cmd_name_text(cmd: &Command) -> Option<String> {
match &cmd.name.inner {
CommandNameInner::Primary(p) => primary_text(p),
CommandNameInner::Concatenation(c) => {
let s: String = c.parts.iter().filter_map(primary_text).collect();
if s.is_empty() { None } else { Some(s) }
}
}
}
fn arg_text(arg: &CommandArgument) -> Option<String> {
match arg {
CommandArgument::Primary(p) => primary_text(p),
CommandArgument::Concatenation(c) => {
let s: String = c.parts.iter().filter_map(primary_text).collect();
if s.is_empty() { None } else { Some(s) }
}
CommandArgument::Regex(r) => Some(r.text.clone()),
CommandArgument::Operator { text, .. } => Some(text.clone()),
}
}
fn primary_text(p: &PrimaryExpression) -> Option<String> {
match p {
PrimaryExpression::Word(w) => Some(w.text.clone()),
PrimaryExpression::RawString(r) => Some(r.text.trim_matches('\'').to_owned()),
PrimaryExpression::StringNode(s) => {
let text: String = s.parts.iter().filter_map(|part| match part {
StringPart::Content(c) => Some(c.text.clone()),
_ => None,
}).collect();
if text.is_empty() { None } else { Some(text) }
}
PrimaryExpression::AnsiCString(a) => Some(a.text.clone()),
PrimaryExpression::Number(n) => Some(n.text.clone()),
PrimaryExpression::SimpleExpansion(_)
| PrimaryExpression::Expansion(_)
| PrimaryExpression::CommandSubstitution(_)
| PrimaryExpression::ArithmeticExpansion(_)
| PrimaryExpression::BraceExpression(_)
| PrimaryExpression::ProcessSubstitution(_)
| PrimaryExpression::TranslatedString(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse_to_ast;
fn peel(src: &str) -> Option<PeeledCommand> {
let prog = parse_to_ast(src).expect("parse failed");
peel_command(&prog)
}
#[test]
fn unwrapped_command_passes_through() {
let p = peel("git push origin main").unwrap();
assert_eq!(p.primary, "git");
assert_eq!(p.args, vec!["push", "origin", "main"]);
assert!(p.wrappers.is_empty());
}
#[test]
fn timeout_peeled() {
let p = peel("timeout 30 git push --force origin main").unwrap();
assert_eq!(p.primary, "git");
assert_eq!(p.args, vec!["push", "--force", "origin", "main"]);
assert_eq!(p.wrappers, vec!["timeout"]);
}
#[test]
fn env_with_kv_peeled() {
let p = peel("env FOO=bar cargo test").unwrap();
assert_eq!(p.primary, "cargo");
assert_eq!(p.args, vec!["test"]);
assert_eq!(p.wrappers, vec!["env"]);
}
#[test]
fn nested_wrappers_recurse() {
let p = peel("timeout 30 env FOO=bar git status").unwrap();
assert_eq!(p.primary, "git");
assert_eq!(p.args, vec!["status"]);
assert_eq!(p.wrappers, vec!["timeout", "env"]);
}
#[test]
fn pipeline_refuses_peel() {
assert!(peel("head -n 20 log | tail -3").is_none());
}
#[test]
fn list_refuses_peel() {
assert!(peel("cd /tmp && cargo build").is_none());
}
#[test]
fn redirect_refuses_peel() {
assert!(peel("echo hi > out.txt").is_none());
}
#[test]
fn leading_assignment_refuses_peel() {
// `RUST_LOG=debug cargo test` is a Command with leading_assignments,
// not a wrapper. Refuses peel — the caller falls back to the
// existing parse_bash flat-form path.
assert!(peel("RUST_LOG=debug cargo test").is_none());
}
#[test]
fn wrapper_alone_doesnt_peel_past_end() {
// `env` with no args prints env — primary stays as "env".
let p = peel("env").unwrap();
assert_eq!(p.primary, "env");
assert!(p.args.is_empty());
assert!(p.wrappers.is_empty());
}
#[test]
fn wrappers_const_matches_json_sidecar() {
// R155-T9: the JSON sidecar at `data/wrappers.json` is the canonical
// wrapper set the FE consumes. Keep the Rust const in lockstep so
// approval-gate matching and FE chip rendering agree on which
// commands are "wrappers".
let from_json: Vec<String> =
serde_json::from_str(super::WRAPPERS_JSON).expect("wrappers.json must be valid JSON");
let from_const: Vec<String> = super::WRAPPERS.iter().map(|s| s.to_string()).collect();
assert_eq!(from_const, from_json,
"bash_ast::wrappers::WRAPPERS drifted from data/wrappers.json — update one or both");
}
#[test]
fn sudo_not_a_wrapper() {
// sudo intentionally not in WRAPPERS — privilege change is its own
// primary semantic.
let p = peel("sudo apt install foo").unwrap();
assert_eq!(p.primary, "sudo");
assert_eq!(p.args, vec!["apt", "install", "foo"]);
}
}