chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
# 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
|
||||
|
||||
```go
|
||||
// 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:
|
||||
|
||||
```go
|
||||
// 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()) }
|
||||
```
|
||||
|
||||
```sh
|
||||
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
|
||||
|
||||
```mermaid
|
||||
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 | write | high-risk-write` taxonomy (always fail-closed) |
|
||||
| `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](./examples/audit-observer/)
|
||||
- [Runnable example: read-only policy](./examples/readonly-policy/)
|
||||
- Builder API: see [`builder.go`](./builder.go) for the full DSL
|
||||
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`,
|
||||
`MustBuild`).
|
||||
- Inventory diagnostic: run `lark-cli config plugins show` after
|
||||
installing your plugin to see hooks/rules attributed to your plugin
|
||||
name.
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// AbortError is returned by a Wrapper that wants to short-circuit the
|
||||
// command chain (instead of calling next). The framework converts it
|
||||
// to a typed errs.* error so the JSON envelope carries the structured
|
||||
// fields agents expect.
|
||||
//
|
||||
// HookName is the framework-namespaced name ("secaudit.approval"); the
|
||||
// Registrar adds the plugin-name prefix automatically.
|
||||
//
|
||||
// Cause and Detail are optional. Cause lets the consumer use
|
||||
// errors.Is/As to find the underlying cause; Detail is serialized into
|
||||
// envelope.detail under the "detail" key for agent consumption.
|
||||
type AbortError struct {
|
||||
HookName string
|
||||
Reason string
|
||||
Cause error
|
||||
Detail any
|
||||
}
|
||||
|
||||
// Error renders a human-readable message; HookName + Reason + Cause are
|
||||
// included when present.
|
||||
func (e *AbortError) Error() string {
|
||||
msg := fmt.Sprintf("hook %q aborted: %s", e.HookName, e.Reason)
|
||||
if e.Cause != nil {
|
||||
msg += ": " + e.Cause.Error()
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// Unwrap enables errors.Is / errors.As to traverse to Cause.
|
||||
func (e *AbortError) Unwrap() error { return e.Cause }
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func TestAbortError_messageFormats(t *testing.T) {
|
||||
bare := &platform.AbortError{HookName: "secaudit.approval", Reason: "needs approval"}
|
||||
if got := bare.Error(); got != `hook "secaudit.approval" aborted: needs approval` {
|
||||
t.Errorf("Error() = %q", got)
|
||||
}
|
||||
|
||||
withCause := &platform.AbortError{
|
||||
HookName: "audit.upload",
|
||||
Reason: "upstream unreachable",
|
||||
Cause: fs.ErrNotExist,
|
||||
}
|
||||
if got := withCause.Error(); got == bare.Error() {
|
||||
t.Errorf("Cause should be appended to message, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// errors.As must traverse Unwrap so consumers can inspect the cause
|
||||
// directly. This is the contract the host's wrapAbortError relies on.
|
||||
func TestAbortError_unwrapErrorsAs(t *testing.T) {
|
||||
root := fs.ErrPermission
|
||||
ab := &platform.AbortError{
|
||||
HookName: "x",
|
||||
Reason: "y",
|
||||
Cause: root,
|
||||
}
|
||||
if !errors.Is(ab, fs.ErrPermission) {
|
||||
t.Errorf("errors.Is should find fs.ErrPermission via Unwrap")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Builder is the ergonomic constructor for Plugin. Use it from init():
|
||||
//
|
||||
// func init() {
|
||||
// platform.Register(
|
||||
// platform.NewPlugin("audit", "0.1.0").
|
||||
// Observer(platform.After, "log", platform.All(), auditFn).
|
||||
// FailOpen().
|
||||
// MustBuild())
|
||||
// }
|
||||
//
|
||||
// The lower-level Plugin interface remains available for cases that
|
||||
// need finer control (state on a struct, complex Install logic). The
|
||||
// Builder enforces:
|
||||
//
|
||||
// - Name format (^[a-z0-9][a-z0-9-]*$)
|
||||
// - hookName format and uniqueness within a plugin
|
||||
// - Restricts ↔ FailClosed consistency (calling Restrict() implies
|
||||
// FailClosed, so plugin authors cannot accidentally ship a policy
|
||||
// plugin under FailOpen)
|
||||
// - Rule validation via ValidateRule analogues (delegated to
|
||||
// internal/cmdpolicy at install time; Builder only fast-fails
|
||||
// blatantly bad input)
|
||||
type Builder struct {
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
|
||||
hookNames map[string]bool
|
||||
errs []error
|
||||
}
|
||||
|
||||
var pluginNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// NewPlugin starts a Builder. Name format is validated lazily — errors
|
||||
// surface at Build()/MustBuild() time, allowing chained calls without
|
||||
// intermediate error handling.
|
||||
func NewPlugin(name, version string) *Builder {
|
||||
b := &Builder{
|
||||
name: name,
|
||||
version: version,
|
||||
hookNames: map[string]bool{},
|
||||
}
|
||||
if !pluginNamePattern.MatchString(name) {
|
||||
b.errs = append(b.errs, fmt.Errorf("invalid plugin name %q: must match ^[a-z0-9][a-z0-9-]*$", name))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// RequireCLI sets Capabilities.RequiredCLIVersion (semver constraint,
|
||||
// e.g. ">=1.1.0"). Empty string means no requirement.
|
||||
func (b *Builder) RequireCLI(constraint string) *Builder {
|
||||
b.caps.RequiredCLIVersion = constraint
|
||||
return b
|
||||
}
|
||||
|
||||
// FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when
|
||||
// neither FailOpen nor FailClosed is called and Restrict is not used.
|
||||
func (b *Builder) FailOpen() *Builder {
|
||||
b.caps.FailurePolicy = FailOpen
|
||||
return b
|
||||
}
|
||||
|
||||
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit
|
||||
// when Restrict() is called.
|
||||
func (b *Builder) FailClosed() *Builder {
|
||||
b.caps.FailurePolicy = FailClosed
|
||||
return b
|
||||
}
|
||||
|
||||
// Observer registers an Observer. Multiple calls accumulate.
|
||||
func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder {
|
||||
if !b.validateHookName(hookName, "observer") {
|
||||
return b
|
||||
}
|
||||
// Capture by value so the action closure doesn't share state with
|
||||
// subsequent Observer() calls (Go ≥1.22 already gives each call
|
||||
// its own copies of parameter values, but pinning is explicit).
|
||||
w, n, s, f := when, hookName, sel, fn
|
||||
b.actions = append(b.actions, func(r Registrar) {
|
||||
r.Observe(w, n, s, f)
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// Wrap registers a Wrapper. Multiple calls accumulate; the host
|
||||
// composes them in registration order (outermost first).
|
||||
func (b *Builder) Wrap(hookName string, sel Selector, wrap Wrapper) *Builder {
|
||||
if !b.validateHookName(hookName, "wrap") {
|
||||
return b
|
||||
}
|
||||
n, s, w := hookName, sel, wrap
|
||||
b.actions = append(b.actions, func(r Registrar) {
|
||||
r.Wrap(n, s, w)
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// On registers a LifecycleHandler.
|
||||
func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler) *Builder {
|
||||
if !b.validateHookName(hookName, "on") {
|
||||
return b
|
||||
}
|
||||
e, n, f := event, hookName, fn
|
||||
b.actions = append(b.actions, func(r Registrar) {
|
||||
r.On(e, n, f)
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// Restrict contributes a pruning Rule. Calling Restrict implicitly
|
||||
// sets Restricts=true and FailurePolicy=FailClosed (the framework
|
||||
// requires both to coexist; the builder enforces the pairing so the
|
||||
// plugin author cannot accidentally ship a policy plugin under
|
||||
// FailOpen). It may be called more than once; each call adds one scoped
|
||||
// Rule and the engine OR-combines them.
|
||||
func (b *Builder) Restrict(rule *Rule) *Builder {
|
||||
if rule == nil {
|
||||
b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be nil"))
|
||||
return b
|
||||
}
|
||||
b.caps.Restricts = true
|
||||
b.caps.FailurePolicy = FailClosed
|
||||
// Defensive clone: capture an independent snapshot so a caller that
|
||||
// reuses and mutates the same *Rule across multiple Restrict calls
|
||||
// gets distinct entries (mirrors the staging registrar's clone).
|
||||
cp := *rule
|
||||
cp.Allow = append([]string(nil), rule.Allow...)
|
||||
cp.Deny = append([]string(nil), rule.Deny...)
|
||||
cp.Identities = append([]Identity(nil), rule.Identities...)
|
||||
b.rules = append(b.rules, &cp)
|
||||
return b
|
||||
}
|
||||
|
||||
// Build returns the configured Plugin, or an error if any builder
|
||||
// step found a fault. MustBuild panics on the same error.
|
||||
//
|
||||
// The Restrict + FailOpen mismatch is checked here, not in the chained
|
||||
// setters, because the two methods may be called in either order.
|
||||
func (b *Builder) Build() (Plugin, error) {
|
||||
if len(b.rules) > 0 && b.caps.FailurePolicy == FailOpen {
|
||||
b.errs = append(b.errs, errors.New(
|
||||
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
||||
}
|
||||
if len(b.errs) > 0 {
|
||||
return nil, errors.Join(b.errs...)
|
||||
}
|
||||
return &builtPlugin{
|
||||
name: b.name,
|
||||
version: b.version,
|
||||
caps: b.caps,
|
||||
actions: b.actions,
|
||||
rules: b.rules,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MustBuild panics if Build() would return an error. Designed for
|
||||
// init():
|
||||
//
|
||||
// func init() { platform.Register(platform.NewPlugin(...).MustBuild()) }
|
||||
//
|
||||
// A panic in init runs before the framework's recover guard is
|
||||
// installed and will crash the binary. That is the intended
|
||||
// behaviour: a misconfigured plugin must NOT be silently registered.
|
||||
func (b *Builder) MustBuild() Plugin {
|
||||
p, err := b.Build()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("plugin %q: %v", b.name, err))
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// validateHookName checks the grammar and uniqueness; returns false
|
||||
// when the name was rejected (caller skips the action).
|
||||
func (b *Builder) validateHookName(hookName, kind string) bool {
|
||||
if !pluginNamePattern.MatchString(hookName) {
|
||||
b.errs = append(b.errs, fmt.Errorf(
|
||||
"%s %q: hookName must match ^[a-z0-9][a-z0-9-]*$", kind, hookName))
|
||||
return false
|
||||
}
|
||||
if b.hookNames[hookName] {
|
||||
b.errs = append(b.errs, fmt.Errorf(
|
||||
"%s %q: hookName already used in this plugin", kind, hookName))
|
||||
return false
|
||||
}
|
||||
b.hookNames[hookName] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// builtPlugin is the Plugin implementation the builder emits.
|
||||
type builtPlugin struct {
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
}
|
||||
|
||||
func (p *builtPlugin) Name() string { return p.name }
|
||||
func (p *builtPlugin) Version() string { return p.version }
|
||||
func (p *builtPlugin) Capabilities() Capabilities { return p.caps }
|
||||
func (p *builtPlugin) Install(r Registrar) error {
|
||||
for _, rule := range p.rules {
|
||||
r.Restrict(rule)
|
||||
}
|
||||
for _, action := range p.actions {
|
||||
action(r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// recorder Registrar captures everything a builder schedules so the
|
||||
// test can assert what Install produced without involving the host.
|
||||
type recorder struct {
|
||||
observers int
|
||||
wrappers int
|
||||
lifecycles int
|
||||
rule *platform.Rule // last rule (existing single-rule assertions)
|
||||
rules []*platform.Rule // every rule, in Restrict order
|
||||
}
|
||||
|
||||
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
|
||||
r.observers++
|
||||
}
|
||||
func (r *recorder) Wrap(string, platform.Selector, platform.Wrapper) { r.wrappers++ }
|
||||
func (r *recorder) On(platform.LifecycleEvent, string, platform.LifecycleHandler) { r.lifecycles++ }
|
||||
func (r *recorder) Restrict(rule *platform.Rule) {
|
||||
r.rule = rule
|
||||
r.rules = append(r.rules, rule)
|
||||
}
|
||||
|
||||
// Restrict must snapshot each rule: a caller that reuses and mutates the
|
||||
// same *Rule object across two Restrict calls must still get two distinct
|
||||
// rules at Install time, not two pointers to the last mutation.
|
||||
func TestBuilder_restrictClonesEachRule(t *testing.T) {
|
||||
shared := &platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: platform.RiskRead}
|
||||
b := platform.NewPlugin("p", "0").Restrict(shared)
|
||||
// Reuse and mutate the same object, then register it again.
|
||||
shared.Name = "im-rw"
|
||||
shared.Allow[0] = "im/**"
|
||||
shared.MaxRisk = platform.RiskWrite
|
||||
p, err := b.Restrict(shared).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if len(r.rules) != 2 {
|
||||
t.Fatalf("got %d rules, want 2", len(r.rules))
|
||||
}
|
||||
if r.rules[0].Name != "docs-ro" || r.rules[0].Allow[0] != "docs/**" || r.rules[0].MaxRisk != platform.RiskRead {
|
||||
t.Errorf("rule[0] leaked later mutation: %+v", r.rules[0])
|
||||
}
|
||||
if r.rules[1].Name != "im-rw" || r.rules[1].Allow[0] != "im/**" {
|
||||
t.Errorf("rule[1] = %+v, want im-rw / im/**", r.rules[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_basicAssembly(t *testing.T) {
|
||||
p, err := platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.Before, "pre", platform.All(),
|
||||
func(context.Context, platform.Invocation) {}).
|
||||
Observer(platform.After, "post", platform.All(),
|
||||
func(context.Context, platform.Invocation) {}).
|
||||
Wrap("policy", platform.All(),
|
||||
func(next platform.Handler) platform.Handler { return next }).
|
||||
On(platform.Startup, "boot",
|
||||
func(context.Context, *platform.LifecycleContext) error { return nil }).
|
||||
FailOpen().
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if p.Name() != "audit" || p.Version() != "0.1.0" {
|
||||
t.Errorf("metadata = %q/%q", p.Name(), p.Version())
|
||||
}
|
||||
if p.Capabilities().FailurePolicy != platform.FailOpen {
|
||||
t.Errorf("FailurePolicy = %v, want FailOpen", p.Capabilities().FailurePolicy)
|
||||
}
|
||||
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if r.observers != 2 || r.wrappers != 1 || r.lifecycles != 1 {
|
||||
t.Errorf("Install dispatch = observers=%d wrappers=%d lifecycles=%d",
|
||||
r.observers, r.wrappers, r.lifecycles)
|
||||
}
|
||||
}
|
||||
|
||||
// Restrict() flips Restricts=true and FailClosed automatically — a
|
||||
// policy plugin can't accidentally ship under FailOpen.
|
||||
func TestBuilder_restrictForcesFailClosed(t *testing.T) {
|
||||
p, err := platform.NewPlugin("policy-plugin", "0.1.0").
|
||||
Restrict(&platform.Rule{Name: "read-only", MaxRisk: platform.RiskRead}).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
caps := p.Capabilities()
|
||||
if !caps.Restricts {
|
||||
t.Errorf("Restricts = false, want true (Restrict() should flip it)")
|
||||
}
|
||||
if caps.FailurePolicy != platform.FailClosed {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed (Restrict() implies it)", caps.FailurePolicy)
|
||||
}
|
||||
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if r.rule == nil || r.rule.Name != "read-only" {
|
||||
t.Errorf("Install did not propagate Rule: %+v", r.rule)
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid name surfaces at Build time, not at NewPlugin.
|
||||
func TestBuilder_invalidPluginName(t *testing.T) {
|
||||
_, err := platform.NewPlugin("Has_Underscore_And_Caps", "0.1").Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject malformed plugin name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid plugin name") {
|
||||
t.Errorf("error should mention plugin name, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate hookName within the same builder is rejected.
|
||||
func TestBuilder_duplicateHookName(t *testing.T) {
|
||||
noopObs := func(context.Context, platform.Invocation) {}
|
||||
_, err := platform.NewPlugin("dup", "0").
|
||||
Observer(platform.Before, "h", platform.All(), noopObs).
|
||||
Observer(platform.After, "h", platform.All(), noopObs).
|
||||
Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject duplicate hookName")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already used") {
|
||||
t.Errorf("error should mention duplicate hookName, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_invalidHookName(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").
|
||||
Observer(platform.Before, "Bad.Name", platform.All(),
|
||||
func(context.Context, platform.Invocation) {}).
|
||||
Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject hookName with dot")
|
||||
}
|
||||
}
|
||||
|
||||
// MustBuild panics on builder error.
|
||||
func TestBuilder_mustBuildPanicsOnError(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatalf("MustBuild must panic when Build would fail")
|
||||
}
|
||||
}()
|
||||
_ = platform.NewPlugin("BadName", "0").MustBuild()
|
||||
}
|
||||
|
||||
func TestBuilder_restrictNilRejected(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").Restrict(nil).Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Restrict(nil) must produce error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_capabilitiesSetters(t *testing.T) {
|
||||
p, err := platform.NewPlugin("p", "0.1").
|
||||
RequireCLI(">=1.0.0").
|
||||
FailClosed().
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
caps := p.Capabilities()
|
||||
if caps.RequiredCLIVersion != ">=1.0.0" {
|
||||
t.Errorf("RequiredCLIVersion = %q, want >=1.0.0", caps.RequiredCLIVersion)
|
||||
}
|
||||
if caps.FailurePolicy != platform.FailClosed {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed", caps.FailurePolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_restrictThenFailOpenRejected(t *testing.T) {
|
||||
rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead}
|
||||
_, err := platform.NewPlugin("p", "0").Restrict(rule).FailOpen().Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject Restrict()+FailOpen() mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "FailClosed") {
|
||||
t.Errorf("error should mention FailClosed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Restrict() flips FailurePolicy to FailClosed; the previous FailOpen()
|
||||
// is overridden. Pin it so the Build-time validation does not over-reject.
|
||||
func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
|
||||
rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead}
|
||||
p, err := platform.NewPlugin("p", "0").FailOpen().Restrict(rule).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("FailOpen()+Restrict() must succeed (Restrict flips to FailClosed): %v", err)
|
||||
}
|
||||
if p.Capabilities().FailurePolicy != platform.FailClosed {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// FailurePolicy controls what the framework does when a plugin's install
|
||||
// stage fails (Capabilities() panics, Install returns error, etc.).
|
||||
type FailurePolicy int
|
||||
|
||||
const (
|
||||
// FailOpen (default) — log a warning and skip THIS plugin; the rest
|
||||
// of the CLI keeps running. Appropriate for pure-observer plugins
|
||||
// where missing audit data is preferable to a broken CLI.
|
||||
FailOpen FailurePolicy = iota
|
||||
|
||||
// FailClosed — abort the entire CLI startup. Required for any
|
||||
// plugin that contributes Restrict() (a missing policy plugin =
|
||||
// missing security boundary) or that owns any safety-sensitive
|
||||
// concern. Enforced by the framework: Capabilities.Restricts=true
|
||||
// must pair with FailurePolicy=FailClosed.
|
||||
FailClosed
|
||||
)
|
||||
|
||||
// Capabilities declares the plugin's self-description. Plugin.Capabilities
|
||||
// MUST be implemented even when every field would be its zero value --
|
||||
// the requirement keeps FailurePolicy / Restricts visible to the author
|
||||
// at the moment they write the plugin, preventing the "I just want to
|
||||
// add an audit observer" mistake of accidentally shipping a policy
|
||||
// plugin with the default FailOpen.
|
||||
type Capabilities struct {
|
||||
// RequiredCLIVersion is a semver constraint (e.g. ">=1.1.0").
|
||||
// Plugins that need a specific framework feature should declare
|
||||
// the minimum version they tested against; the host fails the
|
||||
// install when the running CLI is older. Empty string means "no
|
||||
// version requirement".
|
||||
RequiredCLIVersion string
|
||||
|
||||
// Restricts declares whether Install will call r.Restrict(). The
|
||||
// framework enforces consistency: declaring Restricts=true and
|
||||
// then NOT calling r.Restrict (or vice versa) aborts the install
|
||||
// with the `restricts_mismatch` reason_code. This pre-flight
|
||||
// declaration also lets `config policy show` introspect "which
|
||||
// plugins are policy plugins" without running them.
|
||||
Restricts bool
|
||||
|
||||
// FailurePolicy decides what happens on install failure. See the
|
||||
// constants above; the framework requires FailClosed whenever
|
||||
// Restricts=true.
|
||||
FailurePolicy FailurePolicy
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package platform is the single public extension contract for lark-cli.
|
||||
//
|
||||
// External integrators (plugin authors, embedding platforms) only import this
|
||||
// package; everything else under internal/ is off-limits.
|
||||
//
|
||||
// Plugin lifecycle:
|
||||
//
|
||||
// - Plugin - the interface every plugin implements (Name / Version / Capabilities / Install)
|
||||
// - Registrar - what Install receives; the four registration verbs (Observe / Wrap / On / Restrict)
|
||||
// - Capabilities - declared up front: FailurePolicy (FailOpen | FailClosed) and Restricts
|
||||
// - Register - process-wide entry point; plugins call this from init()
|
||||
//
|
||||
// Hook surface (what Install hangs off Registrar):
|
||||
//
|
||||
// - Observer - side-effect-only callback, panic-safe, runs Before / After RunE
|
||||
// - Wrapper - middleware that can short-circuit via AbortError
|
||||
// - LifecycleHandler - reacts to Startup / Shutdown / etc. (LifecycleEvent + When)
|
||||
// - Selector - chooses which commands a hook applies to (ByDomain / ByWrite / ByReadOnly / ByExactRisk / And / Or / Not, etc.)
|
||||
// - Handler - the inner "run the command" function Wrappers compose around
|
||||
// - Invocation - per-call context passed to handlers (Cmd view + DeniedByPolicy / DenialLayer / DenialPolicySource)
|
||||
// - AbortError - structured short-circuit error from a Wrapper; framework namespaces HookName
|
||||
//
|
||||
// Policy surface (what Restrict contributes, also consumable from yaml policy):
|
||||
//
|
||||
// - Rule - declarative policy rule (Allow / Deny / MaxRisk / Identities / AllowUnannotated)
|
||||
// - CommandView - read-only command metadata view (Path / Domain / Risk / Identities)
|
||||
// - Risk / Identity - defined string types with closed taxonomies; ParseRisk / ParseIdentity
|
||||
// convert raw strings (yaml, cobra annotation) into typed values; r.Rank()
|
||||
// gives a comparable rank for the read < write < high-risk-write ordering
|
||||
// - CommandDeniedError - structured error returned to denied callers
|
||||
//
|
||||
// Stability: every exported symbol here is part of the contract. Internal
|
||||
// orchestration (staging, validation, RunE wrapping, denial guard) lives
|
||||
// under internal/platform, internal/hook and internal/cmdpolicy and is not
|
||||
// importable by third parties.
|
||||
package platform
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CommandDeniedError is the structured error returned by a denyStub. Every
|
||||
// pruned-command execution path -- direct invocation, alias expansion,
|
||||
// internal call -- returns this exact type. The dispatcher converts it to a
|
||||
// typed errs.* error; the Layer field carries the denial layer for the
|
||||
// envelope.
|
||||
//
|
||||
// Layer values:
|
||||
//
|
||||
// - "strict_mode" -- credential strict-mode rejected the command
|
||||
// - "policy" -- user-layer Rule rejected the command
|
||||
//
|
||||
// PolicySource is a free-form identifier such as "plugin:secaudit",
|
||||
// "yaml:mywork", or "strict-mode". Reason fields:
|
||||
//
|
||||
// - ReasonCode -- closed enum, see tech-doc 5.3 (e.g. write_not_allowed,
|
||||
// all_children_denied, identity_not_supported)
|
||||
// - Reason -- human-readable text
|
||||
type CommandDeniedError struct {
|
||||
Path string
|
||||
Layer string
|
||||
PolicySource string
|
||||
RuleName string
|
||||
ReasonCode string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Error implements the standard error interface.
|
||||
func (e *CommandDeniedError) Error() string {
|
||||
if e.Reason != "" {
|
||||
return fmt.Sprintf("command %q denied: %s", e.Path, e.Reason)
|
||||
}
|
||||
return fmt.Sprintf("command %q denied (%s/%s)", e.Path, e.Layer, e.ReasonCode)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func TestCommandDeniedError_messageFormats(t *testing.T) {
|
||||
withReason := &platform.CommandDeniedError{
|
||||
Path: "docs/+update",
|
||||
Layer: "policy",
|
||||
ReasonCode: "write_not_allowed",
|
||||
Reason: "write disabled by policy",
|
||||
}
|
||||
if got := withReason.Error(); got != `command "docs/+update" denied: write disabled by policy` {
|
||||
t.Fatalf("Error() with Reason = %q", got)
|
||||
}
|
||||
|
||||
noReason := &platform.CommandDeniedError{
|
||||
Path: "docs/+update",
|
||||
Layer: "strict_mode",
|
||||
ReasonCode: "identity_not_supported",
|
||||
}
|
||||
if got := noReason.Error(); got != `command "docs/+update" denied (strict_mode/identity_not_supported)` {
|
||||
t.Fatalf("Error() without Reason = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// errors.As must work so consumers can type-assert without unwrap gymnastics.
|
||||
func TestCommandDeniedError_satisfiesErrorsAs(t *testing.T) {
|
||||
var err error = &platform.CommandDeniedError{Path: "x"}
|
||||
var target *platform.CommandDeniedError
|
||||
if !errors.As(err, &target) {
|
||||
t.Fatalf("errors.As should match CommandDeniedError")
|
||||
}
|
||||
if target.Path != "x" {
|
||||
t.Fatalf("target.Path = %q, want %q", target.Path, "x")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// ExampleNewPlugin_observer registers an audit Observer that fires
|
||||
// after every command, regardless of success or failure.
|
||||
func ExampleNewPlugin_observer() {
|
||||
p, _ := platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(ctx context.Context, inv platform.Invocation) {
|
||||
_ = inv.Cmd().Path() // do something useful with the command
|
||||
}).
|
||||
FailOpen().
|
||||
Build()
|
||||
fmt.Println(p.Name(), p.Version())
|
||||
// Output: audit 0.1.0
|
||||
}
|
||||
|
||||
// ExampleNewPlugin_wrapper registers a Wrap that short-circuits any
|
||||
// write-class command. The framework converts the returned
|
||||
// *AbortError into a structured "hook" envelope; observers still
|
||||
// fire on the After stage so audit sees the attempt.
|
||||
func ExampleNewPlugin_wrapper() {
|
||||
p, _ := platform.NewPlugin("policy-plugin", "0.1.0").
|
||||
Wrap("block-writes", platform.ByWrite(),
|
||||
func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
return &platform.AbortError{
|
||||
HookName: "block-writes",
|
||||
Reason: "writes are disabled for this session",
|
||||
}
|
||||
}
|
||||
}).
|
||||
FailOpen().
|
||||
Build()
|
||||
fmt.Println(p.Capabilities().FailurePolicy == platform.FailOpen)
|
||||
// Output: true
|
||||
}
|
||||
|
||||
// ExampleNewPlugin_restrict registers a policy plugin that allows
|
||||
// only docs/* read commands. Note that Restrict() implicitly sets
|
||||
// FailClosed — a policy plugin must abort the binary if it fails to
|
||||
// install, not silently disappear.
|
||||
func ExampleNewPlugin_restrict() {
|
||||
p, _ := platform.NewPlugin("readonly-docs", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "docs-only",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
Build()
|
||||
caps := p.Capabilities()
|
||||
fmt.Println(caps.Restricts, caps.FailurePolicy == platform.FailClosed)
|
||||
// Output: true true
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
audit-observer/audit-observer
|
||||
readonly-policy/readonly-policy
|
||||
@@ -0,0 +1,13 @@
|
||||
# lark-cli plugin examples
|
||||
|
||||
Runnable fork-and-blank-import examples that demonstrate the Plugin
|
||||
SDK in production-shape. Each subdirectory is a complete `main`
|
||||
package: `go build .` produces a working CLI.
|
||||
|
||||
| Example | What it shows |
|
||||
| --- | --- |
|
||||
| [audit-observer](./audit-observer/) | Simplest possible plugin: one Observer matching every command, logs to stderr. |
|
||||
| [readonly-policy](./readonly-policy/) | Policy plugin: `Restrict()` with `MaxRisk=read`, demonstrates the `FailClosed` + `Restricts=true` auto-pairing. |
|
||||
|
||||
All examples are built by CI (`make examples-build`) so they cannot
|
||||
silently drift from the SDK.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Example: audit observer
|
||||
|
||||
The simplest possible lark-cli plugin: one After observer that logs
|
||||
every dispatched command to stderr (success or failure).
|
||||
|
||||
## Build & run
|
||||
|
||||
```sh
|
||||
cd extension/platform/examples/audit-observer
|
||||
go build -o audit-cli .
|
||||
./audit-cli config plugins show
|
||||
# {"plugins":[{"name":"audit", ...}], "total":1}
|
||||
|
||||
./audit-cli api GET /open-apis/contact/v3/users/me
|
||||
# [audit] api ok (on stderr)
|
||||
```
|
||||
|
||||
## Key points
|
||||
|
||||
- `platform.NewPlugin(...).MustBuild()` from `init()`. The blank
|
||||
import of this package in `main.go` triggers `init()`.
|
||||
- `Observer(platform.After, ...)` runs **after** the command's RunE,
|
||||
even on failure (Observers cannot prevent execution).
|
||||
- `FailOpen()` means: if Install ever fails, the binary logs a
|
||||
warning and continues without this plugin. Right default for
|
||||
audit-only plugins.
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command audit-observer is a runnable fork of lark-cli that logs
|
||||
// every dispatched command to stderr. Demonstrates the simplest
|
||||
// possible plugin: one After observer matching All commands.
|
||||
//
|
||||
// Build & run:
|
||||
//
|
||||
// cd extension/platform/examples/audit-observer
|
||||
// go build -o audit-cli .
|
||||
// ./audit-cli config plugins show # see "audit" in the list
|
||||
// ./audit-cli api GET /open-apis/... # observer logs to stderr
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(ctx context.Context, inv platform.Invocation) {
|
||||
path := inv.Cmd().Path()
|
||||
if err := inv.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[audit] %s FAILED: %v\n", path, err)
|
||||
} else {
|
||||
log.Printf("[audit] %s ok", path)
|
||||
}
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(cmd.Execute())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Example: read-only policy
|
||||
|
||||
A policy plugin that installs a `Rule` allowing only `docs/*` and
|
||||
`im/*` read commands. Any write command produces a structured
|
||||
`command_denied` envelope.
|
||||
|
||||
## Build & run
|
||||
|
||||
```sh
|
||||
cd extension/platform/examples/readonly-policy
|
||||
go build -o readonly-cli .
|
||||
|
||||
./readonly-cli config policy show
|
||||
# {
|
||||
# "source": "plugin",
|
||||
# "source_name": "readonly",
|
||||
# "denied_paths": N,
|
||||
# "rule": {
|
||||
# "name": "agent-readonly",
|
||||
# "allow": ["docs/**", "im/**"],
|
||||
# "deny": [],
|
||||
# "max_risk": "read",
|
||||
# "identities": [],
|
||||
# "allow_unannotated": false
|
||||
# }
|
||||
# }
|
||||
|
||||
./readonly-cli docs +update --doc-token X --content Y
|
||||
# {"ok":false,"error":{
|
||||
# "type":"command_denied",
|
||||
# "detail":{
|
||||
# "layer":"policy",
|
||||
# "policy_source":"plugin:readonly",
|
||||
# "rule_name":"agent-readonly",
|
||||
# "reason_code":"write_not_allowed"
|
||||
# }
|
||||
# }}
|
||||
|
||||
./readonly-cli docs +fetch --doc-token X
|
||||
# Normal read response (assuming credentials)
|
||||
```
|
||||
|
||||
## Key points
|
||||
|
||||
- `Restrict(&Rule{...})` is the only call needed — the Builder
|
||||
flips Capabilities to `Restricts=true, FailurePolicy=FailClosed`
|
||||
automatically. A policy plugin that silently fails to install
|
||||
would erase the security boundary, so FailClosed is enforced.
|
||||
- `MaxRisk: platform.RiskRead` rejects any command annotated
|
||||
write / high-risk-write.
|
||||
- `AllowUnannotated` is left default (false): unannotated commands
|
||||
are denied with `risk_not_annotated`. Set it to true if you need
|
||||
a gradual-adoption window for the lark-cli main tree.
|
||||
|
||||
## Caveats
|
||||
|
||||
- A binary may have **only one** plugin calling `Restrict()`. Two
|
||||
policy plugins is a deliberate `plugin_conflict` configuration
|
||||
error.
|
||||
- This Rule shadows any `~/.lark-cli/policy.yml` — plugin Rule
|
||||
wins per the resolver precedence.
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command readonly-policy is a runnable fork of lark-cli that
|
||||
// installs a Rule permitting only docs/* and im/* read commands.
|
||||
// Any write command produces a structured command_denied envelope.
|
||||
//
|
||||
// Build & run:
|
||||
//
|
||||
// cd extension/platform/examples/readonly-policy
|
||||
// go build -o readonly-cli .
|
||||
// ./readonly-cli docs +update --doc-token X --content Y
|
||||
// # {"ok":false,"error":{"type":"command_denied", ...}}
|
||||
//
|
||||
// ./readonly-cli config policy show
|
||||
// # shows the active Rule with source=plugin:readonly
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("readonly", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "agent-readonly",
|
||||
Description: "Only read-class docs/im commands. Suitable for AI-agent sessions.",
|
||||
Allow: []string{"docs/**", "im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
// AllowUnannotated stays default false (fail-closed):
|
||||
// unannotated commands are denied, surfacing missing
|
||||
// risk_level annotations early in adoption.
|
||||
}).
|
||||
MustBuild())
|
||||
// Note: Restrict() implicitly sets Restricts=true and FailClosed.
|
||||
// No need to call FailClosed() explicitly.
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(cmd.Execute())
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "context"
|
||||
|
||||
// Handler is the inner function shape every Wrapper composes. It IS the
|
||||
// "command business logic" from the Wrapper's perspective -- calling
|
||||
// next(ctx, inv) inside a Wrapper means "let the command proceed";
|
||||
// returning early without calling next short-circuits.
|
||||
type Handler func(ctx context.Context, inv Invocation) error
|
||||
|
||||
// Observer is a side-effect-only command hook. No return value, no
|
||||
// next-chain control: an Observer can read Invocation but cannot prevent
|
||||
// the command from running. Used for audit, metrics, and completion
|
||||
// logs. After-stage Observers fire even when the command failed
|
||||
// (Invocation.Err() is populated in that case).
|
||||
type Observer func(ctx context.Context, inv Invocation)
|
||||
|
||||
// Wrapper is a middleware-style hook: it receives the rest of the
|
||||
// handler chain and returns a wrapped version. The Wrapper decides
|
||||
// whether to call next (allow), abstain (deny, return an AbortError),
|
||||
// or transform the result. Multiple Wrappers compose left-to-right by
|
||||
// registration order; the outermost runs first.
|
||||
//
|
||||
// ⚠️ IMPORTANT: The factory function `func(next Handler) Handler` is
|
||||
// invoked ONCE PER COMMAND DISPATCH, not once at plugin install. This
|
||||
// lets the framework recover from a panicking factory and convert it
|
||||
// to a structured envelope, but it means any state captured by the
|
||||
// outer closure is rebuilt on every command. Long-lived state (HTTP
|
||||
// clients, caches, metrics counters) MUST live on the Plugin struct
|
||||
// or in package-level variables, never in factory-local captures.
|
||||
type Wrapper func(next Handler) Handler
|
||||
|
||||
// LifecycleHandler runs at one of the process-level LifecycleEvent
|
||||
// slots. The handler may use ctx for cancellation; in the Shutdown
|
||||
// case the framework supplies a context with a 2-second hard deadline.
|
||||
type LifecycleHandler func(ctx context.Context, lc *LifecycleContext) error
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Identity is the identity taxonomy a command supports.
|
||||
//
|
||||
// Defined type (not alias) so plugin authors get compile-time +
|
||||
// IDE help; raw-string boundaries (yaml, cobra annotation) cross
|
||||
// through ParseIdentity.
|
||||
type Identity string
|
||||
|
||||
const (
|
||||
IdentityUser Identity = "user"
|
||||
IdentityBot Identity = "bot"
|
||||
)
|
||||
|
||||
// ParseIdentity converts a raw string into an Identity. Returns
|
||||
// ("", nil) for empty input ("not specified"), error for unrecognised
|
||||
// values. Matching is strict (case-sensitive, no trim).
|
||||
func ParseIdentity(s string) (Identity, error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
id := Identity(s)
|
||||
if id != IdentityUser && id != IdentityBot {
|
||||
return "", fmt.Errorf("invalid identity %q: must be user|bot", s)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// IsValid reports whether i is one of the two recognised values.
|
||||
func (i Identity) IsValid() bool {
|
||||
return i == IdentityUser || i == IdentityBot
|
||||
}
|
||||
|
||||
// String returns the underlying string.
|
||||
func (i Identity) String() string { return string(i) }
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "time"
|
||||
|
||||
// Invocation is the per-command data a Wrapper / Observer receives. It
|
||||
// is a read-only interface: the framework implementation lives in
|
||||
// internal/hook and is never visible to plugins, so plugin code cannot
|
||||
// mutate denial state.
|
||||
//
|
||||
// The interface is deliberately NOT a context.Context — it is data only,
|
||||
// no cancellation. ctx (from the handler signature) carries
|
||||
// cancellation / timeout / trace propagation.
|
||||
//
|
||||
// Accessor semantics:
|
||||
//
|
||||
// - Cmd / Args / Started are populated before the first hook fires
|
||||
// - Err is populated for After observers and the post-next portion of
|
||||
// a Wrapper (the value the wrapped handler returned)
|
||||
// - DeniedByPolicy / DenialLayer / DenialPolicySource are populated by
|
||||
// the framework's denial guard before any hook runs
|
||||
type Invocation interface {
|
||||
// Cmd returns the read-only metadata view of the dispatched command.
|
||||
Cmd() CommandView
|
||||
|
||||
// Args returns a fresh copy of the positional args.
|
||||
Args() []string
|
||||
|
||||
// Started is the wall-clock time the outermost RunE wrapper began.
|
||||
Started() time.Time
|
||||
|
||||
// Err is the error the wrapped handler returned. Populated for
|
||||
// After observers and the post-next portion of a Wrapper. nil
|
||||
// before the handler runs.
|
||||
Err() error
|
||||
|
||||
// DeniedByPolicy reports whether the command was rejected by either
|
||||
// strict-mode or user-layer policy before the chain reached the
|
||||
// hook. Observers fire even for denied commands (audit case); Wrap
|
||||
// is physically isolated by the framework so plugins do not need
|
||||
// to check this themselves before calling next.
|
||||
DeniedByPolicy() bool
|
||||
|
||||
// DenialLayer returns the layer that rejected the command:
|
||||
//
|
||||
// "" - not denied
|
||||
// "strict_mode" - credential strict-mode
|
||||
// "policy" - user-layer Rule (Plugin.Restrict() or yaml)
|
||||
DenialLayer() string
|
||||
|
||||
// DenialPolicySource returns the specific source identifier
|
||||
// ("plugin:secaudit", "yaml", "strict-mode"). Empty when not denied.
|
||||
DenialPolicySource() string
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// When selects the temporal slot for command-level Observer hooks. The
|
||||
// framework wraps every command's RunE so both stages always fire, even
|
||||
// when RunE itself returns an error (After is failure-safe).
|
||||
type When int
|
||||
|
||||
const (
|
||||
// Before fires immediately before the command's business logic.
|
||||
Before When = iota
|
||||
|
||||
// After fires after the command's business logic (or its denyStub
|
||||
// in the denied path). Always fires, even when RunE returned an
|
||||
// error; Invocation.Err is populated in that case.
|
||||
After
|
||||
)
|
||||
|
||||
// LifecycleEvent selects the temporal slot for Lifecycle hooks. These are
|
||||
// process-level events that fire once per binary execution, not per
|
||||
// command. Only Startup and Shutdown are defined: additional bootstrap
|
||||
// phases can be added later as a non-breaking addition if a concrete
|
||||
// consumer surfaces.
|
||||
type LifecycleEvent int
|
||||
|
||||
const (
|
||||
// Startup fires after plugin install has committed; Plugin.On
|
||||
// handlers for Startup are guaranteed to be registered before this
|
||||
// event is emitted (so they can receive it).
|
||||
Startup LifecycleEvent = iota
|
||||
|
||||
// Shutdown fires once before the process exits. Handler total
|
||||
// execution is bounded by a hard 2s timeout to prevent a
|
||||
// misbehaving handler from holding up exit.
|
||||
Shutdown
|
||||
)
|
||||
|
||||
// LifecycleContext is passed to LifecycleHandler. Err is the error from
|
||||
// the preceding command (when Event == Shutdown after a failed RunE);
|
||||
// otherwise nil.
|
||||
type LifecycleContext struct {
|
||||
Event LifecycleEvent
|
||||
Err error
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// Plugin is the single contract a third-party / embedding integrator
|
||||
// implements to extend lark-cli. Four methods, every one mandatory.
|
||||
//
|
||||
// Name must match the grammar ^[a-z0-9][a-z0-9-]*$. The "." character
|
||||
// is forbidden so plugin-name + hookName namespacing never produces
|
||||
// ambiguous joins.
|
||||
//
|
||||
// Capabilities must be implemented even when every field is zero. The
|
||||
// requirement is deliberate: it keeps FailurePolicy / Restricts in the
|
||||
// author's eyeline.
|
||||
//
|
||||
// Install runs once during the Bootstrap pipeline. The plugin uses the
|
||||
// supplied Registrar to register hooks and (optionally) a Rule. Errors
|
||||
// returned from Install honour the plugin's Capabilities.FailurePolicy
|
||||
// (fail-open warns + skips this plugin; fail-closed aborts the CLI).
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Version() string
|
||||
Capabilities() Capabilities
|
||||
Install(r Registrar) error
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "sync"
|
||||
|
||||
// Register adds a plugin to the global registry. Plugins call this from
|
||||
// init() (typically through a blank import in the embedder's main).
|
||||
//
|
||||
// Register is intentionally tolerant of malformed input: validation
|
||||
// happens later in the host's InstallAll phase, where errors can be
|
||||
// surfaced through the typed plugin_install envelope. Register itself
|
||||
// never panics so that init-time problems do not crash the binary
|
||||
// before main has a chance to install its recover-and-envelope logic.
|
||||
//
|
||||
// The registry holds plugins in insertion order so InstallAll can
|
||||
// process them deterministically.
|
||||
func Register(p Plugin) {
|
||||
pluginRegistry.add(p)
|
||||
}
|
||||
|
||||
// RegisteredPlugins returns a snapshot of the global plugin registry.
|
||||
// Order matches Register insertion. The host reads this once during
|
||||
// InstallAll.
|
||||
func RegisteredPlugins() []Plugin {
|
||||
return pluginRegistry.snapshot()
|
||||
}
|
||||
|
||||
// pluginRegistry is the package-level singleton. The mutex protects
|
||||
// concurrent Register calls -- harmless in practice (init runs
|
||||
// serially) but cheap insurance.
|
||||
var pluginRegistry = ®istry{}
|
||||
|
||||
type registry struct {
|
||||
mu sync.Mutex
|
||||
plugins []Plugin
|
||||
}
|
||||
|
||||
func (r *registry) add(p Plugin) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins = append(r.plugins, p)
|
||||
}
|
||||
|
||||
func (r *registry) snapshot() []Plugin {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]Plugin, len(r.plugins))
|
||||
copy(out, r.plugins)
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *registry) reset() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins = nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
type stubPlugin struct{ name string }
|
||||
|
||||
func (s stubPlugin) Name() string { return s.name }
|
||||
func (s stubPlugin) Version() string { return "0.0.1" }
|
||||
func (s stubPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
|
||||
func (s stubPlugin) Install(platform.Registrar) error { return nil }
|
||||
|
||||
// Tests should always reset the global registry to keep them
|
||||
// independent. Verifies the reset hook is functional.
|
||||
func TestRegister_preservesInsertionOrder(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(stubPlugin{name: "a"})
|
||||
platform.Register(stubPlugin{name: "b"})
|
||||
platform.Register(stubPlugin{name: "c"})
|
||||
|
||||
got := platform.RegisteredPlugins()
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d plugins, want %d", len(got), len(want))
|
||||
}
|
||||
for i, p := range got {
|
||||
if p.Name() != want[i] {
|
||||
t.Errorf("plugins[%d] = %q, want %q", i, p.Name(), want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_resetClears(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
platform.Register(stubPlugin{name: "a"})
|
||||
if len(platform.RegisteredPlugins()) != 1 {
|
||||
t.Fatalf("expected 1 plugin")
|
||||
}
|
||||
platform.ResetForTesting()
|
||||
if len(platform.RegisteredPlugins()) != 0 {
|
||||
t.Fatalf("expected reset to clear")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// ResetForTesting clears the global plugin registry. Exposed for test
|
||||
// isolation only — plugin authors and SDK consumers must NOT call this
|
||||
// from production code. The function is exported (rather than placed in
|
||||
// an internal test-only file) so that `go test ./...` works for every
|
||||
// downstream package without an extra build tag.
|
||||
//
|
||||
// Tests that exercise plugin registration must defer
|
||||
// `t.Cleanup(platform.ResetForTesting)` so subsequent tests start from a
|
||||
// clean slate. The helper is NOT goroutine-safe across concurrent
|
||||
// `t.Parallel()` tests — the global registry is shared process state.
|
||||
func ResetForTesting() { pluginRegistry.reset() }
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// Registrar is the imperative API a plugin uses inside its Install
|
||||
// method to wire up hooks and rules. The framework provides a staging
|
||||
// implementation that buffers calls and commits them atomically when
|
||||
// Install returns nil; failure rolls everything back.
|
||||
//
|
||||
// hookName must match the grammar ^[a-z0-9][a-z0-9-]*$ (no dots). The
|
||||
// framework prepends the plugin's Name() with a dot so the global hook
|
||||
// identifier is "{plugin}.{hook}". A plugin cannot register two hooks
|
||||
// with the same name in the same Install call.
|
||||
//
|
||||
// Restrict may be called multiple times per plugin; each call adds one
|
||||
// scoped Rule (OR-combined by the engine). Two or more DISTINCT plugins
|
||||
// contributing Restrict() is a configuration error (the resolver aborts
|
||||
// startup).
|
||||
type Registrar interface {
|
||||
// Observe registers a side-effect-only command hook at the given
|
||||
// When stage. The selector decides which commands it fires on.
|
||||
Observe(when When, hookName string, sel Selector, fn Observer)
|
||||
|
||||
// Wrap registers a middleware-style command hook. The Wrap chain
|
||||
// composes left-to-right in registration order; the outermost
|
||||
// Wrapper runs first.
|
||||
Wrap(hookName string, sel Selector, w Wrapper)
|
||||
|
||||
// On registers a lifecycle handler for the given event.
|
||||
On(event LifecycleEvent, hookName string, fn LifecycleHandler)
|
||||
|
||||
// Restrict contributes a pruning Rule. May be called more than once
|
||||
// to declare several scoped grants (OR-combined by the engine).
|
||||
// Plugin rules take precedence over the yaml source; two distinct
|
||||
// plugins both calling Restrict abort startup.
|
||||
Restrict(r *Rule)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Risk is the three-tier risk taxonomy declared on every command.
|
||||
//
|
||||
// A defined type (not an alias of string) so plugin authors get
|
||||
// compile-time + IDE candidate help when passing the constants below.
|
||||
// Crossing the string boundary (yaml, cobra annotation) goes through
|
||||
// ParseRisk so typos surface as `risk_invalid` rather than silently
|
||||
// flowing through.
|
||||
type Risk string
|
||||
|
||||
const (
|
||||
RiskRead Risk = "read"
|
||||
RiskWrite Risk = "write"
|
||||
RiskHighRiskWrite Risk = "high-risk-write"
|
||||
)
|
||||
|
||||
// riskOrder maps the Risk taxonomy to a comparable rank. The pruning
|
||||
// engine compares ranks for the MaxRisk axis.
|
||||
var riskOrder = map[Risk]int{
|
||||
RiskRead: 0,
|
||||
RiskWrite: 1,
|
||||
RiskHighRiskWrite: 2,
|
||||
}
|
||||
|
||||
// ParseRisk converts a raw string (yaml, cobra annotation) into a Risk.
|
||||
//
|
||||
// - s == "" → ("", nil) "not specified"
|
||||
// - s 在闭合枚举 → (Risk(s), nil) OK
|
||||
// - s 不在枚举内 → ("", error) invalid
|
||||
//
|
||||
// The (absent vs invalid) split mirrors the cmdpolicy engine's
|
||||
// risk_not_annotated vs risk_invalid reason codes — callers can treat
|
||||
// the "" + nil case as "not specified" without losing the distinction
|
||||
// from a typo.
|
||||
//
|
||||
// Matching is strict: "Read" / "READ" / " read " are all rejected.
|
||||
// annotation is developer code, not user input — strict matching is
|
||||
// the typo-catch mechanism, not a normalisation opportunity.
|
||||
func ParseRisk(s string) (Risk, error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
r := Risk(s)
|
||||
if _, ok := riskOrder[r]; !ok {
|
||||
return "", fmt.Errorf("invalid risk %q: must be read|write|high-risk-write", s)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// IsValid reports whether r is one of the three recognised values.
|
||||
func (r Risk) IsValid() bool {
|
||||
_, ok := riskOrder[r]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Rank returns the comparable rank of r. ok=false when r is not in the
|
||||
// closed taxonomy.
|
||||
func (r Risk) Rank() (rank int, ok bool) {
|
||||
rank, ok = riskOrder[r]
|
||||
return rank, ok
|
||||
}
|
||||
|
||||
// String returns the underlying string. Useful for yaml/json output
|
||||
// and cobra annotation injection.
|
||||
func (r Risk) String() string { return string(r) }
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func TestRisk_Rank_orderedTaxonomy(t *testing.T) {
|
||||
cases := []struct {
|
||||
level platform.Risk
|
||||
want int
|
||||
}{
|
||||
{platform.RiskRead, 0},
|
||||
{platform.RiskWrite, 1},
|
||||
{platform.RiskHighRiskWrite, 2},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := c.level.Rank()
|
||||
if !ok || got != c.want {
|
||||
t.Errorf("Risk(%q).Rank() = (%d,%v), want (%d,true)", c.level, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := platform.Risk("unknown-level").Rank(); ok {
|
||||
t.Fatalf("unknown-level.Rank() ok should be false")
|
||||
}
|
||||
if _, ok := platform.Risk("").Rank(); ok {
|
||||
t.Fatalf("empty.Rank() ok should be false (signals 'no risk annotation')")
|
||||
}
|
||||
}
|
||||
|
||||
// The Risk ordering must be strict: read < write < high-risk-write. The
|
||||
// policy engine compares ranks; a regression that swaps the order would
|
||||
// silently let high-risk commands pass under MaxRisk=write.
|
||||
func TestRisk_Rank_strictlyMonotonic(t *testing.T) {
|
||||
r1, _ := platform.RiskRead.Rank()
|
||||
r2, _ := platform.RiskWrite.Rank()
|
||||
r3, _ := platform.RiskHighRiskWrite.Rank()
|
||||
if !(r1 < r2 && r2 < r3) {
|
||||
t.Fatalf("Risk ranks not monotonic: read=%d write=%d high=%d", r1, r2, r3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRisk_IsValid(t *testing.T) {
|
||||
valid := []platform.Risk{platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite}
|
||||
for _, r := range valid {
|
||||
if !r.IsValid() {
|
||||
t.Errorf("%q.IsValid() = false, want true", r)
|
||||
}
|
||||
}
|
||||
invalid := []platform.Risk{"", "wrtie", "Read", "READ", " read "}
|
||||
for _, r := range invalid {
|
||||
if r.IsValid() {
|
||||
t.Errorf("%q.IsValid() = true, want false", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRisk distinguishes absent (empty input) from invalid (typo).
|
||||
// The absent / invalid split mirrors the cmdpolicy engine's
|
||||
// risk_not_annotated vs risk_invalid reason codes.
|
||||
func TestParseRisk(t *testing.T) {
|
||||
// Empty -> ("", nil) — "not specified"
|
||||
got, err := platform.ParseRisk("")
|
||||
if err != nil || got != "" {
|
||||
t.Errorf(`ParseRisk("") = (%q,%v), want ("",nil)`, got, err)
|
||||
}
|
||||
|
||||
// Valid values pass through
|
||||
for _, want := range []platform.Risk{platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite} {
|
||||
got, err := platform.ParseRisk(string(want))
|
||||
if err != nil || got != want {
|
||||
t.Errorf("ParseRisk(%q) = (%q,%v), want (%q,nil)", want, got, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Typo -> error, strict matching (case-sensitive, no trim)
|
||||
bad := []string{"wrtie", "Read", "READ", " read ", "high_risk_write"}
|
||||
for _, s := range bad {
|
||||
got, err := platform.ParseRisk(s)
|
||||
if err == nil {
|
||||
t.Errorf("ParseRisk(%q) succeeded (got %q), want error", s, got)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("ParseRisk(%q) returned %q, want empty Risk on error", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIdentity(t *testing.T) {
|
||||
got, err := platform.ParseIdentity("")
|
||||
if err != nil || got != "" {
|
||||
t.Errorf(`ParseIdentity("") = (%q,%v), want ("",nil)`, got, err)
|
||||
}
|
||||
for _, want := range []platform.Identity{platform.IdentityUser, platform.IdentityBot} {
|
||||
got, err := platform.ParseIdentity(string(want))
|
||||
if err != nil || got != want {
|
||||
t.Errorf("ParseIdentity(%q) = (%q,%v)", want, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := platform.ParseIdentity("admin"); err == nil {
|
||||
t.Fatalf(`ParseIdentity("admin") want error`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentity_IsValid(t *testing.T) {
|
||||
if !platform.IdentityUser.IsValid() {
|
||||
t.Error("user.IsValid() = false")
|
||||
}
|
||||
if !platform.IdentityBot.IsValid() {
|
||||
t.Error("bot.IsValid() = false")
|
||||
}
|
||||
if platform.Identity("admin").IsValid() {
|
||||
t.Error("admin.IsValid() = true")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// Rule is the declarative policy rule data structure. yaml files and
|
||||
// Plugin.Restrict() both produce the same Rule.
|
||||
//
|
||||
// At any moment there is at most one effective Rule -- the resolver decides
|
||||
// which source wins (Plugin > yaml > none). This package only defines the
|
||||
// shape; selection lives in internal/cmdpolicy.
|
||||
//
|
||||
// The four filter fields are joined by AND. See the engine's Evaluate for
|
||||
// the full semantics. JSON tags are used by `config policy show`; yaml
|
||||
// parsing lives in internal/cmdpolicy/yaml so the public API does not
|
||||
// depend on a yaml library.
|
||||
type Rule struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// Allow is a list of doublestar globs (slash-separated paths). An empty
|
||||
// slice means "no path restriction"; a non-empty slice means "command
|
||||
// path must match at least one glob".
|
||||
Allow []string `json:"allow,omitempty"`
|
||||
|
||||
// Deny is a list of doublestar globs. A path that matches any Deny glob
|
||||
// is rejected regardless of Allow.
|
||||
Deny []string `json:"deny,omitempty"`
|
||||
|
||||
// MaxRisk is the highest allowed risk level (inclusive). Empty string
|
||||
// means "no risk restriction". Comparison uses the closed taxonomy
|
||||
// read < write < high-risk-write.
|
||||
MaxRisk Risk `json:"max_risk,omitempty"`
|
||||
|
||||
// Identities is the allowed identity whitelist. A command passes when
|
||||
// the intersection with the command's own supported identities is
|
||||
// non-empty. Empty slice means "no identity restriction".
|
||||
Identities []Identity `json:"identities,omitempty"`
|
||||
|
||||
// AllowUnannotated controls how commands missing a risk_level
|
||||
// annotation are handled when this Rule is active.
|
||||
//
|
||||
// Default (false, fail-closed): unannotated commands are rejected
|
||||
// with reason_code=risk_not_annotated. This is the safe default
|
||||
// — a typo'd or forgotten annotation cannot slip past an
|
||||
// "agent read-only" rule.
|
||||
//
|
||||
// Set to true to opt out during gradual adoption: lark-cli main
|
||||
// has hundreds of service commands that may not yet carry
|
||||
// risk_level annotations, and a brand-new policy plugin would
|
||||
// otherwise lock the binary to nothing.
|
||||
//
|
||||
// This flag does NOT affect risk_invalid (typos): a command that
|
||||
// claims a risk but mis-spells it is always denied, regardless of
|
||||
// AllowUnannotated. Typo is a code bug, not a migration phase.
|
||||
//
|
||||
// No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so
|
||||
// platform stays free of a yaml library dependency.
|
||||
AllowUnannotated bool `json:"allow_unannotated,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "github.com/bmatcuk/doublestar/v4"
|
||||
|
||||
// Selector picks the commands a hook fires on. A nil Selector is
|
||||
// equivalent to None() -- safer than an "always-match" default because
|
||||
// it forces every hook to declare its scope explicitly. Compose
|
||||
// selectors with And / Or / Not.
|
||||
type Selector func(cmd CommandView) bool
|
||||
|
||||
// All matches every command. Use for audit / metrics observers that
|
||||
// must run on the whole surface.
|
||||
func All() Selector { return func(CommandView) bool { return true } }
|
||||
|
||||
// None matches no command. Useful as a "disabled" placeholder.
|
||||
func None() Selector { return func(CommandView) bool { return false } }
|
||||
|
||||
// ByDomain matches a command whose Domain() is one of the supplied
|
||||
// names. Commands with unknown (empty-string) Domain never match this
|
||||
// selector -- the caller should pair it with a Selector that handles
|
||||
// unknown explicitly when that case matters.
|
||||
func ByDomain(domains ...string) Selector {
|
||||
wanted := newStringSet(domains)
|
||||
return func(cmd CommandView) bool {
|
||||
d := cmd.Domain()
|
||||
return d != "" && wanted[d]
|
||||
}
|
||||
}
|
||||
|
||||
// ByCommandPath matches against the canonical slash-form path. Patterns
|
||||
// are doublestar globs ("docs/+update", "im/*", "**"). Invalid patterns
|
||||
// never match; ValidateRule's twin check catches them at the source.
|
||||
func ByCommandPath(patterns ...string) Selector {
|
||||
return func(cmd CommandView) bool {
|
||||
path := cmd.Path()
|
||||
for _, p := range patterns {
|
||||
if ok, err := doublestar.Match(p, path); err == nil && ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ByIdentity matches when the command's supported identities include
|
||||
// the supplied id. Unknown identities never match.
|
||||
func ByIdentity(id Identity) Selector {
|
||||
return func(cmd CommandView) bool {
|
||||
for _, x := range cmd.Identities() {
|
||||
if x == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Risk-based selectors below match only commands whose declared risk
|
||||
// equals the selector's target level. The closed taxonomy is read /
|
||||
// write / high-risk-write — there is no "unknown" branch in the public
|
||||
// API. When a Rule without AllowUnannotated=true is registered, the
|
||||
// policy engine treats unannotated commands as implicit deny, so risk-
|
||||
// based selectors never see them in hook dispatch under that
|
||||
// configuration.
|
||||
|
||||
// ByExactRisk matches commands whose declared risk level is exactly level.
|
||||
func ByExactRisk(level Risk) Selector {
|
||||
return func(cmd CommandView) bool {
|
||||
v, ok := cmd.Risk()
|
||||
return ok && v == level
|
||||
}
|
||||
}
|
||||
|
||||
// ByWrite matches commands whose risk is "write" or "high-risk-write".
|
||||
func ByWrite() Selector {
|
||||
return func(cmd CommandView) bool {
|
||||
v, ok := cmd.Risk()
|
||||
return ok && (v == RiskWrite || v == RiskHighRiskWrite)
|
||||
}
|
||||
}
|
||||
|
||||
// ByReadOnly matches commands whose risk is "read".
|
||||
func ByReadOnly() Selector {
|
||||
return func(cmd CommandView) bool {
|
||||
v, ok := cmd.Risk()
|
||||
return ok && v == RiskRead
|
||||
}
|
||||
}
|
||||
|
||||
// normalize maps a nil Selector to None() so combinators honour the
|
||||
// "nil == None()" contract documented on the Selector type.
|
||||
func normalize(s Selector) Selector {
|
||||
if s == nil {
|
||||
return None()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// And composes selectors with AND semantics.
|
||||
func (s Selector) And(other Selector) Selector {
|
||||
left, right := normalize(s), normalize(other)
|
||||
return func(cmd CommandView) bool {
|
||||
return left(cmd) && right(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Or composes selectors with OR semantics.
|
||||
func (s Selector) Or(other Selector) Selector {
|
||||
left, right := normalize(s), normalize(other)
|
||||
return func(cmd CommandView) bool {
|
||||
return left(cmd) || right(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Not negates the selector. A nil receiver is treated as None(), so
|
||||
// nil.Not() behaves as All().
|
||||
func (s Selector) Not() Selector {
|
||||
inner := normalize(s)
|
||||
return func(cmd CommandView) bool {
|
||||
return !inner(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func newStringSet(items []string) map[string]bool {
|
||||
out := make(map[string]bool, len(items))
|
||||
for _, x := range items {
|
||||
out[x] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// fakeView is a minimal CommandView for unit-testing selectors.
|
||||
type fakeView struct {
|
||||
path string
|
||||
domain string
|
||||
risk string
|
||||
riskOK bool
|
||||
identities []string
|
||||
}
|
||||
|
||||
func (v fakeView) Path() string { return v.path }
|
||||
func (v fakeView) Domain() string { return v.domain }
|
||||
func (v fakeView) Risk() (platform.Risk, bool) { return platform.Risk(v.risk), v.riskOK }
|
||||
func (v fakeView) Identities() []platform.Identity {
|
||||
out := make([]platform.Identity, len(v.identities))
|
||||
for i, x := range v.identities {
|
||||
out[i] = platform.Identity(x)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (v fakeView) Annotation(key string) (string, bool) { return "", false }
|
||||
|
||||
func TestAll_None(t *testing.T) {
|
||||
cmd := fakeView{}
|
||||
if !platform.All()(cmd) {
|
||||
t.Errorf("All() must match every command")
|
||||
}
|
||||
if platform.None()(cmd) {
|
||||
t.Errorf("None() must match no command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestByDomain(t *testing.T) {
|
||||
sel := platform.ByDomain("docs", "im")
|
||||
if !sel(fakeView{domain: "docs"}) {
|
||||
t.Errorf("docs should match")
|
||||
}
|
||||
if sel(fakeView{domain: "vc"}) {
|
||||
t.Errorf("vc must not match docs/im selector")
|
||||
}
|
||||
// Unknown domain (empty) must not match.
|
||||
if sel(fakeView{domain: ""}) {
|
||||
t.Errorf("unknown domain must not match ByDomain (use ByDomainOrUnknown style if desired)")
|
||||
}
|
||||
}
|
||||
|
||||
// Risk-based selectors match only against the closed taxonomy
|
||||
// (read / write / high-risk-write). Commands without a risk annotation
|
||||
// never match; the policy engine guarantees such commands cannot reach
|
||||
// hook dispatch when a Rule without AllowUnannotated=true is registered.
|
||||
func TestByExactRisk_unknownDoesNotMatch(t *testing.T) {
|
||||
sel := platform.ByExactRisk("write")
|
||||
if !sel(fakeView{risk: "write", riskOK: true}) {
|
||||
t.Errorf("exact write should match")
|
||||
}
|
||||
if sel(fakeView{riskOK: false}) {
|
||||
t.Errorf("unknown must not match ByExactRisk")
|
||||
}
|
||||
if sel(fakeView{risk: "read", riskOK: true}) {
|
||||
t.Errorf("read must not match ByExactRisk(write)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestByWrite_byReadOnly(t *testing.T) {
|
||||
if !platform.ByWrite()(fakeView{risk: "write", riskOK: true}) {
|
||||
t.Errorf("write should match ByWrite")
|
||||
}
|
||||
if !platform.ByWrite()(fakeView{risk: "high-risk-write", riskOK: true}) {
|
||||
t.Errorf("high-risk-write should match ByWrite")
|
||||
}
|
||||
if platform.ByWrite()(fakeView{risk: "read", riskOK: true}) {
|
||||
t.Errorf("read must not match ByWrite")
|
||||
}
|
||||
if platform.ByWrite()(fakeView{riskOK: false}) {
|
||||
t.Errorf("unknown must not match ByWrite")
|
||||
}
|
||||
if !platform.ByReadOnly()(fakeView{risk: "read", riskOK: true}) {
|
||||
t.Errorf("read should match ByReadOnly")
|
||||
}
|
||||
if platform.ByReadOnly()(fakeView{riskOK: false}) {
|
||||
t.Errorf("unknown must not match ByReadOnly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestByCommandPath(t *testing.T) {
|
||||
sel := platform.ByCommandPath("docs/**", "im/+send")
|
||||
if !sel(fakeView{path: "docs/+update"}) {
|
||||
t.Errorf("docs/+update should match docs/**")
|
||||
}
|
||||
if !sel(fakeView{path: "im/+send"}) {
|
||||
t.Errorf("im/+send should match")
|
||||
}
|
||||
if sel(fakeView{path: "contact/+search"}) {
|
||||
t.Errorf("contact/+search must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestByIdentity(t *testing.T) {
|
||||
sel := platform.ByIdentity("bot")
|
||||
if !sel(fakeView{identities: []string{"user", "bot"}}) {
|
||||
t.Errorf("ids containing bot should match")
|
||||
}
|
||||
if sel(fakeView{identities: []string{"user"}}) {
|
||||
t.Errorf("user-only ids must not match bot selector")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelector_AndOrNot(t *testing.T) {
|
||||
docsAndWrite := platform.ByDomain("docs").And(platform.ByExactRisk("write"))
|
||||
if !docsAndWrite(fakeView{domain: "docs", risk: "write", riskOK: true}) {
|
||||
t.Errorf("AND of matching selectors should match")
|
||||
}
|
||||
if docsAndWrite(fakeView{domain: "docs", risk: "read", riskOK: true}) {
|
||||
t.Errorf("AND fails when one side fails")
|
||||
}
|
||||
|
||||
docsOrIm := platform.ByDomain("docs").Or(platform.ByDomain("im"))
|
||||
if !docsOrIm(fakeView{domain: "im"}) {
|
||||
t.Errorf("OR should match either side")
|
||||
}
|
||||
|
||||
notRead := platform.ByReadOnly().Not()
|
||||
if notRead(fakeView{risk: "read", riskOK: true}) {
|
||||
t.Errorf("Not(ByReadOnly) must reject read commands")
|
||||
}
|
||||
if !notRead(fakeView{risk: "write", riskOK: true}) {
|
||||
t.Errorf("Not(ByReadOnly) should match write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelector_NilSafeWhenComposed(t *testing.T) {
|
||||
// A nil Selector is equivalent to None() per the Selector godoc.
|
||||
// Composition must honour that contract: the resulting selector
|
||||
// must not panic when invoked and must produce the documented
|
||||
// boolean outcome (nil-as-None propagates through AND/OR/NOT).
|
||||
var s platform.Selector
|
||||
cmd := fakeView{domain: "docs"}
|
||||
|
||||
if got := s.And(platform.All())(cmd); got {
|
||||
t.Errorf("nil.And(All) should match None semantics (false), got true")
|
||||
}
|
||||
if got := s.Or(platform.All())(cmd); !got {
|
||||
t.Errorf("nil.Or(All) should match (true), got false")
|
||||
}
|
||||
if got := platform.All().And(s)(cmd); got {
|
||||
t.Errorf("All.And(nil) should be None (false), got true")
|
||||
}
|
||||
if got := s.Not()(cmd); !got {
|
||||
t.Errorf("(nil).Not() should be Not(None) = true, got false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// CommandView is the read-only view of a cobra.Command exposed to plugins
|
||||
// and the policy engine. *cobra.Command is deliberately NOT reachable
|
||||
// through this interface -- a plugin should never mutate the command tree.
|
||||
//
|
||||
// View semantics:
|
||||
//
|
||||
// - The view is a live proxy over the underlying *cobra.Command and its
|
||||
// annotation chain. Strict-mode replaces nodes via RemoveCommand+
|
||||
// AddCommand; the replacement stub explicitly carries the original
|
||||
// command's annotations and help text forward so audit / compliance
|
||||
// observers still see Risk / Identities / Domain after a denial.
|
||||
// User-layer policy mutates in place, so its denyStubs preserve the
|
||||
// original metadata by construction.
|
||||
//
|
||||
// - Path() is the canonical slash form ("docs/+fetch"), matching the
|
||||
// doublestar glob semantics used by Rule.Allow / Rule.Deny.
|
||||
//
|
||||
// - Risk() returns ok=false when the command is unannotated. The policy
|
||||
// engine treats an unannotated command as implicit deny whenever any
|
||||
// Rule without AllowUnannotated=true is registered, so risk-based
|
||||
// Selectors never see unannotated commands during normal hook dispatch
|
||||
// under that configuration.
|
||||
type CommandView interface {
|
||||
// Path is the canonical slash-separated path, rootless ("docs/+update").
|
||||
Path() string
|
||||
|
||||
// Domain returns the business domain ("docs", "im", "") inherited from
|
||||
// the nearest ancestor with a cmdmeta.domain annotation. Empty string
|
||||
// when no ancestor declares one.
|
||||
Domain() string
|
||||
|
||||
// Risk returns the static risk level. ok=false signals "no risk_level
|
||||
// annotation found in the parent chain" (unknown).
|
||||
Risk() (level Risk, ok bool)
|
||||
|
||||
// Identities returns the supported identities. nil signals "no
|
||||
// supportedIdentities annotation in the parent chain".
|
||||
Identities() []Identity
|
||||
|
||||
// Annotation exposes the raw cobra annotation map for plugins that
|
||||
// need a tag the framework does not surface.
|
||||
Annotation(key string) (string, bool)
|
||||
}
|
||||
Reference in New Issue
Block a user