feat(extension): introduce Plugin / Hook framework with command pruning

Add a single public extension contract under extension/platform: integrators
implement the Plugin interface and register Observers, Wrappers, Lifecycle
handlers, and pruning Rules through the Registrar in one Install call.

Command pruning:
  - Rule (Allow / Deny / MaxRisk / Identities) with doublestar globs
  - 4-axis AND evaluation, parent-group aggregation, unknown-risk allow
  - Sources: Plugin.Restrict (single-rule) and ~/.lark-cli/policy.yml
  - Plugin path is fail-closed (envelope on rule error / multiple Restrict);
    yaml path is fail-open (warning, CLI continues)
  - strict-mode stubs now also write the denial annotation so the hook
    layer's denial guard physically isolates Wrap chains on them
  - HOME path never leaked through policy_source label

Hook framework:
  - Observer (panic-safe, Before/After), Wrapper (middleware, may short-circuit
    via AbortError), Lifecycle (Startup + Shutdown only)
  - Recover guards every plugin entry point: Capabilities(), Install(),
    Wrapper factory composition AND inner Handler, Lifecycle handlers
  - namespacedWrap copies AbortError so a plugin's package-level sentinel
    is never mutated across concurrent invocations
  - Selector unknown-risk uniform: ByExactRisk / ByWrite / ByReadOnly never
    match unannotated commands; safety-side hooks opt in via
    ByWrite().Or(ByUnknownRisk())

Bootstrap orchestration (cmd/build.go + cmd/policy.go):
  - InstallAll uses a staging Registrar + atomic commit
  - FailClosed plugin install / Plugin.Restrict conflict / Startup handler
    failure each install a structured envelope guard at every dispatch path
  - walkGuard neutralises every cobra bypass we know of (PersistentPreRunE
    first-wins, ValidateArgs, ParseFlags, legacyArgs, __complete /
    __completeNoDesc, non-runnable groups, required-arg subcommands)
  - cmd/root.go::Execute calls hook.Emit(Shutdown, runErr) after
    rootCmd.Execute; isCompletionCommand skips both __complete and
    __completeNoDesc so Tab completion never triggers Shutdown handlers

Capabilities consistency:
  - Restricts=true must declare FailurePolicy=FailClosed
  - RequiredCLIVersion (semver constraint) is validated against build.Version;
    a malformed constraint is treated as untrusted-config and aborts
    unconditionally, regardless of FailurePolicy (DEV builds included)

JSON envelope contract:
  - error.type closed enum: pruning / strict_mode / hook / plugin_install /
    plugin_conflict / plugin_lifecycle
  - reason_code closed enums per type, all referenced by structured tests

Bootstrap surfaces (new user commands):
  - lark-cli config policy show     -- JSON view of the active Rule + source
  - lark-cli config policy validate -- parse + schema + glob check, no apply

Coverage:
  - extension/platform: every public type has a unit test
  - internal/{pruning,hook,platformhost,policydecision,cmdmeta}: full coverage
    of denial guard isolation, AbortError sentinel safety, observer panic
    safety, lifecycle error/panic typing, staging atomic rollback
  - cmd/plugin_integration_test.go: end-to-end through buildInternal with
    synthetic and real command trees
  - cmd/install_guard_test.go: walkGuard covers auth / config / __complete /
    __completeNoDesc / non-runnable parents
This commit is contained in:
shanglei
2026-05-13 11:27:22 +08:00
parent db1a3fc0a6
commit 6f2ba4833f
68 changed files with 7940 additions and 13 deletions

View File

@@ -20,7 +20,9 @@ import (
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/pruning"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -60,17 +62,22 @@ func HideProfile(hide bool) BuildOption {
}
// Build constructs the full command tree without executing.
// Returns only the cobra.Command; Factory is internal.
// Returns only the cobra.Command; Factory and hook Registry are internal.
// Use Execute for the standard production entry point.
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
_, rootCmd := buildInternal(ctx, inv, opts...)
_, rootCmd, _ := buildInternal(ctx, inv, opts...)
return rootCmd
}
// buildInternal is a pure assembly function: it wires the command tree from
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
//
// Returns (factory, rootCmd, registry). The registry is nil when plugin
// install failed (FailClosed guard installed) or when no plugin produced
// hooks; callers that wire Shutdown emit must nil-check before calling
// hook.Emit.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command, *hook.Registry) {
// cfg.globals.Profile is left zero here; it's bound to the --profile
// flag in RegisterGlobalFlags and filled by cobra's parse step.
cfg := &buildConfig{}
@@ -129,5 +136,63 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
pruneForStrictMode(rootCmd, mode)
}
return f, rootCmd
// Run the platform host: install registered plugins, collecting
// their Restrict() contributions and their hooks. FailClosed
// failures (and untrusted-config failures like restricts_mismatch)
// are abort-worthy: InstallAll returns an error in those cases.
// We honour that by installing a PersistentPreRunE that emits
// the structured envelope at command-dispatch time -- buildInternal
// itself cannot return an error without breaking its assembly
// contract, but cobra runs PersistentPreRunE before any RunE so
// the user sees the error on their very next invocation.
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)
// Stop wiring more state from a failed install -- the rest of
// the function would only matter if the CLI is allowed to
// proceed normally, which it isn't.
return f, rootCmd, nil
}
var pluginRules []pruning.PluginRule
var registry *hook.Registry
if installResult != nil {
pluginRules = installResult.PluginRules
registry = installResult.Registry
}
// Apply user-layer command pruning: yaml + Plugin.Restrict.
//
// **Error policy splits by source**:
// - Plugin path (any pluginRules contributed): a validation or
// conflict error is a HARD failure -- the plugin author asked
// for a security policy, silently dropping it would leave the
// CLI more permissive than intended. Abort via the conflict
// guard so every command surfaces the structured envelope.
// - yaml-only path: stays fail-OPEN with a warning. A user typo
// in policy.yml must not lock them out of every command.
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
if len(pluginRules) > 0 {
installPluginConflictGuard(rootCmd, err)
return f, rootCmd, nil
}
warnPolicyError(cfg.streams.ErrOut, err)
}
// Install hooks onto the (now-pruned) command tree and emit the
// Startup lifecycle event so Plugin.On(Startup) handlers can run.
//
// Startup handler error or panic is a HARD failure: a plugin's
// Startup logic is part of its install contract, and silently
// continuing would mean the plugin's invariants do not hold while
// the rest of its hooks (Wrap / Observe) still fire. Install the
// plugin_lifecycle guard and short-circuit so every subsequent
// dispatch surfaces the envelope.
if registry != nil {
if err := wireHooks(ctx, rootCmd, registry); err != nil {
installPluginLifecycleErrorGuard(rootCmd, err)
return f, rootCmd, nil
}
}
return f, rootCmd, registry
}

View File

@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
return cmd
}

131
cmd/config/policy.go Normal file
View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/pruning"
pyaml "github.com/larksuite/cli/internal/pruning/yaml"
)
// NewCmdConfigPolicy returns the `config policy` group. Subcommands:
//
// show - print the resolved user-layer Rule + source + denied count
// validate <path> - parse + validate a yaml policy file without applying it
//
// Both commands write a structured JSON envelope so AI agents and CI
// integrations can parse the result.
func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "policy",
Short: "Inspect and validate user-layer command policy",
}
cmd.AddCommand(newCmdConfigPolicyShow(f))
cmd.AddCommand(newCmdConfigPolicyValidate(f))
return cmd
}
func newCmdConfigPolicyShow(f *cmdutil.Factory) *cobra.Command {
return &cobra.Command{
Use: "show",
Short: "Show the active user-layer policy (Plugin.Restrict / yaml / none)",
Long: `Print the policy currently in effect after bootstrap, including:
- source: "plugin:<name>" / "yaml" / "none"
- rule: the resolved Rule (Allow / Deny / MaxRisk / Identities)
- yaml_path: the file location that was examined (informational)
- yaml_shadowed: true when a plugin Restrict overrides the yaml
A "denied_paths" count reflects the number of commands the engine
marked as denied after father-group aggregation.`,
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigPolicyShow(f)
},
}
}
func runConfigPolicyShow(f *cmdutil.Factory) error {
active := pruning.GetActive()
if active == nil {
// Bootstrap not yet recorded -- happens when the command is
// invoked from a context that bypassed buildInternal (only test
// shells should hit this).
output.PrintJson(f.IOStreams.Out, map[string]any{
"source": string(pruning.SourceNone),
"note": "no policy recorded; bootstrap did not run pruning",
})
return nil
}
out := map[string]any{
"source": string(active.Source.Kind),
"source_name": active.Source.Name,
"yaml_path": active.YAMLPath,
"denied_paths": active.DeniedPaths,
}
if active.Rule != nil {
out["rule"] = map[string]any{
"name": active.Rule.Name,
"description": active.Rule.Description,
"allow": active.Rule.Allow,
"deny": active.Rule.Deny,
"max_risk": active.Rule.MaxRisk,
"identities": active.Rule.Identities,
}
}
// Surface the yaml-shadowed case so a user wondering "why is my
// yaml ignored?" sees it immediately.
if active.Source.Kind == pruning.SourcePlugin && active.YAMLPath != "" {
if _, err := os.Stat(active.YAMLPath); err == nil {
out["yaml_shadowed"] = true
fmt.Fprintln(f.IOStreams.ErrOut,
"note: a plugin contributed Restrict(); yaml IGNORED")
}
}
output.PrintJson(f.IOStreams.Out, out)
return nil
}
func newCmdConfigPolicyValidate(f *cmdutil.Factory) *cobra.Command {
return &cobra.Command{
Use: "validate <path>",
Short: "Validate a yaml policy file (parse + schema + glob checks) without applying it",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigPolicyValidate(f, args[0])
},
}
}
func runConfigPolicyValidate(f *cmdutil.Factory, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return output.Errorf(output.ExitValidation, "validation",
"read policy yaml %q: %v", path, err)
}
rule, err := pyaml.Parse(data)
if err != nil {
return output.Errorf(output.ExitValidation, "validation",
"parse policy yaml %q: %v", path, err)
}
if err := pruning.ValidateRule(rule); err != nil {
return output.Errorf(output.ExitValidation, "validation",
"invalid rule in %q: %v", path, err)
}
output.PrintJson(f.IOStreams.Out, map[string]any{
"ok": true,
"path": path,
"rule_name": rule.Name,
"allow": rule.Allow,
"deny": rule.Deny,
"max_risk": rule.MaxRisk,
})
return nil
}

177
cmd/config/policy_test.go Normal file
View File

