Help us shape the future of Go! Take the 2026 Go Developer Survey

invoke

package
v0.17.0 Latest Latest
Warning

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

Go to latest
Published: Oct 28, 2026 License: MIT Imports: 4 Imported by: 0

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

View Source
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

func EvaluateStages(ctx context.Context, stages []Stage, call Call) error

EvaluateStages 按序求值判定环:全部通过返回 nil;任一环拒绝、或环自身出错 (返回 error 或 panic),返回归口后的 *DenyError。

它存在的唯一理由是让**环语义只有一份**。Chain.Do 与任何「执行体不归自己管」的 调用方都调它,而不是各自再排一遍环。典型场景是程序化编排:模型写的一段代码在 沙箱里跑,代码里的 host.<tool>() 调宿主工具——那种执行体必须跑在沙箱绑定闭包里 (写回预算记账、追加调用账目、打断 VM),压根没法交给 Chain.Do,于是最容易 长出一份「自己排的环」。

复制一份环的代价不是代码重复,是**漂移**:环 panic 与环报错的归口环节名 (stageInternal)和 Cause 都是包内未导出常量,下游既不能引用也不能可靠复制 (硬编码字符串等于与实现耦合),一旦漏掉,「这环自己崩了」就会被谎报成 「这环正常拒绝了」——调用方于是去修策略,而真正该修的是守卫代码。

不持有执行体,因此不构成绕过链的出口:绕过需要 Handler,而 Handler 只有 NewChain 内部拿得到,Chain 也不提供取回方法。返回类型只有 error, 环节与执行体都出不来。

空工具名同样按拒绝处理,与 Chain.Do 一致:空名没有可判定的对象, 门禁不该对它乐观放行。

func Retryable

func Retryable(err error) bool

Retryable 判断一个错误是否值得重试。

认不出的错误一律 false(fail-closed):门禁两类结局的差别只在「是否可恢复」上, 猜错的代价不是少重试一次,而是不停重复一个永远不会成功的调用。

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 直接构造失败:返回一个「空转的链」比构造失败危险得多, 因为它看起来能用。

func (*Chain) Do

func (c *Chain) Do(ctx context.Context, call Call) (res Result, err error)

Do 执行一次调用:逐环判定,全部通过才到达执行体。

无论结局如何都恰好产出一次留痕。返回的 error 在拒绝时是 *DenyError, 调用方用 Retryable(err) 决定退避与否,不必解析错误文本。

func (*Chain) Stages

func (c *Chain) Stages() []string

Stages 返回已装配的环节名(只读快照,调试与自检用)。

刻意**不**返回 Stage 本身或 handler:任何能把执行体交出去的出口, 都是给绕过留的门。

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 一次被门禁拒绝的调用。带是哪一环拒的、为什么、值不值得重试, 让调用方不必靠解析错误字符串做重试决策。

func (*DenyError) Error

func (e *DenyError) Error() string

Error 实现 error。

func (*DenyError) Unwrap

func (e *DenyError) Unwrap() []error

Unwrap 让 errors.Is 同时能命中「归口错误」与「原始错误」。

归口错误(ErrStageFailed、ErrEmptyName)与原始错误都要能命中:调用方一要分清 「策略拒绝」与「门禁自己坏了」(处置不同),二要能顺着链路挖到根因, 三要能分清「压根没有可判定的对象」(空名)与「某环拒了我」。

type Handler

type Handler func(ctx context.Context, c Call) (Result, error)

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

func (o *Outcome) Acted() bool

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

func (o *Outcome) RefusalReason() string

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

func (r Record) Acted() bool

Acted 报告这次调用在动作层上是否真的动了手。

判定表:

  • Outcome != nil ⇒ 按执行体的声明判(声明优先):拒绝 ⇒ false,演练 ⇒ false。
  • Outcome == nil ⇒ 未声明:调用成功收口(Err == nil)按「执行了」处理, 失败收口按「没动」处理。

「声明优先」是关键:执行体说自己做了什么,就以它为准——只有它没说时, 才由链按收口结果兜底。否则「执行体动了手但回包失败」这类结局会被链 改写成「没动」,把执行体的事实覆盖成链的猜测。

func (Record) DryRun added in v0.16.0

func (r Record) DryRun() bool

DryRun 报告本次是否为演练(nil 安全)。演练不是拒绝,是调用方要的行为。

func (Record) Refused added in v0.16.0

func (r Record) Refused() bool

Refused 报告执行体是否拒绝了本次动作(nil 安全)。

与 DryRun 必须分开读:把演练记成拒绝,是在审计唯一要回答的问题上造假警报。

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

type Stage interface {
	Name() string
	Check(ctx context.Context, c Call) (Verdict, error)
}

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,便于宿主快速拼一条链而不必为每环声明类型。

func (StageFunc) Check

func (s StageFunc) Check(ctx context.Context, c Call) (Verdict, error)

Check 实现 Stage;Fn 为 nil 视为拒绝(配漏一环应当拒绝,不该放行)。

func (StageFunc) Name

func (s StageFunc) Name() string

Name 实现 Stage。

type StageResult

type StageResult struct {
	Stage    string
	Allow    bool
	Reason   string
	Duration time.Duration
}

StageResult 单环的判定留痕。

type Verdict

type Verdict struct {
	Allow  bool   // 放行进下一环 / 拒绝收口
	Reason string // 结论依据,供审计留痕;拒绝时必填
	// Retryable 该拒绝是否值得重试。默认 false —— 认不出的否定一律不可重试:
	// 不存在的东西不会因为重试而出现,退避只会把一次策略否定变成无穷重试。
	Retryable bool
}

Verdict 一环的判定结论。

Jump to

Keyboard shortcuts

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