@yottameta/yotta-guardian 0.1.5 → 0.1.7

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # 更新日志
2
2
 
3
+ ## v0.1.7 (2026-09-28)
4
+
5
+ 文档格式修复 + 两处引擎健壮性修复(规则判定行为不变)。
6
+
7
+ - **修复代码围栏**:`SKILL.md` 与 `references/`(intent-verifier / policies / rules)中被写作管线占位符顶替的代码围栏,全部还原为标准三个反引号,共 20 处;此前自 0.1.0 起一直未还原,Markdown 渲染异常(命令与配置示例无法按代码块显示)。
8
+ - **修复非法转义(0.1.0 起既有)**:`scripts/guardian_rules.py` 的模块 docstring 里 `\s`、`\b` 属非法转义 —— Python 3.12+ 每次调用都向 stderr 报 2 行 SyntaxWarning,严格告警(`PYTHONWARNINGS=error`)下引擎直接加载失败。该 docstring 改为 raw 字符串(与规则表内 `r"…"` 写法一致),告警消失。
9
+ - **修复致命异常退出码(fail-closed,0.1.0 起既有)**:此前未捕获异常走 Python 默认退出码 1,而契约里 1 = 允许但带警告 —— 按退出码判定的宿主会把「引擎崩溃」读成放行(fail-open)。现在规则表加载失败与运行期未预期异常统一以**退出码 4**(用法错误 / 致命异常)结束并在 stderr 写明原因;新增 3 项回归用例(导入失败 → 4、运行期异常 → 4、严格告警下仍正常判定,共 86 项)。
10
+ - **修复彩色输出必崩(0.1.6 起既有)**:`check` 的彩色分支 `c()` 格式串只有 1 个 `%s` 却喂 2 个参数 —— 真人终端(`sys.stdout.isatty()` 为真)必抛 `TypeError`,以退出码 4 结束且**判定与命中规则完全不输出**;`--json` / `--no-color` / 管道不受影响(故此前测试未覆盖)。已修正格式串,并新增「假 TTY」回归用例(共 88 项)。
11
+ - **文档**:`SKILL.md` 新增「宿主接入」一节(放行不代批 / 用文件接引擎输出 / stdin 按 UTF-8 读),并把退出码 4 的语义写成「用法错误 / 致命异常(绝不要当作放行)」。
12
+ - **发布门禁联动**:`validate-skill` 与 `preflight-publish` 的交付面检查扩到 Python 一侧(语法错误 / 非法转义等编译期告警),与 Markdown 检查共用同一入口,命中即拦。
13
+ - 规则判定、策略档位、CLI 输出结构与审计格式不变;版本四处对齐 0.1.7。
14
+
15
+ ## v0.1.6 (2026-09-25)
16
+
17
+ 安全修复:复合命令拆分,堵住分隔符后的危险段绕过。
18
+
19
+ - 背景:v0.1.5 只解决了包装命令(`sh -c` / `sudo` / `env` …)绕过,整条命令仍只按第一个词做 argv 分析——`echo ok && rm -rf /`、`ls; rm -rf /`、`true || rm -rf /` 里的危险段藏在分隔符之后,整条命令被判放行(本轮复现)。
20
+ - 修复:新增受控复合命令拆分(`;` `&&` `||` `|` `&` 换行,引号 / 转义内不拆)+ 命令替换展开(`$(...)` / 反引号),逐段跑同一套文本 / argv 规则;放行规则只对单一简单命令生效;引号未闭合时出 `CMD-UNPARSED`(medium,fail-closed)。
21
+ - 安装器加固:拒绝对符号链接目标写入、目标不是目录时拒绝、不做整目录删除。
22
+ - 回归:新增 11 项复合命令用例,测试 83/83 通过。
23
+
3
24
  ## v0.1.5 (2026-09-19)
4
25
 
5
26
  安全修复:包装命令解包,堵住 argv 级分析的绕过路径。
@@ -41,7 +62,7 @@
41
62
 
42
63
  - **README.md 改为英文**:作为 GitHub / npm / ClawHub 首页的英文门面(翻译 + 精简,覆盖定位 / 核心价值 / 命令 / 快速使用 / 安装 / 使用示例 / 边界 / 开发校验全流程)。
43
64
  - **新增 README.zh-CN.md**:原中文完整主文档整体平移,顶部加语言切换链接。
