* 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.
lark-cli Plugin SDK
extension/platform is the in-process plugin SDK for lark-cli.
Plugins compile into a fork of the lark-cli binary via a blank
import; there is no .so loading, no RPC, no subprocess isolation.
A plugin shares the binary's address space and lifecycle.
5-minute hello world
// myplugin/audit.go
package myplugin
import (
"context"
"log"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("audit", "0.1.0").
Observer(platform.After, "log-cmd", platform.All(),
func(ctx context.Context, inv platform.Invocation) {
log.Printf("cmd=%s err=%v", inv.Cmd().Path(), inv.Err())
}).
FailOpen().
MustBuild())
}
Wire into a fork:
// cmd/larkx/main.go in your fork
package main
import (
_ "github.com/me/myplugin" // blank import → init() runs
"github.com/larksuite/cli/cmd"
"os"
)
func main() { os.Exit(cmd.Execute()) }
go build -o larkx ./cmd/larkx && ./larkx config plugins show
You should see audit in the plugin list.
What you can hook
| Hook | Fires | Can block? |
|---|---|---|
Observer |
Before / After each command | No (fire-and-forget audit) |
Wrap |
Around each command's RunE | Yes (return *AbortError) |
On(Startup/Shutdown) |
Process lifecycle | N/A |
Restrict(Rule) |
Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
Plugin lifecycle
sequenceDiagram
participant Host as lark-cli (host)
participant SDK as platform (SDK)
participant Plugin as your plugin
Note over Host,Plugin: Process start (before main)
Plugin->>Plugin: init() (via blank import)
Plugin->>SDK: Register(plugin)
Note over Host,Plugin: Bootstrap (host main)
Host->>SDK: RegisteredPlugins()
SDK-->>Host: snapshot in registration order
Host->>SDK: InstallAll()
SDK->>Plugin: Capabilities()
SDK->>Plugin: Install(Registrar)
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
SDK->>Plugin: On(Startup) fire
Note over Host,Plugin: Each command dispatch
Host->>SDK: hook chain (in registration order)
SDK->>Plugin: Observer Before
SDK->>Plugin: Wrap (around RunE)
SDK->>Plugin: Observer After
Note over Host,Plugin: Process exit
Host->>SDK: Emit(Shutdown)
SDK->>Plugin: On(Shutdown) fire
A command_denied decision (from Restrict or strict-mode) bypasses
the Wrap chain entirely — observers still fire so audit plugins see
the rejected dispatch.
Safety contract (read this)
- A plugin calling
Restrict()MUST declareFailClosed. The Builder flips it automatically; the lower-levelPlugininterface rejects the mismatch withrestricts_mismatch. - A plugin may call
Restrict()more than once; each call adds one scoped Rule and 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. Note a rule'sdenyis scoped to that rule only and cannot veto another rule's allow. Only ONE plugin per binary may contribute rules, though: two DISTINCT plugins each callingRestrict()is a deliberatemultiple_restrict_pluginserror (single-owner assumption — an independent plugin must not be able to widen another's policy). YAML policy at~/.lark-cli/policy.yml(which may itself list several rules underrules:) is shadowed by any plugin Restrict. - The
Wrapfactory runs once per command dispatch, not at install time. Long-lived state (clients, caches, metrics counters) must live on the Plugin struct or in package-level variables. - Plugins cannot suppress a
command_denied: the framework physically isolates denied commands from the Wrap chain (Observers still fire). - Commands missing a
risk_levelannotation are denied by default when a Rule is active. SetRule.AllowUnannotated = true(orallow_unannotated: truein yaml) to opt out during gradual adoption. With several rules this is per-rule: an unannotated command is allowed as long as one rule that opts in also grants it. - Risk annotation typos (e.g.
"wrtie") are always denied withrisk_invalidplus a "did you mean" suggestion.AllowUnannotateddoes NOT bypass this — typo is a code bug, not a missing annotation.
reason_code reference
Every install / dispatch failure emits a command_denied or
plugin_install envelope carrying a detail.reason_code from the
closed enum below. Use the code (not the human-readable message) when
matching errors in agents, CI scripts, or downstream tools — the
messages are localised and may change between releases.
Plugin install (error.type = plugin_install)
| reason_code | When it fires | Honours FailurePolicy? |
|---|---|---|
invalid_plugin_name |
Plugin.Name() doesn't match ^[a-z0-9][a-z0-9-]*$ |
No — always aborts |
plugin_name_panic |
Plugin.Name() panicked |
No — always aborts |
duplicate_plugin_name |
Two plugins return the same Name() |
No — always aborts |
capabilities_panic |
Plugin.Capabilities() panicked |
Yes |
invalid_capability |
Capabilities malformed: bad RequiredCLIVersion, unknown FailurePolicy |
No — always aborts |
capability_unmet |
Current CLI version doesn't satisfy RequiredCLIVersion |
Yes |
restricts_mismatch |
Restricts=true without FailClosed, or Restricts flag inconsistent w/ Install |
No — always aborts |
invalid_hook_name |
Hook name contains . or doesn't match the plugin namespace |
Yes |
duplicate_hook_name |
Same hook name registered twice within a plugin | Yes |
invalid_hook_registration |
Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
invalid_rule |
Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
multiple_restrict_plugins |
Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
install_failed |
Plugin.Install returned a non-nil error |
Yes |
install_panic |
Plugin.Install panicked |
Yes |
"No — always aborts" entries are treated as untrusted-config errors:
the host can't honour the plugin's declared FailurePolicy because the
declaration itself is suspect (e.g. an invalid_capability plugin
might also be lying about being FailOpen).
Command dispatch (error.type = command_denied)
| reason_code | Meaning |
|---|---|
risk_not_annotated |
Command has no risk_level annotation, and the active Rule does not set allow_unannotated: true |
risk_invalid |
Command's risk_level is a typo / not in the `read |
command_denylisted |
Command path matched the active Rule's deny glob |
domain_not_allowed |
Active Rule has a non-empty allow list and the command path did not match any glob |
write_not_allowed |
Command risk is write / high-risk-write and exceeds Rule max_risk |
risk_too_high |
Command risk exceeds Rule max_risk but is not a write (reserved for future risk levels) |
identity_mismatch |
Command's supportedIdentities does not intersect Rule identities |
no_matching_rule |
Several rules are active and the command satisfied none of them (the message summarises each rule's own rejection). Single-rule policies keep their specific reason_code instead |
aggregate_all_denied |
Aggregate stub installed on a parent group because every live child was denied |
The detail.layer field distinguishes who rejected the call:
policy (this SDK's user-layer engine) vs. strict_mode
(cmd/prune.go's credential-hardening pass). Agents that want to
dispatch on "any denial" should match error.type == "command_denied"
and ignore the layer; agents that only care about user-policy denials
should additionally check detail.layer == "policy".
Where to go next
- Runnable example: audit observer
- Runnable example: read-only policy
- Builder API: see
builder.gofor the full DSL (NewPlugin,Observer,Wrap,Restrict,FailOpen/FailClosed,MustBuild). - Inventory diagnostic: run
lark-cli config plugins showafter installing your plugin to see hooks/rules attributed to your plugin name.