chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package hook is the internal Hook dispatch implementation. It owns:
//
// - Registry the in-memory data store mapping (Stage|Event) ->
// registered hooks for fast dispatch
// - Install(root, …) the entry point that wraps every command's RunE
// so Before/After Observers and Wrap chains fire
// around the command's business logic, including
// the denial guard that physically isolates
// pruned commands from Wrap.
// - Emit(event, …) the lifecycle event firing helper used by the
// Bootstrap pipeline.
//
// Plugins NEVER import this package -- they only ever see
// extension/platform. The Registrar contract is implemented inside
// internal/platform, which delegates to this Registry after validating
// the plugin's calls (staging + atomic commit).
package hook
+130
View File
@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/extension/platform"
)
// shutdownDeadline is the hard upper bound on how long Shutdown
// handlers in total may run. Past this, the framework returns control
// to the caller regardless of unfinished handlers. 2s matches the
// design-doc constraint.
const shutdownDeadline = 2 * time.Second
// LifecycleError is the typed failure returned by Emit for non-Shutdown
// events when a LifecycleHandler returns an error or panics. Callers can
// errors.As to extract HookName, Event, and the Panic discriminator
// (panic vs returned error) so the envelope writer can produce
// distinct reason_code values:
//
// - Panic == false -> reason_code = "lifecycle_failed"
// - Panic == true -> reason_code = "lifecycle_panic"
//
// Shutdown handler failures are logged inside emitShutdown and never
// returned through this type (Shutdown is non-recoverable; the contract
// is "best effort, never block exit").
type LifecycleError struct {
Event platform.LifecycleEvent
HookName string
Panic bool
Cause error
}
func (e *LifecycleError) Error() string {
kind := "failed"
if e.Panic {
kind = "panic"
}
return fmt.Sprintf("lifecycle hook %q %s: %v", e.HookName, kind, e.Cause)
}
func (e *LifecycleError) Unwrap() error { return e.Cause }
// Emit fires every LifecycleHandler registered for event in
// registration order. lastErr is propagated to handlers via
// LifecycleContext.Err (typical use: Shutdown handlers see the error
// the command exited with).
//
// Behaviour by event:
//
// - Startup: any handler returning a non-nil error aborts the
// bootstrap (caller decides whether to fail-closed). The first
// such error is returned as *LifecycleError.
//
// - Shutdown: handler errors are logged but do not affect the
// returned error; the framework also caps the total time at
// shutdownDeadline.
func Emit(ctx context.Context, reg *Registry, event platform.LifecycleEvent, lastErr error) error {
if reg == nil {
return nil
}
handlers := reg.LifecycleHandlers(event)
if len(handlers) == 0 {
return nil
}
lc := &platform.LifecycleContext{Event: event, Err: lastErr}
if event == platform.Shutdown {
return emitShutdown(ctx, handlers, lc)
}
for _, h := range handlers {
if err := callLifecycleSafe(ctx, h, lc); err != nil {
return err
}
}
return nil
}
// emitShutdown enforces the 2-second total deadline. Handlers receive
// a derived context with the remaining budget; once the budget is
// exhausted, the remaining handlers are skipped (with a stderr
// warning) and Emit returns.
func emitShutdown(parent context.Context, handlers []LifecycleEntry, lc *platform.LifecycleContext) error {
ctx, cancel := context.WithTimeout(parent, shutdownDeadline)
defer cancel()
deadline := time.Now().Add(shutdownDeadline)
for _, h := range handlers {
if time.Now().After(deadline) {
fmt.Fprintf(stderr(), "warning: shutdown deadline exceeded; skipping hook %q\n", h.Name)
continue
}
if err := callLifecycleSafe(ctx, h, lc); err != nil {
// Shutdown errors are logged, not propagated -- exit is
// non-recoverable anyway.
fmt.Fprintf(stderr(), "warning: shutdown hook %q: %v\n", h.Name, err)
}
}
return nil
}
// callLifecycleSafe invokes a LifecycleHandler with panic recovery.
// Returns *LifecycleError with Panic=true on recovered panic, Panic=false
// on a regular returned error. nil if the handler succeeded.
func callLifecycleSafe(ctx context.Context, h LifecycleEntry, lc *platform.LifecycleContext) (err error) {
defer func() {
if r := recover(); r != nil {
err = &LifecycleError{
Event: lc.Event,
HookName: h.Name,
Panic: true,
Cause: fmt.Errorf("%v", r),
}
}
}()
if e := h.Fn(ctx, lc); e != nil {
return &LifecycleError{
Event: lc.Event,
HookName: h.Name,
Panic: false,
Cause: e,
}
}
return nil
}
+110
View File
@@ -0,0 +1,110 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import (
"context"
"errors"
"testing"
"github.com/larksuite/cli/extension/platform"
)
// A Startup handler returning a regular error must surface as a typed
// *LifecycleError with Panic=false so the cmd-layer guard can pick
// reason_code=lifecycle_failed.
func TestEmit_StartupHandlerError_TypedError(t *testing.T) {
reg := NewRegistry()
want := errors.New("backend down")
reg.AddLifecycle(LifecycleEntry{
Event: platform.Startup,
Name: "p.boot",
Fn: func(context.Context, *platform.LifecycleContext) error { return want },
})
got := Emit(context.Background(), reg, platform.Startup, nil)
if got == nil {
t.Fatal("expected error from Emit, got nil")
}
var le *LifecycleError
if !errors.As(got, &le) {
t.Fatalf("expected *LifecycleError, got %T %v", got, got)
}
if le.Panic {
t.Errorf("Panic = true, want false (returned error)")
}
if le.HookName != "p.boot" {
t.Errorf("HookName = %q, want p.boot", le.HookName)
}
if !errors.Is(got, want) {
t.Errorf("unwrap should reach original error")
}
}
// A Startup handler that panics must be recovered and surface as a
// typed *LifecycleError with Panic=true so the cmd-layer guard can
// pick reason_code=lifecycle_panic.
func TestEmit_StartupHandlerPanic_TypedError(t *testing.T) {
reg := NewRegistry()
reg.AddLifecycle(LifecycleEntry{
Event: platform.Startup,
Name: "p.boot",
Fn: func(context.Context, *platform.LifecycleContext) error { panic("boom") },
})
got := Emit(context.Background(), reg, platform.Startup, nil)
if got == nil {
t.Fatal("expected error from Emit, got nil")
}
var le *LifecycleError
if !errors.As(got, &le) {
t.Fatalf("expected *LifecycleError, got %T %v", got, got)
}
if !le.Panic {
t.Errorf("Panic = false, want true (recovered panic)")
}
if le.HookName != "p.boot" {
t.Errorf("HookName = %q, want p.boot", le.HookName)
}
}
// A Startup handler that succeeds returns nil; subsequent handlers run.
func TestEmit_StartupAllHandlersRun(t *testing.T) {
reg := NewRegistry()
var calls []string
reg.AddLifecycle(LifecycleEntry{
Event: platform.Startup, Name: "a",
Fn: func(context.Context, *platform.LifecycleContext) error {
calls = append(calls, "a")
return nil
},
})
reg.AddLifecycle(LifecycleEntry{
Event: platform.Startup, Name: "b",
Fn: func(context.Context, *platform.LifecycleContext) error {
calls = append(calls, "b")
return nil
},
})
if err := Emit(context.Background(), reg, platform.Startup, nil); err != nil {
t.Fatalf("Emit: %v", err)
}
if len(calls) != 2 || calls[0] != "a" || calls[1] != "b" {
t.Errorf("handlers fired in unexpected order: %v", calls)
}
}
// Shutdown handler errors are logged, not propagated; Emit returns nil.
func TestEmit_ShutdownErrorsSwallowed(t *testing.T) {
reg := NewRegistry()
reg.AddLifecycle(LifecycleEntry{
Event: platform.Shutdown, Name: "flush",
Fn: func(context.Context, *platform.LifecycleContext) error {
return errors.New("flush failed")
},
})
if err := Emit(context.Background(), reg, platform.Shutdown, nil); err != nil {
t.Errorf("Shutdown errors must NOT propagate, got: %v", err)
}
}
+349
View File
@@ -0,0 +1,349 @@
// 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/errs"
"github.com/larksuite/cli/extension/platform"
)
// 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 a
// typed *errs.ValidationError 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 a
// typed error 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 -> typed *errs.ValidationError so the
// envelope writer renders the structured envelope.
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 a typed
// *errs.ValidationError (failed_precondition) so cmd/root.go's typed
// envelope writer emits the structured JSON envelope. Non-AbortError
// values pass through unchanged.
//
// The AbortError is preserved as the Cause so errors.As consumers can
// still extract HookName / Reason / Detail in process.
func wrapAbortError(err error) error {
if err == nil {
return nil
}
var ab *platform.AbortError
if !errors.As(err, &ab) {
return err
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", ab.Error()).
WithHint("plugin hook %q aborted this command; adjust the request to satisfy the hook's policy, or remove the plugin", ab.HookName).
WithCause(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 typed *errs.ValidationError
// (failed_precondition). 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 {
// Preserve the panic value's error identity in the cause
// chain when it is an error, so errors.Is/As can still reach
// it; fall back to %v formatting for non-error panics.
cause := fmt.Errorf("hook %q panic: %v", fullName, r)
if e, ok := r.(error); ok {
cause = fmt.Errorf("hook %q panic: %w", fullName, e)
}
returned = errs.NewValidationError(errs.SubtypeFailedPrecondition,
"hook %q panicked: %v", fullName, r).
WithHint("plugin hook %q crashed while handling this command; report the panic to the plugin author or remove the plugin", fullName).
WithCause(cause)
}
}()
// 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)
}
+11
View File
@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import "os"
// defaultStderr is the real os.Stderr writer. Kept in a separate file so
// tests can replace `stderr` (in install.go) with a buffer without
// shadowing this variable.
var defaultStderr = os.Stderr //nolint:forbidigo // framework-level fallback writer; hooks fire before IOStreams plumbing is available
+414
View File
@@ -0,0 +1,414 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook_test
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
)
// fakeViewSource is a minimal CommandView for tests -- it ignores the
// cobra command and returns a fixed view.
type fakeViewSource struct{ view platform.CommandView }
func (f fakeViewSource) View(*cobra.Command) platform.CommandView { return f.view }
type fakeView struct {
path string
risk string
}
func (v fakeView) Path() string { return v.path }
func (v fakeView) Domain() string { return "" }
func (v fakeView) Risk() (platform.Risk, bool) { return platform.Risk(v.risk), v.risk != "" }
func (v fakeView) Identities() []platform.Identity { return nil }
func (v fakeView) Annotation(string) (string, bool) { return "", false }
func makeLeaf(use string) *cobra.Command {
return &cobra.Command{Use: use, RunE: func(*cobra.Command, []string) error { return nil }}
}
// Observers fire on Before AND After even when RunE returns an error.
// This is the failure-path observability contract -- After must always
// run so audit hooks see completion regardless of outcome.
func TestInstall_observersBeforeAndAfterAlwaysRun(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error {
return errors.New("boom")
}}
root.AddCommand(leaf)
reg := hook.NewRegistry()
var seen []string
reg.AddObserver(hook.ObserverEntry{
Name: "before", When: platform.Before, Selector: platform.All(),
Fn: func(_ context.Context, inv platform.Invocation) {
seen = append(seen, fmt.Sprintf("before:err=%v", inv.Err()))
},
})
reg.AddObserver(hook.ObserverEntry{
Name: "after", When: platform.After, Selector: platform.All(),
Fn: func(_ context.Context, inv platform.Invocation) {
seen = append(seen, fmt.Sprintf("after:err=%v", inv.Err()))
},
})
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
err := leaf.RunE(leaf, nil)
if err == nil || err.Error() != "boom" {
t.Fatalf("expected RunE to return original error, got %v", err)
}
wantBefore := "before:err=<nil>" // before fires with Err still nil
wantAfter := "after:err=boom" // after sees the failed RunE error
if len(seen) != 2 || seen[0] != wantBefore || seen[1] != wantAfter {
t.Fatalf("observer ordering / Err propagation broken, got %v", seen)
}
}
// Wrap chain composes outermost-first (registration order). A regression
// that inverts the composition would change which Wrapper short-circuits
// first for safety-sensitive layers.
func TestInstall_wrapperChainOrder(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
var order []string
leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error {
order = append(order, "RunE")
return nil
}}
root.AddCommand(leaf)
reg := hook.NewRegistry()
reg.AddWrapper(hook.WrapperEntry{
Name: "outer", Selector: platform.All(),
Fn: func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
order = append(order, "outer-before")
err := next(ctx, inv)
order = append(order, "outer-after")
return err
}
},
})
reg.AddWrapper(hook.WrapperEntry{
Name: "inner", Selector: platform.All(),
Fn: func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
order = append(order, "inner-before")
err := next(ctx, inv)
order = append(order, "inner-after")
return err
}
},
})
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
if err := leaf.RunE(leaf, nil); err != nil {
t.Fatalf("RunE: %v", err)
}
want := []string{"outer-before", "inner-before", "RunE", "inner-after", "outer-after"}
if !equalStrings(order, want) {
t.Fatalf("Wrapper order = %v, want %v", order, want)
}
}
// Denial guard physical isolation: the most safety-critical invariant.
// A denied command must NEVER reach a Wrap chain. We register a Wrap
// that, given the chance, would silently allow the call (return nil,
// don't call next, no AbortError). The guard must skip Wrap entirely
// so the denyStub's error reaches the caller.
//
// Without this guarantee, any plugin Wrap matching All() could
// bypass user policy / strict-mode denials.
func TestInstall_denialGuard_physicalIsolation(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
denyStubCalled := false
leaf := &cobra.Command{
Use: "+forbidden",
RunE: func(*cobra.Command, []string) error {
denyStubCalled = true
return errors.New("CommandPruned: this is the denyStub")
},
Annotations: map[string]string{
"lark:policy_denied_layer": "policy",
"lark:policy_denied_source": "yaml",
},
}
root.AddCommand(leaf)
reg := hook.NewRegistry()
maliciousWrapCalled := false
reg.AddWrapper(hook.WrapperEntry{
Name: "malicious", Selector: platform.All(),
Fn: func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
maliciousWrapCalled = true
return nil // suppress the denial
}
},
})
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+forbidden"}})
err := leaf.RunE(leaf, nil)
if maliciousWrapCalled {
t.Errorf("denial guard violated: Wrap was invoked on a denied command")
}
if !denyStubCalled {
t.Errorf("denyStub (original RunE) should still run on the denial path")
}
if err == nil {
t.Fatalf("denyStub error must propagate, got nil")
}
}
// Observer panics must not break the main flow. The guard converts the
// panic to a stderr warning and continues; the command still runs.
func TestInstall_observerPanicIsolated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
runECalled := false
leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error {
runECalled = true
return nil
}}
root.AddCommand(leaf)
reg := hook.NewRegistry()
reg.AddObserver(hook.ObserverEntry{
Name: "buggy", When: platform.Before, Selector: platform.All(),
Fn: func(context.Context, platform.Invocation) {
panic("plugin author wrote bad code")
},
})
// Capture stderr to make sure the warning was emitted. Restore the
// previous sink so a subsequent test isn't stuck writing into our
// discarded buffer.
t.Cleanup(hook.SetStderrForTesting(&bytes.Buffer{})) // discard
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
if err := leaf.RunE(leaf, nil); err != nil {
t.Fatalf("RunE should still succeed when an Observer panicked, got %v", err)
}
if !runECalled {
t.Errorf("RunE must execute despite Observer panic")
}
}
// A Wrapper returning AbortError surfaces as a typed
// *errs.ValidationError (failed_precondition, exit 2) so cmd/root.go's
// envelope writer can serialise it. The original AbortError is preserved
// as the Cause so errors.As consumers still reach HookName / Reason.
func TestInstall_abortErrorBecomesExitError(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leaf := makeLeaf("+x")
root.AddCommand(leaf)
reg := hook.NewRegistry()
reg.AddWrapper(hook.WrapperEntry{
Name: "rejecter", Selector: platform.All(),
Fn: func(_ platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error {
return &platform.AbortError{
HookName: "rejecter",
Reason: "policy says no",
}
}
},
})
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
err := leaf.RunE(leaf, nil)
if err == nil {
t.Fatalf("Wrap aborted; expected error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("AbortError must convert to *errs.ValidationError, got %T %+v", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
}
// The hook name must be discoverable in the user-facing hint.
if !strings.Contains(ve.Hint, "rejecter") {
t.Errorf("hint must carry hook name rejecter, got %q", ve.Hint)
}
// The original AbortError must still be reachable via errors.As, with
// its attribution intact.
var ab *platform.AbortError
if !errors.As(err, &ab) {
t.Fatalf("error chain should expose *platform.AbortError")
}
if ab.HookName != "rejecter" || ab.Reason != "policy says no" {
t.Errorf("AbortError = %+v, want HookName=rejecter Reason=%q", ab, "policy says no")
}
}
// namespacedWrap must not mutate a shared *AbortError. A plugin author
// might construct a sentinel at package scope and return it from
// multiple Wrap invocations; mutating it would let attribution leak
// across concurrent command runs and would also race.
//
// Production path test: drive a real cobra.Command through Install
// so namespacedWrap inside install.go is exercised. The plugin returns
// the same sentinel pointer twice. Both observed envelopes must have
// the framework-namespaced HookName, but the sentinel's own HookName
// must remain whatever the plugin originally set.
func TestInstall_namespacedWrap_doesNotMutateSentinel(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leafA := makeLeaf("+a")
leafB := makeLeaf("+b")
root.AddCommand(leafA)
root.AddCommand(leafB)
sentinel := &platform.AbortError{HookName: "sentinel-original", Reason: "no"}
reg := hook.NewRegistry()
// Two Wrappers, different namespaced names, return the SAME
// sentinel.
reg.AddWrapper(hook.WrapperEntry{
Name: "plugin-a.wrap",
Selector: platform.ByCommandPath("+a"),
Fn: func(platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error { return sentinel }
},
})
reg.AddWrapper(hook.WrapperEntry{
Name: "plugin-b.wrap",
Selector: platform.ByCommandPath("+b"),
Fn: func(platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error { return sentinel }
},
})
hook.Install(root, reg, fakeViewSourceByPath{})
// Invoke both leaves.
errA := leafA.RunE(leafA, nil)
errB := leafB.RunE(leafB, nil)
// Sentinel must remain untouched: the framework must copy before
// rewriting HookName.
if sentinel.HookName != "sentinel-original" {
t.Errorf("sentinel AbortError was mutated: HookName = %q", sentinel.HookName)
}
// Each invocation's envelope must carry the correct namespace --
// proving the framework DID set the right name on its own copy.
checkHookName(t, errA, "plugin-a.wrap")
checkHookName(t, errB, "plugin-b.wrap")
}
// fakeViewSourceByPath returns a CommandView whose Path matches the
// leaf's Use field (so ByCommandPath selectors discriminate).
type fakeViewSourceByPath struct{}
func (fakeViewSourceByPath) View(c *cobra.Command) platform.CommandView {
return fakeView{path: c.Use}
}
func checkHookName(t *testing.T, err error, want string) {
t.Helper()
// The abort surfaces as a typed *errs.ValidationError; the original
// (namespaced copy of the) AbortError is preserved as its Cause, so
// errors.As reaches the attribution the framework wrote.
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
var ab *platform.AbortError
if !errors.As(err, &ab) {
t.Fatalf("error chain should expose *platform.AbortError, got %T", err)
}
if ab.HookName != want {
t.Errorf("hook_name = %v, want %v", ab.HookName, want)
}
}
// A Before observer mutating inv.Args() must not affect what the
// original RunE sees: pins the slice-level read-only contract.
func TestInstall_argsNotMutableByObserver(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
var seenByRunE []string
leaf := &cobra.Command{
Use: "+echo",
RunE: func(_ *cobra.Command, args []string) error {
seenByRunE = append([]string(nil), args...)
return nil
},
}
root.AddCommand(leaf)
reg := hook.NewRegistry()
reg.AddObserver(hook.ObserverEntry{
Name: "tamper", When: platform.Before, Selector: platform.All(),
Fn: func(_ context.Context, inv platform.Invocation) {
got := inv.Args()
if len(got) > 0 {
got[0] = "HIJACKED"
}
},
})
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+echo"}})
originalArgs := []string{"hello", "world"}
if err := leaf.RunE(leaf, originalArgs); err != nil {
t.Fatalf("RunE returned %v", err)
}
if !equalStrings(seenByRunE, originalArgs) {
t.Fatalf("RunE saw mutated args: got %v, want %v", seenByRunE, originalArgs)
}
if originalArgs[0] != "hello" {
t.Fatalf("caller's original args were mutated: %v", originalArgs)
}
}
// Root command (no parent) must never be wrapped -- it dispatches help
// and other framework concerns. The root has no RunE so we instead
// verify the root's children are wrapped while the root itself remains
// untouched (RunE stays nil).
func TestInstall_rootStaysUntouched(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leaf := makeLeaf("+x")
root.AddCommand(leaf)
reg := hook.NewRegistry()
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
if root.RunE != nil {
t.Fatalf("root.RunE should remain nil after Install")
}
if leaf.RunE == nil {
t.Fatalf("child leaf.RunE must remain non-nil (wrapped)")
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import (
"time"
"github.com/larksuite/cli/extension/platform"
)
// invocation is the framework-side concrete implementation of
// platform.Invocation. All setters are unexported so plugin code
// (which only sees the platform.Invocation interface) cannot mutate
// state.
type invocation struct {
cmd platform.CommandView
args []string
started time.Time
err error
denied bool
layer string
source string
}
// newInvocation copies args so the read-only platform.Invocation
// contract holds at the slice level: a hook cannot mutate the args
// the original RunE will see.
func newInvocation(cmd platform.CommandView, args []string) *invocation {
argsCopy := append([]string(nil), args...)
return &invocation{
cmd: cmd,
args: argsCopy,
started: time.Now(),
}
}
// --- platform.Invocation read interface ---
func (i *invocation) Cmd() platform.CommandView { return i.cmd }
// Args returns a fresh copy every call; see newInvocation.
func (i *invocation) Args() []string {
out := make([]string, len(i.args))
copy(out, i.args)
return out
}
func (i *invocation) Started() time.Time { return i.started }
func (i *invocation) Err() error { return i.err }
func (i *invocation) DeniedByPolicy() bool { return i.denied }
func (i *invocation) DenialLayer() string { return i.layer }
func (i *invocation) DenialPolicySource() string {
return i.source
}
// --- framework-internal setters (unexported) ---
func (i *invocation) setDenial(layer, source string) {
i.denied = true
i.layer = layer
i.source = source
}
func (i *invocation) setErr(err error) {
i.err = err
}
+184
View File
@@ -0,0 +1,184 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import (
"context"
"sync"
"github.com/larksuite/cli/extension/platform"
)
// ObserverEntry stores one Observer registration. The full hook name
// (already namespaced with plugin prefix by the caller) lets diagnostic
// output point at the responsible plugin.
type ObserverEntry struct {
Name string
When platform.When
Selector platform.Selector
Fn platform.Observer
}
// WrapperEntry stores one Wrapper registration. Wrappers compose in
// registration order; the outermost (registered first) runs first.
type WrapperEntry struct {
Name string
Selector platform.Selector
Fn platform.Wrapper
}
// LifecycleEntry stores one lifecycle handler. Selector is unused
// (lifecycle events are global), but Name is preserved for diagnostics.
type LifecycleEntry struct {
Name string
Event platform.LifecycleEvent
Fn platform.LifecycleHandler
}
// Registry holds all registered hooks. The framework constructs one
// Registry per binary execution; concurrent reads after Install
// commits are safe because the maps are not mutated thereafter. Writes
// (during Install) are serialised by the internalplatform.
type Registry struct {
mu sync.RWMutex
observers []ObserverEntry
wrappers []WrapperEntry
lifecycles []LifecycleEntry
}
// NewRegistry returns an empty Registry.
func NewRegistry() *Registry { return &Registry{} }
// Observers returns a snapshot of all registered observers. Order is
// registration order. Diagnostic commands (config plugins show) call
// this to enumerate every hook attached to the binary.
func (r *Registry) Observers() []ObserverEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]ObserverEntry, len(r.observers))
copy(out, r.observers)
return out
}
// Wrappers returns a snapshot of all registered wrappers. Order is
// registration order (outermost first).
func (r *Registry) Wrappers() []WrapperEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]WrapperEntry, len(r.wrappers))
copy(out, r.wrappers)
return out
}
// Lifecycles returns a snapshot of all registered lifecycle handlers.
func (r *Registry) Lifecycles() []LifecycleEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]LifecycleEntry, len(r.lifecycles))
copy(out, r.lifecycles)
return out
}
// AddObserver registers an Observer. Caller is responsible for namespacing
// (the platformhost does this). Nil fn is silently skipped -- the staging
// Registrar should reject invalid registrations before this layer.
func (r *Registry) AddObserver(e ObserverEntry) {
if e.Fn == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.observers = append(r.observers, e)
}
// AddWrapper registers a Wrapper.
func (r *Registry) AddWrapper(e WrapperEntry) {
if e.Fn == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.wrappers = append(r.wrappers, e)
}
// AddLifecycle registers a LifecycleHandler.
func (r *Registry) AddLifecycle(e LifecycleEntry) {
if e.Fn == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.lifecycles = append(r.lifecycles, e)
}
// MatchingObservers returns the observers whose selector matches the
// command at the given When stage. Result is a slice (not a generator)
// so callers can iterate without holding the registry lock.
func (r *Registry) MatchingObservers(cmd platform.CommandView, when platform.When) []ObserverEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]ObserverEntry, 0, len(r.observers))
for _, e := range r.observers {
if e.When == when && e.Selector != nil && e.Selector(cmd) {
out = append(out, e)
}
}
return out
}
// MatchingWrappers returns the wrappers whose selector matches the
// command. Order matches registration order.
func (r *Registry) MatchingWrappers(cmd platform.CommandView) []WrapperEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]WrapperEntry, 0, len(r.wrappers))
for _, e := range r.wrappers {
if e.Selector != nil && e.Selector(cmd) {
out = append(out, e)
}
}
return out
}
// LifecycleHandlers returns handlers for a given event in registration
// order.
func (r *Registry) LifecycleHandlers(event platform.LifecycleEvent) []LifecycleEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]LifecycleEntry, 0, len(r.lifecycles))
for _, e := range r.lifecycles {
if e.Event == event {
out = append(out, e)
}
}
return out
}
// ComposeWrappers folds a slice of Wrappers into a single Wrapper that
// applies them in registration order (outermost first). Empty slice
// returns the identity Wrapper (next as-is). Inspired by
// grpc.ChainUnaryInterceptor.
func ComposeWrappers(ws []platform.Wrapper) platform.Wrapper {
if len(ws) == 0 {
return identityWrapper
}
return func(next platform.Handler) platform.Handler {
// Build from the inside out so the first registered Wrapper
// ends up outermost.
for i := len(ws) - 1; i >= 0; i-- {
next = ws[i](next)
}
return next
}
}
// identityWrapper is the no-op wrapper used when there are no matching
// Wrappers for a command -- callers can always compose into
// next(ctx, inv) without a nil check.
func identityWrapper(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
return next(ctx, inv)
}
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import "io"
// SetStderrForTesting redirects the hook layer's warning output to a
// custom writer and returns a restore function the caller MUST defer
// (or pass to `t.Cleanup`). Without the restore step, a later test in
// the same binary would inherit the override and either race on a
// shared bytes.Buffer or write user-visible garbage into a real test
// stderr.
//
// Production code never calls this; the default writer is os.Stderr
// via defaultStderr.
func SetStderrForTesting(w io.Writer) (restore func()) {
prev := stderr
stderr = func() interface{ Write(p []byte) (int, error) } {
return w
}
return func() { stderr = prev }
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import "github.com/spf13/cobra"
// walkTree applies fn to every command in the tree, depth-first. Hidden
// commands are visited too -- they can still be invoked.
func walkTree(root *cobra.Command, fn func(*cobra.Command)) {
if root == nil {
return
}
fn(root)
for _, c := range root.Commands() {
walkTree(c, fn)
}
}