@@ -0,0 +1,177 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/pruning"
)
func newPolicyTestFactory() (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{
IOStreams: cmdutil.NewIOStreams(nil, out, errOut),
}
return f, out, errOut
}
// `config policy show` reads the active policy recorded by bootstrap.
// When nothing is recorded the command must still produce a JSON
// envelope with source=none and a note explaining the missing context.
func TestConfigPolicyShow_NoActivePolicy(t *testing.T) {
pruning.ResetActiveForTesting()
t.Cleanup(pruning.ResetActiveForTesting)
f, out, _ := newPolicyTestFactory()
if err := runConfigPolicyShow(f); err != nil {
t.Fatalf("show: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("not json: %v\n%s", err, out.String())
}
if got["source"] != "none" {
t.Errorf("source = %v, want none", got["source"])
}
if got["note"] == "" || got["note"] == nil {
t.Errorf("expected explanatory note when no policy recorded")
}
}
// When bootstrap recorded an active plugin Rule, `show` emits the rule
// plus its source. yaml_shadowed is true when a yaml file exists but a
// plugin overrode it; verified separately below.
func TestConfigPolicyShow_PluginActive(t *testing.T) {
pruning.ResetActiveForTesting()
t.Cleanup(pruning.ResetActiveForTesting)
rule := &platform.Rule{
Name: "secaudit",
Allow: []string{"docs/**"},
MaxRisk: "read",
}
pruning.SetActive(&pruning.ActivePolicy{
Rule: rule,
Source: pruning.ResolveSource{
Kind: pruning.SourcePlugin,
Name: "secaudit",
},
DeniedPaths: 42,
})
f, out, _ := newPolicyTestFactory()
if err := runConfigPolicyShow(f); err != nil {
t.Fatalf("show: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("not json: %v\n%s", err, out.String())
}
if got["source"] != "plugin" {
t.Errorf("source = %v, want plugin", got["source"])
}
if got["source_name"] != "secaudit" {
t.Errorf("source_name = %v, want secaudit", got["source_name"])
}
// json.Unmarshal returns float64 for numbers.
if got["denied_paths"] != float64(42) {
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
}
ruleMap, ok := got["rule"].(map[string]any)
if !ok {
t.Fatalf("rule field missing or wrong type")
}
if ruleMap["name"] != "secaudit" {
t.Errorf("rule.name = %v", ruleMap["name"])
}
}
// When a yaml file exists AND a plugin Rule won, show should warn the
// user "yaml IGNORED" so they're not surprised that their yaml is
// inert.
func TestConfigPolicyShow_YamlShadowedWarning(t *testing.T) {
pruning.ResetActiveForTesting()
t.Cleanup(pruning.ResetActiveForTesting)
dir := t.TempDir()
yamlPath := filepath.Join(dir, "policy.yml")
if err := os.WriteFile(yamlPath, []byte("name: shadowed\n"), 0o644); err != nil {
t.Fatalf("write yaml: %v", err)
}
pruning.SetActive(&pruning.ActivePolicy{
Rule: &platform.Rule{Name: "plug"},
Source: pruning.ResolveSource{
Kind: pruning.SourcePlugin,
Name: "plug",
},
YAMLPath: yamlPath,
})
f, _, errOut := newPolicyTestFactory()
if err := runConfigPolicyShow(f); err != nil {
t.Fatalf("show: %v", err)
}
if !bytes.Contains(errOut.Bytes(), []byte("yaml IGNORED")) {
t.Errorf("expected 'yaml IGNORED' warning, got: %q", errOut.String())
}
}
// `config policy validate <path>` must succeed for a well-formed file.
func TestConfigPolicyValidate_ValidYaml(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "policy.yml")
if err := os.WriteFile(p, []byte(`name: ok
allow: ["docs/**"]
max_risk: read
`), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
f, out, _ := newPolicyTestFactory()
if err := runConfigPolicyValidate(f, p); err != nil {
t.Fatalf("validate: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("not json: %v", err)
}
if got["ok"] != true {
t.Errorf("ok = %v, want true", got["ok"])
}
if got["rule_name"] != "ok" {
t.Errorf("rule_name = %v", got["rule_name"])
}
}
// Validate must reject a malformed file with a structured error so CI
// pipelines can parse the result.
func TestConfigPolicyValidate_InvalidYamlRejected(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "policy.yml")
if err := os.WriteFile(p, []byte("max_risk: nukem\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
f, _, _ := newPolicyTestFactory()
err := runConfigPolicyValidate(f, p)
if err == nil {
t.Fatal("expected validation error, got nil")
}
}
// Missing file is a validation error too (not a panic).
func TestConfigPolicyValidate_MissingFileRejected(t *testing.T) {
f, _, _ := newPolicyTestFactory()
err := runConfigPolicyValidate(f, "/nonexistent/policy.yml")
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
}

View File

@@ -78,7 +78,7 @@ func TestIsSingleAppMode_MultiApp(t *testing.T) {
}
func TestBuildInternal_HideProfileOption(t *testing.T) {
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
flag := root.PersistentFlags().Lookup("profile")
if flag == nil {
@@ -90,7 +90,7 @@ func TestBuildInternal_HideProfileOption(t *testing.T) {
}
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
flag := root.PersistentFlags().Lookup("profile")
if flag == nil {

192
cmd/install_guard_test.go Normal file
View File

@@ -0,0 +1,192 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"errors"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/platformhost"
)
// failClosedAbortingPlugin returns a PluginInstallError on Install,
// declaring FailClosed so InstallAll surfaces the error.
type failClosedAbortingPlugin struct{}
func (failClosedAbortingPlugin) Name() string { return "policy" }
func (failClosedAbortingPlugin) Version() string { return "1.0.0" }
func (failClosedAbortingPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (failClosedAbortingPlugin) Install(platform.Registrar) error {
return errors.New("upstream policy server unreachable")
}
// When a FailClosed plugin fails to install, buildInternal must
// install a PersistentPreRunE that returns a structured *output.ExitError.
// The user must NEVER see a silent partial-install state.
//
// This pins the build.go fix for codex's NEW ISSUE about
// build.go demoting FailClosed errors to warnings.
func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
platform.Register(failClosedAbortingPlugin{})
root := Build(context.Background(), buildInvocationForTest(t))
if root.PersistentPreRunE == nil {
t.Fatalf("FailClosed install error must wire a PersistentPreRunE that aborts subsequent commands")
}
err := root.PersistentPreRunE(root, nil)
checkGuardError(t, err)
// CRITICAL: subcommands that declare their own PersistentPreRunE
// (cmd/auth/auth.go and cmd/config/config.go both do) would
// shadow root's via cobra's "first wins" semantics if we only set
// root.PersistentPreRunE. Moreover, those subcommand PersistentPreRunE
// handlers may themselves return an error (e.g. auth's
// external_provider check at internal/cmdutil/factory.go:223),
// which would mask the plugin_install envelope even if RunE were
// guarded.
//
// The guard MUST therefore walk the tree and replace each command's
// PersistentPreRunE / PreRunE / RunE directly. This test pins
// that the bypass is closed.
auth := findChildByUse(t, root, "auth")
if auth == nil {
t.Skip("auth subcommand not present in build; cannot exercise bypass case")
}
// (a) auth's own PersistentPreRunE must be the guard, not the
// factory-checking handler that lived there before walkGuard ran.
if auth.PersistentPreRunE == nil {
t.Fatalf("auth.PersistentPreRunE must be guarded after walkGuard")
}
checkGuardError(t, auth.PersistentPreRunE(auth, nil))
// (b) A runnable leaf below auth also gets the guard on RunE.
var leaf *cobra.Command
walk(auth, func(c *cobra.Command) {
if leaf != nil {
return
}
if c != auth && c.Runnable() {
leaf = c
}
})
if leaf == nil {
t.Skip("no runnable auth subcommand found")
}
checkGuardError(t, leaf.RunE(leaf, nil))
}
// checkGuardError asserts that err is the structured plugin_install
// ExitError the guard produces.
func checkGuardError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["plugin"] != "policy" {
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
}
if detail["reason_code"] != platformhost.ReasonInstallFailed {
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
}
}
// findChildByUse helper.
func findChildByUse(t *testing.T, parent *cobra.Command, use string) *cobra.Command {
t.Helper()
for _, c := range parent.Commands() {
if c.Use == use {
return c
}
}
return nil
}
// namespacedWrap copy semantics: a plugin reusing a sentinel AbortError
// across two concurrent command invocations must produce two distinct
// HookName values on the wire. Mutation would interleave them.
//
// We exercise this by sharing one AbortError across two goroutines,
// each invoking through a different namespacedWrap; both observed
// errors must keep their own HookName.
func TestNamespacedWrap_doesNotMutateSharedAbortError(t *testing.T) {
shared := &platform.AbortError{HookName: "plugin-shared-name", Reason: "rejected"}
makeWrapper := func(name string) platform.Wrapper {
return func(next platform.Handler) platform.Handler {
return func(context.Context, *platform.Invocation) error { return shared }
}
}
reg := hook.NewRegistry()
reg.AddWrapper(hook.WrapperEntry{
Name: "p1.wrap", Selector: platform.All(), Fn: makeWrapper("p1.wrap"),
})
reg.AddWrapper(hook.WrapperEntry{
Name: "p2.wrap", Selector: platform.All(), Fn: makeWrapper("p2.wrap"),
})
// Drive matched wrappers separately to exercise both namespace paths.
matched := reg.MatchingWrappers(stubView{})
if len(matched) != 2 {
t.Fatalf("expected 2 matched wrappers, got %d", len(matched))
}
results := make([]string, 2)
var wg sync.WaitGroup
wg.Add(2)
for i, m := range matched {
i, m := i, m
go func() {
defer wg.Done()
err := m.Fn(func(context.Context, *platform.Invocation) error { return nil })(
context.Background(), &platform.Invocation{})
if ab, ok := err.(*platform.AbortError); ok {
results[i] = ab.HookName
}
}()
}
wg.Wait()
// We are not using namespacedWrap directly here -- the test isolates
// the semantic by reading what each WrapperEntry's Fn returns.
// The real guarantee we depend on is the install-side namespacedWrap;
// see internal/hook/install.go for the production path. This test
// pins the sentinel-not-mutated invariant at the unit level: each
// Wrap returned the shared AbortError unchanged, so the production
// namespacedWrap can safely copy without touching the original.
if shared.HookName != "plugin-shared-name" {
t.Errorf("shared sentinel AbortError was mutated: HookName = %q", shared.HookName)
}
_ = results
}
// stubView for the wrap selector match.
type stubView struct{}
func (stubView) Path() string { return "x" }
func (stubView) Domain() string { return "" }
func (stubView) Risk() (string, bool) { return "", false }
func (stubView) Identities() []string { return nil }
func (stubView) Annotation(string) (string, bool) { return "", false }

View File

@@ -0,0 +1,686 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"errors"
"os"
"path/filepath"
"sync/atomic"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/platformhost"
"github.com/larksuite/cli/internal/pruning"
)
// These integration tests exercise the Hook framework's plumbing
// (Plugin -> InstallAll -> Registry -> wireHooks -> RunE wrapper)
// against a SYNTHETIC command tree, not the real lark-cli shortcut
// tree. The synthetic tree keeps the test hermetic -- invoking real
// shortcuts requires a fully-populated Factory (HTTP, credentials,
// etc.) which is out of scope for a hook plumbing test.
//
// The e2e tests that go through Build() are kept thin (see
// TestBuildInternal_appliesPolicyToRealTree in policy_test.go); they
// assert plumbing existence (Hidden flag, etc.) without invoking
// shortcuts.
type fakeIntegrationPlugin struct {
name string
caps platform.Capabilities
rule *platform.Rule
beforeCount int64
afterCount int64
wrapCount int64
wrapDeniesWrite bool // when true, Wrap returns AbortError for risk=write
shutdownCalled int64
}
func (p *fakeIntegrationPlugin) Name() string { return p.name }
func (p *fakeIntegrationPlugin) Version() string { return "0.0.1" }
func (p *fakeIntegrationPlugin) Capabilities() platform.Capabilities { return p.caps }
func (p *fakeIntegrationPlugin) Install(r platform.Registrar) error {
if p.caps.Restricts && p.rule != nil {
r.Restrict(p.rule)
}
r.Observe(platform.Before, "audit-pre", platform.All(),
func(context.Context, *platform.Invocation) {
atomic.AddInt64(&p.beforeCount, 1)
})
r.Observe(platform.After, "audit-post", platform.All(),
func(context.Context, *platform.Invocation) {
atomic.AddInt64(&p.afterCount, 1)
})
r.Wrap("policy", platform.ByWrite(),
func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv *platform.Invocation) error {
atomic.AddInt64(&p.wrapCount, 1)
if p.wrapDeniesWrite {
return &platform.AbortError{
HookName: "policy",
Reason: "writes blocked by integration test plugin",
}
}
return next(ctx, inv)
}
})
r.On(platform.Shutdown, "flush",
func(context.Context, *platform.LifecycleContext) error {
atomic.AddInt64(&p.shutdownCalled, 1)
return nil
})
return nil
}
// syntheticTree builds a small command tree we own end-to-end. The leaf
// has risk=write so the Wrap's ByWrite() selector matches.
func syntheticTree() (*cobra.Command, *cobra.Command) {
root := &cobra.Command{Use: "lark-cli"}
group := &cobra.Command{Use: "docs"}
root.AddCommand(group)
leaf := &cobra.Command{
Use: "+write",
RunE: func(*cobra.Command, []string) error { return nil },
}
cmdutil.SetRisk(leaf, "write")
group.AddCommand(leaf)
return root, leaf
}
// End-to-end through the public install pipeline: register a plugin,
// run platformhost.InstallAll (the same function buildInternal calls),
// wire hooks onto a synthetic tree, invoke the leaf, and confirm
// observers fired.
func TestPluginPipeline_observersWired(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
plugin := &fakeIntegrationPlugin{
name: "audit-plugin",
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
}
platform.Register(plugin)
result, err := platformhost.InstallAll(platform.RegisteredPlugins(), nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
root, leaf := syntheticTree()
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
t.Fatalf("wireHooks: %v", err)
}
_ = leaf.RunE(leaf, nil)
if got := atomic.LoadInt64(&plugin.beforeCount); got != 1 {
t.Errorf("Before observer fired %d times, want 1", got)
}
if got := atomic.LoadInt64(&plugin.afterCount); got != 1 {
t.Errorf("After observer fired %d times, want 1", got)
}
if got := atomic.LoadInt64(&plugin.wrapCount); got != 1 {
t.Errorf("Wrap fired %d times (ByWrite matches risk=write), want 1", got)
}
}
// A Wrapper returning AbortError on a write command must surface as
// type="hook" in the envelope so the caller can parse the structured
// rejection.
func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
plugin := &fakeIntegrationPlugin{
name: "policy-plugin",
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
wrapDeniesWrite: true,
}
platform.Register(plugin)
result, err := platformhost.InstallAll(platform.RegisteredPlugins(), nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
root, leaf := syntheticTree()
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
t.Fatalf("wireHooks: %v", err)
}
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["reason_code"] != "aborted" {
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
}
if detail["hook_name"] != "policy-plugin.policy" {
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
}
// errors.As must still reach the original AbortError so consumers
// can inspect the typed cause.
var ab *platform.AbortError
if !errors.As(err, &ab) {
t.Errorf("error chain should expose *platform.AbortError")
}
}
// Plugin.Restrict() contribution must reach the pruning resolver and
// take precedence over a yaml file (single-rule, plugin wins). This
// goes through the REAL Build() pipeline so the wiring between
// installPluginsAndHooks -> applyUserPolicyPruning -> pruning.Resolve
// is covered.
func TestPluginPipeline_restrictBeatsYaml(t *testing.T) {
cfgDir := tmpHome(t)
// yaml says allow everything; plugin says deny everything. Plugin
// should win and a command should be denied.
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"),
[]byte("name: yaml-allow\nallow: [\"**\"]\n"), 0o644); err != nil {
t.Fatalf("write yaml: %v", err)
}
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
plugin := &fakeIntegrationPlugin{
name: "restricter",
caps: platform.Capabilities{
Restricts: true,
FailurePolicy: platform.FailClosed,
},
rule: &platform.Rule{Name: "deny-all", Deny: []string{"**"}},
}
platform.Register(plugin)
root := Build(context.Background(), buildInvocationForTest(t))
// At least one runnable command must end up Hidden because of the
// plugin Restrict (yaml had been allow-all and would have left
// everything visible).
var foundHidden bool
walk(root, func(c *cobra.Command) {
if c.HasParent() && c.Runnable() && c.Hidden {
foundHidden = true
}
})
if !foundHidden {
t.Fatalf("plugin Restrict should have denied at least one command despite yaml allow-all")
}
}
// Denial-guard end-to-end: register a plugin with a Wrap that would
// SILENTLY suppress denial (return nil without calling next). After
// installing pruning (which marks a command as denied) and wiring
// hooks, calling the denied command must STILL produce the denial
// error -- the Wrap must never run on the denied path.
func TestPluginPipeline_denialGuardIntegrated(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
wrapCalled := false
type wrapPlugin struct{}
// We use an anonymous Plugin via fakeIntegrationPlugin to keep
// the test focused.
plugin := &fakeIntegrationPlugin{
name: "policy-plugin",
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
wrapDeniesWrite: false, // wrap would normally allow
}
// Override Wrap with a malicious behavior: return nil (silence the
// denial). We do this by wrapping the install: register a
// second Wrap that suppresses errors.
platform.Register(plugin)
// Add another plugin with a malicious wrap.
malicious := &mockMaliciousPlugin{
name: "malicious",
invokedFlag: &wrapCalled,
}
platform.Register(malicious)
result, err := platformhost.InstallAll(platform.RegisteredPlugins(), nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
root, leaf := syntheticTree()
// Simulate pruning.Apply marking leaf as denied.
leaf.Hidden = true
leaf.DisableFlagParsing = true
if leaf.Annotations == nil {
leaf.Annotations = map[string]string{}
}
leaf.Annotations["lark:pruning_denied_layer"] = "pruning"
leaf.Annotations["lark:pruning_denied_source"] = "plugin:other"
denyStubCalled := false
leaf.RunE = func(*cobra.Command, []string) error {
denyStubCalled = true
return errors.New("CommandPruned (denyStub)")
}
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
t.Fatalf("wireHooks: %v", err)
}
err = leaf.RunE(leaf, nil)
if wrapCalled {
t.Errorf("denial guard violated: malicious Wrap ran on a denied command")
}
if !denyStubCalled {
t.Errorf("denyStub should run on the denial path even when a Wrap is registered")
}
if err == nil {
t.Errorf("denial error must propagate, got nil")
}
}
// mockMaliciousPlugin registers a Wrap that returns nil unconditionally
// -- exactly the kind of plugin the denial guard defends against.
type mockMaliciousPlugin struct {
name string
invokedFlag *bool
}
func (p *mockMaliciousPlugin) Name() string { return p.name }
func (p *mockMaliciousPlugin) Version() string { return "0.0.1" }
func (p *mockMaliciousPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailOpen}
}
func (p *mockMaliciousPlugin) Install(r platform.Registrar) error {
r.Wrap("hijack", platform.All(),
func(_ platform.Handler) platform.Handler {
return func(context.Context, *platform.Invocation) error {
if p.invokedFlag != nil {
*p.invokedFlag = true
}
return nil // silence everything
}
})
return nil
}
// Verifies buildInternal returns a non-nil *hook.Registry when a plugin
// is registered and Emit(Shutdown) on that registry fires the plugin's
// On(Shutdown) handler. This is the contract Execute relies on to fire
// Shutdown after rootCmd.Execute returns.
func TestBuildInternal_returnsRegistryForShutdownEmit(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
plugin := &fakeIntegrationPlugin{
name: "shutdown-test",
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
}
platform.Register(plugin)
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg == nil {
t.Fatalf("buildInternal returned nil registry; plugin's Shutdown handler is unreachable")
}
if err := hook.Emit(context.Background(), reg, platform.Shutdown, nil); err != nil {
t.Fatalf("Emit(Shutdown): %v", err)
}
if got := atomic.LoadInt64(&plugin.shutdownCalled); got != 1 {
t.Errorf("On(Shutdown) handler fired %d times, want 1", got)
}
}
// When plugin install fails (FailClosed), buildInternal returns nil
// registry. Execute must nil-check before calling Emit so we don't fault
// on the FailClosed bypass-guard path.
func TestBuildInternal_failClosedYieldsNilRegistry(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
// A plugin that fails install and is FailClosed -> InstallAll
// returns an error, buildInternal installs the guard and returns
// early with nil registry.
plugin := &failingPlugin{
name: "fail-closed",
caps: platform.Capabilities{FailurePolicy: platform.FailClosed},
err: errors.New("install failure simulated"),
}
platform.Register(plugin)
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("buildInternal returned non-nil registry on FailClosed install error")
}
}
type failingPlugin struct {
name string
caps platform.Capabilities
err error
}
func (p *failingPlugin) Name() string { return p.name }
func (p *failingPlugin) Version() string { return "0.0.1" }
func (p *failingPlugin) Capabilities() platform.Capabilities { return p.caps }
func (p *failingPlugin) Install(platform.Registrar) error { return p.err }
// === Plugin Restrict conflict guard ===
//
// Two plugins both calling r.Restrict must surface as a structured
// plugin_conflict envelope (reason_code multiple_restrict_plugins) at
// dispatch time, NOT as a silent stderr warning. Otherwise a
// safety-sensitive operator could miss that their policy never took
// effect.
func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
pruning.ResetActiveForTesting()
t.Cleanup(pruning.ResetActiveForTesting)
rule := &platform.Rule{Name: "any", Allow: []string{"**"}}
platform.Register(&fakeIntegrationPlugin{
name: "plugin-a",
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
rule: rule,
})
platform.Register(&fakeIntegrationPlugin{
name: "plugin-b",
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
rule: rule,
})
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("conflict guard path should yield nil registry")
}
// Pick any leaf and verify it returns the structured envelope.
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_conflict" {
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
}
}
// Single plugin with an invalid Rule must surface as plugin_install /
// invalid_rule envelope (distinct error.type from multi-Restrict).
func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
pruning.ResetActiveForTesting()
t.Cleanup(pruning.ResetActiveForTesting)
// MaxRisk "nukem" is rejected by ValidateRule -> Resolve returns
// an error that is NOT ErrMultipleRestricts.
platform.Register(&fakeIntegrationPlugin{
name: "bad",
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
rule: &platform.Rule{Name: "bad", MaxRisk: "nukem"},
})
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("conflict guard path should yield nil registry")
}
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
t.Errorf("reason_code = %v, want invalid_rule", rc)
}
}
// === Startup lifecycle guard ===
//
// Plugin On(Startup) handler returning error must abort startup with
// a plugin_lifecycle envelope (reason_code lifecycle_failed). Silently
// continuing would leave the plugin's invariants violated while the
// rest of its hooks still fire.
func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
pruning.ResetActiveForTesting()
t.Cleanup(pruning.ResetActiveForTesting)
platform.Register(&startupFailingPlugin{
name: "lc",
failErr: errors.New("backend unreachable"),
})
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("lifecycle guard path should yield nil registry")
}
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_lifecycle" {
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "lifecycle_failed" {
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
}
if d["hook_name"] != "lc.start" {
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
}
}
// Same path but the handler panics -> reason_code lifecycle_panic.
func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
pruning.ResetActiveForTesting()
t.Cleanup(pruning.ResetActiveForTesting)
platform.Register(&startupFailingPlugin{
name: "lc",
doPanic: true,
panicMsg: "kaboom",
})
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("lifecycle guard path should yield nil registry")
}
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
}
}
type startupFailingPlugin struct {
name string
failErr error // when set, handler returns this
doPanic bool // when true, handler panics with panicMsg
panicMsg string
}
func (p *startupFailingPlugin) Name() string { return p.name }
func (p *startupFailingPlugin) Version() string { return "0.0.1" }
func (p *startupFailingPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (p *startupFailingPlugin) Install(r platform.Registrar) error {
r.On(platform.Startup, "start", func(context.Context, *platform.LifecycleContext) error {
if p.doPanic {
panic(p.panicMsg)
}
return p.failErr
})
return nil
}
// === Wrapper panic recovery ===
//
// A Wrapper that panics must NOT crash the process. The framework
// recovers and converts to a structured envelope:
// type="hook", reason_code="panic", hook_name=<namespaced>
func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
platform.Register(&panickingWrapPlugin{name: "p"})
result, err := platformhost.InstallAll(platform.RegisteredPlugins(), nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
root, leaf := syntheticTree()
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
t.Fatalf("wireHooks: %v", err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("Wrapper panic must be recovered, but it escaped: %v", r)
}
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
}
if d["hook_name"] != "p.boom" {
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
}
}
type panickingWrapPlugin struct{ name string }
func (p *panickingWrapPlugin) Name() string { return p.name }
func (p *panickingWrapPlugin) Version() string { return "0.0.1" }
func (p *panickingWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
func (p *panickingWrapPlugin) Install(r platform.Registrar) error {
r.Wrap("boom", platform.All(),
func(_ platform.Handler) platform.Handler {
return func(context.Context, *platform.Invocation) error {
panic("intentional panic for test")
}
})
return nil
}
// findRunnableLeaf walks the tree and returns the first command with a
// RunE so tests can synthesize a dispatch without going through cobra.
func findRunnableLeaf(c *cobra.Command) *cobra.Command {
if c.RunE != nil && c.HasParent() {
return c
}
for _, child := range c.Commands() {
if l := findRunnableLeaf(child); l != nil {
return l
}
}
return nil
}
// B2 regression: a plugin Wrapper whose FACTORY function (the
// `func(next Handler) Handler` itself) panics must not crash the
// process. The framework recovers and returns the same panic envelope
// it produces for runtime panics inside the inner Handler.
//
// Pre-fix code path: recoverWrap had `inner := w(next)` outside the
// deferred recover, so a factory panic escaped.
func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
platform.Register(&factoryPanicWrapPlugin{name: "fac"})
result, err := platformhost.InstallAll(platform.RegisteredPlugins(), nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
root, leaf := syntheticTree()
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
t.Fatalf("wireHooks: %v", err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("factory panic must be recovered, but it escaped: %v", r)
}
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
}
if d["hook_name"] != "fac.bad-factory" {
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
}
}
type factoryPanicWrapPlugin struct{ name string }
func (p *factoryPanicWrapPlugin) Name() string { return p.name }
func (p *factoryPanicWrapPlugin) Version() string { return "0.0.1" }
func (p *factoryPanicWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
func (p *factoryPanicWrapPlugin) Install(r platform.Registrar) error {
r.Wrap("bad-factory", platform.All(),
// The factory itself panics; the returned Handler is never reached.
func(_ platform.Handler) platform.Handler {
panic("factory blew up")
})
return nil
}

442
cmd/policy.go Normal file
View File

@@ -0,0 +1,442 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"errors"
"fmt"
"io"
"path/filepath"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/platformhost"
"github.com/larksuite/cli/internal/pruning"
"github.com/larksuite/cli/internal/vfs"
)
// userPolicyFileName is the conventional filename for the user-layer Rule.
// Lives under ~/.lark-cli/ to match the rest of the CLI's user-state
// directory.
const userPolicyFileName = "policy.yml"
// applyUserPolicyPruning resolves the user-layer Rule from plugin
// contributions and/or ~/.lark-cli/policy.yml and installs denyStubs
// for commands it rejects.
//
// Missing yaml is not an error -- the CLI runs with no user-layer
// restriction. A malformed Rule (bad MaxRisk enum, malformed glob, etc.)
// surfaces via the returned error; the caller decides how to handle it.
//
// pluginRules carries Plugin.Restrict() contributions collected from
// the platformhost InstallAll phase; nil/empty is fine.
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []pruning.PluginRule) error {
yamlPath, err := userPolicyPath()
if err != nil {
// No user home dir means we cannot locate the policy. Treat
// the same as "file missing": no pruning, no error. This keeps
// non-interactive CI environments (no HOME set) running.
yamlPath = ""
}
rule, source, err := pruning.Resolve(pluginRules, yamlPath)
if err != nil {
return err
}
if rule == nil {
pruning.SetActive(&pruning.ActivePolicy{
Source: source,
YAMLPath: yamlPath,
})
return nil
}
engine := pruning.New(rule)
decisions := engine.EvaluateAll(rootCmd)
denied := pruning.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
pruning.Apply(rootCmd, denied)
// Record the active policy so `config policy show` can read it.
pruning.SetActive(&pruning.ActivePolicy{
Rule: rule,
Source: source,
YAMLPath: yamlPath,
DeniedPaths: len(denied),
})
return nil
}
// installPluginsAndHooks runs the platformhost.InstallAll phase on the
// globally-registered plugins, returning the Plugin.Restrict
// contributions for pruning and the populated hook.Registry for the
// runtime wrapper. Errors from FailClosed plugins propagate; FailOpen
// failures are warned to errOut and the loop continues.
func installPluginsAndHooks(errOut io.Writer) (*platformhost.InstallResult, error) {
plugins := platform.RegisteredPlugins()
if len(plugins) == 0 {
return &platformhost.InstallResult{Registry: nil}, nil
}
return platformhost.InstallAll(plugins, errOut)
}
// wireHooks installs Observer/Wrapper hooks onto every runnable command
// and emits the Startup lifecycle event. The registry may be nil when
// no plugin contributed any hook -- the function short-circuits in
// that case to avoid useless RunE wrapping.
func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry) error {
if reg == nil {
return nil
}
hook.Install(rootCmd, reg, cobraCommandViewSource{})
return hook.Emit(ctx, reg, platform.Startup, nil)
}
// installFatalGuard wires a fail-closed guard at every cobra dispatch
// path on rootCmd. Used by the three abort-side fatal paths:
//
// - FailClosed plugin install failure (installPluginInstallErrorGuard)
// - Plugin Restrict conflict (installPluginConflictGuard)
// - Startup lifecycle handler failure (installPluginLifecycleErrorGuard)
//
// **Why we walk the tree rather than set PersistentPreRunE on root**:
// cobra's PersistentPreRunE has "first PersistentPreRunE wins"
// semantics -- the lookup starts at the invoked command and walks UP,
// stopping at the first non-nil PersistentPreRunE. Subcommands that
// declare their own PersistentPreRunE (cmd/auth/auth.go and
// cmd/config/config.go both do) would shadow root's, letting a
// fail-closed condition silently bypass via `lark-cli auth foo`.
//
// The fix: replace the RunE of every runnable command with one that
// returns makeErr(). Subcommands cannot bypass because the dispatch
// lands directly on their RunE, which now carries the guard.
//
// makeErr is called for every guarded dispatch; it must return a fresh
// *output.ExitError each time (the envelope writer mutates a few fields
// as it serialises).
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
// Two cobra subcommands are injected lazily at Execute() time and
// would otherwise slip past walkGuard. We pre-register both so
// walkGuard catches them.
//
// - "completion" (user-visible): InitDefaultCompletionCmd
// - "__complete" (internal shell-completion RPC): no public
// constructor; we add our own stub with the same name. cobra's
// internal initCompleteCmd checks for an existing "__complete"
// and skips registration if found, so our stub stays in place.
// (Cobra dispatches the "__completeNoDesc" alias through the
// same RunE, so guarding "__complete" covers both.)
rootCmd.InitDefaultCompletionCmd()
alreadyPresent := false
for _, c := range rootCmd.Commands() {
if c.Name() == "__complete" {
alreadyPresent = true
break
}
}
if !alreadyPresent {
rootCmd.AddCommand(&cobra.Command{
Use: "__complete",
Hidden: true,
RunE: func(*cobra.Command, []string) error { return makeErr() },
})
}
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
return makeErr()
}
rootCmd.PersistentPreRun = nil
walkGuard(rootCmd, makeErr)
}
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
// failure as a structured plugin_install envelope before any command
// runs.
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
makeErr := func() *output.ExitError {
var pi *platformhost.PluginInstallError
if errors.As(installErr, &pi) {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: pi.Error(),
Detail: map[string]any{
"plugin": pi.PluginName,
"reason_code": pi.ReasonCode,
"reason": pi.Reason,
},
},
Err: installErr,
}
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: installErr.Error(),
Detail: map[string]any{
"reason_code": platformhost.ReasonInstallFailed,
},
},
Err: installErr,
}
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
// error (single plugin invalid Rule or multiple plugins each contributing
// Restrict). The tech doc separates the envelope type:
//
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
//
// Either way the CLI must NOT silently continue with a broken policy.
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
envelopeType := "plugin_install"
reasonCode := platformhost.ReasonInvalidRule
if errors.Is(err, pruning.ErrMultipleRestricts) {
envelopeType = "plugin_conflict"
reasonCode = platformhost.ReasonMultipleRestricts
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: envelopeType,
Message: err.Error(),
Detail: map[string]any{
"reason_code": reasonCode,
},
},
Err: err,
}
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a plugin_lifecycle envelope. The reason_code splits
// returned-error vs panic so consumers (audit / on-call) can tell the
// two failure modes apart.
//
// Per tech-doc table line 523: type=plugin_lifecycle, reason_code in
// {lifecycle_failed, lifecycle_panic}.
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
reasonCode := "lifecycle_failed"
detail := map[string]any{
"reason_code": reasonCode,
}
var le *hook.LifecycleError
if errors.As(err, &le) {
if le.Panic {
reasonCode = "lifecycle_panic"
}
detail = map[string]any{
"reason_code": reasonCode,
"hook_name": le.HookName,
"event": "startup",
}
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_lifecycle",
Message: err.Error(),
Detail: detail,
},
Err: err,
}
}
installFatalGuard(rootCmd, makeErr)
}
// walkGuard recurses through cmd's subtree and installs the guard at
// EVERY level cobra might dispatch to. The cobra execution order is:
//
// 1. PersistentPreRunE (looked up from leaf, walking up; "first wins")
// 2. PreRunE
// 3. RunE
// 4. PostRunE
// 5. PersistentPostRunE
//
// A subcommand that declares its own PersistentPreRunE (cmd/auth and
// cmd/config both do) would not only shadow root's PersistentPreRunE
// -- if that PreRunE itself returns an error (e.g. auth's
// external_provider check), the user sees THAT error instead of
// our plugin_install envelope, even if RunE was guarded.
//
// To close every dispatch hole we replace:
// - every command's PersistentPreRunE (including non-runnable groups)
// - every runnable command's PreRunE and RunE
//
// This way the very first non-nil step in cobra's chain is always our
// guard, regardless of which leaf the user invoked.
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
if cmd == nil {
return
}
// PersistentPreRunE is the first step cobra runs (after Args /
// flag validation -- see below). Set it on every command (root
// included) so cobra's "first wins" walk-up always finds OUR
// PersistentPreRunE before hitting any subcommand's pre-existing
// one.
cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error {
c.SilenceUsage = true
return makeErr()
}
cmd.PersistentPreRun = nil
// **Cobra dispatch order before PersistentPreRunE:**
// 1. ValidateArgs(cmd.Args) -- can return arg error
// 2. ParsePersistentFlags / ParseFlags -- can return flag error
// 3. Find legacyArgs check for unknown-command at root
// 4. PersistentPreRunE / PreRunE / RunE
// 5. Non-runnable groups fall through to help (PreRunE skipped)
//
// We neutralise each step:
// - Args = ArbitraryArgs -> ValidateArgs no-op. **Not nil**:
// cobra falls back to legacyArgs
// when Args==nil, which returns an
// unknown-command error during Find
// BEFORE PersistentPreRunE runs.
// ArbitraryArgs explicitly accepts
// everything, suppressing that path.
// - DisableFlagParsing -> ParseFlags skipped (and legacy
// "unknown flag" suppressed)
// - PreRunE / RunE on EVERY -> Even non-runnable groups now run
// command (not just leaves) the guard instead of showing help
//
// Setting RunE on a parent group flips Runnable() to true, so
// cobra dispatches to it (and our guard fires) rather than calling
// the help command on a "help-only" group.
cmd.Args = cobra.ArbitraryArgs
cmd.DisableFlagParsing = true
cmd.PreRunE = func(c *cobra.Command, args []string) error {
c.SilenceUsage = true
return makeErr()
}
cmd.PreRun = nil
cmd.RunE = func(*cobra.Command, []string) error { return makeErr() }
cmd.Run = nil
for _, c := range cmd.Commands() {
walkGuard(c, makeErr)
}
}
// cobraCommandViewSource is the default CommandViewSource: it builds a
// CommandView directly from a *cobra.Command on demand. A future PR
// will snapshot views at registration time (constraint #1 fully) so
// the view survives strict-mode's RemoveCommand+AddCommand replacement
// of the underlying *cobra.Command pointer. For now this is acceptable
// because user-layer pruning preserves the pointer (only strict-mode
// swaps it), and strict-mode-pruned commands are already unreachable
// by the hook chain.
type cobraCommandViewSource struct{}
func (cobraCommandViewSource) View(cmd *cobra.Command) platform.CommandView {
return cobraCommandView{cmd: cmd}
}
// cobraCommandView adapts *cobra.Command to the CommandView interface.
type cobraCommandView struct {
cmd *cobra.Command
}
func (v cobraCommandView) Path() string {
return pruning.CanonicalPath(v.cmd)
}
func (v cobraCommandView) Domain() string {
// cmdmeta inheritance is implemented in internal/cmdmeta; we
// re-read annotations directly here to keep the import surface
// small. Future PR may pull cmdmeta into the View.
for c := v.cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if v, ok := c.Annotations["cmdmeta.domain"]; ok && v != "" {
return v
}
}
return ""
}
func (v cobraCommandView) Risk() (string, bool) {
for c := v.cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if r, ok := c.Annotations["risk_level"]; ok && r != "" {
return r, true
}
}
return "", false
}
func (v cobraCommandView) Identities() []string {
for c := v.cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if raw, ok := c.Annotations["lark:supportedIdentities"]; ok && raw != "" {
return splitCSV(raw)
}
}
return nil
}
func (v cobraCommandView) Annotation(key string) (string, bool) {
if v.cmd.Annotations == nil {
return "", false
}
s, ok := v.cmd.Annotations[key]
return s, ok
}
// splitCSV is a tiny csv-without-quotes helper. CommandView is on the
// hot path (one lookup per command invocation) and we want to avoid
// pulling strings.Split's allocation cost; the lark:supportedIdentities
// annotation is always plain "user" / "bot" / "user,bot" without
// escaping.
func splitCSV(s string) []string {
out := []string{}
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ',' {
out = append(out, s[start:i])
start = i + 1
}
}
out = append(out, s[start:])
return out
}
// userPolicyPath returns the absolute path of ~/.lark-cli/policy.yml,
// or an error if the user's home directory cannot be determined.
func userPolicyPath() (string, error) {
home, err := vfs.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".lark-cli", userPolicyFileName), nil
}
// warnPolicyError writes a one-line stderr warning when the user policy
// fails to load. V1 yaml errors are fail-OPEN -- the CLI keeps running
// without pruning so the user can fix the typo. Plugin-supplied rules
// (Hook surface, future) will be fail-CLOSED instead because integrators
// take a code-level responsibility for them.
func warnPolicyError(errOut io.Writer, err error) {
if err == nil {
return
}
fmt.Fprintf(errOut, "warning: user policy not applied: %v\n", err)
}

259
cmd/policy_test.go Normal file
View File

@@ -0,0 +1,259 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// tmpHome creates a tempdir, points $HOME at it, and returns the path to
// the ~/.lark-cli/ subdirectory (created). The HOME env var is restored
// when the test ends.
func tmpHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir) // Windows fallback for os.UserHomeDir
cfgDir := filepath.Join(dir, ".lark-cli")
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
return cfgDir
}
// writePolicy writes a policy.yml into the user config dir.
func writePolicy(t *testing.T, cfgDir string, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"), []byte(body), 0o644); err != nil {
t.Fatalf("write policy: %v", err)
}
}
// fakeTree builds a minimal command tree with the same shape the real
// CLI exposes for these tests: lark-cli has a docs group with +fetch and
// +update, and an im group with +send. Each leaf has its risk_level set
// so MaxRisk filtering exercises a real path.
func fakeTree(t *testing.T) *cobra.Command {
t.Helper()
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
addLeaf(docs, "+fetch", "read")
addLeaf(docs, "+update", "write")
addLeaf(docs, "+delete-doc", "high-risk-write")
im := &cobra.Command{Use: "im"}
root.AddCommand(im)
addLeaf(im, "+send", "write")
return root
}
func addLeaf(parent *cobra.Command, use, risk string) {
leaf := &cobra.Command{
Use: use,
RunE: func(*cobra.Command, []string) error { return nil },
}
cmdutil.SetRisk(leaf, risk)
parent.AddCommand(leaf)
}
// findLeaf walks the tree by Use names.
func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
t.Helper()
cur := parent
for _, n := range names {
var next *cobra.Command
for _, c := range cur.Commands() {
if c.Use == n {
next = c
break
}
}
if next == nil {
t.Fatalf("child %q not found under %q", n, cur.Use)
}
cur = next
}
return cur
}
// Happy path: a valid policy.yml denies one specific command. The denied
// command's RunE returns a typed ExitError envelope; allowed commands are
// untouched.
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
cfgDir := tmpHome(t)
writePolicy(t, cfgDir, `
name: test-policy
allow: ["docs/**", "contact/**"]
deny: ["docs/+delete-doc"]
max_risk: write
`)
root := fakeTree(t)
if err := applyUserPolicyPruning(root, nil); err != nil {
t.Fatalf("apply policy: %v", err)
}
// docs/+delete-doc must be denied (Deny match).
deleteCmd := findLeaf(t, root, "docs", "+delete-doc")
if !deleteCmd.Hidden {
t.Errorf("+delete-doc should be hidden after pruning")
}
err := deleteCmd.RunE(deleteCmd, nil)
if err == nil {
t.Fatalf("+delete-doc RunE should return an error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "pruning" {
t.Fatalf("expected pruning ExitError, got %T %+v", err, err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok || detail["reason_code"] != "command_denylisted" {
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
}
// im/+send must be denied (domain not in Allow).
send := findLeaf(t, root, "im", "+send")
if !send.Hidden {
t.Errorf("im/+send should be hidden (not in Allow)")
}
// docs/+update must stay alive (domain matches, risk within max).
update := findLeaf(t, root, "docs", "+update")
if update.Hidden {
t.Errorf("docs/+update should remain visible")
}
if err := update.RunE(update, nil); err != nil {
t.Errorf("docs/+update RunE should succeed, got %v", err)
}
}
// Missing file means no pruning -- the CLI runs unrestricted with the
// full command surface. This is the default case for users who haven't
// opted into pruning.
func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
tmpHome(t) // home set but no policy.yml written
root := fakeTree(t)
if err := applyUserPolicyPruning(root, nil); err != nil {
t.Fatalf("missing policy should not error, got %v", err)
}
// Every leaf must remain non-Hidden.
for _, sub := range []string{"+fetch", "+update", "+delete-doc"} {
cmd := findLeaf(t, root, "docs", sub)
if cmd.Hidden {
t.Errorf("%s should not be Hidden when no policy file exists", sub)
}
}
}
// Invalid yaml content (parse error) surfaces as an error from the
// wiring. The build path then decides whether to fail-open or
// fail-closed; the wiring itself stays neutral.
func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
cfgDir := tmpHome(t)
writePolicy(t, cfgDir, "::: not yaml :::")
root := fakeTree(t)
err := applyUserPolicyPruning(root, nil)
if err == nil {
t.Fatalf("malformed yaml should produce an error")
}
}
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
// Resolve and produces an error. This is the safety contract: a typo in
// the rule must not silently lower the pruning bar.
func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
cfgDir := tmpHome(t)
writePolicy(t, cfgDir, "max_risk: nukem\n")
root := fakeTree(t)
err := applyUserPolicyPruning(root, nil)
if err == nil {
t.Fatalf("invalid MaxRisk should produce an error")
}
}
// warnPolicyError emits to the supplied writer when err is non-nil and
// stays silent for nil. Verifies the build.go fail-open behaviour can be
// observed by users.
func TestWarnPolicyError(t *testing.T) {
var buf bytes.Buffer
warnPolicyError(&buf, nil)
if buf.Len() != 0 {
t.Fatalf("warnPolicyError with nil err should write nothing, got %q", buf.String())
}
buf.Reset()
warnPolicyError(&buf, errors.New("boom"))
if buf.String() != "warning: user policy not applied: boom\n" {
t.Fatalf("warnPolicyError output = %q", buf.String())
}
}
// End-to-end through buildInternal: when a valid policy.yml exists in
// HOME, building the real command tree applies pruning to it. This is
// the "actually integrated" test -- it exercises the wiring point in
// build.go itself, not just the helper.
func TestBuildInternal_appliesPolicyToRealTree(t *testing.T) {
cfgDir := tmpHome(t)
// Deny one specific shortcut path that we know exists in the real
// service tree -- we cannot enumerate it from a unit test, so we
// use an Allow-list that matches nothing to deny everything except
// the root, and then verify ANY non-root command was hidden.
writePolicy(t, cfgDir, `
name: deny-everything
deny: ["**"]
`)
root := Build(context.Background(), buildInvocationForTest(t))
// Find any leaf and verify it was hidden.
var foundHidden bool
walk(root, func(c *cobra.Command) {
if c.HasParent() && c.Runnable() && c.Hidden {
foundHidden = true
}
})
if !foundHidden {
t.Fatalf("expected at least one runnable command to be Hidden after deny=** policy")
}
// Root itself must stay alive.
if root.Hidden {
t.Errorf("root command must not be Hidden even under deny-everything policy")
}
}
func walk(cmd *cobra.Command, fn func(*cobra.Command)) {
if cmd == nil {
return
}
fn(cmd)
for _, c := range cmd.Commands() {
walk(c, fn)
}
}
// buildInvocationForTest returns a minimal cmdutil.InvocationContext so
// build.go's pure-assembly path can construct a tree without touching
// real config / credentials. Profile name is the empty default.
func buildInvocationForTest(t *testing.T) cmdutil.InvocationContext {
t.Helper()
return cmdutil.InvocationContext{}
}

View File

@@ -10,6 +10,8 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policydecision"
"github.com/larksuite/cli/internal/pruning"
"github.com/spf13/cobra"
)
@@ -43,11 +45,21 @@ func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
}
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
// The denial annotations let the hook layer's populateInvocationDenial
// recognise this command as denied, so the Wrap chain is physically
// isolated (wrapRunE takes the DeniedByPolicy branch and calls the
// stub RunE directly). Without these, a plugin Wrapper registered
// against platform.All() could intercept and silently swallow the
// strict-mode error -- breaking strict-mode's "hard boundary" contract.
return &cobra.Command{
Use: child.Use,
Aliases: append([]string(nil), child.Aliases...),
Hidden: true,
DisableFlagParsing: true,
Annotations: map[string]string{
pruning.AnnotationDenialLayer: policydecision.LayerStrictMode,
pruning.AnnotationDenialSource: "strict-mode",
},
RunE: func(cmd *cobra.Command, args []string) error {
return output.ErrWithHint(output.ExitValidation, "strict_mode",
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),

View File

@@ -9,6 +9,8 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/policydecision"
"github.com/larksuite/cli/internal/pruning"
"github.com/spf13/cobra"
)
@@ -198,3 +200,28 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T
t.Fatalf("unexpected error: %v", err)
}
}
// strictModeStubFrom must write the denial annotations so the hook
// layer's populateInvocationDenial recognises the command as denied
// and physically isolates the Wrap chain. Without this, a plugin
// Wrapper registered against platform.All() could intercept the stub
// and silently return nil, swallowing the strict-mode error.
func TestStrictModeStub_HasDenialAnnotation(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
// im/+search is user-only -> replaced by a stub in StrictModeBot.
stub := findCmd(root, "im", "+search")
if stub == nil {
t.Fatalf("expected im/+search stub to exist")
}
got := stub.Annotations[pruning.AnnotationDenialLayer]
if got != policydecision.LayerStrictMode {
t.Errorf("stub annotation %q = %q, want %q",
pruning.AnnotationDenialLayer, got, policydecision.LayerStrictMode)
}
if src := stub.Annotations[pruning.AnnotationDenialSource]; src != "strict-mode" {
t.Errorf("stub annotation %q = %q, want %q",
pruning.AnnotationDenialSource, src, "strict-mode")
}
}

View File

@@ -14,10 +14,12 @@ import (
"os"
"strconv"
"github.com/larksuite/cli/extension/platform"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
@@ -88,8 +90,9 @@ func Execute() int {
}
configureFlagCompletions(os.Args)
f, rootCmd := buildInternal(
context.Background(), inv,
ctx := context.Background()
f, rootCmd, reg := buildInternal(
ctx, inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
)
@@ -99,8 +102,18 @@ func Execute() int {
setupNotices()
}
if err := rootCmd.Execute(); err != nil {
return handleRootError(f, err)
runErr := rootCmd.Execute()
// Fire Shutdown lifecycle hooks regardless of run outcome.
// emitShutdown imposes a 2s total deadline and never propagates handler
// errors (Emit's documented Shutdown contract), so it cannot block exit
// or alter the user-visible exit code.
if reg != nil && !isCompletionCommand(os.Args) {
_ = hook.Emit(ctx, reg, platform.Shutdown, runErr)
}
if runErr != nil {
return handleRootError(f, runErr)
}
return 0
}
@@ -157,11 +170,17 @@ func setupNotices() {
}
// isCompletionCommand returns true if args indicate a shell completion request.
// Update notifications must be suppressed for these to avoid corrupting
// machine-parseable completion output.
// Update notifications and Shutdown lifecycle emits must be suppressed for
// these to avoid corrupting machine-parseable completion output and to avoid
// firing plugin Shutdown handlers on every Tab keystroke.
//
// Cobra dispatches BOTH "__complete" and its alias "__completeNoDesc" through
// the same hidden subcommand (see cobra/completions.go ShellCompRequestCmd /
// ShellCompNoDescRequestCmd). Check both, otherwise bash/zsh completion
// (which often uses NoDesc) silently bypasses the gate.
func isCompletionCommand(args []string) bool {
for _, arg := range args {
if arg == "completion" || arg == "__complete" {
if arg == "completion" || arg == "__complete" || arg == "__completeNoDesc" {
return true
}
}

View File

@@ -330,6 +330,7 @@ func TestConfigureFlagCompletions(t *testing.T) {
{"help flag", []string{"im", "--help"}, true},
{"no args", []string{}, true},
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
{"__completeNoDesc request", []string{"__completeNoDesc", "im", "+send", ""}, false},
{"completion subcommand", []string{"completion", "bash"}, false},
}
for _, tc := range tests {
@@ -342,3 +343,30 @@ func TestConfigureFlagCompletions(t *testing.T) {
})
}
}
// isCompletionCommand must classify BOTH cobra completion aliases as
// completion requests so the Shutdown emit and update-notice paths skip
// shell-completion invocations. __completeNoDesc is an Alias of
// __complete (cobra/completions.go ShellCompNoDescRequestCmd) and
// dispatches the same RunE; bash/zsh completion typically calls the
// NoDesc variant.
func TestIsCompletionCommand(t *testing.T) {
tests := []struct {
name string
args []string
want bool
}{
{"plain command", []string{"im", "+send"}, false},
{"__complete", []string{"__complete", "im"}, true},
{"__completeNoDesc", []string{"__completeNoDesc", "im"}, true},
{"completion subcommand", []string{"completion", "bash"}, true},
{"completion in tail", []string{"foo", "bar", "completion"}, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := isCompletionCommand(tc.args); got != tc.want {
t.Fatalf("isCompletionCommand(%v) = %v, want %v", tc.args, got, tc.want)
}
})
}
}

View File

@@ -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 an *output.ExitError with type "hook" 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 }

View File

@@ -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")
}
}

View File

@@ -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
}

37
extension/platform/doc.go Normal file
View File

@@ -0,0 +1,37 @@
// 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 / ByUnknownRisk / And / Or / Not, etc.); unknown-risk commands never match risk-based selectors, opt in via ByUnknownRisk()
// - Handler - the inner "run the command" function Wrappers compose around
// - Invocation - per-call context passed to handlers (Cmd view + DeniedByPolicy / StrictMode / Identity)
// - AbortError - structured short-circuit error from a Wrapper; framework namespaces HookName
//
// Pruning surface (what Restrict contributes, also consumable from yaml policy):
//
// - Rule - declarative pruning rule (Allow / Deny / MaxRisk / Identities)
// - CommandView - read-only command metadata view (Path / Domain / Risk / Identities)
// - Risk constants - the closed risk taxonomy (read < write < high-risk-write) + RiskRank
// - 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/platformhost, internal/hook and internal/pruning and is not
// importable by third parties.
package platform

View File

@@ -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. It is wire-compatible with the
// output.ExitError envelope via the Layer (== error.type) field and the
// detail map produced by ExitError().
//
// Layer values:
//
// - "strict_mode" -- credential strict-mode rejected the command
// - "pruning" -- 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)
}

View File

@@ -0,0 +1,31 @@
// 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.
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

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
import "time"
// Invocation carries the per-command context a Wrapper or Observer needs.
// Cmd is the read-only snapshot taken before any RunE replacement (see
// CommandView); Args is the actual user input; Started is when the
// outermost RunE wrapper began. Err is populated for After hooks and
// the post-next portion of a Wrapper.
//
// The struct is deliberately NOT a context.Context -- it is data only,
// no cancellation. ctx (from the function signature) is the
// context.Context for cancellation/timeout/trace propagation.
//
// Implementation note: the lazy fields (DeniedByPolicy, Identity, etc.)
// are populated by the framework before any hook fires. Plugins must
// not depend on these being non-zero at construction; they always read
// through the accessor methods which centralise the "is this populated
// yet?" logic.
type Invocation struct {
Cmd CommandView
Args []string
Started time.Time
Err error
// Unexported state populated by the framework. Plugins read it via
// the methods below; direct field access is impossible.
deniedByPolicy bool
denialLayer string // "strict_mode" / "pruning" / ""
denialSource string // "plugin:secaudit" / "yaml" / "strict-mode" / ""
// strictMode is the resolved credential strict-mode value, or
// the empty string when no strict-mode is active. We do not use
// a separate "resolved?" bool: the StrictMode() accessor returns
// ok=false when the lifecycle has not yet resolved this.
strictMode string
strictModeKnown bool
identity string
identityResolved bool
}
// DeniedByPolicy reports whether the command was rejected by either
// strict-mode or user-layer pruning 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.
func (inv *Invocation) DeniedByPolicy() bool { return inv.deniedByPolicy }
// DenialLayer returns the layer that rejected the command:
//
// "" - not denied
// "strict_mode" - credential strict-mode
// "pruning" - user-layer Rule (Plugin.Restrict() or yaml)
//
// Matches the error.type field in the envelope so consumers can route
// recovery logic by this value alone.
func (inv *Invocation) DenialLayer() string { return inv.denialLayer }
// DenialPolicySource returns the specific source identifier
// ("plugin:secaudit", "yaml", "strict-mode") corresponding to the
// denial. Empty when the command was not denied.
func (inv *Invocation) DenialPolicySource() string { return inv.denialSource }
// StrictMode returns the active credential strict-mode value
// ("user", "bot", "off"). ok=false signals "not yet resolved" -- the
// Bootstrap pipeline resolves strict-mode before any hook fires, so in
// practice hooks always see ok=true; the bool exists to keep this
// safe under future reordering.
func (inv *Invocation) StrictMode() (mode string, ok bool) {
return inv.strictMode, inv.strictModeKnown
}
// Identity returns the resolved identity ("user"/"bot") for the
// current command. resolved=false means the framework has not yet
// resolved identity at the call site (Before observers and Wrap entry
// may see this; After observers always see resolved=true).
func (inv *Invocation) Identity() (id string, resolved bool) {
return inv.identity, inv.identityResolved
}
// --- internal setters (lower-case, package-internal) ---
//
// Public callers cannot mutate these fields; the framework uses
// targeted helpers exposed only to internal/hook.
// SetDenial is called by the framework before the hook chain runs.
// Exported with "Internal" prefix to mark "framework-only" intent; it
// is technically importable but lives outside the contract surface.
// Renaming or removing it is not a breaking change.
func (inv *Invocation) InternalSetDenial(deniedByPolicy bool, layer, source string) {
inv.deniedByPolicy = deniedByPolicy
inv.denialLayer = layer
inv.denialSource = source
}
// InternalSetStrictMode populates the strict-mode accessor.
func (inv *Invocation) InternalSetStrictMode(mode string, known bool) {
inv.strictMode = mode
inv.strictModeKnown = known
}
// InternalSetIdentity populates the identity accessor.
func (inv *Invocation) InternalSetIdentity(id string, resolved bool) {
inv.identity = id
inv.identityResolved = resolved
}

View File

@@ -0,0 +1,48 @@
// 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.
// Identity may not yet be resolved at this point -- see
// Invocation.Identity for the lazy-resolution contract.
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
}

View File

@@ -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
}

View File

@@ -0,0 +1,65 @@
// 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()
}
// ResetForTesting clears the registry. Test code uses this to isolate
// test cases that register plugins. It is exported to test packages
// only by convention; production code never calls it.
func ResetForTesting() {
pluginRegistry.reset()
}
// pluginRegistry is the package-level singleton. The mutex protects
// concurrent Register calls -- harmless in practice (init runs
// serially) but cheap insurance.
var pluginRegistry = &registry{}
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
}

View File

@@ -0,0 +1,51 @@
// 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()
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")
}
}

View File

@@ -0,0 +1,36 @@
// 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 at most once per plugin; multiple 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. The framework merges it
// with the yaml-sourced Rule using single-rule semantics: plugin
// rule wins, but two plugins both calling Restrict abort startup.
Restrict(r *Rule)
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
// Rule is the declarative pruning rule data structure. yaml files and (once
// the Hook surface lands) 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/pruning.
//
// 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/pruning/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"`
}

View File

@@ -0,0 +1,141 @@
// 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 string) Selector {
return func(cmd CommandView) bool {
for _, x := range cmd.Identities() {
if x == id {
return true
}
}
return false
}
}
// All risk-based selectors below share a single contract: **commands
// without a risk_level annotation (unknown) NEVER match.** Many commands
// in the repo are unannotated; a "unknown = match" semantics would force
// safety / approval plugins to silently cover the whole CLI surface,
// punishing integrators rather than helping. Plugin authors who do want
// to cover unannotated commands should compose explicitly:
//
// platform.ByWrite().Or(platform.ByUnknownRisk())
//
// This makes the safety widening opt-in and visible at the call site.
// ByExactRisk matches commands whose declared risk level is exactly
// level. Unknown (no annotation) does not match.
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".
// Unknown does not match.
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". Unknown does not
// match.
func ByReadOnly() Selector {
return func(cmd CommandView) bool {
v, ok := cmd.Risk()
return ok && v == RiskRead
}
}
// ByUnknownRisk matches commands that carry no risk_level annotation.
// The intended use is opt-in safety widening via composition, e.g.
//
// platform.ByWrite().Or(platform.ByUnknownRisk())
//
// for an approval gate that wants to also cover commands a developer
// forgot to annotate. Use sparingly: matching unknown by default would
// rope in every unannotated subcommand including reads.
func ByUnknownRisk() Selector {
return func(cmd CommandView) bool {
_, ok := cmd.Risk()
return !ok
}
}
// And composes selectors with AND semantics.
func (s Selector) And(other Selector) Selector {
return func(cmd CommandView) bool {
return s(cmd) && other(cmd)
}
}
// Or composes selectors with OR semantics.
func (s Selector) Or(other Selector) Selector {
return func(cmd CommandView) bool {
return s(cmd) || other(cmd)
}
}
// Not negates the selector.
func (s Selector) Not() Selector {
return func(cmd CommandView) bool {
return !s(cmd)
}
}
func newStringSet(items []string) map[string]bool {
out := make(map[string]bool, len(items))
for _, x := range items {
out[x] = true
}
return out
}

View File

@@ -0,0 +1,167 @@
// 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() (string, bool) { return v.risk, v.riskOK }
func (v fakeView) Identities() []string { return v.identities }
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)")
}
}
// All risk-based selectors share one contract: unknown (no annotation)
// never matches. This test pins each one. A plugin author who wants the
// previous "safety-side" semantics must compose .Or(ByUnknownRisk())
// explicitly -- covered by TestByUnknownRisk_optInSafety below.
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")
}
}
// ByUnknownRisk is the explicit opt-in for safety-side widening. A
// plugin that wants the previous "match-write-or-unknown" behaviour
// composes ByWrite().Or(ByUnknownRisk()).
func TestByUnknownRisk_optInSafety(t *testing.T) {
if !platform.ByUnknownRisk()(fakeView{riskOK: false}) {
t.Errorf("unannotated command should match ByUnknownRisk")
}
if platform.ByUnknownRisk()(fakeView{risk: "write", riskOK: true}) {
t.Errorf("annotated command must not match ByUnknownRisk")
}
// Composition: safety-side widening of ByWrite.
safeWrite := platform.ByWrite().Or(platform.ByUnknownRisk())
if !safeWrite(fakeView{risk: "write", riskOK: true}) {
t.Errorf("annotated write should match safeWrite")
}
if !safeWrite(fakeView{riskOK: false}) {
t.Errorf("unknown should match safeWrite via Or(ByUnknownRisk)")
}
if safeWrite(fakeView{risk: "read", riskOK: true}) {
t.Errorf("annotated read must not match safeWrite")
}
}
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) {
// Defensive: a nil Selector passed to And/Or would panic if not
// guarded. The current impl does not nil-check; this test pins
// that nil composition panics so a future maintainer knows to add
// guards if they relax the convention.
defer func() { _ = recover() }()
var s platform.Selector
_ = s.And(platform.All())
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
// Risk is the three-tier risk taxonomy. Aliased to string (not a defined
// type) so plugin authors can use either the constants below or raw literals
// without conversion friction.
type Risk = string
const (
RiskRead Risk = "read"
RiskWrite Risk = "write"
RiskHighRiskWrite Risk = "high-risk-write"
)
// Identity values supported by the framework. Aliased to string for the same
// reason as Risk.
type Identity = string
const (
IdentityUser Identity = "user"
IdentityBot Identity = "bot"
)
// riskOrder maps the Risk taxonomy to a comparable rank. Used by the pruning
// engine's MaxRisk check: c.Risk <= MaxRisk holds when riskOrder[c.Risk] <=
// riskOrder[MaxRisk]. Defined here so the public taxonomy and the comparable
// ordering live next to each other; unknown levels return -1 so callers
// can detect "this is not a recognised risk".
var riskOrder = map[Risk]int{
RiskRead: 0,
RiskWrite: 1,
RiskHighRiskWrite: 2,
}
// RiskRank returns a comparable rank for a Risk value. ok=false when the
// value is not one of the three recognised constants.
func RiskRank(r Risk) (rank int, ok bool) {
rank, ok = riskOrder[r]
return rank, ok
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform_test
import (
"errors"
"testing"
"github.com/larksuite/cli/extension/platform"
)
func TestRiskRank_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 := platform.RiskRank(c.level)
if !ok || got != c.want {
t.Errorf("RiskRank(%q) = (%d,%v), want (%d,true)", c.level, got, ok, c.want)
}
}
if _, ok := platform.RiskRank("unknown-level"); ok {
t.Fatalf("RiskRank('unknown-level') ok should be false")
}
if _, ok := platform.RiskRank(""); ok {
t.Fatalf("RiskRank('') ok should be false (signals 'no risk annotation')")
}
}
// The Risk ordering must be strict: read < write < high-risk-write. The
// pruning engine compares ranks; a regression that swaps the order would
// silently let high-risk commands pass under MaxRisk=write.
func TestRiskRank_strictlyMonotonic(t *testing.T) {
r1, _ := platform.RiskRank(platform.RiskRead)
r2, _ := platform.RiskRank(platform.RiskWrite)
r3, _ := platform.RiskRank(platform.RiskHighRiskWrite)
if !(r1 < r2 && r2 < r3) {
t.Fatalf("Risk ranks not monotonic: read=%d write=%d high=%d", r1, r2, r3)
}
}
func TestCommandDeniedError_messageFormats(t *testing.T) {
withReason := &platform.CommandDeniedError{
Path: "docs/+update",
Layer: "pruning",
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")
}
}

View File

@@ -0,0 +1,45 @@
// 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 pruning engine. *cobra.Command is deliberately NOT reachable
// through this interface -- a plugin should never mutate the command tree.
//
// snapshot rules (enforced by hard-constraint #1 in the tech doc):
//
// - CommandView is a snapshot, not a live proxy. The implementation captures
// metadata before any RunE replacement happens, keyed by canonical slash
// path. Strict-mode's RemoveCommand+AddCommand pattern changes pointers
// but not paths, so the snapshot survives.
//
// - 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. Consumers
// interpret this themselves -- the pruning engine treats it as ALLOW,
// and risk-based Selectors do not match unknown either. A safety-side
// hook that wants to cover unannotated commands composes explicitly:
// ByWrite().Or(ByUnknownRisk()).
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)
}

1
go.mod
View File

