mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +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.
183 lines
5.4 KiB
Go
183 lines
5.4 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package yaml_test
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/extension/platform"
|
|
pyaml "github.com/larksuite/cli/internal/cmdpolicy/yaml"
|
|
)
|
|
|
|
func TestParse_validRule(t *testing.T) {
|
|
data := []byte(`
|
|
name: agent-docs-readonly
|
|
description: only-read docs
|
|
allow:
|
|
- docs/**
|
|
- contact/**
|
|
deny:
|
|
- docs/+update
|
|
max_risk: read
|
|
identities:
|
|
- user
|
|
`)
|
|
rules, err := pyaml.Parse(data)
|
|
if err != nil {
|
|
t.Fatalf("Parse failed: %v", err)
|
|
}
|
|
want := &platform.Rule{
|
|
Name: "agent-docs-readonly",
|
|
Description: "only-read docs",
|
|
Allow: []string{"docs/**", "contact/**"},
|
|
Deny: []string{"docs/+update"},
|
|
MaxRisk: "read",
|
|
Identities: []platform.Identity{"user"},
|
|
}
|
|
// A flat top-level rule yields exactly one element (backward compat).
|
|
if !reflect.DeepEqual(rules, []*platform.Rule{want}) {
|
|
t.Fatalf("rules = %+v, want single %+v", rules, want)
|
|
}
|
|
}
|
|
|
|
// A "rules:" list yields one platform.Rule per entry, in order. This is
|
|
// the multi-rule layout: each rule is a scoped grant the engine
|
|
// OR-combines.
|
|
func TestParse_rulesList(t *testing.T) {
|
|
data := []byte(`
|
|
rules:
|
|
- name: docs-ro
|
|
allow: [docs/**]
|
|
max_risk: read
|
|
- name: im-rw
|
|
allow: [im/**]
|
|
max_risk: write
|
|
identities: [user, bot]
|
|
`)
|
|
rules, err := pyaml.Parse(data)
|
|
if err != nil {
|
|
t.Fatalf("Parse failed: %v", err)
|
|
}
|
|
want := []*platform.Rule{
|
|
{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"},
|
|
{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write", Identities: []platform.Identity{"user", "bot"}},
|
|
}
|
|
if !reflect.DeepEqual(rules, want) {
|
|
t.Fatalf("rules = %+v, want %+v", rules, want)
|
|
}
|
|
}
|
|
|
|
// A "rules:" key that is present but empty is a foot-gun: an empty list
|
|
// would otherwise fall through to a single all-zero Rule that allows
|
|
// every annotated command ("looks like a policy, enforces almost
|
|
// nothing"). Parse must reject it outright instead.
|
|
func TestParse_rejectsEmptyRulesList(t *testing.T) {
|
|
if _, err := pyaml.Parse([]byte("rules: []\n")); err == nil {
|
|
t.Fatalf("Parse should reject a present-but-empty 'rules:' list")
|
|
}
|
|
}
|
|
|
|
// Mixing top-level flat rule fields with a rules: list is ambiguous and
|
|
// must be rejected rather than silently picking one.
|
|
func TestParse_rejectsFlatPlusRulesMix(t *testing.T) {
|
|
data := []byte(`
|
|
name: top-level
|
|
rules:
|
|
- name: nested
|
|
`)
|
|
if _, err := pyaml.Parse(data); err == nil {
|
|
t.Fatalf("Parse should reject mixing top-level fields with a rules: list")
|
|
}
|
|
}
|
|
|
|
// allow_unannotated is documented in the README / author guide as the
|
|
// gradual-adoption opt-in. The yaml schema must carry it through to
|
|
// platform.Rule, otherwise a user following the docs would either hit
|
|
// "unknown field" (under KnownFields strict mode) or silently lose the
|
|
// opt-in and end up with a safer-but-broken policy.
|
|
func TestParse_allowUnannotatedPassesThrough(t *testing.T) {
|
|
data := []byte(`
|
|
name: agent-readonly
|
|
max_risk: read
|
|
allow_unannotated: true
|
|
`)
|
|
rules, err := pyaml.Parse(data)
|
|
if err != nil {
|
|
t.Fatalf("Parse failed: %v", err)
|
|
}
|
|
if !rules[0].AllowUnannotated {
|
|
t.Fatalf("AllowUnannotated = false, want true (yaml field must propagate)")
|
|
}
|
|
if rules[0].MaxRisk != "read" || rules[0].Name != "agent-readonly" {
|
|
t.Errorf("other fields lost: %+v", rules[0])
|
|
}
|
|
}
|
|
|
|
// Default is false when the key is absent: pin the fail-closed default so
|
|
// future schema edits cannot accidentally flip it.
|
|
func TestParse_allowUnannotatedDefaultsFalse(t *testing.T) {
|
|
data := []byte(`
|
|
name: x
|
|
max_risk: read
|
|
`)
|
|
rules, err := pyaml.Parse(data)
|
|
if err != nil {
|
|
t.Fatalf("Parse failed: %v", err)
|
|
}
|
|
if rules[0].AllowUnannotated {
|
|
t.Fatalf("AllowUnannotated must default to false when key is absent")
|
|
}
|
|
}
|
|
|
|
// Unknown fields must be rejected so the old binary cannot silently ignore
|
|
// new schema additions (forward-compat safeguard).
|
|
func TestParse_rejectsUnknownFields(t *testing.T) {
|
|
data := []byte(`
|
|
name: x
|
|
mystery_field: oh no
|
|
`)
|
|
if _, err := pyaml.Parse(data); err == nil {
|
|
t.Fatalf("Parse should reject unknown yaml field 'mystery_field'")
|
|
}
|
|
}
|
|
|
|
// Semantic validation lives in cmdpolicy.ValidateRule. Parse only checks
|
|
// structural yaml; an invalid max_risk passes through (validation happens
|
|
// downstream).
|
|
func TestParse_doesNotValidateSemantics(t *testing.T) {
|
|
rules, err := pyaml.Parse([]byte("max_risk: nuclear\n"))
|
|
if err != nil {
|
|
t.Fatalf("structural parse should succeed, got %v", err)
|
|
}
|
|
if rules[0].MaxRisk != "nuclear" {
|
|
t.Fatalf("MaxRisk = %q, want passed through as-is", rules[0].MaxRisk)
|
|
}
|
|
}
|
|
|
|
// An entirely empty file is rejected: the resolver should fall back to
|
|
// "no rule" by skipping the file in the first place, not by feeding empty
|
|
// bytes through Parse.
|
|
func TestParse_emptyIsError(t *testing.T) {
|
|
if _, err := pyaml.Parse([]byte{}); err == nil {
|
|
t.Fatalf("Parse should reject empty input; the resolver handles 'no file' separately")
|
|
}
|
|
}
|
|
|
|
// A stray "---" separator followed by another document would silently
|
|
// drop the trailing rule if yaml.v3 stopped after the first Decode.
|
|
// Parse must reject multi-document input so the operator can't typo a
|
|
// separator and end up with an unintentionally empty policy.
|
|
func TestParse_rejectsMultipleDocuments(t *testing.T) {
|
|
data := []byte(`name: first
|
|
max_risk: read
|
|
---
|
|
name: second
|
|
max_risk: write
|
|
`)
|
|
if _, err := pyaml.Parse(data); err == nil {
|
|
t.Fatalf("Parse should reject multi-document YAML input")
|
|
}
|
|
}
|