Files
larksuite-cli/extension/platform/README.md
sang-neo03 50b3f0a2af feat(platform): support multiple policy rules per plugin (#1182)
* 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.
2026-05-30 17:05:33 +08:00

9.9 KiB

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 declare FailClosed. The Builder flips it automatically; the lower-level Plugin interface rejects the mismatch with restricts_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's deny is 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 calling Restrict() is a deliberate multiple_restrict_plugins error (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 under rules:) is shadowed by any plugin Restrict.
  • The Wrap factory 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_level annotation are denied by default when a Rule is active. Set Rule.AllowUnannotated = true (or allow_unannotated: true in 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 with risk_invalid plus a "did you mean" suggestion. AllowUnannotated does 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