mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +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
245 lines
7.6 KiB
Go
245 lines
7.6 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/extension/platform"
|
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
|
"github.com/larksuite/cli/internal/hook"
|
|
internalplatform "github.com/larksuite/cli/internal/platform"
|
|
"github.com/larksuite/cli/internal/vfs"
|
|
)
|
|
|
|
// userPolicyFileName is the conventional filename for the user-layer Rule.
|
|
// Lives under ~/.lark-cli/ to match the rest of the CLI's user-state
|
|
// directory.
|
|
const userPolicyFileName = "policy.yml"
|
|
|
|
// applyUserPolicyPruning resolves the user-layer Rule from plugin
|
|
// contributions and/or ~/.lark-cli/policy.yml and installs denyStubs
|
|
// for commands it rejects.
|
|
//
|
|
// Missing yaml is not an error -- the CLI runs with no user-layer
|
|
// restriction. A malformed Rule (bad MaxRisk enum, malformed glob, etc.)
|
|
// surfaces via the returned error; the caller decides how to handle it.
|
|
//
|
|
// pluginRules carries Plugin.Restrict() contributions collected from
|
|
// the InstallAll phase; nil/empty is fine.
|
|
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
|
yamlPath, err := userPolicyPath()
|
|
if err != nil {
|
|
// No user home dir means we cannot locate the policy. Treat
|
|
// the same as "file missing": no pruning, no error. This keeps
|
|
// non-interactive CI environments (no HOME set) running.
|
|
yamlPath = ""
|
|
}
|
|
|
|
rule, source, err := cmdpolicy.Resolve(pluginRules, yamlPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rule == nil {
|
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
|
Source: source,
|
|
YAMLPath: yamlPath,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
engine := cmdpolicy.New(rule)
|
|
decisions := engine.EvaluateAll(rootCmd)
|
|
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
|
|
cmdpolicy.Apply(rootCmd, denied)
|
|
|
|
// Record the active policy so `config policy show` can read it.
|
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
|
Rule: rule,
|
|
Source: source,
|
|
YAMLPath: yamlPath,
|
|
DeniedPaths: len(denied),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
|
// registered plugins, returning the Plugin.Restrict contributions for
|
|
// cmdpolicy and the populated hook.Registry for the runtime wrapper.
|
|
// Errors from FailClosed plugins propagate; FailOpen failures are
|
|
// warned to errOut and the loop continues.
|
|
func installPluginsAndHooks(errOut io.Writer) (*internalplatform.InstallResult, error) {
|
|
plugins := platform.RegisteredPlugins()
|
|
if len(plugins) == 0 {
|
|
return &internalplatform.InstallResult{Registry: nil}, nil
|
|
}
|
|
return internalplatform.InstallAll(plugins, errOut)
|
|
}
|
|
|
|
// recordInventory builds and stores the plugin inventory snapshot for
|
|
// diagnostic commands (config plugins show) to read at runtime. Called
|
|
// once from build.go after applyUserPolicyPruning + wireHooks succeed.
|
|
func recordInventory(installResult *internalplatform.InstallResult) {
|
|
if installResult == nil {
|
|
internalplatform.SetActiveInventory(nil)
|
|
return
|
|
}
|
|
pluginSrcs := make([]internalplatform.PluginInventorySource, 0, len(installResult.Plugins))
|
|
for _, p := range installResult.Plugins {
|
|
pluginSrcs = append(pluginSrcs, internalplatform.PluginInventorySource{
|
|
Name: p.Name,
|
|
Version: p.Version,
|
|
Capabilities: p.Capabilities,
|
|
})
|
|
}
|
|
ruleSrcs := make([]internalplatform.RuleInventorySource, 0, len(installResult.PluginRules))
|
|
for _, r := range installResult.PluginRules {
|
|
if r.Rule == nil {
|
|
continue
|
|
}
|
|
idents := make([]string, len(r.Rule.Identities))
|
|
for i, id := range r.Rule.Identities {
|
|
idents[i] = string(id)
|
|
}
|
|
ruleSrcs = append(ruleSrcs, internalplatform.RuleInventorySource{
|
|
PluginName: r.PluginName,
|
|
Allow: r.Rule.Allow,
|
|
Deny: r.Rule.Deny,
|
|
MaxRisk: string(r.Rule.MaxRisk),
|
|
Identities: idents,
|
|
RuleName: r.Rule.Name,
|
|
Desc: r.Rule.Description,
|
|
AllowUnannotated: r.Rule.AllowUnannotated,
|
|
})
|
|
}
|
|
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
|
|
}
|
|
|
|
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
|
// and emits the Startup lifecycle event. The registry may be nil when
|
|
// no plugin contributed any hook -- the function short-circuits in
|
|
// that case to avoid useless RunE wrapping.
|
|
func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry) error {
|
|
if reg == nil {
|
|
return nil
|
|
}
|
|
hook.Install(rootCmd, reg, cobraCommandViewSource{})
|
|
return hook.Emit(ctx, reg, platform.Startup, nil)
|
|
}
|
|
|
|
// cobraCommandViewSource is the default CommandViewSource: it builds a
|
|
// CommandView directly from a *cobra.Command on demand. A future PR
|
|
// will snapshot views at registration time so the view survives
|
|
// strict-mode's RemoveCommand+AddCommand replacement of the
|
|
// underlying *cobra.Command pointer. For now this is acceptable
|
|
// because user-layer cmdpolicy preserves the pointer (only strict-mode
|
|
// swaps it), and strict-mode-pruned commands are already unreachable
|
|
// by the hook chain.
|
|
type cobraCommandViewSource struct{}
|
|
|
|
func (cobraCommandViewSource) View(cmd *cobra.Command) platform.CommandView {
|
|
return cobraCommandView{cmd: cmd}
|
|
}
|
|
|
|
// cobraCommandView adapts *cobra.Command to the CommandView interface.
|
|
type cobraCommandView struct {
|
|
cmd *cobra.Command
|
|
}
|
|
|
|
func (v cobraCommandView) Path() string {
|
|
return cmdpolicy.CanonicalPath(v.cmd)
|
|
}
|
|
|
|
func (v cobraCommandView) Domain() string {
|
|
for c := v.cmd; c != nil; c = c.Parent() {
|
|
if c.Annotations == nil {
|
|
continue
|
|
}
|
|
if v, ok := c.Annotations["cmdmeta.domain"]; ok && v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (v cobraCommandView) Risk() (platform.Risk, bool) {
|
|
for c := v.cmd; c != nil; c = c.Parent() {
|
|
if c.Annotations == nil {
|
|
continue
|
|
}
|
|
if r, ok := c.Annotations["risk_level"]; ok && r != "" {
|
|
return platform.Risk(r), true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (v cobraCommandView) Identities() []platform.Identity {
|
|
for c := v.cmd; c != nil; c = c.Parent() {
|
|
if c.Annotations == nil {
|
|
continue
|
|
}
|
|
if raw, ok := c.Annotations["lark:supportedIdentities"]; ok && raw != "" {
|
|
parts := splitCSV(raw)
|
|
out := make([]platform.Identity, len(parts))
|
|
for i, p := range parts {
|
|
out[i] = platform.Identity(p)
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (v cobraCommandView) Annotation(key string) (string, bool) {
|
|
if v.cmd.Annotations == nil {
|
|
return "", false
|
|
}
|
|
s, ok := v.cmd.Annotations[key]
|
|
return s, ok
|
|
}
|
|
|
|
// splitCSV is a tiny csv-without-quotes helper. The
|
|
// lark:supportedIdentities annotation is always plain
|
|
// "user" / "bot" / "user,bot" without escaping.
|
|
func splitCSV(s string) []string {
|
|
out := []string{}
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == ',' {
|
|
out = append(out, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
out = append(out, s[start:])
|
|
return out
|
|
}
|
|
|
|
// userPolicyPath returns the absolute path of ~/.lark-cli/policy.yml,
|
|
// or an error if the user's home directory cannot be determined.
|
|
func userPolicyPath() (string, error) {
|
|
home, err := vfs.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(home, ".lark-cli", userPolicyFileName), nil
|
|
}
|
|
|
|
// warnPolicyError writes a one-line stderr warning when the user policy
|
|
// fails to load. V1 yaml errors are fail-OPEN -- the CLI keeps running
|
|
// without policy enforcement so the user can fix the typo. Plugin-supplied
|
|
// rules are fail-CLOSED instead because integrators take a code-level
|
|
// responsibility for them.
|
|
func warnPolicyError(errOut io.Writer, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
fmt.Fprintf(errOut, "warning: user policy not applied: %v\n", err)
|
|
}
|