44
- - **修复代码围栏**:README 中 `_BT_`bash / `_BT_` 占位符全部改为标准 ```bash / ```(Markdown 渲染修复)。
65
+ - **修复代码围栏**:README 中的围栏占位符(写作管线留下的标记)全部改为标准 ```bash / ```(Markdown 渲染修复)。
45
66
  - **package.json**:description 改英文;files 加 README.zh-CN.md;版本 0.1.0 → 0.1.1。
46
67
  - 版本四处对齐:package.json / SKILL frontmatter / 引擎 VERSION / 文档。
47
68
  - 边界(B 方案):references / CHANGELOG / 测试注释不翻译;SKILL 触发描述保持中文。
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: yotta-guardian
3
- version: 0.1.5
3
+ version: 0.1.7
4
4
  description: 元盾 —— 跨智能体的危险调用拦截护栏:确定性规则引擎 + 可插拔意图验证(不绑模型),拦截危险 exec / write / edit / read / run / shell 工具调用,提供审计日志。触发:代理要执行高风险命令(递归删除、磁盘格式化、提权、防火墙改动、反向 shell、下载即执行等)、要写入系统敏感路径或修改系统配置、要在执行危险操作前做安全检查、或用户说 护栏/拦截/危险操作/安全检查 等。边界:默认只读评估,不自动执行也不放行危险操作;不替代用户决策;不隐藏审计记录;规则可配置。
5
5
  license: MIT
6
6
  ---
@@ -27,7 +27,7 @@ license: MIT
27
27
 
28
28
  Windows 用 python,Linux/macOS 用 python3。
29
29
 
30
- `_BT_`bash
30
+ ```bash
31
31
  # 检查一条 exec(0 = 允许)
32
32
  python3 scripts/yotta_guardian.py check exec --cmd "git status"
33
33
 
@@ -43,15 +43,25 @@ python3 scripts/yotta_guardian.py check --batch calls.json --json
43
43
  # 审计
44
44
  python3 scripts/yotta_guardian.py check exec --cmd "..." --audit-log .yotta-guardian/audit.jsonl
45
45
  python3 scripts/yotta_guardian.py audit --file .yotta-guardian/audit.jsonl --tail 20
46
- `_BT_`
46
+ ```
47
47
 
48
48
  ## 工作流程(AI 智能体执行危险操作前)
49
49
 
50
50
  1. **先检查**:把将要执行的工具调用交给护栏 `check`(单条或 `--batch` 批量)。
51
- 2. **看退出码**:0 = 允许;1 = 允许但带警告(建议人工复核);2 / 3 = 拒绝(high / critical,不要执行);4 = 用法错误。
51
+ 2. **看退出码**:0 = 允许;1 = 允许但带警告(建议人工复核);2 / 3 = 拒绝(high / critical,不要执行);4 = 用法错误 / 致命异常(引擎没跑起来 —— 绝不要当作放行)。
52
52
  3. **被拒绝怎么办**:如实向用户报告原因与命中规则;确有授权的操作,用 `--allow` / `--allow-path` / 自定义规则文件放行并留审计记录;**不要绕过检查**。
53
53
  4. **留痕**:高风险场景用 `--audit-log` 落审计日志,供追溯。
54
54
 
55
+ ## 宿主接入(把护栏挂到「工具调用前」事件时)
56
+
57
+ `skill-manifest.json` 声明 `before_tool` + `fallback: explicit-unverified`。接入方按下面三条做,可避开实测踩过的坑:
58
+
59
+ 1. **放行不要回「允许」**:只在拦截时输出拒绝判定(如 `permissionDecision: "deny"`);放行时**不输出任何判定**,把决定权交回宿主。若放行时回「allow」,会覆盖宿主自身的权限流程,护栏就变成自动放行器 —— 护栏只该「拦」,不该「代批」。
60
+ 2. **用文件接引擎输出**:部分 hook 进程环境里,用管道捕获子进程 stdout 会拿到空值(同一写法跑 `python -c "print(1)"` 却正常);把引擎 stdout / stderr 重定向到临时文件再读回,判定即稳定(引擎自身的 stdout 是纯净的,`--json` 可直接解析)。
61
+ 3. **stdin 先按 UTF-8 读**:hook 载荷里的命令文本常含中文,按本机默认码页解码会乱码;读 stdin 前先 `sys.stdin.reconfigure(encoding="utf-8")`(引擎已对自身 stdout / stderr 做 UTF-8 处理,stdin 由接入方负责)。
62
+
63
+ 另外:引擎对「没跑起来」是 fail-closed 的 —— 规则表加载失败或运行期未预期异常一律以退出码 4 结束并在 stderr 写「致命异常: …」。接入方应把 4 当作**不放行 + 报告**处理,而不是放行。
64
+
55
65
  ## 策略(policy)
