Skip to content

feat(platform): support multiple policy rules per plugin#1182

Merged
sang-neo03 merged 3 commits into
mainfrom
feat/multi-rule-policy
May 30, 2026
Merged

feat(platform): support multiple policy rules per plugin#1182
sang-neo03 merged 3 commits into
mainfrom
feat/multi-rule-policy

Conversation

@sang-neo03

@sang-neo03 sang-neo03 commented May 30, 2026

Copy link
Copy Markdown
Collaborator

What

Extend the command policy framework (internal/cmdpolicy + extension/platform) from single-Rule to multi-Rule semantics. A plugin — or ~/.lark-cli/policy.yml — may now contribute several scoped Rules. The engine combines them with OR: a command is allowed when it satisfies every axis (allow / deny / max_risk / identities) of at least one rule.

Why

Real integrations need different policy per command group — e.g. docs/** read-only while im/** is writable, or some commands restricted to the user identity while others also allow bot. A single global Rule (one max_risk, one identity set) cannot express this.

Behaviour

  • OR over rules: a command passes if it clears any one rule's four axes. A rule's deny is scoped to that rule and cannot veto another rule's allow — to block a command everywhere, deny it (or omit it from allow) in every rule.
  • Single-owner, still fail-closed: one plugin may call Restrict multiple times, but two distinct plugins both contributing rules still aborts startup (multiple_restrict_plugins) — independent plugins cannot widen each other's policy.
  • Backward compatible: single-Rule rejections keep their exact reason_code / rule_name / envelope shape. Multi-rule "no rule matched" surfaces the new aggregate no_matching_rule.
  • yaml: policy.yml gains a top-level rules: list (flat single-rule layout still supported). A present-but-empty rules: [] is rejected. When a plugin contributes rules, yaml is shadowed and not read at all (a broken user yaml can't abort a plugin-governed binary).

API / diagnostic changes

  • cmdpolicy.New(*Rule) kept for compat; new NewSet([]*Rule) evaluates OR.
  • config policy show / config plugins show now emit a rules array (was a single rule object). Scripts parsing .rule must read .rules[].
  • New reason_code no_matching_rule; retired double_restrict.
  • Fix: config plugins show previously overwrote down to the last rule when a plugin contributed several — now lists all.

Testing

  • Unit tests: engine (OR / no_matching_rule / single-rule compat), resolver (single-plugin multi-rule, cross-plugin abort), inventory (multi-rule, no last-rule overwrite), yaml (rules: list, flat+list mix rejection, empty-list rejection), builder/host (multiple Restrict), bootstrap (plugin rules shadow and skip a broken yaml). go build ./... + go vet + full non-integration go test green; gofmt clean.
  • Manual verification (not committed): built a real plugin-embedder binary and exercised the documented behaviours end-to-end — OR allow, no_matching_rule, config policy/plugins show rules array, single-rule backward-compat, cross-plugin abort, yaml multi-rule — all observed working. This was a development-time check against a throwaway embedder, not an in-repo regression suite.

Known follow-up

A rule's deny only applies within that rule, so a later grant rule can re-expose a command an earlier rule denied. This is the OR contract (documented), but it is an easy operator foot-gun on a security surface. Tracked separately for a future safeguard (set-level lint / global deny / config policy explain); out of scope here.

Summary by CodeRabbit

  • New Features

    • Policy system now supports multiple rules per policy; CLI outputs and inventories present a rules array.
    • Plugins may contribute multiple scoped rules; runtime evaluation uses OR semantics and aggregates denials.
  • Documentation

    • Policy docs clarified for multi-rule semantics, precedence, ownership, and updated reason codes.
  • Tests

    • Tests added/updated for multi-rule YAML parsing, resolution, evaluation, inventory, and plugin behaviors.

Review Change Stack

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)
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 85c9fcaf-ada3-45e2-bfd8-61c9748625a2

📥 Commits

Reviewing files that changed from the base of the PR and between 58e2c76 and b41cba9.

📒 Files selected for processing (4)
  • cmd/platform_bootstrap.go
  • cmd/platform_bootstrap_test.go
  • internal/cmdpolicy/yaml/schema.go
  • internal/cmdpolicy/yaml/schema_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/cmdpolicy/yaml/schema_test.go
  • internal/cmdpolicy/yaml/schema.go
  • cmd/platform_bootstrap.go

📝 Walkthrough

Walkthrough

This PR converts the command-policy model from a single-rule to multi-rule system: YAML parsing and resolver return rule slices; the engine implements OR semantics and aggregates denials; staging/builder allow multiple Restrict calls; installation and inventory record plural rules; bootstrap applies multi-rule pruning; CLI outputs emit rules arrays; tests and docs updated accordingly.

Changes

Multi-Rule Policy Framework

Layer / File(s) Summary
YAML Parsing and Core Data Structures
internal/cmdpolicy/yaml/schema.go, internal/cmdpolicy/yaml/schema_test.go, internal/cmdpolicy/active.go
pyaml.Parse decodes into []*platform.Rule supporting flat or rules: list. ActivePolicy now holds Rules []*platform.Rule and deep-clone logic copies nested slices.
Policy Resolution with Single-Plugin Validation
internal/cmdpolicy/resolver.go, internal/cmdpolicy/resolver_test.go
Resolve returns a slice of rules with plugin > YAML precedence, enforces single-plugin ownership, and LoadYAMLPolicy returns a rules slice; Sources uses YAMLRules.
Multi-Rule Evaluation with OR Semantics
internal/cmdpolicy/engine.go, internal/cmdpolicy/engine_test.go
Engine stores rules []*platform.Rule; NewSet builds multi-rule engine; EvaluateOne OR-evaluates rules, delegates to evalRule, and aggregates denials with mergeDenials producing no_matching_rule when appropriate.
Plugin Registration: Staging and Builder Accumulation
internal/platform/staging.go, extension/platform/builder.go, extension/platform/builder_test.go
stagingRegistrar and Builder collect multiple Restrict calls into slices, cloning each rule per call; Build/Install apply all rules; tests assert per-registration cloning and ordering.
Plugin Installation and Inventory Tracking
internal/platform/host.go, internal/platform/host_test.go, internal/platform/inventory.go, internal/platform/inventory_test.go
installOne emits one PluginRule per staged rule; PluginEntry.Rules holds a slice; BuildInventory appends per-rule RuleViews; cloneInventory deep-copies plural rules. Tests validate ordering and counts.
Bootstrap Orchestration and Pruning Application
cmd/platform_bootstrap.go, cmd/platform_bootstrap_test.go
applyUserPolicyPruning skips YAML when plugin rules exist, resolves rule slices, uses cmdpolicy.NewSet to evaluate, attributes denial only when single rule present, and stores active policy with Rules. New test ensures malformed user YAML is ignored when plugins provide rules.
User-Facing Command Output
cmd/config/plugins.go, cmd/config/policy.go, cmd/config/policy_test.go
CLI now emits rules JSON arrays for plugin and policy outputs; tests and fixtures use ActivePolicy.Rules and validate arrays and per-rule metadata.
Documentation and Error Code Updates
extension/platform/README.md, extension/platform/registrar.go, internal/platform/error.go
Docs updated to allow ≥1 Restrict per plugin, OR-combination semantics, single-plugin-owner rule; allow_unannotated clarified per-rule; added multiple_restrict_plugins and no_matching_rule reason codes; removed ReasonDoubleRestrict.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • larksuite/cli#910: Introduced the original single-rule policy framework; this PR extends that foundation to multi-rule support.

Suggested labels

feature, size/XL

Suggested reviewers

  • liangshuo-1
  • Roy-oss1

🐰 A rabbit on a keyboard sings:

From single leaf to clustered bunch,
Rules OR'd together, never punch,
One owner tends the garden gate,
Multiple rules decide our fate.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main feature addition: support for multiple policy rules per plugin, which is the primary change throughout the changeset.
Description check ✅ Passed The PR description covers all required sections: Summary (multi-rule semantics motivation), Changes (detailed behavior changes), Test Plan (unit tests, manual verification), and Related Issues. However, the Test Plan section lacks checkboxes marked as complete.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/multi-rule-policy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added the size/L Large or sensitive change across domains or core paths label May 30, 2026
@codecov

codecov Bot commented May 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.03593% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.95%. Comparing base (b1ecf2d) to head (b41cba9).

Files with missing lines Patch % Lines
internal/cmdpolicy/active.go 0.00% 10 Missing ⚠️
internal/platform/inventory.go 33.33% 10 Missing ⚠️
internal/cmdpolicy/resolver.go 85.18% 2 Missing and 2 partials ⚠️
cmd/config/plugins.go 0.00% 2 Missing ⚠️
cmd/platform_bootstrap.go 91.30% 1 Missing and 1 partial ⚠️
internal/cmdpolicy/engine.go 94.11% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1182      +/-   ##
==========================================
+ Coverage   68.92%   68.95%   +0.02%     
==========================================
  Files         629      629              
  Lines       58762    58841      +79     
==========================================
+ Hits        40503    40575      +72     
- Misses      14952    14959       +7     
  Partials     3307     3307              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented May 30, 2026

Copy link
Copy Markdown

🚀 PR Preview Install Guide

🧰 CLI update

npm i -g https://pkg.pr.new/larksuite/cli/@larksuite/cli@b41cba9fc58281f094b253027e1e224523f9fbfd

🧩 Skill update

npx skills add larksuite/cli#feat/multi-rule-policy -y -g

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
cmd/platform_bootstrap.go (1)

47-63: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't let YAML parse failures disable plugin policy.

Resolve already gives PluginRules precedence over YAML, but this code loads YAML first and bails out on any YAML error. If a plugin contributed restrictive rules and the user's policy.yml is malformed, bootstrap now fail-opens and skips the plugin restrictions too.

Suggested fix
-	yamlRules, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
-	if err != nil {
-		// Yaml-only failures are fail-OPEN at the caller (warn and
-		// continue), but the active-policy snapshot is process-global
-		// and may still carry data from a previous build in long-lived
-		// embedders / tests. Clear it explicitly so `config policy
-		// show` reports "no policy" instead of a stale rule that
-		// doesn't reflect the current command tree.
-		cmdpolicy.SetActive(nil)
-		return err
-	}
+	var yamlRules []*platform.Rule
+	if len(pluginRules) == 0 {
+		yamlRules, err = cmdpolicy.LoadYAMLPolicy(yamlPath)
+		if err != nil {
+			// Yaml-only failures are fail-OPEN at the caller (warn and
+			// continue), but the active-policy snapshot is process-global
+			// and may still carry data from a previous build in long-lived
+			// embedders / tests. Clear it explicitly so `config policy
+			// show` reports "no policy" instead of a stale rule that
+			// doesn't reflect the current command tree.
+			cmdpolicy.SetActive(nil)
+			return err
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/platform_bootstrap.go` around lines 47 - 63, The YAML load error handling
currently clears active plugin policies and returns early; instead, when
cmdpolicy.LoadYAMLPolicy(yamlPath) returns an error, log or warn about the
malformed YAML (including yamlPath and the error), do NOT call
cmdpolicy.SetActive(nil) and do NOT return; set yamlRules to nil (or otherwise
omit YAMLRules) and continue to call cmdpolicy.Resolve with PluginRules still
present so plugin-contributed rules remain respected; ensure the function only
returns an error if cmdpolicy.Resolve itself fails.
🧹 Nitpick comments (1)
cmd/config/policy_test.go (1)

60-96: ⚡ Quick win

Add a true multi-rule regression case.

This still only proves that rules exists for a single element. A bug that accidentally truncates output to active.Rules[0] would still pass, even though this PR's contract is plural rules[]. Please add a case with two rules and assert both are emitted.

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@extension/platform/builder.go`:
- Around line 130-137: Builder.Restrict currently appends the caller's *Rule
directly (b.rules = append(b.rules, rule)), which lets later mutations affect
earlier entries; change Restrict to append a fresh copy/clone of the rule
instead: after the nil check, create a new Rule instance that copies the
incoming rule (e.g. newRule := *rule or use rule.Clone() if a Clone method
exists) and append &newRule to b.rules so each call captures its own snapshot
and avoids shared-pointer mutations.

In `@internal/platform/inventory.go`:
- Around line 154-160: In BuildInventory, when constructing each RuleView (in
the entry.Rules append) you're reusing the source slices for Allow, Deny, and
Identities so later mutations to the original rules will affect the snapshot;
fix this by copying those slices into new slices before assigning to RuleView
(i.e. create new slices and copy values for Allow, Deny, and Identities when
building the RuleView in BuildInventory rather than relying on cloneInventory
later), referencing the RuleView struct and BuildInventory function to locate
where to perform the defensive copy.

---

Outside diff comments:
In `@cmd/platform_bootstrap.go`:
- Around line 47-63: The YAML load error handling currently clears active plugin
policies and returns early; instead, when cmdpolicy.LoadYAMLPolicy(yamlPath)
returns an error, log or warn about the malformed YAML (including yamlPath and
the error), do NOT call cmdpolicy.SetActive(nil) and do NOT return; set
yamlRules to nil (or otherwise omit YAMLRules) and continue to call
cmdpolicy.Resolve with PluginRules still present so plugin-contributed rules
remain respected; ensure the function only returns an error if cmdpolicy.Resolve
itself fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d0490c36-c3f9-4557-809f-90a9331d0d12

📥 Commits

Reviewing files that changed from the base of the PR and between b1ecf2d and 975bd42.

📒 Files selected for processing (20)
  • cmd/config/plugins.go
  • cmd/config/policy.go
  • cmd/config/policy_test.go
  • cmd/platform_bootstrap.go
  • extension/platform/README.md
  • extension/platform/builder.go
  • extension/platform/registrar.go
  • internal/cmdpolicy/active.go
  • internal/cmdpolicy/engine.go
  • internal/cmdpolicy/engine_test.go
  • internal/cmdpolicy/resolver.go
  • internal/cmdpolicy/resolver_test.go
  • internal/cmdpolicy/yaml/schema.go
  • internal/cmdpolicy/yaml/schema_test.go
  • internal/platform/error.go
  • internal/platform/host.go
  • internal/platform/host_test.go
  • internal/platform/inventory.go
  • internal/platform/inventory_test.go
  • internal/platform/staging.go
💤 Files with no reviewable changes (1)
  • internal/platform/error.go

Comment thread extension/platform/builder.go Outdated
Comment thread internal/platform/inventory.go Outdated
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.
…s 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.
@sang-neo03
sang-neo03 merged commit 50b3f0a into main May 30, 2026
22 checks passed
@sang-neo03
sang-neo03 deleted the feat/multi-rule-policy branch May 30, 2026 09:05
tuxedomm pushed a commit to zhumiaoxin/cli that referenced this pull request Jun 6, 2026
)

* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large or sensitive change across domains or core paths

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants