Skip to main content

j_agent/util/
shell_safety.rs

1/// 检查命令是否属于危险操作(rm -rf /、mkfs、dd 等),用于 Shell 安全审核
2pub fn is_dangerous_command(cmd: &str) -> bool {
3    let cmd_lower = cmd.to_lowercase();
4    let tokens = shell_words(&cmd_lower);
5
6    if tokens.is_empty() {
7        return false;
8    }
9
10    let first = &tokens[0];
11
12    if first.starts_with("mkfs") || first.starts_with("mkfs.") {
13        return true;
14    }
15
16    if first == "dd"
17        && tokens
18            .iter()
19            .any(|t| t.starts_with("of=/dev/") && !t.starts_with("of=/dev/null"))
20    {
21        return true;
22    }
23
24    if cmd_lower.contains(":(){:|:&};:") || cmd_lower.contains(":(){ :|:& };:") {
25        return true;
26    }
27
28    if first == "chmod" {
29        let has_recursive = tokens.iter().any(|t| t == "-r" || t == "-R");
30        if has_recursive && cmd_lower.contains("777") && tokens.last().is_some_and(|t| t == "/") {
31            return true;
32        }
33    }
34
35    if first == "chown" && cmd_lower.contains("-r") && tokens.last().is_some_and(|t| t == "/") {
36        return true;
37    }
38
39    if tokens.iter().any(|t| t == ">" || t == ">>")
40        && tokens.iter().any(|t| {
41            t.starts_with("/dev/sd") || t.starts_with("/dev/nvme") || t.starts_with("/dev/disk")
42        })
43    {
44        return true;
45    }
46
47    if (first == "curl" || first == "wget")
48        && (cmd_lower.contains("| sh")
49            || cmd_lower.contains("| bash")
50            || cmd_lower.contains("| zsh"))
51    {
52        return true;
53    }
54
55    if first == "alias" && tokens.len() == 1 {
56        return true;
57    }
58
59    if first == "rm" {
60        let has_recursive = tokens.iter().any(|t| {
61            t == "-r" || t == "-rf" || t == "-fr" || t.starts_with("-r") || t.starts_with("-f")
62        });
63        let targets_root = tokens.iter().any(|t| t == "/" || t == "/*");
64        if has_recursive && targets_root {
65            return true;
66        }
67    }
68
69    false
70}
71
72/// 检查命令是否为阻塞式交互命令(vim、top、less 等),返回匹配到的命令名
73pub fn check_blocking_command(cmd: &str) -> Option<&'static str> {
74    let cmd_trimmed = cmd.trim();
75
76    // 优先检测"长运行服务 + &"模式:在 shell 中用 & 后台化的服务命令
77    // 应该使用 run_in_background: true 而非 shell 内部 &
78    if let Some(msg) = check_background_service(cmd_trimmed) {
79        return Some(msg);
80    }
81
82    let segments = split_command_segments(cmd_trimmed);
83
84    for segment in &segments {
85        if let Some(msg) = check_single_segment(segment) {
86            return Some(msg);
87        }
88    }
89    None
90}
91
92fn split_command_segments(cmd: &str) -> Vec<&str> {
93    let mut segments = Vec::new();
94    let mut start = 0;
95    let mut in_single = false;
96    let mut in_double = false;
97
98    for (i, c) in cmd.char_indices() {
99        match c {
100            '\'' if !in_double => in_single = !in_single,
101            '"' if !in_single => in_double = !in_double,
102            ';' if !in_single && !in_double => {
103                let seg = cmd[start..i].trim();
104                if !seg.is_empty() {
105                    segments.push(seg);
106                }
107                start = i + ';'.len_utf8();
108            }
109            '&' if !in_single && !in_double => {
110                let rest = &cmd[i + '&'.len_utf8()..];
111                if rest.starts_with('&') {
112                    // && 逻辑 AND 分隔符
113                    let seg = cmd[start..i].trim();
114                    if !seg.is_empty() {
115                        segments.push(seg);
116                    }
117                    start = i + "&&".len();
118                } else if !cmd[..i].ends_with('&') && !cmd[..i].ends_with('>') {
119                    // 单个 & 后台运行分隔符(排除 && 的第二个 &,以及重定向 >&)
120                    let seg = cmd[start..i].trim();
121                    if !seg.is_empty() {
122                        segments.push(seg);
123                    }
124                    start = i + '&'.len_utf8();
125                }
126            }
127            '|' if !in_single && !in_double => {
128                let rest = &cmd[i + '|'.len_utf8()..];
129                if rest.starts_with('|') {
130                    let seg = cmd[start..i].trim();
131                    if !seg.is_empty() {
132                        segments.push(seg);
133                    }
134                    start = i + "||".len();
135                }
136            }
137            _ => {}
138        }
139    }
140    let last = cmd[start..].trim();
141    if !last.is_empty() {
142        segments.push(last);
143    }
144    if segments.is_empty() {
145        segments.push(cmd);
146    }
147    segments
148}
149
150/// 检测"长运行服务命令 + shell & 后台化"模式。
151/// 当用户用 `cmd &` 在 shell 中后台化一个服务进程时,
152/// 应该使用 `run_in_background: true` 让工具层面管理,而非依赖 shell 的 &。
153fn check_background_service(cmd: &str) -> Option<&'static str> {
154    // 需要命令中包含独立的 &(非 &&)后台运行符号
155    if !contains_background_ampersand(cmd) {
156        return None;
157    }
158
159    // 将命令按 & 分割,检查每个后台段是否包含长运行服务命令
160    let bg_segments = split_at_background(cmd);
161    for segment in &bg_segments {
162        // 后台段可能包含 && 或 ; 连接的多条命令,需进一步拆分
163        let sub_segments = split_command_segments(segment);
164        for sub in &sub_segments {
165            let first_cmd = split_at_pipe(sub);
166            let tokens = shell_words(first_cmd);
167            if tokens.is_empty() {
168                continue;
169            }
170            let first = tokens[0].as_str();
171            if is_long_running_server(first, &tokens) {
172                return Some(
173                    "检测到后台启动长运行服务(shell &)。请设置 run_in_background: true \
174                     来启动服务,然后通过单独的 Shell 调用执行健康检查等操作",
175                );
176            }
177        }
178    }
179
180    None
181}
182
183/// 判断命令是否可能是长运行的服务/服务器进程
184fn is_long_running_server(first: &str, tokens: &[String]) -> bool {
185    // 直接的 server 命令
186    if matches!(
187        first,
188        "nginx"
189            | "apache2"
190            | "httpd"
191            | "redis-server"
192            | "redis-cli"
193            | "mongod"
194            | "mysqld"
195            | "postgres"
196            | "pg_ctl"
197            | "elasticsearch"
198            | "rabbitmq-server"
199            | "consul"
200            | "etcd"
201            | "vault"
202            | "minio"
203    ) {
204        return true;
205    }
206
207    // go run / go serve / air(Go 热重载)
208    if first == "go" && tokens.iter().skip(1).any(|t| t == "run" || t == "serve") {
209        return true;
210    }
211
212    // air(Go 热重载工具)
213    if first == "air" {
214        return true;
215    }
216
217    // node/npx 运行服务器
218    if first == "node" || first == "npx" {
219        // node server.js / node app.js 等
220        if tokens.iter().skip(1).any(|t| !t.starts_with('-')) {
221            return true;
222        }
223    }
224
225    // npm/yarn/pnpm/bun run dev/start/serve
226    if matches!(first, "npm" | "yarn" | "pnpm" | "bun")
227        && tokens
228            .iter()
229            .skip(1)
230            .any(|t| t == "run" || t == "start" || t == "serve" || t == "dev")
231    {
232        return true;
233    }
234
235    // python -m http.server / python manage.py runserver / uvicorn / gunicorn
236    if matches!(first, "python" | "python3")
237        && tokens
238            .iter()
239            .skip(1)
240            .any(|t| t == "runserver" || t == "http.server" || t == "uvicorn" || t == "gunicorn")
241    {
242        return true;
243    }
244    if matches!(
245        first,
246        "uvicorn" | "gunicorn" | "flask" | "django-admin" | "celery"
247    ) {
248        return true;
249    }
250
251    // java -jar xxx.jar / mvn spring-boot:run / gradle bootRun
252    if first == "java" {
253        // java -jar app.jar 基本就是启动服务
254        if tokens.iter().any(|t| t == "-jar") {
255            return true;
256        }
257    }
258    // mvn spring-boot:run / gradle bootRun
259    if (first == "mvn" || first == "./mvnw")
260        && tokens
261            .iter()
262            .skip(1)
263            .any(|t| t.contains("spring-boot") || t == "tomcat:run" || t == "jetty:run")
264    {
265        return true;
266    }
267    if (first == "gradle" || first == "./gradlew")
268        && tokens
269            .iter()
270            .skip(1)
271            .any(|t| t == "bootRun" || t.contains("tomcat") || t.contains("jetty"))
272    {
273        return true;
274    }
275
276    // dotnet run/watch
277    if first == "dotnet"
278        && tokens
279            .iter()
280            .skip(1)
281            .any(|t| t == "run" || t == "watch" || t == "serve")
282    {
283        return true;
284    }
285
286    // cargo watch(Rust 热重载)
287    if first == "cargo" && tokens.iter().skip(1).any(|t| t == "watch") {
288        return true;
289    }
290
291    // docker compose up
292    if first == "docker" && tokens.iter().skip(1).any(|t| t == "compose" || t == "up") {
293        return true;
294    }
295    if first == "docker-compose" {
296        return true;
297    }
298
299    // podman compose up
300    if first == "podman" && tokens.iter().skip(1).any(|t| t == "compose") {
301        return true;
302    }
303    if first == "podman-compose" {
304        return true;
305    }
306
307    // ruby rails server
308    if first == "rails" && tokens.iter().skip(1).any(|t| t == "server" || t == "s") {
309        return true;
310    }
311    if first == "bundle"
312        && tokens
313            .iter()
314            .skip(1)
315            .any(|t| t == "exec" && tokens.iter().any(|t2| t2 == "rails"))
316    {
317        return true;
318    }
319
320    // php artisan serve
321    if first == "php"
322        && tokens
323            .iter()
324            .skip(1)
325            .any(|t| t == "artisan" || t == "serve")
326    {
327        return true;
328    }
329
330    false
331}
332
333/// 检查命令中是否包含独立的后台运行符号 &(非 &&,非重定向 >&)
334fn contains_background_ampersand(cmd: &str) -> bool {
335    let mut in_single = false;
336    let mut in_double = false;
337    let chars: Vec<char> = cmd.chars().collect();
338
339    for i in 0..chars.len() {
340        match chars[i] {
341            '\'' if !in_double => in_single = !in_single,
342            '"' if !in_single => in_double = !in_double,
343            '\\' if !in_single && !in_double => {
344                // 跳过转义字符
345                continue;
346            }
347            '&' if !in_single && !in_double => {
348                // 检查后面不是 &(排除 &&)
349                let next_is_amp = chars.get(i + 1) == Some(&'&');
350                // 检查前面不是 &(排除被 && 的第二个 &)
351                let prev_is_amp = i > 0 && chars[i - 1] == '&';
352                // 检查前面不是 >(排除重定向 >& 或 2>&1)
353                let prev_is_redirect = i > 0 && chars[i - 1] == '>';
354                if !next_is_amp && !prev_is_amp && !prev_is_redirect {
355                    return true;
356                }
357            }
358            _ => {}
359        }
360    }
361    false
362}
363
364/// 按独立 &(非 &&,非重定向 >&)分割命令,返回被后台化的命令段
365fn split_at_background(cmd: &str) -> Vec<&str> {
366    let mut segments = Vec::new();
367    let mut start = 0;
368    let mut in_single = false;
369    let mut in_double = false;
370    let chars: Vec<char> = cmd.chars().collect();
371
372    let mut i = 0;
373    while i < chars.len() {
374        match chars[i] {
375            '\'' if !in_double => in_single = !in_single,
376            '"' if !in_single => in_double = !in_double,
377            '\\' if !in_single && !in_double => {
378                // 跳过转义字符
379                i += 1;
380                continue;
381            }
382            '&' if !in_single && !in_double => {
383                let next_is_amp = chars.get(i + 1) == Some(&'&');
384                let prev_is_amp = i > 0 && chars[i - 1] == '&';
385                let prev_is_redirect = i > 0 && chars[i - 1] == '>';
386                if !next_is_amp && !prev_is_amp && !prev_is_redirect {
387                    // 独立的 &,取前面部分作为一个后台段
388                    let seg = cmd[start..i].trim();
389                    if !seg.is_empty() {
390                        segments.push(seg);
391                    }
392                    start = i + 1;
393                }
394            }
395            _ => {}
396        }
397        i += 1;
398    }
399    segments
400}
401
402fn check_single_segment(segment: &str) -> Option<&'static str> {
403    let first_cmd = split_at_pipe(segment);
404    let tokens = shell_words(first_cmd);
405    if tokens.is_empty() {
406        return None;
407    }
408
409    let first = tokens[0].as_str();
410
411    if first == "ssh" {
412        let non_flag_args: Vec<&String> = tokens
413            .iter()
414            .skip(1)
415            .filter(|t| !t.starts_with('-'))
416            .collect();
417        if non_flag_args.len() >= 2 {
418            return None;
419        }
420        return Some(
421            "SSH 是交互式会话,不支持前台运行。如需远程执行命令,请用 ssh host 'command' 形式并设置 run_in_background: true",
422        );
423    }
424    if first == "telnet" || first == "mosh" {
425        return Some(
426            "telnet/mosh 是交互式会话,不支持前台运行。如需远程执行命令,请用 ssh host 'command' 形式并设置 run_in_background: true",
427        );
428    }
429
430    if matches!(first, "vim" | "vi" | "nano" | "emacs" | "micro" | "pico") {
431        return Some(
432            "交互式编辑器不支持前台运行。请使用 Edit/Write 工具编辑文件,或使用 sed 进行文本替换",
433        );
434    }
435    if first == "code" {
436        let has_non_interactive_flag = tokens.iter().skip(1).any(|t| {
437            t.starts_with("--diff")
438                || t.starts_with("--version")
439                || t.starts_with("--list-extensions")
440                || t.starts_with("--install-extension")
441                || t.starts_with("--uninstall-extension")
442        });
443        if !has_non_interactive_flag {
444            return Some(
445                "交互式编辑器不支持前台运行。请使用 Edit/Write 工具编辑文件,或使用 sed 进行文本替换",
446            );
447        }
448        return None;
449    }
450
451    if matches!(first, "less" | "more" | "most") {
452        return Some(
453            "分页器不支持前台运行。请直接运行命令(输出会自动捕获),或使用 Read 工具查看文件",
454        );
455    }
456
457    if matches!(first, "ipython" | "pry" | "groovysh") {
458        return Some(
459            "交互式 REPL 不支持前台运行。请用 -c 参数执行单条命令,或设置 run_in_background: true",
460        );
461    }
462    if matches!(first, "python" | "python3" | "python2") {
463        let has_script = tokens
464            .iter()
465            .skip(1)
466            .any(|t| t == "-c" || t == "-m" || !t.starts_with('-'));
467        if !has_script {
468            return Some(
469                "交互式 Python REPL 不支持前台运行。请用 -c 参数执行单条命令(如 python3 -c 'code'),或设置 run_in_background: true",
470            );
471        }
472        return None;
473    }
474    if first == "node" {
475        let has_script = tokens
476            .iter()
477            .skip(1)
478            .any(|t| t == "-e" || t == "--eval" || !t.starts_with('-'));
479        if !has_script {
480            return Some(
481                "交互式 Node REPL 不支持前台运行。请用 -e 参数执行单条命令(如 node -e 'code'),或设置 run_in_background: true",
482            );
483        }
484        return None;
485    }
486    if first == "irb" {
487        return Some(
488            "交互式 Ruby REPL 不支持前台运行。请用 ruby -e 'code' 执行单条命令,或设置 run_in_background: true",
489        );
490    }
491    if first == "lua" {
492        let has_script = tokens
493            .iter()
494            .skip(1)
495            .any(|t| t == "-e" || !t.starts_with('-'));
496        if !has_script {
497            return Some(
498                "交互式 Lua REPL 不支持前台运行。请用 -e 参数执行单条命令,或设置 run_in_background: true",
499            );
500        }
501        return None;
502    }
503    if first == "php" {
504        if tokens
505            .iter()
506            .skip(1)
507            .any(|t| t == "-a" || t == "--interactive")
508        {
509            return Some(
510                "交互式 PHP REPL 不支持前台运行。请用 -r 参数执行单条命令,或设置 run_in_background: true",
511            );
512        }
513        return None;
514    }
515    if first == "r" || first == "R" {
516        if tokens.len() > 1 && (tokens[1] == "CMD" || tokens[1] == "cmd") {
517            return None;
518        }
519        return Some(
520            "交互式 R 不支持前台运行。请用 R CMD batch 或 Rscript 运行脚本,或设置 run_in_background: true",
521        );
522    }
523    if first == "scala" {
524        let has_script = tokens
525            .iter()
526            .skip(1)
527            .any(|t| t == "-e" || !t.starts_with('-'));
528        if !has_script {
529            return Some(
530                "交互式 Scala REPL 不支持前台运行。请用 -e 参数执行单条命令,或设置 run_in_background: true",
531            );
532        }
533        return None;
534    }
535
536    if matches!(first, "top" | "htop" | "btop" | "glances") {
537        return Some(
538            "持续监控命令不支持前台运行。请用单次快照方式执行(如 ps aux),或设置 run_in_background: true",
539        );
540    }
541    if first == "watch" {
542        return Some(
543            "watch 持续刷新不支持前台运行。请直接执行命令获取单次输出,或设置 run_in_background: true",
544        );
545    }
546
547    if matches!(first, "gdb" | "lldb" | "pdb") {
548        if first == "gdb" && tokens.iter().any(|t| t == "--batch" || t == "-batch") {
549            return None;
550        }
551        if first == "lldb"
552            && tokens
553                .iter()
554                .any(|t| t == "--batch" || t == "-batch" || t == "-o")
555        {
556            return None;
557        }
558        return Some(
559            "调试器不支持前台运行。请使用 --batch 非交互模式,或设置 run_in_background: true",
560        );
561    }
562    if matches!(first, "strace" | "ltrace") {
563        return None;
564    }
565
566    if matches!(first, "apt" | "apt-get" | "yum" | "dnf" | "pacman") {
567        let has_yes = tokens
568            .iter()
569            .any(|t| t == "-y" || t == "--yes" || t == "--assumeyes" || t == "--noconfirm");
570        if !has_yes {
571            return Some(
572                "包管理器通常需要交互确认。请加 -y/--yes 标志(如 apt-get install -y pkg),或设置 run_in_background: true",
573            );
574        }
575        return None;
576    }
577    if first == "brew" {
578        return None;
579    }
580
581    if first == "docker" {
582        let has_it = tokens
583            .iter()
584            .any(|t| t == "-it" || t == "-ti" || t == "-i" || t == "--interactive");
585        if has_it {
586            let subcmd = tokens.get(1).map(|s| s.as_str()).unwrap_or("");
587            if matches!(subcmd, "run" | "exec") {
588                return Some(
589                    "交互式 Docker 命令不支持前台运行。请去掉 -i/-t 标志,或设置 run_in_background: true",
590                );
591            }
592        }
593        return None;
594    }
595
596    None
597}
598
599fn split_at_pipe(segment: &str) -> &str {
600    let mut in_single = false;
601    let mut in_double = false;
602    for (i, c) in segment.char_indices() {
603        match c {
604            '\'' if !in_double => in_single = !in_single,
605            '"' if !in_single => in_double = !in_double,
606            '|' if !in_single && !in_double => return segment[..i].trim(),
607            _ => {}
608        }
609    }
610    segment.trim()
611}
612
613/// 类 sh 单词拆分:处理单引号、双引号和反斜杠转义,返回拆分后的参数列表
614pub fn shell_words(input: &str) -> Vec<String> {
615    let mut words = Vec::new();
616    let mut current = String::new();
617    let mut in_single = false;
618    let mut in_double = false;
619
620    for c in input.chars() {
621        match c {
622            '\'' if !in_double => {
623                in_single = !in_single;
624            }
625            '"' if !in_single => {
626                in_double = !in_double;
627            }
628            ' ' | '\t' if !in_single && !in_double => {
629                if !current.is_empty() {
630                    words.push(std::mem::take(&mut current));
631                }
632            }
633            _ => {
634                current.push(c);
635            }
636        }
637    }
638    if !current.is_empty() {
639        words.push(current);
640    }
641    words
642}
643
644#[cfg(test)]
645mod tests;