permgate

package
v0.3.2 Latest Latest
Warning

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

Go to latest
Published: Aug 19, 2026 License: Apache-2.0 Imports: 7 Imported by: 0

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

View Source
const RuleSelfCommand = "self-command"

RuleSelfCommand 是自指令命中时填进 Verdict.Rule 的固定值。

agentd 侧据它把「升级人工」这条日志的级别提到 Warn(见 manager.go 的 escalateLogLevel):自指令在本次改动前会被廉价模型静默放行,属于「本该 漏过、现在被拦下」那一类,必须在日志里一眼可见。

Variables

This section is empty.

Functions

func HasExecWrapper

func HasExecWrapper(s string) bool

HasExecWrapper 判断命令是否含执行包装器(sh -c / bash -c / eval / xargs / 通用 -c/-e/-E 执行标志等)。

func InScope

func InScope(path string, scope Scope) (in bool, base string, err error)

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

func IsDiscardTarget(p string) bool

IsDiscardTarget 判断落点是否为 /dev 下的丢弃或终端设备。

参数:p 为落点路径(已展开 ~) 返回:是则 true,调用方应跳过对它的范围判定

func IsSelfCommand added in v0.3.0

func IsSelfCommand(s string) (hit bool, sub string)

IsSelfCommand 判断命令文本里是否存在 handoff 的变更类自指令调用。

参数:s 为待判文本(bash 路由传 Command,其余路由传 Text)

返回:

  • hit: 是否判为自指令
  • sub: 命中的子命令名;未知子命令返回该词元原文;未命中返回 ""

判定分三步(spec §3.3):

  1. 按 | ; & 换行切段,逐段独立判定
  2. 段内找首个 basename 为 handoff/handoff.exe 的词元,其后不以 - 开头的 词元即候选
  3. 三级判定,顺序不可换:含变更词 → 命中;否则含白名单词 → 放行; 否则候选非空 → 命中

注意:本函数不处理引号与执行包装器,调用方(judgeCommand)负责按原文与 StripQuoted 结果各跑一遍。

func RedirectTargets added in v0.3.0

func RedirectTargets(cmd string) []string

RedirectTargets 从命令串里摘出全部输出重定向的落点。

参数:cmd 为命令原文(**不要**先过 StripQuoted,见文件头 why) 返回:落点路径切片,按出现顺序;没有则返回 nil

识别的形态:`>`、`>>`、`>|`、`n>`、`n>>`、`&>`、`&>>` 明确排除的形态:fd 复制与关闭(`2>&1`、`>&2`、`>&-`)——它们不写文件

注意:

  • 引号内的 `>` 不算重定向(`echo "a > b"`、`grep "x->y"` 都不命中)
  • 落点带引号时取引号内的内容,可以含空格
  • `~` 与 `~/` 前缀展开为当前用户 home(why 见 expandTilde)

func StripQuoted

func StripQuoted(s string) string

StripQuoted 把成对引号内的内容清空,保留引号本身。

参数:s 为原始命令串 返回:剥离后的串,如 `git commit -m "去掉 rm -rf 分支"` → `git commit -m ""`

注意:

  • 不做完整 shell 解析。反斜杠转义(`"it\"s"`)会让本函数提前认为引号闭合, 结果是**剥得更少**——剥得少意味着更可能仍然命中黑名单、更可能 Escalate, 方向是安全的,因此接受
  • 未闭合引号:引号之后的内容全部丢弃

func WriteArgTargets added in v0.3.0

func WriteArgTargets(cmd string) []string

WriteArgTargets 从命令串里摘出全部「参数位写落点」。

参数:cmd 为命令原文(**不要**先过 StripQuoted:落点常被引号包住,剥完就没了) 返回:落点路径切片,按出现顺序;没有则返回 nil

注意:

  • 按 `|`、`&`、`;` 分段,逐段判首个词元是不是写命令——只有是,才摘该段的落点
  • `--` 之后的词元一律不当标志看
  • `~` 与 `~/xxx` 展开为当前用户 home(why 见 expandTilde)
  • 丢弃落点(/dev/null 等)**不在这里过滤**:由 judgeBash 统一用 IsDiscardTarget 跳过, 两个落点来源共用同一处豁免,不会两边写两套

Types

type Action

type Action int

Action 是一次权限裁决的出口。

const (
	// AutoAllow 立即放行:不建工单、不发事件、不唤醒任何人。
	// 只可能出自 write/edit 路由(目标路径全部落在任务范围内)。
	AutoAllow Action = iota
	// Consult 交廉价模型审批者裁决(今天未命中黑名单时的默认路径)。
	Consult
	// Escalate 直接升级人工协调者(今天黑名单命中时的路径)。
	Escalate
)

func (Action) String

func (a Action) String() string

String 给出日志可读的短标签。

type Gate

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

Gate 持有编译后的黑名单,是判据的唯一入口。

func New

func New(patterns []string, log *slog.Logger) (*Gate, error)

New 构造判据网关。

参数:

  • patterns: 用户自定义黑名单(config.ApproverConfig.Blacklist);内置 黑名单自动前置,无需调用方传入
  • log: 包日志入口;nil 时用 slog.Default()

返回:

  • 可用的 Gate;任一正则编译失败即返回错误(配置错误应在启动期暴露)

注意:

  • **无论审批者是否启用都必须构造**。AutoAllow 是第 0 层静态判据、不是 审批者的职权;漏构造会让未配置审批者的部署被工作区内的每次写入淹没

func (*Gate) Judge

func (g *Gate) Judge(req Request, scope Scope) Verdict

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。

type Scope

type Scope struct {
	Workdir string
	TaskDir string
}

Scope 是本任务的合法作用范围。

两处都是 handoff 分配给该任务的空间:Workdir 是它要改的仓库/worktree, TaskDir 是 agentd 给它的 0700 私有目录。写这两处不该叫醒任何人。

type Verdict

type Verdict struct {
	Action Action
	Reason string
	Rule   string
}

Verdict 是裁决结果。

  • Reason: 可读理由,进日志与审计
  • Rule: 因黑名单而 Escalate 时命中的规则原文;其余情形为空

Jump to

Keyboard shortcuts

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