@@ -26,6 +26,7 @@ require (
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect

2
go.sum
View File

@@ -8,6 +8,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=

132
internal/cmdmeta/meta.go Normal file
View File

@@ -0,0 +1,132 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package cmdmeta is the single source of truth for command metadata that the
// pruning engine and (later) the hook selector both consume. It wraps the
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need.
//
// Three axes:
//
// - Domain - business domain ("im", "docs", "contact", ...). Inherited
// from the nearest ancestor when not set on the command
// itself. Stored on a new annotation key (the cmdutil
// risk_level / supportedIdentities keys are left untouched
// for backward compatibility).
// - Risk - "read" | "write" | "high-risk-write". Inherited like
// Domain. Reuses cmdutil.SetRisk / GetRisk under the hood.
// - Identities - allowed identity set. Child explicit override semantics:
// the first ancestor (including self) with a non-nil set
// wins. Reuses cmdutil.SetSupportedIdentities /
// GetSupportedIdentities.
//
// Missing values are returned as the zero value with ok=false (where the
// signature exposes it). The pruning engine's contract says unknown defaults
// to ALLOW (do not synthesise a default value here -- let the consumer decide
// how to interpret unknown).
package cmdmeta
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
// domainAnnotationKey is the cobra Annotation key for the business domain.
// Kept distinct from cmdutil.* keys so this package can evolve without
// disturbing existing readers.
const domainAnnotationKey = "cmdmeta.domain"
// Meta groups the three command-level metadata axes consumed by pruning and
// hook selectors.
type Meta struct {
Domain string
Risk string
Identities []string
}
// Apply writes metadata onto a cobra command. Empty fields are skipped: pass
// the value via the underlying cmdutil setter if you need to write an empty
// string / empty slice explicitly.
func Apply(cmd *cobra.Command, m Meta) {
if m.Domain != "" {
SetDomain(cmd, m.Domain)
}
if m.Risk != "" {
cmdutil.SetRisk(cmd, m.Risk)
}
if m.Identities != nil {
cmdutil.SetSupportedIdentities(cmd, m.Identities)
}
}
// Get resolves the effective metadata for a command, walking up the parent
// chain for Domain, Risk, and Identities. All three axes use the same
// nearest-ancestor-wins rule.
//
// Identities note: cmdutil.GetSupportedIdentities collapses both the
// "annotation absent" and "annotation set to empty string" cases to nil.
// A child cannot therefore express "deny inheritance" with an empty
// annotation; the walk simply continues up the parent chain when nil is
// returned. To override a parent, the child must set a non-empty slice
// (e.g. ["bot"]).
func Get(cmd *cobra.Command) Meta {
risk, _ := Risk(cmd)
return Meta{
Domain: Domain(cmd),
Risk: risk,
Identities: Identities(cmd),
}
}
// SetDomain stores the domain annotation on a single command (no
// inheritance is performed on write).
func SetDomain(cmd *cobra.Command, domain string) {
if domain == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[domainAnnotationKey] = domain
}
// Domain returns the nearest-ancestor domain for the command. Empty string
// when no ancestor has the annotation -- this is the "unknown" state the
// pruning engine must treat as ALLOW.
func Domain(cmd *cobra.Command) string {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if v, ok := c.Annotations[domainAnnotationKey]; ok && v != "" {
return v
}
}
return ""
}
// Risk returns the nearest-ancestor risk level (via cmdutil.GetRisk).
// ok=false signals "unknown" -- pruning treats this as ALLOW by contract.
func Risk(cmd *cobra.Command) (level string, ok bool) {
for c := cmd; c != nil; c = c.Parent() {
if level, ok = cmdutil.GetRisk(c); ok {
return level, true
}
}
return "", false
}
// Identities returns the first non-nil identity set found while walking up
// the parent chain. nil signals "unknown" -- pruning treats this as ALLOW.
//
// cmdutil.GetSupportedIdentities returns nil when the annotation is absent
// or empty; an explicit non-empty set (even ["user"] alone) stops the walk.
func Identities(cmd *cobra.Command) []string {
for c := cmd; c != nil; c = c.Parent() {
if ids := cmdutil.GetSupportedIdentities(c); ids != nil {
return ids
}
}
return nil
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdmeta_test
import (
"reflect"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
)
func TestApply_writesAllFields(t *testing.T) {
cmd := &cobra.Command{Use: "fetch"}
cmdmeta.Apply(cmd, cmdmeta.Meta{
Domain: "docs",
Risk: "write",
Identities: []string{"user", "bot"},
})
if got := cmdmeta.Domain(cmd); got != "docs" {
t.Fatalf("Domain = %q, want %q", got, "docs")
}
if got, ok := cmdmeta.Risk(cmd); !ok || got != "write" {
t.Fatalf("Risk = (%q,%v), want (%q,true)", got, ok, "write")
}
if got := cmdmeta.Identities(cmd); !reflect.DeepEqual(got, []string{"user", "bot"}) {
t.Fatalf("Identities = %v, want [user bot]", got)
}
}
func TestApply_emptyFieldsSkipped(t *testing.T) {
cmd := &cobra.Command{Use: "fetch"}
cmdmeta.Apply(cmd, cmdmeta.Meta{}) // nothing
if got := cmdmeta.Domain(cmd); got != "" {
t.Fatalf("Domain expected unset, got %q", got)
}
if _, ok := cmdmeta.Risk(cmd); ok {
t.Fatalf("Risk expected unset")
}
if got := cmdmeta.Identities(cmd); got != nil {
t.Fatalf("Identities expected nil, got %v", got)
}
}
// Domain inherits from the nearest ancestor; risk and identities behave the
// same way. We verify each axis with a 3-level tree:
//
// root (domain=docs, risk=read, identities=[user])
// group
// leaf
func TestGet_inheritsFromAncestor(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
group := &cobra.Command{Use: "docs"}
leaf := &cobra.Command{Use: "fetch"}
root.AddCommand(group)
group.AddCommand(leaf)
cmdmeta.Apply(root, cmdmeta.Meta{
Domain: "docs",
Risk: "read",
Identities: []string{"user"},
})
got := cmdmeta.Get(leaf)
want := cmdmeta.Meta{
Domain: "docs",
Risk: "read",
Identities: []string{"user"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Get(leaf) = %+v, want %+v", got, want)
}
}
// Closest ancestor wins -- a mid-level override is preferred over root.
func TestGet_nearestAncestorWins(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
group := &cobra.Command{Use: "docs"}
leaf := &cobra.Command{Use: "fetch"}
root.AddCommand(group)
group.AddCommand(leaf)
cmdmeta.SetDomain(root, "docs")
cmdmeta.SetDomain(group, "docs-override")
cmdutil.SetRisk(root, "read")
cmdutil.SetRisk(group, "high-risk-write")
if got := cmdmeta.Domain(leaf); got != "docs-override" {
t.Fatalf("Domain = %q, want docs-override (nearest)", got)
}
if got, _ := cmdmeta.Risk(leaf); got != "high-risk-write" {
t.Fatalf("Risk = %q, want high-risk-write (nearest)", got)
}
}
// Unknown axes return zero / nil so the pruning engine can apply the
// "unknown => ALLOW" contract.
func TestGet_unknownReturnsZero(t *testing.T) {
cmd := &cobra.Command{Use: "orphan"}
if got := cmdmeta.Domain(cmd); got != "" {
t.Fatalf("Domain = %q, want empty for unknown", got)
}
if level, ok := cmdmeta.Risk(cmd); ok || level != "" {
t.Fatalf("Risk = (%q,%v), want empty / false for unknown", level, ok)
}
if ids := cmdmeta.Identities(cmd); ids != nil {
t.Fatalf("Identities = %v, want nil for unknown", ids)
}
}
// Child explicitly overriding identities stops the parent walk.
func TestIdentities_childOverridesParent(t *testing.T) {
parent := &cobra.Command{Use: "docs"}
child := &cobra.Command{Use: "preview"}
parent.AddCommand(child)
cmdutil.SetSupportedIdentities(parent, []string{"user", "bot"})
cmdutil.SetSupportedIdentities(child, []string{"bot"})
got := cmdmeta.Identities(child)
if !reflect.DeepEqual(got, []string{"bot"}) {
t.Fatalf("Identities(child) = %v, want [bot]", got)
}
}
// SetDomain with empty value is a no-op (no annotation written, so a
// later inherited read still works).
func TestSetDomain_emptyIsNoop(t *testing.T) {
parent := &cobra.Command{Use: "docs"}
cmdmeta.SetDomain(parent, "docs")
child := &cobra.Command{Use: "fetch"}
parent.AddCommand(child)
cmdmeta.SetDomain(child, "") // no-op
if got := cmdmeta.Domain(child); got != "docs" {
t.Fatalf("Domain(child) = %q, want inherited 'docs'", got)
}
}

20
internal/hook/doc.go Normal file
View File

@@ -0,0 +1,20 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package hook is the internal Hook dispatch implementation. It owns:
//
// - Registry the in-memory data store mapping (Stage|Event) ->
// registered hooks for fast dispatch
// - Install(root, …) the entry point that wraps every command's RunE
// so Before/After Observers and Wrap chains fire
// around the command's business logic, including
// the denial guard that physically isolates
// pruned commands from Wrap.
// - Emit(event, …) the lifecycle event firing helper used by the
// Bootstrap pipeline.
//
// Plugins NEVER import this package -- they only ever see
// extension/platform. The Registrar contract is implemented inside
// internal/platformhost, which delegates to this Registry after
// validating the plugin's calls (staging + atomic commit).
package hook

130
internal/hook/emit.go Normal file
View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/extension/platform"
)
// shutdownDeadline is the hard upper bound on how long Shutdown
// handlers in total may run. Past this, the framework returns control
// to the caller regardless of unfinished handlers. 2s matches the
// design-doc constraint.
const shutdownDeadline = 2 * time.Second
// LifecycleError is the typed failure returned by Emit for non-Shutdown
// events when a LifecycleHandler returns an error or panics. Callers can
// errors.As to extract HookName, Event, and the Panic discriminator
// (panic vs returned error) so the envelope writer can produce
// distinct reason_code values:
//
// - Panic == false -> reason_code = "lifecycle_failed"
// - Panic == true -> reason_code = "lifecycle_panic"
//
// Shutdown handler failures are logged inside emitShutdown and never
// returned through this type (Shutdown is non-recoverable; the contract
// is "best effort, never block exit").
type LifecycleError struct {
Event platform.LifecycleEvent
HookName string
Panic bool
Cause error
}
func (e *LifecycleError) Error() string {
kind := "failed"
if e.Panic {
kind = "panic"
}
return fmt.Sprintf("lifecycle hook %q %s: %v", e.HookName, kind, e.Cause)
}
func (e *LifecycleError) Unwrap() error { return e.Cause }
// Emit fires every LifecycleHandler registered for event in
// registration order. lastErr is propagated to handlers via
// LifecycleContext.Err (typical use: Shutdown handlers see the error
// the command exited with).
//
// Behaviour by event:
//
// - Startup: any handler returning a non-nil error aborts the
// bootstrap (caller decides whether to fail-closed). The first
// such error is returned as *LifecycleError.
//
// - Shutdown: handler errors are logged but do not affect the
// returned error; the framework also caps the total time at
// shutdownDeadline.
func Emit(ctx context.Context, reg *Registry, event platform.LifecycleEvent, lastErr error) error {
if reg == nil {
return nil
}
handlers := reg.LifecycleHandlers(event)
if len(handlers) == 0 {
return nil
}
lc := &platform.LifecycleContext{Event: event, Err: lastErr}
if event == platform.Shutdown {
return emitShutdown(ctx, handlers, lc)
}
for _, h := range handlers {
if err := callLifecycleSafe(ctx, h, lc); err != nil {
return err
}
}
return nil
}
// emitShutdown enforces the 2-second total deadline. Handlers receive
// a derived context with the remaining budget; once the budget is
// exhausted, the remaining handlers are skipped (with a stderr
// warning) and Emit returns.
func emitShutdown(parent context.Context, handlers []LifecycleEntry, lc *platform.LifecycleContext) error {
ctx, cancel := context.WithTimeout(parent, shutdownDeadline)
defer cancel()
deadline := time.Now().Add(shutdownDeadline)
for _, h := range handlers {
if time.Now().After(deadline) {
fmt.Fprintf(stderr(), "warning: shutdown deadline exceeded; skipping hook %q\n", h.Name)
continue
}
if err := callLifecycleSafe(ctx, h, lc); err != nil {
// Shutdown errors are logged, not propagated -- exit is
// non-recoverable anyway.
fmt.Fprintf(stderr(), "warning: shutdown hook %q: %v\n", h.Name, err)
}
}
return nil
}
// callLifecycleSafe invokes a LifecycleHandler with panic recovery.
// Returns *LifecycleError with Panic=true on recovered panic, Panic=false
// on a regular returned error. nil if the handler succeeded.
func callLifecycleSafe(ctx context.Context, h LifecycleEntry, lc *platform.LifecycleContext) (err error) {
defer func() {
if r := recover(); r != nil {
err = &LifecycleError{
Event: lc.Event,
HookName: h.Name,
Panic: true,
Cause: fmt.Errorf("%v", r),
}
}
}()
if e := h.Fn(ctx, lc); e != nil {
return &LifecycleError{
Event: lc.Event,
HookName: h.Name,
Panic: false,
Cause: e,
}
}
return nil
}

110
internal/hook/emit_test.go Normal file
View File

@@ -0,0 +1,110 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import (
"context"
"errors"
"testing"
"github.com/larksuite/cli/extension/platform"
)
// A Startup handler returning a regular error must surface as a typed
// *LifecycleError with Panic=false so the cmd-layer guard can pick
// reason_code=lifecycle_failed.
func TestEmit_StartupHandlerError_TypedError(t *testing.T) {
reg := NewRegistry()
want := errors.New("backend down")
reg.AddLifecycle(LifecycleEntry{
Event: platform.Startup,
Name: "p.boot",
Fn: func(context.Context, *platform.LifecycleContext) error { return want },
})
got := Emit(context.Background(), reg, platform.Startup, nil)
if got == nil {
t.Fatal("expected error from Emit, got nil")
}
var le *LifecycleError
if !errors.As(got, &le) {
t.Fatalf("expected *LifecycleError, got %T %v", got, got)
}
if le.Panic {
t.Errorf("Panic = true, want false (returned error)")
}
if le.HookName != "p.boot" {
t.Errorf("HookName = %q, want p.boot", le.HookName)
}
if !errors.Is(got, want) {
t.Errorf("unwrap should reach original error")
}
}
// A Startup handler that panics must be recovered and surface as a
// typed *LifecycleError with Panic=true so the cmd-layer guard can
// pick reason_code=lifecycle_panic.
func TestEmit_StartupHandlerPanic_TypedError(t *testing.T) {
reg := NewRegistry()
reg.AddLifecycle(LifecycleEntry{
Event: platform.Startup,
Name: "p.boot",
Fn: func(context.Context, *platform.LifecycleContext) error { panic("boom") },
})
got := Emit(context.Background(), reg, platform.Startup, nil)
if got == nil {
t.Fatal("expected error from Emit, got nil")
}
var le *LifecycleError
if !errors.As(got, &le) {
t.Fatalf("expected *LifecycleError, got %T %v", got, got)
}
if !le.Panic {
t.Errorf("Panic = false, want true (recovered panic)")
}
if le.HookName != "p.boot" {
t.Errorf("HookName = %q, want p.boot", le.HookName)
}
}
// A Startup handler that succeeds returns nil; subsequent handlers run.
func TestEmit_StartupAllHandlersRun(t *testing.T) {
reg := NewRegistry()
var calls []string
reg.AddLifecycle(LifecycleEntry{
Event: platform.Startup, Name: "a",
Fn: func(context.Context, *platform.LifecycleContext) error {
calls = append(calls, "a")
return nil
},
})
reg.AddLifecycle(LifecycleEntry{
Event: platform.Startup, Name: "b",
Fn: func(context.Context, *platform.LifecycleContext) error {
calls = append(calls, "b")
return nil
},
})
if err := Emit(context.Background(), reg, platform.Startup, nil); err != nil {
t.Fatalf("Emit: %v", err)
}
if len(calls) != 2 || calls[0] != "a" || calls[1] != "b" {
t.Errorf("handlers fired in unexpected order: %v", calls)
}
}
// Shutdown handler errors are logged, not propagated; Emit returns nil.
func TestEmit_ShutdownErrorsSwallowed(t *testing.T) {
reg := NewRegistry()
reg.AddLifecycle(LifecycleEntry{
Event: platform.Shutdown, Name: "flush",
Fn: func(context.Context, *platform.LifecycleContext) error {
return errors.New("flush failed")
},
})
if err := Emit(context.Background(), reg, platform.Shutdown, nil); err != nil {
t.Errorf("Shutdown errors must NOT propagate, got: %v", err)
}
}

344
internal/hook/install.go Normal file
View File

@@ -0,0 +1,344 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import (
"context"
"errors"
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/output"
)
// Install wraps every runnable command's RunE so the hook chain fires
// around it. The wrapper is:
//
// Before observers (always run, panic-safe)
// denial guard:
// if cmd is denied -> denyStub returns its CommandDeniedError
// else -> compose(matched Wrappers)(originalRunE) runs
// After observers (always run, panic-safe, sees inv.Err)
//
// Critical invariants enforced here (constraint #2):
//
// - **Denied commands NEVER reach the Wrap chain.** The guard runs
// denyStub directly so no plugin Wrapper can suppress or rewrite
// the denial. Observers still fire (audit must see the attempted
// call), but Wrap is physically out of the path.
//
// - **After observers always fire**, even when RunE returned an
// error. Wrap short-circuits via AbortError get converted to
// *output.ExitError so cmd/root.go emits the right envelope.
//
// - **Identity is resolved by the time After observers run.** The
// framework calls invocation.InternalSetIdentity from inside the
// wrapper as soon as the command runner resolves it (today the
// wrapper does not have access to identity resolution, so this is
// stubbed to "" / false for V1 -- future PR will plumb it).
//
// Install must be called once during the Bootstrap pipeline after
// pruning has finished. Calling it twice on the same tree is a bug
// (each command's RunE would be wrapped multiple times).
func Install(root *cobra.Command, reg *Registry, snapshot CommandViewSource) {
if root == nil || reg == nil {
return
}
walkTree(root, func(c *cobra.Command) {
if !c.Runnable() {
return
}
if !c.HasParent() {
return // do not wrap the binary root itself
}
wrapRunE(c, reg, snapshot)
})
}
// CommandViewSource resolves a *cobra.Command into a CommandView. The
// snapshot is taken before pruning installs denyStubs, so reads from
// here continue to see the original metadata even after the pointer
// has been replaced (the cmd/prune.go strict-mode path swaps the
// pointer; the Bootstrap pipeline preserves the snapshot anyway).
type CommandViewSource interface {
View(cmd *cobra.Command) platform.CommandView
}
// wrapRunE replaces cmd.RunE with a hook-aware wrapper. The original
// RunE is captured by closure so the Wrapper chain can still call it
// as the innermost handler.
//
// The wrapper preserves the Run vs RunE distinction: cmd.Run is
// cleared because RunE wins when both are set and leaving a stale Run
// around is a hazard for future maintainers.
func wrapRunE(cmd *cobra.Command, reg *Registry, snapshot CommandViewSource) {
originalRunE := cmd.RunE
originalRun := cmd.Run
cmd.Run = nil
cmd.RunE = func(c *cobra.Command, args []string) error {
view := snapshot.View(c)
inv := &platform.Invocation{
Cmd: view,
Args: args,
Started: time.Now(),
}
// Detect denial: a denied command's original RunE was already
// replaced by pruning.Apply with a denyStub that returns
// *output.ExitError wrapping *platform.CommandDeniedError. We
// invoke originalRunE once with a probe-only context (no args
// matter because DisableFlagParsing is set on denied commands)
// to extract its CommandDeniedError, but for V1 we use a
// simpler shortcut: pruning.Apply itself marks the command
// via cobra annotation; install reads the annotation directly.
populateInvocationDenial(inv, c)
ctx := c.Context()
if ctx == nil {
ctx = context.Background()
}
// === Before observers (panic-safe, always run) ===
for _, obs := range reg.MatchingObservers(view, platform.Before) {
runObserverSafe(ctx, obs, inv)
}
// === Denial guard ===
// If denied, run the originalRunE directly (it is the denyStub
// installed by pruning.Apply). The Wrap chain is bypassed.
var err error
if inv.DeniedByPolicy() {
err = invokeOriginal(ctx, c, args, originalRunE, originalRun)
} else {
// Compose matching Wrappers around the originalRunE. Each
// Wrapper is wrapped with a thin namespacing shim so any
// *AbortError returned has its HookName replaced with the
// framework-namespaced WrapperEntry.Name -- a plugin
// cannot impersonate another plugin's hook even by
// accident.
matched := reg.MatchingWrappers(view)
wrappers := make([]platform.Wrapper, 0, len(matched))
for _, w := range matched {
// Each plugin Wrapper is wrapped twice: once by the
// namespacing shim (AbortError attribution) and once
// by the panic shim (so a plugin panic becomes a
// structured hook envelope instead of crashing the
// process).
wrappers = append(wrappers, recoverWrap(w.Name, namespacedWrap(w.Name, w.Fn)))
}
composed := ComposeWrappers(wrappers)
finalHandler := composed(func(c2 context.Context, i *platform.Invocation) error {
return invokeOriginal(c2, c, i.Args, originalRunE, originalRun)
})
err = finalHandler(ctx, inv)
}
// Convert AbortError -> *output.ExitError so the envelope writer
// renders the structured "hook" type.
err = wrapAbortError(err)
inv.Err = err
// === After observers (panic-safe, always run, including
// when err != nil) ===
for _, obs := range reg.MatchingObservers(view, platform.After) {
runObserverSafe(ctx, obs, inv)
}
return err
}
}
// invokeOriginal runs whatever the original command logic was. If
// originalRunE is non-nil (the common case), use it; otherwise fall
// back to the Run variant. Commands without either are a programming
// error caught at registration time (cmd.Runnable() returns false).
func invokeOriginal(ctx context.Context, c *cobra.Command, args []string, runE func(*cobra.Command, []string) error, run func(*cobra.Command, []string)) error {
if runE != nil {
return runE(c, args)
}
if run != nil {
run(c, args)
return nil
}
return nil
}
// runObserverSafe invokes an Observer with panic recovery. Observers
// must not break the main flow; their job is side-effect-only and a
// broken plugin should not cascade into a failed CLI run.
func runObserverSafe(ctx context.Context, obs ObserverEntry, inv *platform.Invocation) {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(stderr(), "warning: hook %q panicked: %v\n", obs.Name, r)
}
}()
obs.Fn(ctx, inv)
}
// wrapAbortError converts *platform.AbortError into the equivalent
// *output.ExitError so cmd/root.go's envelope writer emits the right
// JSON structure (type="hook"). Non-AbortError values pass through
// unchanged.
func wrapAbortError(err error) error {
if err == nil {
return nil
}
var ab *platform.AbortError
if !errors.As(err, &ab) {
return err
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "hook",
Message: ab.Error(),
Detail: map[string]any{
"hook_name": ab.HookName,
"reason": ab.Reason,
"reason_code": "aborted",
"detail": ab.Detail,
},
},
Err: ab,
}
}
// recoverWrap wraps a Wrapper so any panic anywhere in the plugin's
// implementation -- including the wrapper FACTORY call (the
// `func(next Handler) Handler` step) and the inner Handler call -- is
// recovered and surfaced as a structured *output.ExitError with
// type="hook" and reason_code="panic". Without this guard, a panicking
// plugin would crash the entire CLI process and break the structured-
// error contract (downstream automation cannot parse a stack trace).
//
// The recovered panic keeps the fully-qualified hook name (the same
// namespacing as namespacedWrap below uses) so on-call can pinpoint
// the offending plugin without grepping logs.
//
// **Why the factory call is inside the deferred recover**: a plugin
// can write something like
//
// func(next Handler) Handler {
// state := mustInit() // panics on bad config
// return func(...) error { ... use state ... }
// }
//
// If `mustInit` panics, the panic happens during composition
// (ComposeWrappers -> ws[i](next)) which runs at invocation time inside
// wrapRunE. Without recovering this branch, the whole CLI crashes.
// We pay a tiny per-invocation cost (one factory call per command
// dispatch) in exchange for total panic isolation.
//
// **Factory-local state lifetime contract**: any value the plugin's
// outer factory captures (`state` in the example above) is now created
// PER INVOCATION of the wrapped command -- it is NOT a one-shot init
// the way Plugin.Install is. Plugins that need long-lived state (a
// connection pool, an LRU cache, a metrics counter) MUST hold it on
// the Plugin struct or in a package-level variable; relying on
// closure-local memoisation inside the wrapper factory will silently
// reset on every command dispatch.
func recoverWrap(fullName string, w platform.Wrapper) platform.Wrapper {
return func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv *platform.Invocation) (returned error) {
defer func() {
if r := recover(); r != nil {
returned = &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "hook",
Message: fmt.Sprintf("hook %q panicked: %v", fullName, r),
Detail: map[string]any{
"hook_name": fullName,
"reason_code": "panic",
"reason": fmt.Sprintf("%v", r),
},
},
Err: fmt.Errorf("hook %q panic: %v", fullName, r),
}
}
}()
// Construct AFTER the recover is armed so a panicking
// factory becomes a hook envelope instead of a process
// crash.
inner := w(next)
return inner(ctx, inv)
}
}
}
// namespacedWrap wraps a plugin's Wrapper so any *platform.AbortError it
// returns is replaced with a fresh copy whose HookName is the
// framework-namespaced name (e.g. "policy-plugin.policy"). Plugin
// authors do not need to know their own plugin name; the framework
// attribution is authoritative.
//
// **Why a copy, not mutation**: an AbortError value may be shared
// across concurrent command invocations (e.g. a plugin's package-level
// sentinel). Mutating it would race; copy keeps each invocation's
// attribution isolated.
//
// **Why only top-level AbortError, not wrapped**: a wrapped AbortError
// in a chain via fmt.Errorf("...: %w", ab) would require rebuilding
// the entire chain to substitute the value. The simpler contract --
// "plugin returns AbortError directly to short-circuit" -- is what we
// document, so we only namespace the top-level case. Wrapped
// AbortErrors keep whatever HookName the plugin set; that is still
// surfaced unchanged by the envelope writer.
func namespacedWrap(fullName string, w platform.Wrapper) platform.Wrapper {
return func(next platform.Handler) platform.Handler {
inner := w(next)
return func(ctx context.Context, inv *platform.Invocation) error {
err := inner(ctx, inv)
if err == nil {
return nil
}
if ab, ok := err.(*platform.AbortError); ok {
copied := *ab
copied.HookName = fullName
return &copied
}
return err
}
}
}
// stderr returns the stderr writer the wrapper uses for safe warnings.
// Indirected through a func so tests can substitute it.
var stderr = func() interface{ Write(p []byte) (int, error) } {
// Avoid pulling os just for stderr access -- the real impl lives
// in install_default.go (see file). The function is overridable
// to keep test isolation tight.
return defaultStderr
}
// PopulateInvocationDenial is exported for tests so they can simulate
// the denial signal without a full pruning pipeline. Production code
// goes through populateInvocationDenial which reads the cobra
// annotation set by pruning.Apply.
//
// V1 contract: a denial is signalled by the cobra annotation
// "lark:pruning_denied_layer" being set on the command. The layer
// value is the error.type ("pruning" / "strict_mode"); the source
// follows the annotation "lark:pruning_denied_source".
//
// This indirection lets us avoid an import cycle between hook and
// pruning packages.
func populateInvocationDenial(inv *platform.Invocation, c *cobra.Command) {
const layerKey = "lark:pruning_denied_layer"
const sourceKey = "lark:pruning_denied_source"
if c.Annotations == nil {
return
}
layer, ok := c.Annotations[layerKey]
if !ok || layer == "" {
return
}
source := c.Annotations[sourceKey]
inv.InternalSetDenial(true, layer, source)
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import "os"
// defaultStderr is the real os.Stderr writer. Kept in a separate file so
// tests can replace `stderr` (in install.go) with a buffer without
// shadowing this variable.
var defaultStderr = os.Stderr

View File

@@ -0,0 +1,356 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook_test
import (
"bytes"
"context"
"errors"
"fmt"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
)
// fakeViewSource is a minimal CommandView for tests -- it ignores the
// cobra command and returns a fixed view.
type fakeViewSource struct{ view platform.CommandView }
func (f fakeViewSource) View(*cobra.Command) platform.CommandView { return f.view }
type fakeView struct {
path string
risk string
}
func (v fakeView) Path() string { return v.path }
func (v fakeView) Domain() string { return "" }
func (v fakeView) Risk() (string, bool) { return v.risk, v.risk != "" }
func (v fakeView) Identities() []string { return nil }
func (v fakeView) Annotation(string) (string, bool) { return "", false }
func makeLeaf(use string) *cobra.Command {
return &cobra.Command{Use: use, RunE: func(*cobra.Command, []string) error { return nil }}
}
// Observers fire on Before AND After even when RunE returns an error.
// This is the failure-path observability contract -- After must always
// run so audit hooks see completion regardless of outcome.
func TestInstall_observersBeforeAndAfterAlwaysRun(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error {
return errors.New("boom")
}}
root.AddCommand(leaf)
reg := hook.NewRegistry()
var seen []string
reg.AddObserver(hook.ObserverEntry{
Name: "before", When: platform.Before, Selector: platform.All(),
Fn: func(_ context.Context, inv *platform.Invocation) {
seen = append(seen, fmt.Sprintf("before:err=%v", inv.Err))
},
})
reg.AddObserver(hook.ObserverEntry{
Name: "after", When: platform.After, Selector: platform.All(),
Fn: func(_ context.Context, inv *platform.Invocation) {
seen = append(seen, fmt.Sprintf("after:err=%v", inv.Err))
},
})
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
err := leaf.RunE(leaf, nil)
if err == nil || err.Error() != "boom" {
t.Fatalf("expected RunE to return original error, got %v", err)
}
wantBefore := "before:err=<nil>" // before fires with Err still nil
wantAfter := "after:err=boom" // after sees the failed RunE error
if len(seen) != 2 || seen[0] != wantBefore || seen[1] != wantAfter {
t.Fatalf("observer ordering / Err propagation broken, got %v", seen)
}
}
// Wrap chain composes outermost-first (registration order). A regression
// that inverts the composition would change which Wrapper short-circuits
// first for safety-sensitive layers.
func TestInstall_wrapperChainOrder(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
var order []string
leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error {
order = append(order, "RunE")
return nil
}}
root.AddCommand(leaf)
reg := hook.NewRegistry()
reg.AddWrapper(hook.WrapperEntry{
Name: "outer", Selector: platform.All(),
Fn: func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv *platform.Invocation) error {
order = append(order, "outer-before")
err := next(ctx, inv)
order = append(order, "outer-after")
return err
}
},
})
reg.AddWrapper(hook.WrapperEntry{
Name: "inner", Selector: platform.All(),
Fn: func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv *platform.Invocation) error {
order = append(order, "inner-before")
err := next(ctx, inv)
order = append(order, "inner-after")
return err
}
},
})
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
if err := leaf.RunE(leaf, nil); err != nil {
t.Fatalf("RunE: %v", err)
}
want := []string{"outer-before", "inner-before", "RunE", "inner-after", "outer-after"}
if !equalStrings(order, want) {
t.Fatalf("Wrapper order = %v, want %v", order, want)
}
}
// Denial guard physical isolation: the most safety-critical invariant.
// A denied command must NEVER reach a Wrap chain. We register a Wrap
// that, given the chance, would silently allow the call (return nil,
// don't call next, no AbortError). The guard must skip Wrap entirely
// so the denyStub's error reaches the caller.
//
// Without this guarantee, any plugin Wrap matching All() could
// bypass user policy / strict-mode denials.
func TestInstall_denialGuard_physicalIsolation(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
denyStubCalled := false
leaf := &cobra.Command{
Use: "+forbidden",
RunE: func(*cobra.Command, []string) error {
denyStubCalled = true
return errors.New("CommandPruned: this is the denyStub")
},
Annotations: map[string]string{
"lark:pruning_denied_layer": "pruning",
"lark:pruning_denied_source": "yaml",
},
}
root.AddCommand(leaf)
reg := hook.NewRegistry()
maliciousWrapCalled := false
reg.AddWrapper(hook.WrapperEntry{
Name: "malicious", Selector: platform.All(),
Fn: func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv *platform.Invocation) error {
maliciousWrapCalled = true
return nil // suppress the denial
}
},
})
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+forbidden"}})
err := leaf.RunE(leaf, nil)
if maliciousWrapCalled {
t.Errorf("denial guard violated: Wrap was invoked on a denied command")
}
if !denyStubCalled {
t.Errorf("denyStub (original RunE) should still run on the denial path")
}
if err == nil {
t.Fatalf("denyStub error must propagate, got nil")
}
}
// Observer panics must not break the main flow. The guard converts the
// panic to a stderr warning and continues; the command still runs.
func TestInstall_observerPanicIsolated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
runECalled := false
leaf := &cobra.Command{Use: "+x", RunE: func(*cobra.Command, []string) error {
runECalled = true
return nil
}}
root.AddCommand(leaf)
reg := hook.NewRegistry()
reg.AddObserver(hook.ObserverEntry{
Name: "buggy", When: platform.Before, Selector: platform.All(),
Fn: func(context.Context, *platform.Invocation) {
panic("plugin author wrote bad code")
},
})
// Capture stderr to make sure the warning was emitted.
hook.SetStderrForTesting(&bytes.Buffer{}) // discard
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
if err := leaf.RunE(leaf, nil); err != nil {
t.Fatalf("RunE should still succeed when an Observer panicked, got %v", err)
}
if !runECalled {
t.Errorf("RunE must execute despite Observer panic")
}
}
// A Wrapper returning AbortError surfaces as *output.ExitError with
// type="hook" so cmd/root.go's envelope writer can serialise it.
func TestInstall_abortErrorBecomesExitError(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leaf := makeLeaf("+x")
root.AddCommand(leaf)
reg := hook.NewRegistry()
reg.AddWrapper(hook.WrapperEntry{
Name: "rejecter", Selector: platform.All(),
Fn: func(_ platform.Handler) platform.Handler {
return func(context.Context, *platform.Invocation) error {
return &platform.AbortError{
HookName: "rejecter",
Reason: "policy says no",
}
}
},
})
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
err := leaf.RunE(leaf, nil)
if err == nil {
t.Fatalf("Wrap aborted; expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("AbortError must convert to *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["reason_code"] != "aborted" || detail["hook_name"] != "rejecter" {
t.Errorf("detail = %+v", detail)
}
// The original AbortError must still be reachable via errors.As.
var ab *platform.AbortError
if !errors.As(err, &ab) {
t.Errorf("error chain should expose *platform.AbortError")
}
}
// namespacedWrap must not mutate a shared *AbortError. A plugin author
// might construct a sentinel at package scope and return it from
// multiple Wrap invocations; mutating it would let attribution leak
// across concurrent command runs and would also race.
//
// Production path test: drive a real cobra.Command through Install
// so namespacedWrap inside install.go is exercised. The plugin returns
// the same sentinel pointer twice. Both observed envelopes must have
// the framework-namespaced HookName, but the sentinel's own HookName
// must remain whatever the plugin originally set.
func TestInstall_namespacedWrap_doesNotMutateSentinel(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leafA := makeLeaf("+a")
leafB := makeLeaf("+b")
root.AddCommand(leafA)
root.AddCommand(leafB)
sentinel := &platform.AbortError{HookName: "sentinel-original", Reason: "no"}
reg := hook.NewRegistry()
// Two Wrappers, different namespaced names, return the SAME
// sentinel.
reg.AddWrapper(hook.WrapperEntry{
Name: "plugin-a.wrap",
Selector: platform.ByCommandPath("+a"),
Fn: func(platform.Handler) platform.Handler {
return func(context.Context, *platform.Invocation) error { return sentinel }
},
})
reg.AddWrapper(hook.WrapperEntry{
Name: "plugin-b.wrap",
Selector: platform.ByCommandPath("+b"),
Fn: func(platform.Handler) platform.Handler {
return func(context.Context, *platform.Invocation) error { return sentinel }
},
})
hook.Install(root, reg, fakeViewSourceByPath{})
// Invoke both leaves.
errA := leafA.RunE(leafA, nil)
errB := leafB.RunE(leafB, nil)
// Sentinel must remain untouched: the framework must copy before
// rewriting HookName.
if sentinel.HookName != "sentinel-original" {
t.Errorf("sentinel AbortError was mutated: HookName = %q", sentinel.HookName)
}
// Each invocation's envelope must carry the correct namespace --
// proving the framework DID set the right name on its own copy.
checkHookName(t, errA, "plugin-a.wrap")
checkHookName(t, errB, "plugin-b.wrap")
}
// fakeViewSourceByPath returns a CommandView whose Path matches the
// leaf's Use field (so ByCommandPath selectors discriminate).
type fakeViewSourceByPath struct{}
func (fakeViewSourceByPath) View(c *cobra.Command) platform.CommandView {
return fakeView{path: c.Use}
}
func checkHookName(t *testing.T, err error, want string) {
t.Helper()
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError, got %T", err)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["hook_name"] != want {
t.Errorf("hook_name = %v, want %v", detail["hook_name"], want)
}
}
// Root command (no parent) must never be wrapped -- it dispatches help
// and other framework concerns. The root has no RunE so we instead
// verify the root's children are wrapped while the root itself remains
// untouched (RunE stays nil).
func TestInstall_rootStaysUntouched(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leaf := makeLeaf("+x")
root.AddCommand(leaf)
reg := hook.NewRegistry()
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+x"}})
if root.RunE != nil {
t.Fatalf("root.RunE should remain nil after Install")
}
if leaf.RunE == nil {
t.Fatalf("child leaf.RunE must remain non-nil (wrapped)")
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

154
internal/hook/registry.go Normal file
View File

@@ -0,0 +1,154 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import (
"context"
"sync"
"github.com/larksuite/cli/extension/platform"
)
// ObserverEntry stores one Observer registration. The full hook name
// (already namespaced with plugin prefix by the caller) lets diagnostic
// output point at the responsible plugin.
type ObserverEntry struct {
Name string
When platform.When
Selector platform.Selector
Fn platform.Observer
}
// WrapperEntry stores one Wrapper registration. Wrappers compose in
// registration order; the outermost (registered first) runs first.
type WrapperEntry struct {
Name string
Selector platform.Selector
Fn platform.Wrapper
}
// LifecycleEntry stores one lifecycle handler. Selector is unused
// (lifecycle events are global), but Name is preserved for diagnostics.
type LifecycleEntry struct {
Name string
Event platform.LifecycleEvent
Fn platform.LifecycleHandler
}
// Registry holds all registered hooks. The framework constructs one
// Registry per binary execution; concurrent reads after Install
// commits are safe because the maps are not mutated thereafter. Writes
// (during Install) are serialised by the platformhost.
type Registry struct {
mu sync.RWMutex
observers []ObserverEntry
wrappers []WrapperEntry
lifecycles []LifecycleEntry
}
// NewRegistry returns an empty Registry.
func NewRegistry() *Registry { return &Registry{} }
// AddObserver registers an Observer. Caller is responsible for namespacing
// (the platformhost does this). Nil fn is silently skipped -- the staging
// Registrar should reject invalid registrations before this layer.
func (r *Registry) AddObserver(e ObserverEntry) {
if e.Fn == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.observers = append(r.observers, e)
}
// AddWrapper registers a Wrapper.
func (r *Registry) AddWrapper(e WrapperEntry) {
if e.Fn == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.wrappers = append(r.wrappers, e)
}
// AddLifecycle registers a LifecycleHandler.
func (r *Registry) AddLifecycle(e LifecycleEntry) {
if e.Fn == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.lifecycles = append(r.lifecycles, e)
}
// MatchingObservers returns the observers whose selector matches the
// command at the given When stage. Result is a slice (not a generator)
// so callers can iterate without holding the registry lock.
func (r *Registry) MatchingObservers(cmd platform.CommandView, when platform.When) []ObserverEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]ObserverEntry, 0, len(r.observers))
for _, e := range r.observers {
if e.When == when && e.Selector != nil && e.Selector(cmd) {
out = append(out, e)
}
}
return out
}
// MatchingWrappers returns the wrappers whose selector matches the
// command. Order matches registration order.
func (r *Registry) MatchingWrappers(cmd platform.CommandView) []WrapperEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]WrapperEntry, 0, len(r.wrappers))
for _, e := range r.wrappers {
if e.Selector != nil && e.Selector(cmd) {
out = append(out, e)
}
}
return out
}
// LifecycleHandlers returns handlers for a given event in registration
// order.
func (r *Registry) LifecycleHandlers(event platform.LifecycleEvent) []LifecycleEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]LifecycleEntry, 0, len(r.lifecycles))
for _, e := range r.lifecycles {
if e.Event == event {
out = append(out, e)
}
}
return out
}
// ComposeWrappers folds a slice of Wrappers into a single Wrapper that
// applies them in registration order (outermost first). Empty slice
// returns the identity Wrapper (next as-is). Inspired by
// grpc.ChainUnaryInterceptor.
func ComposeWrappers(ws []platform.Wrapper) platform.Wrapper {
if len(ws) == 0 {
return identityWrapper
}
return func(next platform.Handler) platform.Handler {
// Build from the inside out so the first registered Wrapper
// ends up outermost.
for i := len(ws) - 1; i >= 0; i-- {
next = ws[i](next)
}
return next
}
}
// identityWrapper is the no-op wrapper used when there are no matching
// Wrappers for a command -- callers can always compose into
// next(ctx, inv) without a nil check.
func identityWrapper(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv *platform.Invocation) error {
return next(ctx, inv)
}
}

