Skip to main content

shell_sanitize_rules/
presets.rs

1//! Ready-made sanitizer configurations for common threat models.
2//!
3//! Each preset returns a fully configured [`Sanitizer`] with rules
4//! matched to a specific use case. Choose based on **how the validated
5//! value will be consumed**:
6//!
7//! | Preset | Target context | Rules |
8//! |--------|---------------|-------|
9//! | [`command_arg`] | `Command::new().arg()` | ControlChar |
10//! | [`shell_command`] | `sh -c`, SSH, popen | ShellMeta + ControlChar + EnvExpansion + Glob |
11//! | [`file_path`] | Upload dest, include | PathTraversal + ControlChar |
12//! | [`file_path_absolute`] | Config file, absolute OK | PathTraversal(allow_abs) + ControlChar |
13//! | [`strict`] | SSH remote path ops, max protection | All 5 rules |
14//!
15//! # AI agent use case
16//!
17//! When an LLM generates tool calls (e.g. Claude Code, Copilot, Devin),
18//! treat the **data arguments** as untrusted input — indirect prompt
19//! injection can manipulate what the AI produces.
20//!
21//! ## What this crate CAN validate
22//!
23//! **Path arguments** from structured tool calls — this is the primary
24//! value for AI agents:
25//!
26//! ```text
27//! AI: { tool: "read_file", path: "../../etc/shadow" }
28//!                                 ^^^^^^^^^^^^^^^^
29//!                                 file_path() catches this
30//!
31//! AI: { tool: "write_file", path: "/etc/crontab" }
32//!                                 ^^^^^^^^^^^^^
33//!                                 file_path() catches this
34//! ```
35//!
36//! **Individual arguments** when the framework provides structured
37//! tool calls:
38//!
39//! ```text
40//! AI: { tool: "git_clone", url: "https://evil.com; rm -rf /" }
41//!                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
42//!                                shell_command() catches the `;`
43//! ```
44//!
45//! **Template slots** when a trusted template is filled with AI data:
46//!
47//! ```text
48//! Template (hardcoded):  "rsync -avz {} {}"
49//! Slot 1 (file_path):    validated_src
50//! Slot 2 (shell_command): validated_dest
51//! ```
52//!
53//! ## What this crate CANNOT validate
54//!
55//! **Free-form bash command strings** — the AI generates the entire
56//! command, not just arguments:
57//!
58//! ```text
59//! AI: Bash("git diff HEAD~3")           ← legitimate
60//! AI: Bash("git diff HEAD~3; rm -rf /") ← injection
61//!
62//! Sanitizing the full string would break the legitimate command.
63//! This requires: sandbox, container isolation, command allowlist.
64//! ```
65//!
66//! ## Preset selection for AI tool calls
67//!
68//! | AI tool type | Preset | Example |
69//! |-------------|--------|---------|
70//! | File read/write | [`file_path`] | `read("src/lib.rs")` |
71//! | Config file | [`file_path_absolute`] | `read("/etc/app/config.toml")` |
72//! | Shell arg slot | [`shell_command`] | `ssh("deploy {tag}")` |
73//! | `Command::new().arg()` | [`command_arg`] | `git.arg(branch_name)` |
74//! | Unknown context | [`strict`] | any mixed-use value |
75//! | Free-form bash | *out of scope* | `Bash("cd repo && make")` |
76//!
77//! # Known limitations
78//!
79//! These presets do **not** defend against:
80//!
81//! - **Free-form command strings** — use sandbox/container isolation.
82//! - **Argument injection** (`--upload-pack=evil`) — a flag prefixed with
83//!   `--` is valid shell text. Use `--` separators or command-specific
84//!   validation.
85//! - **URL-encoded bypasses** (`%2e%2e`) — decode input before sanitizing.
86//! - **Semantic attacks** — a path like `safe/but/wrong/file.txt` passes
87//!   all rules but may still be the wrong file.
88
89use shell_sanitize::{FilePath, Sanitizer, ShellArg};
90
91use crate::{ControlCharRule, EnvExpansionRule, GlobRule, PathTraversalRule, ShellMetaRule};
92
93/// Minimal validation for [`Command::new().arg()`][std::process::Command]
94/// contexts.
95///
96/// Even without shell involvement, control characters can cause:
97/// - NUL-byte truncation in C-backed APIs
98/// - ANSI escape sequence injection in terminal output
99/// - Newline injection in log files
100///
101/// # Rules
102///
103/// - [`ControlCharRule`]
104///
105/// # Example
106///
107/// ```
108/// use shell_sanitize_rules::presets;
109///
110/// let s = presets::command_arg();
111/// assert!(s.sanitize("safe-argument").is_ok());
112/// assert!(s.sanitize("has\0null").is_err());
113/// ```
114pub fn command_arg() -> Sanitizer<ShellArg> {
115    Sanitizer::builder()
116        .add_rule(ControlCharRule::default())
117        .build()
118}
119
120/// Sanitizer for values interpolated into shell command strings.
121///
122/// Use when the value will be evaluated by a shell: `sh -c "..."`,
123/// SSH remote commands, `docker exec ... sh -c "..."`, CI/CD `run:`
124/// blocks, or legacy `system()`/`popen()` calls.
125///
126/// # Rules
127///
128/// - [`ShellMetaRule`] — `;`, `|`, `&`, `$`, backtick, `()`, `{}`, `<>`, etc.
129/// - [`ControlCharRule`] — NUL, newline, ANSI escapes
130/// - [`EnvExpansionRule`] — `$HOME`, `${SECRET}`, `%USERPROFILE%`
131/// - [`GlobRule`] — `*`, `?`, `[`, `]`, `{`, `}`
132///
133/// # Example
134///
135/// ```
136/// use shell_sanitize_rules::presets;
137///
138/// let s = presets::shell_command();
139///
140/// // Safe argument
141/// assert!(s.sanitize("my-branch-name").is_ok());
142///
143/// // Shell injection
144/// assert!(s.sanitize("branch; rm -rf /").is_err());
145///
146/// // Prompt injection → env variable exfiltration
147/// assert!(s.sanitize("$AWS_SECRET_ACCESS_KEY").is_err());
148/// ```
149pub fn shell_command() -> Sanitizer<ShellArg> {
150    Sanitizer::builder()
151        .add_rule(ShellMetaRule::default())
152        .add_rule(ControlCharRule::default())
153        .add_rule(EnvExpansionRule::default())
154        .add_rule(GlobRule::default())
155        .build()
156}
157
158/// Sanitizer for relative file paths.
159///
160/// Use for upload destinations, template includes, plugin paths, or
161/// any path that must stay within a base directory. Rejects absolute
162/// paths and `..` traversal.
163///
164/// # Rules
165///
166/// - [`PathTraversalRule`] — `..`, absolute paths, drive letters
167/// - [`ControlCharRule`] — NUL, newline, ANSI escapes
168///
169/// # Example
170///
171/// ```
172/// use shell_sanitize_rules::presets;
173///
174/// let s = presets::file_path();
175///
176/// // Relative path within allowed directory
177/// assert!(s.sanitize("uploads/photo.jpg").is_ok());
178///
179/// // Traversal attack
180/// assert!(s.sanitize("../../etc/passwd").is_err());
181///
182/// // Absolute path escape
183/// assert!(s.sanitize("/etc/shadow").is_err());
184/// ```
185pub fn file_path() -> Sanitizer<FilePath> {
186    Sanitizer::builder()
187        .add_rule(PathTraversalRule::default())
188        .add_rule(ControlCharRule::default())
189        .build()
190}
191
192/// Sanitizer for file paths where absolute paths are acceptable.
193///
194/// Use for user-specified config files, log destinations, or paths
195/// where the caller explicitly allows absolute paths but still needs
196/// to block `..` traversal.
197///
198/// # Rules
199///
200/// - [`PathTraversalRule`] (absolute allowed) — `..` only
201/// - [`ControlCharRule`] — NUL, newline, ANSI escapes
202///
203/// # Example
204///
205/// ```
206/// use shell_sanitize_rules::presets;
207///
208/// let s = presets::file_path_absolute();
209///
210/// // Absolute path is allowed
211/// assert!(s.sanitize("/etc/myapp/config.toml").is_ok());
212///
213/// // Traversal still blocked
214/// assert!(s.sanitize("/etc/myapp/../../shadow").is_err());
215/// ```
216pub fn file_path_absolute() -> Sanitizer<FilePath> {
217    Sanitizer::builder()
218        .add_rule(PathTraversalRule::allow_absolute())
219        .add_rule(ControlCharRule::default())
220        .build()
221}
222
223/// Maximum-protection sanitizer with all rules enabled.
224///
225/// Use for the most dangerous contexts: SSH remote path operations,
226/// values that serve as both shell arguments and file paths, or any
227/// situation where the consumption context is unknown or mixed.
228///
229/// # Rules
230///
231/// All five built-in rules:
232/// - [`ControlCharRule`]
233/// - [`ShellMetaRule`]
234/// - [`PathTraversalRule`]
235/// - [`GlobRule`]
236/// - [`EnvExpansionRule`]
237///
238/// # Example
239///
240/// ```
241/// use shell_sanitize_rules::presets;
242///
243/// let s = presets::strict();
244///
245/// // Must pass every rule
246/// assert!(s.sanitize("safe-filename.txt").is_ok());
247///
248/// // Fails on any violation
249/// assert!(s.sanitize("../../etc/passwd").is_err());
250/// assert!(s.sanitize("file; rm -rf /").is_err());
251/// assert!(s.sanitize("$HOME/.ssh/id_rsa").is_err());
252/// ```
253pub fn strict() -> Sanitizer<ShellArg> {
254    Sanitizer::builder()
255        .add_rules(crate::default_shell_rules())
256        .build()
257}