diff --git a/cmd/build.go b/cmd/build.go index 6b5d1e5c1..6ca4b1486 100644 --- a/cmd/build.go +++ b/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 } diff --git a/cmd/config/config.go b/cmd/config/config.go index b857e19b0..12dd5bab8 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -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 } diff --git a/cmd/config/policy.go b/cmd/config/policy.go new file mode 100644 index 000000000..7b77f77d2 --- /dev/null +++ b/cmd/config/policy.go @@ -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 - 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:" / "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 ", + 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 +} diff --git a/cmd/config/policy_test.go b/cmd/config/policy_test.go new file mode 100644 index 000000000..88baa4d02 --- /dev/null +++ b/cmd/config/policy_test.go @@ -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 ` 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") + } +} diff --git a/cmd/global_flags_test.go b/cmd/global_flags_test.go index c24d1573a..67ee19839 100644 --- a/cmd/global_flags_test.go +++ b/cmd/global_flags_test.go @@ -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 { diff --git a/cmd/install_guard_test.go b/cmd/install_guard_test.go new file mode 100644 index 000000000..2b09bd666 --- /dev/null +++ b/cmd/install_guard_test.go @@ -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 } diff --git a/cmd/plugin_integration_test.go b/cmd/plugin_integration_test.go new file mode 100644 index 000000000..80a329d96 --- /dev/null +++ b/cmd/plugin_integration_test.go @@ -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= +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 +} diff --git a/cmd/policy.go b/cmd/policy.go new file mode 100644 index 000000000..0f1ba18dc --- /dev/null +++ b/cmd/policy.go @@ -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) +} diff --git a/cmd/policy_test.go b/cmd/policy_test.go new file mode 100644 index 000000000..1d492cf45 --- /dev/null +++ b/cmd/policy_test.go @@ -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{} +} diff --git a/cmd/prune.go b/cmd/prune.go index 1a3f05f52..0fb2e9e33 100644 --- a/cmd/prune.go +++ b/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()), diff --git a/cmd/prune_test.go b/cmd/prune_test.go index 8d0594737..8fcd22f23 100644 --- a/cmd/prune_test.go +++ b/cmd/prune_test.go @@ -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") + } +} diff --git a/cmd/root.go b/cmd/root.go index aaced60c3..93e3cdcb1 100644 --- a/cmd/root.go +++ b/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 } } diff --git a/cmd/root_test.go b/cmd/root_test.go index 071889881..2b26c2b7d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -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) + } + }) + } +} diff --git a/extension/platform/abort.go b/extension/platform/abort.go new file mode 100644 index 000000000..9ec99d8b5 --- /dev/null +++ b/extension/platform/abort.go @@ -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 } diff --git a/extension/platform/abort_test.go b/extension/platform/abort_test.go new file mode 100644 index 000000000..364f72fb5 --- /dev/null +++ b/extension/platform/abort_test.go @@ -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") + } +} diff --git a/extension/platform/capabilities.go b/extension/platform/capabilities.go new file mode 100644 index 000000000..fc517c426 --- /dev/null +++ b/extension/platform/capabilities.go @@ -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 +} diff --git a/extension/platform/doc.go b/extension/platform/doc.go new file mode 100644 index 000000000..d763410a9 --- /dev/null +++ b/extension/platform/doc.go @@ -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 diff --git a/extension/platform/errors.go b/extension/platform/errors.go new file mode 100644 index 000000000..aa6df049a --- /dev/null +++ b/extension/platform/errors.go @@ -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) +} diff --git a/extension/platform/handler.go b/extension/platform/handler.go new file mode 100644 index 000000000..2a0cc802b --- /dev/null +++ b/extension/platform/handler.go @@ -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 diff --git a/extension/platform/invocation.go b/extension/platform/invocation.go new file mode 100644 index 000000000..dc8dcc106 --- /dev/null +++ b/extension/platform/invocation.go @@ -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 +} diff --git a/extension/platform/lifecycle.go b/extension/platform/lifecycle.go new file mode 100644 index 000000000..e7e8753d1 --- /dev/null +++ b/extension/platform/lifecycle.go @@ -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 +} diff --git a/extension/platform/plugin.go b/extension/platform/plugin.go new file mode 100644 index 000000000..303f677b5 --- /dev/null +++ b/extension/platform/plugin.go @@ -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 +} diff --git a/extension/platform/register.go b/extension/platform/register.go new file mode 100644 index 000000000..bc95027d2 --- /dev/null +++ b/extension/platform/register.go @@ -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 +} diff --git a/extension/platform/register_test.go b/extension/platform/register_test.go new file mode 100644 index 000000000..eb6e59e5b --- /dev/null +++ b/extension/platform/register_test.go @@ -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") + } +} diff --git a/extension/platform/registrar.go b/extension/platform/registrar.go new file mode 100644 index 000000000..8774050bf --- /dev/null +++ b/extension/platform/registrar.go @@ -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) +} diff --git a/extension/platform/rule.go b/extension/platform/rule.go new file mode 100644 index 000000000..0d40c8dfe --- /dev/null +++ b/extension/platform/rule.go @@ -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"` +} diff --git a/extension/platform/selector.go b/extension/platform/selector.go new file mode 100644 index 000000000..514e97910 --- /dev/null +++ b/extension/platform/selector.go @@ -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 +} diff --git a/extension/platform/selector_test.go b/extension/platform/selector_test.go new file mode 100644 index 000000000..83aa99e07 --- /dev/null +++ b/extension/platform/selector_test.go @@ -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()) +} diff --git a/extension/platform/types.go b/extension/platform/types.go new file mode 100644 index 000000000..b6ec76296 --- /dev/null +++ b/extension/platform/types.go @@ -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 +} diff --git a/extension/platform/types_test.go b/extension/platform/types_test.go new file mode 100644 index 000000000..4e2ffc01d --- /dev/null +++ b/extension/platform/types_test.go @@ -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") + } +} diff --git a/extension/platform/view.go b/extension/platform/view.go new file mode 100644 index 000000000..8aaac5492 --- /dev/null +++ b/extension/platform/view.go @@ -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) +} diff --git a/go.mod b/go.mod index 770cdf589..1e07c7d7f 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 451a3591d..e6026a683 100644 --- a/go.sum +++ b/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= diff --git a/internal/cmdmeta/meta.go b/internal/cmdmeta/meta.go new file mode 100644 index 000000000..841d8749b --- /dev/null +++ b/internal/cmdmeta/meta.go @@ -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 +} diff --git a/internal/cmdmeta/meta_test.go b/internal/cmdmeta/meta_test.go new file mode 100644 index 000000000..0990cbd4c --- /dev/null +++ b/internal/cmdmeta/meta_test.go @@ -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) + } +} diff --git a/internal/hook/doc.go b/internal/hook/doc.go new file mode 100644 index 000000000..507ae6317 --- /dev/null +++ b/internal/hook/doc.go @@ -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 diff --git a/internal/hook/emit.go b/internal/hook/emit.go new file mode 100644 index 000000000..c7cf6ed26 --- /dev/null +++ b/internal/hook/emit.go @@ -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 +} diff --git a/internal/hook/emit_test.go b/internal/hook/emit_test.go new file mode 100644 index 000000000..df6b0af61 --- /dev/null +++ b/internal/hook/emit_test.go @@ -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) + } +} diff --git a/internal/hook/install.go b/internal/hook/install.go new file mode 100644 index 000000000..3f6fae3cd --- /dev/null +++ b/internal/hook/install.go @@ -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) +} diff --git a/internal/hook/install_default.go b/internal/hook/install_default.go new file mode 100644 index 000000000..778c7e08e --- /dev/null +++ b/internal/hook/install_default.go @@ -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 diff --git a/internal/hook/install_test.go b/internal/hook/install_test.go new file mode 100644 index 000000000..2b5e490cf --- /dev/null +++ b/internal/hook/install_test.go @@ -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=" // 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 +} diff --git a/internal/hook/registry.go b/internal/hook/registry.go new file mode 100644 index 000000000..47bf6e928 --- /dev/null +++ b/internal/hook/registry.go @@ -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) + } +} diff --git a/internal/hook/testing.go b/internal/hook/testing.go new file mode 100644 index 000000000..ccbd5eccb --- /dev/null +++ b/internal/hook/testing.go @@ -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 + } +} diff --git a/internal/hook/walk.go b/internal/hook/walk.go new file mode 100644 index 000000000..fe5b0dbf9 --- /dev/null +++ b/internal/hook/walk.go @@ -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) + } +} diff --git a/internal/platformhost/doc.go b/internal/platformhost/doc.go new file mode 100644 index 000000000..1daf1cac5 --- /dev/null +++ b/internal/platformhost/doc.go @@ -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 diff --git a/internal/platformhost/error.go b/internal/platformhost/error.go new file mode 100644 index 000000000..78a3cfca0 --- /dev/null +++ b/internal/platformhost/error.go @@ -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" +) diff --git a/internal/platformhost/host.go b/internal/platformhost/host.go new file mode 100644 index 000000000..a45234058 --- /dev/null +++ b/internal/platformhost/host.go @@ -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: "", + 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 "" +} + +// 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 +} diff --git a/internal/platformhost/host_test.go b/internal/platformhost/host_test.go new file mode 100644 index 000000000..26f315f1b --- /dev/null +++ b/internal/platformhost/host_test.go @@ -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) + } +} diff --git a/internal/platformhost/staging.go b/internal/platformhost/staging.go new file mode 100644 index 000000000..816767804 --- /dev/null +++ b/internal/platformhost/staging.go @@ -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 +} diff --git a/internal/platformhost/version.go b/internal/platformhost/version.go new file mode 100644 index 000000000..4935b5b46 --- /dev/null +++ b/internal/platformhost/version.go @@ -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 +} diff --git a/internal/platformhost/version_test.go b/internal/platformhost/version_test.go new file mode 100644 index 000000000..9f28dcdae --- /dev/null +++ b/internal/platformhost/version_test.go @@ -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 } diff --git a/internal/policydecision/denial.go b/internal/policydecision/denial.go new file mode 100644 index 000000000..01219ee3a --- /dev/null +++ b/internal/policydecision/denial.go @@ -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 + }) +} diff --git a/internal/policydecision/denial_test.go b/internal/policydecision/denial_test.go new file mode 100644 index 000000000..270e98b8f --- /dev/null +++ b/internal/policydecision/denial_test.go @@ -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]) + } + } +} diff --git a/internal/pruning/active.go b/internal/pruning/active.go new file mode 100644 index 000000000..052824ae8 --- /dev/null +++ b/internal/pruning/active.go @@ -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 +} diff --git a/internal/pruning/aggregation_test.go b/internal/pruning/aggregation_test.go new file mode 100644 index 000000000..54b4640d2 --- /dev/null +++ b/internal/pruning/aggregation_test.go @@ -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") + } +} diff --git a/internal/pruning/apply.go b/internal/pruning/apply.go new file mode 100644 index 000000000..e28fb95a5 --- /dev/null +++ b/internal/pruning/apply.go @@ -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 +} diff --git a/internal/pruning/engine.go b/internal/pruning/engine.go new file mode 100644 index 000000000..bc63ef64f --- /dev/null +++ b/internal/pruning/engine.go @@ -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) + } +} diff --git a/internal/pruning/engine_test.go b/internal/pruning/engine_test.go new file mode 100644 index 000000000..aa491ca10 --- /dev/null +++ b/internal/pruning/engine_test.go @@ -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 +} diff --git a/internal/pruning/path.go b/internal/pruning/path.go new file mode 100644 index 000000000..64681cede --- /dev/null +++ b/internal/pruning/path.go @@ -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] " -> "update"). +func useName(cmd *cobra.Command) string { + name := cmd.Use + if i := strings.IndexByte(name, ' '); i >= 0 { + name = name[:i] + } + return name +} diff --git a/internal/pruning/resolver.go b/internal/pruning/resolver.go new file mode 100644 index 000000000..b2e912285 --- /dev/null +++ b/internal/pruning/resolver.go @@ -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 +} diff --git a/internal/pruning/resolver_test.go b/internal/pruning/resolver_test.go new file mode 100644 index 000000000..ca16f1cd7 --- /dev/null +++ b/internal/pruning/resolver_test.go @@ -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) + } +} diff --git a/internal/pruning/source_label_test.go b/internal/pruning/source_label_test.go new file mode 100644 index 000000000..60b69f831 --- /dev/null +++ b/internal/pruning/source_label_test.go @@ -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 +} diff --git a/internal/pruning/strict_mode_skip_test.go b/internal/pruning/strict_mode_skip_test.go new file mode 100644 index 000000000..ea9b52390 --- /dev/null +++ b/internal/pruning/strict_mode_skip_test.go @@ -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) + } +} diff --git a/internal/pruning/validate.go b/internal/pruning/validate.go new file mode 100644 index 000000000..1579bcef0 --- /dev/null +++ b/internal/pruning/validate.go @@ -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 +} diff --git a/internal/pruning/validate_test.go b/internal/pruning/validate_test.go new file mode 100644 index 000000000..a1563e485 --- /dev/null +++ b/internal/pruning/validate_test.go @@ -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) + } +} diff --git a/internal/pruning/yaml/reader.go b/internal/pruning/yaml/reader.go new file mode 100644 index 000000000..41e85a4c9 --- /dev/null +++ b/internal/pruning/yaml/reader.go @@ -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 +} diff --git a/internal/pruning/yaml/schema.go b/internal/pruning/yaml/schema.go new file mode 100644 index 000000000..bdeb1ced2 --- /dev/null +++ b/internal/pruning/yaml/schema.go @@ -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 +} diff --git a/internal/pruning/yaml/schema_test.go b/internal/pruning/yaml/schema_test.go new file mode 100644 index 000000000..82c64d83b --- /dev/null +++ b/internal/pruning/yaml/schema_test.go @@ -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") + } +}