@yottameta/yotta-guardian 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/SKILL.md +1 -1
- package/install.sh +24 -5
- package/package.json +1 -1
- package/references/rules.md +1 -1
- package/scripts/yotta_guardian.py +259 -4
- package/skill-manifest.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## v0.1.6 (2026-09-25)
|
|
4
|
+
|
|
5
|
+
安全修复:复合命令拆分,堵住分隔符后的危险段绕过。
|
|
6
|
+
|
|
7
|
+
- 背景:v0.1.5 只解决了包装命令(`sh -c` / `sudo` / `env` …)绕过,整条命令仍只按第一个词做 argv 分析——`echo ok && rm -rf /`、`ls; rm -rf /`、`true || rm -rf /` 里的危险段藏在分隔符之后,整条命令被判放行(本轮复现)。
|
|
8
|
+
- 修复:新增受控复合命令拆分(`;` `&&` `||` `|` `&` 换行,引号 / 转义内不拆)+ 命令替换展开(`$(...)` / 反引号),逐段跑同一套文本 / argv 规则;放行规则只对单一简单命令生效;引号未闭合时出 `CMD-UNPARSED`(medium,fail-closed)。
|
|
9
|
+
- 安装器加固:拒绝对符号链接目标写入、目标不是目录时拒绝、不做整目录删除。
|
|
10
|
+
- 回归:新增 11 项复合命令用例,测试 83/83 通过。
|
|
11
|
+
|
|
12
|
+
## v0.1.5 (2026-09-19)
|
|
13
|
+
|
|
14
|
+
安全修复:包装命令解包,堵住 argv 级分析的绕过路径。
|
|
15
|
+
|
|
16
|
+
- 背景:此前 argv 级规则只看命令第一个词(`argv[0]`)。`bash -c "…"`、`cmd /c "…"`、
|
|
17
|
+
`powershell -Command "…"`、`sudo …`、`env …`、`nohup …`、`timeout 5 …` 这类包装会把真正的
|
|
18
|
+
动词藏在内层,导致包装后的危险命令被判为放行(例:裸 `rm -rf /` 拒绝,`bash -c "rm -rf /"` 却放行)。
|
|
19
|
+
- 修复:新增受控解包层(`_unwrap_argv` / `_shell_inner` / `_strip_wrapper`)——
|
|
20
|
+
识别 shell(sh/bash/zsh/dash/ksh/ash/busybox/cmd/powershell/pwsh)、提权与环境包装
|
|
21
|
+
(sudo/doas/env/nohup/setsid/stdbuf/nice/ionice/time/xargs/timeout/command/builtin/exec/chroot),
|
|
22
|
+
剥离包装自身的选项与参数后,对内层命令跑同一套 argv 规则;解包深度上限 3 层,可嵌套(如 `sudo sh -c "…"`)。
|
|
23
|
+
- 边界:解包只影响 argv 级动词分析,文本级规则与放行模式行为不变;解包失败时不产生新命中,
|
|
24
|
+
保持既有判定(不引入误报放大)。
|
|
25
|
+
- 验证:测试 72/72(新增 `WrapperBypassTest` 12 项:9 项阻断回归 + 3 项安全侧不误报)。
|
|
26
|
+
|
|
3
27
|
## v0.1.4 (2026-09-19)
|
|
4
28
|
|
|
5
29
|
版本对齐维护:修复历史发布元数据与包内版本不一致;无功能变更。
|
package/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yotta-guardian
|
|
3
|
-
version: 0.1.
|
|
3
|
+
version: 0.1.6
|
|
4
4
|
description: 元盾 —— 跨智能体的危险调用拦截护栏:确定性规则引擎 + 可插拔意图验证(不绑模型),拦截危险 exec / write / edit / read / run / shell 工具调用,提供审计日志。触发:代理要执行高风险命令(递归删除、磁盘格式化、提权、防火墙改动、反向 shell、下载即执行等)、要写入系统敏感路径或修改系统配置、要在执行危险操作前做安全检查、或用户说 护栏/拦截/危险操作/安全检查 等。边界:默认只读评估,不自动执行也不放行危险操作;不替代用户决策;不隐藏审计记录;规则可配置。
|
|
5
5
|
license: MIT
|
|
6
6
|
---
|
package/install.sh
CHANGED
|
@@ -61,10 +61,29 @@ resolve_user() {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
install_to() {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
local base="$1"
|
|
65
|
+
local dest
|
|
66
|
+
case "$base" in
|
|
67
|
+
""|"/") echo "安装失败:拒绝不安全的目标目录:'$base'" >&2; return 1 ;;
|
|
68
|
+
esac
|
|
69
|
+
if [ -L "$base" ]; then
|
|
70
|
+
echo "安装失败:目标目录是符号链接,拒绝跟随:$base" >&2; return 1
|
|
71
|
+
fi
|
|
72
|
+
mkdir -p "$base"
|
|
73
|
+
dest="$base/$SKILL_NAME"
|
|
74
|
+
if [ -L "$dest" ]; then
|
|
75
|
+
echo "安装失败:技能目录是符号链接,拒绝跟随:$dest" >&2; return 1
|
|
76
|
+
fi
|
|
77
|
+
if [ -e "$dest" ] && [ ! -d "$dest" ]; then
|
|
78
|
+
echo "安装失败:技能路径已存在且不是目录:$dest" >&2; return 1
|
|
79
|
+
fi
|
|
80
|
+
mkdir -p "$dest"
|
|
81
|
+
cp -RP "$SOURCE_DIR/." "$dest/"
|
|
82
|
+
# 只清理副本内部的开发残留(固定子路径),不做整目录删除、不跟随符号链接
|
|
83
|
+
if [ -d "$dest/.git" ] && [ ! -L "$dest/.git" ]; then rm -rf "$dest/.git"; fi
|
|
84
|
+
find "$dest" -type d -name '__pycache__' -prune -exec rm -rf {} + 2>/dev/null || true
|
|
85
|
+
find "$dest" -type f -name '*.pyc' -delete 2>/dev/null || true
|
|
86
|
+
echo "installed -> $dest"
|
|
68
87
|
}
|
|
69
88
|
|
|
70
89
|
list() {
|
|
@@ -129,4 +148,4 @@ main() {
|
|
|
129
148
|
fi
|
|
130
149
|
}
|
|
131
150
|
|
|
132
|
-
main "$@"
|
|
151
|
+
main "$@"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yottameta/yotta-guardian",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Yuandun (元盾) — cross-agent dangerous tool-call guardrail: a deterministic rule engine + pluggable intent verifier (model-agnostic) that evaluates exec/write/edit/read/run/shell calls and provides audit logs. Triggers when an agent is about to run a high-risk command, write sensitive paths, or change system config. Boundaries: read-only evaluation by default, configurable allowances.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
package/references/rules.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
| 层 | 作用于 | 说明 |
|
|
9
9
|
|---|---|---|
|
|
10
10
|
| 文本模式 | exec 命令 / write 内容 | 正则匹配危险形态(下载即执行、编码执行、反向 shell、向系统文件追加内容等) |
|
|
11
|
-
| argv 级 | exec / run / shell |
|
|
11
|
+
| argv 级 | exec / run / shell | 先做包装解包(shell `-c` / `cmd /c` / `powershell -Command` / sudo / env / nohup / timeout 等,深度上限 3),再按「动词 + 标志 + 目标」分析(递归删除、格式化设备、提权、防火墙、服务、持久化等) |
|
|
12
12
|
| 敏感路径 | write / edit / read | 目标路径规范化后按系统核心 / 用户敏感 / 越界分级 |
|
|
13
13
|
| 放行规则 | 命令 / 路径 | 命中则高危可降级为警告(critical 不受影响) |
|
|
14
14
|
|
|
@@ -51,7 +51,7 @@ _HERE = Path(__file__).resolve().parent
|
|
|
51
51
|
sys.path.insert(0, str(_HERE))
|
|
52
52
|
import guardian_rules as GR # noqa: E402
|
|
53
53
|
|
|
54
|
-
VERSION = "0.1.
|
|
54
|
+
VERSION = "0.1.6"
|
|
55
55
|
TOOL_NAME = "yotta-guardian"
|
|
56
56
|
TOOL_CN = "元盾"
|
|
57
57
|
|
|
@@ -279,9 +279,18 @@ class RuleEngine:
|
|
|
279
279
|
def _argv_findings(self, call):
|
|
280
280
|
if call.tool not in ("exec", "run", "shell"):
|
|
281
281
|
return []
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
282
|
+
findings = []
|
|
283
|
+
# v0.1.6:先拆复合命令(; && || | & 换行)与命令替换,再逐段解包 +
|
|
284
|
+
# 跑同一套 argv 规则;v0.1.5 的包装解包对每段继续生效。
|
|
285
|
+
for seg in _command_segments(call.cmd):
|
|
286
|
+
argv = _tokenize(seg)
|
|
287
|
+
if not argv:
|
|
288
|
+
continue
|
|
289
|
+
for sub in _unwrap_argv(argv):
|
|
290
|
+
findings += self._argv_findings_one(sub)
|
|
291
|
+
return findings
|
|
292
|
+
|
|
293
|
+
def _argv_findings_one(self, argv):
|
|
285
294
|
verb = os.path.basename(argv[0]).lower()
|
|
286
295
|
findings = []
|
|
287
296
|
if verb in _RM_VERBS:
|
|
@@ -318,6 +327,12 @@ class RuleEngine:
|
|
|
318
327
|
|
|
319
328
|
def _allow_hit(self, call):
|
|
320
329
|
if call.tool in ("exec", "run", "shell"):
|
|
330
|
+
# v0.1.6:放行规则只覆盖「单一简单命令」。复合命令或命令替换不允许被
|
|
331
|
+
# 整条放行——否则 `echo ok && <危险命令>` 会借放行规则绕过风险判定。
|
|
332
|
+
if (len(_split_compound(call.cmd)) > 1
|
|
333
|
+
or _extract_substitutions(call.cmd)
|
|
334
|
+
or _scan_quotes(call.cmd)):
|
|
335
|
+
return False
|
|
321
336
|
for rx in self.allow_text + self.allow_patterns:
|
|
322
337
|
if rx.search(call.cmd):
|
|
323
338
|
return True
|
|
@@ -335,6 +350,14 @@ class RuleEngine:
|
|
|
335
350
|
findings = []
|
|
336
351
|
if call.tool in ("exec", "run", "shell"):
|
|
337
352
|
findings += self._text_findings(call.cmd, "command")
|
|
353
|
+
if _scan_quotes(call.cmd):
|
|
354
|
+
findings.append(Finding("CMD-UNPARSED", "medium", "command",
|
|
355
|
+
"命令引号未闭合,无法可靠拆分,按未知风险处理",
|
|
356
|
+
80))
|
|
357
|
+
segments = _command_segments(call.cmd)
|
|
358
|
+
if len(segments) > 1:
|
|
359
|
+
for seg in segments:
|
|
360
|
+
findings += self._text_findings(seg, "command")
|
|
338
361
|
findings += self._argv_findings(call)
|
|
339
362
|
elif call.tool in ("write", "edit"):
|
|
340
363
|
findings += self._path_findings(call.path)
|
|
@@ -444,6 +467,238 @@ def _tokenize(cmd):
|
|
|
444
467
|
return cmd.split()
|
|
445
468
|
|
|
446
469
|
|
|
470
|
+
# ── 包装命令解包(v0.1.5)─────────────────────────────────────────────────
|
|
471
|
+
# 危险动作常被包装:sh -c "…"、bash -lc "…"、cmd /c "…"、powershell -Command "…"、
|
|
472
|
+
# sudo …、env …、nohup …、timeout 5 …。只分析 argv[0] 会漏掉内层动词,
|
|
473
|
+
# 因此先按已知包装表做受控解包(深度上限),再对内层命令跑同一套 argv 规则。
|
|
474
|
+
_UNWRAP_MAX_DEPTH = 3
|
|
475
|
+
|
|
476
|
+
_SHELL_WRAPPERS = {
|
|
477
|
+
"sh", "bash", "zsh", "dash", "ksh", "ash", "busybox",
|
|
478
|
+
"cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe",
|
|
479
|
+
}
|
|
480
|
+
# shell 的「执行字符串」开关:-c / -lc / -ic / /c / /k / -Command
|
|
481
|
+
_SHELL_EXEC_FLAGS = {"/c", "/k", "-command", "-cmd"}
|
|
482
|
+
_SHELL_EXEC_FLAG_RE = re.compile(r"^-[a-z]*c$")
|
|
483
|
+
|
|
484
|
+
_WRAPPER_SPECS = {
|
|
485
|
+
"sudo": {"value_opts": {"-u", "-g", "-p", "-C", "-h", "-r", "-t", "-U",
|
|
486
|
+
"--user", "--group", "--prompt", "--chdir", "--host",
|
|
487
|
+
"--role", "--type", "--other-user", "--close-from"}},
|
|
488
|
+
"doas": {"value_opts": {"-u", "-C"}},
|
|
489
|
+
"env": {"value_opts": {"-u", "-S", "-C", "--unset", "--split-string", "--chdir"},
|
|
490
|
+
"assignments": True},
|
|
491
|
+
"nohup": {"value_opts": {}},
|
|
492
|
+
"setsid": {"value_opts": {}},
|
|
493
|
+
"stdbuf": {"value_opts": {"-i", "-o", "-e", "--input", "--output", "--error"}},
|
|
494
|
+
"nice": {"value_opts": {"-n", "--adjustment"}},
|
|
495
|
+
"ionice": {"value_opts": {"-c", "-n", "-p", "--class", "--classdata", "--pid"}},
|
|
496
|
+
"time": {"value_opts": {"-o", "-f", "--output", "--format"}},
|
|
497
|
+
"xargs": {"value_opts": {"-I", "-n", "-P", "-d", "-a", "-E", "-s", "-L",
|
|
498
|
+
"--max-args", "--max-procs", "--delimiter", "--arg-file",
|
|
499
|
+
"--eof", "--max-chars", "--max-lines"}},
|
|
500
|
+
"timeout": {"value_opts": {"-s", "-k", "--signal", "--kill-after"},
|
|
501
|
+
"skip_positional": 1},
|
|
502
|
+
"command": {"value_opts": {}},
|
|
503
|
+
"builtin": {"value_opts": {}},
|
|
504
|
+
"exec": {"value_opts": {}},
|
|
505
|
+
"chroot": {"value_opts": {"--userspec", "--groups"}},
|
|
506
|
+
"busybox": {"value_opts": {}},
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _shell_inner(argv):
|
|
511
|
+
"""从 shell 包装 argv 中取出执行开关后的内层命令文本(无则返回 None)。"""
|
|
512
|
+
for i, tok in enumerate(argv[1:], start=1):
|
|
513
|
+
low = tok.lower()
|
|
514
|
+
if low in _SHELL_EXEC_FLAGS or _SHELL_EXEC_FLAG_RE.match(low):
|
|
515
|
+
rest = argv[i + 1:]
|
|
516
|
+
if not rest:
|
|
517
|
+
return None
|
|
518
|
+
parts = []
|
|
519
|
+
for item in rest:
|
|
520
|
+
# Windows 下 shlex(posix=False) 会保留外层引号,剥掉后再拼内层命令
|
|
521
|
+
if len(item) >= 2 and item[0] == item[-1] and item[0] in ("'", '"'):
|
|
522
|
+
item = item[1:-1]
|
|
523
|
+
parts.append(item)
|
|
524
|
+
return " ".join(parts)
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _strip_wrapper(argv, spec):
|
|
529
|
+
"""跳过包装命令自身的选项/参数,返回内层命令 argv(找不到则空列表)。"""
|
|
530
|
+
value_opts = spec.get("value_opts", set())
|
|
531
|
+
i = 1
|
|
532
|
+
n = len(argv)
|
|
533
|
+
while i < n:
|
|
534
|
+
tok = argv[i]
|
|
535
|
+
if spec.get("assignments") and "=" in tok and not tok.startswith("-"):
|
|
536
|
+
i += 1
|
|
537
|
+
continue
|
|
538
|
+
if tok == "--":
|
|
539
|
+
i += 1
|
|
540
|
+
break
|
|
541
|
+
if tok.startswith("-") and tok != "-":
|
|
542
|
+
i += 2 if tok in value_opts else 1
|
|
543
|
+
continue
|
|
544
|
+
break
|
|
545
|
+
i += int(spec.get("skip_positional", 0) or 0)
|
|
546
|
+
return argv[i:] if i < n else []
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _unwrap_argv(argv, depth=0):
|
|
550
|
+
"""返回需要做 argv 级分析的命令列表(含解包出的内层命令)。"""
|
|
551
|
+
if not argv or depth > _UNWRAP_MAX_DEPTH:
|
|
552
|
+
return []
|
|
553
|
+
verb = os.path.basename(argv[0]).lower()
|
|
554
|
+
if verb in _SHELL_WRAPPERS:
|
|
555
|
+
inner = _shell_inner(argv)
|
|
556
|
+
return _unwrap_argv(_tokenize(inner), depth + 1) if inner else []
|
|
557
|
+
spec = _WRAPPER_SPECS.get(verb)
|
|
558
|
+
if spec:
|
|
559
|
+
rest = _strip_wrapper(argv, spec)
|
|
560
|
+
return _unwrap_argv(rest, depth + 1) if rest else []
|
|
561
|
+
return [argv]
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
# ── 复合命令拆分(v0.1.6)─────────────────────────────────────────────────
|
|
565
|
+
# v0.1.5 解决了包装命令(sh -c / sudo / env …)绕过,但整条命令仍然只按第一个词
|
|
566
|
+
# 做 argv 分析:`echo ok && rm -rf /`、`ls; rm -rf /`、`true || rm -rf /` 里的
|
|
567
|
+
# 危险段藏在分隔符之后,整条命令因此被判放行。现在先按顶层分隔符拆分、再逐段跑
|
|
568
|
+
# 同一套文本 / argv 规则;命令替换($(...) / `...`)同样展开;拆分不安全时 fail-closed。
|
|
569
|
+
_COMPOUND_SEPARATORS = (";", "|", "&", "\n", "\r")
|
|
570
|
+
_SEGMENT_MAX = 32
|
|
571
|
+
_SEGMENT_DEPTH = 3
|
|
572
|
+
_SUBST_MAX = 16
|
|
573
|
+
_QUOTES = ("'", '"', "`")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _scan_quotes(cmd):
|
|
577
|
+
"""扫描引号闭合情况,返回未闭合的引号字符(全部闭合返回 None)。"""
|
|
578
|
+
quote = None
|
|
579
|
+
i = 0
|
|
580
|
+
n = len(cmd)
|
|
581
|
+
while i < n:
|
|
582
|
+
ch = cmd[i]
|
|
583
|
+
if quote:
|
|
584
|
+
if quote != "'" and ch == "\\" and i + 1 < n:
|
|
585
|
+
i += 2
|
|
586
|
+
continue
|
|
587
|
+
if ch == quote:
|
|
588
|
+
quote = None
|
|
589
|
+
i += 1
|
|
590
|
+
continue
|
|
591
|
+
if ch in _QUOTES:
|
|
592
|
+
quote = ch
|
|
593
|
+
elif ch == "\\" and i + 1 < n:
|
|
594
|
+
i += 2
|
|
595
|
+
continue
|
|
596
|
+
i += 1
|
|
597
|
+
return quote
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _split_compound(cmd):
|
|
601
|
+
"""按顶层分隔符(; && || | & 换行)拆分命令,引号 / 转义内不拆。"""
|
|
602
|
+
segs = []
|
|
603
|
+
cur = []
|
|
604
|
+
quote = None
|
|
605
|
+
i = 0
|
|
606
|
+
n = len(cmd)
|
|
607
|
+
while i < n:
|
|
608
|
+
ch = cmd[i]
|
|
609
|
+
if quote:
|
|
610
|
+
cur.append(ch)
|
|
611
|
+
if quote != "'" and ch == "\\" and i + 1 < n:
|
|
612
|
+
cur.append(cmd[i + 1])
|
|
613
|
+
i += 2
|
|
614
|
+
continue
|
|
615
|
+
if ch == quote:
|
|
616
|
+
quote = None
|
|
617
|
+
i += 1
|
|
618
|
+
continue
|
|
619
|
+
if ch in _QUOTES:
|
|
620
|
+
quote = ch
|
|
621
|
+
cur.append(ch)
|
|
622
|
+
i += 1
|
|
623
|
+
continue
|
|
624
|
+
if ch == "\\" and i + 1 < n:
|
|
625
|
+
cur.append(ch)
|
|
626
|
+
cur.append(cmd[i + 1])
|
|
627
|
+
i += 2
|
|
628
|
+
continue
|
|
629
|
+
if ch in _COMPOUND_SEPARATORS:
|
|
630
|
+
seg = "".join(cur).strip()
|
|
631
|
+
if seg:
|
|
632
|
+
segs.append(seg)
|
|
633
|
+
cur = []
|
|
634
|
+
i += 1
|
|
635
|
+
continue
|
|
636
|
+
cur.append(ch)
|
|
637
|
+
i += 1
|
|
638
|
+
seg = "".join(cur).strip()
|
|
639
|
+
if seg:
|
|
640
|
+
segs.append(seg)
|
|
641
|
+
return segs
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _extract_substitutions(text):
|
|
645
|
+
"""取出 $(...) 与 `...` 里的内层命令文本(数量上限,防构造爆炸)。"""
|
|
646
|
+
out = []
|
|
647
|
+
i = 0
|
|
648
|
+
n = len(text)
|
|
649
|
+
while i < n and len(out) < _SUBST_MAX:
|
|
650
|
+
ch = text[i]
|
|
651
|
+
if ch == "\\" and i + 1 < n:
|
|
652
|
+
i += 2
|
|
653
|
+
continue
|
|
654
|
+
if ch == "$" and i + 1 < n and text[i + 1] == "(":
|
|
655
|
+
depth = 0
|
|
656
|
+
j = i + 1
|
|
657
|
+
while j < n:
|
|
658
|
+
if text[j] == "(":
|
|
659
|
+
depth += 1
|
|
660
|
+
elif text[j] == ")":
|
|
661
|
+
depth -= 1
|
|
662
|
+
if depth == 0:
|
|
663
|
+
break
|
|
664
|
+
j += 1
|
|
665
|
+
if j < n:
|
|
666
|
+
out.append(text[i + 2:j])
|
|
667
|
+
i = j + 1
|
|
668
|
+
continue
|
|
669
|
+
break
|
|
670
|
+
if ch == "`":
|
|
671
|
+
j = i + 1
|
|
672
|
+
while j < n and text[j] != "`":
|
|
673
|
+
if text[j] == "\\":
|
|
674
|
+
j += 1
|
|
675
|
+
j += 1
|
|
676
|
+
if j < n:
|
|
677
|
+
out.append(text[i + 1:j])
|
|
678
|
+
i = j + 1
|
|
679
|
+
continue
|
|
680
|
+
break
|
|
681
|
+
i += 1
|
|
682
|
+
return out
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _command_segments(cmd, depth=0):
|
|
686
|
+
"""展开复合命令与命令替换,返回需要逐段分析的命令文本(去重、保序)。"""
|
|
687
|
+
segs = _split_compound(cmd)
|
|
688
|
+
expanded = list(segs)
|
|
689
|
+
if depth < _SEGMENT_DEPTH:
|
|
690
|
+
for seg in segs:
|
|
691
|
+
for sub in _extract_substitutions(seg):
|
|
692
|
+
expanded += _command_segments(sub, depth + 1)
|
|
693
|
+
out = []
|
|
694
|
+
for seg in expanded:
|
|
695
|
+
if seg and seg not in out:
|
|
696
|
+
out.append(seg)
|
|
697
|
+
if len(out) >= _SEGMENT_MAX:
|
|
698
|
+
break
|
|
699
|
+
return out
|
|
700
|
+
|
|
701
|
+
|
|
447
702
|
# ── argv 级规则(按动词分组)───────────────────────────────────────────────
|
|
448
703
|
|
|
449
704
|
_RM_VERBS = {"rm", "rmdir", "del", "erase", "unlink", "shred", "remove-item"}
|