Documentation
¶
Overview ¶
Package cmdpolicy 命令级判据(机制面)。
它补的是判定面的**宽度**,不是深度 ¶
本仓的判定链已经覆盖「工具调用」这一层(policy / tooldisclose / watchdog), 但工具**内部**执行的那条命令没有任何机制面看得见:`rm -rf`、`curl | sh`、 带凭据的推送——宿主只能靠正则黑名单,而黑名单在「写法与语义不一致」时必然失效 (`rm -rf`、`r""m`、`$(echo cm0=)…` 都能绕)。
本包把这一层收成三件事:**前缀规则**、**三态裁决**、**规则自带样例**。
为什么是「前缀规则」而不是正则 ¶
命令的语义由**前几个 token** 决定:`git push` 与 `git status` 是两条完全不同的 风险,而它们的区别就在第二个 token。前缀匹配:
- 与正则不同,它**不可能因为引号/转义/空白而漂移**(先分词再比 token);
- 判据可解释(人能一眼看出这条规则管什么),于是拒绝理由能直接回给模型;
- 每段可以是**备选集合**,表达 `git (push|commit)` 而不写正则。
三态裁决与既有词汇对齐 ¶
`allow` / `prompt` / `forbidden` 与 `toolgate/policy` 的裁决词表同义,不引入第二套 口径:`prompt` 直接接 `gatecheck`(「需人审」优先于其他拒绝理由)。
规则自带样例,加载即自检 ¶
每条规则可以自带 `Match`(必须命中本规则)与 `NotMatch`(必须不命中本规则)。 `New` 在**加载期**逐条跑一遍,不一致即报错——于是「这条规则还活着吗」不是 事故后才回答的问题,而是启动时的断言。
这正是本仓「从没见过它触发的规则等于没有规则」那条纪律的机器化: 规则会随包名/命令名漂移,而漂移**不会报错**,只会静静地不再命中。
冲突口径:更具体者胜,同等具体取更严 ¶
多条规则同时命中时的裁决必须**确定性**,否则同一个命令在不同装配顺序下 得到不同结论(这类漂移最难查)。本包的口径:
- 前缀**更长**的规则胜(它更具体);
- 长度相同 ⇒ 取更严的一档(forbidden > prompt > allow);
- 仍相同 ⇒ 取**名字字典序最小**者,只为了让结果确定(不是有语义偏好)。
命令事实面:把「这条命令里出现了什么」交给上游 AST(收编 oagentkit/tools/bashguard)。
为什么事实面必须走 AST 而不能走词法 ¶
词法层(shellx.Split)看不见重定向运算符粘词(echo a>b 里的 > 是运算符不是参数)、 命令替换($(…) / 反引号里的调用)、管道后半——而这些恰恰是前缀判据最典型的 绕过面:git status > /etc/passwd 的前两个 token 仍是 git status,一条只读规则 就放行了任意文件写。手写解析器补这一层必然漏(引号/转义/替换的组合形态太多), 而漏的形态是「判据比声明宽」——本包最贵的那种失效。
故事实面收编上游 bashguard(mvdan.cc/sh 真 AST):全调用集(含嵌套 $(…)、 管道后半、子 shell、多段)与全重定向集都是它的结构化输出(tools#35/#79), 本仓不维护第二份 shell 解析。判据装配(前缀规则 / 三态裁决 / 下限)仍归本仓。
Package cmdpolicy —— 分词面(判据见 cmdpolicy.go)。
分词与判据分成两个文件,不是风格选择:判据(前缀规则 / 三态裁决 / 样例自检) 与分词(shell 引号与转义口径)是**两个各自可独立出错的判定面**。 分开后各自的行数与测试都能单独看;合在一起时,分词那一段只是「判据文件的头尾」, 改一次转义口径要在判据的散文里翻找,而它恰恰是最容易写错的那一段。
分词面已收编上游(2026-11-11) ¶
本包分词是上游 oekit/pkg/shellx.Split 的转发。收编前本仓自研过一份 (85 行分词 + 17 行分隔符合并),那份与上游差四处,**四处都是本仓那份错**:
- 双引号内的反斜杠:本仓丢弃**全部**反斜杠("a\b" → ab), 上游按 POSIX 只转义 " \ $ 反引号 与换行("a\b" → a\b)。 bash 实测与上游一致——本仓那份把文件名里的反斜杠吃掉, 判据看到的于是**另一个文件名**。
- 分隔符合并:本仓按 **token 相邻**合并(a & & b → a && b), 上游按**字符相邻**合并(a & & b → a & & b)。前者把两条独立的后台命令 伪装成一条 && 链。
- 回车:上游把 CR 归入空白(从配置/工单粘贴的 CRLF 命令行, 行尾 CR 不粘进最后一个 token),本仓当普通字符。
- 空参数:上游明确保留并有往返用例钉住,本仓当时也保留(但无契约、无用例)。
收编后本包不再维护第二份分词口径——口径归上游一处。 本包保留的是**上游没有的那一层**:按分隔符切段(SplitCommands)。
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrUnclosedQuote 命令行里有未闭合的引号(判不了就不放行)。 ErrUnclosedQuote = errors.Wrap(shellx.ErrUnclosedQuote, "cmdpolicy: 引号未闭合,无法判定") // ErrTrailingEscape 命令行以悬挂反斜杠结尾。 ErrTrailingEscape = errors.Wrap(shellx.ErrTrailingEscape, "cmdpolicy: 命令以转义符结尾,无法判定") )
分词失败的两个哨兵。它们**包装**上游哨兵而非另立一套, 于是下游两种判法都成立:
errors.Is(err, cmdpolicy.ErrUnclosedQuote) —— 判本包语义 errors.Is(err, shellx.ErrUnclosedQuote) —— 判上游语义
var ErrUnjudgeable = errors.New("cmdpolicy: 命令未进判定(超长/解析失败),判不了就不放行")
ErrUnjudgeable 命令未进 AST 判定(超长 / 解析失败)。
判不了就不放行:与 Split 的 ErrUnclosedQuote 同一处置口径—— 报错让调用方按自己的 fail-closed 口径处置,而不是当成「没有命中规则」。
Functions ¶
func Split ¶
Split 把命令行按 shell 口径分词(单引号内原样、双引号内按 POSIX 处理反斜杠)。
本包**不执行**命令,分词只为判定;不做变量展开、不做命令替换—— 那两件事一旦做,判据就会被「展开后的样子」欺骗($RM -rf 在判定时展开, 与真正执行时的展开时点不同)。这里的立场是:**判据只看字面**, 需要展开的宿主应先把命令展开成 argv 再调 Decide。
实现是上游 oekit/pkg/shellx.Split 的转发:分词口径归上游一处, 本仓不维护第二份。
错误:未闭合引号 / 尾随反斜杠 → 返回错误(**判不了就不放行**), 调用方按自己的 fail-closed 口径处置,而不是把它当成「没有命中规则」。
func SplitCommands ¶
SplitCommands 把命令行按未加引号的分隔符(; && || | & 与换行)切成多段。
⭐ 为什么必须分开判:git status; rm -rf / 的前缀是 git,若只对整串判一次, 一条 git 的 allow 规则就把后半段一起放行了——这是前缀判据最典型的绕过。 调用方应逐段 Decide,并对每段取**最严**结论。
段文本是**可往返的**:段再 Split 一次得到与原段逐项相同的 token。 这一点是判据正确性的前提,不是美观问题——段若被摊平成空格分隔, token 边界就变了:git 'a b' 摊平后是 git a b(三个 token), 含空格的备选前缀(如 {{"git"}, {"a b"}})从此永不命中。
可往返由上游 oekit/pkg/shellx.QuoteArgv 保证(它是分词的逆运算: Split(QuoteArgv(args...)) 必须逐项还原 args,含空参数与特殊字符)。
Types ¶
type Policy ¶
type Policy struct {
// contains filtered or unexported fields
}
Policy 一组已自检通过的前缀规则(构造后只读,可并发共享)。
func New ¶
New 构造判据并在**加载期**逐条自检。
自检项(任一不满足即返回错误,不静默降级):
- 规则名非空且唯一;
- Prefix 非空、每段非空;
- Decision 在词表内;
- 每条 Match 样例都真的被本规则命中;
- 每条 NotMatch 样例都真的不被本规则命中。
func (*Policy) DecideCommand ¶
DecideCommand 按命令行字符串判定(宿主手上只有命令行时用这个)。
判定面是全调用 AST,不只第一个 token ¶
前缀判据有三类典型绕过,逐类在此闭合(事实面走上游 bashguard,见 facts.go):
- **多段**:git status; rm -rf / 的前缀是 git——全调用面把 rm 那段也判了, 取最严(SplitCommands 仍保留,供宿主逐段留痕);
- **命令替换**:echo $(rm -rf /) 里的 rm 是嵌套调用——全调用面看得见它;
- **写重定向**:git status > /etc/passwd 的前缀仍是 git status,而 > 是 语句级事实、不在 argv 里——前缀规则结构上看不见它。
语义三条:
- **全调用面取最严**:管道后半、$(…) 嵌套、子 shell、多段里的每次调用 都过一遍前缀规则,取最严者;同等严取名字字典序最小(确定性)。
- **事实面下限**:含写重定向或动态展开($()/反引号/$VAR)时,裁决 **不低于 prompt**——这两类语义前缀规则看不见,静默 allow 就是 「判据比声明宽」。调用方若已把命令展开成 argv、且确知无重定向, 用 Decide(argv) 即可(那条路径不做事实面判定)。
- **判不了就不放行**:分词失败(未闭合引号 / 尾随转义)与 AST 未评估 (超长 / 解析失败)⇒ 返回错误,让调用方按自己的 fail-closed 口径处置, 而不是把它当成「没有命中规则」。
type Prefix ¶
type Prefix [][]string
Prefix 是命令前缀的一段:一个 token 的**备选集合**(空集合非法)。
例如 `git` 后面要表达 push 或 commit,就写成两段: `Prefix{{"git"}, {"push", "commit"}}`。
type Rule ¶
type Rule struct {
// Name 规则名(唯一;进报告,宿主用它留痕)。
Name string
// Prefix 前缀的各段(每段是备选集合)。
Prefix Prefix
// Decision 命中后的裁决。
Decision Decision
// Justification 给模型/人看的理由与替代建议(可选)。
Justification string
// Match 正例命令:必须在加载期被**本规则**命中。
Match []string
// NotMatch 反例命令:必须在加载期被**本规则**判不中。
NotMatch []string
}
Rule 一条前缀规则。
type Verdict ¶
type Verdict struct {
// Decision 裁决。
Decision Decision
// Rule 命中的规则名(未命中任何规则时为空)。
Rule string
// Justification 命中规则的理由(未命中时为空)。
Justification string
// Matched 命中的 token 数(0 表示没有规则命中)。
Matched int
// Wrote 含写重定向(任一非读侧重定向)。写重定向是**语句级事实**,
// 不在 argv 里——前缀规则结构上看不见它,故它驱动「事实面下限」
// (见 DecideCommand)。仅由 DecideCommand 填写;Decide(argv) 不知情、恒 false。
Wrote bool
// Dynamic 含动态展开($() / 反引号 / $VAR)。判据只看字面,
// 展开内容在判定时不可知 ⇒ 同样驱动事实面下限。同上,仅由 DecideCommand 填写。
Dynamic bool
}
Verdict 一次判定的结论。