mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
Introduces extension/platform — the in-process plugin SDK external
Go forks of lark-cli use to extend or restrict the command surface.
Plugins compile in via blank import; there is no dynamic loading
and no RPC isolation.
Public SDK (extension/platform):
- Plugin interface (Name / Version / Capabilities / Install).
- Registrar verbs: Observe, Wrap, On, Restrict.
- Hook types: Observer (side-effect, panic-safe, fires Before/After
RunE), Wrapper (middleware, may short-circuit via AbortError),
LifecycleHandler (Startup / Shutdown), Selector with nil-safe
And/Or/Not composition.
- Risk / Identity are defined string types with closed taxonomies;
ParseRisk / ParseIdentity convert raw strings with the
absent-vs-invalid distinction the engine relies on.
- Builder ergonomic constructor (NewPlugin().Observer().Wrap()
...MustBuild()) that enforces name/hookName grammar, hookName
uniqueness, and the Restrict ↔ FailClosed pairing regardless of
call order.
- Invocation is a read-only interface; the framework's concrete
invocation type lives in internal/hook so plugins cannot
fabricate denial / strict-mode / identity state. Args() returns
a defensive copy on every call so hook mutation cannot leak
into the original RunE.
- CommandDeniedError + AbortError carry structured fields for the
closed `command_denied` / `hook` envelope contract.
- ResetForTesting gated behind //go:build testing.
- README + godoc examples (Observer / Wrapper / Restrict) + two
runnable example forks (audit-observer, readonly-policy).
Host (internal/platform, internal/hook, internal/cmdpolicy):
- InstallAll: staged plugin registration with atomic commit, panic
isolation, FailOpen / FailClosed semantics, RequiredCLIVersion
semver check, single-Restrict invariant, duplicate-plugin-name
detection.
- hook.Install wraps every runnable cmd.RunE with:
Before observers (panic-safe) → denial guard → composed Wrap
chain → original RunE → After observers (always fire, even on
err). Denied commands physically bypass the Wrap chain so a
plugin Wrapper cannot suppress or rewrite a denial; observers
still see the attempt for audit.
- Recover shim around plugin Wrappers converts panics (including
the factory call) into a structured `hook` envelope with
reason_code=panic; namespacing shim attributes AbortError to
the namespaced hook name.
- cmdpolicy (renamed from internal/pruning) is the user-layer
command policy engine: walks the cobra tree, evaluates each
runnable command against a Rule's four-axis filter (Allow /
Deny / MaxRisk / Identities), produces parent-group aggregate
denials, and installs denyStubs. Rule.AllowUnannotated opts out
of the unannotated-deny gate for gradual adoption; risk_invalid
typos always deny with an edit-distance "did you mean"
suggestion.
- Strict-mode stub in cmd/prune.go composes the shared
detail.* / wrapped CommandDeniedError shape via cmdpolicy
helpers (BuildDenialError / CommandDeniedFromDenial /
DenialDetailMap), so command_denied envelopes from strict-mode
and user-layer policy carry the same closed-enum fields
(detail.layer / reason_code / policy_source). The historical
short Message + independent Hint are preserved unchanged.
- cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml
with KnownFields strict mode, including allow_unannotated.
- `config policy show` / `config policy validate` and the plugin
inventory diagnostic surface the resolved Rule (allow,
deny, max_risk, identities, allow_unannotated) and the hook
contributions per plugin.
Envelope contract (docs/extension/reason-codes.md):
- error.type is a closed set: command_denied, hook, plugin_install,
plugin_conflict, plugin_lifecycle.
- reason_code is a closed enum per error.type, dispatched on by
external agents and CI integrations.
- detail.layer = "policy" | "strict_mode" attributes the rejection.
Build / CI:
- Makefile unit-test / vet / coverage and ci.yml fast-gate +
unit-test + coverage now pass -tags testing so register_testing.go
is visible; ./extension/... is in the package list so the SDK's
own tests actually run.
- fmt-check and examples-build Makefile targets.
- bmatcuk/doublestar/v4 added as a direct dependency for `**` glob
matching in Rule.Allow / Rule.Deny.
Author-facing material:
- docs/extension/ (quickstart, plugin-author-guide, reason-codes)
is provided in the working tree but kept out of git tracking
per repo convention (.gitignore covers docs/).
Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703
248 lines
8.7 KiB
Go
248 lines
8.7 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"errors"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
|
"github.com/larksuite/cli/internal/hook"
|
|
"github.com/larksuite/cli/internal/output"
|
|
internalplatform "github.com/larksuite/cli/internal/platform"
|
|
)
|
|
|
|
// installFatalGuard wires a fail-closed guard at every cobra dispatch
|
|
// path on rootCmd. Used by the three abort-side fatal paths:
|
|
//
|
|
// - FailClosed plugin install failure (installPluginInstallErrorGuard)
|
|
// - Plugin Restrict conflict (installPluginConflictGuard)
|
|
// - Startup lifecycle handler failure (installPluginLifecycleErrorGuard)
|
|
//
|
|
// **Why we walk the tree rather than set PersistentPreRunE on root**:
|
|
// cobra's PersistentPreRunE has "first PersistentPreRunE wins"
|
|
// semantics -- the lookup starts at the invoked command and walks UP,
|
|
// stopping at the first non-nil PersistentPreRunE. Subcommands that
|
|
// declare their own PersistentPreRunE (cmd/auth/auth.go and
|
|
// cmd/config/config.go both do) would shadow root's, letting a
|
|
// fail-closed condition silently bypass via `lark-cli auth foo`.
|
|
//
|
|
// The fix: replace the RunE of every runnable command with one that
|
|
// returns makeErr(). Subcommands cannot bypass because the dispatch
|
|
// lands directly on their RunE, which now carries the guard.
|
|
//
|
|
// makeErr is called for every guarded dispatch; it must return a fresh
|
|
// *output.ExitError each time (the envelope writer mutates a few fields
|
|
// as it serialises).
|
|
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
|
|
// Two cobra subcommands are injected lazily at Execute() time and
|
|
// would otherwise slip past walkGuard. We pre-register both so
|
|
// walkGuard catches them.
|
|
//
|
|
// - "completion" (user-visible): InitDefaultCompletionCmd
|
|
// - "__complete" (internal shell-completion RPC): no public
|
|
// constructor; we add our own stub with the same name. cobra's
|
|
// internal initCompleteCmd checks for an existing "__complete"
|
|
// and skips registration if found, so our stub stays in place.
|
|
// (Cobra dispatches the "__completeNoDesc" alias through the
|
|
// same RunE, so guarding "__complete" covers both.)
|
|
rootCmd.InitDefaultCompletionCmd()
|
|
alreadyPresent := false
|
|
for _, c := range rootCmd.Commands() {
|
|
if c.Name() == "__complete" {
|
|
alreadyPresent = true
|
|
break
|
|
}
|
|
}
|
|
if !alreadyPresent {
|
|
rootCmd.AddCommand(&cobra.Command{
|
|
Use: "__complete",
|
|
Hidden: true,
|
|
RunE: func(*cobra.Command, []string) error { return makeErr() },
|
|
})
|
|
}
|
|
|
|
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
|
|
cmd.SilenceUsage = true
|
|
return makeErr()
|
|
}
|
|
rootCmd.PersistentPreRun = nil
|
|
walkGuard(rootCmd, makeErr)
|
|
}
|
|
|
|
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
|
// failure as a structured plugin_install envelope before any command
|
|
// runs.
|
|
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
|
makeErr := func() *output.ExitError {
|
|
var pi *internalplatform.PluginInstallError
|
|
if errors.As(installErr, &pi) {
|
|
return &output.ExitError{
|
|
Code: output.ExitValidation,
|
|
Detail: &output.ErrDetail{
|
|
Type: "plugin_install",
|
|
Message: pi.Error(),
|
|
Detail: map[string]any{
|
|
"plugin": pi.PluginName,
|
|
"reason_code": pi.ReasonCode,
|
|
"reason": pi.Reason,
|
|
},
|
|
},
|
|
Err: installErr,
|
|
}
|
|
}
|
|
return &output.ExitError{
|
|
Code: output.ExitValidation,
|
|
Detail: &output.ErrDetail{
|
|
Type: "plugin_install",
|
|
Message: installErr.Error(),
|
|
Detail: map[string]any{
|
|
"reason_code": internalplatform.ReasonInstallFailed,
|
|
},
|
|
},
|
|
Err: installErr,
|
|
}
|
|
}
|
|
installFatalGuard(rootCmd, makeErr)
|
|
}
|
|
|
|
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
|
|
// error (single plugin invalid Rule or multiple plugins each contributing
|
|
// Restrict). The design separates the envelope type:
|
|
//
|
|
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
|
|
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
|
//
|
|
// Either way the CLI must NOT silently continue with a broken policy.
|
|
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
|
makeErr := func() *output.ExitError {
|
|
envelopeType := "plugin_install"
|
|
reasonCode := internalplatform.ReasonInvalidRule
|
|
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
|
envelopeType = "plugin_conflict"
|
|
reasonCode = internalplatform.ReasonMultipleRestricts
|
|
}
|
|
return &output.ExitError{
|
|
Code: output.ExitValidation,
|
|
Detail: &output.ErrDetail{
|
|
Type: envelopeType,
|
|
Message: err.Error(),
|
|
Detail: map[string]any{
|
|
"reason_code": reasonCode,
|
|
},
|
|
},
|
|
Err: err,
|
|
}
|
|
}
|
|
installFatalGuard(rootCmd, makeErr)
|
|
}
|
|
|
|
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
|
// failure as a plugin_lifecycle envelope. The reason_code splits
|
|
// returned-error vs panic so consumers (audit / on-call) can tell the
|
|
// two failure modes apart.
|
|
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
|
makeErr := func() *output.ExitError {
|
|
reasonCode := "lifecycle_failed"
|
|
detail := map[string]any{
|
|
"reason_code": reasonCode,
|
|
}
|
|
var le *hook.LifecycleError
|
|
if errors.As(err, &le) {
|
|
if le.Panic {
|
|
reasonCode = "lifecycle_panic"
|
|
}
|
|
detail = map[string]any{
|
|
"reason_code": reasonCode,
|
|
"hook_name": le.HookName,
|
|
"event": "startup",
|
|
}
|
|
}
|
|
return &output.ExitError{
|
|
Code: output.ExitValidation,
|
|
Detail: &output.ErrDetail{
|
|
Type: "plugin_lifecycle",
|
|
Message: err.Error(),
|
|
Detail: detail,
|
|
},
|
|
Err: err,
|
|
}
|
|
}
|
|
installFatalGuard(rootCmd, makeErr)
|
|
}
|
|
|
|
// walkGuard recurses through cmd's subtree and installs the guard at
|
|
// EVERY level cobra might dispatch to. The cobra execution order is:
|
|
//
|
|
// 1. PersistentPreRunE (looked up from leaf, walking up; "first wins")
|
|
// 2. PreRunE
|
|
// 3. RunE
|
|
// 4. PostRunE
|
|
// 5. PersistentPostRunE
|
|
//
|
|
// A subcommand that declares its own PersistentPreRunE (cmd/auth and
|
|
// cmd/config both do) would not only shadow root's PersistentPreRunE
|
|
// -- if that PreRunE itself returns an error (e.g. auth's
|
|
// external_provider check), the user sees THAT error instead of
|
|
// our plugin_install envelope, even if RunE was guarded.
|
|
//
|
|
// To close every dispatch hole we replace:
|
|
// - every command's PersistentPreRunE (including non-runnable groups)
|
|
// - every runnable command's PreRunE and RunE
|
|
//
|
|
// This way the very first non-nil step in cobra's chain is always our
|
|
// guard, regardless of which leaf the user invoked.
|
|
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
|
|
if cmd == nil {
|
|
return
|
|
}
|
|
// PersistentPreRunE is the first step cobra runs (after Args /
|
|
// flag validation -- see below). Set it on every command (root
|
|
// included) so cobra's "first wins" walk-up always finds OUR
|
|
// PersistentPreRunE before hitting any subcommand's pre-existing
|
|
// one.
|
|
cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error {
|
|
c.SilenceUsage = true
|
|
return makeErr()
|
|
}
|
|
cmd.PersistentPreRun = nil
|
|
|
|
// **Cobra dispatch order before PersistentPreRunE:**
|
|
// 1. ValidateArgs(cmd.Args) -- can return arg error
|
|
// 2. ParsePersistentFlags / ParseFlags -- can return flag error
|
|
// 3. Find legacyArgs check for unknown-command at root
|
|
// 4. PersistentPreRunE / PreRunE / RunE
|
|
// 5. Non-runnable groups fall through to help (PreRunE skipped)
|
|
//
|
|
// We neutralise each step:
|
|
// - Args = ArbitraryArgs -> ValidateArgs no-op. **Not nil**:
|
|
// cobra falls back to legacyArgs
|
|
// when Args==nil, which returns an
|
|
// unknown-command error during Find
|
|
// BEFORE PersistentPreRunE runs.
|
|
// ArbitraryArgs explicitly accepts
|
|
// everything, suppressing that path.
|
|
// - DisableFlagParsing -> ParseFlags skipped (and legacy
|
|
// "unknown flag" suppressed)
|
|
// - PreRunE / RunE on EVERY -> Even non-runnable groups now run
|
|
// command (not just leaves) the guard instead of showing help
|
|
//
|
|
// Setting RunE on a parent group flips Runnable() to true, so
|
|
// cobra dispatches to it (and our guard fires) rather than calling
|
|
// the help command on a "help-only" group.
|
|
cmd.Args = cobra.ArbitraryArgs
|
|
cmd.DisableFlagParsing = true
|
|
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
|
c.SilenceUsage = true
|
|
return makeErr()
|
|
}
|
|
cmd.PreRun = nil
|
|
cmd.RunE = func(*cobra.Command, []string) error { return makeErr() }
|
|
cmd.Run = nil
|
|
for _, c := range cmd.Commands() {
|
|
walkGuard(c, makeErr)
|
|
}
|
|
}
|