Files
larksuite-cli/internal/platform/inventory.go
sang-neo03 50b3f0a2af feat(platform): support multiple policy rules per plugin (#1182)
* 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.
2026-05-30 17:05:33 +08:00

273 lines
7.8 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package internalplatform
import (
"strings"
"sync"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
)
// HookEntry is the displayable form of one registered hook.
type HookEntry struct {
Name string `json:"name"`
When string `json:"when,omitempty"` // observers only
Event string `json:"event,omitempty"` // lifecycle only
}
// PluginEntry collects everything one plugin contributed.
type PluginEntry struct {
Name string
Version string
Capabilities CapabilitiesView
// Rules holds the plugin's Restrict contributions, one per r.Restrict
// call (a plugin may declare several scoped rules). Empty when the
// plugin did not call r.Restrict.
Rules []*RuleView
Observers []HookEntry
Wrappers []HookEntry
Lifecycles []HookEntry
}
// CapabilitiesView mirrors platform.Capabilities for display. We keep a
// separate struct so the JSON shape stays under our control and does
// not drift with extension/platform.
type CapabilitiesView struct {
Restricts bool `json:"restricts"`
FailurePolicy string `json:"failure_policy"`
RequiredCLIVersion string `json:"required_cli_version,omitempty"`
}
// NewCapabilitiesView converts a platform.Capabilities value into the
// display struct.
func NewCapabilitiesView(c platform.Capabilities) CapabilitiesView {
return CapabilitiesView{
Restricts: c.Restricts,
FailurePolicy: failurePolicyLabel(c.FailurePolicy),
RequiredCLIVersion: c.RequiredCLIVersion,
}
}
func failurePolicyLabel(p platform.FailurePolicy) string {
switch p {
case platform.FailOpen:
return "FailOpen"
case platform.FailClosed:
return "FailClosed"
}
return ""
}
// RuleView is the displayable form of a Plugin.Restrict contribution.
type RuleView struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Allow []string `json:"allow"`
Deny []string `json:"deny"`
MaxRisk string `json:"max_risk"`
Identities []string `json:"identities"`
AllowUnannotated bool `json:"allow_unannotated"`
}
// Inventory is the full snapshot.
type Inventory struct {
Plugins []PluginEntry
}
// PluginInventorySource is the minimum slice of PluginInfo BuildInventory needs.
type PluginInventorySource struct {
Name string
Version string
Capabilities platform.Capabilities
}
// RuleInventorySource is the minimum slice of cmdpolicy.PluginRule
// BuildInventory needs. Kept as plain strings to avoid an import
// cycle with cmdpolicy (the caller converts platform.Risk / Identity
// to string at the boundary).
type RuleInventorySource struct {
PluginName string
Allow []string
Deny []string
MaxRisk string
Identities []string
RuleName string
Desc string
AllowUnannotated bool
}
// BuildInventory assembles an Inventory from the parts produced by
// InstallAll: the plugin metadata list, the hook registry (may be nil
// when no hooks were registered), and the plugin rules.
//
// Hooks are attributed to plugins by the namespaced name convention:
// each entry's Name starts with "<plugin>.", and we group by the
// leading segment up to the first dot.
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource) *Inventory {
byPlugin := make(map[string]*PluginEntry, len(plugins))
out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))}
for _, p := range plugins {
entry := PluginEntry{
Name: p.Name,
Version: p.Version,
Capabilities: NewCapabilitiesView(p.Capabilities),
}
out.Plugins = append(out.Plugins, entry)
}
for i := range out.Plugins {
byPlugin[out.Plugins[i].Name] = &out.Plugins[i]
}
if registry != nil {
for _, e := range registry.Observers() {
if entry := byPlugin[ownerOf(e.Name)]; entry != nil {
entry.Observers = append(entry.Observers, HookEntry{
Name: e.Name,
When: whenLabel(e.When),
})
}
}
for _, e := range registry.Wrappers() {
if entry := byPlugin[ownerOf(e.Name)]; entry != nil {
entry.Wrappers = append(entry.Wrappers, HookEntry{
Name: e.Name,
})
}
}
for _, e := range registry.Lifecycles() {
if entry := byPlugin[ownerOf(e.Name)]; entry != nil {
entry.Lifecycles = append(entry.Lifecycles, HookEntry{
Name: e.Name,
Event: eventLabel(e.Event),
})
}
}
}
for _, r := range rules {
if entry := byPlugin[r.PluginName]; entry != nil {
entry.Rules = append(entry.Rules, &RuleView{
Name: r.RuleName,
Description: r.Desc,
Allow: append([]string(nil), r.Allow...),
Deny: append([]string(nil), r.Deny...),
MaxRisk: r.MaxRisk,
Identities: append([]string(nil), r.Identities...),
AllowUnannotated: r.AllowUnannotated,
})
}
}
return out
}
// ownerOf extracts the plugin name from a namespaced hook name. The
// platform forbids "." in plugin names, so the first dot is always the
// namespace separator. Names without a dot are returned as-is.
func ownerOf(hookName string) string {
if i := strings.IndexByte(hookName, '.'); i >= 0 {
return hookName[:i]
}
return hookName
}
func whenLabel(w platform.When) string {
switch w {
case platform.Before:
return "Before"
case platform.After:
return "After"
}
return ""
}
func eventLabel(e platform.LifecycleEvent) string {
switch e {
case platform.Startup:
return "Startup"
case platform.Shutdown:
return "Shutdown"
}
return ""
}
// --- Active inventory storage (process-global) ---
var (
inventoryMu sync.RWMutex
activeInventory *Inventory
)
// SetActiveInventory records the inventory built at bootstrap. Called
// once from cmd/policy.go after install + wireHooks complete.
//
// A deep copy is taken so the snapshot is immune to later mutations of
// the input by the caller (or by any other goroutine reading the same
// PluginEntry slice). Without deep-copy, the shallow `cp := *inv`
// previously still aliased Plugins / observer / wrapper / lifecycle
// slices and the embedded RuleView's slice fields.
func SetActiveInventory(inv *Inventory) {
inventoryMu.Lock()
defer inventoryMu.Unlock()
if inv == nil {
activeInventory = nil
return
}
activeInventory = cloneInventory(inv)
}
// GetActiveInventory returns a deep copy of the inventory, or nil if
// bootstrap has not finished. Same reasoning as SetActiveInventory:
// returning a shallow copy would let callers reach into the stored
// global through any of the embedded slices.
func GetActiveInventory() *Inventory {
inventoryMu.RLock()
defer inventoryMu.RUnlock()
if activeInventory == nil {
return nil
}
return cloneInventory(activeInventory)
}
// cloneInventory deep-copies every level the snapshot exposes:
// top-level struct, Plugins slice, each PluginEntry's hook slices, and
// the rule's slice fields. The hook entries themselves are value types
// so the slice copy already disjoints them.
func cloneInventory(in *Inventory) *Inventory {
if in == nil {
return nil
}
out := &Inventory{
Plugins: make([]PluginEntry, len(in.Plugins)),
}
for i, p := range in.Plugins {
entry := PluginEntry{
Name: p.Name,
Version: p.Version,
Capabilities: p.Capabilities,
}
if p.Rules != nil {
entry.Rules = make([]*RuleView, len(p.Rules))
for j, r := range p.Rules {
if r == nil {
continue
}
rv := *r
rv.Allow = append([]string(nil), r.Allow...)
rv.Deny = append([]string(nil), r.Deny...)
rv.Identities = append([]string(nil), r.Identities...)
entry.Rules[j] = &rv
}
}
entry.Observers = append([]HookEntry(nil), p.Observers...)
entry.Wrappers = append([]HookEntry(nil), p.Wrappers...)
entry.Lifecycles = append([]HookEntry(nil), p.Lifecycles...)
out.Plugins[i] = entry
}
return out
}