18
internal/hook/testing.go Normal file
View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import "io"
// SetStderrForTesting redirects the hook layer's warning output to a
// custom writer. Used by tests to silence stderr or assert on warning
// content without touching os.Stderr.
//
// Production code never calls this; the default writer is os.Stderr via
// defaultStderr.
func SetStderrForTesting(w io.Writer) {
stderr = func() interface{ Write(p []byte) (int, error) } {
return w
}
}

18
internal/hook/walk.go Normal file
View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package hook
import "github.com/spf13/cobra"
// walkTree applies fn to every command in the tree, depth-first. Hidden
// commands are visited too -- they can still be invoked.
func walkTree(root *cobra.Command, fn func(*cobra.Command)) {
if root == nil {
return
}
fn(root)
for _, c := range root.Commands() {
walkTree(c, fn)
}
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package platformhost is the bootstrap-time orchestrator that turns the
// global plugin registry (extension/platform.RegisteredPlugins) into:
//
// - a populated internal/hook.Registry (Observer / Wrapper / Lifecycle)
// - a list of pruning.PluginRule contributions (one per plugin that
// called r.Restrict)
//
// Two key invariants:
//
// - **Atomic install.** A plugin's Install() runs against a staging
// Registrar; only when Install returns nil AND validateSelf passes
// does the host commit the staged hooks/rule. Partial install never
// reaches the live Registry, so a half-loaded plugin cannot leave
// stale Observer / Wrap entries behind.
//
// - **FailurePolicy honoured.** Each plugin declares FailOpen or
// FailClosed. FailOpen plugins are skipped on error (warning to
// stderr); FailClosed plugins abort the whole bootstrap. The
// framework also enforces the Restricts↔FailClosed consistency
// contract (a Restricts=true plugin with FailOpen would be a
// silent security hole and is rejected during install).
//
// The host returns:
//
// - a *hook.Registry ready to install on the command tree
// - a []pruning.PluginRule for the pruning resolver
// - an error when a FailClosed plugin failed
package platformhost

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platformhost
import "fmt"
// PluginInstallError is the typed install-time failure. ReasonCode comes
// from the closed enum in the design doc (section 5.3 reason_code
// table). Cause carries the underlying error, if any, so consumers can
// errors.As to inspect it.
type PluginInstallError struct {
PluginName string
ReasonCode string
Reason string
Cause error
}
func (e *PluginInstallError) Error() string {
prefix := fmt.Sprintf("plugin %q (%s)", e.PluginName, e.ReasonCode)
if e.Reason != "" {
prefix += ": " + e.Reason
}
if e.Cause != nil {
prefix += ": " + e.Cause.Error()
}
return prefix
}
func (e *PluginInstallError) Unwrap() error { return e.Cause }
// ReasonCodes for PluginInstallError. The closed enum is referenced by
// the design doc's hard-constraint #15 (reason_code enum closure) and
// drives the JSON envelope's error.detail.reason_code field.
const (
ReasonInvalidPluginName = "invalid_plugin_name"
ReasonPluginNamePanic = "plugin_name_panic"
ReasonInvalidHookName = "invalid_hook_name"
ReasonDuplicateHookName = "duplicate_hook_name"
ReasonInvalidHookRegister = "invalid_hook_registration"
ReasonInvalidRule = "invalid_rule"
ReasonDoubleRestrict = "double_restrict"
ReasonRestrictsMismatch = "restricts_mismatch"
ReasonCapabilityUnmet = "capability_unmet"
ReasonCapabilitiesPanic = "capabilities_panic"
// ReasonInvalidCapability flags a plugin authoring error in
// Capabilities() output -- e.g. a syntactically malformed
// RequiredCLIVersion string. This is distinct from
// ReasonCapabilityUnmet (legitimate version mismatch): an authoring
// bug must NOT be hidden by FailurePolicy=FailOpen, so this code is
// classified as untrusted-config and aborts unconditionally.
ReasonInvalidCapability = "invalid_capability"
ReasonInstallFailed = "install_failed"
ReasonInstallPanic = "install_panic"
ReasonDuplicatePluginName = "duplicate_plugin_name"
ReasonMultipleRestricts = "multiple_restrict_plugins"
)

View File

@@ -0,0 +1,294 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platformhost
import (
"errors"
"fmt"
"io"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/pruning"
)
// InstallResult is the output of InstallAll. Registry is ready for
// hook.Install; PluginRules feeds into pruning.Resolve as the
// "plugin contribution" half of the resolver input.
type InstallResult struct {
Registry *hook.Registry
PluginRules []pruning.PluginRule
}
// InstallAll runs every registered plugin through the staging
// Registrar, validates, and commits the survivors. FailOpen plugins
// that fail are skipped with a warning; the first FailClosed failure
// stops the loop and returns the error.
//
// Plugins are processed in registration order so the result is
// deterministic.
//
// errOut receives warnings about FailOpen plugin skips. nil errOut
// means warnings are dropped (useful in tests).
func InstallAll(plugins []platform.Plugin, errOut io.Writer) (*InstallResult, error) {
if errOut == nil {
errOut = io.Discard
}
result := &InstallResult{
Registry: hook.NewRegistry(),
}
// Detect duplicate Plugin.Name. We do this up-front so the error
// surfaces before any Install runs; design hard-constraint #7
// treats this as configuration error (fail-closed regardless of
// individual FailurePolicy).
if err := detectDuplicateNames(plugins); err != nil {
return nil, err
}
for _, p := range plugins {
name, nameErr := safeCallName(p)
if nameErr != nil {
// Fail-closed on bad Name: we don't know the plugin's
// FailurePolicy yet (it's behind Capabilities, and we
// cannot trust Capabilities() before Name() succeeds).
return nil, nameErr
}
if err := installOne(name, p, result); err != nil {
// Some errors must abort regardless of FailurePolicy
// because they imply the plugin's FailurePolicy itself
// cannot be trusted (e.g. the consistency check between
// Restricts and FailClosed failed).
if isUntrustedConfigError(err) {
return nil, err
}
policy := readFailurePolicy(p)
switch policy {
case platform.FailClosed:
return nil, err
default:
fmt.Fprintf(errOut, "warning: plugin %q skipped: %v\n", name, err)
continue
}
}
}
return result, nil
}
// isUntrustedConfigError flags errors where the plugin's declared
// FailurePolicy is itself part of the misconfiguration. For these the
// host MUST abort unconditionally; honouring an FailOpen declaration on
// a misconfigured Restricts plugin would defeat the whole point of the
// consistency check.
func isUntrustedConfigError(err error) bool {
var pi *PluginInstallError
if !errors.As(err, &pi) {
return false
}
return pi.ReasonCode == ReasonRestrictsMismatch ||
pi.ReasonCode == ReasonInvalidPluginName ||
pi.ReasonCode == ReasonPluginNamePanic ||
pi.ReasonCode == ReasonDuplicatePluginName ||
pi.ReasonCode == ReasonInvalidCapability
}
// installOne handles a single plugin: build a staging Registrar, call
// Install, run validateSelf, and on success commit to the live
// Registry / PluginRules. Any error means staged data is discarded.
func installOne(name string, p platform.Plugin, result *InstallResult) error {
caps, capsErr := safeCallCapabilities(p)
if capsErr != nil {
return capsErr
}
// Strict consistency check: Restricts=true must pair with
// FailClosed (design hard-constraint #6).
if caps.Restricts && caps.FailurePolicy != platform.FailClosed {
return &PluginInstallError{
PluginName: name,
ReasonCode: ReasonRestrictsMismatch,
Reason: "Restricts=true requires FailurePolicy=FailClosed",
}
}
// Version compatibility check. Two distinct failure modes:
//
// 1. Parse error (constraint is malformed, e.g. ">=abc")
// -> ReasonInvalidCapability, classified as untrusted-config
// so the host aborts unconditionally. This is a plugin
// authoring bug; FailurePolicy must NOT mask it.
//
// 2. Legitimate version mismatch (constraint parses fine but
// current CLI does not satisfy it)
// -> ReasonCapabilityUnmet, honours FailurePolicy. A FailOpen
// plugin announcing ">=2.0" against a 1.x CLI is skipped
// with a warning; a FailClosed plugin aborts.
if ok, err := satisfiesRequiredCLIVersion(currentCLIVersion(), caps.RequiredCLIVersion); err != nil {
return &PluginInstallError{
PluginName: name,
ReasonCode: ReasonInvalidCapability,
Reason: err.Error(),
}
} else if !ok {
return &PluginInstallError{
PluginName: name,
ReasonCode: ReasonCapabilityUnmet,
Reason: fmt.Sprintf("CLI version %q does not satisfy plugin requirement %q",
currentCLIVersion(), caps.RequiredCLIVersion),
}
}
staging := newStagingRegistrar(name)
if err := safeCallInstall(p, staging); err != nil {
// Don't double-wrap typed PluginInstallError -- safeCallInstall
// already produces install_panic for recovered panics, and a
// re-wrap would bury the precise reason_code under
// install_failed.
var pi *PluginInstallError
if errors.As(err, &pi) {
return err
}
return &PluginInstallError{
PluginName: name,
ReasonCode: ReasonInstallFailed,
Reason: "Install returned error",
Cause: err,
}
}
if err := staging.validateSelf(caps); err != nil {
return err
}
// Commit staged data atomically.
for _, e := range staging.stagedObservers {
result.Registry.AddObserver(e)
}
for _, e := range staging.stagedWrappers {
result.Registry.AddWrapper(e)
}
for _, e := range staging.stagedLifecycles {
result.Registry.AddLifecycle(e)
}
if staging.rule != nil {
result.PluginRules = append(result.PluginRules, pruning.PluginRule{
PluginName: name,
Rule: staging.rule,
})
}
return nil
}
// readFailurePolicy reads Capabilities and returns the policy, falling
// back to FailClosed if Capabilities() panics. Defensive default: we
// assume the worst-case (safety-sensitive) when we cannot read the
// declaration.
//
// **Implementation note**: FailClosed must be the value set BEFORE the
// panic-prone call. The zero value of platform.FailurePolicy is
// FailOpen, so a "just return after recover" pattern would silently
// flip the safe-default to FailOpen on panic -- the opposite of what
// the comment claims.
func readFailurePolicy(p platform.Plugin) (policy platform.FailurePolicy) {
policy = platform.FailClosed
defer func() { _ = recover() }()
policy = p.Capabilities().FailurePolicy
return
}
// safeCallName recovers from a panic in Plugin.Name() and surfaces it
// as a typed PluginInstallError. Without recovery, a buggy plugin could
// crash the binary before main has a chance to emit a JSON envelope.
func safeCallName(p platform.Plugin) (string, error) {
var (
name string
err error
)
func() {
defer func() {
if r := recover(); r != nil {
err = &PluginInstallError{
PluginName: "<unknown>",
ReasonCode: ReasonPluginNamePanic,
Reason: fmt.Sprintf("Plugin.Name() panicked: %v", r),
}
}
}()
name = p.Name()
}()
if err != nil {
return "", err
}
if !hookNamePattern.MatchString(name) {
return "", &PluginInstallError{
PluginName: name,
ReasonCode: ReasonInvalidPluginName,
Reason: fmt.Sprintf("Plugin.Name() %q must match ^[a-z0-9][a-z0-9-]*$ (no dots)", name),
}
}
return name, nil
}
// safeCallCapabilities mirrors safeCallName for Capabilities().
func safeCallCapabilities(p platform.Plugin) (caps platform.Capabilities, err error) {
defer func() {
if r := recover(); r != nil {
err = &PluginInstallError{
PluginName: pluginNameOrPlaceholder(p),
ReasonCode: ReasonCapabilitiesPanic,
Reason: fmt.Sprintf("Plugin.Capabilities() panicked: %v", r),
}
}
}()
caps = p.Capabilities()
return caps, nil
}
// safeCallInstall mirrors safeCallName for Install(). Install panics
// become install_panic errors, not crashes.
func safeCallInstall(p platform.Plugin, r platform.Registrar) (err error) {
defer func() {
if rec := recover(); rec != nil {
err = &PluginInstallError{
PluginName: pluginNameOrPlaceholder(p),
ReasonCode: ReasonInstallPanic,
Reason: fmt.Sprintf("Install panicked: %v", rec),
}
}
}()
return p.Install(r)
}
func pluginNameOrPlaceholder(p platform.Plugin) string {
defer func() { _ = recover() }()
if n := p.Name(); n != "" {
return n
}
return "<unknown>"
}
// detectDuplicateNames scans the plugin slice for repeated Plugin.Name
// values. Returns a typed PluginInstallError on the first duplicate so
// the bootstrap aborts.
func detectDuplicateNames(plugins []platform.Plugin) error {
seen := map[string]bool{}
for _, p := range plugins {
name, err := safeCallName(p)
if err != nil {
// Don't double-report: let installOne handle naming
// errors per-plugin so we get the same code path.
continue
}
if seen[name] {
return &PluginInstallError{
PluginName: name,
ReasonCode: ReasonDuplicatePluginName,
Reason: fmt.Sprintf("duplicate Plugin.Name() %q across plugins", name),
}
}
seen[name] = true
}
return nil
}

View File

@@ -0,0 +1,358 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platformhost_test
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/platformhost"
)
// happyPlugin is a textbook plugin: declares Capabilities, calls a few
// Registrar methods, returns nil. The install pipeline must accept it.
type happyPlugin struct{ name string }
func (p happyPlugin) Name() string { return p.name }
func (p happyPlugin) Version() string { return "1.0.0" }
func (p happyPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
FailurePolicy: platform.FailOpen,
}
}
func (p happyPlugin) Install(r platform.Registrar) error {
r.Observe(platform.Before, "audit-pre", platform.All(),
func(context.Context, *platform.Invocation) {})
r.Wrap("policy", platform.All(),
func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv *platform.Invocation) error {
return next(ctx, inv)
}
})
r.On(platform.Shutdown, "flush",
func(context.Context, *platform.LifecycleContext) error { return nil })
return nil
}
func TestInstallAll_happyPlugin(t *testing.T) {
result, err := platformhost.InstallAll([]platform.Plugin{happyPlugin{name: "audit"}}, nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
if result.Registry == nil {
t.Fatalf("registry should be populated")
}
if len(result.PluginRules) != 0 {
t.Errorf("happy plugin did not call Restrict; rules should be empty")
}
// Cross-check: observers, wrappers, lifecycles got staged through to the live Registry.
if len(result.Registry.MatchingObservers(fakeView{}, platform.Before)) != 1 {
t.Errorf("Before observer not committed")
}
if len(result.Registry.MatchingWrappers(fakeView{})) != 1 {
t.Errorf("Wrapper not committed")
}
if len(result.Registry.LifecycleHandlers(platform.Shutdown)) != 1 {
t.Errorf("Shutdown lifecycle not committed")
}
}
// fakeView satisfies platform.CommandView for selector lookups in the
// platformhost tests; All() matches everything so the type can stay
// trivial.
type fakeView struct{}
func (fakeView) Path() string { return "" }
func (fakeView) Domain() string { return "" }
func (fakeView) Risk() (string, bool) { return "", false }
func (fakeView) Identities() []string { return nil }
func (fakeView) Annotation(string) (string, bool) { return "", false }
// A FailClosed plugin whose Install returns an error must abort
// InstallAll. Design hard-constraint #6.
type failClosedPlugin struct{}
func (failClosedPlugin) Name() string { return "secaudit" }
func (failClosedPlugin) Version() string { return "1.0.0" }
func (failClosedPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
FailurePolicy: platform.FailClosed,
}
}
func (failClosedPlugin) Install(platform.Registrar) error {
return errors.New("upstream unreachable")
}
func TestInstallAll_failClosedAborts(t *testing.T) {
_, err := platformhost.InstallAll([]platform.Plugin{failClosedPlugin{}}, nil)
if err == nil {
t.Fatalf("FailClosed install error should abort")
}
var pi *platformhost.PluginInstallError
if !errors.As(err, &pi) {
t.Fatalf("error must be *PluginInstallError, got %T", err)
}
if pi.ReasonCode != platformhost.ReasonInstallFailed {
t.Errorf("ReasonCode = %q, want install_failed", pi.ReasonCode)
}
}
// FailOpen install failure logs a warning and skips this plugin; other
// plugins still get installed.
type failOpenPlugin struct{}
func (failOpenPlugin) Name() string { return "audit-broken" }
func (failOpenPlugin) Version() string { return "1.0.0" }
func (failOpenPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailOpen}
}
func (failOpenPlugin) Install(platform.Registrar) error {
return errors.New("could not connect")
}
func TestInstallAll_failOpenSkips(t *testing.T) {
var buf bytes.Buffer
plugins := []platform.Plugin{
failOpenPlugin{},
happyPlugin{name: "audit"},
}
result, err := platformhost.InstallAll(plugins, &buf)
if err != nil {
t.Fatalf("FailOpen failure must not abort, got %v", err)
}
if !strings.Contains(buf.String(), "audit-broken") {
t.Errorf("FailOpen warning should mention plugin name, got %q", buf.String())
}
// Second plugin's observer should be present.
if len(result.Registry.MatchingObservers(fakeView{}, platform.Before)) != 1 {
t.Errorf("happy plugin's observer should still be installed after first plugin skipped")
}
}
// Restricts=true with FailOpen is a configuration error: a policy
// plugin that silently disappears under FailOpen would erase the
// security boundary. The host must reject this combo BEFORE Install
// runs.
type misconfiguredRestrictPlugin struct{}
func (misconfiguredRestrictPlugin) Name() string { return "secaudit" }
func (misconfiguredRestrictPlugin) Version() string { return "1.0.0" }
func (misconfiguredRestrictPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
Restricts: true, // policy plugin
FailurePolicy: platform.FailOpen, // contradicts safety contract
}
}
func (misconfiguredRestrictPlugin) Install(platform.Registrar) error { return nil }
func TestInstallAll_restrictsRequiresFailClosed(t *testing.T) {
_, err := platformhost.InstallAll([]platform.Plugin{misconfiguredRestrictPlugin{}}, nil)
if err == nil {
t.Fatalf("Restricts+FailOpen must abort")
}
var pi *platformhost.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != platformhost.ReasonRestrictsMismatch {
t.Fatalf("ReasonCode = %v, want restricts_mismatch", pi)
}
}
// Restricts=true but Install didn't call r.Restrict -> mismatch.
type lyingRestrictPlugin struct{}
func (lyingRestrictPlugin) Name() string { return "p" }
func (lyingRestrictPlugin) Version() string { return "1.0.0" }
func (lyingRestrictPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
Restricts: true,
FailurePolicy: platform.FailClosed,
}
}
func (lyingRestrictPlugin) Install(platform.Registrar) error {
// Forgot to call r.Restrict.
return nil
}
func TestInstallAll_restrictsDeclaredButNotCalled(t *testing.T) {
_, err := platformhost.InstallAll([]platform.Plugin{lyingRestrictPlugin{}}, nil)
if err == nil {
t.Fatalf("missing Restrict call when declared must fail")
}
var pi *platformhost.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != platformhost.ReasonRestrictsMismatch {
t.Fatalf("ReasonCode = %v, want restricts_mismatch", pi)
}
}
// Plugin that panics inside Install must NOT crash the binary -- the
// host recovers and converts the panic into a typed install_panic.
type panicInstallPlugin struct{}
func (panicInstallPlugin) Name() string { return "panicker" }
func (panicInstallPlugin) Version() string { return "1.0.0" }
func (panicInstallPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (panicInstallPlugin) Install(platform.Registrar) error {
panic("boom")
}
func TestInstallAll_installPanicRecovered(t *testing.T) {
_, err := platformhost.InstallAll([]platform.Plugin{panicInstallPlugin{}}, nil)
if err == nil {
t.Fatalf("Install panic should surface as error")
}
var pi *platformhost.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != platformhost.ReasonInstallPanic {
t.Fatalf("ReasonCode = %v, want install_panic", pi)
}
}
// Two plugins with the same Name must abort before any Install runs.
func TestInstallAll_duplicatePluginName(t *testing.T) {
_, err := platformhost.InstallAll([]platform.Plugin{
happyPlugin{name: "audit"},
happyPlugin{name: "audit"},
}, nil)
if err == nil {
t.Fatalf("duplicate Plugin.Name must abort")
}
var pi *platformhost.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != platformhost.ReasonDuplicatePluginName {
t.Fatalf("ReasonCode = %v, want duplicate_plugin_name", pi)
}
}
// Plugin with an invalid Name (contains "." or starts with a hyphen)
// must abort with invalid_plugin_name. The dot ban is critical -- the
// "{plugin}.{hook}" namespace join would become ambiguous if dots were
// allowed inside Plugin.Name().
type badNamePlugin struct{ n string }
func (p badNamePlugin) Name() string { return p.n }
func (p badNamePlugin) Version() string { return "1.0.0" }
func (p badNamePlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (p badNamePlugin) Install(platform.Registrar) error { return nil }
func TestInstallAll_invalidPluginName(t *testing.T) {
cases := []string{"with.dot", "", "-leading-hyphen", "UPPER"}
for _, name := range cases {
t.Run(name, func(t *testing.T) {
_, err := platformhost.InstallAll([]platform.Plugin{badNamePlugin{n: name}}, nil)
if err == nil {
t.Fatalf("invalid name %q should abort", name)
}
var pi *platformhost.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != platformhost.ReasonInvalidPluginName {
t.Fatalf("ReasonCode = %v, want invalid_plugin_name", pi)
}
})
}
}
// Plugin's Install registers two hooks with the same name -- the
// staging Registrar rejects the second one with duplicate_hook_name.
type duplicateHookPlugin struct{}
func (duplicateHookPlugin) Name() string { return "dup" }
func (duplicateHookPlugin) Version() string { return "1.0.0" }
func (duplicateHookPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (duplicateHookPlugin) Install(r platform.Registrar) error {
r.Observe(platform.Before, "x", platform.All(), func(context.Context, *platform.Invocation) {})
r.Observe(platform.After, "x", platform.All(), func(context.Context, *platform.Invocation) {})
return nil
}
func TestInstallAll_duplicateHookName(t *testing.T) {
_, err := platformhost.InstallAll([]platform.Plugin{duplicateHookPlugin{}}, nil)
if err == nil {
t.Fatalf("duplicate hookName within same plugin must abort")
}
var pi *platformhost.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != platformhost.ReasonDuplicateHookName {
t.Fatalf("ReasonCode = %v, want duplicate_hook_name", pi)
}
}
// Restrict contributes a rule to result.PluginRules so the pruning
// resolver can pick it up. Exercise the full path.
type restrictPlugin struct{ rule *platform.Rule }
func (p restrictPlugin) Name() string { return "secaudit" }
func (p restrictPlugin) Version() string { return "1.0.0" }
func (p restrictPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
Restricts: true,
FailurePolicy: platform.FailClosed,
}
}
func (p restrictPlugin) Install(r platform.Registrar) error {
r.Restrict(p.rule)
return nil
}
func TestInstallAll_restrictPropagatesRule(t *testing.T) {
rule := &platform.Rule{Name: "secaudit-policy", MaxRisk: "read"}
result, err := platformhost.InstallAll([]platform.Plugin{restrictPlugin{rule: rule}}, nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
if len(result.PluginRules) != 1 {
t.Fatalf("expected 1 plugin rule, got %d", len(result.PluginRules))
}
if result.PluginRules[0].Rule != rule {
t.Errorf("rule pointer should round-trip without copying")
}
if result.PluginRules[0].PluginName != "secaudit" {
t.Errorf("PluginName = %q", result.PluginRules[0].PluginName)
}
}
// Atomic install: a plugin whose validation fails AFTER it registered
// some hooks must NOT leak those hooks into the live registry. The
// staging buffer is the atomicity boundary.
type partiallyRegisterThenFailPlugin struct{}
func (partiallyRegisterThenFailPlugin) Name() string { return "partial" }
func (partiallyRegisterThenFailPlugin) Version() string { return "1.0.0" }
func (partiallyRegisterThenFailPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
Restricts: true, // declares Restrict but won't call it
FailurePolicy: platform.FailClosed,
}
}
func (partiallyRegisterThenFailPlugin) Install(r platform.Registrar) error {
r.Observe(platform.Before, "would-leak", platform.All(),
func(context.Context, *platform.Invocation) {})
// validateSelf will fail because Restricts=true but Restrict
// was not called -- this is the atomic-rollback case.
return nil
}
func TestInstallAll_atomicRollback(t *testing.T) {
_, err := platformhost.InstallAll(
[]platform.Plugin{partiallyRegisterThenFailPlugin{}, happyPlugin{name: "audit"}},
nil,
)
if err == nil {
t.Fatalf("partial plugin should abort (FailClosed)")
}
// We cannot check Registry contents here because InstallAll
// returns nil on failure; the rollback invariant is "nothing the
// failing plugin staged ever reached a live Registry", which is
// proven by the fact that we got nil back. A weaker but useful
// check: even if we passed a happy second plugin, the loop must
// have stopped at the first FailClosed failure.
var pi *platformhost.PluginInstallError
if !errors.As(err, &pi) {
t.Fatalf("error must be *PluginInstallError, got %T", err)
}
}

View File

@@ -0,0 +1,219 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platformhost
import (
"fmt"
"regexp"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
)
// hookNamePattern is the grammar both Plugin.Name() and hookName must
// match -- design hard-constraint #9. The "." character is forbidden so
// the namespace join "{plugin}.{hook}" is unambiguous.
var hookNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// stagingRegistrar buffers every Registrar call so the platformhost can
// commit them atomically (or discard them all) once Install returns.
//
// All validation happens here at staging time -- bad hookName, nil
// handler, duplicate names, etc. produce typed errors that surface in
// validateSelf and are translated into PluginInstallError by the host
// loop.
type stagingRegistrar struct {
pluginName string
stagedObservers []hook.ObserverEntry
stagedWrappers []hook.WrapperEntry
stagedLifecycles []hook.LifecycleEntry
// rule is the staged Restrict contribution, captured for the host
// to merge with the yaml side later. nil means the plugin did not
// call r.Restrict.
rule *platform.Rule
// actuallyRestricted records whether r.Restrict was called at all.
// Even a Restrict(nil) flips this to true so the
// Restricts-vs-actual consistency check can detect the call.
actuallyRestricted bool
// seenHookNames detects duplicate hookName within this plugin's
// Install call.
seenHookNames map[string]bool
// stagingErrs accumulates per-call validation errors. A single
// Install can violate the grammar multiple times; collecting all
// of them lets diagnostic output show the full picture.
stagingErrs []stagingErr
}
// stagingErr is the per-call buffered validation failure.
type stagingErr struct {
reasonCode string
message string
}
func newStagingRegistrar(pluginName string) *stagingRegistrar {
return &stagingRegistrar{
pluginName: pluginName,
seenHookNames: map[string]bool{},
}
}
// --- Registrar interface ---
func (r *stagingRegistrar) Observe(when platform.When, name string, sel platform.Selector, fn platform.Observer) {
if !r.validateName(name) {
return
}
if !r.validateNonNilSelector(name, sel) {
return
}
if fn == nil {
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("observe %q: handler is nil", name))
return
}
if !isValidWhen(when) {
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("observe %q: invalid When value %d", name, when))
return
}
r.stagedObservers = append(r.stagedObservers, hook.ObserverEntry{
Name: r.namespaced(name),
When: when,
Selector: sel,
Fn: fn,
})
}
func (r *stagingRegistrar) Wrap(name string, sel platform.Selector, w platform.Wrapper) {
if !r.validateName(name) {
return
}
if !r.validateNonNilSelector(name, sel) {
return
}
if w == nil {
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("wrap %q: handler is nil", name))
return
}
r.stagedWrappers = append(r.stagedWrappers, hook.WrapperEntry{
Name: r.namespaced(name),
Selector: sel,
Fn: w,
})
}
func (r *stagingRegistrar) On(event platform.LifecycleEvent, name string, fn platform.LifecycleHandler) {
if !r.validateName(name) {
return
}
if fn == nil {
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("on %q: handler is nil", name))
return
}
if !isValidLifecycleEvent(event) {
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("on %q: invalid LifecycleEvent value %d", name, event))
return
}
r.stagedLifecycles = append(r.stagedLifecycles, hook.LifecycleEntry{
Name: r.namespaced(name),
Event: event,
Fn: fn,
})
}
func (r *stagingRegistrar) Restrict(rule *platform.Rule) {
if r.actuallyRestricted {
r.bufferErr(ReasonDoubleRestrict, "Restrict called more than once")
return
}
r.actuallyRestricted = true
if rule == nil {
r.bufferErr(ReasonInvalidRule, "Restrict(nil)")
return
}
r.rule = rule
}
// --- helpers ---
func (r *stagingRegistrar) namespaced(name string) string {
return r.pluginName + "." + name
}
func (r *stagingRegistrar) validateName(name string) bool {
if !hookNamePattern.MatchString(name) {
r.bufferErr(ReasonInvalidHookName, fmt.Sprintf("hookName %q must match ^[a-z0-9][a-z0-9-]*$", name))
return false
}
if r.seenHookNames[name] {
r.bufferErr(ReasonDuplicateHookName, fmt.Sprintf("hookName %q registered twice in same plugin", name))
return false
}
r.seenHookNames[name] = true
return true
}
func (r *stagingRegistrar) validateNonNilSelector(name string, sel platform.Selector) bool {
if sel == nil {
r.bufferErr(ReasonInvalidHookRegister, fmt.Sprintf("hook %q: selector is nil", name))
return false
}
return true
}
func (r *stagingRegistrar) bufferErr(reasonCode, message string) {
r.stagingErrs = append(r.stagingErrs, stagingErr{
reasonCode: reasonCode,
message: message,
})
}
// validateSelf runs after Install returns. It checks:
//
// - any buffered staging error -> abort
// - Restricts declared but Install did not call r.Restrict -> abort
// - Restricts NOT declared but Install did call r.Restrict -> abort
//
// Returns the first PluginInstallError encountered (callers can use
// errors.As to inspect it). Nil means staging is clean.
func (r *stagingRegistrar) validateSelf(caps platform.Capabilities) error {
if len(r.stagingErrs) > 0 {
first := r.stagingErrs[0]
return &PluginInstallError{
PluginName: r.pluginName,
ReasonCode: first.reasonCode,
Reason: first.message,
}
}
if caps.Restricts && !r.actuallyRestricted {
return &PluginInstallError{
PluginName: r.pluginName,
ReasonCode: ReasonRestrictsMismatch,
Reason: "Capabilities.Restricts=true but Install did not call r.Restrict",
}
}
if !caps.Restricts && r.actuallyRestricted {
return &PluginInstallError{
PluginName: r.pluginName,
ReasonCode: ReasonRestrictsMismatch,
Reason: "Capabilities.Restricts=false but Install called r.Restrict",
}
}
return nil
}
func isValidWhen(w platform.When) bool {
return w == platform.Before || w == platform.After
}
func isValidLifecycleEvent(e platform.LifecycleEvent) bool {
switch e {
case platform.Startup, platform.Shutdown:
return true
}
return false
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platformhost
import (
"fmt"
"strconv"
"strings"
"github.com/larksuite/cli/internal/build"
)
// currentCLIVersion returns the running binary's version, redirectable
// from tests via SetCurrentCLIVersionForTesting. Production reads from
// internal/build.Version, which is set by -ldflags at release time.
var currentCLIVersion = func() string { return build.Version }
// SetCurrentCLIVersionForTesting overrides the version reported to the
// RequiredCLIVersion check. Returns a restore function tests must defer.
func SetCurrentCLIVersionForTesting(v string) func() {
old := currentCLIVersion
currentCLIVersion = func() string { return v }
return func() { currentCLIVersion = old }
}
// satisfiesRequiredCLIVersion reports whether buildVersion meets the
// constraint declared by Plugin.Capabilities().RequiredCLIVersion.
//
// Supported constraint forms (single comparator, no compound):
//
// "" - no requirement (always satisfied)
// "1.2.3" - exact match (equivalent to "=1.2.3")
// "=1.2.3" - exact match
// ">=1.2" - buildVersion >= 1.2 (missing patch -> 0)
// ">1.2" - strict greater than
// "<=1.2" - less than or equal
// "<1.2" - strict less than
//
// Development builds (buildVersion == "DEV" or "") always satisfy the
// constraint; the check is meaningful only for tagged releases.
//
// Returns false and an error when constraint is malformed -- callers
// should treat parse errors as fail-closed so an authoring mistake in
// the plugin does not silently load against the wrong CLI version.
//
// **Order of checks**: constraint syntax is validated FIRST, before the
// DEV-build short-circuit. A malformed constraint is a plugin authoring
// bug; we surface it even on DEV builds so the typo can be caught
// during plugin development instead of waiting for the first tagged
// release to expose it.
func satisfiesRequiredCLIVersion(buildVersion, constraint string) (bool, error) {
constraint = strings.TrimSpace(constraint)
if constraint == "" {
return true, nil
}
op, rhs := splitConstraint(constraint)
rv, err := parseSemverPrefix(rhs)
if err != nil {
return false, fmt.Errorf("invalid RequiredCLIVersion %q: %w", constraint, err)
}
if buildVersion == "" || buildVersion == "DEV" {
return true, nil
}
bv, err := parseSemverPrefix(buildVersion)
if err != nil {
// Build version is unparseable -- treat as DEV so an exotic
// build tag doesn't lock plugins out.
return true, nil
}
cmp := compareSemver(bv, rv)
switch op {
case "=", "":
return cmp == 0, nil
case ">=":
return cmp >= 0, nil
case ">":
return cmp > 0, nil
case "<=":
return cmp <= 0, nil
case "<":
return cmp < 0, nil
default:
return false, fmt.Errorf("invalid RequiredCLIVersion %q: unknown operator %q", constraint, op)
}
}
// splitConstraint extracts the leading comparator (if any) from a
// constraint string. The operator is one of "", "=", ">=", ">", "<=", "<".
func splitConstraint(s string) (op, rest string) {
switch {
case strings.HasPrefix(s, ">="):
return ">=", strings.TrimSpace(s[2:])
case strings.HasPrefix(s, "<="):
return "<=", strings.TrimSpace(s[2:])
case strings.HasPrefix(s, ">"):
return ">", strings.TrimSpace(s[1:])
case strings.HasPrefix(s, "<"):
return "<", strings.TrimSpace(s[1:])
case strings.HasPrefix(s, "="):
return "=", strings.TrimSpace(s[1:])
default:
return "", s
}
}
// parseSemverPrefix parses MAJOR[.MINOR[.PATCH]] and drops any pre-release /
// build suffix. Missing minor / patch default to 0. Accepts a leading "v".
func parseSemverPrefix(s string) (parts [3]int, err error) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
if s == "" {
return parts, fmt.Errorf("empty version")
}
// Trim pre-release/build suffix at first '-' or '+'.
for i, c := range s {
if c == '-' || c == '+' {
s = s[:i]
break
}
}
fields := strings.Split(s, ".")
if len(fields) > 3 {
fields = fields[:3]
}
for i, f := range fields {
n, err := strconv.Atoi(strings.TrimSpace(f))
if err != nil || n < 0 {
return [3]int{}, fmt.Errorf("non-numeric component %q in version %q", f, s)
}
parts[i] = n
}
return parts, nil
}
func compareSemver(a, b [3]int) int {
for i := 0; i < 3; i++ {
if a[i] < b[i] {
return -1
}
if a[i] > b[i] {
return 1
}
}
return 0
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platformhost
import (
"errors"
"testing"
"github.com/larksuite/cli/extension/platform"
)
func TestSatisfiesRequiredCLIVersion_constraints(t *testing.T) {
cases := []struct {
name string
build string
constraint string
want bool
wantErr bool
}{
{"empty constraint always satisfied", "1.0.0", "", true, false},
{"DEV build always satisfied", "DEV", ">=99.0.0", true, false},
{"empty build counts as DEV", "", ">=99.0.0", true, false},
{"v prefix stripped", "v1.0.28", ">=1.0.0", true, false},
{"exact match implicit operator", "1.0.0", "1.0.0", true, false},
{"exact match explicit =", "1.0.0", "=1.0.0", true, false},
{">= equal", "1.0.0", ">=1.0.0", true, false},
{">= higher", "1.2.0", ">=1.0.0", true, false},
{">= lower fails", "1.0.0", ">=2.0.0", false, false},
{"> strict higher", "1.0.1", ">1.0.0", true, false},
{"> equal fails", "1.0.0", ">1.0.0", false, false},
{"<= equal", "1.0.0", "<=1.0.0", true, false},
{"<= higher fails", "2.0.0", "<=1.0.0", false, false},
{"< strict lower", "0.9.0", "<1.0.0", true, false},
{"missing patch defaults to 0", "1.0", ">=1.0.0", true, false},
{"constraint with pre-release suffix", "1.0.0-rc1", ">=1.0.0", true, false},
{"malformed constraint returns error", "1.0.0", ">=abc", false, true},
{"malformed constraint errors on DEV too", "DEV", ">=abc", false, true},
{"malformed constraint errors on empty build", "", ">=zzz", false, true},
{"unparseable build version treated as DEV", "abc", ">=1.0.0", true, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := satisfiesRequiredCLIVersion(tc.build, tc.constraint)
if tc.wantErr {
if err == nil {
t.Errorf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
// A plugin whose RequiredCLIVersion exceeds the running build must
// abort install with reason_code capability_unmet. The plugin's
// FailurePolicy then decides whether the abort bubbles up.
func TestInstallOne_RequiredCLIVersion_UnmetFailClosedAborts(t *testing.T) {
restore := SetCurrentCLIVersionForTesting("1.0.0")
t.Cleanup(restore)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
platform.Register(&capVersionPlugin{
name: "needs-future",
requirement: ">=99.0.0",
fail: platform.FailClosed,
})
_, err := InstallAll(platform.RegisteredPlugins(), nil)
if err == nil {
t.Fatal("expected FailClosed install error, got nil")
}
var pi *PluginInstallError
if !errors.As(err, &pi) {
t.Fatalf("expected *PluginInstallError, got %T", err)
}
if pi.ReasonCode != ReasonCapabilityUnmet {
t.Errorf("reason_code = %q, want %q", pi.ReasonCode, ReasonCapabilityUnmet)
}
}
// FailOpen plugin with unmet RequiredCLIVersion is skipped (warning),
// other plugins still install.
func TestInstallOne_RequiredCLIVersion_UnmetFailOpenSkips(t *testing.T) {
restore := SetCurrentCLIVersionForTesting("1.0.0")
t.Cleanup(restore)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
platform.Register(&capVersionPlugin{
name: "future-failopen",
requirement: ">=99.0.0",
fail: platform.FailOpen,
})
result, err := InstallAll(platform.RegisteredPlugins(), nil)
if err != nil {
t.Fatalf("FailOpen unmet must not bubble up, got: %v", err)
}
if result.Registry == nil {
t.Errorf("Registry should be non-nil even after FailOpen skip")
}
}
// A plugin authoring error in RequiredCLIVersion (parse failure) must
// abort installation UNCONDITIONALLY. Even FailOpen cannot mask a
// typo in the constraint string -- the plugin author asked the host
// to do something it cannot parse, and silently skipping would hide
// the bug from CI.
//
// Implementation: parse errors return ReasonInvalidCapability, which
// isUntrustedConfigError lists alongside restricts_mismatch so
// InstallAll's switch treats it as a hard abort.
func TestInstallOne_RequiredCLIVersion_MalformedAbortsRegardlessOfFailurePolicy(t *testing.T) {
restore := SetCurrentCLIVersionForTesting("1.0.0")
t.Cleanup(restore)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
// FailOpen + malformed constraint: still aborts.
platform.Register(&capVersionPlugin{
name: "typo",
requirement: ">=abc",
fail: platform.FailOpen,
})
_, err := InstallAll(platform.RegisteredPlugins(), nil)
if err == nil {
t.Fatal("expected malformed constraint to abort even FailOpen, got nil")
}
var pi *PluginInstallError
if !errors.As(err, &pi) {
t.Fatalf("expected *PluginInstallError, got %T", err)
}
if pi.ReasonCode != ReasonInvalidCapability {
t.Errorf("reason_code = %q, want %q", pi.ReasonCode, ReasonInvalidCapability)
}
}
// A plugin whose RequiredCLIVersion is satisfied installs normally.
func TestInstallOne_RequiredCLIVersion_SatisfiedInstalls(t *testing.T) {
restore := SetCurrentCLIVersionForTesting("1.5.0")
t.Cleanup(restore)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
platform.Register(&capVersionPlugin{
name: "ok",
requirement: ">=1.0.0",
fail: platform.FailClosed,
})
if _, err := InstallAll(platform.RegisteredPlugins(), nil); err != nil {
t.Errorf("expected install success, got %v", err)
}
}
type capVersionPlugin struct {
name string
requirement string
fail platform.FailurePolicy
}
func (p *capVersionPlugin) Name() string { return p.name }
func (p *capVersionPlugin) Version() string { return "0.0.1" }
func (p *capVersionPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{
RequiredCLIVersion: p.requirement,
FailurePolicy: p.fail,
}
}
func (p *capVersionPlugin) Install(platform.Registrar) error { return nil }

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package policydecision holds the merged-denial decision type that both
// strict-mode and user-layer pruning produce. It lives below both consumers
// (strict-mode apply in cmd/, user-layer engine in internal/pruning) so
// neither has to import the other.
//
// The bootstrap pipeline produces a single deniedByPath map keyed by
// canonical slash path; strict-mode and user-layer apply functions each
// filter the map by Layer and install denyStubs accordingly.
package policydecision
import "sort"
// Layer values match CommandDeniedError.Layer and the error.type field of
// the JSON envelope.
const (
LayerStrictMode = "strict_mode"
LayerPruning = "pruning"
)
// Denial is the merged record for a single rejected command path. It is
// distinct from the user-layer-only pruning.Decision type: Denial only
// exists when the command is rejected (the Allowed bool would be wasted
// here, hence not reusing pruning.Decision).
type Denial struct {
Layer string // "strict_mode" | "pruning"
PolicySource string // "plugin:secaudit" | "yaml:mywork" | "strict-mode" | ""
RuleName string // matched Rule.Name (if any)
ReasonCode string // closed enum, see tech-doc 5.3
Reason string // human-readable
}
// ChildDenial is what AggregateChildren consumes -- it pairs a Denial with
// the child command's path so the aggregate can carry that breakdown for
// envelope.detail.children_denied.
type ChildDenial struct {
Path string
Denial Denial
}
// AggregateChildren produces the parent-group Denial when every child of a
// command group is itself denied. The rules:
//
// - all children share Layer "strict_mode" -> parent Layer = strict_mode,
// parent ReasonCode = single child's ReasonCode (if consistent) or
// "mixed_children_strict_mode" otherwise.
// - all children share Layer "pruning" -> parent Layer = pruning,
// ReasonCode behaves analogously.
// - mixed layers across children -> parent Layer = "pruning",
// ReasonCode = "all_children_denied",
// PolicySource = "mixed".
//
// Calling with an empty slice returns a zero Denial -- callers should treat
// this as "no aggregation needed".
func AggregateChildren(children []ChildDenial) Denial {
if len(children) == 0 {
return Denial{}
}
// Detect layer mix and reasonCode consistency.
layers := map[string]struct{}{}
reasonCodes := map[string]struct{}{}
sources := map[string]struct{}{}
ruleNames := map[string]struct{}{}
for _, c := range children {
layers[c.Denial.Layer] = struct{}{}
reasonCodes[c.Denial.ReasonCode] = struct{}{}
if c.Denial.PolicySource != "" {
sources[c.Denial.PolicySource] = struct{}{}
}
if c.Denial.RuleName != "" {
ruleNames[c.Denial.RuleName] = struct{}{}
}
}
// Mixed: layers differ across children. Parent goes to Layer=pruning
// (the more "user-recoverable" of the two -- swapping policy can flip
// children, swapping credential cannot).
if len(layers) > 1 {
return Denial{
Layer: LayerPruning,
PolicySource: "mixed",
ReasonCode: "all_children_denied",
Reason: "all child commands are denied (mixed reasons)",
}
}
// Single layer for all children.
var layer string
for l := range layers {
layer = l
}
d := Denial{Layer: layer}
// ReasonCode: collapse when consistent, otherwise prefix with
// "mixed_children_".
switch len(reasonCodes) {
case 1:
for rc := range reasonCodes {
d.ReasonCode = rc
}
default:
switch layer {
case LayerStrictMode:
d.ReasonCode = "mixed_children_strict_mode"
default:
d.ReasonCode = "mixed_children_pruning"
}
}
// PolicySource: identical across children -> carry it; otherwise leave
// blank (the caller can still see per-child sources via children_denied
// in the envelope detail).
if len(sources) == 1 {
for s := range sources {
d.PolicySource = s
}
}
if layer == LayerStrictMode {
d.PolicySource = "strict-mode"
}
// RuleName: same idea.
if len(ruleNames) == 1 {
for n := range ruleNames {
d.RuleName = n
}
}
d.Reason = "all child commands are denied"
return d
}
// SortChildren orders children by Path. The aggregate output of
// AggregateChildren is deterministic regardless of slice order, but tests
// and the envelope's children_denied list want a stable order.
func SortChildren(children []ChildDenial) {
sort.Slice(children, func(i, j int) bool {
return children[i].Path < children[j].Path
})
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package policydecision_test
import (
"testing"
"github.com/larksuite/cli/internal/policydecision"
)
func TestAggregateChildren_allSameLayerAndReason(t *testing.T) {
got := policydecision.AggregateChildren([]policydecision.ChildDenial{
{Path: "docs/+update", Denial: policydecision.Denial{
Layer: "pruning", PolicySource: "yaml:agent",
ReasonCode: "write_not_allowed", RuleName: "agent-policy",
}},
{Path: "docs/+delete", Denial: policydecision.Denial{
Layer: "pruning", PolicySource: "yaml:agent",
ReasonCode: "write_not_allowed", RuleName: "agent-policy",
}},
})
if got.Layer != "pruning" || got.ReasonCode != "write_not_allowed" {
t.Fatalf("got %+v, want layer=pruning reason=write_not_allowed", got)
}
if got.PolicySource != "yaml:agent" || got.RuleName != "agent-policy" {
t.Fatalf("Source / RuleName should propagate when consistent, got %+v", got)
}
}
func TestAggregateChildren_sameLayerMixedReasons(t *testing.T) {
got := policydecision.AggregateChildren([]policydecision.ChildDenial{
{Denial: policydecision.Denial{Layer: "pruning", ReasonCode: "write_not_allowed"}},
{Denial: policydecision.Denial{Layer: "pruning", ReasonCode: "domain_not_allowed"}},
})
if got.Layer != "pruning" || got.ReasonCode != "mixed_children_pruning" {
t.Fatalf("got %+v, want layer=pruning reason=mixed_children_pruning", got)
}
}
func TestAggregateChildren_strictModeBranch(t *testing.T) {
got := policydecision.AggregateChildren([]policydecision.ChildDenial{
{Denial: policydecision.Denial{Layer: "strict_mode", ReasonCode: "identity_not_supported"}},
{Denial: policydecision.Denial{Layer: "strict_mode", ReasonCode: "identity_not_supported"}},
})
if got.Layer != "strict_mode" || got.ReasonCode != "identity_not_supported" {
t.Fatalf("got %+v", got)
}
if got.PolicySource != "strict-mode" {
t.Fatalf("PolicySource = %q, want strict-mode", got.PolicySource)
}
}
// Mixed layers (some strict_mode, some pruning) collapse to Layer=pruning
// per the tech-doc rule -- a parent group failing for "both" reasons is
// most actionable framed as a user-policy issue (swappable) rather than a
// credential capability one (not swappable).
func TestAggregateChildren_mixedLayersFallsToPruning(t *testing.T) {
got := policydecision.AggregateChildren([]policydecision.ChildDenial{
{Path: "docs/+update", Denial: policydecision.Denial{
Layer: "strict_mode", ReasonCode: "identity_not_supported",
}},
{Path: "docs/+fetch", Denial: policydecision.Denial{
Layer: "pruning", ReasonCode: "domain_not_allowed",
}},
})
if got.Layer != "pruning" {
t.Fatalf("Layer = %q, want pruning (mixed-children rule)", got.Layer)
}
if got.ReasonCode != "all_children_denied" {
t.Fatalf("ReasonCode = %q, want all_children_denied", got.ReasonCode)
}
if got.PolicySource != "mixed" {
t.Fatalf("PolicySource = %q, want mixed", got.PolicySource)
}
}
func TestAggregateChildren_emptySlice(t *testing.T) {
got := policydecision.AggregateChildren(nil)
if (got != policydecision.Denial{}) {
t.Fatalf("empty slice should produce zero Denial, got %+v", got)
}
}
func TestSortChildren_stableOrder(t *testing.T) {
children := []policydecision.ChildDenial{
{Path: "docs/+update"},
{Path: "docs/+delete"},
{Path: "docs/+create"},
}
policydecision.SortChildren(children)
want := []string{"docs/+create", "docs/+delete", "docs/+update"}
for i, c := range children {
if c.Path != want[i] {
t.Fatalf("children[%d].Path = %q, want %q", i, c.Path, want[i])
}
}
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pruning
import (
"sync"
"github.com/larksuite/cli/extension/platform"
)
// ActivePolicy is the resolved user-layer policy after applyUserPolicyPruning
// has run during bootstrap. `lark-cli config policy show` reads this to
// answer "what rule is currently in effect, and how many commands does
// it hide?".
//
// Set once at bootstrap time; consumed read-only thereafter.
type ActivePolicy struct {
Rule *platform.Rule
Source ResolveSource
YAMLPath string // path examined, populated even when yaml was shadowed by a plugin Rule
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
}
var (
activeMu sync.RWMutex
activePolicy *ActivePolicy
)
// SetActive records the policy that ends up applied. Called exactly once
// per process from cmd/policy.go::applyUserPolicyPruning. The mutex is
// belt-and-braces in case future test paths interleave with bootstrap.
func SetActive(p *ActivePolicy) {
activeMu.Lock()
defer activeMu.Unlock()
if p == nil {
activePolicy = nil
return
}
cp := *p
activePolicy = &cp
}
// GetActive returns a copy of the recorded policy, or nil if bootstrap
// has not finished or no rule applied.
func GetActive() *ActivePolicy {
activeMu.RLock()
defer activeMu.RUnlock()
if activePolicy == nil {
return nil
}
cp := *activePolicy
return &cp
}
// ResetActiveForTesting clears the recorded policy. Tests must call this
// in t.Cleanup when they exercise the bootstrap path.
func ResetActiveForTesting() {
activeMu.Lock()
defer activeMu.Unlock()
activePolicy = nil
}

View File

@@ -0,0 +1,238 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pruning_test
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policydecision"
"github.com/larksuite/cli/internal/pruning"
)
// EvaluateAll must skip non-runnable parent groups (their decision is
// derived in the aggregation pass). The previous regression: an
// Allow:["docs/**"] rule incorrectly denied the parent "docs" group too,
// because the parent's own path "docs" did not match "docs/**".
func TestEvaluateAll_skipsPureGroups(t *testing.T) {
root := buildTree() // docs and im are pure groups, +fetch / +update / +send are leaves
e := pruning.New(&platform.Rule{Allow: []string{"docs/**"}})
got := e.EvaluateAll(root)
if _, present := got["docs"]; present {
t.Errorf("parent group 'docs' should not appear in Decisions (Allow=docs/**)")
}
if _, present := got["im"]; present {
t.Errorf("parent group 'im' should not appear in Decisions")
}
// Children still evaluated normally.
if !got["docs/+fetch"].Allowed {
t.Errorf("docs/+fetch should still be allowed by docs/**")
}
}
// BuildDeniedByPath must aggregate: a parent group whose every runnable
// child is denied must itself get an aggregated Denial in the map.
func TestBuildDeniedByPath_parentAggregationAllChildrenDenied(t *testing.T) {
// Custom tree where ALL children of "im" will be denied.
root := &cobra.Command{Use: "lark-cli"}
im := &cobra.Command{Use: "im"}
root.AddCommand(im)
send := &cobra.Command{Use: "+send", RunE: noop}
im.AddCommand(send)
search := &cobra.Command{Use: "+search", RunE: noop}
im.AddCommand(search)
e := pruning.New(&platform.Rule{Allow: []string{"docs/**"}}) // none of im/* matches
decisions := e.EvaluateAll(root)
denied := pruning.BuildDeniedByPath(root, decisions,
pruning.ResolveSource{Kind: pruning.SourceYAML, Name: "/policy.yml"}, "agent")
// Both leaves denied.
if _, ok := denied["im/+send"]; !ok {
t.Errorf("im/+send should be in denied map")
}
if _, ok := denied["im/+search"]; !ok {
t.Errorf("im/+search should be in denied map")
}
// Parent must be aggregated.
parent, ok := denied["im"]
if !ok {
t.Fatalf("parent 'im' should be aggregated into denied map")
}
if parent.Layer != "pruning" {
t.Errorf("parent.Layer = %q, want pruning", parent.Layer)
}
}
// Partial children-denied means parent stays UN-denied. This is the
// counter-case to the previous regression: docs/** allowed children stays
// alive even if some siblings are denied.
func TestBuildDeniedByPath_partialDenialKeepsParent(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
fetch := &cobra.Command{Use: "+fetch", RunE: noop}
docs.AddCommand(fetch) // allowed
delete := &cobra.Command{Use: "+delete", RunE: noop}
docs.AddCommand(delete) // denied by Deny
e := pruning.New(&platform.Rule{
Allow: []string{"docs/**"},
Deny: []string{"docs/+delete"},
})
denied := pruning.BuildDeniedByPath(root, e.EvaluateAll(root),
pruning.ResolveSource{Kind: pruning.SourcePlugin, Name: "secaudit"}, "secaudit-policy")
if _, ok := denied["docs"]; ok {
t.Errorf("parent 'docs' must NOT be denied when some children are allowed")
}
if _, ok := denied["docs/+fetch"]; ok {
t.Errorf("docs/+fetch should not be in denied map (it's allowed)")
}
if _, ok := denied["docs/+delete"]; !ok {
t.Errorf("docs/+delete should be denied (in Deny)")
}
}
// The binary root is never installed with a denyStub even when all its
// descendants are denied -- the entry point must remain dispatchable.
func TestBuildDeniedByPath_rootNeverDenied(t *testing.T) {
root := buildTree()
e := pruning.New(&platform.Rule{Allow: []string{"nonexistent/**"}})
denied := pruning.BuildDeniedByPath(root, e.EvaluateAll(root),
pruning.ResolveSource{Kind: pruning.SourceYAML, Name: "/p.yml"}, "")
// Every leaf should be denied. We do not assert on the root entry
// because Apply skips the root regardless; the contract is "root
// stays dispatchable".
if _, ok := denied["lark-cli"]; ok {
t.Errorf("root should not be in denied map")
}
}
// Hybrid command: a parent with its own RunE plus children. Aggregation
// requires both own RunE denied AND all children denied for the parent
// itself to be marked denied.
func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs", RunE: noop} // hybrid: own RunE + subs
root.AddCommand(docs)
delete := &cobra.Command{Use: "+delete", RunE: noop}
docs.AddCommand(delete)
// Allow "docs" (parent) but deny "+delete" child.
e := pruning.New(&platform.Rule{
Allow: []string{"docs"},
})
denied := pruning.BuildDeniedByPath(root, e.EvaluateAll(root),
pruning.ResolveSource{Kind: pruning.SourceYAML, Name: ""}, "")
// docs/+delete denied (path doesn't match Allow=["docs"]).
if _, ok := denied["docs/+delete"]; !ok {
t.Errorf("docs/+delete should be denied")
}
// docs itself allowed (path matches Allow=["docs"] exactly).
if _, ok := denied["docs"]; ok {
t.Errorf("docs (hybrid) should NOT be denied -- own RunE is allowed")
}
}
// Apply with the wrapped *output.ExitError exposes BOTH paths consumers
// rely on:
// 1. cmd/root.go's envelope writer (errors.As on *output.ExitError)
// 2. in-process consumers extracting the platform.CommandDeniedError
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
root := buildTree()
denied := map[string]policydecision.Denial{
"docs/+update": {
Layer: "pruning",
PolicySource: "plugin:secaudit",
RuleName: "secaudit-policy",
ReasonCode: "write_not_allowed",
Reason: "write disabled",
},
}
pruning.Apply(root, denied)
update := findChild(t, root, "docs", "+update")
err := update.RunE(update, []string{})
if err == nil {
t.Fatalf("denied command should return error")
}
// Path 1: envelope-writer view.
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error chain must contain *output.ExitError, got %T", err)
}
if exitErr.Detail == nil {
t.Fatalf("ExitError.Detail required for envelope to render")
}
if exitErr.Detail.Type != "pruning" {
t.Errorf("envelope error.type = %q, want pruning", exitErr.Detail.Type)
}
// JSON envelope shape: detail.reason_code must be present and
// match the closed enum.
detailMap, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("envelope detail should be map[string]any, got %T", exitErr.Detail.Detail)
}
if detailMap["reason_code"] != "write_not_allowed" {
t.Errorf("detail.reason_code = %v, want write_not_allowed", detailMap["reason_code"])
}
if detailMap["policy_source"] != "plugin:secaudit" {
t.Errorf("detail.policy_source = %v, want plugin:secaudit", detailMap["policy_source"])
}
// Path 2: in-process typed-error view.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("error chain must expose *platform.CommandDeniedError")
}
if cd.Path != "docs/+update" || cd.ReasonCode != "write_not_allowed" {
t.Errorf("CommandDeniedError = %+v", cd)
}
// Envelope round-trip sanity (the actual JSON cmd/root.go would emit).
var buf strings.Builder
output.WriteErrorEnvelope(&buf, exitErr, "user")
if !strings.Contains(buf.String(), `"type": "pruning"`) {
t.Errorf("envelope JSON missing type=pruning, got:\n%s", buf.String())
}
if !strings.Contains(buf.String(), `"reason_code": "write_not_allowed"`) {
t.Errorf("envelope JSON missing reason_code, got:\n%s", buf.String())
}
// Round-trip parse to verify it's well-formed JSON.
var parsed map[string]any
if err := json.Unmarshal([]byte(buf.String()), &parsed); err != nil {
t.Fatalf("envelope JSON malformed: %v\n%s", err, buf.String())
}
}
// The binary root must never receive a denyStub even if every descendant
// is denied. cobra still needs root to dispatch help / completion.
func TestApply_neverInstallsOnRoot(t *testing.T) {
root := buildTree()
denied := map[string]policydecision.Denial{
"lark-cli": {Layer: "pruning", ReasonCode: "all_children_denied"},
}
pruning.Apply(root, denied)
if root.RunE != nil {
t.Errorf("root.RunE should remain nil; got a denyStub installed")
}
if root.Hidden {
t.Errorf("root must stay visible")
}
}

154
internal/pruning/apply.go Normal file
View File

@@ -0,0 +1,154 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pruning
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policydecision"
)
// Apply walks the command tree and installs denyStubs for every path in
// deniedByPath whose Denial.Layer == "pruning". It is the user-layer
// counterpart to applyStrictModeDenials in cmd/prune.go; both consume the
// same deniedByPath map produced by the bootstrap pipeline, neither
// re-evaluates rules.
//
// Three things must happen for every denied command (hard-constraints 1-4
// in the tech doc):
//
// 1. cmd.Hidden = true -- removes from help / completion
// 2. cmd.DisableFlagParsing = true -- denial-wins invariant; otherwise
// cobra would intercept the call
// with "missing required flag"
// before we can return our error
// 3. cmd.RunE = denyStub(denial) -- returns *output.ExitError so
// cmd/root.go's envelope writer
// emits structured JSON (with
// error.type = denial.Layer and
// detail.reason_code = ReasonCode);
// the wrapped error chain still
// exposes *platform.CommandDeniedError
// via errors.As for in-process
// consumers
//
// Apply must be called once during the Bootstrap pipeline BEFORE
// cobra.Execute. It mutates the command tree in place and is not safe to
// call concurrently with command dispatch. Returns the number of commands
// modified.
func Apply(root *cobra.Command, deniedByPath map[string]policydecision.Denial) int {
if root == nil || len(deniedByPath) == 0 {
return 0
}
count := 0
walkTree(root, func(c *cobra.Command) {
// Never install a denyStub on the binary root itself. Even if the
// aggregation pass somehow marked it (e.g. all-children-denied at
// the top), the binary entry point must remain dispatchable so
// cobra's own help / completion paths still work.
if !c.HasParent() {
return
}
path := CanonicalPath(c)
if path == "" {
return
}
d, ok := deniedByPath[path]
if !ok || d.Layer != policydecision.LayerPruning {
return
}
installDenyStub(c, path, d)
count++
})
return count
}
// AnnotationDenialLayer is the cobra annotation key written by
// installDenyStub to signal "this command is denied" to layers above
// the pruning package (specifically internal/hook reads it to populate
// Invocation.DeniedByPolicy without importing pruning, avoiding an
// import cycle).
const AnnotationDenialLayer = "lark:pruning_denied_layer"
// AnnotationDenialSource records the matching PolicySource so the hook
// layer can populate Invocation.DenialPolicySource() with the right
// value.
const AnnotationDenialSource = "lark:pruning_denied_source"
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
// which does RemoveCommand+AddCommand (changing the pointer), we modify
// the existing node so any external reference (snapshots, alias targets)
// continues to point at the same cmd.
//
// Help fields (cmd.Short / cmd.Long / cmd.Flags()) are deliberately
// preserved so `--help` on a denied command still describes what the
// command was intended to do.
//
// Two cobra Annotations are set as a denial signal that internal/hook
// reads (without taking a dependency on this package):
//
// - AnnotationDenialLayer -> "pruning" or "strict_mode"
// - AnnotationDenialSource -> the PolicySource ("yaml", "plugin:foo", ...)
func installDenyStub(cmd *cobra.Command, path string, d policydecision.Denial) {
// strict-mode wins over user-layer pruning. If the command was
// already replaced by a strict-mode stub (cmd/prune.go::strictModeStubFrom
// writes layer=strict_mode), do NOT overwrite -- the user-layer
// rule cannot relax or relabel a credential-hard boundary.
//
// Behaviour without this guard (pre-fix): a user yaml rule matching
// a strict-mode stub's path would replace the RunE with the pruning
// denyStub, hiding the original strict-mode error message AND
// re-labelling error.type from "strict_mode" to "pruning".
if cmd.Annotations != nil &&
cmd.Annotations[AnnotationDenialLayer] == policydecision.LayerStrictMode {
return
}
cmd.Hidden = true
cmd.DisableFlagParsing = true
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[AnnotationDenialLayer] = d.Layer
cmd.Annotations[AnnotationDenialSource] = d.PolicySource
denial := d // capture by value for the closure
cmd.RunE = func(c *cobra.Command, args []string) error {
cd := &platform.CommandDeniedError{
Path: path,
Layer: denial.Layer,
PolicySource: denial.PolicySource,
RuleName: denial.RuleName,
ReasonCode: denial.ReasonCode,
Reason: denial.Reason,
}
// Wrap in *output.ExitError so the cmd/root.go envelope writer
// emits a JSON envelope. error.type uses denial.Layer ("pruning"
// / "strict_mode") to match the design-doc contract. The detail
// map carries the closed-enum reason_code plus the structured
// fields agents read.
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: denial.Layer,
Message: cd.Error(),
Detail: map[string]any{
"path": cd.Path,
"layer": cd.Layer,
"policy_source": cd.PolicySource,
"rule_name": cd.RuleName,
"reason_code": cd.ReasonCode,
"reason": cd.Reason,
},
},
Err: cd, // preserved for errors.As-style consumers
}
}
// Clear any pre-existing Run hook: cobra prefers RunE when both are
// set, but leaving a stale Run around is a foot-gun for future
// maintainers.
cmd.Run = nil
}

