Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/src/content/docs/pattern-syntax/placeholders.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Tokens wrapped in `<...>` are **placeholders** — special tokens that match dyn

The `<cmd>` placeholder captures the **remaining tokens** as the wrapped command. The wrapped command is then evaluated against the other rules in the configuration. See [Wrapped Command Recursion](/rule-evaluation/wrapper-recursion/) for details.

`<cmd>` only matches token sequences whose **first token is not a flag** (does not start with `-`). This prevents wrapper patterns from accidentally consuming flag arguments as commands. For example, `command <cmd>` does not match `command -v a` because `-v` is a flag, not a command name.

```yaml
# sudo echo hello -> wrapped command is "echo hello"
- allow: 'sudo <cmd>'
Expand Down Expand Up @@ -243,7 +245,7 @@ In the `find` wrapper example, `\\;` is a backslash-escaped semicolon in YAML. T

## Restrictions

- `<cmd>` captures one or more tokens; it tries all possible split points to find a valid wrapped command
- `<cmd>` captures one or more tokens whose first token is not a flag (does not start with `-`); it tries all possible split points to find a valid wrapped command
- Optional groups, path references, and variable references are not supported inside wrapper patterns

## Related
Expand Down
24 changes: 20 additions & 4 deletions src/rules/pattern_matcher/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ fn match_engine<'a>(
let is_cmd = name == "cmd";
if rest.is_empty() {
if is_cmd {
if cmd_tokens.is_empty() {
if cmd_tokens.is_empty() || cmd_tokens[0].starts_with('-') {
return Ok(false);
}
let saved_len = captured.len();
Expand All @@ -811,6 +811,10 @@ fn match_engine<'a>(
}
} else if !cmd_tokens.is_empty() {
for take in 1..=cmd_tokens.len() {
// <cmd> must not capture a sequence starting with a flag
if is_cmd && cmd_tokens[0].starts_with('-') {
break;
}
let saved_len = captured.len();
if is_cmd {
captured.extend_from_slice(&cmd_tokens[..take]);
Expand Down Expand Up @@ -1776,8 +1780,9 @@ mod tests {
#[case::wildcard_before_cmd(
"xargs * <cmd>",
"xargs -I{} echo hello",
// Wildcard tries skip=0,1,2: all produce candidates
vec![vec!["-I{}", "echo", "hello"], vec!["echo", "hello"], vec!["hello"]],
// Wildcard tries skip=0,1,2: skip=0 is rejected because <cmd> would
// start with a flag ("-I{}"), so only skip=1 and skip=2 produce candidates.
vec![vec!["echo", "hello"], vec!["hello"]],
)]
#[case::no_match(
"sudo <cmd>",
Expand All @@ -1792,7 +1797,8 @@ mod tests {
#[case::negation_before_cmd(
"run !--dry-run <cmd>",
"run --verbose echo hello",
vec![vec!["--verbose", "echo", "hello"]],
// <cmd> rejects capture starting with a flag ("--verbose"), so no match.
Vec::<Vec<&str>>::new(),
)]
#[case::positional_negation_before_cmd(
"run !exec <cmd>",
Expand Down Expand Up @@ -1829,6 +1835,16 @@ mod tests {
"run -v echo hello",
vec![vec!["echo", "hello"]],
)]
#[case::cmd_rejects_flag_start(
"command <cmd>",
"command -v a",
Vec::<Vec<&str>>::new(),
)]
#[case::cmd_accepts_non_flag(
"command <cmd>",
"command ls",
vec![vec!["ls"]],
)]
fn extract_placeholder_cases(
#[case] pattern_str: &str,
#[case] command_str: &str,
Expand Down
29 changes: 29 additions & 0 deletions tests/integration/wrapper_recursive_evaluation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,35 @@ fn wrapper_compound_with_sandbox(empty_context: EvalContext) {
assert_eq!(result.sandbox_preset.as_deref(), Some("py_sandbox"));
}

// ========================================
// <cmd> placeholder does not capture flag-starting sequences:
// `command <cmd>` wrapper should not match `command -v a`,
// so the direct rule `allow: 'command -v|-V *'` takes effect.
// ========================================

#[rstest]
#[case::command_v_allow("command -v a", assert_allow as ActionAssertion)]
#[case::command_uppercase_v_allow("command -V a", assert_allow as ActionAssertion)]
#[case::command_ls_wrapper_applies("command ls", assert_allow as ActionAssertion)]
fn cmd_placeholder_skips_flag_starting_tokens(
#[case] command: &str,
#[case] expected: ActionAssertion,
empty_context: EvalContext,
) {
let config = parse_config(indoc! {"
rules:
- allow: 'command -v|-V *'
- allow: 'ls'
definitions:
wrappers:
- 'command <cmd>'
"})
.unwrap();

let result = evaluate_command(&config, command, &empty_context).unwrap();
expected(&result.action);
}

// ========================================
// find -exec/-execdir wrapper: flag alternation followed by <cmd>
// placeholder is parsed correctly, enabling recursive evaluation
Expand Down
Loading