diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ffa82c37..6ae1b03e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - name: Build run: go build ./... - name: Vet - run: go vet ./... + run: go vet -tags testing ./... - name: Check formatting run: | unformatted=$(gofmt -l .) @@ -63,7 +63,7 @@ jobs: - name: Fetch meta data run: python3 scripts/fetch_meta.py - name: Run tests - run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... + run: go test -tags testing -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/... lint: needs: fast-gate @@ -81,7 +81,7 @@ jobs: - name: Fetch meta data run: python3 scripts/fetch_meta.py - name: Run golangci-lint - run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main + run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --build-tags=testing --new-from-rev=origin/main coverage: needs: fast-gate @@ -99,7 +99,7 @@ jobs: - name: Run tests with coverage run: | packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/') - go test -race -coverprofile=coverage.txt -covermode=atomic $packages + go test -tags testing -race -coverprofile=coverage.txt -covermode=atomic $packages - name: Upload coverage to Codecov uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6 with: @@ -153,14 +153,14 @@ jobs: run: | # Analyze current HEAD (strip line:col for stable diff across line shifts) # Filter "go: downloading ..." lines to avoid false diffs from module cache state - go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \ + go run golang.org/x/tools/cmd/deadcode@v0.31.0 -tags=testing -test ./... 2>&1 | \ grep -v '^go: ' | \ sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-head.txt # Analyze base branch via worktree git worktree add -q /tmp/dc-base "origin/${{ github.base_ref }}" (cd /tmp/dc-base && python3 scripts/fetch_meta.py && \ - go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./... 2>&1 | \ + go run golang.org/x/tools/cmd/deadcode@v0.31.0 -tags=testing -test ./... 2>&1 | \ grep -v '^go: ' | \ sed 's/:[0-9][0-9]*:[0-9][0-9]*:/:/' | sort > /tmp/dc-base.txt) || { echo "::warning::Failed to analyze base branch — skipping incremental dead code check" diff --git a/Makefile b/Makefile index 7733335b4..e0a8b5fd4 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ DATE := $(shell date +%Y-%m-%d) LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) PREFIX ?= /usr/local -.PHONY: all build vet test unit-test integration-test install uninstall clean fetch_meta gitleaks +.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks all: test @@ -19,15 +19,37 @@ build: fetch_meta go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) . vet: fetch_meta - go vet ./... + go vet -tags testing ./... +# fmt-check fails when any file would be reformatted by gofmt. Keep this +# in sync with the fast-gate "Check formatting" step in CI. +fmt-check: + @unformatted=$$(gofmt -l . | grep -v '^\.claude/' || true); \ + if [ -n "$$unformatted" ]; then \ + echo "Unformatted Go files:"; \ + echo "$$unformatted"; \ + echo "Run 'gofmt -w .' and commit."; \ + exit 1; \ + fi + +# unit-test passes -tags testing because public-SDK packages gate test-only +# helpers (e.g. platform.ResetForTesting) behind //go:build testing. The +# ./extension/... package list keeps the public plugin SDK in the default +# test matrix. unit-test: fetch_meta - go test -race -gcflags="all=-N -l" -count=1 ./cmd/... ./internal/... ./shortcuts/... + go test -tags testing -race -gcflags="all=-N -l" -count=1 \ + ./cmd/... ./internal/... ./shortcuts/... ./extension/... + +# examples-build keeps the shipped plugin-SDK examples compilable. If this +# breaks, the plugin author guide's "go build ./..." path is broken. +examples-build: + go build ./extension/platform/examples/audit-observer + go build ./extension/platform/examples/readonly-policy integration-test: build go test -v -count=1 ./tests/... -test: vet unit-test integration-test +test: vet fmt-check unit-test examples-build integration-test install: build install -d $(PREFIX)/bin diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 6a27ee6cf..a519ccc0b 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -50,7 +50,7 @@ For AI agents: this command blocks until the user completes authorization in the browser. Run it in the background and retrieve the verification URL from its output.`, RunE: func(cmd *cobra.Command, args []string) error { if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot { - return output.ErrWithHint(output.ExitValidation, "strict_mode", + return output.ErrWithHint(output.ExitValidation, "command_denied", fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode), "if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") } diff --git a/cmd/build.go b/cmd/build.go index 6b5d1e5c1..b7db05c2d 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -19,7 +19,9 @@ import ( cmdupdate "github.com/larksuite/cli/cmd/update" _ "github.com/larksuite/cli/events" "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/keychain" "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{} @@ -124,10 +131,75 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B service.RegisterServiceCommandsWithContext(ctx, rootCmd, f) shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) + installUnknownSubcommandGuard(rootCmd) + // Prune commands incompatible with strict mode. if mode := f.ResolveStrictMode(ctx); mode.IsActive() { 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 []cmdpolicy.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 + } + } + + // Snapshot the plugin inventory so `config plugins show` can answer + // "what plugins / hooks / rules are currently in effect" without + // re-calling plugin methods at display time. + recordInventory(installResult) + + return f, rootCmd, registry } diff --git a/cmd/config/config.go b/cmd/config/config.go index b857e19b0..ddcb10723 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -14,6 +14,11 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Global CLI configuration management", + Long: `Global CLI configuration management. + +Diagnostic (hidden) commands — runnable but omitted from --help: + lark-cli config policy show Inspect active user-layer policy + lark-cli config plugins show Inspect installed plugins and hooks`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first // PersistentPreRun[E] found walking up the chain, so the root-level @@ -31,6 +36,8 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdConfigShow(f, nil)) cmd.AddCommand(NewCmdConfigDefaultAs(f)) cmd.AddCommand(NewCmdConfigStrictMode(f)) + cmd.AddCommand(NewCmdConfigPolicy(f)) + cmd.AddCommand(NewCmdConfigPlugins(f)) return cmd } diff --git a/cmd/config/plugins.go b/cmd/config/plugins.go new file mode 100644 index 000000000..ba9180bfe --- /dev/null +++ b/cmd/config/plugins.go @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +// NewCmdConfigPlugins exposes the plugin inventory diagnostic command. +// +// `config policy show` is intentionally focused on the user-layer Rule +// (Restrict). Plugins also contribute hooks (Observe / Wrap / Lifecycle) +// that are not policy gates but still mutate the CLI's runtime behaviour. +// This command surfaces both halves so an operator can answer "what is +// this binary doing differently from stock lark-cli?" in one place. +// +// Like config policy show, the dispatch path is exempt from policy +// enforcement (see internal/cmdpolicy/diagnostic.go) so it remains +// usable under any Rule. +func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "plugins", + Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context + Short: "Inspect installed plugins and their hook contributions", + // Same leaf-level no-op as config policy: the parent `config` + // group's PersistentPreRunE requires builtin credential, but + // this is a read-only diagnostic that must work everywhere. + PersistentPreRunE: func(c *cobra.Command, _ []string) error { + c.SilenceUsage = true + return nil + }, + } + cmd.AddCommand(newCmdConfigPluginsShow(f)) + return cmd +} + +func newCmdConfigPluginsShow(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "List successfully installed plugins, their rules, and registered hooks", + Long: `Print every plugin that committed during bootstrap, including: + + - name / version / capabilities (FailurePolicy, Restricts, RequiredCLIVersion) + - rule (when the plugin called r.Restrict) + - hooks: observers (Before / After), wrappers, lifecycle handlers + +Hooks are attributed by their namespaced name -- the framework prepends +the plugin name as the prefix at registration time, so an entry +"secaudit.audit-pre" belongs to plugin "secaudit".`, + RunE: func(cmd *cobra.Command, args []string) error { + return runConfigPluginsShow(f) + }, + } +} + +func runConfigPluginsShow(f *cmdutil.Factory) error { + inv := internalplatform.GetActiveInventory() + if inv == nil { + // Always emit the same field set as the populated branch so + // AI agents and CI scripts don't have to branch on whether + // `total` is present. `note` makes the unusual state explicit + // for human readers. + output.PrintJson(f.IOStreams.Out, map[string]any{ + "plugins": []any{}, + "total": 0, + "note": "no inventory recorded; bootstrap did not finish", + }) + return nil + } + + plugins := make([]map[string]any, 0, len(inv.Plugins)) + for _, p := range inv.Plugins { + entry := map[string]any{ + "name": p.Name, + "version": p.Version, + "capabilities": p.Capabilities, + } + if p.Rule != nil { + entry["rule"] = p.Rule + } + entry["hooks"] = map[string]any{ + "observers": p.Observers, + "wrappers": p.Wrappers, + "lifecycle": p.Lifecycles, + "count": len(p.Observers) + len(p.Wrappers) + len(p.Lifecycles), + } + plugins = append(plugins, entry) + } + output.PrintJson(f.IOStreams.Out, map[string]any{ + "plugins": plugins, + "total": len(plugins), + }) + return nil +} diff --git a/cmd/config/policy.go b/cmd/config/policy.go new file mode 100644 index 000000000..bb1e38a3a --- /dev/null +++ b/cmd/config/policy.go @@ -0,0 +1,115 @@ +// 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/cmdpolicy" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" +) + +// NewCmdConfigPolicy returns the `config policy` group. Subcommands: +// +// show - print the resolved user-layer Rule + source + denied count +// +// The command writes 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", + Hidden: true, // diagnostic-only; kept callable, omitted from --help to reduce noise + Short: "Inspect the user-layer command policy", + // The parent `config` group has a PersistentPreRunE that calls + // RequireBuiltinCredentialProvider, which returns external_provider + // when env credentials are set. `policy show` is a READ-ONLY + // diagnostic command and does not modify credentials, so it must + // work regardless of which credential provider is active. A + // leaf-level no-op PersistentPreRunE wins under cobra's "first + // walking up" rule and bypasses the parent check. + PersistentPreRunE: func(c *cobra.Command, _ []string) error { + c.SilenceUsage = true + return nil + }, + } + cmd.AddCommand(newCmdConfigPolicyShow(f)) + return cmd +} + +func newCmdConfigPolicyShow(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "show", + Hidden: true, // diagnostic-only; kept callable, omitted from --help to reduce noise + 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 := cmdpolicy.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(cmdpolicy.SourceNone), + "note": "no policy recorded; bootstrap did not run pruning", + }) + return nil + } + + // `yaml_path` and the yaml-source `source_name` leak the user's home + // directory in their raw form (e.g. /Users/alice/.lark-cli/policy.yml). + // `config policy show` is read by AI agents and CI logs, so we redact + // the prefix before emitting -- same rule as policySourceLabel for + // envelopes. For plugin sources, Source.Name is the plugin name (no + // path) and is surfaced verbatim. + sourceName := active.Source.Name + if active.Source.Kind == cmdpolicy.SourceYAML { + sourceName = cmdpolicy.RedactHomeDir(sourceName) + } + out := map[string]any{ + "source": string(active.Source.Kind), + "source_name": sourceName, + "yaml_path": cmdpolicy.RedactHomeDir(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, + "allow_unannotated": active.Rule.AllowUnannotated, + } + } + // Surface the yaml-shadowed case so a user wondering "why is my + // yaml ignored?" sees it immediately. + if active.Source.Kind == cmdpolicy.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 +} diff --git a/cmd/config/policy_test.go b/cmd/config/policy_test.go new file mode 100644 index 000000000..d4ec82361 --- /dev/null +++ b/cmd/config/policy_test.go @@ -0,0 +1,144 @@ +// 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/cmdpolicy" + "github.com/larksuite/cli/internal/cmdutil" +) + +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) { + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.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) { + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.ResetActiveForTesting) + + rule := &platform.Rule{ + Name: "secaudit", + Allow: []string{"docs/**"}, + MaxRisk: "read", + } + cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ + Rule: rule, + Source: cmdpolicy.ResolveSource{ + Kind: cmdpolicy.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) { + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.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) + } + + cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ + Rule: &platform.Rule{Name: "plug"}, + Source: cmdpolicy.ResolveSource{ + Kind: cmdpolicy.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()) + } +} + +// Regression: the parent `config` command declares a PersistentPreRunE +// that calls RequireBuiltinCredentialProvider; env credentials cause +// it to return external_provider. `config policy` is a diagnostic +// group that must not be blocked by that check. The group declares +// its own no-op PersistentPreRunE so cobra's "first walking up from +// leaf" picks ours over the config parent's. +func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) { + f, _, _ := newPolicyTestFactory() + group := NewCmdConfigPolicy(f) + if group.PersistentPreRunE == nil { + t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent") + } + if err := group.PersistentPreRunE(group, nil); err != nil { + t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err) + } +} 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/platform_bootstrap.go b/cmd/platform_bootstrap.go new file mode 100644 index 000000000..1b09ed53f --- /dev/null +++ b/cmd/platform_bootstrap.go @@ -0,0 +1,257 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "fmt" + "io" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/hook" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +// 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 InstallAll phase; nil/empty is fine. +func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.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 := cmdpolicy.Resolve(pluginRules, yamlPath) + if err != nil { + // Yaml-only failures are fail-OPEN at the caller (warn and + // continue), but the active-policy snapshot is process-global + // and may still carry data from a previous build in long-lived + // embedders / tests. Clear it explicitly so `config policy + // show` reports "no policy" instead of a stale rule that + // doesn't reflect the current command tree. + cmdpolicy.SetActive(nil) + return err + } + if rule == nil { + cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ + Source: source, + YAMLPath: yamlPath, + }) + return nil + } + + engine := cmdpolicy.New(rule) + decisions := engine.EvaluateAll(rootCmd) + denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name) + cmdpolicy.Apply(rootCmd, denied) + + // Record the active policy so `config policy show` can read it. + cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{ + Rule: rule, + Source: source, + YAMLPath: yamlPath, + DeniedPaths: len(denied), + }) + return nil +} + +// installPluginsAndHooks runs the InstallAll phase on the globally- +// registered plugins, returning the Plugin.Restrict contributions for +// cmdpolicy 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) (*internalplatform.InstallResult, error) { + plugins := platform.RegisteredPlugins() + if len(plugins) == 0 { + return &internalplatform.InstallResult{Registry: nil}, nil + } + return internalplatform.InstallAll(plugins, errOut) +} + +// recordInventory builds and stores the plugin inventory snapshot for +// diagnostic commands (config plugins show) to read at runtime. Called +// once from build.go after applyUserPolicyPruning + wireHooks succeed. +func recordInventory(installResult *internalplatform.InstallResult) { + if installResult == nil { + internalplatform.SetActiveInventory(nil) + return + } + pluginSrcs := make([]internalplatform.PluginInventorySource, 0, len(installResult.Plugins)) + for _, p := range installResult.Plugins { + pluginSrcs = append(pluginSrcs, internalplatform.PluginInventorySource{ + Name: p.Name, + Version: p.Version, + Capabilities: p.Capabilities, + }) + } + ruleSrcs := make([]internalplatform.RuleInventorySource, 0, len(installResult.PluginRules)) + for _, r := range installResult.PluginRules { + if r.Rule == nil { + continue + } + idents := make([]string, len(r.Rule.Identities)) + for i, id := range r.Rule.Identities { + idents[i] = string(id) + } + ruleSrcs = append(ruleSrcs, internalplatform.RuleInventorySource{ + PluginName: r.PluginName, + Allow: r.Rule.Allow, + Deny: r.Rule.Deny, + MaxRisk: string(r.Rule.MaxRisk), + Identities: idents, + RuleName: r.Rule.Name, + Desc: r.Rule.Description, + AllowUnannotated: r.Rule.AllowUnannotated, + }) + } + internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs)) +} + +// 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) +} + +// 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 so the view survives +// strict-mode's RemoveCommand+AddCommand replacement of the +// underlying *cobra.Command pointer. For now this is acceptable +// because user-layer cmdpolicy 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 cmdpolicy.CanonicalPath(v.cmd) +} + +func (v cobraCommandView) Domain() string { + 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() (platform.Risk, 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 platform.Risk(r), true + } + } + return "", false +} + +func (v cobraCommandView) Identities() []platform.Identity { + for c := v.cmd; c != nil; c = c.Parent() { + if c.Annotations == nil { + continue + } + if raw, ok := c.Annotations["lark:supportedIdentities"]; ok && raw != "" { + parts := splitCSV(raw) + out := make([]platform.Identity, len(parts)) + for i, p := range parts { + out[i] = platform.Identity(p) + } + return out + } + } + 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. 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 path of /policy.yml. +// +// The base directory honours LARKSUITE_CLI_CONFIG_DIR (via +// core.GetBaseConfigDir) so that test isolation, container deployments +// and per-Agent config overrides all see a consistent policy location. +// Using vfs.UserHomeDir directly here would silently bypass the env +// override and route every test through the real ~/.lark-cli. +// +// The error return is retained for caller compatibility but is always +// nil today: GetBaseConfigDir falls back to a relative ".lark-cli" when +// the home dir can't be resolved, and the resolver already treats a +// missing file as "no policy". +func userPolicyPath() (string, error) { + return filepath.Join(core.GetBaseConfigDir(), 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 policy enforcement so the user can fix the typo. Plugin-supplied +// rules are 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/platform_bootstrap_test.go b/cmd/platform_bootstrap_test.go new file mode 100644 index 000000000..4fe814453 --- /dev/null +++ b/cmd/platform_bootstrap_test.go @@ -0,0 +1,268 @@ +// 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. +// +// LARKSUITE_CLI_CONFIG_DIR is force-set to the same path. Without that +// override, a developer running the tests with a personal +// LARKSUITE_CLI_CONFIG_DIR exported in their shell (or a CI runner with +// a baked-in value) would resolve userPolicyPath() to their real +// machine and bleed unrelated yaml into the test fixtures. With the +// override pinned here, the test is hermetic regardless of the host +// environment. +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) + } + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir) + 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 != "command_denied" { + t.Fatalf("expected command_denied 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/platform_guards.go b/cmd/platform_guards.go new file mode 100644 index 000000000..714d147fd --- /dev/null +++ b/cmd/platform_guards.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/hook" + "github.com/larksuite/cli/internal/output" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +// 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 *internalplatform.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": internalplatform.ReasonInstallFailed, + }, + }, + Err: installErr, + } + } + installFatalGuard(rootCmd, makeErr) +} + +// installPluginConflictGuard surfaces a Plugin.Restrict() configuration +// error (single plugin invalid Rule or multiple plugins each contributing +// Restrict). The design 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 := internalplatform.ReasonInvalidRule + if errors.Is(err, cmdpolicy.ErrMultipleRestricts) { + envelopeType = "plugin_conflict" + reasonCode = internalplatform.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. +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) + } +} diff --git a/cmd/platform_guards_test.go b/cmd/platform_guards_test.go new file mode 100644 index 000000000..6928c38f0 --- /dev/null +++ b/cmd/platform_guards_test.go @@ -0,0 +1,210 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/hook" + "github.com/larksuite/cli/internal/output" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +// 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. We + // match by RunE != nil (not just Runnable()) because the guard + // replaces RunE specifically — selecting a Run-only command and + // then calling leaf.RunE would nil-deref. + var leaf *cobra.Command + walk(auth, func(c *cobra.Command) { + if leaf != nil { + return + } + if c != auth && c.RunE != nil { + leaf = c + } + }) + if leaf == nil { + t.Skip("no auth subcommand with RunE 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"] != internalplatform.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 { + go func() { + defer wg.Done() + err := m.Fn(func(context.Context, platform.Invocation) error { return nil })( + context.Background(), stubInvocation{}) + 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() (platform.Risk, bool) { return "", false } +func (stubView) Identities() []platform.Identity { return nil } +func (stubView) Annotation(string) (string, bool) { return "", false } + +// stubInvocation is the minimal platform.Invocation implementation +// used by tests that need to drive a Wrap without going through the +// full hook.Install pipeline. +type stubInvocation struct{} + +func (stubInvocation) Cmd() platform.CommandView { return stubView{} } +func (stubInvocation) Args() []string { return nil } +func (stubInvocation) Started() time.Time { return time.Time{} } +func (stubInvocation) Err() error { return nil } +func (stubInvocation) DeniedByPolicy() bool { return false } +func (stubInvocation) DenialLayer() string { return "" } +func (stubInvocation) DenialPolicySource() string { return "" } +func (stubInvocation) StrictMode() (string, bool) { return "", false } +func (stubInvocation) Identity() (string, bool) { return "", false } diff --git a/cmd/plugin_integration_test.go b/cmd/plugin_integration_test.go new file mode 100644 index 000000000..e439adbfc --- /dev/null +++ b/cmd/plugin_integration_test.go @@ -0,0 +1,684 @@ +// 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/cmdpolicy" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/hook" + "github.com/larksuite/cli/internal/output" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +// 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 internalplatform.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 := internalplatform.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 := internalplatform.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 -> cmdpolicy.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 + 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 := internalplatform.InstallAll(platform.RegisteredPlugins(), nil) + if err != nil { + t.Fatalf("InstallAll: %v", err) + } + + root, leaf := syntheticTree() + // Simulate cmdpolicy.Apply marking leaf as denied. + leaf.Hidden = true + leaf.DisableFlagParsing = true + if leaf.Annotations == nil { + leaf.Annotations = map[string]string{} + } + leaf.Annotations["lark:policy_denied_layer"] = "policy" + leaf.Annotations["lark:policy_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) + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.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) + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.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) + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.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) + cmdpolicy.ResetActiveForTesting() + t.Cleanup(cmdpolicy.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 := internalplatform.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 := internalplatform.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/prune.go b/cmd/prune.go index 1a3f05f52..5d7d18828 100644 --- a/cmd/prune.go +++ b/cmd/prune.go @@ -7,10 +7,12 @@ import ( "fmt" "slices" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" - "github.com/spf13/cobra" ) // pruneForStrictMode removes commands incompatible with the active strict mode. @@ -43,15 +45,66 @@ 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. + // + // Args + PersistentPreRunE overrides mirror cmdpolicy/apply.go::installDenyStub: + // + // - Args=ArbitraryArgs: with DisableFlagParsing the user's flags + // look like positional args; the original child's Args validator + // (e.g. cobra.NoArgs) would fire BEFORE RunE and produce a + // cobra usage error instead of our strict_mode envelope. + // + // - PersistentPreRunE no-op: cmd/auth/auth.go declares a parent + // PersistentPreRunE that returns external_provider when env + // credentials are set. Cobra's "first wins walking up" would + // pick auth's instead of our denial. A leaf-level no-op makes + // cobra stop here and proceed to the wrapped RunE. + // + // strict-mode keeps its short Message + independent Hint and + // composes the shared detail.* / wrapped-CommandDeniedError shape + // by hand; BuildDenialError would override Message with the + // CommandDeniedError.Error() long form. + stubMessage := fmt.Sprintf( + "strict mode is %q, only %s-identity commands are available", + mode, mode.ForcedIdentity()) + const stubHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)" + denial := cmdpolicy.Denial{ + Layer: cmdpolicy.LayerStrictMode, + PolicySource: "strict-mode", + ReasonCode: "identity_not_supported", + Reason: stubMessage, + } return &cobra.Command{ Use: child.Use, Aliases: append([]string(nil), child.Aliases...), Hidden: true, DisableFlagParsing: true, - 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()), - "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") + Args: cobra.ArbitraryArgs, + Annotations: map[string]string{ + cmdpolicy.AnnotationDenialLayer: cmdpolicy.LayerStrictMode, + cmdpolicy.AnnotationDenialSource: "strict-mode", + }, + PersistentPreRunE: func(c *cobra.Command, _ []string) error { + c.SilenceUsage = true + return nil + }, + RunE: func(c *cobra.Command, _ []string) error { + cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial) + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "command_denied", + Message: stubMessage, + Hint: stubHint, + Detail: cmdpolicy.DenialDetailMap(cd), + }, + Err: cd, + } }, } } diff --git a/cmd/prune_test.go b/cmd/prune_test.go index 8d0594737..c002de22e 100644 --- a/cmd/prune_test.go +++ b/cmd/prune_test.go @@ -4,11 +4,15 @@ package cmd import ( + "errors" "strings" "testing" + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -198,3 +202,132 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T t.Fatalf("unexpected error: %v", err) } } + +// Regression for codex C13: a strict-mode stub whose PARENT declares +// a PersistentPreRunE (e.g. cmd/auth/auth.go's external_provider +// check on env credentials) must surface the strict_mode envelope, +// not the parent's error. Cobra's "first PersistentPreRunE wins +// walking up from leaf" semantics will pick the parent's unless the +// stub itself carries its own. +// +// Fix: strictModeStubFrom installs a no-op PersistentPreRunE so cobra +// stops at the stub and proceeds to its RunE. +func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) { + root := newTestTree() + pruneForStrictMode(root, core.StrictModeBot) + stub := findCmd(root, "auth", "login") + if stub == nil { + t.Fatal("auth/login stub should exist after StrictModeBot") + } + if stub.PersistentPreRunE == nil { + t.Fatal("strict-mode stub must declare PersistentPreRunE on leaf") + } + if err := stub.PersistentPreRunE(stub, nil); err != nil { + t.Errorf("strict-mode stub PersistentPreRunE should be no-op, got %v", err) + } +} + +// Regression for codex H13: strict-mode stub must accept arbitrary +// positional args. With DisableFlagParsing=true, a user passing +// `auth login --scope ...` looks like 4 positional args; the original +// cobra.Args validator would surface a usage error BEFORE strict-mode +// stub's RunE. +func TestStrictModeStub_BypassesArgsValidator(t *testing.T) { + root := newTestTree() + pruneForStrictMode(root, core.StrictModeBot) + stub := findCmd(root, "auth", "login") + if stub == nil { + t.Fatal("auth/login stub should exist after StrictModeBot") + } + if stub.Args == nil { + t.Fatal("strict-mode stub must declare Args validator") + } + if err := stub.Args(stub, []string{"--scope", "im.message", "--profile", "default"}); err != nil { + t.Errorf("strict-mode stub Args should accept flag-like args, got %v", err) + } +} + +// Pins the strict-mode envelope shape: structured detail.* / wrapped +// CommandDeniedError for external agents, AND the historical short +// Message + independent Hint for existing consumers. +func TestStrictModeStub_StructuredEnvelope(t *testing.T) { + root := newTestTree() + pruneForStrictMode(root, core.StrictModeBot) + stub := findCmd(root, "im", "+search") + if stub == nil { + t.Fatalf("expected im/+search stub") + } + err := stub.RunE(stub, nil) + if err == nil { + t.Fatalf("strict-mode stub RunE should return error") + } + + var ee *output.ExitError + if !errors.As(err, &ee) { + t.Fatalf("err is not *output.ExitError: %T", err) + } + if ee.Detail == nil { + t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON") + } + if ee.Detail.Type != "command_denied" { + t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type) + } + dm, ok := ee.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail) + } + if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode { + t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode) + } + if got, _ := dm["reason_code"].(string); got != "identity_not_supported" { + t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got) + } + if got, _ := dm["policy_source"].(string); got != "strict-mode" { + t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got) + } + + var cd *platform.CommandDeniedError + if !errors.As(err, &cd) { + t.Fatalf("err does not unwrap to *platform.CommandDeniedError") + } + if cd.Layer != cmdpolicy.LayerStrictMode { + t.Errorf("CommandDeniedError.Layer = %q, want %q", cd.Layer, cmdpolicy.LayerStrictMode) + } + if cd.ReasonCode != "identity_not_supported" { + t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode) + } + if !strings.Contains(cd.Reason, `strict mode is "bot"`) { + t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason) + } + if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` { + t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message) + } + if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") { + t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint) + } +} + +// 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[cmdpolicy.AnnotationDenialLayer] + if got != cmdpolicy.LayerStrictMode { + t.Errorf("stub annotation %q = %q, want %q", + cmdpolicy.AnnotationDenialLayer, got, cmdpolicy.LayerStrictMode) + } + if src := stub.Annotations[cmdpolicy.AnnotationDenialSource]; src != "strict-mode" { + t.Errorf("stub annotation %q = %q, want %q", + cmdpolicy.AnnotationDenialSource, src, "strict-mode") + } +} diff --git a/cmd/root.go b/cmd/root.go index 54fb5ed34..00d9a24bc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,12 +12,17 @@ import ( "io" "net/url" "os" + "sort" "strconv" + "strings" + "github.com/larksuite/cli/extension/platform" internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdpolicy" "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 +93,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 +105,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 } @@ -159,11 +175,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 } } @@ -263,6 +285,70 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr fmt.Fprint(w, buffer.String()) } +// installUnknownSubcommandGuard replaces cobra's silent help fallback on +// group commands (no Run/RunE) with an unknown_subcommand error. +// +// IMPORTANT: every command modified here is also tagged with +// cmdpolicy.AnnotationPureGroup so the user-layer policy engine +// continues to treat the command as a pure parent group. Without the +// tag, the RunE injection here would flip Runnable()=true and a user +// rule like `max_risk: read` would deny every ` --help` call +// with reason_code=risk_not_annotated. +func installUnknownSubcommandGuard(cmd *cobra.Command) { + if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil { + cmd.RunE = unknownSubcommandRunE + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[cmdpolicy.AnnotationPureGroup] = "true" + } + for _, c := range cmd.Commands() { + installUnknownSubcommandGuard(c) + } +} + +func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + unknown := args[0] + available := availableSubcommandNames(cmd) + msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath()) + hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath()) + if len(available) > 0 { + hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", ")) + } + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "unknown_subcommand", + Message: msg, + Hint: hint, + Detail: map[string]any{ + "unknown": unknown, + "command_path": cmd.CommandPath(), + "available": available, + }, + }, + } +} + +func availableSubcommandNames(cmd *cobra.Command) []string { + subs := make([]string, 0, len(cmd.Commands())) + for _, c := range cmd.Commands() { + if c.Hidden || !c.IsAvailableCommand() { + continue + } + name := c.Name() + if name == "help" || name == "completion" { + continue + } + subs = append(subs, name) + } + sort.Strings(subs) + return subs +} + // installTipsHelpFunc wraps the default help function to append a TIPS section // when a command has tips set via cmdutil.SetTips. It also force-shows global // flags that are normally hidden in single-app mode (currently --profile) diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index 416777a44..a8919d1ce 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -27,6 +27,14 @@ import ( "github.com/spf13/cobra" ) +// Canonical strict-mode envelope strings shared across fixtures +// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom). +const ( + strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available` + strictModeUserMessage = `strict mode is "user", only user-identity commands are available` + strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)" +) + // buildIntegrationRootCmd creates a root command with api, service, and shortcut // subcommands wired to a test factory, simulating the real CLI command tree. func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command { @@ -353,9 +361,17 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ OK: false, Error: &output.ErrDetail{ - Type: "strict_mode", - Message: `strict mode is "bot", only bot-identity commands are available`, - Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", + Type: "command_denied", + Message: strictModeBotMessage, + Hint: strictModeHint, + Detail: map[string]any{ + "path": "auth/login", + "layer": "strict_mode", + "policy_source": "strict-mode", + "rule_name": "", + "reason_code": "identity_not_supported", + "reason": strictModeBotMessage, + }, }, }) } @@ -371,9 +387,17 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ OK: false, Error: &output.ErrDetail{ - Type: "strict_mode", - Message: `strict mode is "bot", only bot-identity commands are available`, - Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", + Type: "command_denied", + Message: strictModeBotMessage, + Hint: strictModeHint, + Detail: map[string]any{ + "path": "im/+messages-search", + "layer": "strict_mode", + "policy_source": "strict-mode", + "rule_name": "", + "reason_code": "identity_not_supported", + "reason": strictModeBotMessage, + }, }, }) } @@ -409,7 +433,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn OK: false, Identity: "bot", Error: &output.ErrDetail{ - Type: "strict_mode", + Type: "command_denied", Message: `strict mode is "user", only user-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, @@ -428,7 +452,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv OK: false, Identity: "user", Error: &output.ErrDetail{ - Type: "strict_mode", + Type: "command_denied", Message: `strict mode is "bot", only bot-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, @@ -446,9 +470,17 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{ OK: false, Error: &output.ErrDetail{ - Type: "strict_mode", - Message: `strict mode is "user", only user-identity commands are available`, - Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", + Type: "command_denied", + Message: strictModeUserMessage, + Hint: strictModeHint, + Detail: map[string]any{ + "path": "im/images/create", + "layer": "strict_mode", + "policy_source": "strict-mode", + "rule_name": "", + "reason_code": "identity_not_supported", + "reason": strictModeUserMessage, + }, }, }) } @@ -465,7 +497,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop OK: false, Identity: "user", Error: &output.ErrDetail{ - Type: "strict_mode", + Type: "command_denied", Message: `strict mode is "bot", only bot-identity commands are available`, Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)", }, diff --git a/cmd/root_test.go b/cmd/root_test.go index 0f5ac1ad9..6aac983db 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -356,6 +356,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 { @@ -368,3 +369,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/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go new file mode 100644 index 000000000..4bba607d5 --- /dev/null +++ b/cmd/unknown_subcommand_test.go @@ -0,0 +1,177 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/output" +) + +func newGroupTree() (root, drive, files *cobra.Command) { + root = &cobra.Command{Use: "lark-cli"} + drive = &cobra.Command{Use: "drive", Short: "drive ops"} + root.AddCommand(drive) + + search := &cobra.Command{Use: "+search", RunE: func(*cobra.Command, []string) error { return nil }} + upload := &cobra.Command{Use: "+upload", RunE: func(*cobra.Command, []string) error { return nil }} + hidden := &cobra.Command{Use: "+secret", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }} + drive.AddCommand(search, upload, hidden) + + files = &cobra.Command{Use: "files", Short: "files ops"} + drive.AddCommand(files) + files.AddCommand(&cobra.Command{Use: "list", RunE: func(*cobra.Command, []string) error { return nil }}) + + return root, drive, files +} + +func TestInstallUnknownSubcommandGuard_InstallsOnGroupsOnly(t *testing.T) { + root, drive, files := newGroupTree() + leaf := drive.Commands()[0] // +search + + installUnknownSubcommandGuard(root) + + if drive.RunE == nil { + t.Error("drive should have RunE installed") + } + if files.RunE == nil { + t.Error("files should have RunE installed") + } + if err := leaf.RunE(leaf, []string{"unexpected-arg"}); err != nil { + t.Errorf("leaf +search RunE should be untouched, got error %v", err) + } +} + +func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + called := false + custom := &cobra.Command{ + Use: "custom", + RunE: func(*cobra.Command, []string) error { + called = true + return nil + }, + } + // Child makes custom a "group" command, exercising the Run/RunE override guard. + custom.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.AddCommand(custom) + + installUnknownSubcommandGuard(root) + + if err := custom.RunE(custom, nil); err != nil { + t.Fatalf("preserved RunE returned error: %v", err) + } + if !called { + t.Error("guard must not overwrite a command that already defines Run/RunE") + } +} + +func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) { + _, drive, _ := newGroupTree() + installUnknownSubcommandGuard(drive.Root()) + + var buf bytes.Buffer + drive.SetOut(&buf) + drive.SetErr(&buf) + + if err := drive.RunE(drive, nil); err != nil { + t.Fatalf("expected no-args invocation to succeed, got: %v", err) + } + if !strings.Contains(buf.String(), "drive ops") { + t.Errorf("expected help output to include the command's Short, got:\n%s", buf.String()) + } +} + +func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) { + _, drive, _ := newGroupTree() + installUnknownSubcommandGuard(drive.Root()) + + err := drive.RunE(drive, []string{"+bogus"}) + if err == nil { + t.Fatal("expected error for unknown subcommand") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if exitErr.Code != output.ExitValidation { + t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code) + } + if exitErr.Detail == nil { + t.Fatal("expected ExitError to carry Detail") + } + if exitErr.Detail.Type != "unknown_subcommand" { + t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) { + t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message) + } + if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") { + t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint) + } + if strings.Contains(exitErr.Detail.Hint, "+secret") { + t.Error("hidden commands must not appear in the hint") + } + + detail, ok := exitErr.Detail.Detail.(map[string]any) + if !ok { + t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail) + } + if detail["unknown"] != "+bogus" { + t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"]) + } + if detail["command_path"] != "lark-cli drive" { + t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"]) + } + available, ok := detail["available"].([]string) + if !ok { + t.Fatalf("detail.available should be []string, got %T", detail["available"]) + } + if len(available) != 3 { + t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available) + } +} + +func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) { + root, _, files := newGroupTree() + installUnknownSubcommandGuard(root) + + err := files.RunE(files, []string{"bogus"}) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError on nested group, got %T", err) + } + if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" { + t.Errorf("command_path should reflect the nested resource, got %v", + exitErr.Detail.Detail.(map[string]any)["command_path"]) + } +} + +func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + root.AddCommand( + &cobra.Command{Use: "alpha", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "help", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "completion", RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "beta", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }}, + &cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }}, + ) + + got := availableSubcommandNames(root) + want := []string{"alpha", "gamma"} + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for i, name := range want { + if got[i] != name { + t.Errorf("availableSubcommandNames[%d] = %q, want %q", i, got[i], name) + } + } +} diff --git a/extension/platform/README.md b/extension/platform/README.md new file mode 100644 index 000000000..d2834ddd7 --- /dev/null +++ b/extension/platform/README.md @@ -0,0 +1,186 @@ +# lark-cli Plugin SDK + +`extension/platform` is the **in-process plugin SDK** for lark-cli. +Plugins compile into a **fork** of the lark-cli binary via a blank +import; there is no `.so` loading, no RPC, no subprocess isolation. +A plugin shares the binary's address space and lifecycle. + +## 5-minute hello world + +```go +// myplugin/audit.go +package myplugin + +import ( + "context" + "log" + + "github.com/larksuite/cli/extension/platform" +) + +func init() { + platform.Register( + platform.NewPlugin("audit", "0.1.0"). + Observer(platform.After, "log-cmd", platform.All(), + func(ctx context.Context, inv platform.Invocation) { + log.Printf("cmd=%s err=%v", inv.Cmd().Path(), inv.Err()) + }). + FailOpen(). + MustBuild()) +} +``` + +Wire into a fork: + +```go +// cmd/larkx/main.go in your fork +package main + +import ( + _ "github.com/me/myplugin" // blank import → init() runs + + "github.com/larksuite/cli/cmd" + "os" +) + +func main() { os.Exit(cmd.Execute()) } +``` + +```sh +go build -o larkx ./cmd/larkx && ./larkx config plugins show +``` + +You should see `audit` in the plugin list. + +## What you can hook + +| Hook | Fires | Can block? | +| -------------------------- | ---------------------------------- | -------------------------------- | +| `Observer` | Before / After each command | No (fire-and-forget audit) | +| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) | +| `On(Startup/Shutdown)` | Process lifecycle | N/A | +| `Restrict(Rule)` | Bootstrap-time, single per binary | Denies whole subtrees | + +### Plugin lifecycle + +```mermaid +sequenceDiagram + participant Host as lark-cli (host) + participant SDK as platform (SDK) + participant Plugin as your plugin + + Note over Host,Plugin: Process start (before main) + Plugin->>Plugin: init() (via blank import) + Plugin->>SDK: Register(plugin) + + Note over Host,Plugin: Bootstrap (host main) + Host->>SDK: RegisteredPlugins() + SDK-->>Host: snapshot in registration order + Host->>SDK: InstallAll() + SDK->>Plugin: Capabilities() + SDK->>Plugin: Install(Registrar) + Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown) + SDK->>Plugin: On(Startup) fire + + Note over Host,Plugin: Each command dispatch + Host->>SDK: hook chain (in registration order) + SDK->>Plugin: Observer Before + SDK->>Plugin: Wrap (around RunE) + SDK->>Plugin: Observer After + + Note over Host,Plugin: Process exit + Host->>SDK: Emit(Shutdown) + SDK->>Plugin: On(Shutdown) fire +``` + +A `command_denied` decision (from `Restrict` or strict-mode) bypasses +the `Wrap` chain entirely — observers still fire so audit plugins see +the rejected dispatch. + +## Safety contract (read this) + +- A plugin calling `Restrict()` MUST declare `FailClosed`. The Builder + flips it automatically; the lower-level `Plugin` interface rejects + the mismatch with `restricts_mismatch`. +- Only ONE plugin per binary can call `Restrict()`. Multi-plugin + Restrict is a deliberate `plugin_conflict` error (single-rule + ecosystem assumption). YAML policy at `~/.lark-cli/policy.yml` is + shadowed by any plugin Restrict. +- The `Wrap` factory runs **once per command dispatch**, not at + install time. Long-lived state (clients, caches, metrics counters) + must live on the Plugin struct or in package-level variables. +- Plugins cannot suppress a `command_denied`: the framework + physically isolates denied commands from the Wrap chain (Observers + still fire). +- Commands missing a `risk_level` annotation are denied by default + when a Rule is active. Set `Rule.AllowUnannotated = true` (or + `allow_unannotated: true` in yaml) to opt out during gradual + adoption. +- Risk annotation typos (e.g. `"wrtie"`) are always denied with + `risk_invalid` plus a "did you mean" suggestion. `AllowUnannotated` + does NOT bypass this — typo is a code bug, not a missing + annotation. + +## reason_code reference + +Every install / dispatch failure emits a `command_denied` or +`plugin_install` envelope carrying a `detail.reason_code` from the +closed enum below. Use the code (not the human-readable message) when +matching errors in agents, CI scripts, or downstream tools — the +messages are localised and may change between releases. + +### Plugin install (`error.type = plugin_install`) + +| reason_code | When it fires | Honours FailurePolicy? | +| --------------------------- | ------------------------------------------------------------------------------ | ---------------------- | +| `invalid_plugin_name` | `Plugin.Name()` doesn't match `^[a-z0-9][a-z0-9-]*$` | No — always aborts | +| `plugin_name_panic` | `Plugin.Name()` panicked | No — always aborts | +| `duplicate_plugin_name` | Two plugins return the same `Name()` | No — always aborts | +| `capabilities_panic` | `Plugin.Capabilities()` panicked | Yes | +| `invalid_capability` | `Capabilities` malformed: bad `RequiredCLIVersion`, unknown `FailurePolicy` | No — always aborts | +| `capability_unmet` | Current CLI version doesn't satisfy `RequiredCLIVersion` | Yes | +| `restricts_mismatch` | `Restricts=true` without `FailClosed`, or `Restricts` flag inconsistent w/ Install | No — always aborts | +| `invalid_hook_name` | Hook name contains `.` or doesn't match the plugin namespace | Yes | +| `duplicate_hook_name` | Same hook name registered twice within a plugin | Yes | +| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes | +| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes | +| `double_restrict` | Plugin called `r.Restrict()` more than once in one Install | Yes | +| `multiple_restrict_plugins` | Two or more plugins each contributed Restrict | Yes | +| `install_failed` | `Plugin.Install` returned a non-nil error | Yes | +| `install_panic` | `Plugin.Install` panicked | Yes | + +"No — always aborts" entries are treated as **untrusted-config errors**: +the host can't honour the plugin's declared `FailurePolicy` because the +declaration itself is suspect (e.g. an `invalid_capability` plugin +might also be lying about being `FailOpen`). + +### Command dispatch (`error.type = command_denied`) + +| reason_code | Meaning | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `risk_not_annotated` | Command has no `risk_level` annotation, and the active Rule does not set `allow_unannotated: true` | +| `risk_invalid` | Command's `risk_level` is a typo / not in the `read | write | high-risk-write` taxonomy (always fail-closed) | +| `command_denylisted` | Command path matched the active Rule's `deny` glob | +| `domain_not_allowed` | Active Rule has a non-empty `allow` list and the command path did not match any glob | +| `write_not_allowed` | Command risk is `write` / `high-risk-write` and exceeds Rule `max_risk` | +| `risk_too_high` | Command risk exceeds Rule `max_risk` but is not a write (reserved for future risk levels) | +| `identity_mismatch` | Command's `supportedIdentities` does not intersect Rule `identities` | +| `aggregate_all_denied` | Aggregate stub installed on a parent group because every live child was denied | + +The `detail.layer` field distinguishes who rejected the call: +`policy` (this SDK's user-layer engine) vs. `strict_mode` +(`cmd/prune.go`'s credential-hardening pass). Agents that want to +dispatch on "any denial" should match `error.type == "command_denied"` +and ignore the layer; agents that only care about user-policy denials +should additionally check `detail.layer == "policy"`. + +## Where to go next + +- [Runnable example: audit observer](./examples/audit-observer/) +- [Runnable example: read-only policy](./examples/readonly-policy/) +- Builder API: see [`builder.go`](./builder.go) for the full DSL + (`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`, + `MustBuild`). +- Inventory diagnostic: run `lark-cli config plugins show` after + installing your plugin to see hooks/rules attributed to your plugin + name. 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/builder.go b/extension/platform/builder.go new file mode 100644 index 000000000..1bcba749f --- /dev/null +++ b/extension/platform/builder.go @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import ( + "errors" + "fmt" + "regexp" +) + +// Builder is the ergonomic constructor for Plugin. Use it from init(): +// +// func init() { +// platform.Register( +// platform.NewPlugin("audit", "0.1.0"). +// Observer(platform.After, "log", platform.All(), auditFn). +// FailOpen(). +// MustBuild()) +// } +// +// The lower-level Plugin interface remains available for cases that +// need finer control (state on a struct, complex Install logic). The +// Builder enforces: +// +// - Name format (^[a-z0-9][a-z0-9-]*$) +// - hookName format and uniqueness within a plugin +// - Restricts ↔ FailClosed consistency (calling Restrict() implies +// FailClosed, so plugin authors cannot accidentally ship a policy +// plugin under FailOpen) +// - Rule validation via ValidateRule analogues (delegated to +// internal/cmdpolicy at install time; Builder only fast-fails +// blatantly bad input) +type Builder struct { + name string + version string + caps Capabilities + + actions []func(Registrar) + rule *Rule + + hookNames map[string]bool + errs []error +} + +var pluginNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// NewPlugin starts a Builder. Name format is validated lazily — errors +// surface at Build()/MustBuild() time, allowing chained calls without +// intermediate error handling. +func NewPlugin(name, version string) *Builder { + b := &Builder{ + name: name, + version: version, + hookNames: map[string]bool{}, + } + if !pluginNamePattern.MatchString(name) { + b.errs = append(b.errs, fmt.Errorf("invalid plugin name %q: must match ^[a-z0-9][a-z0-9-]*$", name)) + } + return b +} + +// RequireCLI sets Capabilities.RequiredCLIVersion (semver constraint, +// e.g. ">=1.1.0"). Empty string means no requirement. +func (b *Builder) RequireCLI(constraint string) *Builder { + b.caps.RequiredCLIVersion = constraint + return b +} + +// FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when +// neither FailOpen nor FailClosed is called and Restrict is not used. +func (b *Builder) FailOpen() *Builder { + b.caps.FailurePolicy = FailOpen + return b +} + +// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit +// when Restrict() is called. +func (b *Builder) FailClosed() *Builder { + b.caps.FailurePolicy = FailClosed + return b +} + +// Observer registers an Observer. Multiple calls accumulate. +func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder { + if !b.validateHookName(hookName, "observer") { + return b + } + // Capture by value so the action closure doesn't share state with + // subsequent Observer() calls (Go ≥1.22 already gives each call + // its own copies of parameter values, but pinning is explicit). + w, n, s, f := when, hookName, sel, fn + b.actions = append(b.actions, func(r Registrar) { + r.Observe(w, n, s, f) + }) + return b +} + +// Wrap registers a Wrapper. Multiple calls accumulate; the host +// composes them in registration order (outermost first). +func (b *Builder) Wrap(hookName string, sel Selector, wrap Wrapper) *Builder { + if !b.validateHookName(hookName, "wrap") { + return b + } + n, s, w := hookName, sel, wrap + b.actions = append(b.actions, func(r Registrar) { + r.Wrap(n, s, w) + }) + return b +} + +// On registers a LifecycleHandler. +func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler) *Builder { + if !b.validateHookName(hookName, "on") { + return b + } + e, n, f := event, hookName, fn + b.actions = append(b.actions, func(r Registrar) { + r.On(e, n, f) + }) + return b +} + +// Restrict contributes a pruning Rule. Calling Restrict implicitly +// sets Restricts=true and FailurePolicy=FailClosed (the framework +// requires both to coexist; the builder enforces the pairing so the +// plugin author cannot accidentally ship a policy plugin under +// FailOpen). +func (b *Builder) Restrict(rule *Rule) *Builder { + if rule == nil { + b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be nil")) + return b + } + b.caps.Restricts = true + b.caps.FailurePolicy = FailClosed + b.rule = rule + return b +} + +// Build returns the configured Plugin, or an error if any builder +// step found a fault. MustBuild panics on the same error. +// +// The Restrict + FailOpen mismatch is checked here, not in the chained +// setters, because the two methods may be called in either order. +func (b *Builder) Build() (Plugin, error) { + if b.rule != nil && b.caps.FailurePolicy == FailOpen { + b.errs = append(b.errs, errors.New( + "Restrict() requires FailClosed; do not call FailOpen() after Restrict()")) + } + if len(b.errs) > 0 { + return nil, errors.Join(b.errs...) + } + return &builtPlugin{ + name: b.name, + version: b.version, + caps: b.caps, + actions: b.actions, + rule: b.rule, + }, nil +} + +// MustBuild panics if Build() would return an error. Designed for +// init(): +// +// func init() { platform.Register(platform.NewPlugin(...).MustBuild()) } +// +// A panic in init runs before the framework's recover guard is +// installed and will crash the binary. That is the intended +// behaviour: a misconfigured plugin must NOT be silently registered. +func (b *Builder) MustBuild() Plugin { + p, err := b.Build() + if err != nil { + panic(fmt.Sprintf("plugin %q: %v", b.name, err)) + } + return p +} + +// validateHookName checks the grammar and uniqueness; returns false +// when the name was rejected (caller skips the action). +func (b *Builder) validateHookName(hookName, kind string) bool { + if !pluginNamePattern.MatchString(hookName) { + b.errs = append(b.errs, fmt.Errorf( + "%s %q: hookName must match ^[a-z0-9][a-z0-9-]*$", kind, hookName)) + return false + } + if b.hookNames[hookName] { + b.errs = append(b.errs, fmt.Errorf( + "%s %q: hookName already used in this plugin", kind, hookName)) + return false + } + b.hookNames[hookName] = true + return true +} + +// builtPlugin is the Plugin implementation the builder emits. +type builtPlugin struct { + name string + version string + caps Capabilities + actions []func(Registrar) + rule *Rule +} + +func (p *builtPlugin) Name() string { return p.name } +func (p *builtPlugin) Version() string { return p.version } +func (p *builtPlugin) Capabilities() Capabilities { return p.caps } +func (p *builtPlugin) Install(r Registrar) error { + if p.rule != nil { + r.Restrict(p.rule) + } + for _, action := range p.actions { + action(r) + } + return nil +} diff --git a/extension/platform/builder_test.go b/extension/platform/builder_test.go new file mode 100644 index 000000000..541271a1b --- /dev/null +++ b/extension/platform/builder_test.go @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +// recorder Registrar captures everything a builder schedules so the +// test can assert what Install produced without involving the host. +type recorder struct { + observers int + wrappers int + lifecycles int + rule *platform.Rule +} + +func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) { + r.observers++ +} +func (r *recorder) Wrap(string, platform.Selector, platform.Wrapper) { r.wrappers++ } +func (r *recorder) On(platform.LifecycleEvent, string, platform.LifecycleHandler) { r.lifecycles++ } +func (r *recorder) Restrict(rule *platform.Rule) { r.rule = rule } + +func TestBuilder_basicAssembly(t *testing.T) { + p, err := platform.NewPlugin("audit", "0.1.0"). + Observer(platform.Before, "pre", platform.All(), + func(context.Context, platform.Invocation) {}). + Observer(platform.After, "post", platform.All(), + func(context.Context, platform.Invocation) {}). + Wrap("policy", platform.All(), + func(next platform.Handler) platform.Handler { return next }). + On(platform.Startup, "boot", + func(context.Context, *platform.LifecycleContext) error { return nil }). + FailOpen(). + Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + if p.Name() != "audit" || p.Version() != "0.1.0" { + t.Errorf("metadata = %q/%q", p.Name(), p.Version()) + } + if p.Capabilities().FailurePolicy != platform.FailOpen { + t.Errorf("FailurePolicy = %v, want FailOpen", p.Capabilities().FailurePolicy) + } + + r := &recorder{} + if err := p.Install(r); err != nil { + t.Fatalf("Install: %v", err) + } + if r.observers != 2 || r.wrappers != 1 || r.lifecycles != 1 { + t.Errorf("Install dispatch = observers=%d wrappers=%d lifecycles=%d", + r.observers, r.wrappers, r.lifecycles) + } +} + +// Restrict() flips Restricts=true and FailClosed automatically — a +// policy plugin can't accidentally ship under FailOpen. +func TestBuilder_restrictForcesFailClosed(t *testing.T) { + p, err := platform.NewPlugin("policy-plugin", "0.1.0"). + Restrict(&platform.Rule{Name: "read-only", MaxRisk: platform.RiskRead}). + Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + caps := p.Capabilities() + if !caps.Restricts { + t.Errorf("Restricts = false, want true (Restrict() should flip it)") + } + if caps.FailurePolicy != platform.FailClosed { + t.Errorf("FailurePolicy = %v, want FailClosed (Restrict() implies it)", caps.FailurePolicy) + } + + r := &recorder{} + if err := p.Install(r); err != nil { + t.Fatalf("Install: %v", err) + } + if r.rule == nil || r.rule.Name != "read-only" { + t.Errorf("Install did not propagate Rule: %+v", r.rule) + } +} + +// Invalid name surfaces at Build time, not at NewPlugin. +func TestBuilder_invalidPluginName(t *testing.T) { + _, err := platform.NewPlugin("Has_Underscore_And_Caps", "0.1").Build() + if err == nil { + t.Fatalf("Build must reject malformed plugin name") + } + if !strings.Contains(err.Error(), "invalid plugin name") { + t.Errorf("error should mention plugin name, got: %v", err) + } +} + +// Duplicate hookName within the same builder is rejected. +func TestBuilder_duplicateHookName(t *testing.T) { + noopObs := func(context.Context, platform.Invocation) {} + _, err := platform.NewPlugin("dup", "0"). + Observer(platform.Before, "h", platform.All(), noopObs). + Observer(platform.After, "h", platform.All(), noopObs). + Build() + if err == nil { + t.Fatalf("Build must reject duplicate hookName") + } + if !strings.Contains(err.Error(), "already used") { + t.Errorf("error should mention duplicate hookName, got %v", err) + } +} + +func TestBuilder_invalidHookName(t *testing.T) { + _, err := platform.NewPlugin("p", "0"). + Observer(platform.Before, "Bad.Name", platform.All(), + func(context.Context, platform.Invocation) {}). + Build() + if err == nil { + t.Fatalf("Build must reject hookName with dot") + } +} + +// MustBuild panics on builder error. +func TestBuilder_mustBuildPanicsOnError(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("MustBuild must panic when Build would fail") + } + }() + _ = platform.NewPlugin("BadName", "0").MustBuild() +} + +func TestBuilder_restrictNilRejected(t *testing.T) { + _, err := platform.NewPlugin("p", "0").Restrict(nil).Build() + if err == nil { + t.Fatalf("Restrict(nil) must produce error") + } +} + +func TestBuilder_capabilitiesSetters(t *testing.T) { + p, err := platform.NewPlugin("p", "0.1"). + RequireCLI(">=1.0.0"). + FailClosed(). + Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + caps := p.Capabilities() + if caps.RequiredCLIVersion != ">=1.0.0" { + t.Errorf("RequiredCLIVersion = %q, want >=1.0.0", caps.RequiredCLIVersion) + } + if caps.FailurePolicy != platform.FailClosed { + t.Errorf("FailurePolicy = %v, want FailClosed", caps.FailurePolicy) + } +} + +func TestBuilder_restrictThenFailOpenRejected(t *testing.T) { + rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead} + _, err := platform.NewPlugin("p", "0").Restrict(rule).FailOpen().Build() + if err == nil { + t.Fatalf("Build must reject Restrict()+FailOpen() mismatch") + } + if !strings.Contains(err.Error(), "FailClosed") { + t.Errorf("error should mention FailClosed, got: %v", err) + } +} + +// Restrict() flips FailurePolicy to FailClosed; the previous FailOpen() +// is overridden. Pin it so the Build-time validation does not over-reject. +func TestBuilder_failOpenThenRestrictOK(t *testing.T) { + rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead} + p, err := platform.NewPlugin("p", "0").FailOpen().Restrict(rule).Build() + if err != nil { + t.Fatalf("FailOpen()+Restrict() must succeed (Restrict flips to FailClosed): %v", err) + } + if p.Capabilities().FailurePolicy != platform.FailClosed { + t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy) + } +} 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..f6241c366 --- /dev/null +++ b/extension/platform/doc.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package platform is the single public extension contract for lark-cli. +// +// External integrators (plugin authors, embedding platforms) only import this +// package; everything else under internal/ is off-limits. +// +// Plugin lifecycle: +// +// - Plugin - the interface every plugin implements (Name / Version / Capabilities / Install) +// - Registrar - what Install receives; the four registration verbs (Observe / Wrap / On / Restrict) +// - Capabilities - declared up front: FailurePolicy (FailOpen | FailClosed) and Restricts +// - Register - process-wide entry point; plugins call this from init() +// +// Hook surface (what Install hangs off Registrar): +// +// - Observer - side-effect-only callback, panic-safe, runs Before / After RunE +// - Wrapper - middleware that can short-circuit via AbortError +// - LifecycleHandler - reacts to Startup / Shutdown / etc. (LifecycleEvent + When) +// - Selector - chooses which commands a hook applies to (ByDomain / ByWrite / ByReadOnly / ByExactRisk / And / Or / Not, etc.) +// - Handler - the inner "run the command" function Wrappers compose around +// - Invocation - per-call context passed to handlers (Cmd view + DeniedByPolicy / StrictMode / Identity) +// - AbortError - structured short-circuit error from a Wrapper; framework namespaces HookName +// +// Policy surface (what Restrict contributes, also consumable from yaml policy): +// +// - Rule - declarative policy rule (Allow / Deny / MaxRisk / Identities / AllowUnannotated) +// - CommandView - read-only command metadata view (Path / Domain / Risk / Identities) +// - Risk / Identity - defined string types with closed taxonomies; ParseRisk / ParseIdentity +// convert raw strings (yaml, cobra annotation) into typed values; r.Rank() +// gives a comparable rank for the read < write < high-risk-write ordering +// - CommandDeniedError - structured error returned to denied callers +// +// Stability: every exported symbol here is part of the contract. Internal +// orchestration (staging, validation, RunE wrapping, denial guard) lives +// under internal/platform, internal/hook and internal/cmdpolicy and is not +// importable by third parties. +package platform diff --git a/extension/platform/errors.go b/extension/platform/errors.go new file mode 100644 index 000000000..7bd99f2d2 --- /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 +// - "policy" -- user-layer Rule rejected the command +// +// PolicySource is a free-form identifier such as "plugin:secaudit", +// "yaml:mywork", or "strict-mode". Reason fields: +// +// - ReasonCode -- closed enum, see tech-doc 5.3 (e.g. write_not_allowed, +// all_children_denied, identity_not_supported) +// - Reason -- human-readable text +type CommandDeniedError struct { + Path string + Layer string + PolicySource string + RuleName string + ReasonCode string + Reason string +} + +// Error implements the standard error interface. +func (e *CommandDeniedError) Error() string { + if e.Reason != "" { + return fmt.Sprintf("command %q denied: %s", e.Path, e.Reason) + } + return fmt.Sprintf("command %q denied (%s/%s)", e.Path, e.Layer, e.ReasonCode) +} diff --git a/extension/platform/errors_test.go b/extension/platform/errors_test.go new file mode 100644 index 000000000..767e00d89 --- /dev/null +++ b/extension/platform/errors_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +func TestCommandDeniedError_messageFormats(t *testing.T) { + withReason := &platform.CommandDeniedError{ + Path: "docs/+update", + Layer: "policy", + ReasonCode: "write_not_allowed", + Reason: "write disabled by policy", + } + if got := withReason.Error(); got != `command "docs/+update" denied: write disabled by policy` { + t.Fatalf("Error() with Reason = %q", got) + } + + noReason := &platform.CommandDeniedError{ + Path: "docs/+update", + Layer: "strict_mode", + ReasonCode: "identity_not_supported", + } + if got := noReason.Error(); got != `command "docs/+update" denied (strict_mode/identity_not_supported)` { + t.Fatalf("Error() without Reason = %q", got) + } +} + +// errors.As must work so consumers can type-assert without unwrap gymnastics. +func TestCommandDeniedError_satisfiesErrorsAs(t *testing.T) { + var err error = &platform.CommandDeniedError{Path: "x"} + var target *platform.CommandDeniedError + if !errors.As(err, &target) { + t.Fatalf("errors.As should match CommandDeniedError") + } + if target.Path != "x" { + t.Fatalf("target.Path = %q, want %q", target.Path, "x") + } +} diff --git a/extension/platform/example_test.go b/extension/platform/example_test.go new file mode 100644 index 000000000..078398252 --- /dev/null +++ b/extension/platform/example_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/extension/platform" +) + +// ExampleNewPlugin_observer registers an audit Observer that fires +// after every command, regardless of success or failure. +func ExampleNewPlugin_observer() { + p, _ := platform.NewPlugin("audit", "0.1.0"). + Observer(platform.After, "log", platform.All(), + func(ctx context.Context, inv platform.Invocation) { + _ = inv.Cmd().Path() // do something useful with the command + }). + FailOpen(). + Build() + fmt.Println(p.Name(), p.Version()) + // Output: audit 0.1.0 +} + +// ExampleNewPlugin_wrapper registers a Wrap that short-circuits any +// write-class command. The framework converts the returned +// *AbortError into a structured "hook" envelope; observers still +// fire on the After stage so audit sees the attempt. +func ExampleNewPlugin_wrapper() { + p, _ := platform.NewPlugin("policy-plugin", "0.1.0"). + Wrap("block-writes", platform.ByWrite(), + func(next platform.Handler) platform.Handler { + return func(ctx context.Context, inv platform.Invocation) error { + return &platform.AbortError{ + HookName: "block-writes", + Reason: "writes are disabled for this session", + } + } + }). + FailOpen(). + Build() + fmt.Println(p.Capabilities().FailurePolicy == platform.FailOpen) + // Output: true +} + +// ExampleNewPlugin_restrict registers a policy plugin that allows +// only docs/* read commands. Note that Restrict() implicitly sets +// FailClosed — a policy plugin must abort the binary if it fails to +// install, not silently disappear. +func ExampleNewPlugin_restrict() { + p, _ := platform.NewPlugin("readonly-docs", "0.1.0"). + Restrict(&platform.Rule{ + Name: "docs-only", + Allow: []string{"docs/**"}, + MaxRisk: platform.RiskRead, + }). + Build() + caps := p.Capabilities() + fmt.Println(caps.Restricts, caps.FailurePolicy == platform.FailClosed) + // Output: true true +} diff --git a/extension/platform/examples/.gitignore b/extension/platform/examples/.gitignore new file mode 100644 index 000000000..6c34736fb --- /dev/null +++ b/extension/platform/examples/.gitignore @@ -0,0 +1,2 @@ +audit-observer/audit-observer +readonly-policy/readonly-policy diff --git a/extension/platform/examples/README.md b/extension/platform/examples/README.md new file mode 100644 index 000000000..c7eab33d7 --- /dev/null +++ b/extension/platform/examples/README.md @@ -0,0 +1,13 @@ +# lark-cli plugin examples + +Runnable fork-and-blank-import examples that demonstrate the Plugin +SDK in production-shape. Each subdirectory is a complete `main` +package: `go build .` produces a working CLI. + +| Example | What it shows | +| --- | --- | +| [audit-observer](./audit-observer/) | Simplest possible plugin: one Observer matching every command, logs to stderr. | +| [readonly-policy](./readonly-policy/) | Policy plugin: `Restrict()` with `MaxRisk=read`, demonstrates the `FailClosed` + `Restricts=true` auto-pairing. | + +All examples are built by CI (`make examples-build`) so they cannot +silently drift from the SDK. diff --git a/extension/platform/examples/audit-observer/README.md b/extension/platform/examples/audit-observer/README.md new file mode 100644 index 000000000..a860a4dd9 --- /dev/null +++ b/extension/platform/examples/audit-observer/README.md @@ -0,0 +1,26 @@ +# Example: audit observer + +The simplest possible lark-cli plugin: one After observer that logs +every dispatched command to stderr (success or failure). + +## Build & run + +```sh +cd extension/platform/examples/audit-observer +go build -o audit-cli . +./audit-cli config plugins show +# {"plugins":[{"name":"audit", ...}], "total":1} + +./audit-cli api GET /open-apis/contact/v3/users/me +# [audit] api ok (on stderr) +``` + +## Key points + +- `platform.NewPlugin(...).MustBuild()` from `init()`. The blank + import of this package in `main.go` triggers `init()`. +- `Observer(platform.After, ...)` runs **after** the command's RunE, + even on failure (Observers cannot prevent execution). +- `FailOpen()` means: if Install ever fails, the binary logs a + warning and continues without this plugin. Right default for + audit-only plugins. diff --git a/extension/platform/examples/audit-observer/main.go b/extension/platform/examples/audit-observer/main.go new file mode 100644 index 000000000..2c3c30534 --- /dev/null +++ b/extension/platform/examples/audit-observer/main.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Command audit-observer is a runnable fork of lark-cli that logs +// every dispatched command to stderr. Demonstrates the simplest +// possible plugin: one After observer matching All commands. +// +// Build & run: +// +// cd extension/platform/examples/audit-observer +// go build -o audit-cli . +// ./audit-cli config plugins show # see "audit" in the list +// ./audit-cli api GET /open-apis/... # observer logs to stderr +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/larksuite/cli/cmd" + "github.com/larksuite/cli/extension/platform" +) + +func init() { + platform.Register( + platform.NewPlugin("audit", "0.1.0"). + Observer(platform.After, "log", platform.All(), + func(ctx context.Context, inv platform.Invocation) { + path := inv.Cmd().Path() + if err := inv.Err(); err != nil { + fmt.Fprintf(os.Stderr, "[audit] %s FAILED: %v\n", path, err) + } else { + log.Printf("[audit] %s ok", path) + } + }). + FailOpen(). + MustBuild()) +} + +func main() { + os.Exit(cmd.Execute()) +} diff --git a/extension/platform/examples/readonly-policy/README.md b/extension/platform/examples/readonly-policy/README.md new file mode 100644 index 000000000..638c7686f --- /dev/null +++ b/extension/platform/examples/readonly-policy/README.md @@ -0,0 +1,62 @@ +# Example: read-only policy + +A policy plugin that installs a `Rule` allowing only `docs/*` and +`im/*` read commands. Any write command produces a structured +`command_denied` envelope. + +## Build & run + +```sh +cd extension/platform/examples/readonly-policy +go build -o readonly-cli . + +./readonly-cli config policy show +# { +# "source": "plugin", +# "source_name": "readonly", +# "yaml_path": "/Users/you/.lark-cli/policy.yml", +# "denied_paths": N, +# "rule": { +# "name": "agent-readonly", +# "allow": ["docs/**", "im/**"], +# "deny": [], +# "max_risk": "read", +# "identities": [], +# "allow_unannotated": false +# } +# } + +./readonly-cli docs +update --doc-token X --content Y +# {"ok":false,"error":{ +# "type":"command_denied", +# "detail":{ +# "layer":"policy", +# "policy_source":"plugin:readonly", +# "rule_name":"agent-readonly", +# "reason_code":"write_not_allowed" +# } +# }} + +./readonly-cli docs +fetch --doc-token X +# Normal read response (assuming credentials) +``` + +## Key points + +- `Restrict(&Rule{...})` is the only call needed — the Builder + flips Capabilities to `Restricts=true, FailurePolicy=FailClosed` + automatically. A policy plugin that silently fails to install + would erase the security boundary, so FailClosed is enforced. +- `MaxRisk: platform.RiskRead` rejects any command annotated + write / high-risk-write. +- `AllowUnannotated` is left default (false): unannotated commands + are denied with `risk_not_annotated`. Set it to true if you need + a gradual-adoption window for the lark-cli main tree. + +## Caveats + +- A binary may have **only one** plugin calling `Restrict()`. Two + policy plugins is a deliberate `plugin_conflict` configuration + error. +- This Rule shadows any `~/.lark-cli/policy.yml` — plugin Rule + wins per the resolver precedence. diff --git a/extension/platform/examples/readonly-policy/main.go b/extension/platform/examples/readonly-policy/main.go new file mode 100644 index 000000000..21b674bdc --- /dev/null +++ b/extension/platform/examples/readonly-policy/main.go @@ -0,0 +1,45 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Command readonly-policy is a runnable fork of lark-cli that +// installs a Rule permitting only docs/* and im/* read commands. +// Any write command produces a structured command_denied envelope. +// +// Build & run: +// +// cd extension/platform/examples/readonly-policy +// go build -o readonly-cli . +// ./readonly-cli docs +update --doc-token X --content Y +// # {"ok":false,"error":{"type":"command_denied", ...}} +// +// ./readonly-cli config policy show +// # shows the active Rule with source=plugin:readonly +package main + +import ( + "os" + + "github.com/larksuite/cli/cmd" + "github.com/larksuite/cli/extension/platform" +) + +func init() { + platform.Register( + platform.NewPlugin("readonly", "0.1.0"). + Restrict(&platform.Rule{ + Name: "agent-readonly", + Description: "Only read-class docs/im commands. Suitable for AI-agent sessions.", + Allow: []string{"docs/**", "im/**"}, + MaxRisk: platform.RiskRead, + // AllowUnannotated stays default false (fail-closed): + // unannotated commands are denied, surfacing missing + // risk_level annotations early in adoption. + }). + MustBuild()) + // Note: Restrict() implicitly sets Restricts=true and FailClosed. + // No need to call FailClosed() explicitly. +} + +func main() { + os.Exit(cmd.Execute()) +} diff --git a/extension/platform/handler.go b/extension/platform/handler.go new file mode 100644 index 000000000..c08635962 --- /dev/null +++ b/extension/platform/handler.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "context" + +// Handler is the inner function shape every Wrapper composes. It IS the +// "command business logic" from the Wrapper's perspective -- calling +// next(ctx, inv) inside a Wrapper means "let the command proceed"; +// returning early without calling next short-circuits. +type Handler func(ctx context.Context, inv Invocation) error + +// Observer is a side-effect-only command hook. No return value, no +// next-chain control: an Observer can read Invocation but cannot prevent +// the command from running. Used for audit, metrics, and completion +// logs. After-stage Observers fire even when the command failed +// (Invocation.Err() is populated in that case). +type Observer func(ctx context.Context, inv Invocation) + +// Wrapper is a middleware-style hook: it receives the rest of the +// handler chain and returns a wrapped version. The Wrapper decides +// whether to call next (allow), abstain (deny, return an AbortError), +// or transform the result. Multiple Wrappers compose left-to-right by +// registration order; the outermost runs first. +// +// ⚠️ IMPORTANT: The factory function `func(next Handler) Handler` is +// invoked ONCE PER COMMAND DISPATCH, not once at plugin install. This +// lets the framework recover from a panicking factory and convert it +// to a structured envelope, but it means any state captured by the +// outer closure is rebuilt on every command. Long-lived state (HTTP +// clients, caches, metrics counters) MUST live on the Plugin struct +// or in package-level variables, never in factory-local captures. +type Wrapper func(next Handler) Handler + +// LifecycleHandler runs at one of the process-level LifecycleEvent +// slots. The handler may use ctx for cancellation; in the Shutdown +// case the framework supplies a context with a 2-second hard deadline. +type LifecycleHandler func(ctx context.Context, lc *LifecycleContext) error diff --git a/extension/platform/identity.go b/extension/platform/identity.go new file mode 100644 index 000000000..1354f37dd --- /dev/null +++ b/extension/platform/identity.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "fmt" + +// Identity is the identity taxonomy a command supports. +// +// Defined type (not alias) so plugin authors get compile-time + +// IDE help; raw-string boundaries (yaml, cobra annotation) cross +// through ParseIdentity. +type Identity string + +const ( + IdentityUser Identity = "user" + IdentityBot Identity = "bot" +) + +// ParseIdentity converts a raw string into an Identity. Returns +// ("", nil) for empty input ("not specified"), error for unrecognised +// values. Matching is strict (case-sensitive, no trim). +func ParseIdentity(s string) (Identity, error) { + if s == "" { + return "", nil + } + id := Identity(s) + if id != IdentityUser && id != IdentityBot { + return "", fmt.Errorf("invalid identity %q: must be user|bot", s) + } + return id, nil +} + +// IsValid reports whether i is one of the two recognised values. +func (i Identity) IsValid() bool { + return i == IdentityUser || i == IdentityBot +} + +// String returns the underlying string. +func (i Identity) String() string { return string(i) } diff --git a/extension/platform/invocation.go b/extension/platform/invocation.go new file mode 100644 index 000000000..80fa6b53b --- /dev/null +++ b/extension/platform/invocation.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "time" + +// Invocation is the per-command data a Wrapper / Observer receives. It +// is a read-only interface: the framework implementation lives in +// internal/hook and is never visible to plugins, so there is no way +// for plugin code to mutate denial / strict-mode / identity state. +// +// The struct is deliberately NOT a context.Context — it is data only, +// no cancellation. ctx (from the handler signature) carries +// cancellation / timeout / trace propagation. +// +// Accessor semantics: +// +// - Cmd / Args / Started are populated before the first hook fires +// - Err is populated for After observers and the post-next portion of +// a Wrapper (the value the wrapped handler returned) +// - DeniedByPolicy / DenialLayer / DenialPolicySource are populated by +// the framework's denial guard before any hook runs +// - StrictMode / Identity may return ok=false in Before observers if +// the bootstrap pipeline has not yet resolved them; After observers +// always see ok=true +type Invocation interface { + // Cmd is the read-only snapshot of the dispatched command. + Cmd() CommandView + + // Args is the positional args slice the user invoked the command with. + Args() []string + + // Started is the wall-clock time the outermost RunE wrapper began. + Started() time.Time + + // Err is the error the wrapped handler returned. Populated for + // After observers and the post-next portion of a Wrapper. nil + // before the handler runs. + Err() error + + // DeniedByPolicy reports whether the command was rejected by either + // strict-mode or user-layer policy before the chain reached the + // hook. Observers fire even for denied commands (audit case); Wrap + // is physically isolated by the framework so plugins do not need + // to check this themselves before calling next. + DeniedByPolicy() bool + + // DenialLayer returns the layer that rejected the command: + // + // "" - not denied + // "strict_mode" - credential strict-mode + // "policy" - user-layer Rule (Plugin.Restrict() or yaml) + // + // Matches the detail.layer field in the envelope so consumers can + // route recovery logic by this value alone. + DenialLayer() string + + // DenialPolicySource returns the specific source identifier + // ("plugin:secaudit", "yaml", "strict-mode") corresponding to the + // denial. Empty when the command was not denied. + DenialPolicySource() string + + // StrictMode returns the active credential strict-mode value + // ("user", "bot", "off"). ok=false signals "not yet resolved". + StrictMode() (mode string, ok bool) + + // 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. + Identity() (id string, resolved bool) +} 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..fe22059dc --- /dev/null +++ b/extension/platform/register.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "sync" + +// Register adds a plugin to the global registry. Plugins call this from +// init() (typically through a blank import in the embedder's main). +// +// Register is intentionally tolerant of malformed input: validation +// happens later in the host's InstallAll phase, where errors can be +// surfaced through the typed plugin_install envelope. Register itself +// never panics so that init-time problems do not crash the binary +// before main has a chance to install its recover-and-envelope logic. +// +// The registry holds plugins in insertion order so InstallAll can +// process them deterministically. +func Register(p Plugin) { + pluginRegistry.add(p) +} + +// RegisteredPlugins returns a snapshot of the global plugin registry. +// Order matches Register insertion. The host reads this once during +// InstallAll. +func RegisteredPlugins() []Plugin { + return pluginRegistry.snapshot() +} + +// pluginRegistry is the package-level singleton. The mutex protects +// concurrent Register calls -- harmless in practice (init runs +// serially) but cheap insurance. +var pluginRegistry = ®istry{} + +type registry struct { + mu sync.Mutex + plugins []Plugin +} + +func (r *registry) add(p Plugin) { + r.mu.Lock() + defer r.mu.Unlock() + r.plugins = append(r.plugins, p) +} + +func (r *registry) snapshot() []Plugin { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]Plugin, len(r.plugins)) + copy(out, r.plugins) + return out +} + +func (r *registry) reset() { + r.mu.Lock() + defer r.mu.Unlock() + r.plugins = nil +} diff --git a/extension/platform/register_test.go b/extension/platform/register_test.go new file mode 100644 index 000000000..80425e701 --- /dev/null +++ b/extension/platform/register_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +type stubPlugin struct{ name string } + +func (s stubPlugin) Name() string { return s.name } +func (s stubPlugin) Version() string { return "0.0.1" } +func (s stubPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} } +func (s stubPlugin) Install(platform.Registrar) error { return nil } + +// Tests should always reset the global registry to keep them +// independent. Verifies the reset hook is functional. +func TestRegister_preservesInsertionOrder(t *testing.T) { + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + + platform.Register(stubPlugin{name: "a"}) + platform.Register(stubPlugin{name: "b"}) + platform.Register(stubPlugin{name: "c"}) + + got := platform.RegisteredPlugins() + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("got %d plugins, want %d", len(got), len(want)) + } + for i, p := range got { + if p.Name() != want[i] { + t.Errorf("plugins[%d] = %q, want %q", i, p.Name(), want[i]) + } + } +} + +func TestRegister_resetClears(t *testing.T) { + platform.ResetForTesting() + t.Cleanup(platform.ResetForTesting) + platform.Register(stubPlugin{name: "a"}) + if len(platform.RegisteredPlugins()) != 1 { + t.Fatalf("expected 1 plugin") + } + platform.ResetForTesting() + if len(platform.RegisteredPlugins()) != 0 { + t.Fatalf("expected reset to clear") + } +} diff --git a/extension/platform/register_testing.go b/extension/platform/register_testing.go new file mode 100644 index 000000000..8d32f67f0 --- /dev/null +++ b/extension/platform/register_testing.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +// ResetForTesting clears the global plugin registry. Exposed for test +// isolation only — plugin authors and SDK consumers must NOT call this +// from production code. The function is exported (rather than placed in +// an internal test-only file) so that `go test ./...` works for every +// downstream package without an extra build tag. +// +// Tests that exercise plugin registration must defer +// `t.Cleanup(platform.ResetForTesting)` so subsequent tests start from a +// clean slate. The helper is NOT goroutine-safe across concurrent +// `t.Parallel()` tests — the global registry is shared process state. +func ResetForTesting() { pluginRegistry.reset() } 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/risk.go b/extension/platform/risk.go new file mode 100644 index 000000000..287c5ff8a --- /dev/null +++ b/extension/platform/risk.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "fmt" + +// Risk is the three-tier risk taxonomy declared on every command. +// +// A defined type (not an alias of string) so plugin authors get +// compile-time + IDE candidate help when passing the constants below. +// Crossing the string boundary (yaml, cobra annotation) goes through +// ParseRisk so typos surface as `risk_invalid` rather than silently +// flowing through. +type Risk string + +const ( + RiskRead Risk = "read" + RiskWrite Risk = "write" + RiskHighRiskWrite Risk = "high-risk-write" +) + +// riskOrder maps the Risk taxonomy to a comparable rank. The pruning +// engine compares ranks for the MaxRisk axis. +var riskOrder = map[Risk]int{ + RiskRead: 0, + RiskWrite: 1, + RiskHighRiskWrite: 2, +} + +// ParseRisk converts a raw string (yaml, cobra annotation) into a Risk. +// +// - s == "" → ("", nil) "not specified" +// - s 在闭合枚举 → (Risk(s), nil) OK +// - s 不在枚举内 → ("", error) invalid +// +// The (absent vs invalid) split mirrors the cmdpolicy engine's +// risk_not_annotated vs risk_invalid reason codes — callers can treat +// the "" + nil case as "not specified" without losing the distinction +// from a typo. +// +// Matching is strict: "Read" / "READ" / " read " are all rejected. +// annotation is developer code, not user input — strict matching is +// the typo-catch mechanism, not a normalisation opportunity. +func ParseRisk(s string) (Risk, error) { + if s == "" { + return "", nil + } + r := Risk(s) + if _, ok := riskOrder[r]; !ok { + return "", fmt.Errorf("invalid risk %q: must be read|write|high-risk-write", s) + } + return r, nil +} + +// IsValid reports whether r is one of the three recognised values. +func (r Risk) IsValid() bool { + _, ok := riskOrder[r] + return ok +} + +// Rank returns the comparable rank of r. ok=false when r is not in the +// closed taxonomy. +func (r Risk) Rank() (rank int, ok bool) { + rank, ok = riskOrder[r] + return rank, ok +} + +// String returns the underlying string. Useful for yaml/json output +// and cobra annotation injection. +func (r Risk) String() string { return string(r) } diff --git a/extension/platform/risk_test.go b/extension/platform/risk_test.go new file mode 100644 index 000000000..d934a03c5 --- /dev/null +++ b/extension/platform/risk_test.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +func TestRisk_Rank_orderedTaxonomy(t *testing.T) { + cases := []struct { + level platform.Risk + want int + }{ + {platform.RiskRead, 0}, + {platform.RiskWrite, 1}, + {platform.RiskHighRiskWrite, 2}, + } + for _, c := range cases { + got, ok := c.level.Rank() + if !ok || got != c.want { + t.Errorf("Risk(%q).Rank() = (%d,%v), want (%d,true)", c.level, got, ok, c.want) + } + } + + if _, ok := platform.Risk("unknown-level").Rank(); ok { + t.Fatalf("unknown-level.Rank() ok should be false") + } + if _, ok := platform.Risk("").Rank(); ok { + t.Fatalf("empty.Rank() ok should be false (signals 'no risk annotation')") + } +} + +// The Risk ordering must be strict: read < write < high-risk-write. The +// policy engine compares ranks; a regression that swaps the order would +// silently let high-risk commands pass under MaxRisk=write. +func TestRisk_Rank_strictlyMonotonic(t *testing.T) { + r1, _ := platform.RiskRead.Rank() + r2, _ := platform.RiskWrite.Rank() + r3, _ := platform.RiskHighRiskWrite.Rank() + if !(r1 < r2 && r2 < r3) { + t.Fatalf("Risk ranks not monotonic: read=%d write=%d high=%d", r1, r2, r3) + } +} + +func TestRisk_IsValid(t *testing.T) { + valid := []platform.Risk{platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite} + for _, r := range valid { + if !r.IsValid() { + t.Errorf("%q.IsValid() = false, want true", r) + } + } + invalid := []platform.Risk{"", "wrtie", "Read", "READ", " read "} + for _, r := range invalid { + if r.IsValid() { + t.Errorf("%q.IsValid() = true, want false", r) + } + } +} + +// ParseRisk distinguishes absent (empty input) from invalid (typo). +// The absent / invalid split mirrors the cmdpolicy engine's +// risk_not_annotated vs risk_invalid reason codes. +func TestParseRisk(t *testing.T) { + // Empty -> ("", nil) — "not specified" + got, err := platform.ParseRisk("") + if err != nil || got != "" { + t.Errorf(`ParseRisk("") = (%q,%v), want ("",nil)`, got, err) + } + + // Valid values pass through + for _, want := range []platform.Risk{platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite} { + got, err := platform.ParseRisk(string(want)) + if err != nil || got != want { + t.Errorf("ParseRisk(%q) = (%q,%v), want (%q,nil)", want, got, err, want) + } + } + + // Typo -> error, strict matching (case-sensitive, no trim) + bad := []string{"wrtie", "Read", "READ", " read ", "high_risk_write"} + for _, s := range bad { + got, err := platform.ParseRisk(s) + if err == nil { + t.Errorf("ParseRisk(%q) succeeded (got %q), want error", s, got) + } + if got != "" { + t.Errorf("ParseRisk(%q) returned %q, want empty Risk on error", s, got) + } + } +} + +func TestParseIdentity(t *testing.T) { + got, err := platform.ParseIdentity("") + if err != nil || got != "" { + t.Errorf(`ParseIdentity("") = (%q,%v), want ("",nil)`, got, err) + } + for _, want := range []platform.Identity{platform.IdentityUser, platform.IdentityBot} { + got, err := platform.ParseIdentity(string(want)) + if err != nil || got != want { + t.Errorf("ParseIdentity(%q) = (%q,%v)", want, got, err) + } + } + if _, err := platform.ParseIdentity("admin"); err == nil { + t.Fatalf(`ParseIdentity("admin") want error`) + } +} + +func TestIdentity_IsValid(t *testing.T) { + if !platform.IdentityUser.IsValid() { + t.Error("user.IsValid() = false") + } + if !platform.IdentityBot.IsValid() { + t.Error("bot.IsValid() = false") + } + if platform.Identity("admin").IsValid() { + t.Error("admin.IsValid() = true") + } +} diff --git a/extension/platform/rule.go b/extension/platform/rule.go new file mode 100644 index 000000000..cf5ecebaf --- /dev/null +++ b/extension/platform/rule.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +// Rule is the declarative policy rule data structure. yaml files and +// Plugin.Restrict() both produce the same Rule. +// +// At any moment there is at most one effective Rule -- the resolver decides +// which source wins (Plugin > yaml > none). This package only defines the +// shape; selection lives in internal/cmdpolicy. +// +// The four filter fields are joined by AND. See the engine's Evaluate for +// the full semantics. JSON tags are used by `config policy show`; yaml +// parsing lives in internal/cmdpolicy/yaml so the public API does not +// depend on a yaml library. +type Rule struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + + // Allow is a list of doublestar globs (slash-separated paths). An empty + // slice means "no path restriction"; a non-empty slice means "command + // path must match at least one glob". + Allow []string `json:"allow,omitempty"` + + // Deny is a list of doublestar globs. A path that matches any Deny glob + // is rejected regardless of Allow. + Deny []string `json:"deny,omitempty"` + + // MaxRisk is the highest allowed risk level (inclusive). Empty string + // means "no risk restriction". Comparison uses the closed taxonomy + // read < write < high-risk-write. + MaxRisk Risk `json:"max_risk,omitempty"` + + // Identities is the allowed identity whitelist. A command passes when + // the intersection with the command's own supported identities is + // non-empty. Empty slice means "no identity restriction". + Identities []Identity `json:"identities,omitempty"` + + // AllowUnannotated controls how commands missing a risk_level + // annotation are handled when this Rule is active. + // + // Default (false, fail-closed): unannotated commands are rejected + // with reason_code=risk_not_annotated. This is the safe default + // — a typo'd or forgotten annotation cannot slip past an + // "agent read-only" rule. + // + // Set to true to opt out during gradual adoption: lark-cli main + // has hundreds of service commands that may not yet carry + // risk_level annotations, and a brand-new policy plugin would + // otherwise lock the binary to nothing. + // + // This flag does NOT affect risk_invalid (typos): a command that + // claims a risk but mis-spells it is always denied, regardless of + // AllowUnannotated. Typo is a code bug, not a migration phase. + // + // No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so + // platform stays free of a yaml library dependency. + AllowUnannotated bool `json:"allow_unannotated,omitempty"` +} diff --git a/extension/platform/selector.go b/extension/platform/selector.go new file mode 100644 index 000000000..0e632537f --- /dev/null +++ b/extension/platform/selector.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform + +import "github.com/bmatcuk/doublestar/v4" + +// Selector picks the commands a hook fires on. A nil Selector is +// equivalent to None() -- safer than an "always-match" default because +// it forces every hook to declare its scope explicitly. Compose +// selectors with And / Or / Not. +type Selector func(cmd CommandView) bool + +// All matches every command. Use for audit / metrics observers that +// must run on the whole surface. +func All() Selector { return func(CommandView) bool { return true } } + +// None matches no command. Useful as a "disabled" placeholder. +func None() Selector { return func(CommandView) bool { return false } } + +// ByDomain matches a command whose Domain() is one of the supplied +// names. Commands with unknown (empty-string) Domain never match this +// selector -- the caller should pair it with a Selector that handles +// unknown explicitly when that case matters. +func ByDomain(domains ...string) Selector { + wanted := newStringSet(domains) + return func(cmd CommandView) bool { + d := cmd.Domain() + return d != "" && wanted[d] + } +} + +// ByCommandPath matches against the canonical slash-form path. Patterns +// are doublestar globs ("docs/+update", "im/*", "**"). Invalid patterns +// never match; ValidateRule's twin check catches them at the source. +func ByCommandPath(patterns ...string) Selector { + return func(cmd CommandView) bool { + path := cmd.Path() + for _, p := range patterns { + if ok, err := doublestar.Match(p, path); err == nil && ok { + return true + } + } + return false + } +} + +// ByIdentity matches when the command's supported identities include +// the supplied id. Unknown identities never match. +func ByIdentity(id Identity) Selector { + return func(cmd CommandView) bool { + for _, x := range cmd.Identities() { + if x == id { + return true + } + } + return false + } +} + +// Risk-based selectors below match only commands whose declared risk +// equals the selector's target level. The closed taxonomy is read / +// write / high-risk-write — there is no "unknown" branch in the public +// API. When a Rule without AllowUnannotated=true is registered, the +// policy engine treats unannotated commands as implicit deny, so risk- +// based selectors never see them in hook dispatch under that +// configuration. + +// ByExactRisk matches commands whose declared risk level is exactly level. +func ByExactRisk(level Risk) Selector { + return func(cmd CommandView) bool { + v, ok := cmd.Risk() + return ok && v == level + } +} + +// ByWrite matches commands whose risk is "write" or "high-risk-write". +func ByWrite() Selector { + return func(cmd CommandView) bool { + v, ok := cmd.Risk() + return ok && (v == RiskWrite || v == RiskHighRiskWrite) + } +} + +// ByReadOnly matches commands whose risk is "read". +func ByReadOnly() Selector { + return func(cmd CommandView) bool { + v, ok := cmd.Risk() + return ok && v == RiskRead + } +} + +// normalize maps a nil Selector to None() so combinators honour the +// "nil == None()" contract documented on the Selector type. +func normalize(s Selector) Selector { + if s == nil { + return None() + } + return s +} + +// And composes selectors with AND semantics. +func (s Selector) And(other Selector) Selector { + left, right := normalize(s), normalize(other) + return func(cmd CommandView) bool { + return left(cmd) && right(cmd) + } +} + +// Or composes selectors with OR semantics. +func (s Selector) Or(other Selector) Selector { + left, right := normalize(s), normalize(other) + return func(cmd CommandView) bool { + return left(cmd) || right(cmd) + } +} + +// Not negates the selector. A nil receiver is treated as None(), so +// nil.Not() behaves as All(). +func (s Selector) Not() Selector { + inner := normalize(s) + return func(cmd CommandView) bool { + return !inner(cmd) + } +} + +func newStringSet(items []string) map[string]bool { + out := make(map[string]bool, len(items)) + for _, x := range items { + out[x] = true + } + return out +} diff --git a/extension/platform/selector_test.go b/extension/platform/selector_test.go new file mode 100644 index 000000000..f08b0c660 --- /dev/null +++ b/extension/platform/selector_test.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package platform_test + +import ( + "testing" + + "github.com/larksuite/cli/extension/platform" +) + +// fakeView is a minimal CommandView for unit-testing selectors. +type fakeView struct { + path string + domain string + risk string + riskOK bool + identities []string +} + +func (v fakeView) Path() string { return v.path } +func (v fakeView) Domain() string { return v.domain } +func (v fakeView) Risk() (platform.Risk, bool) { return platform.Risk(v.risk), v.riskOK } +func (v fakeView) Identities() []platform.Identity { + out := make([]platform.Identity, len(v.identities)) + for i, x := range v.identities { + out[i] = platform.Identity(x) + } + return out +} +func (v fakeView) Annotation(key string) (string, bool) { return "", false } + +func TestAll_None(t *testing.T) { + cmd := fakeView{} + if !platform.All()(cmd) { + t.Errorf("All() must match every command") + } + if platform.None()(cmd) { + t.Errorf("None() must match no command") + } +} + +func TestByDomain(t *testing.T) { + sel := platform.ByDomain("docs", "im") + if !sel(fakeView{domain: "docs"}) { + t.Errorf("docs should match") + } + if sel(fakeView{domain: "vc"}) { + t.Errorf("vc must not match docs/im selector") + } + // Unknown domain (empty) must not match. + if sel(fakeView{domain: ""}) { + t.Errorf("unknown domain must not match ByDomain (use ByDomainOrUnknown style if desired)") + } +} + +// Risk-based selectors match only against the closed taxonomy +// (read / write / high-risk-write). Commands without a risk annotation +// never match; the policy engine guarantees such commands cannot reach +// hook dispatch when a Rule without AllowUnannotated=true is registered. +func TestByExactRisk_unknownDoesNotMatch(t *testing.T) { + sel := platform.ByExactRisk("write") + if !sel(fakeView{risk: "write", riskOK: true}) { + t.Errorf("exact write should match") + } + if sel(fakeView{riskOK: false}) { + t.Errorf("unknown must not match ByExactRisk") + } + if sel(fakeView{risk: "read", riskOK: true}) { + t.Errorf("read must not match ByExactRisk(write)") + } +} + +func TestByWrite_byReadOnly(t *testing.T) { + if !platform.ByWrite()(fakeView{risk: "write", riskOK: true}) { + t.Errorf("write should match ByWrite") + } + if !platform.ByWrite()(fakeView{risk: "high-risk-write", riskOK: true}) { + t.Errorf("high-risk-write should match ByWrite") + } + if platform.ByWrite()(fakeView{risk: "read", riskOK: true}) { + t.Errorf("read must not match ByWrite") + } + if platform.ByWrite()(fakeView{riskOK: false}) { + t.Errorf("unknown must not match ByWrite") + } + if !platform.ByReadOnly()(fakeView{risk: "read", riskOK: true}) { + t.Errorf("read should match ByReadOnly") + } + if platform.ByReadOnly()(fakeView{riskOK: false}) { + t.Errorf("unknown must not match ByReadOnly") + } +} + +func TestByCommandPath(t *testing.T) { + sel := platform.ByCommandPath("docs/**", "im/+send") + if !sel(fakeView{path: "docs/+update"}) { + t.Errorf("docs/+update should match docs/**") + } + if !sel(fakeView{path: "im/+send"}) { + t.Errorf("im/+send should match") + } + if sel(fakeView{path: "contact/+search"}) { + t.Errorf("contact/+search must not match") + } +} + +func TestByIdentity(t *testing.T) { + sel := platform.ByIdentity("bot") + if !sel(fakeView{identities: []string{"user", "bot"}}) { + t.Errorf("ids containing bot should match") + } + if sel(fakeView{identities: []string{"user"}}) { + t.Errorf("user-only ids must not match bot selector") + } +} + +func TestSelector_AndOrNot(t *testing.T) { + docsAndWrite := platform.ByDomain("docs").And(platform.ByExactRisk("write")) + if !docsAndWrite(fakeView{domain: "docs", risk: "write", riskOK: true}) { + t.Errorf("AND of matching selectors should match") + } + if docsAndWrite(fakeView{domain: "docs", risk: "read", riskOK: true}) { + t.Errorf("AND fails when one side fails") + } + + docsOrIm := platform.ByDomain("docs").Or(platform.ByDomain("im")) + if !docsOrIm(fakeView{domain: "im"}) { + t.Errorf("OR should match either side") + } + + notRead := platform.ByReadOnly().Not() + if notRead(fakeView{risk: "read", riskOK: true}) { + t.Errorf("Not(ByReadOnly) must reject read commands") + } + if !notRead(fakeView{risk: "write", riskOK: true}) { + t.Errorf("Not(ByReadOnly) should match write") + } +} + +func TestSelector_NilSafeWhenComposed(t *testing.T) { + // A nil Selector is equivalent to None() per the Selector godoc. + // Composition must honour that contract: the resulting selector + // must not panic when invoked and must produce the documented + // boolean outcome (nil-as-None propagates through AND/OR/NOT). + var s platform.Selector + cmd := fakeView{domain: "docs"} + + if got := s.And(platform.All())(cmd); got { + t.Errorf("nil.And(All) should match None semantics (false), got true") + } + if got := s.Or(platform.All())(cmd); !got { + t.Errorf("nil.Or(All) should match (true), got false") + } + if got := platform.All().And(s)(cmd); got { + t.Errorf("All.And(nil) should be None (false), got true") + } + if got := s.Not()(cmd); !got { + t.Errorf("(nil).Not() should be Not(None) = true, got false") + } +} diff --git a/extension/platform/view.go b/extension/platform/view.go new file mode 100644 index 000000000..67c68a4e9 --- /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 policy 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. The policy +// engine treats an unannotated command as implicit deny whenever any +// Rule without AllowUnannotated=true is registered, so risk-based +// Selectors never see unannotated commands during normal hook dispatch +// under that configuration. +type CommandView interface { + // Path is the canonical slash-separated path, rootless ("docs/+update"). + Path() string + + // Domain returns the business domain ("docs", "im", "") inherited from + // the nearest ancestor with a cmdmeta.domain annotation. Empty string + // when no ancestor declares one. + Domain() string + + // Risk returns the static risk level. ok=false signals "no risk_level + // annotation found in the parent chain" (unknown). + Risk() (level Risk, ok bool) + + // Identities returns the supported identities. nil signals "no + // supportedIdentities annotation in the parent chain". + Identities() []Identity + + // Annotation exposes the raw cobra annotation map for plugins that + // need a tag the framework does not surface. + Annotation(key string) (string, bool) +} diff --git a/go.mod b/go.mod index 770cdf589..7862e24ea 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.0 require ( github.com/Microsoft/go-winio v0.6.2 + github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/gofrs/flock v0.8.1 @@ -21,6 +22,7 @@ require ( golang.org/x/sys v0.33.0 golang.org/x/term v0.27.0 golang.org/x/text v0.23.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -60,5 +62,4 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.15.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // 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..f0a9ea6b4 --- /dev/null +++ b/internal/cmdmeta/meta.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package cmdmeta is the single source of truth for command metadata that the +// policy engine and 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). Interpretation is up to the consumer: the policy +// engine treats a missing risk as fail-closed when a Rule is registered +// without AllowUnannotated=true, and as allow otherwise. Identities still +// defaults to ALLOW. Do not synthesise defaults here -- let each consumer +// decide. +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 the policy +// engine 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 +// policy 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" -- the policy engine treats this as +// fail-closed (deny with risk_not_annotated) whenever a Rule without +// AllowUnannotated=true is active, and as allow otherwise. +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" -- the policy engine 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..61e831319 --- /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 policy 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/cmdpolicy/active.go b/internal/cmdpolicy/active.go new file mode 100644 index 000000000..b40d29289 --- /dev/null +++ b/internal/cmdpolicy/active.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +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. +// +// A deep copy is taken so the snapshot is immune to later mutations of +// the input by the caller (a plugin-supplied *Rule could otherwise +// mutate the embedded Allow/Deny/Identities slices after we stored it). +func SetActive(p *ActivePolicy) { + activeMu.Lock() + defer activeMu.Unlock() + if p == nil { + activePolicy = nil + return + } + activePolicy = cloneActivePolicy(p) +} + +// GetActive returns a deep copy of the recorded policy, or nil if +// bootstrap has not finished or no rule applied. Callers can freely +// mutate the result — including the embedded Rule slices — without +// affecting the stored global. +func GetActive() *ActivePolicy { + activeMu.RLock() + defer activeMu.RUnlock() + if activePolicy == nil { + return nil + } + return cloneActivePolicy(activePolicy) +} + +// cloneActivePolicy deep-copies the top-level struct plus the embedded +// Rule's slice fields. Other fields (Source, YAMLPath, DeniedPaths) +// are value types so the struct copy already disjoints them. +func cloneActivePolicy(in *ActivePolicy) *ActivePolicy { + if in == nil { + return nil + } + cp := *in + if in.Rule != nil { + rule := *in.Rule + rule.Allow = append([]string(nil), in.Rule.Allow...) + rule.Deny = append([]string(nil), in.Rule.Deny...) + rule.Identities = append([]platform.Identity(nil), in.Rule.Identities...) + cp.Rule = &rule + } + 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/cmdpolicy/aggregation_test.go b/internal/cmdpolicy/aggregation_test.go new file mode 100644 index 000000000..59384952a --- /dev/null +++ b/internal/cmdpolicy/aggregation_test.go @@ -0,0 +1,364 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" +) + +// 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 := cmdpolicy.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} + cmdutil.SetRisk(send, "write") + im.AddCommand(send) + search := &cobra.Command{Use: "+search", RunE: noop} + cmdutil.SetRisk(search, "read") + im.AddCommand(search) + + // Risk is set on both leaves so the rejection comes from the Allow + // axis (the contract this test pins), not from the risk gate. + e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}}) // none of im/* matches + decisions := e.EvaluateAll(root) + + // Pin the rejection axis: both leaves are rejected by Allow miss, + // NOT by the risk_not_annotated gate. If a future edit drops the + // SetRisk lines above, this assertion fails and the test stops + // silently testing the wrong axis. + if rc := decisions["im/+send"].ReasonCode; rc != "domain_not_allowed" { + t.Errorf("im/+send ReasonCode = %q, want domain_not_allowed", rc) + } + if rc := decisions["im/+search"].ReasonCode; rc != "domain_not_allowed" { + t.Errorf("im/+search ReasonCode = %q, want domain_not_allowed", rc) + } + + denied := cmdpolicy.BuildDeniedByPath(root, decisions, + cmdpolicy.ResolveSource{Kind: cmdpolicy.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 != "policy" { + 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} + cmdutil.SetRisk(fetch, "read") + docs.AddCommand(fetch) // allowed + + delete := &cobra.Command{Use: "+delete", RunE: noop} + cmdutil.SetRisk(delete, "high-risk-write") + docs.AddCommand(delete) // denied by Deny + + e := cmdpolicy.New(&platform.Rule{ + Allow: []string{"docs/**"}, + Deny: []string{"docs/+delete"}, + }) + denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root), + cmdpolicy.ResolveSource{Kind: cmdpolicy.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 := cmdpolicy.New(&platform.Rule{Allow: []string{"nonexistent/**"}}) + denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root), + cmdpolicy.ResolveSource{Kind: cmdpolicy.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 + cmdutil.SetRisk(docs, "read") + root.AddCommand(docs) + delete := &cobra.Command{Use: "+delete", RunE: noop} + cmdutil.SetRisk(delete, "high-risk-write") + docs.AddCommand(delete) + + // Allow "docs" (parent) but deny "+delete" child. + e := cmdpolicy.New(&platform.Rule{ + Allow: []string{"docs"}, + }) + denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root), + cmdpolicy.ResolveSource{Kind: cmdpolicy.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]cmdpolicy.Denial{ + "docs/+update": { + Layer: "policy", + PolicySource: "plugin:secaudit", + RuleName: "secaudit-policy", + ReasonCode: "write_not_allowed", + Reason: "write disabled", + }, + } + cmdpolicy.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 != "command_denied" { + t.Errorf("envelope error.type = %q, want command_denied", 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": "command_denied"`) { + t.Errorf("envelope JSON missing type=command_denied, 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()) + } +} + +// Regression: a pure parent group carrying AnnotationPureGroup must be +// skipped by both EvaluateAll and aggregateParents. Without the skip, +// the cmd.installUnknownSubcommandGuard pass (which attaches a RunE to +// every group for cobra's silent-help fallback) would flip Runnable() +// to true for `docs`, `drive`, etc., and a yaml rule like +// `max_risk: read` would deny every ` --help` invocation with +// reason_code = risk_not_annotated. +func TestEvaluateAll_skipsAnnotatedPureGroup(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + drive := &cobra.Command{ + Use: "drive", + RunE: func(*cobra.Command, []string) error { return nil }, // emulate guard injection + Annotations: map[string]string{ + cmdpolicy.AnnotationPureGroup: "true", + }, + } + root.AddCommand(drive) + pull := &cobra.Command{Use: "+pull", RunE: noop} + cmdutil.SetRisk(pull, "read") + drive.AddCommand(pull) + + e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"}) + got := e.EvaluateAll(root) + + if d, present := got["drive"]; present { + t.Errorf("annotated pure group should not appear in Decisions; got %+v", d) + } + if !got["drive/+pull"].Allowed { + t.Errorf("leaf under pure group must still be evaluated; got %+v", got["drive/+pull"]) + } +} + +// Regression: hasRunnableDescendant must also treat +// AnnotationPureGroup-tagged commands as non-runnable. Without the +// skip, an entire branch consisting of a pure-group placeholder + a +// single pure-group leaf would advertise itself as a "live" subtree +// and the parent aggregation pass would refuse to install a deny stub +// (allLiveChildrenDenied flips to false because the pure group is +// neither runnable nor in `denied`). +func TestHasRunnableDescendant_ignoresAnnotatedPureGroup(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + + // A pure-group sibling of a real leaf. The parent must still + // aggregate based on the real leaf alone. + placeholder := &cobra.Command{ + Use: "placeholder", + RunE: func(*cobra.Command, []string) error { return nil }, + Annotations: map[string]string{ + cmdpolicy.AnnotationPureGroup: "true", + }, + } + docs.AddCommand(placeholder) + noChild := &cobra.Command{ + Use: "+ghost", + RunE: func(*cobra.Command, []string) error { return nil }, + Annotations: map[string]string{ + cmdpolicy.AnnotationPureGroup: "true", + }, + } + placeholder.AddCommand(noChild) + + fetch := &cobra.Command{Use: "+fetch", RunE: noop} + cmdutil.SetRisk(fetch, "write") + docs.AddCommand(fetch) + + e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"}) + decisions := e.EvaluateAll(root) + denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "") + + if _, ok := denied["docs"]; !ok { + t.Fatalf("docs should be aggregated as fully denied (pure-group children excluded from live count); map=%+v", denied) + } +} + +// Regression: aggregateParents must treat an AnnotationPureGroup-tagged +// command exactly like a parent-only group. With cmdRunnable accidentally +// true (RunE attached by the guard), the aggregator would otherwise look +// for an own-RunE denial entry and skip aggregation, leaving ` +// --help` reachable even when every live child is denied. +func TestBuildDeniedByPath_aggregatesAnnotatedPureGroup(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + drive := &cobra.Command{ + Use: "drive", + RunE: func(*cobra.Command, []string) error { return nil }, + Annotations: map[string]string{ + cmdpolicy.AnnotationPureGroup: "true", + }, + } + root.AddCommand(drive) + push := &cobra.Command{Use: "+push", RunE: noop} + cmdutil.SetRisk(push, "write") + drive.AddCommand(push) + pull := &cobra.Command{Use: "+pull", RunE: noop} + cmdutil.SetRisk(pull, "write") + drive.AddCommand(pull) + + e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"}) + decisions := e.EvaluateAll(root) + denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "") + + if _, ok := denied["drive"]; !ok { + t.Fatalf("aggregator must install drive denial when all children denied; map=%+v", denied) + } +} + +// 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]cmdpolicy.Denial{ + "lark-cli": {Layer: "policy", ReasonCode: "all_children_denied"}, + } + cmdpolicy.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/cmdpolicy/apply.go b/internal/cmdpolicy/apply.go new file mode 100644 index 000000000..fead7fd4d --- /dev/null +++ b/internal/cmdpolicy/apply.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import ( + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/output" +) + +// Apply walks the command tree and installs denyStubs for every path in +// deniedByPath whose Denial.Layer == "policy". 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]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 != LayerPolicy { + return + } + if installDenyStub(c, path, d) { + count++ + } + }) + return count +} + +// AnnotationDenialLayer / AnnotationDenialSource carry the denial +// signal to internal/hook through cobra annotations, avoiding an +// import cycle between hook and cmdpolicy. +const ( + AnnotationDenialLayer = "lark:policy_denied_layer" + AnnotationDenialSource = "lark:policy_denied_source" + + // AnnotationPureGroup marks a cobra.Command that is logically a + // parent-only group but had a RunE attached by the bootstrap-time + // unknown-subcommand guard. The engine treats annotated commands + // the same as un-annotated parent groups (no RunE): they are not + // evaluated against the Rule, and aggregateParents does not treat + // them as hybrids. + // + // Without this signal, a user enabling a policy.yml with + // max_risk: read would see every group (`lark-cli drive --help`, + // `lark-cli docs --help`) return exit 2 + risk_not_annotated, + // because the guard's RunE flips Runnable()=true and the engine + // then demands a risk_level annotation on the group itself. + AnnotationPureGroup = "lark:cmd_pure_group" +) + +// IsPureGroup reports whether cmd carries the AnnotationPureGroup marker. +// Used by the engine to skip evaluation and by the aggregator to treat the +// command as a parent-only group regardless of cobra's Runnable() answer. +func IsPureGroup(cmd *cobra.Command) bool { + if cmd == nil || cmd.Annotations == nil { + return false + } + return cmd.Annotations[AnnotationPureGroup] == "true" +} + +// CommandDeniedFromDenial materialises the wrapped error type carried +// on ExitError.Err so errors.As works for in-process consumers. +func CommandDeniedFromDenial(path string, d Denial) *platform.CommandDeniedError { + return &platform.CommandDeniedError{ + Path: path, + Layer: d.Layer, + PolicySource: d.PolicySource, + RuleName: d.RuleName, + ReasonCode: d.ReasonCode, + Reason: d.Reason, + } +} + +// DenialDetailMap is the canonical detail.* shape every `command_denied` +// envelope shares (see docs/extension/reason-codes.md). Use it as +// ErrDetail.Detail when constructing an envelope outside BuildDenialError. +func DenialDetailMap(cd *platform.CommandDeniedError) map[string]any { + return map[string]any{ + "path": cd.Path, + "layer": cd.Layer, + "policy_source": cd.PolicySource, + "rule_name": cd.RuleName, + "reason_code": cd.ReasonCode, + "reason": cd.Reason, + } +} + +// BuildDenialError is the default envelope for user-layer denials: +// Message comes from CommandDeniedError.Error(), no Hint. Callers that +// need a custom Message or an independent Hint (strict-mode) should +// compose CommandDeniedFromDenial + DenialDetailMap themselves. +func BuildDenialError(path string, d Denial) *output.ExitError { + cd := CommandDeniedFromDenial(path, d) + return &output.ExitError{ + Code: output.ExitValidation, + Detail: &output.ErrDetail{ + Type: "command_denied", + Message: cd.Error(), + Detail: DenialDetailMap(cd), + }, + Err: cd, + } +} + +// 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 -> "policy" or "strict_mode" +// - AnnotationDenialSource -> the PolicySource ("yaml", "plugin:foo", ...) +// +// Returns true when the stub was actually installed and false on the +// strict-mode early-return so callers can compute an accurate "commands +// modified" count. +func installDenyStub(cmd *cobra.Command, path string, d Denial) bool { + // 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 detail.layer from "strict_mode" to "policy". + if cmd.Annotations != nil && + cmd.Annotations[AnnotationDenialLayer] == LayerStrictMode { + return false + } + cmd.Hidden = true + cmd.DisableFlagParsing = true + + // Bypass cobra's pre-RunE gates that would otherwise short-circuit + // before the wrapped RunE (= where observers + denial guard live): + // + // 1. Args validator: original commands often declare cobra.NoArgs + // or a custom Args function. With DisableFlagParsing=true, + // `--doc xxx` looks like positional args; cobra.ValidateArgs + // fires BEFORE PersistentPreRunE / PreRunE / RunE and would + // surface a Cobra usage error instead of our pruning envelope. + // ArbitraryArgs accepts everything. + // + // 2. Parent's PersistentPreRunE: cobra's "first PersistentPreRunE + // wins" walks UP from the leaf. cmd/auth/auth.go declares a + // PersistentPreRunE that returns external_provider when env + // credentials are set; without our leaf-level override, that + // fires before pruning's RunE and the caller sees the wrong + // envelope. We set a no-op leaf PersistentPreRunE that just + // silences usage and returns nil, so dispatch proceeds to the + // wrapped RunE (which produces the real pruning envelope and + // lets Before/After observers fire). + cmd.Args = cobra.ArbitraryArgs + cmd.PersistentPreRunE = func(c *cobra.Command, _ []string) error { + c.SilenceUsage = true + return nil + } + cmd.PersistentPreRun = nil + cmd.PreRunE = nil + cmd.PreRun = nil + + 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 { + // error.type is the user-facing semantic ("a command was denied by + // policy"). detail.layer carries the implementation distinction + // ("policy" vs "strict_mode") for debugging. + return BuildDenialError(path, denial) + } + // 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 + return true +} diff --git a/internal/cmdpolicy/denial.go b/internal/cmdpolicy/denial.go new file mode 100644 index 000000000..3411984d0 --- /dev/null +++ b/internal/cmdpolicy/denial.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import "sort" + +// Layer values match CommandDeniedError.Layer and the detail.layer +// field of the JSON envelope (under error.type = "command_denied"). +const ( + LayerStrictMode = "strict_mode" + // LayerPolicy is the user-layer enforcement label. The string value + // is "policy" — the package name "cmdpolicy" matches it. This + // replaces the older "pruning" label. + LayerPolicy = "policy" +) + +// Denial is the merged record for a single rejected command path. It +// is distinct from the user-layer-only Decision type: Denial only +// exists when the command is rejected (the Allowed bool would be +// wasted here, hence not reusing Decision). +type Denial struct { + Layer string // "strict_mode" | "policy" + PolicySource string // "plugin:secaudit" | "yaml:mywork" | "strict-mode" | "" + RuleName string // matched Rule.Name (if any) + ReasonCode string // closed enum, see docs/extension/reason-codes.md + 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 "policy" → parent Layer = policy, +// ReasonCode behaves analogously. +// - mixed layers across children → parent Layer = "policy", +// 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{} + } + + 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=policy + // (the more "user-recoverable" of the two — swapping policy can + // flip children, swapping credential cannot). + if len(layers) > 1 { + return Denial{ + Layer: LayerPolicy, + PolicySource: "mixed", + ReasonCode: "all_children_denied", + Reason: "all child commands are denied (mixed reasons)", + } + } + + var layer string + for l := range layers { + layer = l + } + + d := Denial{Layer: layer} + + 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_policy" + } + } + + if len(sources) == 1 { + for s := range sources { + d.PolicySource = s + } + } + if layer == LayerStrictMode { + d.PolicySource = "strict-mode" + } + + 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/cmdpolicy/denial_test.go b/internal/cmdpolicy/denial_test.go new file mode 100644 index 000000000..6c66665cb --- /dev/null +++ b/internal/cmdpolicy/denial_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "testing" + + "github.com/larksuite/cli/internal/cmdpolicy" +) + +func TestAggregateChildren_allSameLayerAndReason(t *testing.T) { + got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{ + {Path: "docs/+update", Denial: cmdpolicy.Denial{ + Layer: cmdpolicy.LayerPolicy, PolicySource: "yaml:agent", + ReasonCode: "write_not_allowed", RuleName: "agent-policy", + }}, + {Path: "docs/+delete", Denial: cmdpolicy.Denial{ + Layer: cmdpolicy.LayerPolicy, PolicySource: "yaml:agent", + ReasonCode: "write_not_allowed", RuleName: "agent-policy", + }}, + }) + if got.Layer != cmdpolicy.LayerPolicy || got.ReasonCode != "write_not_allowed" { + t.Fatalf("got %+v, want layer=policy 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 := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{ + {Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerPolicy, ReasonCode: "write_not_allowed"}}, + {Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerPolicy, ReasonCode: "domain_not_allowed"}}, + }) + if got.Layer != cmdpolicy.LayerPolicy || got.ReasonCode != "mixed_children_policy" { + t.Fatalf("got %+v, want layer=policy reason=mixed_children_policy", got) + } +} + +func TestAggregateChildren_strictModeBranch(t *testing.T) { + got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{ + {Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported"}}, + {Denial: cmdpolicy.Denial{Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported"}}, + }) + if got.Layer != cmdpolicy.LayerStrictMode || 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 policy) collapse to Layer=policy +// per the design 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_mixedLayersFallsToPolicy(t *testing.T) { + got := cmdpolicy.AggregateChildren([]cmdpolicy.ChildDenial{ + {Path: "docs/+update", Denial: cmdpolicy.Denial{ + Layer: cmdpolicy.LayerStrictMode, ReasonCode: "identity_not_supported", + }}, + {Path: "docs/+fetch", Denial: cmdpolicy.Denial{ + Layer: cmdpolicy.LayerPolicy, ReasonCode: "domain_not_allowed", + }}, + }) + if got.Layer != cmdpolicy.LayerPolicy { + t.Fatalf("Layer = %q, want policy (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 := cmdpolicy.AggregateChildren(nil) + if (got != cmdpolicy.Denial{}) { + t.Fatalf("empty slice should produce zero Denial, got %+v", got) + } +} + +func TestSortChildren_stableOrder(t *testing.T) { + children := []cmdpolicy.ChildDenial{ + {Path: "docs/+update"}, + {Path: "docs/+delete"}, + {Path: "docs/+create"}, + } + cmdpolicy.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/cmdpolicy/diagnostic.go b/internal/cmdpolicy/diagnostic.go new file mode 100644 index 000000000..9b2393248 --- /dev/null +++ b/internal/cmdpolicy/diagnostic.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +// diagnosticPaths lists command paths that are unconditionally allowed, +// regardless of any user-layer Rule. Entries must satisfy two properties: +// +// 1. Read-only. The command performs no I/O outside the local process +// and never mutates remote state. +// 2. Self-reflective. Denying the command would produce a UX dead-end +// where the operator can no longer inspect / validate the policy +// that is locking them out. +// +// Today this is `config policy show` and `config plugins show` -- +// both purely local introspection over the resolved policy. Keep the +// list small and audited: every entry is a permanent hole in the +// fail-closed boundary. +var diagnosticPaths = map[string]bool{ + "config/policy/show": true, + "config/plugins/show": true, +} + +// IsDiagnosticPath reports whether the given canonical command path is +// exempt from user-layer pruning. Exported for test packages; callers +// inside this package use the unexported helper. +func IsDiagnosticPath(path string) bool { + return diagnosticPaths[path] +} diff --git a/internal/cmdpolicy/diagnostic_test.go b/internal/cmdpolicy/diagnostic_test.go new file mode 100644 index 000000000..cc1c3ffa6 --- /dev/null +++ b/internal/cmdpolicy/diagnostic_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" +) + +// configPolicyTree builds the minimal slice of the real command tree +// where diagnostic exemption applies: root -> config -> policy -> show. +func configPolicyTree() *cobra.Command { + root := &cobra.Command{Use: "lark-cli"} + config := &cobra.Command{Use: "config"} + root.AddCommand(config) + policy := &cobra.Command{Use: "policy"} + config.AddCommand(policy) + policy.AddCommand(&cobra.Command{Use: "show", RunE: noop}) + // Plus an unrelated command that the Rule will deny, to anchor the + // "everything except diagnostics" check. + im := &cobra.Command{Use: "im"} + root.AddCommand(im) + im.AddCommand(&cobra.Command{Use: "+send", RunE: noop}) + return root +} + +func TestEvaluate_diagnosticAllowedDespiteStrictAllow(t *testing.T) { + root := configPolicyTree() + // Rule that allows ONLY docs/** -- normally locks out everything else. + e := cmdpolicy.New(&platform.Rule{ + Allow: []string{"docs/**"}, + }) + got := e.EvaluateAll(root) + + if !got["config/policy/show"].Allowed { + t.Errorf("config/policy/show must be unconditionally allowed; got Allowed=false reason=%q", + got["config/policy/show"].ReasonCode) + } + // Sanity: a non-diagnostic command is still denied so we know the + // rule itself is active. + if got["im/+send"].Allowed { + t.Errorf("im/+send should be denied by Allow=[docs/**]; got Allowed=true") + } +} + +func TestEvaluate_diagnosticAllowedDespiteExplicitDeny(t *testing.T) { + // Even a Rule that explicitly Denies the path must not lock the + // operator out -- diagnostic is a permanent hole. If a security- + // sensitive deployment needs to block introspection, they should + // strip the binary, not rely on Rule. + root := configPolicyTree() + e := cmdpolicy.New(&platform.Rule{ + Allow: []string{"**"}, + Deny: []string{"config/policy/**"}, + }) + got := e.EvaluateAll(root) + + if !got["config/policy/show"].Allowed { + t.Errorf("config/policy/show must override explicit Deny; got Allowed=false reason=%q", + got["config/policy/show"].ReasonCode) + } +} + +func TestIsDiagnosticPath(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"config/policy/show", true}, + {"config/plugins/show", true}, + {"config/policy", false}, // parent group itself is not exempt + {"config/plugins", false}, // parent group itself is not exempt + {"docs/+fetch", false}, + {"", false}, + } + for _, tc := range cases { + if got := cmdpolicy.IsDiagnosticPath(tc.path); got != tc.want { + t.Errorf("IsDiagnosticPath(%q) = %v, want %v", tc.path, got, tc.want) + } + } +} diff --git a/internal/cmdpolicy/engine.go b/internal/cmdpolicy/engine.go new file mode 100644 index 000000000..d46a39bcd --- /dev/null +++ b/internal/cmdpolicy/engine.go @@ -0,0 +1,384 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package cmdpolicy is the user-layer command policy 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, which produces command_denied envelopes of the same +// shape via BuildDenialError so external agents can dispatch on +// detail.layer / reason_code uniformly regardless of which layer rejected +// the call. +package cmdpolicy + +import ( + "fmt" + + "github.com/bmatcuk/doublestar/v4" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdmeta" +) + +// Decision is the user-layer single-rule evaluation result. Distinct from +// 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 + } + // Pure parent groups carrying the AnnotationPureGroup marker + // (installed by cmd.installUnknownSubcommandGuard) look + // Runnable to cobra but are not a real leaf: skip them just + // like cobra-native parent groups, so a user-level Rule does + // not block ` --help` discovery. + if IsPureGroup(c) { + 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) + + if IsDiagnosticPath(path) { + return Decision{Allowed: true} + } + + // A registered Rule expresses intent over the closed risk taxonomy + // (read / write / high-risk-write). Two ways a command can fall + // outside that taxonomy: + // + // - "absent" (no risk_level annotation) — fail-closed by default, + // but Rule.AllowUnannotated=true opts out for gradual adoption. + // - "invalid" (annotation exists but is a typo / not in the + // closed enum) — always fail-closed regardless of + // AllowUnannotated. Typo is a code bug, not a migration phase. + cmdRiskStr, hasRisk := cmdmeta.Risk(cmd) + cmdRisk := platform.Risk(cmdRiskStr) + var ( + cmdRank int + cmdRankOk bool + ) + if hasRisk { + cmdRank, cmdRankOk = cmdRisk.Rank() + if !cmdRankOk { + return Decision{ + Allowed: false, + ReasonCode: "risk_invalid", + Reason: fmt.Sprintf("invalid risk %q; did you mean %q?", cmdRiskStr, suggestRisk(cmdRiskStr)), + } + } + } else if !r.AllowUnannotated { + return Decision{ + Allowed: false, + ReasonCode: "risk_not_annotated", + Reason: "command has no risk_level annotation; required when a Rule is active (set rule.allow_unannotated=true to opt out during gradual adoption)", + } + } + + // 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. Skipped when cmd risk is absent + AllowUnannotated: + // the engine has no rank to compare against, and AllowUnannotated + // is the explicit "allow this through" opt-in. + if r.MaxRisk != "" && cmdRankOk { + if limit, limitOk := r.MaxRisk.Rank(); limitOk && 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 && !hasIdentityIntersection(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 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]Denial { + out := map[string]Denial{} + + sourceLabel := policySourceLabel(source) + for path, d := range decisions { + if !d.Allowed { + out[path] = Denial{ + Layer: LayerPolicy, + 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]Denial) bool { + if cmd == nil { + return false + } + + children := cmd.Commands() + // A pure parent group decorated with the unknown-subcommand guard + // looks Runnable() to cobra but is not a true hybrid: treat it + // exactly like cobra-native parent groups so the aggregation pass + // can still install an aggregate deny stub when every live child + // is denied. + cmdRunnable := cmd.Runnable() && !IsPureGroup(cmd) + 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([]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, ChildDenial{Path: cp, Denial: d}) + } + } + } + + if !liveChildSeen { + // No reachable runnable descendant in children, but cmd itself + // may still be a runnable hybrid (own RunE + placeholder + // children). The contract is "every runnable descendant + // beneath cmd (including cmd itself when runnable) is denied", + // so when cmd is runnable, the answer depends on whether cmd + // itself was denied. Returning false unconditionally here lost + // that signal and blocked aggregation up the chain. + if cmdRunnable { + _, ownDenied := denied[cmdPath] + return ownDenied + } + 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 { + SortChildren(childDenials) + denied[cmdPath] = 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() && !IsPureGroup(cmd) { + 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 +} + +// hasIdentityIntersection reports whether the rule's typed identities +// share any value with the command's raw identity strings. Both slices +// are short (usually 1-2 identities) so a nested loop beats allocating +// a set. +func hasIdentityIntersection(rule []platform.Identity, cmd []string) bool { + for _, x := range rule { + for _, y := range cmd { + if string(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/cmdpolicy/engine_test.go b/internal/cmdpolicy/engine_test.go new file mode 100644 index 000000000..c102b58fd --- /dev/null +++ b/internal/cmdpolicy/engine_test.go @@ -0,0 +1,447 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdmeta" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/cmdutil" +) + +// 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 := cmdpolicy.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 := cmdpolicy.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 := cmdpolicy.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 := cmdpolicy.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) + } +} + +// Unannotated commands are implicit-deny when any Rule is registered. +// The closed risk taxonomy (read / write / high-risk-write) is the only +// vocabulary a Rule can reason about; an unannotated command falls +// outside that vocabulary and is denied with reason_code +// "risk_not_annotated", regardless of whether the rule sets MaxRisk. +func TestEvaluate_unannotatedRiskIsDeny(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + // Note: no SetRisk on this command -> unannotated + orphan := &cobra.Command{Use: "+orphan", RunE: noop} + docs.AddCommand(orphan) + + // Rule without MaxRisk still triggers the implicit deny. + e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}}) + got := e.EvaluateAll(root) + if got["docs/+orphan"].Allowed { + t.Fatalf("unannotated risk must be denied when a Rule is registered") + } + if got["docs/+orphan"].ReasonCode != "risk_not_annotated" { + t.Errorf("ReasonCode = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode) + } + + // And with MaxRisk it still uses risk_not_annotated (the missing- + // annotation gate runs before the MaxRisk axis). + e = cmdpolicy.New(&platform.Rule{MaxRisk: "read"}) + got = e.EvaluateAll(root) + if got["docs/+orphan"].ReasonCode != "risk_not_annotated" { + t.Errorf("ReasonCode under MaxRisk = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode) + } + + // An empty Rule{} (no Allow / Deny / MaxRisk / Identities) still + // triggers the implicit deny. "any registered Rule = enter the safety + // boundary" is the design contract; pin it so future edits cannot + // silently weaken it. + e = cmdpolicy.New(&platform.Rule{}) + got = e.EvaluateAll(root) + if got["docs/+orphan"].Allowed { + t.Fatalf("empty Rule{} must still deny unannotated commands") + } + if got["docs/+orphan"].ReasonCode != "risk_not_annotated" { + t.Errorf("empty Rule{} ReasonCode = %q, want risk_not_annotated", got["docs/+orphan"].ReasonCode) + } + + // Without any Rule, unannotated commands are still allowed (no + // policy engine is invoked when no plugin registers a Rule). + e = cmdpolicy.New(nil) + got = e.EvaluateAll(root) + if !got["docs/+orphan"].Allowed { + t.Fatalf("nil Rule must allow unannotated commands (no main-flow impact)") + } +} + +// AllowUnannotated=true opts out of the "unannotated = deny" rule for +// gradual adoption. The flag does NOT loosen any other axis: Deny still +// rejects, MaxRisk is skipped (no rank to compare), Allow/Identities still +// apply. +func TestEvaluate_allowUnannotatedOptsOutOfDeny(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + orphan := &cobra.Command{Use: "+orphan", RunE: noop} + docs.AddCommand(orphan) + + // Without opt-in: still denied + e := cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}}) + if got := e.EvaluateAll(root); got["docs/+orphan"].Allowed { + t.Fatalf("default behaviour must deny unannotated; AllowUnannotated should be opt-in") + } + + // With opt-in: allowed + e = cmdpolicy.New(&platform.Rule{ + Allow: []string{"docs/**"}, + AllowUnannotated: true, + }) + got := e.EvaluateAll(root) + if !got["docs/+orphan"].Allowed { + t.Fatalf("AllowUnannotated=true must allow unannotated commands; got %+v", got["docs/+orphan"]) + } + + // AllowUnannotated does NOT bypass Deny: an unannotated command + // hitting a Deny glob is still rejected. + e = cmdpolicy.New(&platform.Rule{ + Deny: []string{"docs/+orphan"}, + AllowUnannotated: true, + }) + got = e.EvaluateAll(root) + if got["docs/+orphan"].Allowed { + t.Fatalf("AllowUnannotated must not bypass Deny; got %+v", got["docs/+orphan"]) + } + if got["docs/+orphan"].ReasonCode != "command_denylisted" { + t.Errorf("ReasonCode under Deny+AllowUnannotated = %q, want command_denylisted", + got["docs/+orphan"].ReasonCode) + } +} + +// risk_invalid (typo) is unaffected by AllowUnannotated and emits a +// "did you mean" suggestion in the reason text. +func TestEvaluate_invalidRiskAlwaysDeny_andSuggests(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + typo := &cobra.Command{Use: "+typo", RunE: noop} + cmdutil.SetRisk(typo, "wrtie") + docs.AddCommand(typo) + + // AllowUnannotated=true must NOT bypass risk_invalid — typo is a + // code bug, not a missing annotation. + e := cmdpolicy.New(&platform.Rule{ + MaxRisk: "read", + AllowUnannotated: true, + }) + got := e.EvaluateAll(root) + if got["docs/+typo"].Allowed { + t.Fatalf("AllowUnannotated must not bypass risk_invalid; got %+v", got["docs/+typo"]) + } + if got["docs/+typo"].ReasonCode != "risk_invalid" { + t.Errorf("ReasonCode = %q, want risk_invalid", got["docs/+typo"].ReasonCode) + } + if !strings.Contains(got["docs/+typo"].Reason, "write") { + t.Errorf("Reason should contain suggestion 'write', got %q", got["docs/+typo"].Reason) + } +} + +// Invalid risk annotations (typos like "wrtie" or anything outside the +// read|write|high-risk-write taxonomy) are denied with reason_code +// "risk_invalid". Without this gate they used to pass the MaxRisk axis +// because RiskRank returned ok=false and the comparison was skipped -- +// a typo SetRisk would silently slip past an "agent read-only" rule. +func TestEvaluate_invalidRiskIsDeny(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + docs := &cobra.Command{Use: "docs"} + root.AddCommand(docs) + typo := &cobra.Command{Use: "+typo", RunE: noop} + cmdutil.SetRisk(typo, "wrtie") // typo for "write" + docs.AddCommand(typo) + + // Even under MaxRisk=read the typo command must not slip through. + e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"}) + got := e.EvaluateAll(root) + if got["docs/+typo"].Allowed { + t.Fatalf("invalid risk must be denied under MaxRisk=read, got allowed") + } + if got["docs/+typo"].ReasonCode != "risk_invalid" { + t.Errorf("ReasonCode = %q, want risk_invalid", got["docs/+typo"].ReasonCode) + } + + // Same when no MaxRisk is set -- the taxonomy check runs unconditionally + // once a Rule is present. + e = cmdpolicy.New(&platform.Rule{Allow: []string{"docs/**"}}) + got = e.EvaluateAll(root) + if got["docs/+typo"].ReasonCode != "risk_invalid" { + t.Errorf("ReasonCode without MaxRisk = %q, want risk_invalid", got["docs/+typo"].ReasonCode) + } + + // The risk_invalid gate must fire BEFORE Deny matching, otherwise a + // typo command landing in the deny list would surface as + // command_denylisted and mask the underlying taxonomy violation. + e = cmdpolicy.New(&platform.Rule{Deny: []string{"docs/+typo"}}) + got = e.EvaluateAll(root) + if got["docs/+typo"].ReasonCode != "risk_invalid" { + t.Errorf("ReasonCode under Deny match = %q, want risk_invalid (taxonomy gate must precede Deny)", got["docs/+typo"].ReasonCode) + } + + // Without any Rule, invalid risk is not policed (same main-flow + // no-impact rule as risk_not_annotated). + e = cmdpolicy.New(nil) + got = e.EvaluateAll(root) + if !got["docs/+typo"].Allowed { + t.Fatalf("nil Rule must allow invalid risk (no main-flow impact)") + } +} + +func TestEvaluate_identitiesIntersection(t *testing.T) { + root := buildTree() + e := cmdpolicy.New(&platform.Rule{ + Identities: []platform.Identity{"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 defaults to ALLOW. A command with risk annotated +// but 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} + cmdutil.SetRisk(cmd, "read") + root.AddCommand(cmd) + // no SetSupportedIdentities + + e := cmdpolicy.New(&platform.Rule{Identities: []platform.Identity{"bot"}}) + got := e.EvaluateAll(root) + if !got["+x"].Allowed { + t.Fatalf("unknown identities must pass any identity rule") + } +} + +// Apply must install denyStubs only on Layer="policy" 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]cmdpolicy.Denial{ + "docs/+update": {Layer: "policy", ReasonCode: "write_not_allowed"}, + "docs/+fetch": {Layer: "strict_mode", ReasonCode: "identity_not_supported"}, + } + + count := cmdpolicy.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 cmdpolicy.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() + cmdpolicy.Apply(root, map[string]cmdpolicy.Denial{ + "docs/+update": { + Layer: "policy", + 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 != "policy" || 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 := cmdpolicy.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 := cmdpolicy.CanonicalPath(update); got != "docs/+update" { + t.Fatalf("CanonicalPath = %q, want docs/+update", got) + } + if got := cmdpolicy.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/cmdpolicy/path.go b/internal/cmdpolicy/path.go new file mode 100644 index 000000000..e090ed95a --- /dev/null +++ b/internal/cmdpolicy/path.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import ( + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/vfs" +) + +// 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 +} + +// RedactHomeDir collapses environment-rooted prefixes so path strings +// can be safely surfaced through `config policy show` and resolver +// error messages without leaking the user's filesystem layout to AI +// agents / CI logs. +// +// It folds, in priority order: +// 1. core.GetBaseConfigDir() (typically ~/.lark-cli, or a custom +// directory under LARKSUITE_CLI_CONFIG_DIR — e.g. +// "/private/tmp/sandbox/.lark-cli" in a sandboxed run) → "" +// 2. The user's home directory → "~" +// +// (1) runs first so a `LARKSUITE_CLI_CONFIG_DIR` pointing outside `$HOME` +// still produces a stable, non-identifying label. When neither prefix +// matches, the input is returned unchanged — those cases don't leak +// anything that wasn't already passed in by the caller. +// +// The implementation operates on the cleaned strings (no +// `filepath.Abs`) because the depguard / forbidigo lint policy bans +// direct filesystem access from internal/. All real call sites pass +// already-absolute paths (`core.GetBaseConfigDir()` returns absolute +// when LARKSUITE_CLI_CONFIG_DIR or $HOME is set; resolver builds +// yamlPath via filepath.Join on that absolute root). A relative input +// simply falls through the prefix checks and is returned unchanged. +func RedactHomeDir(path string) string { + if path == "" { + return "" + } + clean := filepath.Clean(path) + + if rel, ok := foldPrefix(clean, core.GetBaseConfigDir()); ok { + if rel == "" { + return "" + } + return "/" + rel + } + + home, err := vfs.UserHomeDir() + if err != nil || home == "" { + return path + } + if rel, ok := foldPrefix(clean, home); ok { + if rel == "" { + return "~" + } + return "~/" + rel + } + return path +} + +// foldPrefix reports whether path lives at or beneath prefix; on hit +// it returns the slash-form relative tail (empty when path == prefix). +// filepath.Rel itself rejects the relative-vs-absolute mismatch case +// with an error, so a relative input against an absolute prefix (or +// vice versa) falls through to the "not a hit" branch — no extra +// validation needed. +func foldPrefix(path, prefix string) (string, bool) { + if prefix == "" { + return "", false + } + cleanPrefix := filepath.Clean(prefix) + rel, err := filepath.Rel(cleanPrefix, path) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + if rel == "." { + return "", true + } + return filepath.ToSlash(rel), true +} diff --git a/internal/cmdpolicy/path_test.go b/internal/cmdpolicy/path_test.go new file mode 100644 index 000000000..939a86c09 --- /dev/null +++ b/internal/cmdpolicy/path_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/cmdpolicy" +) + +// RedactHomeDir folds two prefixes: +// +// 1. core.GetBaseConfigDir() → "" (covers the +// LARKSUITE_CLI_CONFIG_DIR override, which is the only way a real +// deployment writes the policy file outside $HOME). +// 2. The user's home dir → "~" (catches the conventional +// ~/.lark-cli/policy.yml path when no override is set). +// +// Both folds run in path-prefix space (not string-prefix), so a path +// like "/Usersfoo" never gets folded against "/Users". +func TestRedactHomeDir_foldsConfigDirOverride(t *testing.T) { + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + + policyPath := filepath.Join(tmp, "policy.yml") + got := cmdpolicy.RedactHomeDir(policyPath) + if got != "/policy.yml" { + t.Errorf("override path = %q, want /policy.yml", got) + } + + // A path that equals the config dir itself collapses to "". + if got := cmdpolicy.RedactHomeDir(tmp); got != "" { + t.Errorf("exact-prefix path = %q, want ", got) + } +} + +// A path outside both the config dir and $HOME stays absolute. This is +// the "no leak introduced" property: redaction never invents a label +// for something it doesn't recognise. +func TestRedactHomeDir_unrelatedPathUnchanged(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "/var/lib/lark-cli") + + path := "/etc/random/file.yml" + if got := cmdpolicy.RedactHomeDir(path); got != path { + t.Errorf("unrelated path = %q, want %q (unchanged)", got, path) + } +} + +// Empty input round-trips. Callers (e.g. `config policy show` with +// no yaml configured) rely on this. +func TestRedactHomeDir_emptyStays(t *testing.T) { + if got := cmdpolicy.RedactHomeDir(""); got != "" { + t.Errorf("empty input = %q, want empty string", got) + } +} diff --git a/internal/cmdpolicy/resolver.go b/internal/cmdpolicy/resolver.go new file mode 100644 index 000000000..dea23b62c --- /dev/null +++ b/internal/cmdpolicy/resolver.go @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import ( + "errors" + "fmt" + "os" + + "github.com/larksuite/cli/extension/platform" + pyaml "github.com/larksuite/cli/internal/cmdpolicy/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. + // + // Error messages use the home-dir-redacted form so the user's + // absolute path doesn't reach agents / CI logs through the + // warnPolicyError stderr line. + display := RedactHomeDir(yamlPath) + 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", display, err) + } + data, err := vfs.ReadFile(yamlPath) + if err != nil { + return nil, ResolveSource{}, fmt.Errorf("read policy yaml %q: %w", display, err) + } + rule, err := pyaml.Parse(data) + if err != nil { + return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", display, err) + } + if err := ValidateRule(rule); err != nil { + return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", display, err) + } + return rule, ResolveSource{Kind: SourceYAML, Name: yamlPath}, nil + } + + return nil, ResolveSource{Kind: SourceNone}, nil +} diff --git a/internal/cmdpolicy/resolver_test.go b/internal/cmdpolicy/resolver_test.go new file mode 100644 index 000000000..d4ff7db0c --- /dev/null +++ b/internal/cmdpolicy/resolver_test.go @@ -0,0 +1,99 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" +) + +func TestResolve_singlePluginWins(t *testing.T) { + rule := &platform.Rule{Name: "secaudit"} + got, src, err := cmdpolicy.Resolve([]cmdpolicy.PluginRule{ + {PluginName: "secaudit", Rule: rule}, + }, "") + if err != nil { + t.Fatalf("Resolve err: %v", err) + } + if got != rule || src.Kind != cmdpolicy.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 := cmdpolicy.Resolve( + []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}}, + yamlPath, + ) + if err != nil { + t.Fatalf("Resolve err: %v", err) + } + if got.Name != "from-plugin" || src.Kind != cmdpolicy.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 := cmdpolicy.Resolve(nil, yamlPath) + if err != nil { + t.Fatalf("Resolve err: %v", err) + } + if got.Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML { + t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src) + } +} + +func TestResolve_missingYamlIsNoRule(t *testing.T) { + // A guaranteed-missing path under t.TempDir() keeps the test + // hermetic — a stray `/nonexistent/policy.yml` could in principle + // exist on some sandbox runners and make the assertion misleading. + missing := filepath.Join(t.TempDir(), "absent-policy.yml") + got, src, err := cmdpolicy.Resolve(nil, missing) + if err != nil { + t.Fatalf("missing yaml should not error, got %v", err) + } + if got != nil || src.Kind != cmdpolicy.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 := cmdpolicy.Resolve([]cmdpolicy.PluginRule{ + {PluginName: "a", Rule: &platform.Rule{Name: "a"}}, + {PluginName: "b", Rule: &platform.Rule{Name: "b"}}, + }, "") + if !errors.Is(err, cmdpolicy.ErrMultipleRestricts) { + t.Fatalf("err = %v, want ErrMultipleRestricts", err) + } +} + +func TestResolve_emptyEverythingIsNone(t *testing.T) { + got, src, err := cmdpolicy.Resolve(nil, "") + if err != nil { + t.Fatalf("Resolve err: %v", err) + } + if got != nil || src.Kind != cmdpolicy.SourceNone { + t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src) + } +} diff --git a/internal/cmdpolicy/source_label_test.go b/internal/cmdpolicy/source_label_test.go new file mode 100644 index 000000000..dbd31d560 --- /dev/null +++ b/internal/cmdpolicy/source_label_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/output" +) + +// 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 := cmdpolicy.New(&platform.Rule{ + Name: "my-readonly-rule", + Allow: []string{"contact/**"}, // docs/* falls outside, denied + }) + denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root), + cmdpolicy.ResolveSource{ + Kind: cmdpolicy.SourceYAML, + Name: "/Users/alice/.lark-cli/policy.yml", // simulate an absolute path + }, "my-readonly-rule") + + cmdpolicy.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 := cmdpolicy.New(&platform.Rule{ + Name: "secaudit-policy", + Deny: []string{"+block"}, + }) + denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root), + cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, + "secaudit-policy") + cmdpolicy.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/cmdpolicy/strict_mode_skip_test.go b/internal/cmdpolicy/strict_mode_skip_test.go new file mode 100644 index 000000000..90276cab5 --- /dev/null +++ b/internal/cmdpolicy/strict_mode_skip_test.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdpolicy" +) + +// cmdpolicy.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{ + cmdpolicy.AnnotationDenialLayer: cmdpolicy.LayerStrictMode, + cmdpolicy.AnnotationDenialSource: "strict-mode", + }, + RunE: func(*cobra.Command, []string) error { return nil }, + } + root.AddCommand(stub) + + // User-layer pruning denies the same path. + denied := map[string]cmdpolicy.Denial{ + "victim": { + Layer: cmdpolicy.LayerPolicy, + PolicySource: "yaml", + Reason: "denied by user yaml", + ReasonCode: "command_denylisted", + }, + } + cmdpolicy.Apply(root, denied) + + if got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerStrictMode { + t.Errorf("strict-mode layer overwritten by pruning: got %q want %q", + got, cmdpolicy.LayerStrictMode) + } + if got := stub.Annotations[cmdpolicy.AnnotationDenialSource]; got != "strict-mode" { + t.Errorf("strict-mode source overwritten: got %q", got) + } +} + +// Regression for codex H13 / C6: a denied command that carries +// flag-like positional args (because DisableFlagParsing=true makes +// every `--doc xxx` look positional) MUST surface the pruning +// envelope, not a cobra usage error. Pre-fix, the original command's +// Args validator (e.g. cobra.NoArgs from shortcut registration) would +// fire BEFORE PersistentPreRunE / RunE and produce +// "Error: positional arguments are not supported". +// +// Fix: installDenyStub sets Args=ArbitraryArgs so cobra's validate +// step always passes, letting dispatch reach the wrapped RunE. +func TestApply_DenyStubBypassesArgsValidator(t *testing.T) { + root := &cobra.Command{Use: "root"} + leaf := &cobra.Command{ + Use: "+update", + Args: cobra.NoArgs, // shortcut style: refuse all positional args + RunE: func(*cobra.Command, []string) error { return nil }, + } + root.AddCommand(leaf) + + denied := map[string]cmdpolicy.Denial{ + "+update": { + Layer: cmdpolicy.LayerPolicy, + PolicySource: "yaml", + ReasonCode: "command_denylisted", + Reason: "denied by user yaml", + }, + } + cmdpolicy.Apply(root, denied) + + if leaf.Args == nil { + t.Fatal("denied command must have non-nil Args validator after Apply") + } + // ArbitraryArgs returns nil for every input -> Args validation no-ops. + if err := leaf.Args(leaf, []string{"--doc", "xxx", "--mode", "append"}); err != nil { + t.Errorf("denied command Args validator should accept any input, got %v", err) + } +} + +// Regression for codex C11 / C13: a denied command whose PARENT +// declares a PersistentPreRunE (e.g. cmd/auth/auth.go's +// external_provider check) MUST surface the pruning envelope, not +// the parent's error. Cobra's "first PersistentPreRunE walking up +// from leaf wins" semantics will pick the parent's PersistentPreRunE +// unless the denied leaf carries its own. +// +// Fix: installDenyStub installs a no-op PersistentPreRunE on the leaf +// so cobra stops there and proceeds to the wrapped RunE (which holds +// the real pruning envelope). +func TestApply_DenyStubBypassesParentPersistentPreRunE(t *testing.T) { + root := &cobra.Command{Use: "root"} + parent := &cobra.Command{ + Use: "auth", + PersistentPreRunE: func(*cobra.Command, []string) error { + return errors.New("parent PersistentPreRunE fired (would mask pruning)") + }, + } + root.AddCommand(parent) + leaf := &cobra.Command{ + Use: "login", + RunE: func(*cobra.Command, []string) error { return nil }, + } + parent.AddCommand(leaf) + + denied := map[string]cmdpolicy.Denial{ + "auth/login": { + Layer: cmdpolicy.LayerPolicy, + PolicySource: "yaml", + ReasonCode: "identity_mismatch", + Reason: "denied", + }, + } + cmdpolicy.Apply(root, denied) + + if leaf.PersistentPreRunE == nil { + t.Fatal("denied command must have leaf-level PersistentPreRunE") + } + // Our PersistentPreRunE must NOT propagate the parent's error. + if err := leaf.PersistentPreRunE(leaf, nil); err != nil { + t.Errorf("denied command leaf PersistentPreRunE should be no-op, got %v", err) + } +} + +// 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]cmdpolicy.Denial{ + "normal": { + Layer: cmdpolicy.LayerPolicy, + PolicySource: "yaml", + Reason: "denied", + ReasonCode: "command_denylisted", + }, + } + cmdpolicy.Apply(root, denied) + + if got := leaf.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerPolicy { + t.Errorf("expected pruning layer annotation, got %q", got) + } +} diff --git a/internal/cmdpolicy/suggest.go b/internal/cmdpolicy/suggest.go new file mode 100644 index 000000000..2f7362e31 --- /dev/null +++ b/internal/cmdpolicy/suggest.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import ( + "github.com/larksuite/cli/extension/platform" +) + +// suggestRisk returns the closest valid Risk literal by edit distance +// for risk_invalid diagnostics; input is never silently substituted. +// Case-insensitive ("WRITE" → "write"); empty in, empty out (the +// absent-annotation case goes to risk_not_annotated, not here). +func suggestRisk(bad string) string { + if bad == "" { + return "" + } + lowered := toLower(bad) + candidates := []platform.Risk{ + platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite, + } + best := string(candidates[0]) + bestDist := levenshtein(lowered, best) + for _, c := range candidates[1:] { + if d := levenshtein(lowered, string(c)); d < bestDist { + bestDist, best = d, string(c) + } + } + return best +} + +// toLower is an ASCII-only lowercase. Risk taxonomy values are +// ASCII; pulling in unicode here would be overkill. +func toLower(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + +// levenshtein computes the classic edit distance between two strings. +// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set +// makes raw performance irrelevant — clarity beats trickiness here. +func levenshtein(a, b string) int { + if len(a) == 0 { + return len(b) + } + if len(b) == 0 { + return len(a) + } + prev := make([]int, len(b)+1) + curr := make([]int, len(b)+1) + for j := 0; j <= len(b); j++ { + prev[j] = j + } + for i := 1; i <= len(a); i++ { + curr[0] = i + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[j] = min3( + prev[j]+1, // deletion + curr[j-1]+1, // insertion + prev[j-1]+cost, // substitution + ) + } + prev, curr = curr, prev + } + return prev[len(b)] +} + +func min3(a, b, c int) int { + m := a + if b < m { + m = b + } + if c < m { + m = c + } + return m +} diff --git a/internal/cmdpolicy/suggest_test.go b/internal/cmdpolicy/suggest_test.go new file mode 100644 index 000000000..da91495a2 --- /dev/null +++ b/internal/cmdpolicy/suggest_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +import "testing" + +// suggest is unexported, so the test lives in the same package. + +func TestSuggestRisk(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"wrtie", "write"}, + {"WRITE", "write"}, + {"reed", "read"}, + {"rad", "read"}, + {"high-rik-write", "high-risk-write"}, + // "highrisk" is genuinely ambiguous between "write" and + // "high-risk-write" — not testing it. + {"", ""}, // empty input has no meaningful suggestion; the engine + // routes the absent case to risk_not_annotated, not risk_invalid. + } + for _, c := range cases { + got := suggestRisk(c.input) + if got != c.want { + t.Errorf("suggestRisk(%q) = %q, want %q", c.input, got, c.want) + } + } +} + +func TestLevenshtein(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"", "", 0}, + {"", "abc", 3}, + {"abc", "", 3}, + {"abc", "abc", 0}, + {"wrtie", "write", 2}, + {"kitten", "sitting", 3}, + } + for _, c := range cases { + got := levenshtein(c.a, c.b) + if got != c.want { + t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} diff --git a/internal/cmdpolicy/validate.go b/internal/cmdpolicy/validate.go new file mode 100644 index 000000000..21bb168fb --- /dev/null +++ b/internal/cmdpolicy/validate.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy + +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 !r.MaxRisk.IsValid() { + 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.IsValid() { + 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/cmdpolicy/validate_test.go b/internal/cmdpolicy/validate_test.go new file mode 100644 index 000000000..3961f12a3 --- /dev/null +++ b/internal/cmdpolicy/validate_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdpolicy_test + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" +) + +// nil rule is "no restriction" everywhere -- validation must agree. +func TestValidateRule_nilIsOk(t *testing.T) { + if err := cmdpolicy.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: []platform.Identity{"user", "bot"}, + } + if err := cmdpolicy.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: platform.Risk(bad)} + err := cmdpolicy.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: []platform.Identity{"user", "admin"}} + err := cmdpolicy.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 := cmdpolicy.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 := cmdpolicy.ValidateRule(r); err != nil { + t.Fatalf("empty optional fields should validate, got %v", err) + } +} diff --git a/internal/cmdpolicy/yaml/reader.go b/internal/cmdpolicy/yaml/reader.go new file mode 100644 index 000000000..41e85a4c9 --- /dev/null +++ b/internal/cmdpolicy/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/cmdpolicy/yaml/schema.go b/internal/cmdpolicy/yaml/schema.go new file mode 100644 index 000000000..718d2a8bd --- /dev/null +++ b/internal/cmdpolicy/yaml/schema.go @@ -0,0 +1,77 @@ +// 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/cmdpolicy.ValidateRule so a single contract is enforced regardless +// of whether the Rule came from yaml or from Plugin.Restrict. +package yaml + +import ( + "errors" + "fmt" + "io" + + 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"` + AllowUnannotated bool `yaml:"allow_unannotated,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/cmdpolicy.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) + } + + // Reject multi-document input: yaml.v3 only decodes one document + // per call, so a stray "---" followed by another document would + // silently drop the trailing rule. + var extra schema + if err := dec.Decode(&extra); !errors.Is(err, io.EOF) { + if err == nil { + return nil, fmt.Errorf("parse policy yaml: multiple YAML documents are not allowed") + } + return nil, fmt.Errorf("parse policy yaml: %w", err) + } + + idents := make([]platform.Identity, len(s.Identities)) + for i, id := range s.Identities { + idents[i] = platform.Identity(id) + } + return &platform.Rule{ + Name: s.Name, + Description: s.Description, + Allow: s.Allow, + Deny: s.Deny, + MaxRisk: platform.Risk(s.MaxRisk), + Identities: idents, + AllowUnannotated: s.AllowUnannotated, + }, nil +} diff --git a/internal/cmdpolicy/yaml/schema_test.go b/internal/cmdpolicy/yaml/schema_test.go new file mode 100644 index 000000000..912c8b2a5 --- /dev/null +++ b/internal/cmdpolicy/yaml/schema_test.go @@ -0,0 +1,131 @@ +// 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/cmdpolicy/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: []platform.Identity{"user"}, + } + if !reflect.DeepEqual(rule, want) { + t.Fatalf("rule = %+v, want %+v", rule, want) + } +} + +// allow_unannotated is documented in the README / author guide as the +// gradual-adoption opt-in. The yaml schema must carry it through to +// platform.Rule, otherwise a user following the docs would either hit +// "unknown field" (under KnownFields strict mode) or silently lose the +// opt-in and end up with a safer-but-broken policy. +func TestParse_allowUnannotatedPassesThrough(t *testing.T) { + data := []byte(` +name: agent-readonly +max_risk: read +allow_unannotated: true +`) + rule, err := pyaml.Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if !rule.AllowUnannotated { + t.Fatalf("AllowUnannotated = false, want true (yaml field must propagate)") + } + if rule.MaxRisk != "read" || rule.Name != "agent-readonly" { + t.Errorf("other fields lost: %+v", rule) + } +} + +// Default is false when the key is absent: pin the fail-closed default so +// future schema edits cannot accidentally flip it. +func TestParse_allowUnannotatedDefaultsFalse(t *testing.T) { + data := []byte(` +name: x +max_risk: read +`) + rule, err := pyaml.Parse(data) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if rule.AllowUnannotated { + t.Fatalf("AllowUnannotated must default to false when key is absent") + } +} + +// 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 cmdpolicy.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") + } +} + +// A stray "---" separator followed by another document would silently +// drop the trailing rule if yaml.v3 stopped after the first Decode. +// Parse must reject multi-document input so the operator can't typo a +// separator and end up with an unintentionally empty policy. +func TestParse_rejectsMultipleDocuments(t *testing.T) { + data := []byte(`name: first +max_risk: read +--- +name: second +max_risk: write +`) + if _, err := pyaml.Parse(data); err == nil { + t.Fatalf("Parse should reject multi-document YAML input") + } +} diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 1ccfee440..5eff1931f 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -161,7 +161,7 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode { func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error { mode := f.ResolveStrictMode(ctx) if mode.IsActive() && !mode.AllowsIdentity(as) { - return output.ErrWithHint(output.ExitValidation, "strict_mode", + return output.ErrWithHint(output.ExitValidation, "command_denied", fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()), "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)") } diff --git a/internal/hook/doc.go b/internal/hook/doc.go new file mode 100644 index 000000000..6993cb1bb --- /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/platform, 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..cd4346c3a --- /dev/null +++ b/internal/hook/install.go @@ -0,0 +1,358 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package hook + +import ( + "context" + "errors" + "fmt" + + "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. +// +// - **Denial layer / source are populated from cobra annotations +// before any hook fires.** populateInvocationDenial reads the +// annotations attached by cmdpolicy.Apply and strictModeStubFrom, +// avoiding an import cycle between hook and cmdpolicy. +// +// Install must be called once during the Bootstrap pipeline after +// policy 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 := newInvocation(view, args) + + // Detect denial: a denied command's original RunE was already + // replaced by cmdpolicy.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: cmdpolicy.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 cmdpolicy.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) + // Pass the wrapRunE-local args, not i.Args(): the original + // RunE must see what cobra parsed, not what a hook may have + // observed via the read-only interface. + finalHandler := composed(func(c2 context.Context, _ platform.Invocation) error { + return invokeOriginal(c2, c, args, originalRunE, originalRun) + }) + err = finalHandler(ctx, inv) + } + + // Convert AbortError -> *output.ExitError so the envelope writer + // renders the structured "hook" type. + err = wrapAbortError(err) + + inv.setErr(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). +// +// The wrapper-propagated ctx is set on cmd via SetContext *before* the +// inner RunE/Run is invoked, so any context values injected by an +// upstream Wrapper (auth tokens, request-scoped IDs, trace spans, +// cancellation deadlines) reach the original handler. Without this +// hand-off the inner handler would observe c.Context() — the +// pre-wrapper context — and silently lose every value the Wrap chain +// added. +// +// We restore the previous context on return so a single command's +// SetContext mutation cannot leak to sibling dispatches that share the +// same *cobra.Command pointer (cobra reuses the tree across calls in +// long-running embedders). +func invokeOriginal(ctx context.Context, c *cobra.Command, args []string, runE func(*cobra.Command, []string) error, run func(*cobra.Command, []string)) error { + prev := c.Context() + c.SetContext(ctx) + defer c.SetContext(prev) + + 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 reads the cobra annotation set by +// cmdpolicy.Apply and propagates it onto the framework-internal +// invocation. +// +// V1 contract: a denial is signalled by the cobra annotation +// "lark:policy_denied_layer" being set on the command. The layer +// value is the enforcement layer ("policy" / "strict_mode") that +// gets emitted as detail.layer in the envelope; the source follows +// the annotation "lark:policy_denied_source". +// +// This indirection lets us avoid an import cycle between hook and +// pruning packages. +func populateInvocationDenial(inv *invocation, c *cobra.Command) { + const layerKey = "lark:policy_denied_layer" + const sourceKey = "lark:policy_denied_source" + if c.Annotations == nil { + return + } + layer, ok := c.Annotations[layerKey] + if !ok || layer == "" { + return + } + source := c.Annotations[sourceKey] + inv.setDenial(layer, source) +} diff --git a/internal/hook/install_default.go b/internal/hook/install_default.go new file mode 100644 index 000000000..2c382a76e --- /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 //nolint:forbidigo // framework-level fallback writer; hooks fire before IOStreams plumbing is available diff --git a/internal/hook/install_test.go b/internal/hook/install_test.go new file mode 100644 index 000000000..7f11f2897 --- /dev/null +++ b/internal/hook/install_test.go @@ -0,0 +1,397 @@ +// 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() (platform.Risk, bool) { return platform.Risk(v.risk), v.risk != "" } +func (v fakeView) Identities() []platform.Identity { 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:policy_denied_layer": "policy", + "lark:policy_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. Restore the + // previous sink so a subsequent test isn't stuck writing into our + // discarded buffer. + t.Cleanup(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) + } +} + +// A Before observer mutating inv.Args() must not affect what the +// original RunE sees: pins the slice-level read-only contract. +func TestInstall_argsNotMutableByObserver(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + + var seenByRunE []string + leaf := &cobra.Command{ + Use: "+echo", + RunE: func(_ *cobra.Command, args []string) error { + seenByRunE = append([]string(nil), args...) + return nil + }, + } + root.AddCommand(leaf) + + reg := hook.NewRegistry() + reg.AddObserver(hook.ObserverEntry{ + Name: "tamper", When: platform.Before, Selector: platform.All(), + Fn: func(_ context.Context, inv platform.Invocation) { + got := inv.Args() + if len(got) > 0 { + got[0] = "HIJACKED" + } + }, + }) + hook.Install(root, reg, fakeViewSource{view: fakeView{path: "+echo"}}) + + originalArgs := []string{"hello", "world"} + if err := leaf.RunE(leaf, originalArgs); err != nil { + t.Fatalf("RunE returned %v", err) + } + if !equalStrings(seenByRunE, originalArgs) { + t.Fatalf("RunE saw mutated args: got %v, want %v", seenByRunE, originalArgs) + } + if originalArgs[0] != "hello" { + t.Fatalf("caller's original args were mutated: %v", originalArgs) + } +} + +// 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/invocation.go b/internal/hook/invocation.go new file mode 100644 index 000000000..fceb1b606 --- /dev/null +++ b/internal/hook/invocation.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package hook + +import ( + "time" + + "github.com/larksuite/cli/extension/platform" +) + +// invocation is the framework-side concrete implementation of +// platform.Invocation. All setters are unexported so plugin code +// (which only sees the platform.Invocation interface) cannot mutate +// state. +// +// The "denial" / "strict_mode" / "identity" fields are populated by +// the framework's bootstrap pipeline before any hook fires; plugins +// only read them through the interface. +type invocation struct { + cmd platform.CommandView + args []string + started time.Time + err error + + denied bool + layer string + source string + + strictMode string + strictModeKnown bool + + identity string + identityResolved bool +} + +// newInvocation copies args so the read-only platform.Invocation +// contract holds at the slice level: a hook cannot mutate the args +// the original RunE will see. +func newInvocation(cmd platform.CommandView, args []string) *invocation { + argsCopy := append([]string(nil), args...) + return &invocation{ + cmd: cmd, + args: argsCopy, + started: time.Now(), + } +} + +// --- platform.Invocation read interface --- + +func (i *invocation) Cmd() platform.CommandView { return i.cmd } + +// Args returns a fresh copy every call; see newInvocation. +func (i *invocation) Args() []string { + out := make([]string, len(i.args)) + copy(out, i.args) + return out +} +func (i *invocation) Started() time.Time { return i.started } +func (i *invocation) Err() error { return i.err } + +func (i *invocation) DeniedByPolicy() bool { return i.denied } +func (i *invocation) DenialLayer() string { return i.layer } +func (i *invocation) DenialPolicySource() string { + return i.source +} + +func (i *invocation) StrictMode() (string, bool) { return i.strictMode, i.strictModeKnown } +func (i *invocation) Identity() (string, bool) { return i.identity, i.identityResolved } + +// --- framework-internal setters (unexported) --- + +func (i *invocation) setDenial(layer, source string) { + i.denied = true + i.layer = layer + i.source = source +} + +// StrictMode and Identity setters are intentionally absent in V1: the +// framework does not yet plumb either value to the invocation, and +// platform.Invocation.StrictMode() / Identity() therefore return zero +// values. Add the setters when the bootstrap pipeline starts resolving +// them. + +func (i *invocation) setErr(err error) { + i.err = err +} diff --git a/internal/hook/registry.go b/internal/hook/registry.go new file mode 100644 index 000000000..90235c270 --- /dev/null +++ b/internal/hook/registry.go @@ -0,0 +1,184 @@ +// 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 internalplatform. +type Registry struct { + mu sync.RWMutex + + observers []ObserverEntry + wrappers []WrapperEntry + lifecycles []LifecycleEntry +} + +// NewRegistry returns an empty Registry. +func NewRegistry() *Registry { return &Registry{} } + +// Observers returns a snapshot of all registered observers. Order is +// registration order. Diagnostic commands (config plugins show) call +// this to enumerate every hook attached to the binary. +func (r *Registry) Observers() []ObserverEntry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]ObserverEntry, len(r.observers)) + copy(out, r.observers) + return out +} + +// Wrappers returns a snapshot of all registered wrappers. Order is +// registration order (outermost first). +func (r *Registry) Wrappers() []WrapperEntry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]WrapperEntry, len(r.wrappers)) + copy(out, r.wrappers) + return out +} + +// Lifecycles returns a snapshot of all registered lifecycle handlers. +func (r *Registry) Lifecycles() []LifecycleEntry { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]LifecycleEntry, len(r.lifecycles)) + copy(out, r.lifecycles) + return out +} + +// 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..611257e1b --- /dev/null +++ b/internal/hook/testing.go @@ -0,0 +1,23 @@ +// 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 and returns a restore function the caller MUST defer +// (or pass to `t.Cleanup`). Without the restore step, a later test in +// the same binary would inherit the override and either race on a +// shared bytes.Buffer or write user-visible garbage into a real test +// stderr. +// +// Production code never calls this; the default writer is os.Stderr +// via defaultStderr. +func SetStderrForTesting(w io.Writer) (restore func()) { + prev := stderr + stderr = func() interface{ Write(p []byte) (int, error) } { + return w + } + return func() { stderr = prev } +} 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/platform/doc.go b/internal/platform/doc.go new file mode 100644 index 000000000..1a70e594c --- /dev/null +++ b/internal/platform/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 cmdpolicy.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 []cmdpolicy.PluginRule for the pruning resolver +// - an error when a FailClosed plugin failed +package internalplatform diff --git a/internal/platform/error.go b/internal/platform/error.go new file mode 100644 index 000000000..8ee037aa6 --- /dev/null +++ b/internal/platform/error.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +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/platform/host.go b/internal/platform/host.go new file mode 100644 index 000000000..2f13cf59d --- /dev/null +++ b/internal/platform/host.go @@ -0,0 +1,344 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +import ( + "errors" + "fmt" + "io" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/cmdpolicy" + "github.com/larksuite/cli/internal/hook" +) + +// PluginInfo is the metadata of a successfully-installed plugin, +// captured at install time so diagnostic commands (config plugins show) +// can enumerate plugins without re-calling potentially panic-prone +// plugin methods at display time. +type PluginInfo struct { + Name string + Version string + Capabilities platform.Capabilities +} + +// InstallResult is the output of InstallAll. Registry is ready for +// hook.Install; PluginRules feeds into cmdpolicy.Resolve as the +// "plugin contribution" half of the resolver input. Plugins lists +// every plugin that committed successfully (FailOpen-skipped plugins +// are absent), for downstream diagnostics. +type InstallResult struct { + Registry *hook.Registry + PluginRules []cmdpolicy.PluginRule + Plugins []PluginInfo +} + +// 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 + } + + // FailurePolicy is a closed enum. An out-of-range value almost + // always means the plugin author shipped FailurePolicy(2)/etc. by + // mistake, and the host's switch on caps.FailurePolicy below would + // silently treat the unknown value as FailOpen — defeating the + // security boundary the policy was meant to express. Reject up + // front with ReasonInvalidCapability (classified as + // untrusted-config, so the abort is unconditional). + if caps.FailurePolicy != platform.FailOpen && caps.FailurePolicy != platform.FailClosed { + return &PluginInstallError{ + PluginName: name, + ReasonCode: ReasonInvalidCapability, + Reason: fmt.Sprintf("FailurePolicy=%d is not a recognised value (expected FailOpen or FailClosed)", + caps.FailurePolicy), + } + } + + // 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, cmdpolicy.PluginRule{ + PluginName: name, + Rule: staging.rule, + }) + } + + // Record the plugin in the inventory. Version is fetched here under + // a recover-wrapped helper so a plugin's Version() panic does not + // abort the install we just committed. + result.Plugins = append(result.Plugins, PluginInfo{ + Name: name, + Version: safeCallVersion(p), + Capabilities: caps, + }) + return nil +} + +// safeCallVersion mirrors safeCallName but for Plugin.Version. Failures +// degrade to the empty string -- Version is informational, not a hard +// contract field, so we never want it to abort installation. +func safeCallVersion(p platform.Plugin) (v string) { + defer func() { + if r := recover(); r != nil { + v = "" + } + }() + return p.Version() +} + +// 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/platform/host_test.go b/internal/platform/host_test.go new file mode 100644 index 000000000..13b1f574e --- /dev/null +++ b/internal/platform/host_test.go @@ -0,0 +1,391 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform_test + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/extension/platform" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +// 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 := internalplatform.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() (platform.Risk, bool) { return "", false } +func (fakeView) Identities() []platform.Identity { 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 := internalplatform.InstallAll([]platform.Plugin{failClosedPlugin{}}, nil) + if err == nil { + t.Fatalf("FailClosed install error should abort") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) { + t.Fatalf("error must be *PluginInstallError, got %T", err) + } + if pi.ReasonCode != internalplatform.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 := internalplatform.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 := internalplatform.InstallAll([]platform.Plugin{misconfiguredRestrictPlugin{}}, nil) + if err == nil { + t.Fatalf("Restricts+FailOpen must abort") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.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 := internalplatform.InstallAll([]platform.Plugin{lyingRestrictPlugin{}}, nil) + if err == nil { + t.Fatalf("missing Restrict call when declared must fail") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.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 := internalplatform.InstallAll([]platform.Plugin{panicInstallPlugin{}}, nil) + if err == nil { + t.Fatalf("Install panic should surface as error") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.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 := internalplatform.InstallAll([]platform.Plugin{ + happyPlugin{name: "audit"}, + happyPlugin{name: "audit"}, + }, nil) + if err == nil { + t.Fatalf("duplicate Plugin.Name must abort") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.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 := internalplatform.InstallAll([]platform.Plugin{badNamePlugin{n: name}}, nil) + if err == nil { + t.Fatalf("invalid name %q should abort", name) + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.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 := internalplatform.InstallAll([]platform.Plugin{duplicateHookPlugin{}}, nil) + if err == nil { + t.Fatalf("duplicate hookName within same plugin must abort") + } + var pi *internalplatform.PluginInstallError + if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.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", + Allow: []string{"docs/**"}, + Deny: []string{"docs/+delete-doc"}, + Identities: []platform.Identity{"bot"}, + } + result, err := internalplatform.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)) + } + stored := result.PluginRules[0].Rule + if stored == nil { + t.Fatalf("stored rule is nil") + } + + // stagingRegistrar.Restrict defensively clones the plugin-supplied + // rule so a misbehaving plugin can't mutate it after Install + // returns. The clone must carry identical contents but live on a + // distinct pointer. + if stored == rule { + t.Errorf("stored rule should be a clone, got identical pointer") + } + if stored.Name != rule.Name || stored.MaxRisk != rule.MaxRisk { + t.Errorf("stored rule lost data: %+v", stored) + } + if got, want := len(stored.Allow), len(rule.Allow); got != want { + t.Errorf("stored Allow len = %d, want %d", got, want) + } + + // Verify the clone is actually isolated: mutating the plugin's + // rule after install must not change the stored one. + rule.Allow[0] = "evil/**" + rule.Deny = append(rule.Deny, "extra/**") + if stored.Allow[0] == "evil/**" { + t.Errorf("Allow slice aliased plugin storage") + } + if len(stored.Deny) != 1 { + t.Errorf("Deny slice aliased plugin storage: %v", stored.Deny) + } + + 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 := internalplatform.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 *internalplatform.PluginInstallError + if !errors.As(err, &pi) { + t.Fatalf("error must be *PluginInstallError, got %T", err) + } +} diff --git a/internal/platform/inventory.go b/internal/platform/inventory.go new file mode 100644 index 000000000..1127f9f46 --- /dev/null +++ b/internal/platform/inventory.go @@ -0,0 +1,264 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +import ( + "strings" + "sync" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/hook" +) + +// HookEntry is the displayable form of one registered hook. +type HookEntry struct { + Name string `json:"name"` + When string `json:"when,omitempty"` // observers only + Event string `json:"event,omitempty"` // lifecycle only +} + +// PluginEntry collects everything one plugin contributed. +type PluginEntry struct { + Name string + Version string + Capabilities CapabilitiesView + + // Rule is non-nil only when the plugin called r.Restrict. + Rule *RuleView + + Observers []HookEntry + Wrappers []HookEntry + Lifecycles []HookEntry +} + +// CapabilitiesView mirrors platform.Capabilities for display. We keep a +// separate struct so the JSON shape stays under our control and does +// not drift with extension/platform. +type CapabilitiesView struct { + Restricts bool `json:"restricts"` + FailurePolicy string `json:"failure_policy"` + RequiredCLIVersion string `json:"required_cli_version,omitempty"` +} + +// NewCapabilitiesView converts a platform.Capabilities value into the +// display struct. +func NewCapabilitiesView(c platform.Capabilities) CapabilitiesView { + return CapabilitiesView{ + Restricts: c.Restricts, + FailurePolicy: failurePolicyLabel(c.FailurePolicy), + RequiredCLIVersion: c.RequiredCLIVersion, + } +} + +func failurePolicyLabel(p platform.FailurePolicy) string { + switch p { + case platform.FailOpen: + return "FailOpen" + case platform.FailClosed: + return "FailClosed" + } + return "" +} + +// RuleView is the displayable form of a Plugin.Restrict contribution. +type RuleView struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Allow []string `json:"allow"` + Deny []string `json:"deny"` + MaxRisk string `json:"max_risk"` + Identities []string `json:"identities"` + AllowUnannotated bool `json:"allow_unannotated"` +} + +// Inventory is the full snapshot. +type Inventory struct { + Plugins []PluginEntry +} + +// PluginInventorySource is the minimum slice of PluginInfo BuildInventory needs. +type PluginInventorySource struct { + Name string + Version string + Capabilities platform.Capabilities +} + +// RuleInventorySource is the minimum slice of cmdpolicy.PluginRule +// BuildInventory needs. Kept as plain strings to avoid an import +// cycle with cmdpolicy (the caller converts platform.Risk / Identity +// to string at the boundary). +type RuleInventorySource struct { + PluginName string + Allow []string + Deny []string + MaxRisk string + Identities []string + RuleName string + Desc string + AllowUnannotated bool +} + +// BuildInventory assembles an Inventory from the parts produced by +// InstallAll: the plugin metadata list, the hook registry (may be nil +// when no hooks were registered), and the plugin rules. +// +// Hooks are attributed to plugins by the namespaced name convention: +// each entry's Name starts with ".", and we group by the +// leading segment up to the first dot. +func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource) *Inventory { + byPlugin := make(map[string]*PluginEntry, len(plugins)) + out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))} + for _, p := range plugins { + entry := PluginEntry{ + Name: p.Name, + Version: p.Version, + Capabilities: NewCapabilitiesView(p.Capabilities), + } + out.Plugins = append(out.Plugins, entry) + } + for i := range out.Plugins { + byPlugin[out.Plugins[i].Name] = &out.Plugins[i] + } + + if registry != nil { + for _, e := range registry.Observers() { + if entry := byPlugin[ownerOf(e.Name)]; entry != nil { + entry.Observers = append(entry.Observers, HookEntry{ + Name: e.Name, + When: whenLabel(e.When), + }) + } + } + for _, e := range registry.Wrappers() { + if entry := byPlugin[ownerOf(e.Name)]; entry != nil { + entry.Wrappers = append(entry.Wrappers, HookEntry{ + Name: e.Name, + }) + } + } + for _, e := range registry.Lifecycles() { + if entry := byPlugin[ownerOf(e.Name)]; entry != nil { + entry.Lifecycles = append(entry.Lifecycles, HookEntry{ + Name: e.Name, + Event: eventLabel(e.Event), + }) + } + } + } + + for _, r := range rules { + if entry := byPlugin[r.PluginName]; entry != nil { + entry.Rule = &RuleView{ + Name: r.RuleName, + Description: r.Desc, + Allow: r.Allow, + Deny: r.Deny, + MaxRisk: r.MaxRisk, + Identities: r.Identities, + AllowUnannotated: r.AllowUnannotated, + } + } + } + return out +} + +// ownerOf extracts the plugin name from a namespaced hook name. The +// platform forbids "." in plugin names, so the first dot is always the +// namespace separator. Names without a dot are returned as-is. +func ownerOf(hookName string) string { + if i := strings.IndexByte(hookName, '.'); i >= 0 { + return hookName[:i] + } + return hookName +} + +func whenLabel(w platform.When) string { + switch w { + case platform.Before: + return "Before" + case platform.After: + return "After" + } + return "" +} + +func eventLabel(e platform.LifecycleEvent) string { + switch e { + case platform.Startup: + return "Startup" + case platform.Shutdown: + return "Shutdown" + } + return "" +} + +// --- Active inventory storage (process-global) --- + +var ( + inventoryMu sync.RWMutex + activeInventory *Inventory +) + +// SetActiveInventory records the inventory built at bootstrap. Called +// once from cmd/policy.go after install + wireHooks complete. +// +// A deep copy is taken so the snapshot is immune to later mutations of +// the input by the caller (or by any other goroutine reading the same +// PluginEntry slice). Without deep-copy, the shallow `cp := *inv` +// previously still aliased Plugins / observer / wrapper / lifecycle +// slices and the embedded RuleView's slice fields. +func SetActiveInventory(inv *Inventory) { + inventoryMu.Lock() + defer inventoryMu.Unlock() + if inv == nil { + activeInventory = nil + return + } + activeInventory = cloneInventory(inv) +} + +// GetActiveInventory returns a deep copy of the inventory, or nil if +// bootstrap has not finished. Same reasoning as SetActiveInventory: +// returning a shallow copy would let callers reach into the stored +// global through any of the embedded slices. +func GetActiveInventory() *Inventory { + inventoryMu.RLock() + defer inventoryMu.RUnlock() + if activeInventory == nil { + return nil + } + return cloneInventory(activeInventory) +} + +// cloneInventory deep-copies every level the snapshot exposes: +// top-level struct, Plugins slice, each PluginEntry's hook slices, and +// the rule's slice fields. The hook entries themselves are value types +// so the slice copy already disjoints them. +func cloneInventory(in *Inventory) *Inventory { + if in == nil { + return nil + } + out := &Inventory{ + Plugins: make([]PluginEntry, len(in.Plugins)), + } + for i, p := range in.Plugins { + entry := PluginEntry{ + Name: p.Name, + Version: p.Version, + Capabilities: p.Capabilities, + } + if p.Rule != nil { + rv := *p.Rule + rv.Allow = append([]string(nil), p.Rule.Allow...) + rv.Deny = append([]string(nil), p.Rule.Deny...) + rv.Identities = append([]string(nil), p.Rule.Identities...) + entry.Rule = &rv + } + entry.Observers = append([]HookEntry(nil), p.Observers...) + entry.Wrappers = append([]HookEntry(nil), p.Wrappers...) + entry.Lifecycles = append([]HookEntry(nil), p.Lifecycles...) + out.Plugins[i] = entry + } + return out +} diff --git a/internal/platform/inventory_test.go b/internal/platform/inventory_test.go new file mode 100644 index 000000000..a9d8d8b51 --- /dev/null +++ b/internal/platform/inventory_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform_test + +import ( + "context" + "testing" + + "github.com/larksuite/cli/extension/platform" + "github.com/larksuite/cli/internal/hook" + internalplatform "github.com/larksuite/cli/internal/platform" +) + +func TestBuildInventory_groupsByPluginName(t *testing.T) { + plugins := []internalplatform.PluginInventorySource{ + {Name: "a", Version: "1.0", Capabilities: platform.Capabilities{ + Restricts: true, FailurePolicy: platform.FailClosed, + }}, + {Name: "b", Version: "2.0"}, + } + + r := hook.NewRegistry() + obs := func(context.Context, platform.Invocation) {} + wrap := func(next platform.Handler) platform.Handler { return next } + lc := func(context.Context, *platform.LifecycleContext) error { return nil } + + r.AddObserver(hook.ObserverEntry{Name: "a.pre", When: platform.Before, Selector: platform.All(), Fn: obs}) + r.AddObserver(hook.ObserverEntry{Name: "a.post", When: platform.After, Selector: platform.All(), Fn: obs}) + r.AddObserver(hook.ObserverEntry{Name: "b.audit", When: platform.Before, Selector: platform.All(), Fn: obs}) + r.AddWrapper(hook.WrapperEntry{Name: "a.approval", Selector: platform.All(), Fn: wrap}) + r.AddLifecycle(hook.LifecycleEntry{Name: "a.boot", Event: platform.Startup, Fn: lc}) + r.AddLifecycle(hook.LifecycleEntry{Name: "b.bye", Event: platform.Shutdown, Fn: lc}) + + rules := []internalplatform.RuleInventorySource{ + {PluginName: "a", RuleName: "a-rule", Allow: []string{"docs/**"}, MaxRisk: "read"}, + } + + inv := internalplatform.BuildInventory(plugins, r, rules) + + if got := len(inv.Plugins); got != 2 { + t.Fatalf("Plugins len = %d, want 2", got) + } + a := findPlugin(inv, "a") + b := findPlugin(inv, "b") + if a == nil || b == nil { + t.Fatalf("missing entries: a=%v b=%v", a, b) + } + + if got := len(a.Observers); got != 2 { + t.Errorf("a.Observers = %d, want 2", got) + } + if got := len(a.Wrappers); got != 1 { + t.Errorf("a.Wrappers = %d, want 1", got) + } + if got := len(a.Lifecycles); got != 1 { + t.Errorf("a.Lifecycles = %d, want 1", got) + } + if a.Rule == nil || a.Rule.Name != "a-rule" { + t.Errorf("a.Rule = %+v, want name a-rule", a.Rule) + } + if a.Capabilities.FailurePolicy != "FailClosed" { + t.Errorf("a.Capabilities.FailurePolicy = %q, want FailClosed", a.Capabilities.FailurePolicy) + } + + if got := len(b.Observers); got != 1 { + t.Errorf("b.Observers = %d, want 1 (only b.audit)", got) + } + if b.Rule != nil { + t.Errorf("b.Rule = %+v, want nil (b did not call Restrict)", b.Rule) + } + if b.Capabilities.FailurePolicy != "FailOpen" { + t.Errorf("b.Capabilities.FailurePolicy = %q, want FailOpen (zero value)", b.Capabilities.FailurePolicy) + } +} + +func TestBuildInventory_empty(t *testing.T) { + inv := internalplatform.BuildInventory(nil, nil, nil) + if got := len(inv.Plugins); got != 0 { + t.Errorf("Plugins len = %d, want 0", got) + } +} + +func findPlugin(inv *internalplatform.Inventory, name string) *internalplatform.PluginEntry { + for i := range inv.Plugins { + if inv.Plugins[i].Name == name { + return &inv.Plugins[i] + } + } + return nil +} diff --git a/internal/platform/staging.go b/internal/platform/staging.go new file mode 100644 index 000000000..1b0b7668a --- /dev/null +++ b/internal/platform/staging.go @@ -0,0 +1,228 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +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 + } + // Defensive clone: retaining the caller's *Rule directly would let + // the plugin mutate Allow/Deny/Identities (or even the whole rule) + // after Install returns, bypassing the validation we run on the + // stored copy in validateSelf. Take an independent snapshot of + // every slice field so the post-validation rule is frozen. + cp := *rule + cp.Allow = append([]string(nil), rule.Allow...) + cp.Deny = append([]string(nil), rule.Deny...) + cp.Identities = append([]platform.Identity(nil), rule.Identities...) + r.rule = &cp +} + +// --- 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/platform/version.go b/internal/platform/version.go new file mode 100644 index 000000000..9cdc05fb7 --- /dev/null +++ b/internal/platform/version.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +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 //nolint:nilerr // intentional fail-open for unparseable buildVersion + } + 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, ".") + // Reject `1.2.3.4` and longer instead of silently truncating — + // truncation hides the typo and lets a malformed RequiredCLIVersion + // pass validation while the comparator below operates on the wrong + // components. Build-version parsing has its own fail-open guard + // upstream (see satisfiesRequiredCLIVersion comment about exotic + // build tags), so it stays compatible. + if len(fields) > 3 { + return [3]int{}, fmt.Errorf("version %q has more than three numeric components", s) + } + 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/platform/version_test.go b/internal/platform/version_test.go new file mode 100644 index 000000000..fec37bf0b --- /dev/null +++ b/internal/platform/version_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package internalplatform + +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/shortcuts/register.go b/shortcuts/register.go index f2e9f85dd..439870787 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -9,6 +9,7 @@ import ( "github.com/larksuite/cli/shortcuts/okr" "github.com/spf13/cobra" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts/base" @@ -92,6 +93,18 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f } program.AddCommand(svc) } + // Tag the service group with its domain so platform.ByDomain + // and Rule.Allow path-globs work without each leaf shortcut + // having to declare the domain itself: cmdmeta.Domain walks up + // the parent chain and stops at the first annotated ancestor + // (this command). + // + // Done OUTSIDE the create branch so the tag is still applied + // when the service command was pre-created by cmd/service + // (OpenAPI auto-registration adds im, drive, calendar, etc. + // before shortcuts run). Without this, only pure-shortcut + // services like `docs` would get tagged. + cmdmeta.SetDomain(svc, service) if service == "docs" { doc.ConfigureServiceHelp(svc) } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 87dddb1fe..3d791759f 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -13,6 +13,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" @@ -98,6 +99,37 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) { } } +// Service-level cobra commands created by RegisterShortcuts must carry +// the cmdmeta.Domain annotation so plugin Selectors (platform.ByDomain) +// and Rule.Allow path-globs can resolve a command's business domain. +// The annotation is set on the parent; cmdmeta.Domain walks up the +// parent chain so every leaf shortcut inherits without extra tagging. +func TestRegisterShortcutsTagsServiceDomain(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newRegisterTestFactory(t)) + + for _, svc := range []string{"im", "docs", "drive", "calendar", "base"} { + group, _, err := program.Find([]string{svc}) + if err != nil || group == nil { + t.Errorf("service %q not mounted", svc) + continue + } + if got := cmdmeta.Domain(group); got != svc { + t.Errorf("service %q domain = %q, want %q", svc, got, svc) + } + } + + // Inheritance: a leaf shortcut under a service must also resolve + // to the parent's domain via cmdmeta.Domain's parent-chain walk. + leaf, _, err := program.Find([]string{"im", "+messages-send"}) + if err != nil || leaf == nil { + t.Fatalf("expected im/+messages-send to be mounted") + } + if got := cmdmeta.Domain(leaf); got != "im" { + t.Errorf("leaf domain via parent inheritance = %q, want %q", got, "im") + } +} + func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) { program := &cobra.Command{Use: "root"} RegisterShortcuts(program, newRegisterTestFactory(t))