aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/atuin-ai/src/permissions/check.rs15
-rw-r--r--crates/atuin-ai/src/permissions/shell.rs102
-rw-r--r--crates/atuin-ai/src/tools/mod.rs133
-rw-r--r--docs/docs/ai/images/tool_fs.pngbin0 -> 74298 bytes
-rw-r--r--docs/docs/ai/images/tool_shell.pngbin0 -> 59603 bytes
-rw-r--r--docs/docs/ai/tools-permissions.md100
6 files changed, 298 insertions, 52 deletions
diff --git a/crates/atuin-ai/src/permissions/check.rs b/crates/atuin-ai/src/permissions/check.rs
index 6b908b93..96abc3ab 100644
--- a/crates/atuin-ai/src/permissions/check.rs
+++ b/crates/atuin-ai/src/permissions/check.rs
@@ -57,15 +57,12 @@ impl PermissionChecker {
}
}
- for rule in &file.content.permissions.allow {
- if request.call.matches_rule(rule) {
- tracing::debug!(
- "Permission 'ALLOW' by rule: {} in file: {}",
- rule,
- file.path.display()
- );
- return Ok(PermissionResponse::Allowed);
- }
+ if request.call.all_covered_by(&file.content.permissions.allow) {
+ tracing::debug!(
+ "Permission 'ALLOW' by rules in file: {}",
+ file.path.display()
+ );
+ return Ok(PermissionResponse::Allowed);
}
}
diff --git a/crates/atuin-ai/src/permissions/shell.rs b/crates/atuin-ai/src/permissions/shell.rs
index 7a2eee2e..29b9f5d8 100644
--- a/crates/atuin-ai/src/permissions/shell.rs
+++ b/crates/atuin-ai/src/permissions/shell.rs
@@ -341,10 +341,22 @@ fn push_segment(segment: &mut String, commands: &mut Vec<ShellCommand>) {
/// - `ls*` (no space before `*`) — matches `lsof`, `ls`, `ls -a` (prefix/glob)
/// - `rm` (no wildcard) — matches exactly `rm`
/// - `git * amend` — matches `git commit amend` (middle wildcard matches zero+ words)
-pub(crate) fn any_subcommand_matches(subcommands: &[ShellCommand], scope: &str) -> bool {
+///
+/// When `prefix_bare` is true, a bare pattern without wildcards (e.g. `rm`)
+/// uses word-boundary prefix matching — `rm` matches `rm -rf /`. When false,
+/// bare patterns require an exact match — `rm` only matches `rm`.
+///
+/// Allow rules should pass `prefix_bare: false` (strict), while deny/ask rules
+/// should pass `prefix_bare: true` (broad) so that denying `rm` also blocks
+/// `rm -rf /`.
+pub(crate) fn any_subcommand_matches(
+ subcommands: &[ShellCommand],
+ prefix_bare: bool,
+ scope: &str,
+) -> bool {
let scope = scope.trim();
- if scope == "*" {
+ if scope.is_empty() || scope == "*" {
return true;
}
@@ -373,11 +385,16 @@ pub(crate) fn any_subcommand_matches(subcommands: &[ShellCommand], scope: &str)
.any(|cmd| scope_matches_words(scope, cmd.full.split_whitespace().collect()));
}
- // No wildcard: word-boundary prefix match
+ // No wildcard: exact or prefix depending on context
let scope_words: Vec<&str> = scope.split_whitespace().collect();
subcommands.iter().any(|cmd| {
let cmd_words: Vec<&str> = cmd.full.split_whitespace().collect();
- cmd_words.len() >= scope_words.len() && cmd_words[..scope_words.len()] == scope_words[..]
+ if prefix_bare {
+ cmd_words.len() >= scope_words.len()
+ && cmd_words[..scope_words.len()] == scope_words[..]
+ } else {
+ cmd_words == scope_words
+ }
})
}
@@ -542,7 +559,7 @@ mod tests {
full: "npm test".into(),
},
];
- assert!(any_subcommand_matches(&commands, "*"));
+ assert!(any_subcommand_matches(&commands, true, "*"));
}
#[test]
@@ -557,11 +574,18 @@ mod tests {
full: "npm test".into(),
},
];
- assert!(any_subcommand_matches(&commands, "git commit *"));
- assert!(any_subcommand_matches(&commands, "git commit"));
- assert!(!any_subcommand_matches(&commands, "git push *"));
- assert!(!any_subcommand_matches(&commands, "git push"));
- assert!(any_subcommand_matches(&commands, "npm *"));
+ assert!(any_subcommand_matches(&commands, true, "git commit *"));
+ assert!(!any_subcommand_matches(&commands, true, "git push *"));
+ assert!(!any_subcommand_matches(&commands, true, "git push"));
+ assert!(any_subcommand_matches(&commands, true, "npm *"));
+ assert!(any_subcommand_matches(&commands, true, "npm test"));
+
+ // prefix_bare=true: bare "git commit" prefix-matches "git commit -m msg" (deny/ask)
+ assert!(any_subcommand_matches(&commands, true, "git commit"));
+ // prefix_bare=false: bare "git commit" does NOT match "git commit -m msg" (allow)
+ assert!(!any_subcommand_matches(&commands, false, "git commit"));
+ // Exact match works in both modes when command has no extra args
+ assert!(any_subcommand_matches(&commands, false, "npm test"));
}
#[test]
@@ -577,12 +601,12 @@ mod tests {
},
];
// `ls *` — word boundary: matches `ls -a` but not `lsof`
- assert!(any_subcommand_matches(&commands, "ls *"));
- assert!(!any_subcommand_matches(&commands, "cat *"));
- assert!(any_subcommand_matches(&commands, "lsof *"));
+ assert!(any_subcommand_matches(&commands, true, "ls *"));
+ assert!(!any_subcommand_matches(&commands, true, "cat *"));
+ assert!(any_subcommand_matches(&commands, true, "lsof *"));
// `ls*` — glob/prefix: matches both `ls -a` and `lsof`
- assert!(any_subcommand_matches(&commands, "ls*"));
+ assert!(any_subcommand_matches(&commands, true, "ls*"));
}
#[test]
@@ -591,8 +615,8 @@ mod tests {
name: "ls".into(),
full: "ls".into(),
}];
- assert!(any_subcommand_matches(&commands, "ls"));
- assert!(!any_subcommand_matches(&commands, "cat"));
+ assert!(any_subcommand_matches(&commands, true, "ls"));
+ assert!(!any_subcommand_matches(&commands, true, "cat"));
}
#[cfg(feature = "tree-sitter")]
@@ -678,9 +702,13 @@ mod tests {
name: "git".into(),
full: "git commit -m amend".into(),
}];
- assert!(any_subcommand_matches(&commands, "git * amend"));
- assert!(any_subcommand_matches(&commands, "git commit * amend"));
- assert!(!any_subcommand_matches(&commands, "git push * amend"));
+ assert!(any_subcommand_matches(&commands, true, "git * amend"));
+ assert!(any_subcommand_matches(
+ &commands,
+ true,
+ "git commit * amend"
+ ));
+ assert!(!any_subcommand_matches(&commands, true, "git push * amend"));
}
#[test]
@@ -690,7 +718,7 @@ mod tests {
full: "git commit".into(),
}];
// `*` matches zero words, so `git * commit` should match `git commit`
- assert!(any_subcommand_matches(&commands, "git * commit"));
+ assert!(any_subcommand_matches(&commands, true, "git * commit"));
}
#[test]
@@ -699,8 +727,8 @@ mod tests {
name: "docker".into(),
full: "docker run --rm alpine".into(),
}];
- assert!(any_subcommand_matches(&commands, "* alpine"));
- assert!(!any_subcommand_matches(&commands, "* ubuntu"));
+ assert!(any_subcommand_matches(&commands, true, "* alpine"));
+ assert!(!any_subcommand_matches(&commands, true, "* ubuntu"));
}
#[test]
@@ -709,8 +737,12 @@ mod tests {
name: "git".into(),
full: "git rebase -i HEAD~5".into(),
}];
- assert!(any_subcommand_matches(&commands, "git * -i * HEAD~5"));
- assert!(!any_subcommand_matches(&commands, "git * -i * HEAD~10"));
+ assert!(any_subcommand_matches(&commands, true, "git * -i * HEAD~5"));
+ assert!(!any_subcommand_matches(
+ &commands,
+ true,
+ "git * -i * HEAD~10"
+ ));
}
}
@@ -1232,7 +1264,7 @@ mod adversarial {
full: "ls".into(),
}];
// Empty scope matches everything (nothing to constrain)
- assert!(any_subcommand_matches(&commands, ""));
+ assert!(any_subcommand_matches(&commands, true, ""));
}
#[test]
@@ -1242,7 +1274,7 @@ mod adversarial {
full: "ls".into(),
}];
// " *" with empty prefix = match anything
- assert!(any_subcommand_matches(&commands, " *"));
+ assert!(any_subcommand_matches(&commands, true, " *"));
}
#[test]
@@ -1252,7 +1284,7 @@ mod adversarial {
full: "ls".into(),
}];
// `ls*` matches `ls` (prefix match with nothing after)
- assert!(any_subcommand_matches(&commands, "ls*"));
+ assert!(any_subcommand_matches(&commands, true, "ls*"));
}
#[test]
@@ -1262,7 +1294,7 @@ mod adversarial {
name: "git".into(),
full: "git commit".into(),
}];
- assert!(any_subcommand_matches(&commands, "git * commit"));
+ assert!(any_subcommand_matches(&commands, true, "git * commit"));
}
#[test]
@@ -1272,7 +1304,7 @@ mod adversarial {
name: "git".into(),
full: "git commit".into(),
}];
- assert!(any_subcommand_matches(&commands, "git ** commit"));
+ assert!(any_subcommand_matches(&commands, true, "git ** commit"));
}
#[test]
@@ -1281,8 +1313,14 @@ mod adversarial {
name: "LS".into(),
full: "LS -la".into(),
}];
- assert!(!any_subcommand_matches(&commands, "ls"));
- assert!(any_subcommand_matches(&commands, "LS"));
+ // Wildcard: case matters
+ assert!(!any_subcommand_matches(&commands, true, "ls *"));
+ assert!(any_subcommand_matches(&commands, true, "LS *"));
+ // prefix_bare=true: bare "LS" prefix-matches "LS -la"
+ assert!(!any_subcommand_matches(&commands, true, "ls"));
+ assert!(any_subcommand_matches(&commands, true, "LS"));
+ // prefix_bare=false: bare "LS" does NOT match "LS -la"
+ assert!(!any_subcommand_matches(&commands, false, "LS"));
}
#[test]
@@ -1292,6 +1330,6 @@ mod adversarial {
name: "git".into(),
full: "git commit-amend".into(),
}];
- assert!(!any_subcommand_matches(&commands, "git commit"));
+ assert!(!any_subcommand_matches(&commands, true, "git commit"));
}
}
diff --git a/crates/atuin-ai/src/tools/mod.rs b/crates/atuin-ai/src/tools/mod.rs
index 8a670be0..e66d64b8 100644
--- a/crates/atuin-ai/src/tools/mod.rs
+++ b/crates/atuin-ai/src/tools/mod.rs
@@ -248,6 +248,15 @@ impl ClientToolCall {
pub(crate) trait PermissableToolCall {
/// Checks if this tool call matches the given permission rule.
fn matches_rule(&self, rule: &Rule) -> bool;
+
+ /// Check if every part of this tool call is covered by at least one rule in
+ /// the set. For compound operations (e.g. shell pipelines), each sub-part
+ /// must be individually covered. The default treats the call as atomic —
+ /// any single matching rule is sufficient.
+ fn all_covered_by(&self, rules: &[Rule]) -> bool {
+ rules.iter().any(|r| self.matches_rule(r))
+ }
+
/// Returns the target directory of this tool call, if applicable, for checking against directory-based rules.
fn target_dir(&self) -> Option<&Path> {
None
@@ -259,6 +268,13 @@ impl PermissableToolCall for ClientToolCall {
self.matches_rule(rule)
}
+ fn all_covered_by(&self, rules: &[Rule]) -> bool {
+ match self {
+ ClientToolCall::Shell(tool) => tool.all_covered_by(rules),
+ _ => rules.iter().any(|r| self.matches_rule(r)),
+ }
+ }
+
fn target_dir(&self) -> Option<&Path> {
self.target_dir()
}
@@ -771,7 +787,38 @@ impl PermissableToolCall for ShellToolCall {
let shell_kind = crate::permissions::shell::ShellKind::from_shell_name(&self.shell);
let parsed = crate::permissions::shell::parse_shell_command(&self.command, shell_kind);
- crate::permissions::shell::any_subcommand_matches(&parsed.subcommands, scope)
+ // Deny/ask path: prefix_bare = true so `deny = ["Shell(rm)"]` blocks `rm -rf /`
+ crate::permissions::shell::any_subcommand_matches(&parsed.subcommands, true, scope)
+ }
+
+ /// For compound shell commands, every subcommand must be individually
+ /// covered by at least one rule. This ensures that `allow = ["Shell(git *)"]`
+ /// does not silently permit `git add . && rm -rf /`.
+ fn all_covered_by(&self, rules: &[Rule]) -> bool {
+ use crate::permissions::shell;
+
+ let shell_kind = shell::ShellKind::from_shell_name(&self.shell);
+ let parsed = shell::parse_shell_command(&self.command, shell_kind);
+
+ // If parsing yields nothing, don't vacuously allow — fall through to ask.
+ !parsed.subcommands.is_empty()
+ && parsed.subcommands.iter().all(|subcmd| {
+ rules.iter().any(|rule| {
+ if rule.tool != "Shell" {
+ return false;
+ }
+ match rule.scope.as_deref() {
+ None | Some("*") => true,
+ // Allow path: prefix_bare = false so `Shell(git commit)`
+ // only allows exactly `git commit`, not `git commit --amend`
+ Some(scope) => shell::any_subcommand_matches(
+ std::slice::from_ref(subcmd),
+ false,
+ scope,
+ ),
+ }
+ })
+ })
}
}
@@ -1237,6 +1284,90 @@ mod tests {
assert!(!tool.matches_rule(&read_rule(Some("crates/**/*.py"))));
}
+ // ── all_covered_by tests (compound shell command semantics) ──
+
+ fn shell_rule(scope: Option<&str>) -> Rule {
+ Rule {
+ tool: "Shell".to_string(),
+ scope: scope.map(String::from),
+ }
+ }
+
+ fn shell_tool(command: &str) -> ShellToolCall {
+ ShellToolCall {
+ dir: None,
+ command: command.to_string(),
+ shell: "bash".to_string(),
+ timeout_secs: 30,
+ description: None,
+ }
+ }
+
+ #[test]
+ fn all_covered_by_simple_command() {
+ let rules = vec![shell_rule(Some("git *"))];
+ assert!(shell_tool("git add .").all_covered_by(&rules));
+ assert!(!shell_tool("npm test").all_covered_by(&rules));
+ }
+
+ #[test]
+ fn all_covered_by_compound_all_covered() {
+ let rules = vec![shell_rule(Some("git *")), shell_rule(Some("npm *"))];
+ assert!(shell_tool("git add . && npm test").all_covered_by(&rules));
+ }
+
+ #[test]
+ fn all_covered_by_compound_partially_covered() {
+ // Only git is allowed — npm subcommand is not covered, so the
+ // compound command must not be auto-allowed.
+ let rules = vec![shell_rule(Some("git *"))];
+ assert!(!shell_tool("git add . && npm test").all_covered_by(&rules));
+ }
+
+ #[test]
+ fn all_covered_by_unscoped_shell_rule() {
+ // Shell without scope covers everything
+ let rules = vec![shell_rule(None)];
+ assert!(shell_tool("git add . && rm -rf /").all_covered_by(&rules));
+ }
+
+ #[test]
+ fn all_covered_by_wildcard_shell_rule() {
+ let rules = vec![shell_rule(Some("*"))];
+ assert!(shell_tool("git add . && npm test").all_covered_by(&rules));
+ }
+
+ #[test]
+ fn all_covered_by_non_shell_tool_unchanged() {
+ // Non-shell tools use the default (any single rule matches)
+ let rules = vec![read_rule(Some("*.md"))];
+ assert!(read_tool("notes.md").all_covered_by(&rules));
+ assert!(!read_tool("notes.txt").all_covered_by(&rules));
+ }
+
+ #[test]
+ fn matches_rule_still_uses_any_semantics() {
+ // matches_rule (used for deny/ask) still triggers on any subcommand
+ let rule = shell_rule(Some("rm *"));
+ assert!(shell_tool("git add . && rm -rf /").matches_rule(&rule));
+ }
+
+ #[test]
+ fn bare_pattern_asymmetry() {
+ // Deny (matches_rule, prefix_bare=true): bare "rm" blocks "rm -rf /"
+ let deny_rule = shell_rule(Some("rm"));
+ assert!(shell_tool("rm -rf /").matches_rule(&deny_rule));
+
+ // Allow (all_covered_by, prefix_bare=false): bare "rm" only allows exactly "rm"
+ let allow_rules = vec![shell_rule(Some("rm"))];
+ assert!(shell_tool("rm").all_covered_by(&allow_rules));
+ assert!(!shell_tool("rm -rf /").all_covered_by(&allow_rules));
+
+ // Bare prefix match is word-boundary, not substring — "rm" must not match "rmbackup"
+ assert!(!shell_tool("rmbackup").matches_rule(&deny_rule));
+ assert!(!shell_tool("rmbackup /tmp").matches_rule(&deny_rule));
+ }
+
// ── Unix-specific tests (absolute paths with forward slashes) ──
#[cfg(unix)]
diff --git a/docs/docs/ai/images/tool_fs.png b/docs/docs/ai/images/tool_fs.png
new file mode 100644
index 00000000..33b6033a
--- /dev/null
+++ b/docs/docs/ai/images/tool_fs.png
Binary files differ
diff --git a/docs/docs/ai/images/tool_shell.png b/docs/docs/ai/images/tool_shell.png
new file mode 100644
index 00000000..1ccb962f
--- /dev/null
+++ b/docs/docs/ai/images/tool_shell.png
Binary files differ
diff --git a/docs/docs/ai/tools-permissions.md b/docs/docs/ai/tools-permissions.md
index a8f16298..b9620b8e 100644
--- a/docs/docs/ai/tools-permissions.md
+++ b/docs/docs/ai/tools-permissions.md
@@ -2,12 +2,9 @@
Atuin AI has a number of tools that it can use to interact with your system, given your permission. The AI can use these tools to help answer questions and perform actions on your behalf.
-!!! note "More tools coming soon"
- We will be expanding the list of tools that Atuin AI can use over time.
-
## Permission System
-By default, Atuin AI asks your permission before using any client-side tool. You can change these defaults using a *permission file*.
+By default, Atuin AI asks your permission before using any client-side tool. You can change these defaults using a _permission file_.
### Permission Files
@@ -43,20 +40,17 @@ Most rules can be scoped to a particular path or other context. For example, you
### Example Config
-Here's an example of a permission file that allows Atuin AI to read and write any markdown files in the current project, but denies it access to any `.env` files. Attempts to read or write any *other* files will result in Atuin AI requesting permission before proceeding.
-
-!!! note "Reading and writing files"
- Atuin AI cannot currently read or write files; that capability is currently in development.
+Here's an example of a permission file that allows Atuin AI to read and write any markdown files in the current project (because Write implies Read — see below), but denies it access to any `.env` files. Attempts to read or write any _other_ files will result in Atuin AI requesting permission before proceeding.
```toml
[permissions]
allow = [
- "Read(**/*.md)", "Write(**/*.md)"
+ "Write(**/*.md)"
]
deny = [
- "Read(.env)", "Write(.env)"
+ "Read(.env)"
]
```
@@ -79,3 +73,89 @@ The `AtuinHistory` tool allows Atuin AI to search your Atuin history for relevan
allow = ["AtuinHistory"]
```
+
+### Read
+
+The `Read` tool allows Atuin AI to read files on your system. Atuin AI might ask to use this tool when you ask it to analyze the contents of a file, when you ask for edits to the contents of a file, or when you ask a question that is most easily answered by consulting the contents of a file.
+
+![Example of Atuin FS Tools](../images/tool_fs.png)
+
+**Permission rule and scope:** `Read(<glob_pattern>)` (e.g. `Read(**/\*.md)`to allow reading all markdown files in the current directory and subdirectories). A missing glob pattern (e.g.`Read`) matches all files.
+
+**Config value:** `ai.capabilities.enable_fs_tools` (see [settings documentation](./settings.md#capabilities)) — this setting enables both the `Read` and `Write` tools.
+
+**Example permissions file:**
+
+```toml
+[permissions]
+allow = ["Read(**/*.md)"]
+deny = ["Read(.secret/**)"]
+```
+
+!!! warning "Write Implies Read"
+
+ To prevent accidental data loss, Atuin AI is required to read the contents of a file before writing to it. This means that any permission rule that allows the `Write` tool for a particular file or set of files will also automatically allow the `Read` tool for those same files. For example, if you have a rule that allows `Write(**/*.md)`, Atuin AI will also be able to read any markdown files in the current directory and subdirectories, even if you don't have an explicit rule allowing `Read(**/*.md)`.
+
+### Write
+
+The `Write` tool allows Atuin AI to create and edit files on your system. Atuin AI might ask to use this tool when you ask it to update configuration for a tool or help debug a problem.
+
+![Example of Atuin FS Tools](../images/tool_fs.png)
+
+**Permission rule and scope:** `Write(<glob_pattern>)` (e.g. `Write(**/\*.md)`to allow reading all markdown files in the current directory and subdirectories). A missing glob pattern (e.g.`Write`) matches all files.
+
+**Config value:** `ai.capabilities.enable_fs_tools` (see [settings documentation](./settings.md#capabilities)) — this setting enables both the `Read` and `Write` tools.
+
+**Example permissions file:**
+
+```toml
+[permissions]
+allow = ["Write(**/*.md)"]
+deny = ["Write(.secret/**)"]
+```
+
+!!! note "File Backups"
+
+ The first time Atuin AI writes to a file in a session, it creates a backup of the original file and stores it in Atuin's data directory, under `ai/sessions/<session_id>`. A manifest file in that directory maps the original file paths to the backup file paths. In the future, we'll be providing easier ways to recover from accidental data loss.
+
+### Shell Command Execution
+
+The `Shell` tool allows Atuin AI to execute shell commands on your system. Atuin AI might ask to use this tool when you ask it to perform an action that is most easily accomplished by running a shell command itself, or when you ask for help debugging a failing command, or during a multi-step workflow.
+
+![Example of Atuin Shell Tool](../images/tool_shell.png)
+
+**Permission rule and scope:** `Shell(<command pattern>)` (e.g. `Shell(git *)` to allow any command that starts with `git`). A missing command pattern (e.g. `Shell`) matches all commands.
+
+**Config value:** `ai.capabilities.enable_command_execution` (see [settings documentation](./settings.md#capabilities))
+
+**Example permissions file:**
+
+```toml
+[permissions]
+allow = [
+ "Shell(git add *)",
+ "Shell(git commit *)"
+]
+```
+
+!!! note "Command Execution Scope"
+
+ The command pattern in a `Shell` permission rule is matched against the words in the command. The `*` wildcard has different behavior depending on where it appears:
+
+ | Pattern | Matches | Does Not Match |
+ |---------|---------|----------------|
+ | `*` | Any command | — |
+ | `git commit *` | `git commit`, `git commit -m "msg"` | `git`, `git push` |
+ | `ls*` | `ls`, `ls -a`, `lsof` | `cat` |
+ | `git * --amend` | `git commit --amend`, `git rebase --amend` | `git commit` |
+ | `git commit` | `git commit` | `git`, `git push`, `git commit -m "msg"` |
+
+ Note the difference between `ls *` (with a space) and `ls*` (without). The space-separated form uses **word-boundary** matching — `ls *` matches `ls` and `ls -a` but _not_ `lsof`. The attached form uses **prefix** matching — `ls*` matches all of those, including `lsof`.
+
+ For `allow` and `ask` rules, a pattern without any wildcard (e.g. `git commit`) is an **exact match** — it only matches when the command words are identical. Use `git commit *` if you want to allow `git commit` with any arguments.
+
+ For `deny` rules, a pattern without any wildcard (e.g. `rm`) is a **prefix match** — it matches any command that starts with that prefix. This means that a `deny` rule of `rm` would deny `rm`, `rm -rf /`, and `rm ./README.md` so be careful when writing `deny` rules without explicit wildcards.
+
+!!! warning "Compound Commands"
+
+ When the AI runs a compound command (e.g. `git add . && npm test`), Atuin parses it into individual subcommands. For a command to be automatically allowed, all subcommands must be allowed. This means that `git add . && npm test` must be enabled by both `Shell(git add *)` and `Shell(npm test)` for it to be allowed, else it would fall through and ask for permission. However, our parsing is not perfect, and there may be edge cases where it fails to correctly identify the subcommands, and some shells where command parsing is sub-par. For this reason, we recommend being cautious when allowing compound commands with broad patterns.