j_agent/util/
shell_safety.rs1pub 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
72pub fn check_blocking_command(cmd: &str) -> Option<&'static str> {
74 let cmd_trimmed = cmd.trim();
75
76 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 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 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
150fn check_background_service(cmd: &str) -> Option<&'static str> {
154 if !contains_background_ampersand(cmd) {
156 return None;
157 }
158
159 let bg_segments = split_at_background(cmd);
161 for segment in &bg_segments {
162 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
183fn is_long_running_server(first: &str, tokens: &[String]) -> bool {
185 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 if first == "go" && tokens.iter().skip(1).any(|t| t == "run" || t == "serve") {
209 return true;
210 }
211
212 if first == "air" {
214 return true;
215 }
216
217 if first == "node" || first == "npx" {
219 if tokens.iter().skip(1).any(|t| !t.starts_with('-')) {
221 return true;
222 }
223 }
224
225 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 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 if first == "java" {
253 if tokens.iter().any(|t| t == "-jar") {
255 return true;
256 }
257 }
258 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 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 if first == "cargo" && tokens.iter().skip(1).any(|t| t == "watch") {
288 return true;
289 }
290
291 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 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 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 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
333fn 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 continue;
346 }
347 '&' if !in_single && !in_double => {
348 let next_is_amp = chars.get(i + 1) == Some(&'&');
350 let prev_is_amp = i > 0 && chars[i - 1] == '&';
352 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
364fn 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 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 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
613pub 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;