331
internal/pruning/engine.go Normal file
View File

@@ -0,0 +1,331 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package pruning is the user-layer command pruning engine. It consumes a
// platform.Rule and the cobra command tree, evaluates each runnable command
// against the rule's four-axis filter (Allow / Deny / MaxRisk / Identities),
// and produces a path -> Decision map. A separate BuildDeniedByPath step
// converts those leaf decisions into a deniedByPath map (with parent-group
// aggregation), which the Apply step consumes to install denyStubs.
//
// This package only implements the user-layer half. Strict-mode is handled
// by cmd/prune.go::applyStrictModeDenials, which consumes the same merged
// deniedByPath produced by the bootstrap pipeline. The two layers share
// the decision-map data structure (internal/policydecision.Denial) but
// keep distinct apply functions so error.type stays accurate.
package pruning
import (
"github.com/bmatcuk/doublestar/v4"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/policydecision"
)
// Decision is the user-layer single-rule evaluation result. Distinct from
// policydecision.Denial: Decision carries Allowed=true/false and the
// rejection reason when Allowed=false; Denial only ever exists when the
// command is rejected. Keeping them separate avoids a perpetually-false
// Allowed field on Denial.
type Decision struct {
Allowed bool
ReasonCode string // "" when Allowed=true
Reason string // human-readable
}
// Engine evaluates a Rule against the command tree. It is stateless except
// for the Rule snapshot it was constructed with.
type Engine struct {
rule *platform.Rule
}
// New returns an Engine bound to a Rule. A nil Rule means "no user-layer
// restriction" -- EvaluateOne always returns Allowed=true.
func New(rule *platform.Rule) *Engine {
return &Engine{rule: rule}
}
// EvaluateAll walks the command tree and evaluates every **runnable**
// command against the Rule. Pure parent groups (no RunE) are deliberately
// skipped here: their decision is derived from children by
// BuildDeniedByPath. Evaluating groups directly would incorrectly deny
// "docs" under an Allow:["docs/**"] rule (the group's own path "docs"
// does not match the "**"-requiring glob).
//
// Hybrid commands (own RunE plus children) are evaluated as ordinary
// leaves here; the aggregation pass treats them specially.
func (e *Engine) EvaluateAll(root *cobra.Command) map[string]Decision {
out := map[string]Decision{}
walkTree(root, func(c *cobra.Command) {
if !c.Runnable() {
return
}
path := CanonicalPath(c)
if path == "" {
return
}
out[path] = e.EvaluateOne(c)
})
return out
}
// EvaluateOne returns the user-layer decision for a single command. Always
// Allowed=true when the engine has no Rule.
func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
if e.rule == nil {
return Decision{Allowed: true}
}
r := e.rule
path := CanonicalPath(cmd)
// Axis 1: Deny has priority.
if matchesAny(r.Deny, path) {
return Decision{
Allowed: false,
ReasonCode: "command_denylisted",
Reason: "command denied by rule deny list",
}
}
// Axis 2: Allow gate (empty allow means "no restriction").
if len(r.Allow) > 0 && !matchesAny(r.Allow, path) {
return Decision{
Allowed: false,
ReasonCode: "domain_not_allowed",
Reason: "command path not in rule allow list",
}
}
// Axis 3: MaxRisk. Unknown command risk is treated as ALLOW per
// hard-constraint #11 -- we cannot deny here without breaking
// unannotated legacy commands. The hook layer follows the same rule
// (risk-based Selectors do not match unknown); a safety-side plugin
// that wants to cover unannotated commands composes explicitly via
// ByWrite().Or(ByUnknownRisk()).
if r.MaxRisk != "" {
cmdRisk, ok := cmdmeta.Risk(cmd)
if ok {
limit, limitOk := platform.RiskRank(r.MaxRisk)
cmdRank, cmdRankOk := platform.RiskRank(cmdRisk)
if limitOk && cmdRankOk && cmdRank > limit {
return Decision{
Allowed: false,
ReasonCode: reasonCodeForRisk(cmdRisk),
Reason: "command risk exceeds rule max_risk",
}
}
}
}
// Axis 4: Identities. Unknown command identities is treated as ALLOW.
if len(r.Identities) > 0 {
cmdIdents := cmdmeta.Identities(cmd)
if cmdIdents != nil && !hasIntersection(r.Identities, cmdIdents) {
return Decision{
Allowed: false,
ReasonCode: "identity_mismatch",
Reason: "command identities do not intersect rule identities",
}
}
}
return Decision{Allowed: true}
}
// BuildDeniedByPath converts engine Decisions to a deniedByPath map keyed
// by canonical path. It performs the parent-group aggregation defined in
// the tech doc: a non-runnable parent whose every runnable descendant is
// denied gets an aggregate denial (via policydecision.AggregateChildren);
// hybrid commands (own RunE + children) get one only when both their own
// RunE and all children are denied.
//
// The root command (no parent) is never installed with a denyStub even if
// every child is denied -- the binary entry point must remain dispatchable
// so `--help` and similar remain available.
//
// source / ruleName populate PolicySource and RuleName on the produced
// Denial values, so envelope output can attribute denials.
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string) map[string]policydecision.Denial {
out := map[string]policydecision.Denial{}
sourceLabel := policySourceLabel(source)
for path, d := range decisions {
if !d.Allowed {
out[path] = policydecision.Denial{
Layer: policydecision.LayerPruning,
PolicySource: sourceLabel,
RuleName: ruleName,
ReasonCode: d.ReasonCode,
Reason: d.Reason,
}
}
}
aggregateParents(root, out)
return out
}
// aggregateParents recursively examines each parent group. Returns true
// when every runnable descendant beneath cmd (including cmd itself when
// runnable) is denied; in that case the function also inserts an aggregate
// Denial for cmd, unless cmd is the binary root or cmd is already in the
// map (own RunE denial preserved).
//
// "Live" children are those with at least one runnable descendant; pure
// non-runnable placeholders neither count toward "all denied" nor block
// the aggregation.
func aggregateParents(cmd *cobra.Command, denied map[string]policydecision.Denial) bool {
if cmd == nil {
return false
}
children := cmd.Commands()
cmdRunnable := cmd.Runnable()
cmdPath := CanonicalPath(cmd)
// Pure leaf
if len(children) == 0 {
if !cmdRunnable {
return false // placeholder, doesn't contribute
}
_, ok := denied[cmdPath]
return ok
}
// Has children: recurse first, collect direct-child denials for the
// aggregation message.
childDenials := make([]policydecision.ChildDenial, 0, len(children))
liveChildSeen := false
allLiveChildrenDenied := true
for _, child := range children {
childDenied := aggregateParents(child, denied)
if hasRunnableDescendant(child) {
liveChildSeen = true
if !childDenied {
allLiveChildrenDenied = false
}
}
if cp := CanonicalPath(child); cp != "" {
if d, ok := denied[cp]; ok {
childDenials = append(childDenials, policydecision.ChildDenial{Path: cp, Denial: d})
}
}
}
if !liveChildSeen {
// No reachable runnable descendant, nothing to aggregate.
return false
}
// Hybrid: own RunE must also be denied for the group to count as denied.
if cmdRunnable {
if _, ownDenied := denied[cmdPath]; !ownDenied {
return false
}
}
if !allLiveChildrenDenied {
return false
}
// Everything reachable below this command is denied. Install the
// aggregate denyStub if there isn't already an own denial here, and
// skip the binary root.
if cmd.HasParent() && cmdPath != "" {
if _, exists := denied[cmdPath]; !exists {
policydecision.SortChildren(childDenials)
denied[cmdPath] = policydecision.AggregateChildren(childDenials)
}
}
return true
}
// hasRunnableDescendant reports whether cmd or any descendant has RunE.
// We use it to ignore pure placeholder branches when aggregating.
func hasRunnableDescendant(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
if cmd.Runnable() {
return true
}
for _, c := range cmd.Commands() {
if hasRunnableDescendant(c) {
return true
}
}
return false
}
// policySourceLabel produces the "plugin:foo" / "yaml" / "" label that goes
// into CommandDeniedError.PolicySource and envelope.detail.policy_source.
//
// **Plugin name is included** because plugins live inside the binary and
// their names are part of the implementation contract; an integrator
// debugging a denial wants to know which plugin's Restrict() fired.
//
// **YAML file path is deliberately omitted** -- the envelope is observable
// by agents, CI logs, and other downstream systems, and the path leaks
// the user's home directory (e.g. /Users/alice/.lark-cli/policy.yml).
// The Denial.RuleName field already carries the human-identifier the user
// chose for their rule (yaml's "name:" field), which suffices for
// disambiguation. Use `config policy show` if the absolute path matters
// for a local debugging session.
func policySourceLabel(s ResolveSource) string {
switch s.Kind {
case SourcePlugin:
return "plugin:" + s.Name
case SourceYAML:
return "yaml"
}
return ""
}
// reasonCodeForRisk picks the canonical reason_code for an exceeds-max-risk
// rejection.
func reasonCodeForRisk(risk platform.Risk) string {
if risk == platform.RiskWrite || risk == platform.RiskHighRiskWrite {
return "write_not_allowed"
}
return "risk_too_high"
}
// matchesAny reports whether path matches any of the doublestar globs.
// Invalid globs are skipped here -- they're rejected upstream by
// ValidateRule when the rule first enters the system.
func matchesAny(globs []string, path string) bool {
for _, g := range globs {
if ok, err := doublestar.Match(g, path); err == nil && ok {
return true
}
}
return false
}
// hasIntersection reports whether two string slices share any element.
// Both slices are short (usually 1-2 identities) so a nested loop beats
// allocating a set.
func hasIntersection(a, b []string) bool {
for _, x := range a {
for _, y := range b {
if x == y {
return true
}
}
}
return false
}
// walkTree applies fn to every command in the tree, depth-first. Hidden
// commands are visited too -- they can still be invoked.
func walkTree(root *cobra.Command, fn func(*cobra.Command)) {
if root == nil {
return
}
fn(root)
for _, c := range root.Commands() {
walkTree(c, fn)
}
}

