chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contentsafety
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register installs a content-safety Provider. Later registrations
|
||||
// override earlier ones (last-write-wins).
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contentsafety
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Provider scans parsed response data for content-safety issues.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Scan(ctx context.Context, req ScanRequest) (*Alert, error)
|
||||
}
|
||||
|
||||
// ScanRequest carries the data to scan.
|
||||
type ScanRequest struct {
|
||||
Path string // normalized command path (e.g. "im.messages_search")
|
||||
Data any // parsed response data (generic JSON shape)
|
||||
ErrOut io.Writer // stderr for provider-level notices (e.g. lazy-config creation)
|
||||
}
|
||||
|
||||
// Alert holds the result of a content-safety scan that detected issues.
|
||||
type Alert struct {
|
||||
Provider string `json:"provider"`
|
||||
MatchedRules []string `json:"matched_rules"`
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contentsafety
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlertFields(t *testing.T) {
|
||||
a := &Alert{
|
||||
Provider: "regex",
|
||||
MatchedRules: []string{"rule_a", "rule_b"},
|
||||
}
|
||||
if a.Provider != "regex" {
|
||||
t.Errorf("Provider = %q, want %q", a.Provider, "regex")
|
||||
}
|
||||
if len(a.MatchedRules) != 2 {
|
||||
t.Errorf("MatchedRules length = %d, want 2", len(a.MatchedRules))
|
||||
}
|
||||
}
|
||||
|
||||
type stubProvider struct{}
|
||||
|
||||
func (s *stubProvider) Name() string { return "stub" }
|
||||
func (s *stubProvider) Scan(_ context.Context, _ ScanRequest) (*Alert, error) {
|
||||
return &Alert{Provider: "stub", MatchedRules: []string{"test"}}, nil
|
||||
}
|
||||
|
||||
func TestProviderInterface(t *testing.T) {
|
||||
var p Provider = &stubProvider{}
|
||||
if p.Name() != "stub" {
|
||||
t.Errorf("Name() = %q, want %q", p.Name(), "stub")
|
||||
}
|
||||
alert, err := p.Scan(context.Background(), ScanRequest{Path: "test", Data: nil, ErrOut: io.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan() error = %v", err)
|
||||
}
|
||||
if alert.Provider != "stub" {
|
||||
t.Errorf("alert.Provider = %q, want %q", alert.Provider, "stub")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryLastWriteWins(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := provider
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
provider = old
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
if GetProvider() != nil {
|
||||
t.Fatal("expected nil provider initially")
|
||||
}
|
||||
p1 := &stubProvider{}
|
||||
Register(p1)
|
||||
if GetProvider() != p1 {
|
||||
t.Fatal("expected p1 after first Register")
|
||||
}
|
||||
p2 := &stubProvider{}
|
||||
Register(p2)
|
||||
if GetProvider() != p2 {
|
||||
t.Fatal("expected p2 after second Register (last-write-wins)")
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package env
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// Provider resolves credentials from environment variables.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "env" }
|
||||
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
appID := os.Getenv(envvars.CliAppID)
|
||||
appSecret := os.Getenv(envvars.CliAppSecret)
|
||||
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
|
||||
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
|
||||
if appID == "" && appSecret == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
case hasTAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
|
||||
}
|
||||
if appSecret == "" && !hasUAT && !hasTAT {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
}
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
|
||||
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit strict mode policy takes priority
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
case "":
|
||||
// Infer from available tokens
|
||||
if hasUAT {
|
||||
acct.SupportedIdentities |= credential.SupportsUser
|
||||
}
|
||||
if hasTAT {
|
||||
acct.SupportedIdentities |= credential.SupportsBot
|
||||
}
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
if acct.DefaultAs == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
acct.DefaultAs = credential.IdentityUser
|
||||
case hasTAT:
|
||||
acct.DefaultAs = credential.IdentityBot
|
||||
}
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
var envKey string
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
envKey = envvars.CliUserAccessToken
|
||||
case credential.TokenTypeTAT:
|
||||
envKey = envvars.CliTenantAccessToken
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
token := os.Getenv(envKey)
|
||||
if token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return &credential.Token{Value: token, Source: "env:" + envKey}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package env
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
func TestProvider_Name(t *testing.T) {
|
||||
if (&Provider{}).Name() != "env" {
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_BothSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
t.Setenv(envvars.CliBrand, " LARK ")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.AppID != "cli_test" || acct.AppSecret != "secret_test" || acct.Brand != "lark" {
|
||||
t.Errorf("unexpected: %+v", acct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_NeitherSet(t *testing.T) {
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil || acct != nil {
|
||||
t.Errorf("expected nil, nil; got %+v, %v", acct, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyIDSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("expected account, got nil")
|
||||
}
|
||||
if acct.AppSecret != credential.NoAppSecret {
|
||||
t.Fatalf("AppSecret = %q, want credential.NoAppSecret", acct.AppSecret)
|
||||
}
|
||||
if acct.AppID != "cli_test" {
|
||||
t.Fatalf("AppID = %q, want cli_test", acct.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlySecretSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_DefaultBrand(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
acct, _ := (&Provider{}).ResolveAccount(context.Background())
|
||||
if acct.Brand != "feishu" {
|
||||
t.Errorf("expected 'feishu', got %q", acct.Brand)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_DefaultAsFromEnv(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "secret_test")
|
||||
t.Setenv(envvars.CliDefaultAs, "user")
|
||||
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_UATSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-env")
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Value != "u-env" || tok.Source != "env:"+envvars.CliUserAccessToken {
|
||||
t.Errorf("unexpected: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_TATSet(t *testing.T) {
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-env")
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.Value != "t-env" || tok.Source != "env:"+envvars.CliTenantAccessToken {
|
||||
t.Errorf("unexpected: %+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotSet(t *testing.T) {
|
||||
tok, err := (&Provider{}).ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil || tok != nil {
|
||||
t.Errorf("expected nil, nil; got %+v, %v", tok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeBot(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "bot")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("expected bot-only, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeUser(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "user")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.UserOnly() {
|
||||
t.Errorf("expected user-only, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeOff(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "off")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferFromUATOnly(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.UserOnly() {
|
||||
t.Errorf("expected user-only from UAT inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user from UAT inference, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferFromTATOnly(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("expected bot-only from TAT inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "bot" {
|
||||
t.Errorf("expected default-as bot from TAT inference, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InferBothTokens(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("expected SupportsAll, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
if acct.DefaultAs != "user" {
|
||||
t.Errorf("expected default-as user when both tokens are present, got %q", acct.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictModeOverridesTokenInference(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "u-tok")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "t-tok")
|
||||
t.Setenv(envvars.CliStrictMode, "bot")
|
||||
acct, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !acct.SupportedIdentities.BotOnly() {
|
||||
t.Errorf("strict mode should override token inference, got %d", acct.SupportedIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliStrictMode, "invalid")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid strict mode")
|
||||
}
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "app")
|
||||
t.Setenv(envvars.CliAppSecret, "secret")
|
||||
t.Setenv(envvars.CliDefaultAs, "invalid")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid default-as")
|
||||
}
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
providers []Provider
|
||||
)
|
||||
|
||||
// Register registers a credential Provider.
|
||||
// Providers are consulted in priority order (lowest value first).
|
||||
// Providers that implement Priority() int are sorted accordingly;
|
||||
// those that do not default to priority 10.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
providers = append(providers, p)
|
||||
sort.SliceStable(providers, func(i, j int) bool {
|
||||
return providerPriority(providers[i]) < providerPriority(providers[j])
|
||||
})
|
||||
}
|
||||
|
||||
// providerPriority returns the priority of a provider.
|
||||
// If the provider implements interface{ Priority() int }, that value is used;
|
||||
// otherwise 10 is returned as the default priority.
|
||||
// Lower values are consulted first.
|
||||
func providerPriority(p Provider) int {
|
||||
if pp, ok := p.(interface{ Priority() int }); ok {
|
||||
return pp.Priority()
|
||||
}
|
||||
return 10
|
||||
}
|
||||
|
||||
// Providers returns all registered providers (snapshot).
|
||||
func Providers() []Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result := make([]Provider, len(providers))
|
||||
copy(result, providers)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubProvider struct{ name string }
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
func (s *stubProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
return &Account{AppID: s.name}, nil
|
||||
}
|
||||
func (s *stubProvider) ResolveToken(ctx context.Context, req TokenSpec) (*Token, error) {
|
||||
return &Token{Value: "tok-" + s.name, Source: s.name}, nil
|
||||
}
|
||||
|
||||
func TestRegisterAndProviders(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "a"})
|
||||
Register(&stubProvider{name: "b"})
|
||||
|
||||
got := Providers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
if got[0].Name() != "a" || got[1].Name() != "b" {
|
||||
t.Errorf("unexpected order: %s, %s", got[0].Name(), got[1].Name())
|
||||
}
|
||||
}
|
||||
|
||||
type priorityProvider struct {
|
||||
stubProvider
|
||||
priority int
|
||||
}
|
||||
|
||||
func (p *priorityProvider) Priority() int { return p.priority }
|
||||
|
||||
func TestRegister_PriorityOrder(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "env"}) // priority 10 (default)
|
||||
Register(&priorityProvider{stubProvider: stubProvider{name: "sidecar"}, priority: 0}) // priority 0 (first)
|
||||
|
||||
got := Providers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(got))
|
||||
}
|
||||
if got[0].Name() != "sidecar" || got[1].Name() != "env" {
|
||||
t.Errorf("expected sidecar before env, got %s, %s", got[0].Name(), got[1].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviders_ReturnsSnapshot(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := providers
|
||||
providers = nil
|
||||
mu.Unlock()
|
||||
defer func() { mu.Lock(); providers = old; mu.Unlock() }()
|
||||
|
||||
Register(&stubProvider{name: "x"})
|
||||
snap := Providers()
|
||||
Register(&stubProvider{name: "y"})
|
||||
|
||||
if len(snap) != 1 {
|
||||
t.Fatalf("snapshot should not be affected, got %d", len(snap))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a noop credential provider for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies
|
||||
// placeholder credentials so the CLI's auth pipeline can proceed normally.
|
||||
// Real tokens are never present in the sandbox; the sidecar transport
|
||||
// interceptor routes requests to the trusted sidecar process instead.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Provider is the noop credential provider for sidecar mode.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "sidecar" }
|
||||
func (p *Provider) Priority() int { return 0 }
|
||||
|
||||
// ResolveAccount returns a minimal Account when sidecar mode is active.
|
||||
// The account contains AppID and Brand from environment variables, a
|
||||
// placeholder secret, and SupportedIdentities derived from STRICT_MODE.
|
||||
// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set).
|
||||
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return nil, nil // not in sidecar mode, skip
|
||||
}
|
||||
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err),
|
||||
}
|
||||
}
|
||||
|
||||
appID := os.Getenv(envvars.CliAppID)
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing",
|
||||
}
|
||||
}
|
||||
|
||||
if os.Getenv(envvars.CliProxyKey) == "" {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing",
|
||||
}
|
||||
}
|
||||
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
|
||||
acct := &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: credential.NoAppSecret,
|
||||
Brand: brand,
|
||||
}
|
||||
|
||||
// Parse DefaultAs
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse SupportedIdentities from STRICT_MODE, default to SupportsAll.
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off", "":
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
}
|
||||
}
|
||||
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// ResolveToken returns a sentinel token whose value encodes the token type.
|
||||
// The transport interceptor reads this sentinel to determine the identity
|
||||
// (user vs bot), strips it, and the sidecar injects the real token.
|
||||
// Returns nil, nil when sidecar mode is not active.
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
if os.Getenv(envvars.CliAuthProxy) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var sentinel string
|
||||
switch req.Type {
|
||||
case credential.TokenTypeUAT:
|
||||
sentinel = sidecar.SentinelUAT
|
||||
case credential.TokenTypeTAT:
|
||||
sentinel = sidecar.SentinelTAT
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &credential.Token{
|
||||
Value: sentinel,
|
||||
Scopes: "", // empty → scope pre-check is skipped
|
||||
Source: "sidecar",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
credential.Register(&Provider{})
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
func setEnv(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
old, hadOld := os.LookupEnv(key)
|
||||
os.Setenv(key, value)
|
||||
t.Cleanup(func() {
|
||||
if hadOld {
|
||||
os.Setenv(key, old)
|
||||
} else {
|
||||
os.Unsetenv(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func unsetEnv(t *testing.T, key string) {
|
||||
t.Helper()
|
||||
old, hadOld := os.LookupEnv(key)
|
||||
os.Unsetenv(key)
|
||||
t.Cleanup(func() {
|
||||
if hadOld {
|
||||
os.Setenv(key, old)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestResolveAccount_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct != nil {
|
||||
t.Fatal("expected nil account when AUTH_PROXY not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_Active(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test123")
|
||||
setEnv(t, envvars.CliBrand, " LARK ")
|
||||
unsetEnv(t, envvars.CliDefaultAs)
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
|
||||
p := &Provider{}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct == nil {
|
||||
t.Fatal("expected non-nil account")
|
||||
}
|
||||
if acct.AppID != "cli_test123" {
|
||||
t.Errorf("AppID = %q, want %q", acct.AppID, "cli_test123")
|
||||
}
|
||||
if acct.Brand != credential.BrandLark {
|
||||
t.Errorf("Brand = %q, want %q", acct.Brand, credential.BrandLark)
|
||||
}
|
||||
if acct.AppSecret != credential.NoAppSecret {
|
||||
t.Errorf("AppSecret should be NoAppSecret, got %q", acct.AppSecret)
|
||||
}
|
||||
if acct.SupportedIdentities != credential.SupportsAll {
|
||||
t.Errorf("SupportedIdentities = %d, want %d (SupportsAll)", acct.SupportedIdentities, credential.SupportsAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_MissingProxyKey(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
unsetEnv(t, envvars.CliProxyKey)
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
p := &Provider{}
|
||||
_, err := p.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when PROXY_KEY is missing")
|
||||
}
|
||||
if _, ok := err.(*credential.BlockError); !ok {
|
||||
t.Fatalf("expected BlockError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_MissingAppID(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
unsetEnv(t, envvars.CliAppID)
|
||||
|
||||
p := &Provider{}
|
||||
_, err := p.ResolveAccount(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when APP_ID is missing")
|
||||
}
|
||||
if _, ok := err.(*credential.BlockError); !ok {
|
||||
t.Fatalf("expected BlockError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_StrictMode(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
tests := []struct {
|
||||
mode string
|
||||
want credential.IdentitySupport
|
||||
}{
|
||||
{"bot", credential.SupportsBot},
|
||||
{"user", credential.SupportsUser},
|
||||
{"off", credential.SupportsAll},
|
||||
{"", credential.SupportsAll},
|
||||
}
|
||||
|
||||
p := &Provider{}
|
||||
for _, tt := range tests {
|
||||
t.Run("strict_"+tt.mode, func(t *testing.T) {
|
||||
if tt.mode == "" {
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
} else {
|
||||
setEnv(t, envvars.CliStrictMode, tt.mode)
|
||||
}
|
||||
acct, err := p.ResolveAccount(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if acct.SupportedIdentities != tt.want {
|
||||
t.Errorf("SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
p := &Provider{}
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tok != nil {
|
||||
t.Fatal("expected nil token when AUTH_PROXY not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_Sentinels(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
|
||||
p := &Provider{}
|
||||
|
||||
// UAT
|
||||
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("UAT: unexpected error: %v", err)
|
||||
}
|
||||
if tok.Value != sidecar.SentinelUAT {
|
||||
t.Errorf("UAT value = %q, want %q", tok.Value, sidecar.SentinelUAT)
|
||||
}
|
||||
if tok.Scopes != "" {
|
||||
t.Errorf("UAT scopes should be empty, got %q", tok.Scopes)
|
||||
}
|
||||
|
||||
// TAT
|
||||
tok, err = p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
|
||||
if err != nil {
|
||||
t.Fatalf("TAT: unexpected error: %v", err)
|
||||
}
|
||||
if tok.Value != sidecar.SentinelTAT {
|
||||
t.Errorf("TAT value = %q, want %q", tok.Value, sidecar.SentinelTAT)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "context"
|
||||
|
||||
// Brand represents the Lark platform brand.
|
||||
type Brand string
|
||||
|
||||
const (
|
||||
BrandLark Brand = "lark"
|
||||
BrandFeishu Brand = "feishu"
|
||||
)
|
||||
|
||||
// NoAppSecret marks that a credential source does not provide a real app secret.
|
||||
// Token-only sources should return this value instead of inventing placeholder text.
|
||||
const NoAppSecret = ""
|
||||
|
||||
// Identity represents the caller identity type.
|
||||
type Identity string
|
||||
|
||||
const (
|
||||
IdentityUser Identity = "user"
|
||||
IdentityBot Identity = "bot"
|
||||
IdentityAuto Identity = "auto"
|
||||
)
|
||||
|
||||
// IdentitySupport declares which identities a credential source can provide.
|
||||
type IdentitySupport uint8
|
||||
|
||||
const (
|
||||
SupportsUser IdentitySupport = 1 << iota
|
||||
SupportsBot
|
||||
SupportsAll = SupportsUser | SupportsBot
|
||||
)
|
||||
|
||||
// Has reports whether s includes the given flag.
|
||||
func (s IdentitySupport) Has(flag IdentitySupport) bool { return s&flag != 0 }
|
||||
|
||||
// UserOnly returns true if only user identity is supported.
|
||||
func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
|
||||
|
||||
// BotOnly returns true if only bot identity is supported.
|
||||
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
|
||||
|
||||
// Account holds resolved app credentials and configuration.
|
||||
type Account struct {
|
||||
AppID string
|
||||
AppSecret string // real app secret; empty or NoAppSecret means unavailable
|
||||
Brand Brand // BrandLark or BrandFeishu
|
||||
DefaultAs Identity // IdentityUser / IdentityBot / IdentityAuto; empty = not set
|
||||
ProfileName string
|
||||
OpenID string // optional; if UAT is available, API result takes precedence
|
||||
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
|
||||
}
|
||||
|
||||
// Token holds a resolved access token and optional metadata.
|
||||
type Token struct {
|
||||
Value string
|
||||
Scopes string // space-separated; empty = skip scope pre-check
|
||||
Source string // e.g. "env:LARKSUITE_CLI_USER_ACCESS_TOKEN", "vault:addr"
|
||||
}
|
||||
|
||||
// TokenType represents the kind of access token.
|
||||
type TokenType string
|
||||
|
||||
const (
|
||||
TokenTypeUAT TokenType = "uat"
|
||||
TokenTypeTAT TokenType = "tat"
|
||||
)
|
||||
|
||||
// TokenSpec describes what token is needed.
|
||||
type TokenSpec struct {
|
||||
Type TokenType
|
||||
AppID string
|
||||
}
|
||||
|
||||
// BlockError is returned by a Provider to actively reject a request
|
||||
// and prevent subsequent providers in the chain from being consulted.
|
||||
type BlockError struct {
|
||||
Provider string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *BlockError) Error() string {
|
||||
return "blocked by " + e.Provider + ": " + e.Reason
|
||||
}
|
||||
|
||||
// Provider is the unified interface for credential resolution.
|
||||
//
|
||||
// Flow control uses Go's native mechanisms:
|
||||
// - Handle: return &Account{...}, nil or return &Token{...}, nil
|
||||
// - Skip: return nil, nil
|
||||
// - Block: return nil, &BlockError{...}
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveAccount(ctx context.Context) (*Account, error)
|
||||
ResolveToken(ctx context.Context, req TokenSpec) (*Token, error)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIdentitySupport_Has(t *testing.T) {
|
||||
if !SupportsAll.Has(SupportsUser) {
|
||||
t.Error("SupportsAll should have SupportsUser")
|
||||
}
|
||||
if !SupportsAll.Has(SupportsBot) {
|
||||
t.Error("SupportsAll should have SupportsBot")
|
||||
}
|
||||
if SupportsUser.Has(SupportsBot) {
|
||||
t.Error("SupportsUser should not have SupportsBot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitySupport_UserOnly(t *testing.T) {
|
||||
if !SupportsUser.UserOnly() {
|
||||
t.Error("SupportsUser.UserOnly() should be true")
|
||||
}
|
||||
if SupportsAll.UserOnly() {
|
||||
t.Error("SupportsAll.UserOnly() should be false")
|
||||
}
|
||||
if IdentitySupport(0).UserOnly() {
|
||||
t.Error("zero value UserOnly() should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitySupport_BotOnly(t *testing.T) {
|
||||
if !SupportsBot.BotOnly() {
|
||||
t.Error("SupportsBot.BotOnly() should be true")
|
||||
}
|
||||
if SupportsAll.BotOnly() {
|
||||
t.Error("SupportsAll.BotOnly() should be false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrPathValidation indicates the path failed security validation
|
||||
// (traversal, absolute, control chars, symlink escape, etc.).
|
||||
var ErrPathValidation = errors.New("path validation failed")
|
||||
|
||||
// PathValidationError wraps a path validation error.
|
||||
// errors.Is(err, ErrPathValidation) returns true.
|
||||
// errors.Is(err, <original OS error>) also works via the chain.
|
||||
type PathValidationError struct {
|
||||
Err error // original error
|
||||
}
|
||||
|
||||
func (e *PathValidationError) Error() string { return e.Err.Error() }
|
||||
func (e *PathValidationError) Unwrap() []error {
|
||||
return []error{ErrPathValidation, e.Err}
|
||||
}
|
||||
|
||||
// MkdirError indicates parent directory creation failed.
|
||||
// Use errors.As(err, &fileio.MkdirError{}) to match.
|
||||
type MkdirError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *MkdirError) Error() string { return e.Err.Error() }
|
||||
func (e *MkdirError) Unwrap() error { return e.Err }
|
||||
|
||||
// WriteError indicates file write failed.
|
||||
// Use errors.As(err, &fileio.WriteError{}) to match.
|
||||
type WriteError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *WriteError) Error() string { return e.Err.Error() }
|
||||
func (e *WriteError) Unwrap() error { return e.Err }
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register registers a FileIO Provider.
|
||||
// Later registrations override earlier ones (last-write-wins).
|
||||
// Unlike credential.Register which appends to a chain (multiple credential
|
||||
// sources are tried in order), FileIO uses a single active provider because
|
||||
// only one file I/O backend is active at a time (local vs server mode).
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// Provider creates FileIO instances.
|
||||
// Follows the same API style as extension/credential.Provider.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveFileIO(ctx context.Context) FileIO
|
||||
}
|
||||
|
||||
// FileIO abstracts file transfer operations for CLI commands.
|
||||
// The default implementation operates on the local filesystem with
|
||||
// path validation, directory creation, and atomic writes.
|
||||
// Inject a custom implementation via Factory.FileIOProvider to replace
|
||||
// file transfer behavior (e.g. streaming in server mode).
|
||||
type FileIO interface {
|
||||
// Open opens a file for reading (upload, attachment, template scenarios).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
Open(name string) (File, error)
|
||||
|
||||
// Stat returns file metadata (size validation, existence checks).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
// Use os.IsNotExist(err) to distinguish "file not found" from "invalid path".
|
||||
Stat(name string) (FileInfo, error)
|
||||
|
||||
// ResolvePath returns the validated, absolute path for the given output path.
|
||||
// The default implementation delegates to SafeOutputPath.
|
||||
// Use this to obtain the canonical saved path for user-facing output.
|
||||
ResolvePath(path string) (string, error)
|
||||
|
||||
// Save writes content to the target path and returns a SaveResult.
|
||||
// The default implementation validates via SafeOutputPath, creates
|
||||
// parent directories, and writes atomically.
|
||||
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
|
||||
}
|
||||
|
||||
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
|
||||
// os.FileInfo satisfies this interface.
|
||||
type FileInfo interface {
|
||||
Size() int64
|
||||
IsDir() bool
|
||||
Mode() fs.FileMode
|
||||
}
|
||||
|
||||
// File is the interface returned by FileIO.Open.
|
||||
// It covers the subset of *os.File methods actually used by CLI commands.
|
||||
// *os.File satisfies this interface without adaptation.
|
||||
type File interface {
|
||||
io.Reader
|
||||
io.ReaderAt
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// SaveResult holds the outcome of a Save operation.
|
||||
type SaveResult interface {
|
||||
Size() int64 // actual bytes written
|
||||
}
|
||||
|
||||
// SaveOptions carries metadata for Save.
|
||||
// The default (local) implementation ignores these fields;
|
||||
// server-mode implementations use them to construct streaming response frames.
|
||||
type SaveOptions struct {
|
||||
ContentType string // MIME type
|
||||
ContentLength int64 // content length; -1 if unknown
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
# 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, ≥1 per plugin | 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`.
|
||||
- A plugin may call `Restrict()` more than once; each call adds one
|
||||
scoped Rule and the engine combines them with **OR** — a command is
|
||||
allowed when it satisfies every axis (allow / deny / max_risk /
|
||||
identities) of at least one rule. Note a rule's `deny` is scoped to
|
||||
that rule only and cannot veto another rule's allow. Only ONE plugin
|
||||
per binary may contribute rules, though: two DISTINCT plugins each
|
||||
calling `Restrict()` is a deliberate `multiple_restrict_plugins` error
|
||||
(single-owner assumption — an independent plugin must not be able to
|
||||
widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which
|
||||
may itself list several rules under `rules:`) 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. With several rules this is per-rule: an unannotated command
|
||||
is allowed as long as one rule that opts in also grants it.
|
||||
- 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 |
|
||||
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | 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` |
|
||||
| `no_matching_rule` | Several rules are active and the command satisfied none of them (the message summarises each rule's own rejection). Single-rule policies keep their specific reason_code instead |
|
||||
| `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.
|
||||
@@ -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 a typed errs.* error 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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// 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)
|
||||
rules []*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). It may be called more than once; each call adds one scoped
|
||||
// Rule and the engine OR-combines them.
|
||||
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
|
||||
// Defensive clone: capture an independent snapshot so a caller that
|
||||
// reuses and mutates the same *Rule across multiple Restrict calls
|
||||
// gets distinct entries (mirrors the staging registrar's clone).
|
||||
cp := *rule
|
||||
cp.Allow = append([]string(nil), rule.Allow...)
|
||||
cp.Deny = append([]string(nil), rule.Deny...)
|
||||
cp.Identities = append([]Identity(nil), rule.Identities...)
|
||||
b.rules = append(b.rules, &cp)
|
||||
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 len(b.rules) > 0 && 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,
|
||||
rules: b.rules,
|
||||
}, 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)
|
||||
rules []*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 {
|
||||
for _, rule := range p.rules {
|
||||
r.Restrict(rule)
|
||||
}
|
||||
for _, action := range p.actions {
|
||||
action(r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// 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 // last rule (existing single-rule assertions)
|
||||
rules []*platform.Rule // every rule, in Restrict order
|
||||
}
|
||||
|
||||
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
|
||||
r.rules = append(r.rules, rule)
|
||||
}
|
||||
|
||||
// Restrict must snapshot each rule: a caller that reuses and mutates the
|
||||
// same *Rule object across two Restrict calls must still get two distinct
|
||||
// rules at Install time, not two pointers to the last mutation.
|
||||
func TestBuilder_restrictClonesEachRule(t *testing.T) {
|
||||
shared := &platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: platform.RiskRead}
|
||||
b := platform.NewPlugin("p", "0").Restrict(shared)
|
||||
// Reuse and mutate the same object, then register it again.
|
||||
shared.Name = "im-rw"
|
||||
shared.Allow[0] = "im/**"
|
||||
shared.MaxRisk = platform.RiskWrite
|
||||
p, err := b.Restrict(shared).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if len(r.rules) != 2 {
|
||||
t.Fatalf("got %d rules, want 2", len(r.rules))
|
||||
}
|
||||
if r.rules[0].Name != "docs-ro" || r.rules[0].Allow[0] != "docs/**" || r.rules[0].MaxRisk != platform.RiskRead {
|
||||
t.Errorf("rule[0] leaked later mutation: %+v", r.rules[0])
|
||||
}
|
||||
if r.rules[1].Name != "im-rw" || r.rules[1].Allow[0] != "im/**" {
|
||||
t.Errorf("rule[1] = %+v, want im-rw / im/**", r.rules[1])
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 / DenialLayer / DenialPolicySource)
|
||||
// - 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
|
||||
@@ -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. The dispatcher converts it to a
|
||||
// typed errs.* error; the Layer field carries the denial layer for the
|
||||
// envelope.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
audit-observer/audit-observer
|
||||
readonly-policy/readonly-policy
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# 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",
|
||||
# "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.
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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) }
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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 plugin code cannot
|
||||
// mutate denial state.
|
||||
//
|
||||
// The interface 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
|
||||
type Invocation interface {
|
||||
// Cmd returns the read-only metadata view of the dispatched command.
|
||||
Cmd() CommandView
|
||||
|
||||
// Args returns a fresh copy of the positional args.
|
||||
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)
|
||||
DenialLayer() string
|
||||
|
||||
// DenialPolicySource returns the specific source identifier
|
||||
// ("plugin:secaudit", "yaml", "strict-mode"). Empty when not denied.
|
||||
DenialPolicySource() string
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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 multiple times per plugin; each call adds one
|
||||
// scoped Rule (OR-combined by the engine). Two or more DISTINCT 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. May be called more than once
|
||||
// to declare several scoped grants (OR-combined by the engine).
|
||||
// Plugin rules take precedence over the yaml source; two distinct
|
||||
// plugins both calling Restrict abort startup.
|
||||
Restrict(r *Rule)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// 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.
|
||||
//
|
||||
// View semantics:
|
||||
//
|
||||
// - The view is a live proxy over the underlying *cobra.Command and its
|
||||
// annotation chain. Strict-mode replaces nodes via RemoveCommand+
|
||||
// AddCommand; the replacement stub explicitly carries the original
|
||||
// command's annotations and help text forward so audit / compliance
|
||||
// observers still see Risk / Identities / Domain after a denial.
|
||||
// User-layer policy mutates in place, so its denyStubs preserve the
|
||||
// original metadata by construction.
|
||||
//
|
||||
// - 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)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrAborted is a sentinel matched by errors.Is on any extension-triggered
|
||||
// round-trip abort. Callers that only need to know whether an error was
|
||||
// caused by an extension interception should use:
|
||||
//
|
||||
// if errors.Is(err, transport.ErrAborted) { ... }
|
||||
var ErrAborted = errors.New("round trip aborted by extension")
|
||||
|
||||
// AbortError is returned by the built-in middleware when an AbortableInterceptor
|
||||
// short-circuits a request via PreRoundTripE. It wraps the extension's original
|
||||
// reason and carries the extension's Provider.Name() for traceability.
|
||||
//
|
||||
// Use errors.As to recover the typed error:
|
||||
//
|
||||
// var aErr *transport.AbortError
|
||||
// if errors.As(err, &aErr) {
|
||||
// log.Printf("blocked by %s: %v", aErr.Extension, aErr.Reason)
|
||||
// }
|
||||
//
|
||||
// errors.Is(err, transport.ErrAborted) also works, and errors.Is against the
|
||||
// inner reason still works via Unwrap.
|
||||
type AbortError struct {
|
||||
// Extension is the name of the Provider whose interceptor aborted the
|
||||
// request (from Provider.Name()). May be empty if the provider did not
|
||||
// supply a name.
|
||||
Extension string
|
||||
// Reason is the original non-nil error returned by PreRoundTripE.
|
||||
Reason error
|
||||
}
|
||||
|
||||
func (e *AbortError) Error() string {
|
||||
if e.Extension != "" {
|
||||
return fmt.Sprintf("extension %q aborted round trip: %v", e.Extension, e.Reason)
|
||||
}
|
||||
return fmt.Sprintf("extension aborted round trip: %v", e.Reason)
|
||||
}
|
||||
|
||||
// Unwrap lets errors.Is / errors.As traverse to the underlying Reason.
|
||||
func (e *AbortError) Unwrap() error { return e.Reason }
|
||||
|
||||
// Is enables errors.Is(err, ErrAborted) at any nesting depth.
|
||||
func (e *AbortError) Is(target error) bool { return target == ErrAborted }
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAbortError_Error(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *AbortError
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "with extension name",
|
||||
err: &AbortError{Extension: "audit", Reason: errors.New("bad")},
|
||||
want: `extension "audit" aborted round trip: bad`,
|
||||
},
|
||||
{
|
||||
name: "without extension name",
|
||||
err: &AbortError{Reason: errors.New("bad")},
|
||||
want: "extension aborted round trip: bad",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.err.Error(); got != tt.want {
|
||||
t.Fatalf("Error() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_Unwrap(t *testing.T) {
|
||||
reason := errors.New("bad")
|
||||
e := &AbortError{Reason: reason}
|
||||
if got := e.Unwrap(); got != reason {
|
||||
t.Fatalf("Unwrap() = %v, want %v", got, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_IsErrAborted(t *testing.T) {
|
||||
e := &AbortError{Reason: errors.New("bad")}
|
||||
if !errors.Is(e, ErrAborted) {
|
||||
t.Fatal("errors.Is(e, ErrAborted) = false, want true")
|
||||
}
|
||||
// Sanity: not matched by unrelated sentinels.
|
||||
if errors.Is(e, errors.New("other")) {
|
||||
t.Fatal("errors.Is matched unrelated sentinel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_UnwrapReachesInnerSentinel(t *testing.T) {
|
||||
// Extensions often return typed/sentinel errors; callers should still be
|
||||
// able to errors.Is against those after the middleware wraps them.
|
||||
innerSentinel := errors.New("policy-deny-42")
|
||||
e := &AbortError{Reason: fmt.Errorf("wrapped: %w", innerSentinel)}
|
||||
if !errors.Is(e, innerSentinel) {
|
||||
t.Fatal("errors.Is(e, innerSentinel) = false, want true (Unwrap chain broken)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbortError_As(t *testing.T) {
|
||||
reason := errors.New("bad")
|
||||
base := &AbortError{Extension: "audit", Reason: reason}
|
||||
|
||||
// Direct As.
|
||||
var aErr *AbortError
|
||||
if !errors.As(base, &aErr) {
|
||||
t.Fatal("errors.As(base, *AbortError) = false")
|
||||
}
|
||||
if aErr.Extension != "audit" || aErr.Reason != reason {
|
||||
t.Fatalf("aErr = %+v, want {audit, bad}", aErr)
|
||||
}
|
||||
|
||||
// Nested As: even when the *AbortError is wrapped in another error,
|
||||
// errors.As must still find it via Unwrap chain.
|
||||
wrapped := fmt.Errorf("outer: %w", base)
|
||||
var aErr2 *AbortError
|
||||
if !errors.As(wrapped, &aErr2) {
|
||||
t.Fatal("errors.As(wrapped, *AbortError) = false")
|
||||
}
|
||||
if aErr2 != base {
|
||||
t.Fatalf("aErr2 = %p, want %p", aErr2, base)
|
||||
}
|
||||
|
||||
// errors.Is still matches the sentinel through the outer wrapper.
|
||||
if !errors.Is(wrapped, ErrAborted) {
|
||||
t.Fatal("errors.Is(wrapped, ErrAborted) = false via nested wrap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrAborted_IsItselfSentinel(t *testing.T) {
|
||||
// Guard against accidental re-assignment of ErrAborted: a bare ErrAborted
|
||||
// value should still satisfy errors.Is(err, ErrAborted) for symmetry.
|
||||
if !errors.Is(ErrAborted, ErrAborted) {
|
||||
t.Fatal("errors.Is(ErrAborted, ErrAborted) = false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register registers a transport Provider.
|
||||
// Later registrations override earlier ones.
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubInterceptor struct{}
|
||||
|
||||
func (s *stubInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubProvider struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *stubProvider) Name() string { return s.name }
|
||||
func (s *stubProvider) ResolveInterceptor(context.Context) Interceptor { return &stubInterceptor{} }
|
||||
|
||||
func TestGetProvider_NilByDefault(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
if got := GetProvider(); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAndGet(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
p := &stubProvider{name: "a"}
|
||||
Register(p)
|
||||
|
||||
got := GetProvider()
|
||||
if got != p {
|
||||
t.Fatalf("expected registered provider, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastRegistrationWins(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
a := &stubProvider{name: "a"}
|
||||
b := &stubProvider{name: "b"}
|
||||
Register(a)
|
||||
Register(b)
|
||||
|
||||
got := GetProvider()
|
||||
if got != b {
|
||||
t.Fatalf("expected provider b, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInterceptor_ReturnsNonNil(t *testing.T) {
|
||||
mu.Lock()
|
||||
provider = nil
|
||||
mu.Unlock()
|
||||
|
||||
p := &stubProvider{name: "test"}
|
||||
Register(p)
|
||||
|
||||
ic := GetProvider().ResolveInterceptor(context.Background())
|
||||
if ic == nil {
|
||||
t.Fatal("expected non-nil Interceptor")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
||||
// outgoing requests are rewritten to the sidecar address. The interceptor
|
||||
// strips placeholder credentials, injects proxy headers, and signs each
|
||||
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
||||
// standard http.Transport connects to the sidecar via plain HTTP.
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// Provider implements transport.Provider for the sidecar mode.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "sidecar" }
|
||||
|
||||
// ResolveInterceptor returns a SidecarInterceptor when sidecar mode is active.
|
||||
// Returns nil when sidecar mode is disabled or the proxy address is invalid;
|
||||
// in the latter case a warning is emitted to stderr and requests fall back to
|
||||
// the non-sidecar transport path (where the credential layer will typically
|
||||
// block them for lack of a valid account).
|
||||
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return nil
|
||||
}
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err)
|
||||
return nil
|
||||
}
|
||||
key := os.Getenv(envvars.CliProxyKey)
|
||||
return &Interceptor{
|
||||
key: []byte(key),
|
||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// Interceptor rewrites requests for the sidecar proxy.
|
||||
type Interceptor struct {
|
||||
key []byte // HMAC signing key
|
||||
sidecarHost string // sidecar host:port for URL rewriting
|
||||
}
|
||||
|
||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||
// sentinel token. Requests without a sentinel token (e.g. pre-signed download
|
||||
// URLs) are passed through unmodified.
|
||||
//
|
||||
// Supports two auth patterns:
|
||||
// - Standard OpenAPI: Authorization: Bearer <sentinel>
|
||||
// - MCP protocol: X-Lark-MCP-UAT/TAT: <sentinel>
|
||||
func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, err error) {
|
||||
identity, authHeader := detectSentinel(req)
|
||||
if identity == "" {
|
||||
return nil // not a sidecar-managed request, pass through
|
||||
}
|
||||
|
||||
// 1. Buffer the body first, before mutating any request state. A partial
|
||||
// read would sign a truncated body and cause a misleading HMAC mismatch
|
||||
// on the sidecar side; bail out early and let the request fall through
|
||||
// unmodified so the credential layer can surface an actionable error.
|
||||
var bodyBytes []byte
|
||||
if req.Body != nil {
|
||||
var err error
|
||||
bodyBytes, err = io.ReadAll(req.Body)
|
||||
_ = req.Body.Close() // release original body (fd/pipe/etc.) after buffering
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: sidecar interceptor failed to read request body: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
if req.GetBody != nil {
|
||||
req.GetBody = func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Save original target (scheme://host)
|
||||
originalScheme := "https"
|
||||
if req.URL.Scheme != "" {
|
||||
originalScheme = req.URL.Scheme
|
||||
}
|
||||
originalHost := req.URL.Host
|
||||
req.Header.Set(sidecar.HeaderProxyTarget, originalScheme+"://"+originalHost)
|
||||
|
||||
// 3. Set identity and tell sidecar which header to inject real token into
|
||||
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
|
||||
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
|
||||
|
||||
// 4. Strip placeholder auth header(s)
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del(sidecar.HeaderMCPUAT)
|
||||
req.Header.Del(sidecar.HeaderMCPTAT)
|
||||
|
||||
bodySHA := sidecar.BodySHA256(bodyBytes)
|
||||
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
|
||||
|
||||
pathAndQuery := req.URL.RequestURI()
|
||||
ts := sidecar.Timestamp()
|
||||
// Cover identity and authHeader in the signature so an on-path attacker
|
||||
// within the replay window cannot flip the injected token's identity or
|
||||
// redirect the token into a different header.
|
||||
sig := sidecar.Sign(i.key, sidecar.CanonicalRequest{
|
||||
Version: sidecar.ProtocolV1,
|
||||
Method: req.Method,
|
||||
Host: originalHost,
|
||||
PathAndQuery: pathAndQuery,
|
||||
BodySHA256: bodySHA,
|
||||
Timestamp: ts,
|
||||
Identity: identity,
|
||||
AuthHeader: authHeader,
|
||||
})
|
||||
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||
|
||||
// 5. Rewrite URL to route through sidecar
|
||||
req.URL.Scheme = "http"
|
||||
req.URL.Host = i.sidecarHost
|
||||
|
||||
return nil // no post-hook needed
|
||||
}
|
||||
|
||||
// detectSentinel checks both standard Authorization and MCP auth headers for
|
||||
// sentinel tokens. Returns the identity ("user"/"bot") and the header name
|
||||
// that carried the sentinel.
|
||||
//
|
||||
// Returns ("", "") when the request carries no sentinel token — typically
|
||||
// requests that require no auth (e.g. pre-signed download URLs where the
|
||||
// token is embedded in the URL query parameters).
|
||||
func detectSentinel(req *http.Request) (identity, authHeader string) {
|
||||
// Check standard Authorization: Bearer <sentinel>
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
switch token {
|
||||
case sidecar.SentinelUAT:
|
||||
return sidecar.IdentityUser, "Authorization"
|
||||
case sidecar.SentinelTAT:
|
||||
return sidecar.IdentityBot, "Authorization"
|
||||
}
|
||||
}
|
||||
// Check MCP headers: X-Lark-MCP-UAT/TAT: <sentinel>
|
||||
if v := req.Header.Get(sidecar.HeaderMCPUAT); v == sidecar.SentinelUAT {
|
||||
return sidecar.IdentityUser, sidecar.HeaderMCPUAT
|
||||
}
|
||||
if v := req.Header.Get(sidecar.HeaderMCPTAT); v == sidecar.SentinelTAT {
|
||||
return sidecar.IdentityBot, sidecar.HeaderMCPTAT
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
||||
if proxyAddr == "" {
|
||||
return
|
||||
}
|
||||
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err)
|
||||
return
|
||||
}
|
||||
transport.Register(&Provider{})
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package sidecar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
|
||||
type failingBody struct {
|
||||
err error
|
||||
closed bool
|
||||
readCall bool
|
||||
}
|
||||
|
||||
func (b *failingBody) Read(p []byte) (int, error) {
|
||||
b.readCall = true
|
||||
return 0, b.err
|
||||
}
|
||||
|
||||
func (b *failingBody) Close() error {
|
||||
b.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInterceptor_PreRoundTrip(t *testing.T) {
|
||||
key := []byte("test-key-for-hmac-signing-32byte!")
|
||||
interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
body := []byte(`{"msg":"hello"}`)
|
||||
req, _ := http.NewRequest("POST", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", io.NopCloser(bytes.NewReader(body)))
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
req.Header.Set("X-Cli-Source", "lark-cli")
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook")
|
||||
}
|
||||
|
||||
// URL should be rewritten to sidecar
|
||||
if req.URL.Scheme != "http" {
|
||||
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "http")
|
||||
}
|
||||
if req.URL.Host != "127.0.0.1:16384" {
|
||||
t.Errorf("host = %q, want %q", req.URL.Host, "127.0.0.1:16384")
|
||||
}
|
||||
|
||||
// Original target should be preserved
|
||||
target := req.Header.Get(sidecar.HeaderProxyTarget)
|
||||
if target != "https://open.feishu.cn" {
|
||||
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
||||
}
|
||||
|
||||
// Identity should be user (from SentinelUAT)
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
||||
}
|
||||
|
||||
// Authorization should be stripped
|
||||
if auth := req.Header.Get("Authorization"); auth != "" {
|
||||
t.Errorf("Authorization header should be stripped, got %q", auth)
|
||||
}
|
||||
|
||||
// HMAC headers should be set
|
||||
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
||||
t.Error("signature header should be set")
|
||||
}
|
||||
if ts := req.Header.Get(sidecar.HeaderProxyTimestamp); ts == "" {
|
||||
t.Error("timestamp header should be set")
|
||||
}
|
||||
if sha := req.Header.Get(sidecar.HeaderBodySHA256); sha == "" {
|
||||
t.Error("body SHA256 header should be set")
|
||||
}
|
||||
if v := req.Header.Get(sidecar.HeaderProxyVersion); v != sidecar.ProtocolV1 {
|
||||
t.Errorf("version header = %q, want %q", v, sidecar.ProtocolV1)
|
||||
}
|
||||
|
||||
// Non-proxy headers should be preserved
|
||||
if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" {
|
||||
t.Errorf("X-Cli-Source should be preserved, got %q", src)
|
||||
}
|
||||
|
||||
// Body should still be readable
|
||||
readBody, _ := io.ReadAll(req.Body)
|
||||
if !bytes.Equal(readBody, body) {
|
||||
t.Errorf("body should be preserved after PreRoundTrip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/calendar/v4/events", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_NonSentinelToken_PassThrough(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
origURL := "https://some-cdn.example.com/presigned-download?token=abc"
|
||||
req, _ := http.NewRequest("GET", origURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer some-real-token")
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
// Should NOT be rewritten — no sentinel token
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook for pass-through")
|
||||
}
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged, got %q", req.URL.String())
|
||||
}
|
||||
if req.Header.Get(sidecar.HeaderProxyTarget) != "" {
|
||||
t.Error("proxy target header should not be set for pass-through")
|
||||
}
|
||||
if req.Header.Get("Authorization") != "Bearer some-real-token" {
|
||||
t.Error("Authorization should be preserved for pass-through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_NoAuth_PassThrough(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
origURL := "https://cdn.feishu.cn/download/file"
|
||||
req, _ := http.NewRequest("GET", origURL, nil)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
// No Authorization header at all — should pass through
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged for no-auth request, got %q", req.URL.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_MCP_UAT(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{"jsonrpc":"2.0"}`)))
|
||||
req.Header.Set(sidecar.HeaderMCPUAT, sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
// Should be intercepted and rewritten
|
||||
if req.URL.Host != "127.0.0.1:16384" {
|
||||
t.Errorf("host = %q, want sidecar host", req.URL.Host)
|
||||
}
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser)
|
||||
}
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPUAT {
|
||||
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPUAT)
|
||||
}
|
||||
// MCP sentinel should be stripped
|
||||
if v := req.Header.Get(sidecar.HeaderMCPUAT); v != "" {
|
||||
t.Errorf("MCP-UAT should be stripped, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_MCP_TAT(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{}`)))
|
||||
req.Header.Set(sidecar.HeaderMCPTAT, sidecar.SentinelTAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot {
|
||||
t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot)
|
||||
}
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPTAT {
|
||||
t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPTAT)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_StandardAuth_SetsAuthorizationHeader(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != "Authorization" {
|
||||
t.Errorf("auth header = %q, want %q", ah, "Authorization")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterceptor_BodyReadError verifies that when io.ReadAll on the request
|
||||
// body fails partway, PreRoundTrip skips the rewrite entirely rather than
|
||||
// signing a truncated body (which would produce a misleading HMAC mismatch on
|
||||
// the sidecar side) and releases the original body.
|
||||
func TestInterceptor_BodyReadError(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
const origURL = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
body := &failingBody{err: errors.New("disk gremlin")}
|
||||
|
||||
req, _ := http.NewRequest("POST", origURL, body)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
|
||||
post := interceptor.PreRoundTrip(req)
|
||||
|
||||
if post != nil {
|
||||
t.Error("expected nil post hook on body read failure")
|
||||
}
|
||||
|
||||
// Original body must be closed to avoid leaking fd/pipe-like resources.
|
||||
if !body.readCall {
|
||||
t.Error("expected ReadAll to have attempted reading from the body")
|
||||
}
|
||||
if !body.closed {
|
||||
t.Error("expected original body to be Close()'d after read failure")
|
||||
}
|
||||
|
||||
// URL must NOT be rewritten — request should fall through to the next
|
||||
// layer (credential) which can surface a meaningful error.
|
||||
if req.URL.String() != origURL {
|
||||
t.Errorf("URL should be unchanged on read failure, got %q", req.URL.String())
|
||||
}
|
||||
|
||||
// No proxy/HMAC headers should leak onto the request.
|
||||
for _, h := range []string{
|
||||
sidecar.HeaderProxyVersion,
|
||||
sidecar.HeaderProxyTarget,
|
||||
sidecar.HeaderProxySignature,
|
||||
sidecar.HeaderProxyTimestamp,
|
||||
sidecar.HeaderBodySHA256,
|
||||
sidecar.HeaderProxyIdentity,
|
||||
sidecar.HeaderProxyAuthHeader,
|
||||
} {
|
||||
if v := req.Header.Get(h); v != "" {
|
||||
t.Errorf("%s should not be set on read failure, got %q", h, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptor_EmptyBody(t *testing.T) {
|
||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||
|
||||
req, _ := http.NewRequest("GET", "https://open.feishu.cn/path", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT)
|
||||
interceptor.PreRoundTrip(req)
|
||||
|
||||
sha := req.Header.Get(sidecar.HeaderBodySHA256)
|
||||
expectedEmpty := sidecar.BodySHA256(nil)
|
||||
if sha != expectedEmpty {
|
||||
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Provider creates Interceptor instances.
|
||||
// Follows the same API style as extension/credential.Provider and extension/fileio.Provider.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveInterceptor(ctx context.Context) Interceptor
|
||||
}
|
||||
|
||||
// Interceptor defines network-layer customization via a pre/post hook pair.
|
||||
// The built-in transport chain always executes between PreRoundTrip and the
|
||||
// returned post function, and cannot be skipped or overridden by the extension.
|
||||
//
|
||||
// PreRoundTrip is called before the built-in chain. Use it to add custom
|
||||
// headers, rewrite the host, or start trace spans. Built-in decorators run
|
||||
// after this and will override any same-named security headers set here.
|
||||
// The extension must not replace req.Context() — the middleware restores
|
||||
// the original context after PreRoundTrip returns.
|
||||
//
|
||||
// The returned function (if non-nil) is called after the built-in chain
|
||||
// completes. Use it for logging, ending trace spans, or recording metrics.
|
||||
//
|
||||
// Body note: the middleware Clones the caller's request before invoking the
|
||||
// interceptor, which copies headers/URL/etc. but shares the underlying
|
||||
// io.ReadCloser. Extensions that read req.Body are responsible for restoring
|
||||
// a replayable body (e.g. via req.GetBody) before returning, otherwise the
|
||||
// built-in chain will see an exhausted stream.
|
||||
type Interceptor interface {
|
||||
PreRoundTrip(req *http.Request) func(resp *http.Response, err error)
|
||||
}
|
||||
|
||||
// AbortableInterceptor is an optional extension of Interceptor that lets an
|
||||
// extension reject a request before the built-in chain runs. Extensions that
|
||||
// implement this interface are detected by the built-in middleware via a
|
||||
// type assertion; both methods must be present, but when an extension
|
||||
// implements PreRoundTripE the middleware will NOT call PreRoundTrip.
|
||||
//
|
||||
// Returning a non-nil error from PreRoundTripE aborts the request: the
|
||||
// built-in chain is not executed and the middleware returns an *AbortError
|
||||
// wrapping the reason. The returned post function (if non-nil) is still
|
||||
// invoked with (nil, reason) so that extensions can unwind any state they
|
||||
// created in the pre hook (spans, metrics, audit records).
|
||||
//
|
||||
// Extensions that only care about the abortable variant can provide a no-op
|
||||
// PreRoundTrip method alongside PreRoundTripE to satisfy Interceptor.
|
||||
type AbortableInterceptor interface {
|
||||
Interceptor
|
||||
PreRoundTripE(req *http.Request) (post func(resp *http.Response, err error), err error)
|
||||
}
|
||||
在新工单中引用