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 ¶
- func Register(p Plugin)
- func ResetForTesting()
- type AbortError
- type Builder
- func (b *Builder) Build() (Plugin, error)
- func (b *Builder) FailClosed() *Builder
- func (b *Builder) FailOpen() *Builder
- func (b *Builder) MustBuild() Plugin
- func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder
- func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler) *Builder
- func (b *Builder) RequireCLI(constraint string) *Builder
- func (b *Builder) Restrict(rule *Rule) *Builder
- func (b *Builder) Wrap(hookName string, sel Selector, wrap Wrapper) *Builder
- type Capabilities
- type CommandDeniedError
- type CommandView
- type FailurePolicy
- type Handler
- type Identity
- type Invocation
- type LifecycleContext
- type LifecycleEvent
- type LifecycleHandler
- type Observer
- type Plugin
- type Registrar
- type Risk
- type Rule
- type Selector
- type When
- type Wrapper
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 ¶
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 ¶
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 ¶
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 ¶
FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit when Restrict() is called.
func (*Builder) FailOpen ¶
FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when neither FailOpen nor FailClosed is called and Restrict is not used.
func (*Builder) MustBuild ¶
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) On ¶
func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler) *Builder
On registers a LifecycleHandler.
func (*Builder) RequireCLI ¶
RequireCLI sets Capabilities.RequiredCLIVersion (semver constraint, e.g. ">=1.1.0"). Empty string means no requirement.
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.
func ParseIdentity ¶
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).
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.
func ParseRisk ¶
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.
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 ¶
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 ¶
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 ¶
ByExactRisk matches commands whose declared risk level is exactly level.
func ByIdentity ¶
ByIdentity matches when the command's supported identities include the supplied id. Unknown identities never match.
func ByWrite ¶
func ByWrite() Selector
ByWrite matches commands whose risk is "write" or "high-risk-write".
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).
type Wrapper ¶
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.
Source Files
¶
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. |