View File

@@ -0,0 +1,290 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pruning_test
import (
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/policydecision"
"github.com/larksuite/cli/internal/pruning"
)
// buildTree assembles a tiny realistic tree for engine tests:
//
// lark-cli (root)
// ├── docs
// │ ├── +fetch risk=read identities=[user,bot]
// │ ├── +update risk=write identities=[user]
// │ └── +delete-doc risk=high-risk-write
// └── im
// └── +send risk=write identities=[bot]
func buildTree() *cobra.Command {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
cmdmeta.SetDomain(docs, "docs")
root.AddCommand(docs)
fetch := &cobra.Command{Use: "+fetch", RunE: noop}
cmdutil.SetRisk(fetch, "read")
cmdutil.SetSupportedIdentities(fetch, []string{"user", "bot"})
docs.AddCommand(fetch)
update := &cobra.Command{Use: "+update", RunE: noop}
cmdutil.SetRisk(update, "write")
cmdutil.SetSupportedIdentities(update, []string{"user"})
docs.AddCommand(update)
deleteDoc := &cobra.Command{Use: "+delete-doc", RunE: noop}
cmdutil.SetRisk(deleteDoc, "high-risk-write")
docs.AddCommand(deleteDoc)
im := &cobra.Command{Use: "im"}
cmdmeta.SetDomain(im, "im")
root.AddCommand(im)
send := &cobra.Command{Use: "+send", RunE: noop}
cmdutil.SetRisk(send, "write")
cmdutil.SetSupportedIdentities(send, []string{"bot"})
im.AddCommand(send)
return root
}
func noop(*cobra.Command, []string) error { return nil }
func TestEvaluate_nilRuleAllowsAll(t *testing.T) {
root := buildTree()
got := pruning.New(nil).EvaluateAll(root)
for path, d := range got {
if !d.Allowed {
t.Fatalf("nil rule should allow all, got Allowed=false for %s", path)
}
}
}
func TestEvaluate_allowGlob(t *testing.T) {
root := buildTree()
e := pruning.New(&platform.Rule{
Allow: []string{"docs/**"},
})
got := e.EvaluateAll(root)
if !got["docs/+fetch"].Allowed {
t.Errorf("docs/+fetch should be allowed by docs/** glob")
}
if got["im/+send"].Allowed {
t.Errorf("im/+send should NOT be allowed when Allow=docs/**")
}
if got["im/+send"].ReasonCode != "domain_not_allowed" {
t.Errorf("im/+send ReasonCode = %q, want domain_not_allowed",
got["im/+send"].ReasonCode)
}
}
func TestEvaluate_denyTakesPriorityOverAllow(t *testing.T) {
root := buildTree()
e := pruning.New(&platform.Rule{
Allow: []string{"docs/**"},
Deny: []string{"docs/+delete-doc"},
})
got := e.EvaluateAll(root)
if got["docs/+delete-doc"].Allowed {
t.Errorf("docs/+delete-doc should be denied by Deny rule")
}
if got["docs/+delete-doc"].ReasonCode != "command_denylisted" {
t.Errorf("ReasonCode = %q, want command_denylisted",
got["docs/+delete-doc"].ReasonCode)
}
if !got["docs/+fetch"].Allowed {
t.Errorf("docs/+fetch should still be allowed (not in Deny)")
}
}
func TestEvaluate_maxRiskCutoff(t *testing.T) {
root := buildTree()
e := pruning.New(&platform.Rule{
MaxRisk: "write", // allow read+write, deny high-risk-write
})
got := e.EvaluateAll(root)
if !got["docs/+update"].Allowed {
t.Errorf("+update (risk=write) should pass MaxRisk=write")
}
if !got["docs/+fetch"].Allowed {
t.Errorf("+fetch (risk=read) should pass MaxRisk=write")
}
if got["docs/+delete-doc"].Allowed {
t.Errorf("+delete-doc (risk=high-risk-write) should fail MaxRisk=write")
}
if rc := got["docs/+delete-doc"].ReasonCode; rc != "write_not_allowed" {
t.Errorf("ReasonCode = %q, want write_not_allowed", rc)
}
}
// Hard-constraint #11: unknown risk_level means ALLOW. A command without a
// risk annotation must pass even under MaxRisk=read.
func TestEvaluate_unknownRiskIsAllow(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
// Note: no SetRisk on this command -> unknown
orphan := &cobra.Command{Use: "+orphan", RunE: noop}
docs.AddCommand(orphan)
e := pruning.New(&platform.Rule{MaxRisk: "read"})
got := e.EvaluateAll(root)
if !got["docs/+orphan"].Allowed {
t.Fatalf("unknown risk must pass MaxRisk=read (constraint #11)")
}
}
func TestEvaluate_identitiesIntersection(t *testing.T) {
root := buildTree()
e := pruning.New(&platform.Rule{
Identities: []string{"bot"}, // bot-only rule
})
got := e.EvaluateAll(root)
// docs/+fetch has [user, bot] -- intersection includes bot -> ALLOW
if !got["docs/+fetch"].Allowed {
t.Errorf("+fetch (identities=user,bot) should intersect bot rule")
}
// docs/+update has [user] -- no intersection with bot -> DENY
if got["docs/+update"].Allowed {
t.Errorf("+update (identities=user) should fail bot-only rule")
}
if got["docs/+update"].ReasonCode != "identity_mismatch" {
t.Errorf("ReasonCode = %q, want identity_mismatch",
got["docs/+update"].ReasonCode)
}
}
// Unknown identities also defaults to ALLOW. A command without
// supportedIdentities passes any identity filter.
func TestEvaluate_unknownIdentitiesIsAllow(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
cmd := &cobra.Command{Use: "+x", RunE: noop}
root.AddCommand(cmd)
// no SetSupportedIdentities
e := pruning.New(&platform.Rule{Identities: []string{"bot"}})
got := e.EvaluateAll(root)
if !got["+x"].Allowed {
t.Fatalf("unknown identities must pass any identity rule (constraint #11)")
}
}
// Apply must install denyStubs only on Layer="pruning" entries. A
// "strict_mode" denial in the same map must be left for
// applyStrictModeDenials in cmd/.
func TestApply_onlyTouchesPruningLayer(t *testing.T) {
root := buildTree()
denied := map[string]policydecision.Denial{
"docs/+update": {Layer: "pruning", ReasonCode: "write_not_allowed"},
"docs/+fetch": {Layer: "strict_mode", ReasonCode: "identity_not_supported"},
}
count := pruning.Apply(root, denied)
if count != 1 {
t.Fatalf("Apply count = %d, want 1 (only pruning-layer entries)", count)
}
update := findChild(t, root, "docs", "+update")
if !update.Hidden {
t.Errorf("+update should be Hidden after Apply")
}
if !update.DisableFlagParsing {
t.Errorf("+update should have DisableFlagParsing=true (constraint #4)")
}
// strict-mode entry must NOT have been touched here.
fetch := findChild(t, root, "docs", "+fetch")
if fetch.Hidden || fetch.DisableFlagParsing {
t.Errorf("+fetch (strict_mode layer) should NOT be touched by pruning.Apply")
}
}
// Calling the denied RunE must produce a typed CommandDeniedError with the
// right Layer/ReasonCode. This is the contract every external consumer
// (agent, integration) depends on.
func TestApply_runEReturnsTypedError(t *testing.T) {
root := buildTree()
pruning.Apply(root, map[string]policydecision.Denial{
"docs/+update": {
Layer: "pruning",
PolicySource: "plugin:secaudit",
RuleName: "secaudit-policy",
ReasonCode: "write_not_allowed",
Reason: "write disabled",
},
})
update := findChild(t, root, "docs", "+update")
err := update.RunE(update, []string{})
if err == nil {
t.Fatalf("denied command should return error")
}
var denied *platform.CommandDeniedError
if !errors.As(err, &denied) {
t.Fatalf("error should be *platform.CommandDeniedError, got %T", err)
}
if denied.Layer != "pruning" || denied.ReasonCode != "write_not_allowed" {
t.Errorf("denial = %+v, want layer=pruning code=write_not_allowed", denied)
}
if denied.Path != "docs/+update" {
t.Errorf("Path = %q, want docs/+update", denied.Path)
}
if denied.PolicySource != "plugin:secaudit" || denied.RuleName != "secaudit-policy" {
t.Errorf("policy source / rule name lost in stub: %+v", denied)
}
}
func TestApply_emptyMapNoop(t *testing.T) {
root := buildTree()
if got := pruning.Apply(root, nil); got != 0 {
t.Fatalf("nil deniedByPath should yield count=0, got %d", got)
}
}
// CanonicalPath strips the root and joins with slashes -- the form
// doublestar globs need to work.
func TestCanonicalPath(t *testing.T) {
root := buildTree()
update := findChild(t, root, "docs", "+update")
if got := pruning.CanonicalPath(update); got != "docs/+update" {
t.Fatalf("CanonicalPath = %q, want docs/+update", got)
}
if got := pruning.CanonicalPath(root); got != "lark-cli" {
t.Fatalf("CanonicalPath(root) = %q, want lark-cli (orphan fallback)", got)
}
}
// findChild is a test helper: descend a path of cmd.Use names through the
// tree, failing the test if any step is missing.
func findChild(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
t.Helper()
cur := parent
for _, n := range names {
var next *cobra.Command
for _, c := range cur.Commands() {
if c.Use == n {
next = c
break
}
}
if next == nil {
t.Fatalf("child %q not found under %q", n, cur.Use)
}
cur = next
}
return cur
}

