Skip to main content

opendev_runtime/
constants.rs

1//! Shared constants for the approval system.
2//!
3//! Provides canonical definitions for safe commands and autonomy levels
4//! used by both TUI and Web UI approval managers.
5//!
6//! Ported from `opendev/core/runtime/approval/constants.py`.
7
8use serde::{Deserialize, Serialize};
9use std::fmt;
10
11/// Autonomy levels for command approval.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
13pub enum AutonomyLevel {
14    /// Every command requires manual approval.
15    #[serde(rename = "Manual")]
16    Manual,
17    /// Safe commands auto-approved; others require approval.
18    #[serde(rename = "Semi-Auto")]
19    #[default]
20    SemiAuto,
21    /// All commands auto-approved (dangerous still flagged).
22    #[serde(rename = "Auto")]
23    Auto,
24}
25
26impl fmt::Display for AutonomyLevel {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            AutonomyLevel::Manual => write!(f, "Manual"),
30            AutonomyLevel::SemiAuto => write!(f, "Semi-Auto"),
31            AutonomyLevel::Auto => write!(f, "Auto"),
32        }
33    }
34}
35
36impl AutonomyLevel {
37    /// Parse from string (case-insensitive).
38    pub fn from_str_loose(s: &str) -> Option<Self> {
39        match s.to_lowercase().as_str() {
40            "manual" => Some(Self::Manual),
41            "semi-auto" | "semiauto" | "semi" => Some(Self::SemiAuto),
42            "auto" | "full" => Some(Self::Auto),
43            _ => None,
44        }
45    }
46}
47
48/// Safe commands that can be auto-approved in Semi-Auto mode.
49///
50/// Shared between TUI and Web approval managers.
51/// Uses prefix matching: `cargo test` matches `cargo test --workspace`.
52pub const SAFE_COMMANDS: &[&str] = &[
53    // ── File inspection & text processing ──
54    "cd",
55    "ls",
56    "cat",
57    "head",
58    "tail",
59    "grep",
60    "find",
61    "wc",
62    "pwd",
63    "echo",
64    "which",
65    "type",
66    "file",
67    "stat",
68    "du",
69    "df",
70    "tree",
71    "diff",
72    "md5sum",
73    "sha256sum",
74    "readlink",
75    "basename",
76    "dirname",
77    "realpath",
78    "sort",
79    "uniq",
80    "cut",
81    "awk",
82    "sed",
83    "tr",
84    "jq",
85    "yq",
86    "column",
87    "hexdump",
88    "xxd",
89    "strings",
90    "nm",
91    "objdump",
92    "ldd",
93    "tar tf",
94    "zip -l",
95    "unzip -l",
96    // ── Git (read-only) ──
97    "git status",
98    "git log",
99    "git diff",
100    "git branch",
101    "git show",
102    "git remote",
103    "git tag",
104    "git stash list",
105    "git blame",
106    "git rev-parse",
107    "git ls-files",
108    "git config --get",
109    "git stash show",
110    "git shortlog",
111    "git describe",
112    // ── Build & test tools ──
113    "cargo check",
114    "cargo build",
115    "cargo test",
116    "cargo clippy",
117    "cargo fmt",
118    "cargo doc",
119    "cargo add",
120    "cargo update",
121    "cargo install",
122    "npm run",
123    "npm test",
124    "npm ci",
125    "npm install",
126    "npm list",
127    "npm outdated",
128    "npx",
129    "yarn install",
130    "yarn list",
131    "pnpm install",
132    "bun install",
133    "make",
134    "cmake",
135    "ninja",
136    "go build",
137    "go test",
138    "go vet",
139    "go get",
140    "go mod tidy",
141    "go mod download",
142    "pip install",
143    "pip list",
144    "pip show",
145    "pip freeze",
146    "pipenv install",
147    "poetry install",
148    "poetry show",
149    "gem install",
150    "gem list",
151    "bundle install",
152    "bundle list",
153    "composer install",
154    "composer show",
155    "brew install",
156    "brew list",
157    "brew info",
158    "bazel build",
159    "bazel test",
160    "gradle build",
161    "gradle test",
162    "mvn compile",
163    "mvn test",
164    "sbt compile",
165    "sbt test",
166    // ── Language runtimes & version checks ──
167    "python --version",
168    "python3 --version",
169    "node --version",
170    "npm --version",
171    "cargo --version",
172    "go version",
173    "ruby --version",
174    "ruby -v",
175    "java --version",
176    "javac --version",
177    "dotnet --version",
178    "php --version",
179    "perl --version",
180    "swift --version",
181    "kotlin -version",
182    "scala -version",
183    "elixir --version",
184    "lua -v",
185    "deno --version",
186    "bun --version",
187    "rustc --version",
188    "rustup show",
189    // ── Linters & formatters ──
190    "eslint",
191    "prettier",
192    "black",
193    "ruff",
194    "flake8",
195    "mypy",
196    "pylint",
197    "rubocop",
198    "gofmt",
199    "golangci-lint",
200    "shellcheck",
201    "tsc",
202    "biome",
203    // ── Testing frameworks ──
204    "pytest",
205    "jest",
206    "vitest",
207    "mocha",
208    "rspec",
209    "phpunit",
210    "dotnet test",
211    "flutter test",
212    // ── CI/CD & containers (read-only) ──
213    "docker ps",
214    "docker images",
215    "docker logs",
216    "docker inspect",
217    "docker compose ps",
218    "kubectl get",
219    "kubectl describe",
220    "kubectl logs",
221    "gh pr list",
222    "gh pr view",
223    "gh issue list",
224    "gh issue view",
225    "gh run list",
226    "gh run view",
227    "terraform plan",
228    "terraform show",
229    // ── System info ──
230    "uname",
231    "env",
232    "printenv",
233    "whoami",
234    "hostname",
235    "date",
236    "uptime",
237    "id",
238    "lsof",
239    "netstat",
240    "ss",
241    "dig",
242    "nslookup",
243    "ping",
244    "traceroute",
245    "ifconfig",
246    "ip addr",
247    "ps",
248    "pgrep",
249    "free",
250    "vmstat",
251    "iostat",
252    "top -l 1",
253    "curl",
254    "wget",
255];
256
257/// Check if a command is considered safe for auto-approval.
258///
259/// Performs shell-aware parsing:
260/// 1. Rejects commands containing dangerous shell constructs (`$(...)`, backticks)
261/// 2. Splits on shell operators (`&&`, `||`, `;`, `|`) and checks **every** segment
262/// 3. For each segment, strips leading env vars (`KEY=val`) and path prefixes (`/usr/bin/git`)
263/// 4. Matches the normalized command against `SAFE_COMMANDS` using prefix matching
264pub fn is_safe_command(command: &str) -> bool {
265    let trimmed = command.trim();
266    if trimmed.is_empty() {
267        return false;
268    }
269
270    // Reject commands with shell injection constructs.
271    if contains_shell_injection(trimmed) {
272        return false;
273    }
274
275    // Split on shell operators and verify ALL segments are safe.
276    let segments = split_shell_segments(trimmed);
277    if segments.is_empty() {
278        return false;
279    }
280    segments.iter().all(|seg| is_segment_safe(seg))
281}
282
283/// Returns true if the command string contains dangerous shell constructs.
284fn contains_shell_injection(cmd: &str) -> bool {
285    // Command substitution: $(...) or `...`
286    if cmd.contains("$(") || cmd.contains('`') {
287        return true;
288    }
289    // Process substitution: <(...) or >(...)
290    if cmd.contains("<(") || cmd.contains(">(") {
291        return true;
292    }
293    // File output redirects (but allow fd redirects like 2>&1)
294    if contains_file_redirect(cmd) {
295        return true;
296    }
297    false
298}
299
300/// Check if command contains a file output redirect (e.g. `> file`, `>> file`).
301/// Allows fd redirects like `2>&1`, `>&2`.
302fn contains_file_redirect(cmd: &str) -> bool {
303    let bytes = cmd.as_bytes();
304    let len = bytes.len();
305    let mut i = 0;
306    while i < len {
307        if bytes[i] == b'\'' {
308            i += 1;
309            while i < len && bytes[i] != b'\'' {
310                i += 1;
311            }
312            i += 1;
313            continue;
314        }
315        if bytes[i] == b'"' {
316            i += 1;
317            while i < len {
318                if bytes[i] == b'\\' && i + 1 < len {
319                    i += 2;
320                    continue;
321                }
322                if bytes[i] == b'"' {
323                    break;
324                }
325                i += 1;
326            }
327            i += 1;
328            continue;
329        }
330        if bytes[i] == b'>' {
331            if i + 1 < len && bytes[i + 1] == b'&' {
332                i += 2;
333                continue;
334            }
335            if i > 0 && bytes[i - 1].is_ascii_digit() && i + 1 < len && bytes[i + 1] == b'&' {
336                i += 2;
337                continue;
338            }
339            return true;
340        }
341        i += 1;
342    }
343    false
344}
345
346/// Split a command string on shell operators: `&&`, `||`, `;`, `|`.
347fn split_shell_segments(cmd: &str) -> Vec<&str> {
348    let mut segments = Vec::new();
349    let mut start = 0;
350    let bytes = cmd.as_bytes();
351    let len = bytes.len();
352    let mut i = 0;
353
354    while i < len {
355        if bytes[i] == b'\'' {
356            i += 1;
357            while i < len && bytes[i] != b'\'' {
358                i += 1;
359            }
360            i += 1;
361            continue;
362        }
363        if bytes[i] == b'"' {
364            i += 1;
365            while i < len {
366                if bytes[i] == b'\\' && i + 1 < len {
367                    i += 2;
368                    continue;
369                }
370                if bytes[i] == b'"' {
371                    break;
372                }
373                i += 1;
374            }
375            i += 1;
376            continue;
377        }
378
379        if i + 1 < len
380            && ((bytes[i] == b'&' && bytes[i + 1] == b'&')
381                || (bytes[i] == b'|' && bytes[i + 1] == b'|'))
382        {
383            let seg = cmd[start..i].trim();
384            if !seg.is_empty() {
385                segments.push(seg);
386            }
387            i += 2;
388            start = i;
389            continue;
390        }
391        if bytes[i] == b';' || (bytes[i] == b'|' && (i + 1 >= len || bytes[i + 1] != b'|')) {
392            let seg = cmd[start..i].trim();
393            if !seg.is_empty() {
394                segments.push(seg);
395            }
396            i += 1;
397            start = i;
398            continue;
399        }
400        i += 1;
401    }
402
403    let seg = cmd[start..].trim();
404    if !seg.is_empty() {
405        segments.push(seg);
406    }
407    segments
408}
409
410/// Check if a single command segment (no shell operators) is safe.
411fn is_segment_safe(segment: &str) -> bool {
412    let normalized = normalize_segment(segment);
413    if normalized.is_empty() {
414        return false;
415    }
416    let cmd_lower = normalized.to_lowercase();
417    SAFE_COMMANDS.iter().any(|safe| {
418        let safe_lower = safe.to_lowercase();
419        cmd_lower == safe_lower || cmd_lower.starts_with(&format!("{safe_lower} "))
420    })
421}
422
423/// Normalize a command segment by stripping leading env vars and path prefixes.
424fn normalize_segment(segment: &str) -> String {
425    let mut parts: Vec<&str> = segment.split_whitespace().collect();
426    if parts.is_empty() {
427        return String::new();
428    }
429
430    while !parts.is_empty() && is_env_assignment(parts[0]) {
431        parts.remove(0);
432    }
433    if parts.is_empty() {
434        return String::new();
435    }
436
437    if let Some(basename) = parts[0].rsplit('/').next()
438        && !basename.is_empty()
439    {
440        parts[0] = basename;
441    }
442
443    parts.join(" ")
444}
445
446/// Check if a token looks like a shell env var assignment: `KEY=VALUE`.
447fn is_env_assignment(token: &str) -> bool {
448    if let Some(eq_pos) = token.find('=') {
449        if eq_pos == 0 {
450            return false;
451        }
452        let name = &token[..eq_pos];
453        let mut chars = name.chars();
454        if let Some(first) = chars.next()
455            && (first.is_ascii_alphabetic() || first == '_')
456            && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
457        {
458            return true;
459        }
460    }
461    false
462}
463
464/// Tools that use subcommands — matching should include the subcommand.
465const MULTI_WORD_TOOLS: &[&str] = &[
466    "cargo",
467    "git",
468    "npm",
469    "yarn",
470    "pnpm",
471    "go",
472    "pip",
473    "pipenv",
474    "poetry",
475    "gem",
476    "bundle",
477    "composer",
478    "brew",
479    "bazel",
480    "gradle",
481    "mvn",
482    "sbt",
483    "docker",
484    "kubectl",
485    "gh",
486    "terraform",
487    "dotnet",
488    "flutter",
489];
490
491/// Extract a command prefix for auto-approval patterns.
492///
493/// For multi-word tools (e.g. `cargo test`, `git status`), returns the
494/// first two tokens. For single-word tools (e.g. `eslint`), returns one.
495/// Strips leading env var assignments and path prefixes.
496pub fn extract_command_prefix(command: &str) -> String {
497    let parts: Vec<&str> = command.split_whitespace().collect();
498    if parts.is_empty() {
499        return String::new();
500    }
501
502    let mut start = 0;
503    while start < parts.len() && is_env_assignment(parts[start]) {
504        start += 1;
505    }
506
507    if start >= parts.len() {
508        return String::new();
509    }
510
511    let binary = parts[start].rsplit('/').next().unwrap_or(parts[start]);
512
513    let bin_lower = binary.to_lowercase();
514    if MULTI_WORD_TOOLS.contains(&bin_lower.as_str())
515        && start + 1 < parts.len()
516        && !parts[start + 1].starts_with('-')
517    {
518        return format!("{} {}", binary, parts[start + 1]);
519    }
520
521    binary.to_string()
522}
523
524#[cfg(test)]
525#[path = "constants_tests.rs"]
526mod tests;