platform

package
v1.0.84 Latest Latest
Warning

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

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 8 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 (
    "os"

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

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

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

You should see audit in the plugin list.

That is sufficient for a hook-only plugin such as the audit observer. A wrapper main does not compile lark-cli's repository-root content_embed.go, so distribution content is a separate, explicit host choice.

Ship skills and command guidance

If the distribution exposes embedded skills or customizes them with EmbeddedSkills, copy or generate both content trees under the wrapper package and wire both:

package main

import (
    "embed"
    "io/fs"
    "os"

    _ "github.com/me/myplugin"

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

//go:embed skills affordance
var distributionContent embed.FS

func main() {
    skillTree, err := fs.Sub(distributionContent, "skills")
    if err != nil {
        panic(err)
    }
    affordanceTree, err := fs.Sub(distributionContent, "affordance")
    if err != nil {
        panic(err)
    }
    cmd.SetEmbeddedSkillContent(skillTree)
    cmd.SetEmbeddedAffordanceContent(affordanceTree)
    os.Exit(cmd.Execute())
}

go:embed only reads files in the package being compiled; it cannot reach into the replaced github.com/larksuite/cli module. Each skills/<name>/ must contain SKILL.md. The affordance/*.md files are the structured source for command help and canonical skill references; ship the ones for the domains your distribution retains. Without SetEmbeddedSkillContent, skills list has no base content and an Allow or Remove overlay deliberately aborts startup. A plugin may instead provide a complete SkillsOverlay.Base. Without SetEmbeddedAffordanceContent, commands still run, but distribution-specific guidance and its skill pointers are absent.

Keep the executable available as lark-cli on PATH: command-linked guidance invokes that canonical name.

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, ≥1 per plugin Denies whole subtrees
EmbeddedSkills(SkillsOverlay) Bootstrap-time, ≤1 per plugin Build-integrity (fail-closed)
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 / EmbeddedSkills / 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 rule or strict-mode denial 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.

  • A plugin may call Restrict() more than once; each call adds one scoped Rule and the engine combines them with OR — a command is allowed when it satisfies every axis (allow / deny / max_risk / identities) of at least one rule. Note a rule's deny is scoped to that rule only and cannot veto another rule's allow. Only ONE plugin per binary may contribute rules, though: two DISTINCT plugins each calling Restrict() is a deliberate multiple_restrict_plugins error (single-owner assumption — an independent plugin must not be able to widen another's policy). YAML policy at ~/.lark-cli/policy.yml (which may itself list several rules under rules:) is shadowed by any plugin Restrict.

  • A plugin may call EmbeddedSkills() at most once to customize the embedded skill tree — Allow keeps only the listed skills (the allow-list counterpart of Rule.Allow, so a CLI upgrade cannot widen the build; Remove wins over Allow, and Overlay entries are exempt), Remove drops skills, Overlay adds/replaces ones, or swap the whole Base — layered over the host-provided base skill tree. The repository's root lark-cli binary wires its default in content_embed.go; an external fork main must call cmd.SetEmbeddedSkillContent as shown above (unless its plugin supplies Base) and should wire cmd.SetEmbeddedAffordanceContent for command guidance. EmbeddedSkills() implies FailClosed: it declares distribution assets, and silently falling back could republish content the distribution explicitly removed or replaced. Removing a skill drops its skills read content and every framework-owned structured help block that depends on it; it does NOT disable matching commands (use Restrict() for that). The inverse is also explicit: concealing a command does not automatically delete its skill content. Command policy and distribution assets are independent axes; use EmbeddedSkills when both must be trimmed. ReferenceRemaps can rename a whole referenced skill while preserving relative paths, or override one exact reference:

    EmbeddedSkills(&platform.SkillsOverlay{
        Base: customizedSkills,
        ReferenceRemaps: []platform.SkillRefRemap{
            platform.RemapSkillRef("lark-doc", "acme-docx"),
            platform.RemapSkillRef(
                "lark-doc/references/lark-doc-fetch.md",
                "acme-docx/guides/fetch.md",
            ),
        },
    })
    

    Remaps apply only to structured CLI help/affordance references; they never scan or rewrite arbitrary prose or links inside Skill Markdown. An explicit remap to a missing target aborts startup, while an unmapped canonical reference removed from the final tree causes its complete dependent help block to be omitted. Only ONE plugin per binary may contribute a SkillsOverlay; two DISTINCT plugins is a deliberate multiple_skills_overlay_plugins error. The top-level skill set and each skill's owning FS are snapshotted during CLI build; files inside an owned skill directory remain live. Both Base and Overlay must contain only valid skill directories with SKILL.md.

  • A command denied by a Rule is hidden from normal command discovery and returns validation/failed_precondition with its policy source, rule, and reason code in the recovery hint. This is the established Restrict contract for both plugin and yaml sources. A distribution that wants plugin-restricted commands to look absent must opt in from its wrapper main with cmd.ExecuteWithOptions(cmd.ConcealRestrictedCommands(...)); presentation is a host choice, not part of Rule or Capabilities. One carve-out: a command already retired by the user's strict-mode setting keeps its strict-mode identity error even when a plugin Rule also matches it — strict-mode is a user-side security boundary and is never re-labelled. A wrapper may customize the absent-capability message:

    os.Exit(cmd.ExecuteWithOptions(
        cmd.ConcealRestrictedCommands(
            cmd.UnavailableMessage("capability not shipped by this distribution"),
        ),
    ))
    
  • config policy show / config plugins show stay executable under any plugin policy (hidden from help when their domain is denied) so an operator can still inspect the rule that locked the build. A concealed distribution can remove those escape hatches with the host-side cmd.HidePolicyDiagnostics() presentation option.

  • 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 denied dispatch: 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. With several rules this is per-rule: an unannotated command is allowed as long as one rule that opts in also grants it.

  • 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

Install and rule evaluation keep a closed reason_code taxonomy for operator diagnostics and in-process errors. The established Restrict presentation includes the reason code in the error hint. A distribution that explicitly enables command concealment replaces that wire presentation with validation/command_unavailable.

Plugin installation/configuration diagnostics

Fail-closed bootstrap errors that reach the CLI dispatcher use error.type=validation and error.subtype=failed_precondition. The diagnostic reason_code values below currently appear in the human-readable hint; they are not a separate detail field. In-process hosts should inspect the wrapped platform error with errors.As / errors.Is when they need the precise cause.

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 version/policy, or EmbeddedSkills contributed under FailOpen 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
multiple_restrict_plugins Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) Yes
invalid_skills_overlay Registration fault (nil / duplicate call), or invalid selection/content/reference remap Registration honours policy; composition always aborts
multiple_skills_overlay_plugins Two or more DISTINCT plugins each contributed a SkillsOverlay (only one may own skill content) No — always aborts (dispatch guard)
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 rule evaluation (internal/operator diagnostics)
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
no_matching_rule Several rules are active and the command satisfied none of them (the message summarises each rule's own rejection). Single-rule policies keep their specific reason_code instead
aggregate_all_denied Aggregate stub installed on a parent group because every live child was denied

These codes remain available to in-process hosts through the wrapped *platform.CommandDeniedError cause. Operator commands expose the active rule and shipped-tree summary. Agents consuming a host that explicitly enabled concealment should match error.type == "validation" and error.subtype == "command_unavailable" instead of branching on a rule-specific reason. The canonical validation/command_unavailable contract defines its exit code, wire fields, and consumer behavior.

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. Interfaces never gain methods; new host capability surfaces arrive as optional extension interfaces (see EmbeddedSkillsRegistrar). 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 a typed errs.* error 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)
  • EmbeddedSkills ↔ FailClosed consistency (declaring distribution assets is a build-integrity commitment; falling back to host defaults is never allowed)
  • 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.

FailOpen mismatches are checked against the final builder state, not in the chained setters, because FailOpen/FailClosed and the contributing methods may be called in either order.

func (*Builder) EmbeddedSkills added in v1.0.84

func (b *Builder) EmbeddedSkills(spec *SkillsOverlay) *Builder

EmbeddedSkills contributes a SkillsOverlay (see SkillsOverlay) customizing the CLI's embedded skill content. It implies FailClosed: although skill content is not a command-enforcement boundary, the overlay is a distribution build-integrity declaration. Silently skipping it could republish host defaults that the distribution explicitly removed or replaced.

Calling FailOpen before EmbeddedSkills is allowed; EmbeddedSkills overrides it to FailClosed, matching Restrict. Calling FailOpen afterward leaves an invalid final state that Build rejects. A later FailClosed restores a valid final state. A plugin owns at most one SkillsOverlay, so calling EmbeddedSkills more than once is a build error.

func (*Builder) FailClosed

func (b *Builder) FailClosed() *Builder

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

func (*Builder) FailOpen

func (b *Builder) FailOpen() *Builder

FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when neither FailOpen nor FailClosed is called and neither Restrict nor EmbeddedSkills is used. Build rejects a final FailOpen state after either safety-sensitive contribution.

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). It may be called more than once; each call adds one scoped Rule and the engine OR-combines them.

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 or Install contributes EmbeddedSkills.
	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. The dispatcher converts it to a typed errs.* error; the Layer field carries the denial layer for the envelope.

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 EmbeddedSkillsRegistrar added in v1.0.84

type EmbeddedSkillsRegistrar interface {
	// EmbeddedSkills contributes a SkillsOverlay customizing the CLI's
	// embedded skill content (see SkillsOverlay).
	EmbeddedSkills(spec *SkillsOverlay)
}

EmbeddedSkillsRegistrar is the optional extension a host registrar implements to accept embedded-skill customization. It is deliberately NOT part of Registrar: every exported symbol in this package is a stability contract, and widening Registrar would break existing third-party implementations (fakes, decorators, custom hosts). A Builder-built plugin type-asserts for this interface at Install time and fails closed when the host lacks it -- a declared customization is never silently dropped.

Skill content has a single owner: a second customizing plugin, a FailOpen declaration, or a SkillsOverlay that cannot compose aborts startup unconditionally. EmbeddedSkills is a distribution build-integrity boundary: silently dropping it could republish host defaults the distribution removed. Removing a skill drops it from skills list/read and from structured framework-owned pointers, but does not disable any command; use Restrict to actually block a command.

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), EmbeddedSkills() (silently dropping distribution
	// assets would violate build integrity), or any other safety-sensitive
	// concern. The Builder sets it automatically for Restrict and
	// EmbeddedSkills; the host validates hand-written plugins after staging.
	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. May be called more than once
	// to declare several scoped grants (OR-combined by the engine).
	// Plugin rules take precedence over the yaml source; two distinct
	// 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 multiple times per plugin; each call adds one scoped Rule (OR-combined by the engine). Two or more DISTINCT 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 SOURCE of rules -- the resolver decides which wins (Plugin > yaml > none); the winning source may contribute several scoped rules. 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 SkillRefRemap added in v1.0.84

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

SkillRefRemap is an immutable mapping between two structured embedded-skill references. Its fields are private so malformed values can only enter via a zero value (which the resolver rejects) or RemapSkillRef.

Use From and To for diagnostics; callers should not parse their values to perform resolution themselves.

func RemapSkillRef added in v1.0.84

func RemapSkillRef(from, to string) SkillRefRemap

RemapSkillRef maps a canonical "name[/relative/path]" reference to the runtime reference shipped by a distribution. Syntax and target existence are validated when the SkillsOverlay is composed, so it participates in the same build-integrity failure path as Base, Allow, Remove, and Overlay.

func (SkillRefRemap) From added in v1.0.84

func (m SkillRefRemap) From() string

From returns the canonical source reference.

func (SkillRefRemap) To added in v1.0.84

func (m SkillRefRemap) To() string

To returns the distribution runtime target reference.

type SkillsOverlay added in v1.0.84

type SkillsOverlay struct {
	// Allow, when non-empty, keeps only these skills (by name) from the
	// base tree — the allow-list counterpart of Rule.Allow. Skills the
	// CLI adds in future versions stay out of the build until listed
	// here, which a Remove-only spec cannot guarantee. A name not
	// present in the base aborts startup. Overlay entries are exempt:
	// content the integrator explicitly ships needs no allow-listing.
	Allow []string

	// Remove hides these skills, by name (e.g. "lark-shared"), from the
	// base tree; it wins over Allow, mirroring Rule's Deny-over-Allow. A
	// name not present in the base aborts startup rather than being
	// silently ignored.
	Remove []string

	// Overlay contributes skills laid over the base: a same-named skill
	// replaces the base's entirely, a new name adds one. It is rooted at
	// the skill list (entries like "my-skill/SKILL.md"); each top-level
	// entry must be a "<name>/" directory containing SKILL.md. Any fs.FS
	// works (embed.FS, os.DirFS, fstest.MapFS); embed.FS is not required.
	Overlay fs.FS

	// Base replaces the host-provided base skill tree instead of layering
	// over it. nil keeps whatever base the host wired with
	// cmd.SetEmbeddedSkillContent; it does not import the repository
	// binary's default into an external wrapper main. Every top-level
	// entry must be a valid skill directory containing SKILL.md. Most
	// integrators leave Base nil and use Remove/Overlay so unchanged
	// host-provided skills need no copy inside the plugin.
	Base fs.FS

	// ReferenceRemaps maps CLI-authored canonical skill references to the
	// runtime names and files this distribution ships. References use the
	// same "name[/relative/path]" form accepted by `lark-cli skills read`.
	//
	// A bare source remaps the whole skill name while preserving the
	// referenced relative path:
	//
	//	RemapSkillRef("lark-doc", "acme-docx")
	//
	// maps both "lark-doc" and
	// "lark-doc/references/lark-doc-fetch.md" to the corresponding paths
	// under "acme-docx". A source carrying a relative path is an exact
	// reference override and wins over the whole-skill mapping:
	//
	//	RemapSkillRef(
	//	    "lark-doc/references/lark-doc-fetch.md",
	//	    "acme-docx/guides/fetch.md",
	//	)
	//
	// Remaps affect structured CLI help/affordance references only. They
	// do not rewrite prose or links inside embedded Markdown. Every
	// explicitly mapped target must exist in the composed tree; otherwise
	// startup fails as an invalid SkillsOverlay. An unmapped canonical
	// reference whose file is absent is simply unavailable to presenters.
	ReferenceRemaps []SkillRefRemap
	// contains filtered or unexported fields
}

SkillsOverlay declares how a plugin customizes the CLI's embedded skill content, contributed via Builder.EmbeddedSkills. At most one source may own skill content; two customizing plugins abort startup.

Allow / Remove mirror Rule's Allow / Deny: an allow-list keeps only what it names, a remove-list drops what it names, and Remove wins over Allow. Composition order is fixed: Base (or the host-provided base) -> Allow -> Remove -> Overlay, a same-named skill resolving to Overlay. The repository's root binary provides its base from content_embed.go; an external wrapper main has no implicit CLI default and must call cmd.SetEmbeddedSkillContent before Execute if it relies on that base.

Skills are addressed by exact name (a directory carrying SKILL.md, e.g. "lark-doc"), not by command path and not by glob — the skill list is flat, so misspellings abort startup instead of silently matching nothing. Removing a skill drops its content and framework-generated guidance; it does not disable any command (use Restrict for that). ReferenceRemaps lets a distribution map the CLI's canonical skill references to the runtime names and files it ships.

The top-level skill set and each skill's owning FS are snapshotted when the CLI builds. Later additions or removals of top-level directories do not change the manifest; files within an owned skill directory are read live. Base and Overlay must contain only valid skill directories. A skill may declare hard dependencies under metadata.requires.skills in SKILL.md. Every declared dependency must be present in the final composed manifest; Allow is never widened and Remove is never overridden to satisfy one. A same-named Overlay replacement uses the replacement SKILL.md's dependency metadata, not the base copy's. Declaring this asset composition is a build-integrity commitment: invalid selection, content, ownership, or reference remaps abort the build rather than silently falling back to host defaults.

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