cmdpolicy

package
v0.31.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 6 Imported by: 0

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` 在**加载期**逐条跑一遍,不一致即报错——于是「这条规则还活着吗」不是 事故后才回答的问题,而是启动时的断言。

这正是本仓「从没见过它触发的规则等于没有规则」那条纪律的机器化: 规则会随包名/命令名漂移,而漂移**不会报错**,只会静静地不再命中。

冲突口径:更具体者胜,同等具体取更严

多条规则同时命中时的裁决必须**确定性**,否则同一个命令在不同装配顺序下 得到不同结论(这类漂移最难查)。本包的口径:

  1. 前缀**更长**的规则胜(它更具体);
  2. 长度相同 ⇒ 取更严的一档(forbidden > prompt > allow);
  3. 仍相同 ⇒ 取**名字字典序最小**者,只为了让结果确定(不是有语义偏好)。

命令事实面:把「这条命令里出现了什么」交给上游 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 行分隔符合并),那份与上游差四处,**四处都是本仓那份错**:

  1. 双引号内的反斜杠:本仓丢弃**全部**反斜杠("a\b" → ab), 上游按 POSIX 只转义 " \ $ 反引号 与换行("a\b" → a\b)。 bash 实测与上游一致——本仓那份把文件名里的反斜杠吃掉, 判据看到的于是**另一个文件名**。
  2. 分隔符合并:本仓按 **token 相邻**合并(a & & b → a && b), 上游按**字符相邻**合并(a & & b → a & & b)。前者把两条独立的后台命令 伪装成一条 && 链。
  3. 回车:上游把 CR 归入空白(从配置/工单粘贴的 CRLF 命令行, 行尾 CR 不粘进最后一个 token),本仓当普通字符。
  4. 空参数:上游明确保留并有往返用例钉住,本仓当时也保留(但无契约、无用例)。

收编后本包不再维护第二份分词口径——口径归上游一处。 本包保留的是**上游没有的那一层**:按分隔符切段(SplitCommands)。

Index

Constants

This section is empty.

Variables

View Source
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) —— 判上游语义

View Source
var ErrUnjudgeable = errors.New("cmdpolicy: 命令未进判定(超长/解析失败),判不了就不放行")

ErrUnjudgeable 命令未进 AST 判定(超长 / 解析失败)。

判不了就不放行:与 Split 的 ErrUnclosedQuote 同一处置口径—— 报错让调用方按自己的 fail-closed 口径处置,而不是当成「没有命中规则」。

Functions

func Split

func Split(cmdline string) ([]string, error)

Split 把命令行按 shell 口径分词(单引号内原样、双引号内按 POSIX 处理反斜杠)。

本包**不执行**命令,分词只为判定;不做变量展开、不做命令替换—— 那两件事一旦做,判据就会被「展开后的样子」欺骗($RM -rf 在判定时展开, 与真正执行时的展开时点不同)。这里的立场是:**判据只看字面**, 需要展开的宿主应先把命令展开成 argv 再调 Decide。

实现是上游 oekit/pkg/shellx.Split 的转发:分词口径归上游一处, 本仓不维护第二份。

错误:未闭合引号 / 尾随反斜杠 → 返回错误(**判不了就不放行**), 调用方按自己的 fail-closed 口径处置,而不是把它当成「没有命中规则」。

func SplitCommands

func SplitCommands(cmdline string) ([]string, error)

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 Decision

type Decision int

Decision 是一条规则对某个命令的裁决。

const (
	// DecisionAllow 放行。
	DecisionAllow Decision = iota
	// DecisionPrompt 需人审:先问人,不直接拒。
	DecisionPrompt
	// DecisionForbidden 禁止。
	DecisionForbidden
)

func (Decision) String

func (d Decision) String() string

type Policy

type Policy struct {
	// contains filtered or unexported fields
}

Policy 一组已自检通过的前缀规则(构造后只读,可并发共享)。

func New

func New(rules []Rule) (*Policy, error)

New 构造判据并在**加载期**逐条自检。

自检项(任一不满足即返回错误,不静默降级):

  • 规则名非空且唯一;
  • Prefix 非空、每段非空;
  • Decision 在词表内;
  • 每条 Match 样例都真的被本规则命中;
  • 每条 NotMatch 样例都真的不被本规则命中。

func (*Policy) Decide

func (p *Policy) Decide(argv []string) Verdict

Decide 按 argv 判定(调用方已分好词时用这个,最省事也最没有歧义)。

func (*Policy) DecideCommand

func (p *Policy) DecideCommand(cmdline string) (Verdict, error)

DecideCommand 按命令行字符串判定(宿主手上只有命令行时用这个)。

判定面是全调用 AST,不只第一个 token

前缀判据有三类典型绕过,逐类在此闭合(事实面走上游 bashguard,见 facts.go):

  • **多段**:git status; rm -rf / 的前缀是 git——全调用面把 rm 那段也判了, 取最严(SplitCommands 仍保留,供宿主逐段留痕);
  • **命令替换**:echo $(rm -rf /) 里的 rm 是嵌套调用——全调用面看得见它;
  • **写重定向**:git status > /etc/passwd 的前缀仍是 git status,而 > 是 语句级事实、不在 argv 里——前缀规则结构上看不见它。

语义三条:

  1. **全调用面取最严**:管道后半、$(…) 嵌套、子 shell、多段里的每次调用 都过一遍前缀规则,取最严者;同等严取名字字典序最小(确定性)。
  2. **事实面下限**:含写重定向或动态展开($()/反引号/$VAR)时,裁决 **不低于 prompt**——这两类语义前缀规则看不见,静默 allow 就是 「判据比声明宽」。调用方若已把命令展开成 argv、且确知无重定向, 用 Decide(argv) 即可(那条路径不做事实面判定)。
  3. **判不了就不放行**:分词失败(未闭合引号 / 尾随转义)与 AST 未评估 (超长 / 解析失败)⇒ 返回错误,让调用方按自己的 fail-closed 口径处置, 而不是把它当成「没有命中规则」。

func (*Policy) Rules

func (p *Policy) Rules() []Rule

Rules 返回已加载规则的快照(按名字排序),供宿主自检与报告使用。

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 一次判定的结论。

func (Verdict) Explain

func (v Verdict) Explain() string

Explain 把裁决渲染成一句可回给模型的说明(拒绝/需人审时用)。

事实面信号(写重定向 / 动态展开)追加在后:它们常常是裁决被下限抬到 prompt 的**真正原因**,不写出来,模型看到「prompt (rule ...)" 会以为 是那条规则判的——下次就学会了「绕开这条规则」而不是「别写重定向」。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL