Documentation
¶
Overview ¶
Package invoke 提供不可绕过的工具调用链:门禁链由构造保证,而不是由调用点的自觉保证。
为什么要有这个包 ¶
本仓既有的三包都只能**加**约束,没有一个能**强制**约束:
- policy.AuditGate 是装饰器——宿主忘了包一层,这次调用就没有审计门;
- toolsched 做的是调度与去重,不参与判定;
- toolprior 是优先级表,同样是数据不是闸。
「宿主忘了包」是个非常现实的失效形态:它不报错、不告警,工具照常执行, 事后从日志里也看不出这次调用没走审计门。OpenCode 2.0 上线一周内爆出的三个 安全回归(配置热重载窗口绕过 provider deny、系统级配置目录被静默丢弃、 TUI 层 sandbox 在工具执行搬进后台服务后静默失效)全部属于同一形态: **信任边界移动了,沙箱没跟着移动**,而且静默。
所以本包的立论是:把门禁从「装饰」改成「结构」。
结构在哪 ¶
NewChain 把 Handler **按值收进私有字段**,此后不再对外暴露。于是:
- 想执行一次调用,唯一的办法就是 chain.Do,它必然过完整条链;
- 宿主无法拿到裸 Handler 再绕过链去调——那个函数值已经被链吞掉了;
- 链不可变(构造后 stages 只读),并发下多个调用共享同一条链没有竞态。
这条不变量是**类型层面**的,不是约定层面的:绕过代码写不出来。
环语义只有一份 ¶
Chain.Do 之外还有一类调用方:执行体不归自己管,链用不上。典型形态是程序化编排—— 模型写的一段代码在沙箱里跑,代码里的 host.<tool>() 调宿主工具,那种执行体必须 跑在沙箱绑定闭包里(写回预算记账、追加调用账目、打断 VM),压根没法交给 Chain.Do。
这类调用方最容易长出一份「自己排的环」。而复制一份环的代价不是代码重复,是**漂移**: 环 panic 与环报错的归口环节名(stageInternal)与 Cause 都是包内未导出常量, 下游既不能引用也不能可靠复制(硬编码字符串等于与实现耦合)。漏掉的直接后果是—— 经那条路径触发的守卫 panic,被谎报成「守卫正常拒绝了」:调用方拿着错误去修策略, 而真正该修的是守卫代码。
因此求值环只有一个导出入口 EvaluateStages,Chain.Do 自己也调它:一份语义,两处受益, panic 归口只存在于本包一处。它不持有执行体(返回类型只有 error),所以不构成 绕过链的出口——绕过需要 Handler,而 Handler 只有 NewChain 内部拿得到。
与审计面的一致性 ¶
一次调用无论结局是放行、拒绝、阶段报错还是 panic,Do 都**恰好**产出一条 Record。 这条同样重要:本仓此前存在审计面不对称——经 Code Mode 的调用有完整调用账目, 普通调用没有对等物。能力更强的路径审计更好,会让人在评审时高估整体覆盖。
两层结论:门禁层与动作层 ¶
一次调用有**两个**结论,分属两层事实:
- **门禁层**(Record.Allowed / Record.Err):门禁判了什么。
- **动作层**(Record.Outcome):事情做没做。
链只保证**门禁层**恰好一条 Record——那是链能看见的东西:capability、 confirm 标志、每一环的判定。「命令是什么」只有工具知道,于是「工具自己在 执行体里拒了这次动作」或「这次只是演练」只能由执行体给出结论。
只留门禁层会漏掉一整类结局:工具自拒(缺 confirm)或演练(什么都没跑, 但这是调用方要的)都以**一次成功返回**结束 ⇒ 门禁层长成 Allowed=true、Err=nil, 而 Allowed 读起来是「执行了」。于是链只保证门禁层恰好一条 Record; **动作层结论由执行体在 Result.Outcome 里显式给出,链原样抄进 Record.Outcome**, 未声明时按「执行了」处理,保持既有调用方行为不变。
「拒绝」与「演练」在 Outcome 里是两个独立字段,不是同一个 bool 的两种取值: 合成之后下游一定会把演练记成拒绝——那是在审计唯一要回答的问题上造假警报。
fail-closed ¶
构造期与执行期都偏向拒绝:
- NewChain(nil handler) 直接构造失败,不返回一个「空转」的链;
- 空工具名按拒绝处理(门禁不该对空名乐观放行);
- Stage 返回 error 时按拒绝处理,不是按「跳过该环」处理;
- Stage 或 Handler panic 时被 recover 并转成错误,不杀编排进程。
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrNoHandler 构造链时未给执行体。 ErrNoHandler = errors.New("invoke: 链必须持有执行体") // ErrEmptyName 空工具名。空名无法定位、无法审计,按拒绝而非放行。 ErrEmptyName = errors.New("invoke: 工具名为空") // ErrStageFailed 门禁环自身报错,按拒绝收口。 ErrStageFailed = errors.New("invoke: 门禁环判定失败") // ErrPanic 门禁环或执行体 panic,已被 recover 并转成错误。 ErrPanic = errors.New("invoke: 调用过程中 panic") )
哨兵错误。用 oekit/pkg/errors(与全家族一致),调用方用 errors.Is 判定。
Functions ¶
func EvaluateStages ¶
EvaluateStages 按序求值判定环:全部通过返回 nil;任一环拒绝、或环自身出错 (返回 error 或 panic),返回归口后的 *DenyError。
它存在的唯一理由是让**环语义只有一份**。Chain.Do 与任何「执行体不归自己管」的 调用方都调它,而不是各自再排一遍环。典型场景是程序化编排:模型写的一段代码在 沙箱里跑,代码里的 host.<tool>() 调宿主工具——那种执行体必须跑在沙箱绑定闭包里 (写回预算记账、追加调用账目、打断 VM),压根没法交给 Chain.Do,于是最容易 长出一份「自己排的环」。
复制一份环的代价不是代码重复,是**漂移**:环 panic 与环报错的归口环节名 (stageInternal)和 Cause 都是包内未导出常量,下游既不能引用也不能可靠复制 (硬编码字符串等于与实现耦合),一旦漏掉,「这环自己崩了」就会被谎报成 「这环正常拒绝了」——调用方于是去修策略,而真正该修的是守卫代码。
不持有执行体,因此不构成绕过链的出口:绕过需要 Handler,而 Handler 只有 NewChain 内部拿得到,Chain 也不提供取回方法。返回类型只有 error, 环节与执行体都出不来。
空工具名同样按拒绝处理,与 Chain.Do 一致:空名没有可判定的对象, 门禁不该对它乐观放行。
Types ¶
type Call ¶
type Call struct {
Name string // 工具名,必填;空名按拒绝处理
Server string // 来源标识(如 MCP server 名),单工具场景可空
Caller string // 调用者标识(agent 名 / 环节名),供审计定位
Args string // 参数原文(通常是 JSON 文本),按次静态可判
}
Call 一次工具调用的统一契约。
刻意**不**携带任何框架类型(如 policy.Op):本包与 policy/toolegate 无耦合, 审计语义由各 Stage 自己从 Name/Server/Args 读,跨包的判定口径不互相污染。
type Chain ¶
type Chain struct {
// contains filtered or unexported fields
}
Chain 不可绕过的调用链。
构造后不可变:stages 只读、handler 私有、没有取回裸 handler 的方法。 因此并发下多调用共享一条链是安全的,不需要额外同步。
func NewChain ¶
func NewChain(handler Handler, stages []Stage, opts ...ChainOption) (*Chain, error)
NewChain 构造调用链。handler 按值收进私有字段,此后不再对外暴露—— 这是「不可绕过」的全部机制:绕过代码写不出来,而不是靠文档说不要这么写。
handler 为 nil 直接构造失败:返回一个「空转的链」比构造失败危险得多, 因为它看起来能用。
type ChainOption ¶
type ChainOption func(*Chain)
ChainOption 链构造选项。
func WithRecorder ¶
func WithRecorder(f func(Record)) ChainOption
WithRecorder 挂统一调用留痕。f 为 nil 时不挂(不留痕是显式选择,不是默认)。
type DenyError ¶
type DenyError struct {
Stage string // 拒绝的环节名,"precondition" 表示链前校验拒的
Tool string
Reason string
Retryable bool
// Cause 阶段报错/panic 的原始错误。为 nil 表示这是一次正常判定得出的拒绝,
// 而不是「这一环自己出问题了」——两者对调用方的处置完全不同。
Cause error
}
DenyError 一次被门禁拒绝的调用。带是哪一环拒的、为什么、值不值得重试, 让调用方不必靠解析错误字符串做重试决策。
type Handler ¶
Handler 真正的执行体——链的终点,也是链唯一持有的执行入口。
契约:Handler 不得把 ctx 之外的可变状态当成请求无关的;跨调用共享状态由调用方 自己负责同步。本包不替 Handler 加锁(那是执行体的事,不是链的事)。
执行体若在内部拒绝了这次动作、或做了一次演练(两者都以**成功返回**结束), 必须由它自己在 Result.Outcome 里声明:门禁能看见的只有 capability 与 confirm 标志,命令是什么只有执行体知道。门禁替它猜是不行的——猜错的方向恰恰是 审计唯一要回答的那个问题。
type Outcome ¶ added in v0.16.0
type Outcome struct {
// Refused 非空 ⇒ 执行体拒绝执行本次动作;值是拒绝原因。
//
// 用字符串而非 bool:拒绝原因本身就是审计要留的东西(缺 confirm?
// 超出配额?),把它压成一个 bool 就等于把原因扔在门外。
Refused string
// DryRun 为真 ⇒ 本次是演练:什么都没跑,但这是调用方要的(不是拒绝)。
DryRun bool
// WouldExecute 为真 ⇒ 这次演练**若真跑,会执行**;为假 ⇒ 不会
// (它被风险分类挡住了,差一次确认)。
//
// 它把「演练」分成两种:这条命令本来就能跑(只读 / 普通写,或确认本来就
// 带着),与它差一次确认才会动真格。只记 DryRun 的话,审计答不出
// 「这次差一步就动真格吗」——那是两个完全不同的结论。
//
// 语义边界:**只在 DryRun 为真时有意义**;不是演练时恒为 false。
// (演练什么都没跑,这一条不因 WouldExecute 而变——见 Acted。)
WouldExecute bool
}
Outcome 动作层结论——执行体自己的说法:「事情做没做」。
为什么需要它 ¶
一次调用有**两层**结论,分属两层事实:
- **门禁层**(Record.Allowed / Record.Err):门禁判了什么。门禁能看见的只有 capability 与 confirm 标志,**命令是什么只有工具知道**。
- **动作层**(本类型):事情做没做。只能由执行体给出。
只留门禁层会漏掉一整类结局:工具在执行体内部自己拒绝了这次动作(缺 confirm)、 或做了一次演练(什么都没跑,但这是调用方要的),两者都以**一次成功返回**结束 ⇒ 门禁层长成 Allowed=true, Err=nil,而 Allowed 读起来是「执行了」。
各字段必须独立 ¶
「拒绝」与「演练」不可合成一个 bool:合成之后下游一定会把演练记成拒绝—— 那是在审计唯一要回答的那个问题上造假警报。演练不是拒绝,是调用方要的行为。
「这次演练若真跑会不会跑」(WouldExecute)是第三个**独立**事实:它与 「拒绝/演练」都不正交(演练里两种都有),所以同样不能合成进同一个 bool。
func (*Outcome) Acted ¶ added in v0.16.0
Acted 报告「事情做没做」。
判定表:
- o == nil ⇒ true。未声明按「执行了」处理,保持既有调用方行为不变。
- Refused 非空 ⇒ false。执行体自己拒了,什么都没做。
- DryRun 为真 ⇒ false。演练:什么都没跑。
- 其余 ⇒ true。执行体声明了「做了」。
nil 安全:调用方拿到 *Outcome 之后不必先判空——未声明与「声明了但做了」 在这里都是 true,判空只会在下游写出一份漏判的分支。
⚠️ WouldExecute **不参与**这个判定:它是「若真跑会怎样」的预判,不是 「这次做了什么」的结论。演练(DryRun=true)即便 WouldExecute=true, 这次也什么都没做 ⇒ Acted() 仍为 false。
func (*Outcome) RefusalReason ¶ added in v0.16.0
RefusalReason 返回执行体给出的拒绝原因;未声明或未拒绝时返回空串。
nil 安全:调用方不必先判空就能读到「为什么被拒」——审计要的正是这句话。
type Record ¶
type Record struct {
Call Call
Stages []StageResult
Allowed bool // 是否真的到达了执行体
// Outcome 动作层结论:执行体自己声明的「事情做没做」,原样抄自 Result.Outcome。
//
// nil ⇒ 执行体未声明 ⇒ 按「执行了」处理。
// 它与 Allowed 分属两层事实:Allowed 说的是门禁判了什么,Outcome 说的是
// 事情做没做——执行体确实被到达了、门禁也确实放行了,但它可能自己拒了或只是演练。
Outcome *Outcome
Err error // 收口原因,nil 即放行成功
Duration time.Duration // 整链耗时
OutBytes int // 出参字节数(Text + Structured),留痕用
}
Record 一次调用的完整留痕。
不变式:**任何结局都恰好一条**——放行、拒绝、阶段报错、执行体报错、panic。 「有一条」和「只有一条」同样重要:多一条会让审计把同一次调用算成两次, 少一条则等于这次调用没发生过。
一条留痕里有**两层结论**(门禁层 / 动作层),缺一层审计就答不全问题: Allowed / Err 是门禁判了什么,Outcome 是事情做没做。见 Outcome 的说明。
func (Record) Acted ¶ added in v0.16.0
Acted 报告这次调用在动作层上是否真的动了手。
判定表:
- Outcome != nil ⇒ 按执行体的声明判(声明优先):拒绝 ⇒ false,演练 ⇒ false。
- Outcome == nil ⇒ 未声明:调用成功收口(Err == nil)按「执行了」处理, 失败收口按「没动」处理。
「声明优先」是关键:执行体说自己做了什么,就以它为准——只有它没说时, 才由链按收口结果兜底。否则「执行体动了手但回包失败」这类结局会被链 改写成「没动」,把执行体的事实覆盖成链的猜测。
type Result ¶
type Result struct {
Text string
Structured json.RawMessage
// Outcome 执行体自己的动作层结论(可选):「事情做没做」。
//
// nil ⇒ 未声明 ⇒ 按「执行了」处理,保持既有调用方行为不变。
// 声明了就由链原样抄进 Record(见 Record.Outcome),让留痕能回答
// 「这次到底动了没有」——那是门禁层回答不了的问题(门禁只知道 capability
// 与 confirm 标志,命令是什么只有工具知道)。
Outcome *Outcome
}
Result 统一出参。Text 与 Structured 至多其一有值,出参类型由 Handler 自决。
type Stage ¶
Stage 链上的一环。
返回约定(两条都重要):
- (Verdict, nil):按 Verdict 判定;
- (Verdict, error):**按拒绝处理**,链立刻收口。原因为空时用错误本身兜底。
error 不被当作「这环管不着,跳过」——那会让一次故障的门禁静默变成不设防。
type StageFunc ¶
type StageFunc struct {
// StageName 该环的名字,进审计留痕;空串按 "unnamed" 记录(不留空是因为
// 「哪一环拒的」正是审计要回答的问题,空名等于这个问题没有答案)。
StageName string
Fn func(ctx context.Context, c Call) (Verdict, error)
}
StageFunc 把普通函数适配成 Stage,便于宿主快速拼一条链而不必为每环声明类型。
type StageResult ¶
StageResult 单环的判定留痕。