mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
* feat(platform): support multiple policy rules per plugin
Extend the command policy framework from single-Rule to multi-Rule
semantics. A plugin (or policy.yml) may now contribute several scoped
Rules; the engine combines them with OR -- a command is allowed when it
satisfies every axis of at least one rule. This lets one integration
apply different risk ceilings and identity restrictions to different
command groups.
The cross-plugin fail-closed boundary is preserved: two distinct plugins
both calling Restrict still aborts startup (multiple_restrict_plugins).
Single-Rule behaviour is fully backward compatible -- the rejection
reason_code / rule_name / envelope shape are byte-for-byte unchanged;
multi-rule rejection surfaces the aggregate reason_code no_matching_rule.
- engine: New keeps single-rule compat, add NewSet for OR over rules
- resolver: dedupe by owner (one plugin may contribute many rules),
return []*Rule; yaml gains a top-level rules: list
- registrar/builder/staging: Restrict may be called more than once;
retire the double_restrict error
- config policy show / config plugins show: emit a rules array
- inventory: PluginEntry.Rules is now a slice (fixes last-rule-wins
overwrite when a plugin contributes multiple rules)
* fix(platform): clone rules in Builder.Restrict and inventory snapshot
Address review feedback. Builder.Restrict stored the caller's *Rule
directly, so reusing and mutating one Rule object across multiple
Restrict calls collapsed entries to the last mutation; clone the rule and
its slices on append, mirroring the staging registrar.
BuildInventory likewise reused the source Allow/Deny/Identities slices;
copy them when building the RuleView snapshot instead of relying on
cloneInventory downstream.
Add a regression test: reusing and mutating one Rule across two Restrict
calls now yields two independent rules.
* fix(platform): skip yaml when a plugin owns policy; reject empty rules list
Two policy-config robustness fixes from review:
- A malformed ~/.lark-cli/policy.yml could abort a plugin-governed
binary. applyUserPolicyPruning read yaml before resolving, and
build.go fail-closes on any policy error when a plugin is present.
Plugin rules shadow yaml anyway, so skip reading yaml entirely when a
plugin contributed rules -- an unrelated broken file on the user's
machine can no longer lock the CLI.
- A present-but-empty "rules: []" collapsed to a single all-zero Rule
that allows every annotated command ("looks like policy, enforces
almost nothing"). yaml.Parse now distinguishes absent from
present-but-empty (Rules is a pointer) and rejects the empty list.
Add regression tests for both.
224 lines
6.8 KiB
Go
224 lines
6.8 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package platform
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
)
|
|
|
|
// 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)
|
|
type Builder struct {
|
|
name string
|
|
version string
|
|
caps Capabilities
|
|
|
|
actions []func(Registrar)
|
|
rules []*Rule
|
|
|
|
hookNames map[string]bool
|
|
errs []error
|
|
}
|
|
|
|
var pluginNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
|
|
|
// NewPlugin starts a Builder. Name format is validated lazily — errors
|
|
// surface at Build()/MustBuild() time, allowing chained calls without
|
|
// intermediate error handling.
|
|
func NewPlugin(name, version string) *Builder {
|
|
b := &Builder{
|
|
name: name,
|
|
version: version,
|
|
hookNames: map[string]bool{},
|
|
}
|
|
if !pluginNamePattern.MatchString(name) {
|
|
b.errs = append(b.errs, fmt.Errorf("invalid plugin name %q: must match ^[a-z0-9][a-z0-9-]*$", name))
|
|
}
|
|
return b
|
|
}
|
|
|
|
// RequireCLI sets Capabilities.RequiredCLIVersion (semver constraint,
|
|
// e.g. ">=1.1.0"). Empty string means no requirement.
|
|
func (b *Builder) RequireCLI(constraint string) *Builder {
|
|
b.caps.RequiredCLIVersion = constraint
|
|
return b
|
|
}
|
|
|
|
// FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when
|
|
// neither FailOpen nor FailClosed is called and Restrict is not used.
|
|
func (b *Builder) FailOpen() *Builder {
|
|
b.caps.FailurePolicy = FailOpen
|
|
return b
|
|
}
|
|
|
|
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit
|
|
// when Restrict() is called.
|
|
func (b *Builder) FailClosed() *Builder {
|
|
b.caps.FailurePolicy = FailClosed
|
|
return b
|
|
}
|
|
|
|
// Observer registers an Observer. Multiple calls accumulate.
|
|
func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder {
|
|
if !b.validateHookName(hookName, "observer") {
|
|
return b
|
|
}
|
|
// Capture by value so the action closure doesn't share state with
|
|
// subsequent Observer() calls (Go ≥1.22 already gives each call
|
|
// its own copies of parameter values, but pinning is explicit).
|
|
w, n, s, f := when, hookName, sel, fn
|
|
b.actions = append(b.actions, func(r Registrar) {
|
|
r.Observe(w, n, s, f)
|
|
})
|
|
return b
|
|
}
|
|
|
|
// Wrap registers a Wrapper. Multiple calls accumulate; the host
|
|
// composes them in registration order (outermost first).
|
|
func (b *Builder) Wrap(hookName string, sel Selector, wrap Wrapper) *Builder {
|
|
if !b.validateHookName(hookName, "wrap") {
|
|
return b
|
|
}
|
|
n, s, w := hookName, sel, wrap
|
|
b.actions = append(b.actions, func(r Registrar) {
|
|
r.Wrap(n, s, w)
|
|
})
|
|
return b
|
|
}
|
|
|
|
// On registers a LifecycleHandler.
|
|
func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler) *Builder {
|
|
if !b.validateHookName(hookName, "on") {
|
|
return b
|
|
}
|
|
e, n, f := event, hookName, fn
|
|
b.actions = append(b.actions, func(r Registrar) {
|
|
r.On(e, n, f)
|
|
})
|
|
return b
|
|
}
|
|
|
|
// 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 (b *Builder) Restrict(rule *Rule) *Builder {
|
|
if rule == nil {
|
|
b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be nil"))
|
|
return b
|
|
}
|
|
b.caps.Restricts = true
|
|
b.caps.FailurePolicy = FailClosed
|
|
// Defensive clone: capture an independent snapshot so a caller that
|
|
// reuses and mutates the same *Rule across multiple Restrict calls
|
|
// gets distinct entries (mirrors the staging registrar's clone).
|
|
cp := *rule
|
|
cp.Allow = append([]string(nil), rule.Allow...)
|
|
cp.Deny = append([]string(nil), rule.Deny...)
|
|
cp.Identities = append([]Identity(nil), rule.Identities...)
|
|
b.rules = append(b.rules, &cp)
|
|
return b
|
|
}
|
|
|
|
// 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 (b *Builder) Build() (Plugin, error) {
|
|
if len(b.rules) > 0 && b.caps.FailurePolicy == FailOpen {
|
|
b.errs = append(b.errs, errors.New(
|
|
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
|
}
|
|
if len(b.errs) > 0 {
|
|
return nil, errors.Join(b.errs...)
|
|
}
|
|
return &builtPlugin{
|
|
name: b.name,
|
|
version: b.version,
|
|
caps: b.caps,
|
|
actions: b.actions,
|
|
rules: b.rules,
|
|
}, nil
|
|
}
|
|
|
|
// 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 (b *Builder) MustBuild() Plugin {
|
|
p, err := b.Build()
|
|
if err != nil {
|
|
panic(fmt.Sprintf("plugin %q: %v", b.name, err))
|
|
}
|
|
return p
|
|
}
|
|
|
|
// validateHookName checks the grammar and uniqueness; returns false
|
|
// when the name was rejected (caller skips the action).
|
|
func (b *Builder) validateHookName(hookName, kind string) bool {
|
|
if !pluginNamePattern.MatchString(hookName) {
|
|
b.errs = append(b.errs, fmt.Errorf(
|
|
"%s %q: hookName must match ^[a-z0-9][a-z0-9-]*$", kind, hookName))
|
|
return false
|
|
}
|
|
if b.hookNames[hookName] {
|
|
b.errs = append(b.errs, fmt.Errorf(
|
|
"%s %q: hookName already used in this plugin", kind, hookName))
|
|
return false
|
|
}
|
|
b.hookNames[hookName] = true
|
|
return true
|
|
}
|
|
|
|
// builtPlugin is the Plugin implementation the builder emits.
|
|
type builtPlugin struct {
|
|
name string
|
|
version string
|
|
caps Capabilities
|
|
actions []func(Registrar)
|
|
rules []*Rule
|
|
}
|
|
|
|
func (p *builtPlugin) Name() string { return p.name }
|
|
func (p *builtPlugin) Version() string { return p.version }
|
|
func (p *builtPlugin) Capabilities() Capabilities { return p.caps }
|
|
func (p *builtPlugin) Install(r Registrar) error {
|
|
for _, rule := range p.rules {
|
|
r.Restrict(rule)
|
|
}
|
|
for _, action := range p.actions {
|
|
action(r)
|
|
}
|
|
return nil
|
|
}
|