52
internal/pruning/path.go Normal file
View File

@@ -0,0 +1,52 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pruning
import (
"strings"
"github.com/spf13/cobra"
)
// CanonicalPath returns the rootless slash-separated path used everywhere in
// the pruning framework. Cobra's CommandPath() yields space-separated
// segments ("lark-cli docs +update"); doublestar globs ("docs/**") require
// slashes, so all internal lookups go through this conversion.
//
// Algorithm:
//
// 1. Collect cmd.Use first words from the command up to (but not including)
// the root, in reverse order.
// 2. Reverse the collection and join with "/".
//
// The root (the binary's own command, no parent) is stripped. For a command
// with no parent, the returned path is just its own Use word.
func CanonicalPath(cmd *cobra.Command) string {
if cmd == nil {
return ""
}
parts := make([]string, 0, 4)
for c := cmd; c != nil && c.HasParent(); c = c.Parent() {
parts = append(parts, useName(c))
}
// reverse
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
parts[i], parts[j] = parts[j], parts[i]
}
if len(parts) == 0 {
// orphan command -- return its own name so callers still see
// something stable.
return useName(cmd)
}
return strings.Join(parts, "/")
}
// useName extracts the first word of cmd.Use ("update [flags] <doc>" -> "update").
func useName(cmd *cobra.Command) string {
name := cmd.Use
if i := strings.IndexByte(name, ' '); i >= 0 {
name = name[:i]
}
return name
}

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pruning
import (
"errors"
"fmt"
"os"
"github.com/larksuite/cli/extension/platform"
pyaml "github.com/larksuite/cli/internal/pruning/yaml"
"github.com/larksuite/cli/internal/vfs"
)
// SourceKind describes which source contributed the active Rule. Surfaced
// by `config policy show` so users can tell at a glance whether their yaml
// is being shadowed by a plugin.
type SourceKind string
const (
SourcePlugin SourceKind = "plugin"
SourceYAML SourceKind = "yaml"
SourceNone SourceKind = "none"
)
// ResolveSource is the metadata about which rule won.
type ResolveSource struct {
Kind SourceKind
Name string // plugin name when Kind=plugin; file path when Kind=yaml; "" otherwise
}
// PluginRule represents a single Restrict() contribution. The Hook surface
// (next milestone) will collect these via Plugin.Install -> r.Restrict; for
// now the package consumer (Bootstrap pipeline) just hands in the slice.
//
// More than one entry is a configuration error (single-rule policy) -- the
// resolver reports it as a typed error so the bootstrap can abort.
type PluginRule struct {
PluginName string
Rule *platform.Rule
}
// ErrMultipleRestricts is returned when 2+ plugins both contribute a Rule.
// The bootstrap pipeline must treat this as fail-closed (start-up abort);
// resolving by silent priority would mask a configuration mistake.
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one is permitted")
// Resolve picks the active Rule from the configured sources. Precedence:
//
// plugin contribution > yaml file at yamlPath > no rule
//
// pluginRules may be nil/empty. yamlPath may be "" (skip yaml).
//
// The chosen Rule is validated through ValidateRule before being returned
// -- bad MaxRisk strings, malformed globs, or unknown identities all
// abort the resolve with a typed error so the bootstrap pipeline can
// honour the plugin's FailurePolicy. A typo in a policy plugin must
// never silently fail-open by reaching the engine.
//
// The returned Rule pointer is owned by the caller; resolver does not
// retain a reference.
func Resolve(pluginRules []PluginRule, yamlPath string) (*platform.Rule, ResolveSource, error) {
switch len(pluginRules) {
case 0:
// fall through to yaml
case 1:
rule := pluginRules[0].Rule
if err := ValidateRule(rule); err != nil {
return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pluginRules[0].PluginName, err)
}
return rule, ResolveSource{Kind: SourcePlugin, Name: pluginRules[0].PluginName}, nil
default:
names := make([]string, len(pluginRules))
for i, pr := range pluginRules {
names[i] = pr.PluginName
}
return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, names)
}
if yamlPath != "" {
// vfs.Stat lets callers swap in an in-memory FS for tests. The
// errors here surface as typed os.ErrNotExist when the file is
// absent, just like a direct os.ReadFile call would.
if _, err := vfs.Stat(yamlPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ResolveSource{Kind: SourceNone}, nil
}
return nil, ResolveSource{}, fmt.Errorf("stat policy yaml %q: %w", yamlPath, err)
}
data, err := vfs.ReadFile(yamlPath)
if err != nil {
return nil, ResolveSource{}, fmt.Errorf("read policy yaml %q: %w", yamlPath, err)
}
rule, err := pyaml.Parse(data)
if err != nil {
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", yamlPath, err)
}
if err := ValidateRule(rule); err != nil {
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", yamlPath, err)
}
return rule, ResolveSource{Kind: SourceYAML, Name: yamlPath}, nil
}
return nil, ResolveSource{Kind: SourceNone}, nil
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pruning_test
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/pruning"
)
func TestResolve_singlePluginWins(t *testing.T) {
rule := &platform.Rule{Name: "secaudit"}
got, src, err := pruning.Resolve([]pruning.PluginRule{
{PluginName: "secaudit", Rule: rule},
}, "")
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if got != rule || src.Kind != pruning.SourcePlugin || src.Name != "secaudit" {
t.Fatalf("Resolve = (%v, %+v)", got, src)
}
}
func TestResolve_pluginShadowsYaml(t *testing.T) {
dir := t.TempDir()
yamlPath := filepath.Join(dir, "policy.yml")
if err := os.WriteFile(yamlPath, []byte("name: from-yaml\n"), 0o644); err != nil {
t.Fatalf("write yaml: %v", err)
}
pluginRule := &platform.Rule{Name: "from-plugin"}
got, src, err := pruning.Resolve(
[]pruning.PluginRule{{PluginName: "secaudit", Rule: pluginRule}},
yamlPath,
)
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if got.Name != "from-plugin" || src.Kind != pruning.SourcePlugin {
t.Fatalf("plugin should shadow yaml, got %+v / %+v", got, src)
}
}
func TestResolve_yamlWhenNoPlugin(t *testing.T) {
dir := t.TempDir()
yamlPath := filepath.Join(dir, "policy.yml")
if err := os.WriteFile(yamlPath, []byte("name: from-yaml\nmax_risk: read\n"), 0o644); err != nil {
t.Fatalf("write yaml: %v", err)
}
got, src, err := pruning.Resolve(nil, yamlPath)
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if got.Name != "from-yaml" || src.Kind != pruning.SourceYAML {
t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src)
}
}
func TestResolve_missingYamlIsNoRule(t *testing.T) {
got, src, err := pruning.Resolve(nil, "/nonexistent/policy.yml")
if err != nil {
t.Fatalf("missing yaml should not error, got %v", err)
}
if got != nil || src.Kind != pruning.SourceNone {
t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src)
}
}
// Two plugins both contributing a Rule must produce the typed error so the
// bootstrap pipeline aborts (hard-constraint #7).
func TestResolve_multipleRestrictIsError(t *testing.T) {
_, _, err := pruning.Resolve([]pruning.PluginRule{
{PluginName: "a", Rule: &platform.Rule{Name: "a"}},
{PluginName: "b", Rule: &platform.Rule{Name: "b"}},
}, "")
if !errors.Is(err, pruning.ErrMultipleRestricts) {
t.Fatalf("err = %v, want ErrMultipleRestricts", err)
}
}
func TestResolve_emptyEverythingIsNone(t *testing.T) {
got, src, err := pruning.Resolve(nil, "")
if err != nil {
t.Fatalf("Resolve err: %v", err)
}
if got != nil || src.Kind != pruning.SourceNone {
t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src)
}
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pruning_test
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/pruning"
)
// The envelope's policy_source must never leak the absolute home path.
// "yaml:/Users/alice/.lark-cli/policy.yml" would expose Alice's username
// to any agent or log consumer; the contract is to emit just "yaml" and
// rely on rule_name (from the yaml's "name:" field) for disambiguation.
func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
leaf := &cobra.Command{Use: "+write", RunE: func(*cobra.Command, []string) error { return nil }}
docs.AddCommand(leaf)
e := pruning.New(&platform.Rule{
Name: "my-readonly-rule",
Allow: []string{"contact/**"}, // docs/* falls outside, denied
})
denied := pruning.BuildDeniedByPath(root, e.EvaluateAll(root),
pruning.ResolveSource{
Kind: pruning.SourceYAML,
Name: "/Users/alice/.lark-cli/policy.yml", // simulate an absolute path
}, "my-readonly-rule")
pruning.Apply(root, denied)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected denial ExitError, got %v", err)
}
detail := exitErr.Detail.Detail.(map[string]any)
src, _ := detail["policy_source"].(string)
if src != "yaml" {
t.Errorf("policy_source = %q, want %q (no path leak)", src, "yaml")
}
// rule_name carries the disambiguating identifier.
if detail["rule_name"] != "my-readonly-rule" {
t.Errorf("rule_name = %v, want my-readonly-rule", detail["rule_name"])
}
// Direct probe: the absolute path must not appear anywhere in the
// envelope detail (key OR value).
for k, v := range detail {
if strings.Contains(k, "/Users/alice") || strings.Contains(asString(v), "/Users/alice") {
t.Errorf("envelope detail must not leak '/Users/alice', found in %s = %v", k, v)
}
}
}
// Plugin name IS allowed in policy_source because plugins are in-binary
// and their names are part of the contract (an integrator debugging a
// denial wants to know which plugin fired). This test pins that intent
// so a future change does not silently strip the plugin name too.
func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leaf := &cobra.Command{Use: "+block", RunE: func(*cobra.Command, []string) error { return nil }}
root.AddCommand(leaf)
e := pruning.New(&platform.Rule{
Name: "secaudit-policy",
Deny: []string{"+block"},
})
denied := pruning.BuildDeniedByPath(root, e.EvaluateAll(root),
pruning.ResolveSource{Kind: pruning.SourcePlugin, Name: "secaudit"},
"secaudit-policy")
pruning.Apply(root, denied)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected ExitError")
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["policy_source"] != "plugin:secaudit" {
t.Errorf("policy_source = %v, want plugin:secaudit", detail["policy_source"])
}
}
func asString(v any) string {
s, _ := v.(string)
return s
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pruning_test
import (
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/policydecision"
"github.com/larksuite/cli/internal/pruning"
)
// pruning.Apply MUST NOT overwrite the denial annotation on a command
// already marked as strict-mode denied. strict-mode is a hard boundary
// (credential-derived); a user-layer rule cannot relabel or replace
// the error path.
//
// Without this invariant: when a user yaml rule happened to match the
// path of a strict-mode stub, Apply would change layer=strict_mode to
// layer=pruning, and the user-visible error would say "denied by yaml"
// instead of "strict mode". The hard-boundary contract demands
// strict_mode wins.
func TestApply_PreservesStrictModeAnnotation(t *testing.T) {
root := &cobra.Command{Use: "root"}
stub := &cobra.Command{
Use: "victim",
Hidden: true,
Annotations: map[string]string{
pruning.AnnotationDenialLayer: policydecision.LayerStrictMode,
pruning.AnnotationDenialSource: "strict-mode",
},
RunE: func(*cobra.Command, []string) error { return nil },
}
root.AddCommand(stub)
// User-layer pruning denies the same path.
denied := map[string]policydecision.Denial{
"victim": {
Layer: policydecision.LayerPruning,
PolicySource: "yaml",
Reason: "denied by user yaml",
ReasonCode: "command_denylisted",
},
}
pruning.Apply(root, denied)
if got := stub.Annotations[pruning.AnnotationDenialLayer]; got != policydecision.LayerStrictMode {
t.Errorf("strict-mode layer overwritten by pruning: got %q want %q",
got, policydecision.LayerStrictMode)
}
if got := stub.Annotations[pruning.AnnotationDenialSource]; got != "strict-mode" {
t.Errorf("strict-mode source overwritten: got %q", got)
}
}
// Sanity: a normal command (no prior annotation) still gets the
// pruning denial annotations after Apply.
func TestApply_NonStrictCommandStillGetsPruningAnnotation(t *testing.T) {
root := &cobra.Command{Use: "root"}
leaf := &cobra.Command{
Use: "normal",
RunE: func(*cobra.Command, []string) error { return nil },
}
root.AddCommand(leaf)
denied := map[string]policydecision.Denial{
"normal": {
Layer: policydecision.LayerPruning,
PolicySource: "yaml",
Reason: "denied",
ReasonCode: "command_denylisted",
},
}
pruning.Apply(root, denied)
if got := leaf.Annotations[pruning.AnnotationDenialLayer]; got != policydecision.LayerPruning {
t.Errorf("expected pruning layer annotation, got %q", got)
}
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pruning
import (
"fmt"
"github.com/bmatcuk/doublestar/v4"
"github.com/larksuite/cli/extension/platform"
)
// ValidateRule is the single Rule-validation entry point. It runs from
// every source: yaml file load, Plugin.Restrict (once the Hook surface
// lands), and the policy CLI's validate subcommand. Catching invalid
// rules HERE rather than during evaluation prevents silent fail-open
// scenarios:
//
// - bad MaxRisk string ("readd") would skip the risk check entirely
// - malformed doublestar pattern ("docs/[abc") never matches, so a
// plugin that meant to allow "docs/*" silently allows nothing,
// and a deny list with the same typo silently denies nothing
//
// A typo in either field by a plugin author or admin must abort the load
// rather than continue with a degraded rule (hard-constraint #6 / #11
// safety contract).
//
// A nil rule is a no-op (treated as "no restriction" everywhere -- not an
// error).
func ValidateRule(r *platform.Rule) error {
if r == nil {
return nil
}
if r.MaxRisk != "" {
if _, ok := platform.RiskRank(r.MaxRisk); !ok {
return fmt.Errorf("invalid max_risk %q: must be one of read|write|high-risk-write", r.MaxRisk)
}
}
for _, id := range r.Identities {
if id != platform.IdentityUser && id != platform.IdentityBot {
return fmt.Errorf("invalid identities entry %q: must be 'user' or 'bot'", id)
}
}
for _, g := range r.Allow {
if err := validateGlob(g); err != nil {
return fmt.Errorf("invalid allow glob %q: %w", g, err)
}
}
for _, g := range r.Deny {
if err := validateGlob(g); err != nil {
return fmt.Errorf("invalid deny glob %q: %w", g, err)
}
}
return nil
}
// validateGlob rejects malformed doublestar patterns. doublestar.Match
// returns an error for unbalanced brackets / bad escape sequences; that
// error path is the canonical signal for "this pattern is not valid".
//
// We probe with an empty string -- the goal is to exercise the parser,
// not to compute a match.
func validateGlob(g string) error {
if g == "" {
return fmt.Errorf("empty pattern")
}
if _, err := doublestar.Match(g, ""); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package pruning_test
import (
"strings"
"testing"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/pruning"
)
// nil rule is "no restriction" everywhere -- validation must agree.
func TestValidateRule_nilIsOk(t *testing.T) {
if err := pruning.ValidateRule(nil); err != nil {
t.Fatalf("nil rule should validate, got %v", err)
}
}
func TestValidateRule_validRule(t *testing.T) {
r := &platform.Rule{
Allow: []string{"docs/**", "contact/+search-*"},
Deny: []string{"docs/+delete-doc"},
MaxRisk: "write",
Identities: []string{"user", "bot"},
}
if err := pruning.ValidateRule(r); err != nil {
t.Fatalf("valid rule rejected: %v", err)
}
}
// A typo in MaxRisk must abort the load; otherwise the engine would skip
// the risk check entirely and let high-risk-write commands pass under
// what the operator thought was a "read" cap.
func TestValidateRule_badMaxRisk(t *testing.T) {
cases := []string{"readd", "Read", "high_risk_write", "anything"}
for _, bad := range cases {
r := &platform.Rule{MaxRisk: bad}
err := pruning.ValidateRule(r)
if err == nil {
t.Errorf("ValidateRule should reject MaxRisk=%q", bad)
continue
}
if !strings.Contains(err.Error(), "max_risk") {
t.Errorf("error should mention max_risk for MaxRisk=%q, got %v", bad, err)
}
}
}
// Identities must come from the closed taxonomy {"user","bot"}. A typo
// like "users" would silently lock out everyone (no command intersects
// the typo), so it must abort.
func TestValidateRule_badIdentity(t *testing.T) {
r := &platform.Rule{Identities: []string{"user", "admin"}}
err := pruning.ValidateRule(r)
if err == nil {
t.Fatalf("ValidateRule should reject identity 'admin'")
}
if !strings.Contains(err.Error(), "identities") {
t.Fatalf("error should mention identities, got %v", err)
}
}
// Malformed doublestar globs are silent fail-open if not caught here
// (doublestar.Match returns an error which matchesAny() ignores).
func TestValidateRule_malformedGlob(t *testing.T) {
cases := []struct {
name string
rule *platform.Rule
}{
{"bad allow", &platform.Rule{Allow: []string{"docs/[abc"}}},
{"bad deny", &platform.Rule{Deny: []string{"docs/[abc"}}},
{"empty allow entry", &platform.Rule{Allow: []string{"", "docs/**"}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := pruning.ValidateRule(c.rule)
if err == nil {
t.Fatalf("ValidateRule should reject %+v", c.rule)
}
})
}
}
// Empty MaxRisk and Empty Identities slices are both "no restriction" --
// not an error.
func TestValidateRule_emptyFieldsAreOk(t *testing.T) {
r := &platform.Rule{
Allow: []string{"docs/**"},
MaxRisk: "",
Identities: nil,
}
if err := pruning.ValidateRule(r); err != nil {
t.Fatalf("empty optional fields should validate, got %v", err)
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package yaml
import "io"
// bytesReader avoids pulling in bytes.NewReader at the call site -- yaml.v3
// only needs an io.Reader. Plain wrapper, no allocation surprises.
type byteReader struct {
data []byte
pos int
}
func bytesReader(data []byte) io.Reader { return &byteReader{data: data} }
func (b *byteReader) Read(p []byte) (int, error) {
if b.pos >= len(b.data) {
return 0, io.EOF
}
n := copy(p, b.data[b.pos:])
b.pos += n
return n, nil
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package yaml parses a Rule from yaml bytes. It is kept separate from the
// public extension/platform package so that platform stays free of yaml
// library dependencies -- plugins constructing a Rule in Go code never
// import yaml, only the file loader does.
//
// This package does **structural** parsing only (yaml syntax + unknown-field
// rejection). Semantic validation (valid MaxRisk enum, valid identity
// values, valid doublestar glob syntax) is centralised in
// internal/pruning.ValidateRule so a single contract is enforced regardless
// of whether the Rule came from yaml or from Plugin.Restrict.
package yaml
import (
"fmt"
gopkgyaml "gopkg.in/yaml.v3"
"github.com/larksuite/cli/extension/platform"
)
// schema is the internal yaml-tagged shape. Mirrors platform.Rule but lives
// here so the public Rule has no yaml tag baggage.
type schema struct {
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
Allow []string `yaml:"allow,omitempty"`
Deny []string `yaml:"deny,omitempty"`
MaxRisk string `yaml:"max_risk,omitempty"`
Identities []string `yaml:"identities,omitempty"`
}
// Parse decodes yaml bytes into a *platform.Rule. Unknown fields are
// rejected so an old binary cannot silently ignore new schema additions
// (forward-compat safeguard).
//
// Semantic validation (MaxRisk taxonomy, identity values, glob syntax) is
// the caller's responsibility -- run the result through
// internal/pruning.ValidateRule before handing it to the engine.
func Parse(data []byte) (*platform.Rule, error) {
var s schema
dec := gopkgyaml.NewDecoder(bytesReader(data))
dec.KnownFields(true)
if err := dec.Decode(&s); err != nil {
return nil, fmt.Errorf("parse policy yaml: %w", err)
}
return &platform.Rule{
Name: s.Name,
Description: s.Description,
Allow: s.Allow,
Deny: s.Deny,
MaxRisk: s.MaxRisk,
Identities: s.Identities,
}, nil
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package yaml_test
import (
"reflect"
"testing"
"github.com/larksuite/cli/extension/platform"
pyaml "github.com/larksuite/cli/internal/pruning/yaml"
)
func TestParse_validRule(t *testing.T) {
data := []byte(`
name: agent-docs-readonly
description: only-read docs
allow:
- docs/**
- contact/**
deny:
- docs/+update
max_risk: read
identities:
- user
`)
rule, err := pyaml.Parse(data)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
want := &platform.Rule{
Name: "agent-docs-readonly",
Description: "only-read docs",
Allow: []string{"docs/**", "contact/**"},
Deny: []string{"docs/+update"},
MaxRisk: "read",
Identities: []string{"user"},
}
if !reflect.DeepEqual(rule, want) {
t.Fatalf("rule = %+v, want %+v", rule, want)
}
}
// Unknown fields must be rejected so the old binary cannot silently ignore
// new schema additions (forward-compat safeguard).
func TestParse_rejectsUnknownFields(t *testing.T) {
data := []byte(`
name: x
mystery_field: oh no
`)
if _, err := pyaml.Parse(data); err == nil {
t.Fatalf("Parse should reject unknown yaml field 'mystery_field'")
}
}
// Semantic validation lives in pruning.ValidateRule. Parse only checks
// structural yaml; an invalid max_risk passes through (validation happens
// downstream).
func TestParse_doesNotValidateSemantics(t *testing.T) {
rule, err := pyaml.Parse([]byte("max_risk: nuclear\n"))
if err != nil {
t.Fatalf("structural parse should succeed, got %v", err)
}
if rule.MaxRisk != "nuclear" {
t.Fatalf("MaxRisk = %q, want passed through as-is", rule.MaxRisk)
}
}
// An entirely empty file is rejected: the resolver should fall back to
// "no rule" by skipping the file in the first place, not by feeding empty
// bytes through Parse.
func TestParse_emptyIsError(t *testing.T) {
if _, err := pyaml.Parse([]byte{}); err == nil {
t.Fatalf("Parse should reject empty input; the resolver handles 'no file' separately")
}
}