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

invoke

package
v0.12.0 Latest Latest
Warning

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

Go to latest
Published: Oct 21, 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 的调用有完整调用账目, 普通调用没有对等物。能力更强的路径审计更好,会让人在评审时高估整体覆盖。

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 加锁(那是执行体的事,不是链的事)。

type Record

type Record struct {
	Call     Call
	Stages   []StageResult
	Allowed  bool          // 是否真的到达了执行体
	Err      error         // 收口原因,nil 即放行成功
	Duration time.Duration // 整链耗时
	OutBytes int           // 出参字节数(Text + Structured),留痕用
}

Record 一次调用的完整留痕。

不变式:**任何结局都恰好一条**——放行、拒绝、阶段报错、执行体报错、panic。 「有一条」和「只有一条」同样重要:多一条会让审计把同一次调用算成两次, 少一条则等于这次调用没发生过。

type Result

type Result struct {
	Text       string
	Structured json.RawMessage
}

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