mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.
Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift
Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.
Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.
At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.
First PR in the feat/error-contract-* series.
366 lines
13 KiB
Go
366 lines
13 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package hook
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/extension/platform"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// Install wraps every runnable command's RunE so the hook chain fires
|
|
// around it. The wrapper is:
|
|
//
|
|
// Before observers (always run, panic-safe)
|
|
// denial guard:
|
|
// if cmd is denied -> denyStub returns its CommandDeniedError
|
|
// else -> compose(matched Wrappers)(originalRunE) runs
|
|
// After observers (always run, panic-safe, sees inv.Err)
|
|
//
|
|
// Critical invariants enforced here (constraint #2):
|
|
//
|
|
// - **Denied commands NEVER reach the Wrap chain.** The guard runs
|
|
// denyStub directly so no plugin Wrapper can suppress or rewrite
|
|
// the denial. Observers still fire (audit must see the attempted
|
|
// call), but Wrap is physically out of the path.
|
|
//
|
|
// - **After observers always fire**, even when RunE returned an
|
|
// error. Wrap short-circuits via AbortError get converted to
|
|
// *output.ExitError so cmd/root.go emits the right envelope.
|
|
//
|
|
// - **Denial layer / source are populated from cobra annotations
|
|
// before any hook fires.** populateInvocationDenial reads the
|
|
// annotations attached by cmdpolicy.Apply and strictModeStubFrom,
|
|
// avoiding an import cycle between hook and cmdpolicy.
|
|
//
|
|
// Install must be called once during the Bootstrap pipeline after
|
|
// policy pruning has finished. Calling it twice on the same tree is a
|
|
// bug (each command's RunE would be wrapped multiple times).
|
|
func Install(root *cobra.Command, reg *Registry, snapshot CommandViewSource) {
|
|
if root == nil || reg == nil {
|
|
return
|
|
}
|
|
walkTree(root, func(c *cobra.Command) {
|
|
if !c.Runnable() {
|
|
return
|
|
}
|
|
if !c.HasParent() {
|
|
return // do not wrap the binary root itself
|
|
}
|
|
wrapRunE(c, reg, snapshot)
|
|
})
|
|
}
|
|
|
|
// CommandViewSource resolves a *cobra.Command into a CommandView. The
|
|
// default implementation returns a live view over the cobra node;
|
|
// strict-mode's replacement stubs (cmd/prune.go) carry the original
|
|
// command's annotations forward so the view keeps reporting accurate
|
|
// Risk / Identities / Domain after replacement.
|
|
type CommandViewSource interface {
|
|
View(cmd *cobra.Command) platform.CommandView
|
|
}
|
|
|
|
// wrapRunE replaces cmd.RunE with a hook-aware wrapper. The original
|
|
// RunE is captured by closure so the Wrapper chain can still call it
|
|
// as the innermost handler.
|
|
//
|
|
// The wrapper preserves the Run vs RunE distinction: cmd.Run is
|
|
// cleared because RunE wins when both are set and leaving a stale Run
|
|
// around is a hazard for future maintainers.
|
|
func wrapRunE(cmd *cobra.Command, reg *Registry, snapshot CommandViewSource) {
|
|
originalRunE := cmd.RunE
|
|
originalRun := cmd.Run
|
|
cmd.Run = nil
|
|
|
|
cmd.RunE = func(c *cobra.Command, args []string) error {
|
|
view := snapshot.View(c)
|
|
inv := newInvocation(view, args)
|
|
|
|
// Detect denial: a denied command's original RunE was already
|
|
// replaced by cmdpolicy.Apply with a denyStub that returns
|
|
// *output.ExitError wrapping *platform.CommandDeniedError. We
|
|
// invoke originalRunE once with a probe-only context (no args
|
|
// matter because DisableFlagParsing is set on denied commands)
|
|
// to extract its CommandDeniedError, but for V1 we use a
|
|
// simpler shortcut: cmdpolicy.Apply itself marks the command
|
|
// via cobra annotation; install reads the annotation directly.
|
|
populateInvocationDenial(inv, c)
|
|
|
|
ctx := c.Context()
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// === Before observers (panic-safe, always run) ===
|
|
for _, obs := range reg.MatchingObservers(view, platform.Before) {
|
|
runObserverSafe(ctx, obs, inv)
|
|
}
|
|
|
|
// === Denial guard ===
|
|
// If denied, run the originalRunE directly (it is the denyStub
|
|
// installed by cmdpolicy.Apply). The Wrap chain is bypassed.
|
|
var err error
|
|
if inv.DeniedByPolicy() {
|
|
err = invokeOriginal(ctx, c, args, originalRunE, originalRun)
|
|
} else {
|
|
// Compose matching Wrappers around the originalRunE. Each
|
|
// Wrapper is wrapped with a thin namespacing shim so any
|
|
// *AbortError returned has its HookName replaced with the
|
|
// framework-namespaced WrapperEntry.Name -- a plugin
|
|
// cannot impersonate another plugin's hook even by
|
|
// accident.
|
|
matched := reg.MatchingWrappers(view)
|
|
wrappers := make([]platform.Wrapper, 0, len(matched))
|
|
for _, w := range matched {
|
|
// Each plugin Wrapper is wrapped twice: once by the
|
|
// namespacing shim (AbortError attribution) and once
|
|
// by the panic shim (so a plugin panic becomes a
|
|
// structured hook envelope instead of crashing the
|
|
// process).
|
|
wrappers = append(wrappers, recoverWrap(w.Name, namespacedWrap(w.Name, w.Fn)))
|
|
}
|
|
composed := ComposeWrappers(wrappers)
|
|
// Pass the wrapRunE-local args, not i.Args(): the original
|
|
// RunE must see what cobra parsed, not what a hook may have
|
|
// observed via the read-only interface.
|
|
finalHandler := composed(func(c2 context.Context, _ platform.Invocation) error {
|
|
return invokeOriginal(c2, c, args, originalRunE, originalRun)
|
|
})
|
|
err = finalHandler(ctx, inv)
|
|
}
|
|
|
|
// Convert AbortError -> *output.ExitError so the envelope writer
|
|
// renders the structured "hook" type.
|
|
err = wrapAbortError(err)
|
|
|
|
inv.setErr(err)
|
|
|
|
// === After observers (panic-safe, always run, including
|
|
// when err != nil) ===
|
|
for _, obs := range reg.MatchingObservers(view, platform.After) {
|
|
runObserverSafe(ctx, obs, inv)
|
|
}
|
|
|
|
return err
|
|
}
|
|
}
|
|
|
|
// invokeOriginal runs whatever the original command logic was. If
|
|
// originalRunE is non-nil (the common case), use it; otherwise fall
|
|
// back to the Run variant. Commands without either are a programming
|
|
// error caught at registration time (cmd.Runnable() returns false).
|
|
//
|
|
// The wrapper-propagated ctx is set on cmd via SetContext *before* the
|
|
// inner RunE/Run is invoked, so any context values injected by an
|
|
// upstream Wrapper (auth tokens, request-scoped IDs, trace spans,
|
|
// cancellation deadlines) reach the original handler. Without this
|
|
// hand-off the inner handler would observe c.Context() — the
|
|
// pre-wrapper context — and silently lose every value the Wrap chain
|
|
// added.
|
|
//
|
|
// We restore the previous context on return so a single command's
|
|
// SetContext mutation cannot leak to sibling dispatches that share the
|
|
// same *cobra.Command pointer (cobra reuses the tree across calls in
|
|
// long-running embedders).
|
|
func invokeOriginal(ctx context.Context, c *cobra.Command, args []string, runE func(*cobra.Command, []string) error, run func(*cobra.Command, []string)) error {
|
|
prev := c.Context()
|
|
c.SetContext(ctx)
|
|
defer c.SetContext(prev)
|
|
|
|
if runE != nil {
|
|
return runE(c, args)
|
|
}
|
|
if run != nil {
|
|
run(c, args)
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// runObserverSafe invokes an Observer with panic recovery. Observers
|
|
// must not break the main flow; their job is side-effect-only and a
|
|
// broken plugin should not cascade into a failed CLI run.
|
|
func runObserverSafe(ctx context.Context, obs ObserverEntry, inv platform.Invocation) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
fmt.Fprintf(stderr(), "warning: hook %q panicked: %v\n", obs.Name, r)
|
|
}
|
|
}()
|
|
obs.Fn(ctx, inv)
|
|
}
|
|
|
|
// wrapAbortError converts *platform.AbortError into the equivalent
|
|
// *output.ExitError so cmd/root.go's envelope writer emits the right
|
|
// JSON structure (type="hook"). Non-AbortError values pass through
|
|
// unchanged.
|
|
//
|
|
// Deprecated: wrapAbortError converts to a legacy *output.ExitError that
|
|
// predates the typed error contract introduced by errs/. New code MUST NOT
|
|
// add producers of this shape — hook abort signals should move to a typed
|
|
// *errs.XxxError (typed hook error is tracked for the hook framework
|
|
// migration PR). This helper is retained only while existing call sites are
|
|
// migrated; it will be removed once they have moved to the typed surface.
|
|
func wrapAbortError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var ab *platform.AbortError
|
|
if !errors.As(err, &ab) {
|
|
return err
|
|
}
|
|
return &output.ExitError{
|
|
Code: output.ExitValidation,
|
|
Detail: &output.ErrDetail{
|
|
Type: "hook",
|
|
Message: ab.Error(),
|
|
Detail: map[string]any{
|
|
"hook_name": ab.HookName,
|
|
"reason": ab.Reason,
|
|
"reason_code": "aborted",
|
|
"detail": ab.Detail,
|
|
},
|
|
},
|
|
Err: ab,
|
|
}
|
|
}
|
|
|
|
// recoverWrap wraps a Wrapper so any panic anywhere in the plugin's
|
|
// implementation -- including the wrapper FACTORY call (the
|
|
// `func(next Handler) Handler` step) and the inner Handler call -- is
|
|
// recovered and surfaced as a structured *output.ExitError with
|
|
// type="hook" and reason_code="panic". Without this guard, a panicking
|
|
// plugin would crash the entire CLI process and break the structured-
|
|
// error contract (downstream automation cannot parse a stack trace).
|
|
//
|
|
// The recovered panic keeps the fully-qualified hook name (the same
|
|
// namespacing as namespacedWrap below uses) so on-call can pinpoint
|
|
// the offending plugin without grepping logs.
|
|
//
|
|
// **Why the factory call is inside the deferred recover**: a plugin
|
|
// can write something like
|
|
//
|
|
// func(next Handler) Handler {
|
|
// state := mustInit() // panics on bad config
|
|
// return func(...) error { ... use state ... }
|
|
// }
|
|
//
|
|
// If `mustInit` panics, the panic happens during composition
|
|
// (ComposeWrappers -> ws[i](next)) which runs at invocation time inside
|
|
// wrapRunE. Without recovering this branch, the whole CLI crashes.
|
|
// We pay a tiny per-invocation cost (one factory call per command
|
|
// dispatch) in exchange for total panic isolation.
|
|
//
|
|
// **Factory-local state lifetime contract**: any value the plugin's
|
|
// outer factory captures (`state` in the example above) is now created
|
|
// PER INVOCATION of the wrapped command -- it is NOT a one-shot init
|
|
// the way Plugin.Install is. Plugins that need long-lived state (a
|
|
// connection pool, an LRU cache, a metrics counter) MUST hold it on
|
|
// the Plugin struct or in a package-level variable; relying on
|
|
// closure-local memoisation inside the wrapper factory will silently
|
|
// reset on every command dispatch.
|
|
func recoverWrap(fullName string, w platform.Wrapper) platform.Wrapper {
|
|
return func(next platform.Handler) platform.Handler {
|
|
return func(ctx context.Context, inv platform.Invocation) (returned error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
returned = &output.ExitError{
|
|
Code: output.ExitValidation,
|
|
Detail: &output.ErrDetail{
|
|
Type: "hook",
|
|
Message: fmt.Sprintf("hook %q panicked: %v", fullName, r),
|
|
Detail: map[string]any{
|
|
"hook_name": fullName,
|
|
"reason_code": "panic",
|
|
"reason": fmt.Sprintf("%v", r),
|
|
},
|
|
},
|
|
Err: fmt.Errorf("hook %q panic: %v", fullName, r),
|
|
}
|
|
}
|
|
}()
|
|
// Construct AFTER the recover is armed so a panicking
|
|
// factory becomes a hook envelope instead of a process
|
|
// crash.
|
|
inner := w(next)
|
|
return inner(ctx, inv)
|
|
}
|
|
}
|
|
}
|
|
|
|
// namespacedWrap wraps a plugin's Wrapper so any *platform.AbortError it
|
|
// returns is replaced with a fresh copy whose HookName is the
|
|
// framework-namespaced name (e.g. "policy-plugin.policy"). Plugin
|
|
// authors do not need to know their own plugin name; the framework
|
|
// attribution is authoritative.
|
|
//
|
|
// **Why a copy, not mutation**: an AbortError value may be shared
|
|
// across concurrent command invocations (e.g. a plugin's package-level
|
|
// sentinel). Mutating it would race; copy keeps each invocation's
|
|
// attribution isolated.
|
|
//
|
|
// **Why only top-level AbortError, not wrapped**: a wrapped AbortError
|
|
// in a chain via fmt.Errorf("...: %w", ab) would require rebuilding
|
|
// the entire chain to substitute the value. The simpler contract --
|
|
// "plugin returns AbortError directly to short-circuit" -- is what we
|
|
// document, so we only namespace the top-level case. Wrapped
|
|
// AbortErrors keep whatever HookName the plugin set; that is still
|
|
// surfaced unchanged by the envelope writer.
|
|
func namespacedWrap(fullName string, w platform.Wrapper) platform.Wrapper {
|
|
return func(next platform.Handler) platform.Handler {
|
|
inner := w(next)
|
|
return func(ctx context.Context, inv platform.Invocation) error {
|
|
err := inner(ctx, inv)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if ab, ok := err.(*platform.AbortError); ok {
|
|
copied := *ab
|
|
copied.HookName = fullName
|
|
return &copied
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// stderr returns the stderr writer the wrapper uses for safe warnings.
|
|
// Indirected through a func so tests can substitute it.
|
|
var stderr = func() interface{ Write(p []byte) (int, error) } {
|
|
// Avoid pulling os just for stderr access -- the real impl lives
|
|
// in install_default.go (see file). The function is overridable
|
|
// to keep test isolation tight.
|
|
return defaultStderr
|
|
}
|
|
|
|
// populateInvocationDenial reads the cobra annotation set by
|
|
// cmdpolicy.Apply and propagates it onto the framework-internal
|
|
// invocation.
|
|
//
|
|
// V1 contract: a denial is signalled by the cobra annotation
|
|
// "lark:policy_denied_layer" being set on the command. The layer
|
|
// value is the enforcement layer ("policy" / "strict_mode") that
|
|
// gets emitted as detail.layer in the envelope; the source follows
|
|
// the annotation "lark:policy_denied_source".
|
|
//
|
|
// This indirection lets us avoid an import cycle between hook and
|
|
// pruning packages.
|
|
func populateInvocationDenial(inv *invocation, c *cobra.Command) {
|
|
const layerKey = "lark:policy_denied_layer"
|
|
const sourceKey = "lark:policy_denied_source"
|
|
if c.Annotations == nil {
|
|
return
|
|
}
|
|
layer, ok := c.Annotations[layerKey]
|
|
if !ok || layer == "" {
|
|
return
|
|
}
|
|
source := c.Annotations[sourceKey]
|
|
inv.setDenial(layer, source)
|
|
}
|