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
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:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user