mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
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:
73
cmd/build.go
73
cmd/build.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
131
cmd/config/policy.go
Normal 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
177
cmd/config/policy_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
192
cmd/install_guard_test.go
Normal 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 }
|
||||
686
cmd/plugin_integration_test.go
Normal file
686
cmd/plugin_integration_test.go
Normal 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
442
cmd/policy.go
Normal 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
259
cmd/policy_test.go
Normal 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{}
|
||||
}
|
||||
12
cmd/prune.go
12
cmd/prune.go
@@ -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()),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
33
cmd/root.go
33
cmd/root.go
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
37
extension/platform/abort.go
Normal file
37
extension/platform/abort.go
Normal 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 }
|
||||
42
extension/platform/abort_test.go
Normal file
42
extension/platform/abort_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
50
extension/platform/capabilities.go
Normal file
50
extension/platform/capabilities.go
Normal 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
37
extension/platform/doc.go
Normal 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
|
||||
40
extension/platform/errors.go
Normal file
40
extension/platform/errors.go
Normal 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)
|
||||
}
|
||||
31
extension/platform/handler.go
Normal file
31
extension/platform/handler.go
Normal 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
|
||||
110
extension/platform/invocation.go
Normal file
110
extension/platform/invocation.go
Normal 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
|
||||
}
|
||||
48
extension/platform/lifecycle.go
Normal file
48
extension/platform/lifecycle.go
Normal 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
|
||||
}
|
||||
26
extension/platform/plugin.go
Normal file
26
extension/platform/plugin.go
Normal 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
|
||||
}
|
||||
65
extension/platform/register.go
Normal file
65
extension/platform/register.go
Normal 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 = ®istry{}
|
||||
|
||||
type registry struct {
|
||||
mu sync.Mutex
|
||||
plugins []Plugin
|
||||
}
|
||||
|
||||
func (r *registry) add(p Plugin) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins = append(r.plugins, p)
|
||||
}
|
||||
|
||||
func (r *registry) snapshot() []Plugin {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]Plugin, len(r.plugins))
|
||||
copy(out, r.plugins)
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *registry) reset() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins = nil
|
||||
}
|
||||
51
extension/platform/register_test.go
Normal file
51
extension/platform/register_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
36
extension/platform/registrar.go
Normal file
36
extension/platform/registrar.go
Normal 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)
|
||||
}
|
||||
39
extension/platform/rule.go
Normal file
39
extension/platform/rule.go
Normal 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"`
|
||||
}
|
||||
141
extension/platform/selector.go
Normal file
141
extension/platform/selector.go
Normal 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
|
||||
}
|
||||
167
extension/platform/selector_test.go
Normal file
167
extension/platform/selector_test.go
Normal 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())
|
||||
}
|
||||
42
extension/platform/types.go
Normal file
42
extension/platform/types.go
Normal 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
|
||||
}
|
||||
80
extension/platform/types_test.go
Normal file
80
extension/platform/types_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
45
extension/platform/view.go
Normal file
45
extension/platform/view.go
Normal 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
1
go.mod
@@ -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
2
go.sum
@@ -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
132
internal/cmdmeta/meta.go
Normal 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
|
||||
}
|
||||
143
internal/cmdmeta/meta_test.go
Normal file
143
internal/cmdmeta/meta_test.go
Normal 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
20
internal/hook/doc.go
Normal 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
130
internal/hook/emit.go
Normal 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
110
internal/hook/emit_test.go
Normal 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
344
internal/hook/install.go
Normal 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)
|
||||
}
|
||||
11
internal/hook/install_default.go
Normal file
11
internal/hook/install_default.go
Normal 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
|
||||
356
internal/hook/install_test.go
Normal file
356
internal/hook/install_test.go
Normal 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
154
internal/hook/registry.go
Normal 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
18
internal/hook/testing.go
Normal 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
18
internal/hook/walk.go
Normal 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)
|
||||
}
|
||||
}
|
||||
31
internal/platformhost/doc.go
Normal file
31
internal/platformhost/doc.go
Normal 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
|
||||
57
internal/platformhost/error.go
Normal file
57
internal/platformhost/error.go
Normal 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"
|
||||
)
|
||||
294
internal/platformhost/host.go
Normal file
294
internal/platformhost/host.go
Normal 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
|
||||
}
|
||||
358
internal/platformhost/host_test.go
Normal file
358
internal/platformhost/host_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
219
internal/platformhost/staging.go
Normal file
219
internal/platformhost/staging.go
Normal 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
|
||||
}
|
||||
148
internal/platformhost/version.go
Normal file
148
internal/platformhost/version.go
Normal 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
|
||||
}
|
||||
178
internal/platformhost/version_test.go
Normal file
178
internal/platformhost/version_test.go
Normal 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 }
|
||||
144
internal/policydecision/denial.go
Normal file
144
internal/policydecision/denial.go
Normal 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
|
||||
})
|
||||
}
|
||||
98
internal/policydecision/denial_test.go
Normal file
98
internal/policydecision/denial_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
62
internal/pruning/active.go
Normal file
62
internal/pruning/active.go
Normal 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
|
||||
}
|
||||
238
internal/pruning/aggregation_test.go
Normal file
238
internal/pruning/aggregation_test.go
Normal 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
154
internal/pruning/apply.go
Normal 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
331
internal/pruning/engine.go
Normal 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)
|
||||
}
|
||||
}
|
||||
290
internal/pruning/engine_test.go
Normal file
290
internal/pruning/engine_test.go
Normal 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
52
internal/pruning/path.go
Normal 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
|
||||
}
|
||||
106
internal/pruning/resolver.go
Normal file
106
internal/pruning/resolver.go
Normal 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
|
||||
}
|
||||
95
internal/pruning/resolver_test.go
Normal file
95
internal/pruning/resolver_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
96
internal/pruning/source_label_test.go
Normal file
96
internal/pruning/source_label_test.go
Normal 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
|
||||
}
|
||||
81
internal/pruning/strict_mode_skip_test.go
Normal file
81
internal/pruning/strict_mode_skip_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
75
internal/pruning/validate.go
Normal file
75
internal/pruning/validate.go
Normal 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
|
||||
}
|
||||
97
internal/pruning/validate_test.go
Normal file
97
internal/pruning/validate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
24
internal/pruning/yaml/reader.go
Normal file
24
internal/pruning/yaml/reader.go
Normal 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
|
||||
}
|
||||
58
internal/pruning/yaml/schema.go
Normal file
58
internal/pruning/yaml/schema.go
Normal 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
|
||||
}
|
||||
76
internal/pruning/yaml/schema_test.go
Normal file
76
internal/pruning/yaml/schema_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user