56
66
 
57
67
  | 策略 | 行为 |
package/install.sh CHANGED
@@ -61,10 +61,29 @@ resolve_user() {
61
61
  }
62
62
 
63
63
  install_to() {
64
- mkdir -p "$1/$SKILL_NAME"
65
- cp -r "$SOURCE_DIR/." "$1/$SKILL_NAME/"
66
- rm -rf "$1/$SKILL_NAME/.git"
67
- echo "installed -> $1/$SKILL_NAME"
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.5",
3
+ "version": "0.1.7",
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": [
@@ -13,7 +13,7 @@
13
13
 
14
14
  引擎对「允许」结论的调用发起验证,写入 stdin 的 JSON:
15
15
 
16
- `_BT_`json
16
+ ```json
17
17
  {
18
18
  "tool": "exec",
19
19
  "cmd": "npm install",
@@ -24,19 +24,19 @@
24
24
  "cwd": "/home/user/project",
25
25
  "findings": []
26
26
  }
27
- `_BT_`
27
+ ```
28
28
 
29
29
  字段说明:tool 为工具类型;cmd / path / content_preview(内容前 500 字符)/ target 为调用内容;policy 为当前策略;findings 为确定性规则已命中的条目(允许结论下通常为空或仅低危)。
30
30
 
31
31
  ## 三、响应(stdout,JSON)
32
32
 
33
- `_BT_`json
33
+ ```json
34
34
  {
35
35
  "verdict": "allow",
36
36
  "severity": "low",
37
37
  "reason": "安装依赖,无恶意信号"
38
38
  }
39
- `_BT_`
39
+ ```
40
40
 
41
41
  - verdict:allow / deny / review 三选一(必须)。
42
42
  - severity:可选,deny 时默认 high;取值 info / low / medium / high / critical。
@@ -59,7 +59,7 @@
59
59
 
60
60
  ## 六、示例验证器(Python 桩)
61
61
 
62
- `_BT_`python
62
+ ```python
63
63
  #!/usr/bin/env python3
64
64
  import json
65
65
  import sys
@@ -71,6 +71,6 @@ if cmd.startswith("danger"):
71
71
  else:
72
72
  out = {"verdict": "allow", "severity": "low", "reason": "无风险信号"}
73
73
  json.dump(out, sys.stdout)
74
- `_BT_`
74
+ ```
75
75
 
76
76
  真实场景可用该协议把调用摘要发给 LLM 网关做语义判断,返回同样的 JSON 即可接入。
@@ -34,9 +34,9 @@ AI 智能体判断:退出码非 0 时不要直接执行;2 / 3 一律停下
34
34
 
35
35
  ### 执行前 gate
36
36
 
37
- `_BT_`bash
37
+ ```bash
38
38
  python3 scripts/yotta_guardian.py check exec --cmd "<待执行命令>" --json
39
- `_BT_`
39
+ ```
40
40
 
41
41
  - exit 0:执行。
42
42
  - exit 1:执行但提示用户「护栏给出中危警告」。
@@ -45,9 +45,9 @@ python3 scripts/yotta_guardian.py check exec --cmd "<待执行命令>" --json
45
45
 
46
46
  ### 批量预检
47
47
 
48
- `_BT_`bash
48
+ ```bash
49
49
  python3 scripts/yotta_guardian.py check --batch calls.json --json
50
- `_BT_`
50
+ ```
51
51
 
52
52
  calls.json 为数组或 {"calls": [...]},每项含 tool / cmd / path / content / old / new / target。任一被拒时整体退出码取最严重值。
53
53
 
@@ -55,25 +55,25 @@ calls.json 为数组或 {"calls": [...]},每项含 tool / cmd / path / content
55
55
 
56
56
  确有授权的操作(如正规重启、备份清理)用显式放行,而不是绕过检查:
57
57
 
58
- `_BT_`bash
58
+ ```bash
59
59
  python3 scripts/yotta_guardian.py check exec --cmd "<命令>" --allow "<模式>"
60
60
  python3 scripts/yotta_guardian.py check write --path "<路径>" --allow-path "<前缀>"
61
- `_BT_`
61
+ ```
62
62
 
63
63
  ### 审计
64
64
 
65
- `_BT_`bash
65
+ ```bash
66
66
  # 检查时落审计
67
67
  python3 scripts/yotta_guardian.py check exec --cmd "<命令>" --audit-log .yotta-guardian/audit.jsonl
68
68
 
69
69
  # 查询
70
70
  python3 scripts/yotta_guardian.py audit --file .yotta-guardian/audit.jsonl --tail 20
71
71
  python3 scripts/yotta_guardian.py audit --file .yotta-guardian/audit.jsonl --denied --json
72
- `_BT_`
72
+ ```
73
73
 
74
74
  ## 五、JSON 输出结构(check --json)
75
75
 
76
- `_BT_`json
76
+ ```json
77
77
  {
78
78
  "tool": "yotta-guardian",
79
79
  "version": "0.1.0",
@@ -93,4 +93,4 @@ python3 scripts/yotta_guardian.py audit --file .yotta-guardian/audit.jsonl --den
93
93
  }
94
94
  ]
95
95
  }
96
- `_BT_`
96
+ ```
@@ -101,7 +101,7 @@
101
101
 
102
102
  ## 六、自定义规则文件(--config)
103
103
 
104
- `_BT_`json
104
+ ```json
105
105
  {
106
106
  "policy": "default",
107
107
  "allow": ["^git "],
@@ -109,7 +109,7 @@
109
109
  "deny": ["^evil"],
110
110
  "verifier": { "command": ["python3", "/path/verifier.py"], "timeout": 30 }
111
111
  }
112
- `_BT_`
112
+ ```
113
113
 
114
114
  - policy: default / strict / loose
115
115
  - allow: 命令正则放行模式
@@ -1,5 +1,5 @@
1
1
  # -*- coding: utf-8 -*-
2
- """guardian_rules.py — YottaMeta 元盾(yotta-guardian)内置规则表(数据,不含匹配逻辑)。
2
+ r"""guardian_rules.py — YottaMeta 元盾(yotta-guardian)内置规则表(数据,不含匹配逻辑)。
3
3
 
4
4
  本文件只声明规则数据,与 yotta-security-audit(元安)/ yotta-vetter(元审)的
5
5
  「源码静态扫描」规则不同:这里是「工具调用护栏」规则,面向 exec / write / edit 等
@@ -49,9 +49,15 @@ except Exception:
49
49
 
50
50
  _HERE = Path(__file__).resolve().parent
51
51
  sys.path.insert(0, str(_HERE))
52
- import guardian_rules as GR # noqa: E402
52
+ try:
53
+ import guardian_rules as GR # noqa: E402
54
+ except BaseException as _import_exc: # 规则表加载失败 = 引擎没跑起来 → 致命异常(4)
55
+ sys.stderr.write(
56
+ "致命异常: 规则表加载失败: %s: %s\n" % (type(_import_exc).__name__, _import_exc)
57
+ )
58
+ raise SystemExit(4)
53
59
 
54
- VERSION = "0.1.5"
60
+ VERSION = "0.1.7"
55
61
  TOOL_NAME = "yotta-guardian"
56
62
  TOOL_CN = "元盾"
57
63
 
@@ -279,14 +285,15 @@ class RuleEngine:
279
285
  def _argv_findings(self, call):
280
286
  if call.tool not in ("exec", "run", "shell"):
281
287
  return []
282
- argv = _tokenize(call.cmd)
283
- if not argv:
284
- return []
285
288
  findings = []
286
- # v0.1.5:先做包装解包(sh -c / cmd /c / powershell -Command / sudo / env …),
287
- # 否则只看 argv[0] 会漏掉内层真正的危险动词。
288
- for sub in _unwrap_argv(argv):
289
- findings += self._argv_findings_one(sub)
289
+ # v0.1.6:先拆复合命令(; && || | & 换行)与命令替换,再逐段解包 +
290
+ # 跑同一套 argv 规则;v0.1.5 的包装解包对每段继续生效。
291
+ for seg in _command_segments(call.cmd):
292
+ argv = _tokenize(seg)
293
+ if not argv:
294
+ continue
295
+ for sub in _unwrap_argv(argv):
296
+ findings += self._argv_findings_one(sub)
290
297
  return findings
291
298
 
292
299
  def _argv_findings_one(self, argv):
@@ -326,6 +333,12 @@ class RuleEngine:
326
333
 
327
334
  def _allow_hit(self, call):
328
335
  if call.tool in ("exec", "run", "shell"):
336
+ # v0.1.6:放行规则只覆盖「单一简单命令」。复合命令或命令替换不允许被
337
+ # 整条放行——否则 `echo ok && <危险命令>` 会借放行规则绕过风险判定。
338
+ if (len(_split_compound(call.cmd)) > 1
339
+ or _extract_substitutions(call.cmd)
340
+ or _scan_quotes(call.cmd)):
341
+ return False
329
342
  for rx in self.allow_text + self.allow_patterns:
330
343
  if rx.search(call.cmd):
331
344
  return True
@@ -343,6 +356,14 @@ class RuleEngine:
343
356
  findings = []
344
357
  if call.tool in ("exec", "run", "shell"):
345
358
  findings += self._text_findings(call.cmd, "command")
359
+ if _scan_quotes(call.cmd):
360
+ findings.append(Finding("CMD-UNPARSED", "medium", "command",
361
+ "命令引号未闭合,无法可靠拆分,按未知风险处理",
362
+ 80))
363
+ segments = _command_segments(call.cmd)
364
+ if len(segments) > 1:
365
+ for seg in segments:
366
+ findings += self._text_findings(seg, "command")
346
367
  findings += self._argv_findings(call)
347
368
  elif call.tool in ("write", "edit"):
348
369
  findings += self._path_findings(call.path)
@@ -546,6 +567,144 @@ def _unwrap_argv(argv, depth=0):
546
567
  return [argv]
547
568
 
548
569
 
570
+ # ── 复合命令拆分(v0.1.6)─────────────────────────────────────────────────
571
+ # v0.1.5 解决了包装命令(sh -c / sudo / env …)绕过,但整条命令仍然只按第一个词
572
+ # 做 argv 分析:`echo ok && rm -rf /`、`ls; rm -rf /`、`true || rm -rf /` 里的
573
+ # 危险段藏在分隔符之后,整条命令因此被判放行。现在先按顶层分隔符拆分、再逐段跑
574
+ # 同一套文本 / argv 规则;命令替换($(...) / `...`)同样展开;拆分不安全时 fail-closed。
575
+ _COMPOUND_SEPARATORS = (";", "|", "&", "\n", "\r")
576
+ _SEGMENT_MAX = 32
577
+ _SEGMENT_DEPTH = 3
578
+ _SUBST_MAX = 16
579
+ _QUOTES = ("'", '"', "`")
580
+
581
+
582
+ def _scan_quotes(cmd):
583
+ """扫描引号闭合情况,返回未闭合的引号字符(全部闭合返回 None)。"""
584
+ quote = None
585
+ i = 0
586
+ n = len(cmd)
587
+ while i < n:
588
+ ch = cmd[i]
589
+ if quote:
590
+ if quote != "'" and ch == "\\" and i + 1 < n:
591
+ i += 2
592
+ continue
593
+ if ch == quote:
594
+ quote = None
595
+ i += 1
596
+ continue
597
+ if ch in _QUOTES:
598
+ quote = ch
599
+ elif ch == "\\" and i + 1 < n:
600
+ i += 2
601
+ continue
602
+ i += 1
603
+ return quote
604
+
605
+
606
+ def _split_compound(cmd):
607
+ """按顶层分隔符(; && || | & 换行)拆分命令,引号 / 转义内不拆。"""
608
+ segs = []
609
+ cur = []
610
+ quote = None
611
+ i = 0
612
+ n = len(cmd)
613
+ while i < n:
614
+ ch = cmd[i]
615
+ if quote:
616
+ cur.append(ch)
617
+ if quote != "'" and ch == "\\" and i + 1 < n:
618
+ cur.append(cmd[i + 1])
619
+ i += 2
620
+ continue
621
+ if ch == quote:
622
+ quote = None
623
+ i += 1
624
+ continue
625
+ if ch in _QUOTES:
626
+ quote = ch
627
+ cur.append(ch)
628
+ i += 1
629
+ continue
630
+ if ch == "\\" and i + 1 < n:
631
+ cur.append(ch)
632
+ cur.append(cmd[i + 1])
633
+ i += 2
634
+ continue
635
+ if ch in _COMPOUND_SEPARATORS:
636
+ seg = "".join(cur).strip()
637
+ if seg:
638
+ segs.append(seg)
639
+ cur = []
640
+ i += 1
641
+ continue
642
+ cur.append(ch)
643
+ i += 1
644
+ seg = "".join(cur).strip()
645
+ if seg:
646
+ segs.append(seg)
647
+ return segs
648
+
649
+
650
+ def _extract_substitutions(text):
651
+ """取出 $(...) 与 `...` 里的内层命令文本(数量上限,防构造爆炸)。"""
652
+ out = []
653
+ i = 0
654
+ n = len(text)
655
+ while i < n and len(out) < _SUBST_MAX:
656
+ ch = text[i]
657
+ if ch == "\\" and i + 1 < n:
658
+ i += 2
659
+ continue
660
+ if ch == "$" and i + 1 < n and text[i + 1] == "(":
661
+ depth = 0
662
+ j = i + 1
663
+ while j < n:
664
+ if text[j] == "(":
665
+ depth += 1
666
+ elif text[j] == ")":
667
+ depth -= 1
668
+ if depth == 0:
669
+ break
670
+ j += 1
671
+ if j < n:
672
+ out.append(text[i + 2:j])
673
+ i = j + 1
674
+ continue
675
+ break
676
+ if ch == "`":
677
+ j = i + 1
678
+ while j < n and text[j] != "`":
679
+ if text[j] == "\\":
680
+ j += 1
681
+ j += 1
682
+ if j < n:
683
+ out.append(text[i + 1:j])
684
+ i = j + 1
685
+ continue
686
+ break
687
+ i += 1
688
+ return out
689
+
690
+
691
+ def _command_segments(cmd, depth=0):
692
+ """展开复合命令与命令替换,返回需要逐段分析的命令文本(去重、保序)。"""
693
+ segs = _split_compound(cmd)
694
+ expanded = list(segs)
695
+ if depth < _SEGMENT_DEPTH:
696
+ for seg in segs:
697
+ for sub in _extract_substitutions(seg):
698
+ expanded += _command_segments(sub, depth + 1)
699
+ out = []
700
+ for seg in expanded:
701
+ if seg and seg not in out:
702
+ out.append(seg)
703
+ if len(out) >= _SEGMENT_MAX:
704
+ break
705
+ return out
706
+
707
+
549
708
  # ── argv 级规则(按动词分组)───────────────────────────────────────────────
550
709
 
551
710
  _RM_VERBS = {"rm", "rmdir", "del", "erase", "unlink", "shred", "remove-item"}
@@ -985,7 +1144,8 @@ def _build_verifier(args):
985
1144
 
986
1145
  def _print_check_text(call, verdict, color):
987
1146
  def c(s, code):
988
- return "\033[%sm\033[0m" % (code, s) if color else s
1147
+ # 2026-09-28:原写法只有 1 个 %s 却喂了 2 个参数 → 彩色路径(真人终端)必崩 TypeError。
1148
+ return "\033[%sm%s\033[0m" % (code, s) if color else s
989
1149
  kind = c("DENY", "31;1") if not verdict.allowed else c("ALLOW", "32;1")
990
1150
  sev = verdict.severity.upper()
991
1151
  print("[检查] %s" % call.describe(300))
@@ -1214,4 +1374,16 @@ def main(argv=None):
1214
1374
 
1215
1375
 
1216
1376
  if __name__ == "__main__":
1217
- main()
1377
+ # 顶层兜底(2026-09-28):未捕获异常一律按「致命异常」退出码 4 结束。
1378
+ # 绝不能让引擎崩溃落到 1 —— 契约里 1 = 允许但带警告,按退出码判定的宿主会把
1379
+ # 「护栏没跑起来」读成放行(fail-open)。SystemExit / KeyboardInterrupt 原样透传。
1380
+ try:
1381
+ main()
1382
+ except (SystemExit, KeyboardInterrupt):
1383
+ raise
1384
+ except BaseException as exc: # 兜底就是要 catch-all
1385
+ try:
1386
+ sys.stderr.write("致命异常: %s: %s\n" % (type(exc).__name__, exc))
1387
+ except Exception:
1388
+ pass
1389
+ raise SystemExit(4)
@@ -3,7 +3,7 @@
3
3
  "slug": "yotta-guardian",
4
4
  "name": "元盾",
5
5
  "package": "@yottameta/yotta-guardian",
6
- "version": "0.1.5",
6
+ "version": "0.1.7",
7
7
  "trust": "yottameta",
8
8
  "install": {
9
9
  "idempotent": true