platform

package
v1.0.37 Latest Latest
Warning

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

Go to latest
Published: May 21, 2026 License: MIT Imports: 7 Imported by: 0

README

lark-cli Plugin SDK

extension/platform is the in-process plugin SDK for lark-cli. Plugins compile into a fork of the lark-cli binary via a blank import; there is no .so loading, no RPC, no subprocess isolation. A plugin shares the binary's address space and lifecycle.

5-minute hello world

// myplugin/audit.go
package myplugin

import (
    "context"
    "log"

    "github.com/larksuite/cli/extension/platform"
)

func init() {
    platform.Register(
        platform.NewPlugin("audit", "0.1.0").
            Observer(platform.After, "log-cmd", platform.All(),
                func(ctx context.Context, inv platform.Invocation) {
                    log.Printf("cmd=%s err=%v", inv.Cmd().Path(), inv.Err())
                }).
            FailOpen().
            MustBuild())
}

Wire into a fork:

// cmd/larkx/main.go in your fork
package main

import (
    _ "github.com/me/myplugin"  // blank import → init() runs

    "github.com/larksuite/cli/cmd"
    "os"
)

func main() { os.Exit(cmd.Execute()) }
go build -o larkx ./cmd/larkx && ./larkx config plugins show

You should see audit in the plugin list.

What you can hook

Hook Fires Can block?
Observer Before / After each command No (fire-and-forget audit)
Wrap Around each command's RunE Yes (return *AbortError)
On(Startup/Shutdown) Process lifecycle N/A
Restrict(Rule) Bootstrap-time, single per binary Denies whole subtrees
Plugin lifecycle
sequenceDiagram
    participant Host as lark-cli (host)
    participant SDK as platform (SDK)
    participant Plugin as your plugin

    Note over Host,Plugin: Process start (before main)
    Plugin->>Plugin: init() (via blank import)
    Plugin->>SDK: Register(plugin)

    Note over Host,Plugin: Bootstrap (host main)
    Host->>SDK: RegisteredPlugins()
    SDK-->>Host: snapshot in registration order
    Host->>SDK: InstallAll()
    SDK->>Plugin: Capabilities()
    SDK->>Plugin: Install(Registrar)
    Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
    SDK->>Plugin: On(Startup) fire

    Note over Host,Plugin: Each command dispatch
    Host->>SDK: hook chain (in registration order)
    SDK->>Plugin: Observer Before
    SDK->>Plugin: Wrap (around RunE)
    SDK->>Plugin: Observer After

    Note over Host,Plugin: Process exit
    Host->>SDK: Emit(Shutdown)
    SDK->>Plugin: On(Shutdown) fire

A command_denied decision (from Restrict or strict-mode) bypasses the Wrap chain entirely — observers still fire so audit plugins see the rejected dispatch.

Safety contract (read this)

  • A plugin calling Restrict() MUST declare FailClosed. The Builder flips it automatically; the lower-level Plugin interface rejects the mismatch with restricts_mismatch.
  • Only ONE plugin per binary can call Restrict(). Multi-plugin Restrict is a deliberate plugin_conflict error (single-rule ecosystem assumption). YAML policy at ~/.lark-cli/policy.yml is shadowed by any plugin Restrict.
  • The Wrap factory runs once per command dispatch, not at install time. Long-lived state (clients, caches, metrics counters) must live on the Plugin struct or in package-level variables.
  • Plugins cannot suppress a command_denied: the framework physically isolates denied commands from the Wrap chain (Observers still fire).
  • Commands missing a risk_level annotation are denied by default when a Rule is active. Set Rule.AllowUnannotated = true (or allow_unannotated: true in yaml) to opt out during gradual adoption.
  • Risk annotation typos (e.g. "wrtie") are always denied with risk_invalid plus a "did you mean" suggestion. AllowUnannotated does NOT bypass this — typo is a code bug, not a missing annotation.

reason_code reference

Every install / dispatch failure emits a command_denied or plugin_install envelope carrying a detail.reason_code from the closed enum below. Use the code (not the human-readable message) when matching errors in agents, CI scripts, or downstream tools — the messages are localised and may change between releases.

