Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6f2ba48
feat(extension): introduce Plugin / Hook framework with command pruning
sang-neo03 May 13, 2026
f34d051
fix(pruning): deny stub must override Args + PersistentPreRunE
sang-neo03 May 13, 2026
b3a6a35
Merge branch 'main' into feat/extension-platform
sang-neo03 May 13, 2026
2bedf8c
fix(config): policy subcommand bypasses parent's credential check
sang-neo03 May 13, 2026
b0e2d4d
feat(shortcuts): tag service groups with cmdmeta.Domain
sang-neo03 May 13, 2026
203ef32
feat(diagnostic): add unconditionally allowed command paths for intro…
sang-neo03 May 14, 2026
c046ed4
feat(plugins): add diagnostic command to inspect installed plugins an…
sang-neo03 May 14, 2026
aa9760e
Merge branch 'main' into feat/extension-platform
sang-neo03 May 15, 2026
9429c52
fix(cli): surface unknown_subcommand error instead of silent help fal…
sang-neo03 May 15, 2026
adfe1f8
refactor(envelope): rename error.type pruning/strict_mode to command_…
sang-neo03 May 15, 2026
bb20e40
feat(platform): fail closed on unannotated/invalid risk when a Rule i…
sang-neo03 May 15, 2026
27efb9d
chore(config): hide diagnostic policy/plugins commands from --help
sang-neo03 May 15, 2026
7d28188
Merge branch 'main' into feat/extension-platform
sang-neo03 May 15, 2026
cbfa752
style: gofmt
sang-neo03 May 15, 2026
a2dff61
Merge branch 'main' into feat/extension-platform
sang-neo03 May 15, 2026
2a6f0f4
fix(platform): nil Selector honours None contract; reject multi-doc p…
sang-neo03 May 16, 2026
06b06ed
chore: go mod tidy
sang-neo03 May 16, 2026
461e3c6
feat(extension/platform): plugin SDK with policy engine, hooks, and B…
liangshuo-1 May 16, 2026
4ff5ad2
feat(extension/platform): plugin SDK with policy engine, hooks, and B…
liangshuo-1 May 16, 2026
b78df3d
Merge remote-tracking branch 'origin/feat/extension-platform' into fe…
sang-neo03 May 16, 2026
aacb5d7
refactor(policy): remove validate command and update diagnostics
sang-neo03 May 16, 2026
0efee93
fix(extension/platform): address PR review must-fix items
sang-neo03 May 16, 2026
baa8878
Merge branch 'main' into feat/extension-platform
sang-neo03 May 16, 2026
eba0d75
fix(extension/platform): address CodeRabbit review comments + CI gofmt
sang-neo03 May 16, 2026
1f9f75a
fix(cmdpolicy): replace filepath.Abs with filepath.Clean for lint policy
sang-neo03 May 16, 2026
52cbb92
refactor(cmdpolicy): pure Resolve + drop path redaction & verbose com…
liangshuo-1 May 17, 2026
b5e599a
refactor(platform): drop StrictMode/Identity from Invocation interface
liangshuo-1 May 17, 2026
7da7364
fix(prune): preserve original metadata on strict-mode denial stubs
liangshuo-1 May 17, 2026
d0c098c
chore(platform): align docs with implementation; fold home in yaml wa…
liangshuo-1 May 17, 2026
3edc55f
chore(build): drop vestigial -tags testing from Makefile and CI
liangshuo-1 May 17, 2026
2eb0c2b
feat(cmdpolicy): enrich denial Reason with attempted value + rule con…
liangshuo-1 May 17, 2026
5eb4470
fix(cmdpolicy): gofmt engine_test.go
liangshuo-1 May 17, 2026
5d9d438
feat(cmd): annotate risk_level on all hand-written cobra commands
sang-neo03 May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...

lint:
needs: fast-gate
Expand Down
25 changes: 22 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -21,13 +21,32 @@ build: fetch_meta
vet: fetch_meta
go vet ./...

# 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

# ./extension/... 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 -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
Expand Down
1 change: 1 addition & 0 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "write")

return cmd
}
Expand Down
1 change: 1 addition & 0 deletions cmd/auth/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.

cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read")

return cmd
}
Expand Down
1 change: 1 addition & 0 deletions cmd/auth/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts)
},
}
cmdutil.SetRisk(cmd, "read")

return cmd
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand All @@ -62,6 +62,7 @@ browser. Run it in the background and retrieve the verification URL from its out
},
}
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
cmdutil.SetRisk(cmd, "write")

cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
Expand Down
1 change: 1 addition & 0 deletions cmd/auth/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts)
},
}
cmdutil.SetRisk(cmd, "write")

return cmd
}
Expand Down
1 change: 1 addition & 0 deletions cmd/auth/scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
}

cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmdutil.SetRisk(cmd, "read")

return cmd
}
Expand Down
1 change: 1 addition & 0 deletions cmd/auth/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
}

cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
cmdutil.SetRisk(cmd, "read")

return cmd
}
Expand Down
56 changes: 50 additions & 6 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
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"
Expand Down Expand Up @@ -59,18 +61,28 @@
}
}

// Build constructs the full command tree without executing.
// Returns only the cobra.Command; Factory is internal.
// Build constructs the full command tree. It also installs registered
// plugins and emits the Startup lifecycle event during assembly --
// so Plugin.On(Startup) handlers run even if the returned command is
// never dispatched. The matching Shutdown event is only emitted by
// Execute; callers that bypass Execute will not see Shutdown fire.
//
// 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{}
Expand Down Expand Up @@ -124,10 +136,42 @@
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)

// Prune commands incompatible with strict mode.
installUnknownSubcommandGuard(rootCmd)

if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}

return f, rootCmd
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)
return f, rootCmd, nil
}
var pluginRules []cmdpolicy.PluginRule
var registry *hook.Registry
if installResult != nil {
pluginRules = installResult.PluginRules
registry = installResult.Registry
}

// Policy errors fail-CLOSED when a plugin contributed (security
// intent must not be silently dropped); yaml-only errors fail-OPEN
// with a warning so a typo can't lock the user out.
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
if len(pluginRules) > 0 {
installPluginConflictGuard(rootCmd, err)
return f, rootCmd, nil
}
warnPolicyError(cfg.streams.ErrOut, err)

Check warning on line 165 in cmd/build.go

View check run for this annotation

Codecov / codecov/patch

cmd/build.go#L165

Added line #L165 was not covered by tests
Comment thread
sang-neo03 marked this conversation as resolved.
}

if registry != nil {
if err := wireHooks(ctx, rootCmd, registry); err != nil {
installPluginLifecycleErrorGuard(rootCmd, err)
return f, rootCmd, nil
}
}

recordInventory(installResult)
return f, rootCmd, registry
}
1 change: 1 addition & 0 deletions cmd/completion/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRisk(cmd, "read")
return cmd
}
1 change: 1 addition & 0 deletions cmd/config/bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
cmdutil.SetRisk(cmd, "write")

return cmd
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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
}

Expand Down
1 change: 1 addition & 0 deletions cmd/config/default_as.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,6 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
return nil
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}
1 change: 1 addition & 0 deletions cmd/config/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmdutil.SetRisk(cmd, "write")

return cmd
}
Expand Down
101 changes: 101 additions & 0 deletions cmd/config/plugins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// 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
},

Check warning on line 36 in cmd/config/plugins.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/plugins.go#L34-L36

Added lines #L34 - L36 were not covered by tests
}
cmd.AddCommand(newCmdConfigPluginsShow(f))
return cmd
}

func newCmdConfigPluginsShow(f *cmdutil.Factory) *cobra.Command {
cmd := &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)
},

Check warning on line 57 in cmd/config/plugins.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/plugins.go#L56-L57

Added lines #L56 - L57 were not covered by tests
}
cmdutil.SetRisk(cmd, "read")
return cmd
}

func runConfigPluginsShow(f *cmdutil.Factory) error {
inv := internalplatform.GetActiveInventory()
if inv == nil {

Check warning on line 65 in cmd/config/plugins.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/plugins.go#L63-L65

Added lines #L63 - L65 were not covered by tests
// 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",
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return nil

Check warning on line 75 in cmd/config/plugins.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/plugins.go#L70-L75

Added lines #L70 - L75 were not covered by tests
}

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,

Check warning on line 83 in cmd/config/plugins.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/plugins.go#L78-L83

Added lines #L78 - L83 were not covered by tests
}
if p.Rule != nil {
entry["rule"] = p.Rule

Check warning on line 86 in cmd/config/plugins.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/plugins.go#L85-L86

Added lines #L85 - L86 were not covered by tests
}
entry["hooks"] = map[string]any{
"observers": p.Observers,
"wrappers": p.Wrappers,
"lifecycle": p.Lifecycles,
"count": len(p.Observers) + len(p.Wrappers) + len(p.Lifecycles),

Check warning on line 92 in cmd/config/plugins.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/plugins.go#L88-L92

Added lines #L88 - L92 were not covered by tests
}
plugins = append(plugins, entry)

Check warning on line 94 in cmd/config/plugins.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/plugins.go#L94

Added line #L94 was not covered by tests
}
output.PrintJson(f.IOStreams.Out, map[string]any{
"plugins": plugins,
"total": len(plugins),
})
return nil

Check warning on line 100 in cmd/config/plugins.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/plugins.go#L96-L100

Added lines #L96 - L100 were not covered by tests
}
Loading
Loading