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.
304 lines
9.5 KiB
Go
304 lines
9.5 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/extension/platform"
|
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// tmpHome creates a tempdir, points $HOME at it, and returns the path to
|
|
// the ~/.lark-cli/ subdirectory (created). The HOME env var is restored
|
|
// when the test ends.
|
|
//
|
|
// LARKSUITE_CLI_CONFIG_DIR is force-set to the same path. Without that
|
|
// override, a developer running the tests with a personal
|
|
// LARKSUITE_CLI_CONFIG_DIR exported in their shell (or a CI runner with
|
|
// a baked-in value) would resolve userPolicyPath() to their real
|
|
// machine and bleed unrelated yaml into the test fixtures. With the
|
|
// override pinned here, the test is hermetic regardless of the host
|
|
// environment.
|
|
func tmpHome(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
t.Setenv("HOME", dir)
|
|
t.Setenv("USERPROFILE", dir) // Windows fallback for os.UserHomeDir
|
|
cfgDir := filepath.Join(dir, ".lark-cli")
|
|
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir)
|
|
return cfgDir
|
|
}
|
|
|
|
// writePolicy writes a policy.yml into the user config dir.
|
|
func writePolicy(t *testing.T, cfgDir string, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"), []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write policy: %v", err)
|
|
}
|
|
}
|
|
|
|
// fakeTree builds a minimal command tree with the same shape the real
|
|
// CLI exposes for these tests: lark-cli has a docs group with +fetch and
|
|
// +update, and an im group with +send. Each leaf has its risk_level set
|
|
// so MaxRisk filtering exercises a real path.
|
|
func fakeTree(t *testing.T) *cobra.Command {
|
|
t.Helper()
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
|
|
docs := &cobra.Command{Use: "docs"}
|
|
root.AddCommand(docs)
|
|
addLeaf(docs, "+fetch", "read")
|
|
addLeaf(docs, "+update", "write")
|
|
addLeaf(docs, "+delete-doc", "high-risk-write")
|
|
|
|
im := &cobra.Command{Use: "im"}
|
|
root.AddCommand(im)
|
|
addLeaf(im, "+send", "write")
|
|
|
|
return root
|
|
}
|
|
|
|
func addLeaf(parent *cobra.Command, use, risk string) {
|
|
leaf := &cobra.Command{
|
|
Use: use,
|
|
RunE: func(*cobra.Command, []string) error { return nil },
|
|
}
|
|
cmdutil.SetRisk(leaf, risk)
|
|
parent.AddCommand(leaf)
|
|
}
|
|
|
|
// findLeaf walks the tree by Use names.
|
|
func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
|
|
t.Helper()
|
|
cur := parent
|
|
for _, n := range names {
|
|
var next *cobra.Command
|
|
for _, c := range cur.Commands() {
|
|
if c.Use == n {
|
|
next = c
|
|
break
|
|
}
|
|
}
|
|
if next == nil {
|
|
t.Fatalf("child %q not found under %q", n, cur.Use)
|
|
}
|
|
cur = next
|
|
}
|
|
return cur
|
|
}
|
|
|
|
// Happy path: a valid policy.yml denies one specific command. The denied
|
|
// command's RunE returns a typed ExitError envelope; allowed commands are
|
|
// untouched.
|
|
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
|
|
cfgDir := tmpHome(t)
|
|
writePolicy(t, cfgDir, `
|
|
name: test-policy
|
|
allow: ["docs/**", "contact/**"]
|
|
deny: ["docs/+delete-doc"]
|
|
max_risk: write
|
|
`)
|
|
|
|
root := fakeTree(t)
|
|
if err := applyUserPolicyPruning(root, nil); err != nil {
|
|
t.Fatalf("apply policy: %v", err)
|
|
}
|
|
|
|
// docs/+delete-doc must be denied (Deny match).
|
|
deleteCmd := findLeaf(t, root, "docs", "+delete-doc")
|
|
if !deleteCmd.Hidden {
|
|
t.Errorf("+delete-doc should be hidden after pruning")
|
|
}
|
|
err := deleteCmd.RunE(deleteCmd, nil)
|
|
if err == nil {
|
|
t.Fatalf("+delete-doc RunE should return an error")
|
|
}
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
|
|
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
|
|
}
|
|
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
|
if !ok || detail["reason_code"] != "command_denylisted" {
|
|
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
|
|
}
|
|
|
|
// im/+send must be denied (domain not in Allow).
|
|
send := findLeaf(t, root, "im", "+send")
|
|
if !send.Hidden {
|
|
t.Errorf("im/+send should be hidden (not in Allow)")
|
|
}
|
|
|
|
// docs/+update must stay alive (domain matches, risk within max).
|
|
update := findLeaf(t, root, "docs", "+update")
|
|
if update.Hidden {
|
|
t.Errorf("docs/+update should remain visible")
|
|
}
|
|
if err := update.RunE(update, nil); err != nil {
|
|
t.Errorf("docs/+update RunE should succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
// Missing file means no pruning -- the CLI runs unrestricted with the
|
|
// full command surface. This is the default case for users who haven't
|
|
// opted into pruning.
|
|
func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
|
|
tmpHome(t) // home set but no policy.yml written
|
|
|
|
root := fakeTree(t)
|
|
if err := applyUserPolicyPruning(root, nil); err != nil {
|
|
t.Fatalf("missing policy should not error, got %v", err)
|
|
}
|
|
|
|
// Every leaf must remain non-Hidden.
|
|
for _, sub := range []string{"+fetch", "+update", "+delete-doc"} {
|
|
cmd := findLeaf(t, root, "docs", sub)
|
|
if cmd.Hidden {
|
|
t.Errorf("%s should not be Hidden when no policy file exists", sub)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Invalid yaml content (parse error) surfaces as an error from the
|
|
// wiring. The build path then decides whether to fail-open or
|
|
// fail-closed; the wiring itself stays neutral.
|
|
func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
|
cfgDir := tmpHome(t)
|
|
writePolicy(t, cfgDir, "::: not yaml :::")
|
|
|
|
root := fakeTree(t)
|
|
err := applyUserPolicyPruning(root, nil)
|
|
if err == nil {
|
|
t.Fatalf("malformed yaml should produce an error")
|
|
}
|
|
}
|
|
|
|
// When a plugin contributed rules, a malformed user policy.yml must NOT
|
|
// abort: plugin rules shadow yaml entirely, so the broken file is never
|
|
// read. Regression -- previously LoadYAMLPolicy ran first and an
|
|
// unrelated broken yaml on the user's machine could fatal a
|
|
// plugin-governed binary (build.go fail-CLOSES on policy errors when a
|
|
// plugin is present).
|
|
func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
|
|
cfgDir := tmpHome(t)
|
|
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
|
writePolicy(t, cfgDir, "::: not yaml :::") // broken on purpose
|
|
|
|
pluginRules := []cmdpolicy.PluginRule{
|
|
{PluginName: "secaudit", Rule: &platform.Rule{
|
|
Name: "docs-only",
|
|
Allow: []string{"docs/**"},
|
|
MaxRisk: "write",
|
|
}},
|
|
}
|
|
root := fakeTree(t)
|
|
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
|
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
|
|
}
|
|
|
|
// Plugin rule actually applied: im/+send is outside docs/** -> hidden.
|
|
if send := findLeaf(t, root, "im", "+send"); !send.Hidden {
|
|
t.Errorf("im/+send should be hidden by plugin rule (not in docs/** allow)")
|
|
}
|
|
// docs/+update is within allow and at/below max_risk -> stays visible.
|
|
if update := findLeaf(t, root, "docs", "+update"); update.Hidden {
|
|
t.Errorf("docs/+update should remain visible under plugin rule")
|
|
}
|
|
}
|
|
|
|
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
|
|
// Resolve and produces an error. This is the safety contract: a typo in
|
|
// the rule must not silently lower the pruning bar.
|
|
func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
|
|
cfgDir := tmpHome(t)
|
|
writePolicy(t, cfgDir, "max_risk: nukem\n")
|
|
|
|
root := fakeTree(t)
|
|
err := applyUserPolicyPruning(root, nil)
|
|
if err == nil {
|
|
t.Fatalf("invalid MaxRisk should produce an error")
|
|
}
|
|
}
|
|
|
|
// warnPolicyError emits to the supplied writer when err is non-nil and
|
|
// stays silent for nil. Verifies the build.go fail-open behaviour can be
|
|
// observed by users.
|
|
func TestWarnPolicyError(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
warnPolicyError(&buf, nil)
|
|
if buf.Len() != 0 {
|
|
t.Fatalf("warnPolicyError with nil err should write nothing, got %q", buf.String())
|
|
}
|
|
|
|
buf.Reset()
|
|
warnPolicyError(&buf, errors.New("boom"))
|
|
if buf.String() != "warning: user policy not applied: boom\n" {
|
|
t.Fatalf("warnPolicyError output = %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// End-to-end through buildInternal: when a valid policy.yml exists in
|
|
// HOME, building the real command tree applies pruning to it. This is
|
|
// the "actually integrated" test -- it exercises the wiring point in
|
|
// build.go itself, not just the helper.
|
|
func TestBuildInternal_appliesPolicyToRealTree(t *testing.T) {
|
|
cfgDir := tmpHome(t)
|
|
// Deny one specific shortcut path that we know exists in the real
|
|
// service tree -- we cannot enumerate it from a unit test, so we
|
|
// use an Allow-list that matches nothing to deny everything except
|
|
// the root, and then verify ANY non-root command was hidden.
|
|
writePolicy(t, cfgDir, `
|
|
name: deny-everything
|
|
deny: ["**"]
|
|
`)
|
|
|
|
root := Build(context.Background(), buildInvocationForTest(t))
|
|
|
|
// Find any leaf and verify it was hidden.
|
|
var foundHidden bool
|
|
walk(root, func(c *cobra.Command) {
|
|
if c.HasParent() && c.Runnable() && c.Hidden {
|
|
foundHidden = true
|
|
}
|
|
})
|
|
if !foundHidden {
|
|
t.Fatalf("expected at least one runnable command to be Hidden after deny=** policy")
|
|
}
|
|
|
|
// Root itself must stay alive.
|
|
if root.Hidden {
|
|
t.Errorf("root command must not be Hidden even under deny-everything policy")
|
|
}
|
|
}
|
|
|
|
func walk(cmd *cobra.Command, fn func(*cobra.Command)) {
|
|
if cmd == nil {
|
|
return
|
|
}
|
|
fn(cmd)
|
|
for _, c := range cmd.Commands() {
|
|
walk(c, fn)
|
|
}
|
|
}
|
|
|
|
// buildInvocationForTest returns a minimal cmdutil.InvocationContext so
|
|
// build.go's pure-assembly path can construct a tree without touching
|
|
// real config / credentials. Profile name is the empty default.
|
|
func buildInvocationForTest(t *testing.T) cmdutil.InvocationContext {
|
|
t.Helper()
|
|
return cmdutil.InvocationContext{}
|
|
}
|