mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +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.
94 lines
2.7 KiB
Go
94 lines
2.7 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmdpolicy
|
|
|
|
import (
|
|
"sync"
|
|
|
|
"github.com/larksuite/cli/extension/platform"
|
|
)
|
|
|
|
// ActivePolicy is the resolved user-layer policy after applyUserPolicyPruning
|
|
// has run during bootstrap. `lark-cli config policy show` reads this to
|
|
// answer "what rule is currently in effect, and how many commands does
|
|
// it hide?".
|
|
//
|
|
// Set once at bootstrap time; consumed read-only thereafter.
|
|
//
|
|
// Rules is the full set the winning source contributed (one rule for the
|
|
// common single-rule case, several when a plugin or yaml declares scoped
|
|
// grants). nil/empty means "no rule applied".
|
|
type ActivePolicy struct {
|
|
Rules []*platform.Rule
|
|
Source ResolveSource
|
|
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
|
}
|
|
|
|
var (
|
|
activeMu sync.RWMutex
|
|
activePolicy *ActivePolicy
|
|
)
|
|
|
|
// SetActive records the policy that ends up applied. Called exactly once
|
|
// per process from cmd/policy.go::applyUserPolicyPruning. The mutex is
|
|
// belt-and-braces in case future test paths interleave with bootstrap.
|
|
//
|
|
// A deep copy is taken so the snapshot is immune to later mutations of
|
|
// the input by the caller (a plugin-supplied *Rule could otherwise
|
|
// mutate the embedded Allow/Deny/Identities slices after we stored it).
|
|
func SetActive(p *ActivePolicy) {
|
|
activeMu.Lock()
|
|
defer activeMu.Unlock()
|
|
if p == nil {
|
|
activePolicy = nil
|
|
return
|
|
}
|
|
activePolicy = cloneActivePolicy(p)
|
|
}
|
|
|
|
// GetActive returns a deep copy of the recorded policy, or nil if
|
|
// bootstrap has not finished or no rule applied. Callers can freely
|
|
// mutate the result — including the embedded Rule slices — without
|
|
// affecting the stored global.
|
|
func GetActive() *ActivePolicy {
|
|
activeMu.RLock()
|
|
defer activeMu.RUnlock()
|
|
if activePolicy == nil {
|
|
return nil
|
|
}
|
|
return cloneActivePolicy(activePolicy)
|
|
}
|
|
|
|
// cloneActivePolicy deep-copies the top-level struct, the Rules slice, and
|
|
// each Rule's own slice fields. Other fields (Source, DeniedPaths) are
|
|
// value types so the struct copy already disjoints them.
|
|
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
cp := *in
|
|
if in.Rules != nil {
|
|
cp.Rules = make([]*platform.Rule, len(in.Rules))
|
|
for i, r := range in.Rules {
|
|
if r == nil {
|
|
continue
|
|
}
|
|
rule := *r
|
|
rule.Allow = append([]string(nil), r.Allow...)
|
|
rule.Deny = append([]string(nil), r.Deny...)
|
|
rule.Identities = append([]platform.Identity(nil), r.Identities...)
|
|
cp.Rules[i] = &rule
|
|
}
|
|
}
|
|
return &cp
|
|
}
|
|
|
|
// ResetActiveForTesting clears the recorded policy. Tests must call this
|
|
// in t.Cleanup when they exercise the bootstrap path.
|
|
func ResetActiveForTesting() {
|
|
activeMu.Lock()
|
|
defer activeMu.Unlock()
|
|
activePolicy = nil
|
|
}
|