Documentation
¶
Overview ¶
blacklist.go —— 黑名单硬规则与命令类判据。
职责:
- 持有内置黑名单模式表(自 internal/agentd/approver.go 迁入)
- 引号字面量剥离与执行包装器识别
- judgeCommand:命令类请求(bash 及其余工具)的判定
边界:
- 不认识工具名、不碰路径:路由在 permgate.go,路径判定在 path.go
path.go —— 写文件目标路径的范围归属判定。
职责:
- 把可能是相对路径、可能经软链的目标路径归一化为真实绝对路径
- 判定它是否落在任务范围(Workdir 或 TaskDir)的子树内
边界:
- 只读文件系统(EvalSymlinks 探测),不创建、不修改任何东西
- 不认识工具名、不做黑名单匹配
已知残余风险(TOCTOU):判定通过后、executor 实际写入前,软链可能被换掉。 闭合它需要在 executor 侧持有文件句柄,而写入动作发生在 agent 进程里, 超出 handoff 的可控范围——spec §5.4 明确接受此风险。
Package permgate 提供权限请求的结构化判据。
职责:
- 把一次权限请求判成三个出口之一:AutoAllow(立即放行)、Consult (交廉价模型审批者)、Escalate(直接升级人工协调者)
- 承载全部判定规则:黑名单模式匹配、引号剥离、执行包装器识别、 写文件目标路径的范围归属
边界:
- 纯计算,无 I/O:不写 store、不碰 adapter、不发网络请求(EvalSymlinks 的文件系统只读探测除外,它是路径判定的必需品)
- 无 deny 权:出口里没有「拒绝」——拒绝只有人能做,与 approver 同源
- 不做状态迁移、不建工单:调用方(manager)据 Verdict 决定后续动作
为什么判据要独立成包:三个 adapter(claude/grok/opencode)的权限载荷形态 完全不同,但判据必须只有一份——判据分散到 adapter 里,就会重演「opencode 有 external_directory、claude 和 grok 没有」这种各家一套的漂移。
redirect.go —— shell 输出重定向落点的提取。
职责:
- 从命令原文里摘出「输出会落到哪个文件」,供 judgeBash 做范围判定
- 展开 ~ 前缀;识别 /dev/* 这类不构成文件写入的丢弃落点
边界:
- 不做完整 shell 解析:不展开变量、不处理 heredoc、不解析子 shell、不跟 cd
- 不判断路径是否越界:范围判定在 path.go,本文件只负责「落点是什么」
为什么不能先过 StripQuoted:落点常常被引号包住(echo x > "/etc/foo"), 剥完引号落点就没了。这里要的恰恰是引号里的内容,所以扫描的是命令原文, 自己带引号状态机。
已知不覆盖(spec §4.3 明列的残余):相对路径逃逸(> ../../x)能摘出来并交 InScope 判,但 `cd /etc && echo x > passwd` 这种先换目录再相对写的形态摘到的 是 "passwd",InScope 会按 workdir 拼接判成范围内——本轮不跟 cd。
selfcmd.go —— handoff 自指令判据(身份越权)。
职责:
- 识别「executor 在 shell 里调用 handoff 自身 CLI 的变更类子命令」
- 只读子命令按白名单放行,其余一律判为自指令
边界:
- 不判危险性:rm -rf / sudo 这类由 blacklist.go 管。两者威胁轴不同—— 那边问「这条命令会不会破坏东西」,这边问「这个角色该不该做这件事」
- 不做引号剥离与执行包装器识别:那是 judgeCommand 的编排职责,本文件 只提供一次「这段文本里有没有自指令」的纯判定,无 I/O、无状态
writeargs.go —— 「落点在参数位」的写命令目的地提取。
职责:
- 从命令原文里摘出 tee/cp/mv/ln/install/dd 会写到哪个路径,供 judgeBash 做范围判定
- 展开 ~ 前缀(与 redirect.go 同一套语义)
边界:
- 不做完整 shell 解析:不展开变量、不解析子 shell、不跟 cd
- 不判断路径是否越界:范围判定在 path.go
- 不管重定向:那是 redirect.go 的事,两者由 judgeBash 分别调用后合并
为什么需要它:重定向落点由 RedirectTargets 覆盖,但同样是写仓库外, `echo x | tee /tmp/y`、`cp secret /outside`、`dd of=/tmp/x` 的落点在**参数位**, 摘不出来就只能落 Consult 交廉价模型(2026-08-18 真机实测:claude 上这两条都是 `交审批者 黑名单未命中`)。
为什么只看每一段的**首个词元**:这样 `git commit -m "cp a /etc/x"` 天然不误伤—— 段首是 git,引号里的 cp 不是命令。误伤一次的代价是平白叫醒协调者,比漏判更常发生。
Index ¶
- Constants
- func HasExecWrapper(s string) bool
- func InScope(path string, scope Scope) (in bool, base string, err error)
- func IsDiscardTarget(p string) bool
- func IsSelfCommand(s string) (hit bool, sub string)
- func RedirectTargets(cmd string) []string
- func StripQuoted(s string) string
- func WriteArgTargets(cmd string) []string
- type Action
- type Gate
- type Request
- type Scope
- type Verdict
Constants ¶
const RuleSelfCommand = "self-command"
RuleSelfCommand 是自指令命中时填进 Verdict.Rule 的固定值。
agentd 侧据它把「升级人工」这条日志的级别提到 Warn(见 manager.go 的 escalateLogLevel):自指令在本次改动前会被廉价模型静默放行,属于「本该 漏过、现在被拦下」那一类,必须在日志里一眼可见。
Variables ¶
This section is empty.
Functions ¶
func HasExecWrapper ¶
HasExecWrapper 判断命令是否含执行包装器(sh -c / bash -c / eval / xargs / 通用 -c/-e/-E 执行标志等)。
func InScope ¶
InScope 判定目标路径是否落在任务范围内。
参数:
- path: 目标路径,可为相对路径(按 scope.Workdir 解析)
- scope: 任务范围;其中为空的基准目录被跳过,不参与判定
返回:
- in: 是否落在范围内
- base: in=true 时命中的基准目录(归一化后),供日志说明「凭哪条放行」
- err: 路径归一化失败;调用方须按 fail-closed 处理为升级人工
注意:
- 用 filepath.Rel 判归属而非字符串前缀——strings.HasPrefix("/repo-evil/x", "/repo") 为真,前缀匹配会把仓库外的路径判成内部
- 对已存在的最长前缀求 EvalSymlinks——目标文件常常尚不存在(Write 新建), 不解软链则 `ln -s ~ /repo/link` 之后写 /repo/link/.ssh/authorized_keys 直接绕过
func IsDiscardTarget ¶ added in v0.3.0
IsDiscardTarget 判断落点是否为 /dev 下的丢弃或终端设备。
参数:p 为落点路径(已展开 ~) 返回:是则 true,调用方应跳过对它的范围判定
func IsSelfCommand ¶ added in v0.3.0
IsSelfCommand 判断命令文本里是否存在 handoff 的变更类自指令调用。
参数:s 为待判文本(bash 路由传 Command,其余路由传 Text)
返回:
- hit: 是否判为自指令
- sub: 命中的子命令名;未知子命令返回该词元原文;未命中返回 ""
判定分三步(spec §3.3):
- 按 | ; & 换行切段,逐段独立判定
- 段内找首个 basename 为 handoff/handoff.exe 的词元,其后不以 - 开头的 词元即候选
- 三级判定,顺序不可换:含变更词 → 命中;否则含白名单词 → 放行; 否则候选非空 → 命中
注意:本函数不处理引号与执行包装器,调用方(judgeCommand)负责按原文与 StripQuoted 结果各跑一遍。
func RedirectTargets ¶ added in v0.3.0
RedirectTargets 从命令串里摘出全部输出重定向的落点。
参数:cmd 为命令原文(**不要**先过 StripQuoted,见文件头 why) 返回:落点路径切片,按出现顺序;没有则返回 nil
识别的形态:`>`、`>>`、`>|`、`n>`、`n>>`、`&>`、`&>>` 明确排除的形态:fd 复制与关闭(`2>&1`、`>&2`、`>&-`)——它们不写文件
注意:
- 引号内的 `>` 不算重定向(`echo "a > b"`、`grep "x->y"` 都不命中)
- 落点带引号时取引号内的内容,可以含空格
- `~` 与 `~/` 前缀展开为当前用户 home(why 见 expandTilde)
func StripQuoted ¶
StripQuoted 把成对引号内的内容清空,保留引号本身。
参数:s 为原始命令串 返回:剥离后的串,如 `git commit -m "去掉 rm -rf 分支"` → `git commit -m ""`
注意:
- 不做完整 shell 解析。反斜杠转义(`"it\"s"`)会让本函数提前认为引号闭合, 结果是**剥得更少**——剥得少意味着更可能仍然命中黑名单、更可能 Escalate, 方向是安全的,因此接受
- 未闭合引号:引号之后的内容全部丢弃
func WriteArgTargets ¶ added in v0.3.0
WriteArgTargets 从命令串里摘出全部「参数位写落点」。
参数:cmd 为命令原文(**不要**先过 StripQuoted:落点常被引号包住,剥完就没了) 返回:落点路径切片,按出现顺序;没有则返回 nil
注意:
- 按 `|`、`&`、`;` 分段,逐段判首个词元是不是写命令——只有是,才摘该段的落点
- `--` 之后的词元一律不当标志看
- `~` 与 `~/xxx` 展开为当前用户 home(why 见 expandTilde)
- 丢弃落点(/dev/null 等)**不在这里过滤**:由 judgeBash 统一用 IsDiscardTarget 跳过, 两个落点来源共用同一处豁免,不会两边写两套
Types ¶
type Gate ¶
type Gate struct {
// contains filtered or unexported fields
}
Gate 持有编译后的黑名单,是判据的唯一入口。
func New ¶
New 构造判据网关。
参数:
- patterns: 用户自定义黑名单(config.ApproverConfig.Blacklist);内置 黑名单自动前置,无需调用方传入
- log: 包日志入口;nil 时用 slog.Default()
返回:
- 可用的 Gate;任一正则编译失败即返回错误(配置错误应在启动期暴露)
注意:
- **无论审批者是否启用都必须构造**。AutoAllow 是第 0 层静态判据、不是 审批者的职权;漏构造会让未配置审批者的部署被工作区内的每次写入淹没
func (*Gate) Judge ¶
Judge 判定一次权限请求(spec §3.4 的路由 + §7 的 fail-closed 表)。
参数:
- req: 结构化后的权限请求
- scope: 本任务的合法作用范围
返回:Verdict,调用方据 Action 决定后续动作
路由:
- write / edit → 路径归属判定,**并且**对 Text 跑一次黑名单(路径本身 可能命中,如 Write: /etc/sudoers);两项是与关系
- bash → 先判全部落点的范围归属,落点干净才做命令类判定
- 其余 → 对 Text 做命令类判定
注意:
- Truncated 一律直接 Escalate 且不再往下判:看到的是不完整的描述, 危险片段可能落在截断之外,黑名单与模型都不可信
- 本方法**永不因失败而返回 AutoAllow**(spec §7)
type Request ¶
type Request struct {
Tool string // 归一化工具名,取 executor.PermTool* 常量
Text string // 权限描述全文(与工单同源)
Command string // Tool=bash 时的完整命令串
Paths []string // Tool=write|edit 时的目标路径(可为相对路径)
Truncated bool // 描述含 executor.TruncationMarker
}
Request 是结构化后的权限请求。
Text 与 Command 不是重复:Text 是给人看的全文(与工单同源,形如 "Bash: xxx"),Command 是命令类工具的纯命令串。bash 路由判 Command, 其余路由退回判 Text。