mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 19:33:43 +08:00
Introduces extension/platform — the in-process plugin SDK external
Go forks of lark-cli use to extend or restrict the command surface.
Plugins compile in via blank import; there is no dynamic loading
and no RPC isolation.
Public SDK (extension/platform):
- Plugin interface (Name / Version / Capabilities / Install).
- Registrar verbs: Observe, Wrap, On, Restrict.
- Hook types: Observer (side-effect, panic-safe, fires Before/After
RunE), Wrapper (middleware, may short-circuit via AbortError),
LifecycleHandler (Startup / Shutdown), Selector with nil-safe
And/Or/Not composition.
- Risk / Identity are defined string types with closed taxonomies;
ParseRisk / ParseIdentity convert raw strings with the
absent-vs-invalid distinction the engine relies on.
- Builder ergonomic constructor (NewPlugin().Observer().Wrap()
...MustBuild()) that enforces name/hookName grammar, hookName
uniqueness, and the Restrict ↔ FailClosed pairing regardless of
call order.
- Invocation is a read-only interface; the framework's concrete
invocation type lives in internal/hook so plugins cannot
fabricate denial / strict-mode / identity state. Args() returns
a defensive copy on every call so hook mutation cannot leak
into the original RunE.
- CommandDeniedError + AbortError carry structured fields for the
closed `command_denied` / `hook` envelope contract.
- ResetForTesting gated behind //go:build testing.
- README + godoc examples (Observer / Wrapper / Restrict) + two
runnable example forks (audit-observer, readonly-policy).
Host (internal/platform, internal/hook, internal/cmdpolicy):
- InstallAll: staged plugin registration with atomic commit, panic
isolation, FailOpen / FailClosed semantics, RequiredCLIVersion
semver check, single-Restrict invariant, duplicate-plugin-name
detection.
- hook.Install wraps every runnable cmd.RunE with:
Before observers (panic-safe) → denial guard → composed Wrap
chain → original RunE → After observers (always fire, even on
err). Denied commands physically bypass the Wrap chain so a
plugin Wrapper cannot suppress or rewrite a denial; observers
still see the attempt for audit.
- Recover shim around plugin Wrappers converts panics (including
the factory call) into a structured `hook` envelope with
reason_code=panic; namespacing shim attributes AbortError to
the namespaced hook name.
- cmdpolicy (renamed from internal/pruning) is the user-layer
command policy engine: walks the cobra tree, evaluates each
runnable command against a Rule's four-axis filter (Allow /
Deny / MaxRisk / Identities), produces parent-group aggregate
denials, and installs denyStubs. Rule.AllowUnannotated opts out
of the unannotated-deny gate for gradual adoption; risk_invalid
typos always deny with an edit-distance "did you mean"
suggestion.
- Strict-mode stub in cmd/prune.go composes the shared
detail.* / wrapped CommandDeniedError shape via cmdpolicy
helpers (BuildDenialError / CommandDeniedFromDenial /
DenialDetailMap), so command_denied envelopes from strict-mode
and user-layer policy carry the same closed-enum fields
(detail.layer / reason_code / policy_source). The historical
short Message + independent Hint are preserved unchanged.
- cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml
with KnownFields strict mode, including allow_unannotated.
- `config policy show` / `config policy validate` and the plugin
inventory diagnostic surface the resolved Rule (allow,
deny, max_risk, identities, allow_unannotated) and the hook
contributions per plugin.
Envelope contract (docs/extension/reason-codes.md):
- error.type is a closed set: command_denied, hook, plugin_install,
plugin_conflict, plugin_lifecycle.
- reason_code is a closed enum per error.type, dispatched on by
external agents and CI integrations.
- detail.layer = "policy" | "strict_mode" attributes the rejection.
Build / CI:
- Makefile unit-test / vet / coverage and ci.yml fast-gate +
unit-test + coverage now pass -tags testing so register_testing.go
is visible; ./extension/... is in the package list so the SDK's
own tests actually run.
- fmt-check and examples-build Makefile targets.
- bmatcuk/doublestar/v4 added as a direct dependency for `**` glob
matching in Rule.Allow / Rule.Deny.
Author-facing material:
- docs/extension/ (quickstart, plugin-author-guide, reason-codes)
is provided in the working tree but kept out of git tracking
per repo convention (.gitignore covers docs/).
Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703
260 lines
7.6 KiB
Go
260 lines
7.6 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// tmpHome creates a tempdir, points $HOME at it, and returns the path to
|
|
// the ~/.lark-cli/ subdirectory (created). The HOME env var is restored
|
|
// when the test ends.
|
|
func tmpHome(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
t.Setenv("HOME", dir)
|
|
t.Setenv("USERPROFILE", dir) // Windows fallback for os.UserHomeDir
|
|
cfgDir := filepath.Join(dir, ".lark-cli")
|
|
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
return cfgDir
|
|
}
|
|
|
|
// writePolicy writes a policy.yml into the user config dir.
|
|
func writePolicy(t *testing.T, cfgDir string, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"), []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write policy: %v", err)
|
|
}
|
|
}
|
|
|
|
// fakeTree builds a minimal command tree with the same shape the real
|
|
// CLI exposes for these tests: lark-cli has a docs group with +fetch and
|
|
// +update, and an im group with +send. Each leaf has its risk_level set
|
|
// so MaxRisk filtering exercises a real path.
|
|
func fakeTree(t *testing.T) *cobra.Command {
|
|
t.Helper()
|
|
root := &cobra.Command{Use: "lark-cli"}
|
|
|
|
docs := &cobra.Command{Use: "docs"}
|
|
root.AddCommand(docs)
|
|
addLeaf(docs, "+fetch", "read")
|
|
addLeaf(docs, "+update", "write")
|
|
addLeaf(docs, "+delete-doc", "high-risk-write")
|
|
|
|
im := &cobra.Command{Use: "im"}
|
|
root.AddCommand(im)
|
|
addLeaf(im, "+send", "write")
|
|
|
|
return root
|
|
}
|
|
|
|
func addLeaf(parent *cobra.Command, use, risk string) {
|
|
leaf := &cobra.Command{
|
|
Use: use,
|
|
RunE: func(*cobra.Command, []string) error { return nil },
|
|
}
|
|
cmdutil.SetRisk(leaf, risk)
|
|
parent.AddCommand(leaf)
|
|
}
|
|
|
|
// findLeaf walks the tree by Use names.
|
|
func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
|
|
t.Helper()
|
|
cur := parent
|
|
for _, n := range names {
|
|
var next *cobra.Command
|
|
for _, c := range cur.Commands() {
|
|
if c.Use == n {
|
|
next = c
|
|
break
|
|
}
|
|
}
|
|
if next == nil {
|
|
t.Fatalf("child %q not found under %q", n, cur.Use)
|
|
}
|
|
cur = next
|
|
}
|
|
return cur
|
|
}
|
|
|
|
// Happy path: a valid policy.yml denies one specific command. The denied
|
|
// command's RunE returns a typed ExitError envelope; allowed commands are
|
|
// untouched.
|
|
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
|
|
cfgDir := tmpHome(t)
|
|
writePolicy(t, cfgDir, `
|
|
name: test-policy
|
|
allow: ["docs/**", "contact/**"]
|
|
deny: ["docs/+delete-doc"]
|
|
max_risk: write
|
|
`)
|
|
|
|
root := fakeTree(t)
|
|
if err := applyUserPolicyPruning(root, nil); err != nil {
|
|
t.Fatalf("apply policy: %v", err)
|
|
}
|
|
|
|
// docs/+delete-doc must be denied (Deny match).
|
|
deleteCmd := findLeaf(t, root, "docs", "+delete-doc")
|
|
if !deleteCmd.Hidden {
|
|
t.Errorf("+delete-doc should be hidden after pruning")
|
|
}
|
|
err := deleteCmd.RunE(deleteCmd, nil)
|
|
if err == nil {
|
|
t.Fatalf("+delete-doc RunE should return an error")
|
|
}
|
|
var exitErr *output.ExitError
|
|
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "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{}
|
|
}
|