Plugin install (error.type = plugin_install)
reason_code When it fires Honours FailurePolicy?
invalid_plugin_name Plugin.Name() doesn't match ^[a-z0-9][a-z0-9-]*$ No — always aborts
plugin_name_panic Plugin.Name() panicked No — always aborts
duplicate_plugin_name Two plugins return the same Name() No — always aborts
capabilities_panic Plugin.Capabilities() panicked Yes
invalid_capability Capabilities malformed: bad RequiredCLIVersion, unknown FailurePolicy No — always aborts
capability_unmet Current CLI version doesn't satisfy RequiredCLIVersion Yes
restricts_mismatch Restricts=true without FailClosed, or Restricts flag inconsistent w/ Install No — always aborts
invalid_hook_name Hook name contains . or doesn't match the plugin namespace Yes
duplicate_hook_name Same hook name registered twice within a plugin Yes
invalid_hook_registration Hook factory returns nil / Wrap chain re-entry / etc. Yes
invalid_rule Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) Yes
double_restrict Plugin called r.Restrict() more than once in one Install Yes
multiple_restrict_plugins Two or more plugins each contributed Restrict Yes
install_failed Plugin.Install returned a non-nil error Yes
install_panic Plugin.Install panicked Yes

"No — always aborts" entries are treated as untrusted-config errors: the host can't honour the plugin's declared FailurePolicy because the declaration itself is suspect (e.g. an invalid_capability plugin might also be lying about being FailOpen).

Command dispatch (error.type = command_denied)
reason_code Meaning
risk_not_annotated Command has no risk_level annotation, and the active Rule does not set allow_unannotated: true
risk_invalid Command's risk_level is a typo / not in the `read
command_denylisted Command path matched the active Rule's deny glob
domain_not_allowed Active Rule has a non-empty allow list and the command path did not match any glob
write_not_allowed Command risk is write / high-risk-write and exceeds Rule max_risk
risk_too_high Command risk exceeds Rule max_risk but is not a write (reserved for future risk levels)
identity_mismatch Command's supportedIdentities does not intersect Rule identities
aggregate_all_denied Aggregate stub installed on a parent group because every live child was denied

The detail.layer field distinguishes who rejected the call: policy (this SDK's user-layer engine) vs. strict_mode (cmd/prune.go's credential-hardening pass). Agents that want to dispatch on "any denial" should match error.type == "command_denied" and ignore the layer; agents that only care about user-policy denials should additionally check detail.layer == "policy".

Where to go next

Documentation

Overview

Package platform is the single public extension contract for lark-cli.

External integrators (plugin authors, embedding platforms) only import this package; everything else under internal/ is off-limits.

Plugin lifecycle:

  • Plugin - the interface every plugin implements (Name / Version / Capabilities / Install)
  • Registrar - what Install receives; the four registration verbs (Observe / Wrap / On / Restrict)
  • Capabilities - declared up front: FailurePolicy (FailOpen | FailClosed) and Restricts
  • Register - process-wide entry point; plugins call this from init()

Hook surface (what Install hangs off Registrar):

  • Observer - side-effect-only callback, panic-safe, runs Before / After RunE
  • Wrapper - middleware that can short-circuit via AbortError
  • LifecycleHandler - reacts to Startup / Shutdown / etc. (LifecycleEvent + When)
  • Selector - chooses which commands a hook applies to (ByDomain / ByWrite / ByReadOnly / ByExactRisk / And / Or / Not, etc.)
  • Handler - the inner "run the command" function Wrappers compose around
  • Invocation - per-call context passed to handlers (Cmd view + DeniedByPolicy / DenialLayer / DenialPolicySource)
  • AbortError - structured short-circuit error from a Wrapper; framework namespaces HookName

Policy surface (what Restrict contributes, also consumable from yaml policy):

  • Rule - declarative policy rule (Allow / Deny / MaxRisk / Identities / AllowUnannotated)
  • CommandView - read-only command metadata view (Path / Domain / Risk / Identities)
  • Risk / Identity - defined string types with closed taxonomies; ParseRisk / ParseIdentity convert raw strings (yaml, cobra annotation) into typed values; r.Rank() gives a comparable rank for the read < write < high-risk-write ordering
  • CommandDeniedError - structured error returned to denied callers

Stability: every exported symbol here is part of the contract. Internal orchestration (staging, validation, RunE wrapping, denial guard) lives under internal/platform, internal/hook and internal/cmdpolicy and is not importable by third parties.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Register

