invoke

package
v0.21.0 Latest Latest
Warning

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

Go to latest
Published: Nov 7, 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 是否真的到达了执行体(#73):**到达即置位,与执行成败无关**——
	// 「已到达但出错」(副作用已施加、回包失败)是审计上最危险的结局,
	// 记成「未放行」会让读者以为门禁拦下了它。成败由 Err 表达:
	// Allowed=true 且 Err!=nil 即「执行体跑过但失败」。
	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 ⇒ 未声明:按 Allowed 兜底——门禁拦下(Allowed=false)⇒ 没动;到达过执行体(Allowed=true)⇒ 动了(#73 保守化:哪怕收口带错, 执行体已经跑过,副作用可能已施加,审计面宁可记「动了」也不记「没动」)。

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

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