func Register(p Plugin)

Register adds a plugin to the global registry. Plugins call this from init() (typically through a blank import in the embedder's main).

Register is intentionally tolerant of malformed input: validation happens later in the host's InstallAll phase, where errors can be surfaced through the typed plugin_install envelope. Register itself never panics so that init-time problems do not crash the binary before main has a chance to install its recover-and-envelope logic.

The registry holds plugins in insertion order so InstallAll can process them deterministically.

func ResetForTesting

func ResetForTesting()

ResetForTesting clears the global plugin registry. Exposed for test isolation only — plugin authors and SDK consumers must NOT call this from production code. The function is exported (rather than placed in an internal test-only file) so that `go test ./...` works for every downstream package without an extra build tag.

Tests that exercise plugin registration must defer `t.Cleanup(platform.ResetForTesting)` so subsequent tests start from a clean slate. The helper is NOT goroutine-safe across concurrent `t.Parallel()` tests — the global registry is shared process state.

Types

type AbortError

type AbortError struct {
	HookName string
	Reason   string
	Cause    error
	Detail   any
}

AbortError is returned by a Wrapper that wants to short-circuit the command chain (instead of calling next). The framework converts it to an *output.ExitError with type "hook" so the JSON envelope carries the structured fields agents expect.

HookName is the framework-namespaced name ("secaudit.approval"); the Registrar adds the plugin-name prefix automatically.

Cause and Detail are optional. Cause lets the consumer use errors.Is/As to find the underlying cause; Detail is serialized into envelope.detail under the "detail" key for agent consumption.

func (*AbortError) Error

func (e *AbortError) Error() string

Error renders a human-readable message; HookName + Reason + Cause are included when present.

func (*AbortError) Unwrap

func (e *AbortError) Unwrap() error

Unwrap enables errors.Is / errors.As to traverse to Cause.

type Builder

type Builder struct {
	// contains filtered or unexported fields
}

Builder is the ergonomic constructor for Plugin. Use it from init():

func init() {
    platform.Register(
        platform.NewPlugin("audit", "0.1.0").
            Observer(platform.After, "log", platform.All(), auditFn).
            FailOpen().
            MustBuild())
}

The lower-level Plugin interface remains available for cases that need finer control (state on a struct, complex Install logic). The Builder enforces:

  • Name format (^[a-z0-9][a-z0-9-]*$)
  • hookName format and uniqueness within a plugin
  • Restricts ↔ FailClosed consistency (calling Restrict() implies FailClosed, so plugin authors cannot accidentally ship a policy plugin under FailOpen)
  • Rule validation via ValidateRule analogues (delegated to internal/cmdpolicy at install time; Builder only fast-fails blatantly bad input)

func NewPlugin

func NewPlugin(name, version string) *Builder

NewPlugin starts a Builder. Name format is validated lazily — errors surface at Build()/MustBuild() time, allowing chained calls without intermediate error handling.

Example (Observer)

ExampleNewPlugin_observer registers an audit Observer that fires after every command, regardless of success or failure.

package main

import (
	"context"
	"fmt"

	"github.com/larksuite/cli/extension/platform"
)

func main() {
	p, _ := platform.NewPlugin("audit", "0.1.0").
		Observer(platform.After, "log", platform.All(),
			func(ctx context.Context, inv platform.Invocation) {
				_ = inv.Cmd().Path() // do something useful with the command
			}).
		FailOpen().
		Build()
	fmt.Println(p.Name(), p.Version())
}
Output:
audit 0.1.0
Example (Restrict)

ExampleNewPlugin_restrict registers a policy plugin that allows only docs/* read commands. Note that Restrict() implicitly sets FailClosed — a policy plugin must abort the binary if it fails to install, not silently disappear.

package main

import (
	"fmt"

	"github.com/larksuite/cli/extension/platform"
)

func main() {
	p, _ := platform.NewPlugin("readonly-docs", "0.1.0").
		Restrict(&platform.Rule{
			Name:    "docs-only",
			Allow:   []string{"docs/**"},
			MaxRisk: platform.RiskRead,
		}).
		Build()
	caps := p.Capabilities()
	fmt.Println(caps.Restricts, caps.FailurePolicy == platform.FailClosed)
}
Output:
true true
Example (Wrapper)

ExampleNewPlugin_wrapper registers a Wrap that short-circuits any write-class command. The framework converts the returned *AbortError into a structured "hook" envelope; observers still fire on the After stage so audit sees the attempt.

package main

import (
	"context"
	"fmt"

	"github.com/larksuite/cli/extension/platform"
)

func main() {
	p, _ := platform.NewPlugin("policy-plugin", "0.1.0").
		Wrap("block-writes", platform.ByWrite(),
			func(next platform.Handler) platform.Handler {
				return func(ctx context.Context, inv platform.Invocation) error {
					return &platform.AbortError{
						HookName: "block-writes",
						Reason:   "writes are disabled for this session",
					}
				}
			}).
		FailOpen().
		Build()
	fmt.Println(p.Capabilities().FailurePolicy == platform.FailOpen)
}
Output:
true

func (*Builder) Build

func (b *Builder) Build() (Plugin, error)

Build returns the configured Plugin, or an error if any builder step found a fault. MustBuild panics on the same error.

The Restrict + FailOpen mismatch is checked here, not in the chained setters, because the two methods may be called in either order.

func (*Builder) FailClosed

func (b *Builder) FailClosed() *Builder

FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit when Restrict() is called.

func (*Builder) FailOpen

func (b *Builder) FailOpen() *Builder

FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when neither FailOpen nor FailClosed is called and Restrict is not used.

func (*Builder) MustBuild

func (b *Builder) MustBuild() Plugin

MustBuild panics if Build() would return an error. Designed for init():

func init() { platform.Register(platform.NewPlugin(...).MustBuild()) }

A panic in init runs before the framework's recover guard is installed and will crash the binary. That is the intended behaviour: a misconfigured plugin must NOT be silently registered.

func (*Builder) Observer

func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder

Observer registers an Observer. Multiple calls accumulate.

func (*Builder) On

func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler) *Builder

On registers a LifecycleHandler.

func (*Builder) RequireCLI

func (b *Builder) RequireCLI(constraint string) *Builder

RequireCLI sets Capabilities.RequiredCLIVersion (semver constraint, e.g. ">=1.1.0"). Empty string means no requirement.

func (*Builder) Restrict

func (b *Builder) Restrict(rule *Rule) *Builder

Restrict contributes a pruning Rule. Calling Restrict implicitly sets Restricts=true and FailurePolicy=FailClosed (the framework requires both to coexist; the builder enforces the pairing so the plugin author cannot accidentally ship a policy plugin under FailOpen).

func (*Builder) Wrap

func (b *Builder) Wrap(hookName string, sel Selector, wrap Wrapper) *Builder

Wrap registers a Wrapper. Multiple calls accumulate; the host composes them in registration order (outermost first).

type Capabilities

type Capabilities struct {
	// RequiredCLIVersion is a semver constraint (e.g. ">=1.1.0").
	// Plugins that need a specific framework feature should declare
	// the minimum version they tested against; the host fails the
	// install when the running CLI is older. Empty string means "no
	// version requirement".
	RequiredCLIVersion string

	// Restricts declares whether Install will call r.Restrict(). The
	// framework enforces consistency: declaring Restricts=true and
	// then NOT calling r.Restrict (or vice versa) aborts the install
	// with the `restricts_mismatch` reason_code. This pre-flight
	// declaration also lets `config policy show` introspect "which
	// plugins are policy plugins" without running them.
	Restricts bool

	// FailurePolicy decides what happens on install failure. See the
	// constants above; the framework requires FailClosed whenever
	// Restricts=true.
	FailurePolicy FailurePolicy
}

Capabilities declares the plugin's self-description. Plugin.Capabilities MUST be implemented even when every field would be its zero value -- the requirement keeps FailurePolicy / Restricts visible to the author at the moment they write the plugin, preventing the "I just want to add an audit observer" mistake of accidentally shipping a policy plugin with the default FailOpen.

type CommandDeniedError

type CommandDeniedError struct {
	Path         string
	Layer        string
	PolicySource string
	RuleName     string
	ReasonCode   string
	Reason       string
}

CommandDeniedError is the structured error returned by a denyStub. Every pruned-command execution path -- direct invocation, alias expansion, internal call -- returns this exact type. It is wire-compatible with the output.ExitError envelope via the Layer (== error.type) field and the detail map produced by ExitError().

Layer values:

  • "strict_mode" -- credential strict-mode rejected the command
  • "policy" -- user-layer Rule rejected the command

PolicySource is a free-form identifier such as "plugin:secaudit", "yaml:mywork", or "strict-mode". Reason fields:

  • ReasonCode -- closed enum, see tech-doc 5.3 (e.g. write_not_allowed, all_children_denied, identity_not_supported)
  • Reason -- human-readable text

func (*CommandDeniedError) Error

func (e *CommandDeniedError) Error() string

Error implements the standard error interface.

type CommandView

type CommandView interface {
	// Path is the canonical slash-separated path, rootless ("docs/+update").
	Path() string

	// Domain returns the business domain ("docs", "im", "") inherited from
	// the nearest ancestor with a cmdmeta.domain annotation. Empty string
	// when no ancestor declares one.
	Domain() string

	// Risk returns the static risk level. ok=false signals "no risk_level
	// annotation found in the parent chain" (unknown).
	Risk() (level Risk, ok bool)

	// Identities returns the supported identities. nil signals "no
	// supportedIdentities annotation in the parent chain".
	Identities() []Identity

	// Annotation exposes the raw cobra annotation map for plugins that
	// need a tag the framework does not surface.
	Annotation(key string) (string, bool)
}

CommandView is the read-only view of a cobra.Command exposed to plugins and the policy engine. *cobra.Command is deliberately NOT reachable through this interface -- a plugin should never mutate the command tree.

View semantics:

  • The view is a live proxy over the underlying *cobra.Command and its annotation chain. Strict-mode replaces nodes via RemoveCommand+ AddCommand; the replacement stub explicitly carries the original command's annotations and help text forward so audit / compliance observers still see Risk / Identities / Domain after a denial. User-layer policy mutates in place, so its denyStubs preserve the original metadata by construction.

  • Path() is the canonical slash form ("docs/+fetch"), matching the doublestar glob semantics used by Rule.Allow / Rule.Deny.

  • Risk() returns ok=false when the command is unannotated. The policy engine treats an unannotated command as implicit deny whenever any Rule without AllowUnannotated=true is registered, so risk-based Selectors never see unannotated commands during normal hook dispatch under that configuration.

type FailurePolicy

type FailurePolicy int

FailurePolicy controls what the framework does when a plugin's install stage fails (Capabilities() panics, Install returns error, etc.).

const (
	// FailOpen (default) — log a warning and skip THIS plugin; the rest
	// of the CLI keeps running. Appropriate for pure-observer plugins
	// where missing audit data is preferable to a broken CLI.
	FailOpen FailurePolicy = iota

	// FailClosed — abort the entire CLI startup. Required for any
	// plugin that contributes Restrict() (a missing policy plugin =
	// missing security boundary) or that owns any safety-sensitive
	// concern. Enforced by the framework: Capabilities.Restricts=true
	// must pair with FailurePolicy=FailClosed.
	FailClosed
)

type Handler

type Handler func(ctx context.Context, inv Invocation) error

Handler is the inner function shape every Wrapper composes. It IS the "command business logic" from the Wrapper's perspective -- calling next(ctx, inv) inside a Wrapper means "let the command proceed"; returning early without calling next short-circuits.

type Identity

type Identity string

Identity is the identity taxonomy a command supports.

Defined type (not alias) so plugin authors get compile-time + IDE help; raw-string boundaries (yaml, cobra annotation) cross through ParseIdentity.

const (
	IdentityUser Identity = "user"
	IdentityBot  Identity = "bot"
)

func ParseIdentity

func ParseIdentity(s string) (Identity, error)

ParseIdentity converts a raw string into an Identity. Returns ("", nil) for empty input ("not specified"), error for unrecognised values. Matching is strict (case-sensitive, no trim).

func (Identity) IsValid

func (i Identity) IsValid() bool

IsValid reports whether i is one of the two recognised values.

func (Identity) String

func (i Identity) String() string

String returns the underlying string.

type Invocation

type Invocation interface {
	// Cmd returns the read-only metadata view of the dispatched command.
	Cmd() CommandView

	// Args returns a fresh copy of the positional args.
	Args() []string

	// Started is the wall-clock time the outermost RunE wrapper began.
	Started() time.Time

	// Err is the error the wrapped handler returned. Populated for
	// After observers and the post-next portion of a Wrapper. nil
	// before the handler runs.
	Err() error

	// DeniedByPolicy reports whether the command was rejected by either
	// strict-mode or user-layer policy before the chain reached the
	// hook. Observers fire even for denied commands (audit case); Wrap
	// is physically isolated by the framework so plugins do not need
	// to check this themselves before calling next.
	DeniedByPolicy() bool

	// DenialLayer returns the layer that rejected the command:
	//
	//   ""             - not denied
	//   "strict_mode"  - credential strict-mode
	//   "policy"       - user-layer Rule (Plugin.Restrict() or yaml)
	DenialLayer() string

	// DenialPolicySource returns the specific source identifier
	// ("plugin:secaudit", "yaml", "strict-mode"). Empty when not denied.
	DenialPolicySource() string
}

Invocation is the per-command data a Wrapper / Observer receives. It is a read-only interface: the framework implementation lives in internal/hook and is never visible to plugins, so plugin code cannot mutate denial state.

The interface is deliberately NOT a context.Context — it is data only, no cancellation. ctx (from the handler signature) carries cancellation / timeout / trace propagation.

Accessor semantics:

  • Cmd / Args / Started are populated before the first hook fires
  • Err is populated for After observers and the post-next portion of a Wrapper (the value the wrapped handler returned)
  • DeniedByPolicy / DenialLayer / DenialPolicySource are populated by the framework's denial guard before any hook runs

type LifecycleContext

type LifecycleContext struct {
	Event LifecycleEvent
	Err   error
}

LifecycleContext is passed to LifecycleHandler. Err is the error from the preceding command (when Event == Shutdown after a failed RunE); otherwise nil.

type LifecycleEvent

type LifecycleEvent int

LifecycleEvent selects the temporal slot for Lifecycle hooks. These are process-level events that fire once per binary execution, not per command. Only Startup and Shutdown are defined: additional bootstrap phases can be added later as a non-breaking addition if a concrete consumer surfaces.

const (
	// Startup fires after plugin install has committed; Plugin.On
	// handlers for Startup are guaranteed to be registered before this
	// event is emitted (so they can receive it).
	Startup LifecycleEvent = iota

	// Shutdown fires once before the process exits. Handler total
	// execution is bounded by a hard 2s timeout to prevent a
	// misbehaving handler from holding up exit.
	Shutdown
)

type LifecycleHandler

type LifecycleHandler func(ctx context.Context, lc *LifecycleContext) error

LifecycleHandler runs at one of the process-level LifecycleEvent slots. The handler may use ctx for cancellation; in the Shutdown case the framework supplies a context with a 2-second hard deadline.

type Observer

type Observer func(ctx context.Context, inv Invocation)

Observer is a side-effect-only command hook. No return value, no next-chain control: an Observer can read Invocation but cannot prevent the command from running. Used for audit, metrics, and completion logs. After-stage Observers fire even when the command failed (Invocation.Err() is populated in that case).

type Plugin

type Plugin interface {
	Name() string
	Version() string
	Capabilities() Capabilities
	Install(r Registrar) error
}

Plugin is the single contract a third-party / embedding integrator implements to extend lark-cli. Four methods, every one mandatory.

Name must match the grammar ^[a-z0-9][a-z0-9-]*$. The "." character is forbidden so plugin-name + hookName namespacing never produces ambiguous joins.

Capabilities must be implemented even when every field is zero. The requirement is deliberate: it keeps FailurePolicy / Restricts in the author's eyeline.

Install runs once during the Bootstrap pipeline. The plugin uses the supplied Registrar to register hooks and (optionally) a Rule. Errors returned from Install honour the plugin's Capabilities.FailurePolicy (fail-open warns + skips this plugin; fail-closed aborts the CLI).

func RegisteredPlugins

func RegisteredPlugins() []Plugin

RegisteredPlugins returns a snapshot of the global plugin registry. Order matches Register insertion. The host reads this once during InstallAll.

type Registrar

type Registrar interface {
	// Observe registers a side-effect-only command hook at the given
	// When stage. The selector decides which commands it fires on.
	Observe(when When, hookName string, sel Selector, fn Observer)

	// Wrap registers a middleware-style command hook. The Wrap chain
	// composes left-to-right in registration order; the outermost
	// Wrapper runs first.
	Wrap(hookName string, sel Selector, w Wrapper)

	// On registers a lifecycle handler for the given event.
	On(event LifecycleEvent, hookName string, fn LifecycleHandler)

	// Restrict contributes a pruning Rule. The framework merges it
	// with the yaml-sourced Rule using single-rule semantics: plugin
	// rule wins, but two plugins both calling Restrict abort startup.
	Restrict(r *Rule)
}

Registrar is the imperative API a plugin uses inside its Install method to wire up hooks and rules. The framework provides a staging implementation that buffers calls and commits them atomically when Install returns nil; failure rolls everything back.

hookName must match the grammar ^[a-z0-9][a-z0-9-]*$ (no dots). The framework prepends the plugin's Name() with a dot so the global hook identifier is "{plugin}.{hook}". A plugin cannot register two hooks with the same name in the same Install call.

Restrict may be called at most once per plugin; multiple plugins contributing Restrict() is a configuration error (the resolver aborts startup).

type Risk

type Risk string

Risk is the three-tier risk taxonomy declared on every command.

A defined type (not an alias of string) so plugin authors get compile-time + IDE candidate help when passing the constants below. Crossing the string boundary (yaml, cobra annotation) goes through ParseRisk so typos surface as `risk_invalid` rather than silently flowing through.

const (
	RiskRead          Risk = "read"
	RiskWrite         Risk = "write"
	RiskHighRiskWrite Risk = "high-risk-write"
)

func ParseRisk

func ParseRisk(s string) (Risk, error)

ParseRisk converts a raw string (yaml, cobra annotation) into a Risk.

  • s == "" → ("", nil) "not specified"
  • s 在闭合枚举 → (Risk(s), nil) OK
  • s 不在枚举内 → ("", error) invalid

The (absent vs invalid) split mirrors the cmdpolicy engine's risk_not_annotated vs risk_invalid reason codes — callers can treat the "" + nil case as "not specified" without losing the distinction from a typo.

Matching is strict: "Read" / "READ" / " read " are all rejected. annotation is developer code, not user input — strict matching is the typo-catch mechanism, not a normalisation opportunity.

func (Risk) IsValid

func (r Risk) IsValid() bool

IsValid reports whether r is one of the three recognised values.

func (Risk) Rank

func (r Risk) Rank() (rank int, ok bool)

Rank returns the comparable rank of r. ok=false when r is not in the closed taxonomy.

func (Risk) String

func (r Risk) String() string

String returns the underlying string. Useful for yaml/json output and cobra annotation injection.

type Rule

type Rule struct {
	Name        string `json:"name"`
	Description string `json:"description,omitempty"`

	// Allow is a list of doublestar globs (slash-separated paths). An empty
	// slice means "no path restriction"; a non-empty slice means "command
	// path must match at least one glob".
	Allow []string `json:"allow,omitempty"`

	// Deny is a list of doublestar globs. A path that matches any Deny glob
	// is rejected regardless of Allow.
	Deny []string `json:"deny,omitempty"`

	// MaxRisk is the highest allowed risk level (inclusive). Empty string
	// means "no risk restriction". Comparison uses the closed taxonomy
	// read < write < high-risk-write.
	MaxRisk Risk `json:"max_risk,omitempty"`

	// Identities is the allowed identity whitelist. A command passes when
	// the intersection with the command's own supported identities is
	// non-empty. Empty slice means "no identity restriction".
	Identities []Identity `json:"identities,omitempty"`

	// AllowUnannotated controls how commands missing a risk_level
	// annotation are handled when this Rule is active.
	//
	// Default (false, fail-closed): unannotated commands are rejected
	// with reason_code=risk_not_annotated. This is the safe default
	// — a typo'd or forgotten annotation cannot slip past an
	// "agent read-only" rule.
	//
	// Set to true to opt out during gradual adoption: lark-cli main
	// has hundreds of service commands that may not yet carry
	// risk_level annotations, and a brand-new policy plugin would
	// otherwise lock the binary to nothing.
	//
	// This flag does NOT affect risk_invalid (typos): a command that
	// claims a risk but mis-spells it is always denied, regardless of
	// AllowUnannotated. Typo is a code bug, not a migration phase.
	//
	// No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so
	// platform stays free of a yaml library dependency.
	AllowUnannotated bool `json:"allow_unannotated,omitempty"`
}

Rule is the declarative policy rule data structure. yaml files and Plugin.Restrict() both produce the same Rule.

At any moment there is at most one effective Rule -- the resolver decides which source wins (Plugin > yaml > none). This package only defines the shape; selection lives in internal/cmdpolicy.

The four filter fields are joined by AND. See the engine's Evaluate for the full semantics. JSON tags are used by `config policy show`; yaml parsing lives in internal/cmdpolicy/yaml so the public API does not depend on a yaml library.

type Selector

type Selector func(cmd CommandView) bool

Selector picks the commands a hook fires on. A nil Selector is equivalent to None() -- safer than an "always-match" default because it forces every hook to declare its scope explicitly. Compose selectors with And / Or / Not.

func All

func All() Selector

All matches every command. Use for audit / metrics observers that must run on the whole surface.

func ByCommandPath

func ByCommandPath(patterns ...string) Selector

ByCommandPath matches against the canonical slash-form path. Patterns are doublestar globs ("docs/+update", "im/*", "**"). Invalid patterns never match; ValidateRule's twin check catches them at the source.

func ByDomain

func ByDomain(domains ...string) Selector

ByDomain matches a command whose Domain() is one of the supplied names. Commands with unknown (empty-string) Domain never match this selector -- the caller should pair it with a Selector that handles unknown explicitly when that case matters.

func ByExactRisk

func ByExactRisk(level Risk) Selector

ByExactRisk matches commands whose declared risk level is exactly level.

func ByIdentity

func ByIdentity(id Identity) Selector

ByIdentity matches when the command's supported identities include the supplied id. Unknown identities never match.

func ByReadOnly

func ByReadOnly() Selector

ByReadOnly matches commands whose risk is "read".

func ByWrite

func ByWrite() Selector

ByWrite matches commands whose risk is "write" or "high-risk-write".

func None

func None() Selector

None matches no command. Useful as a "disabled" placeholder.

func (Selector) And

func (s Selector) And(other Selector) Selector

And composes selectors with AND semantics.

func (Selector) Not

func (s Selector) Not() Selector

Not negates the selector. A nil receiver is treated as None(), so nil.Not() behaves as All().

func (Selector) Or

func (s Selector) Or(other Selector) Selector

Or composes selectors with OR semantics.

type When

type When int

When selects the temporal slot for command-level Observer hooks. The framework wraps every command's RunE so both stages always fire, even when RunE itself returns an error (After is failure-safe).

const (
	// Before fires immediately before the command's business logic.
	Before When = iota

	// After fires after the command's business logic (or its denyStub
	// in the denied path). Always fires, even when RunE returned an
	// error; Invocation.Err is populated in that case.
	After
)

type Wrapper

type Wrapper func(next Handler) Handler

Wrapper is a middleware-style hook: it receives the rest of the handler chain and returns a wrapped version. The Wrapper decides whether to call next (allow), abstain (deny, return an AbortError), or transform the result. Multiple Wrappers compose left-to-right by registration order; the outermost runs first.

⚠️ IMPORTANT: The factory function `func(next Handler) Handler` is invoked ONCE PER COMMAND DISPATCH, not once at plugin install. This lets the framework recover from a panicking factory and convert it to a structured envelope, but it means any state captured by the outer closure is rebuilt on every command. Long-lived state (HTTP clients, caches, metrics counters) MUST live on the Plugin struct or in package-level variables, never in factory-local captures.

Directories

Path Synopsis
examples
audit-observer command
Command audit-observer is a runnable fork of lark-cli that logs every dispatched command to stderr.
Command audit-observer is a runnable fork of lark-cli that logs every dispatched command to stderr.
readonly-policy command
Command readonly-policy is a runnable fork of lark-cli that installs a Rule permitting only docs/* and im/* read commands.
Command readonly-policy is a runnable fork of lark-cli that installs a Rule permitting only docs/* and im/* read commands.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL