Compare commits

...

9 Commits

Author SHA1 Message Date
AlbertSun
054ff9339b feat(sec): integrate enterprise cli 2026-05-22 16:43:24 +08:00
AlbertSun
bdb0cd14d1 feat(sec): fetch lark-sec-cli install manifest via OAPI
Replace the embedded bootstrap manifest with a typed OAPI call to
GET /open-apis/security_plugin/v1/sec_cli/manifest, resolving the
download URL per-platform/per-arch against the live release set.
TAT auth flows through the existing credential chain; an x-tt-env
header is injected when LARKSUITE_CLI_X_TT_ENV is set, for BOE
routing.

Drop the standalone `sec install` verb — `sec run --auto-install`
(default on) makes it redundant. Add a persistent --verbose / -v
flag on the sec parent, inherited by every subcommand, that emits
step-by-step trace output on stderr.

bootstrap.json and bootstrap.go remain in-tree as dead code; they
will be removed in a follow-up cleanup.
2026-05-20 20:29:24 +08:00
AlbertSun
6c41d12792 feat(sec): add lark-sec-cli bootstrap install lifecycle
Scaffold the lark-cli sec subsystem: the `sec` command tree
(install, run, stop, status, config init) and the internal/sec
package that drives it.

The bootstrap manifest is embedded at build time as JSON, mapping
(platform, arch, region) to download URLs. The installer resolves
the right artifact for the current host, downloads with optional
SHA256 verification, extracts into versions/<version>/, swaps the
`current` symlink atomically (copy on Windows), and writes
state.json.

`sec run` enables the binary as a user-level system service
(launchd / systemd-user / registry+VBS) so the OS supervises
restarts. After this first install, lark-sec-cli takes over its
own upgrade lifecycle.
2026-05-20 20:29:24 +08:00
zhaojunchang
2286937366 feat(secplugin): add security plugin for proxy and auth token handling 2026-05-20 11:52:31 +08:00
河伯
d793790807 feat(doc): warn before overwrite when document contains whiteboard or file blocks (#825)
* feat(doc): warn before overwrite when document contains whiteboard or file blocks

Before executing an overwrite in v1 mode, pre-fetch the current document
and scan the Markdown for <whiteboard> and <file> resource blocks. If any
are found, print a warning to stderr listing the counts and suggesting the
user take a backup with `docs +fetch` first.

Overwrite replaces the entire document and cannot reconstruct these blocks
from Markdown; previously the data was lost with no indication to the caller.
The check is best-effort: a failed pre-fetch silently skips the guard rather
than blocking the overwrite.

* test(doc): add validateSelectionByTitleV1 tests and drop redundant empty-md guard in warnOverwriteResourceBlocks

* fix(doc): use regex for resource block detection, add latency/coverage comments, document skip_task_detail purpose
2026-05-20 11:28:57 +08:00
liangshuo-1
13411d9a51 chore(release): v1.0.34 (#972)
Change-Id: I0908c20f6ab9cf76a5d75cc1c81871591aa6a841
2026-05-19 20:03:56 +08:00
search_zhuhao
939b7b6fb6 docs(lark-vc): clarify meeting search evidence flow (#866)
* docs(lark-vc): clarify meeting search evidence flow

Change-Id: I997ec0654b9448eb0cc6ed7c15493dd2316ffa39

* docs(lark-vc): clarify pagination precedence

Change-Id: Icdcc38db2ce3db3a3371c6451624fd52a71170e3
2026-05-19 19:41:12 +08:00
SunPeiYang996
a4c5ec99c8 docs(drive): clarify add comment constraints (#967)
Change-Id: I637cfaf2d6a228c43e3b3041fef8e030bc80b9d0
2026-05-19 18:09:28 +08:00
fangshuyu-768
7c54f9b023 feat(drive): switch markdown export to V2 docs_ai fetch API (#948)
Switch `drive +export --file-extension markdown` from the legacy V1
GET /open-apis/docs/v1/content API to the V2
POST /open-apis/docs_ai/v1/documents/{token}/fetch API for
higher-quality Lark-flavored Markdown output.

- Update DryRun and Execute paths to use V2 endpoint with JSON body
- Add docx:document:readonly scope for the new API
- Validate V2 response structure (fail fast on missing document/content)
- Encode token in URL path via validate.EncodePathSegment
- Update unit tests and add V2 response validation error path tests
- Add E2E dry-run test for markdown export path
- Update skill documentation
2026-05-19 17:53:54 +08:00
46 changed files with 4715 additions and 50 deletions

View File

@@ -2,6 +2,35 @@
All notable changes to this project will be documented in this file.
## [v1.0.34] - 2026-05-19
### Features
- **drive**: Switch markdown export to V2 `docs_ai` fetch API (#948)
- **drive**: Add `+inspect` shortcut for document URL inspection with wiki unwrapping (#947)
- **wiki**: Add `+node-get` / `+node-delete` / `+space-create` shortcuts (#904)
- **base**: Support Base attachment APIs (#887)
- **mail**: Validate `bot` + `mailbox=me` and add dynamic `--as` help tests (#895)
- **mail**: Expose draft priority in `--inspect` projection and document `--set-priority` (#779)
### Bug Fixes
- **identitydiag**: Harden verify path and tighten status semantics (#961)
- **wiki**: Surface real node URL for `+node-create` / `+node-copy` (#960)
- **auth**: Split bot and user identity diagnostics (#957)
- **base**: Address Base attachment review follow-ups (#958)
- **docs**: Clarify `replace_all` selection errors (#954)
### Documentation
- **drive**: Clarify add comment constraints (#967)
- **lark-im**: Clarify message activity search (#865)
### Tests
- Verify e2e resource cleanup (#949)
- **lint**: Exclude `bidichk` from test files (#959)
## [v1.0.33] - 2026-05-18
### Features
@@ -745,6 +774,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31

View File

@@ -15,6 +15,7 @@ import (
cmdevent "github.com/larksuite/cli/cmd/event"
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/sec"
"github.com/larksuite/cli/cmd/service"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
@@ -133,6 +134,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(sec.NewCmdSec(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)

251
cmd/sec/config_init.go Normal file
View File

@@ -0,0 +1,251 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// NewCmdSecConfig is the parent for `lark-cli sec config <verb>`. Currently
// it only carries `init`; future verbs (e.g. `show`, `reset`) plug in here.
func NewCmdSecConfig(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage lark-sec-cli daemon configuration",
}
cmd.AddCommand(NewCmdSecConfigInit(f, nil))
return cmd
}
// ConfigInitOptions holds inputs for `lark-cli sec config init`.
type ConfigInitOptions struct {
Factory *cmdutil.Factory
AppID string
AppSecret string
Brand string
Yes bool // skip the interactive form when all required values are provided
}
// NewCmdSecConfigInit collects App ID / App Secret / Brand from the user and
// registers them with the running lark-sec-cli daemon's admin endpoint. The
// daemon stashes the secret in the OS keychain and switches into sidecar mode
// for SEC_AUTH credential isolation.
func NewCmdSecConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f}
cmd := &cobra.Command{
Use: "init",
Short: "Register a Lark App with the running lark-sec-cli daemon",
Long: `Register an App ID / App Secret with the lark-sec-cli daemon.
The daemon must already be running (start it with "lark-cli sec run"). The
registration POSTs to /_sec/api/v1/register-app on the local proxy port,
HMAC-signed with the daemon's proxy.key.`,
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runConfigInit(cmd, opts)
},
}
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (skips the prompt when set)")
cmd.Flags().StringVar(&opts.AppSecret, "app-secret", "", "App Secret (skips the prompt when set)")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark")
cmd.Flags().BoolVarP(&opts.Yes, "yes", "y", false, "skip the interactive form when all required values are provided")
return cmd
}
// secBridge mirrors what the daemon writes to ~/.lark-cli/sec_config.json.
// It's the single contract between lark-cli and lark-sec-cli at runtime —
// we don't reach into lark-sec-cli internals, only what it chooses to publish.
type secBridge struct {
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
CA string `json:"LARKSUITE_CLI_SEC_CA"`
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
}
func runConfigInit(cmd *cobra.Command, opts *ConfigInitOptions) error {
errOut := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, errOut)
tracef(trace, "sec config init", "loading daemon bridge from %s/sec_config.json", core.GetConfigDir())
bridge, err := loadBridge()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "sec_bridge_missing",
fmt.Sprintf("daemon bridge file unreadable: %v", err),
"Start the daemon first: `lark-cli sec run`.")
}
tracef(trace, "sec config init", "bridge: enable=%t proxy=%s ca=%s auth=%t", bridge.Enable, bridge.Proxy, bridge.CA, bridge.Auth)
if !bridge.Enable || bridge.Proxy == "" {
return output.ErrWithHint(output.ExitValidation, "sec_not_running",
"lark-sec-cli is not advertising an active proxy",
"Run `lark-cli sec run` to start it.")
}
// The HMAC key sits next to the CA in the daemon's config dir. Deriving
// from the bridge's SEC_CA path keeps lark-cli decoupled from the daemon's
// install location — if the daemon ever moves, the bridge follows and we
// follow with it.
tracef(trace, "sec config init", "reading daemon HMAC key beside %s", bridge.CA)
hmacKey, err := readHMACKey(bridge.CA)
if err != nil {
return output.Errorf(output.ExitInternal, "sec_hmac_key", "read daemon HMAC key: %v", err)
}
if err := promptForMissing(opts); err != nil {
return err
}
tracef(trace, "sec config init", "POST %s/_sec/api/v1/register-app app_id=%s brand=%s", bridge.Proxy, opts.AppID, opts.Brand)
if err := registerApp(cmd.Context(), bridge.Proxy, hmacKey, opts.AppID, opts.AppSecret, opts.Brand); err != nil {
return output.Errorf(output.ExitAPI, "sec_register_app", "register-app: %v", err)
}
output.PrintSuccess(errOut,
fmt.Sprintf("registered app %s with lark-sec-cli (%s)", opts.AppID, opts.Brand))
return nil
}
// loadBridge reads the daemon-written sec_config.json from lark-cli's config dir.
func loadBridge() (*secBridge, error) {
path := filepath.Join(core.GetConfigDir(), "sec_config.json")
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var b secBridge
if err := json.Unmarshal(data, &b); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &b, nil
}
// readHMACKey returns the daemon's proxy.key bytes. The daemon writes the key
// hex-encoded (64 ASCII chars); we hex-decode here. If the file is a raw
// 32-byte blob (older daemon variants), we use it as-is.
func readHMACKey(caPath string) ([]byte, error) {
if caPath == "" {
return nil, errors.New("sec_config.json has no LARKSUITE_CLI_SEC_CA — can't locate proxy.key")
}
keyPath := filepath.Join(filepath.Dir(caPath), "proxy.key")
raw, err := os.ReadFile(keyPath)
if err != nil {
return nil, err
}
raw = bytes.TrimSpace(raw)
if len(raw) == 64 {
if decoded, err := hex.DecodeString(string(raw)); err == nil {
return decoded, nil
}
}
return raw, nil
}
// promptForMissing fills in any of AppID / AppSecret / Brand the user didn't
// provide via flags. --yes refuses to prompt; that's caller error if any are
// still missing at that point.
func promptForMissing(opts *ConfigInitOptions) error {
if opts.AppID != "" && opts.AppSecret != "" && opts.Brand != "" {
return nil
}
if opts.Yes {
return output.ErrValidation("--yes set but missing one of --app-id / --app-secret / --brand")
}
groups := []*huh.Group{}
if opts.AppID == "" {
groups = append(groups, huh.NewGroup(
huh.NewInput().Title("App ID").Placeholder("cli_xxxx").Value(&opts.AppID),
))
}
if opts.AppSecret == "" {
groups = append(groups, huh.NewGroup(
huh.NewInput().Title("App Secret").EchoMode(huh.EchoModePassword).Value(&opts.AppSecret),
))
}
if opts.Brand == "" {
opts.Brand = "feishu"
groups = append(groups, huh.NewGroup(
huh.NewSelect[string]().Title("Brand").Options(
huh.NewOption("Feishu (cn)", "feishu"),
huh.NewOption("Lark (intl)", "lark"),
).Value(&opts.Brand),
))
}
if len(groups) == 0 {
return nil
}
form := huh.NewForm(groups...).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return output.ErrBare(1)
}
return err
}
return nil
}
// registerApp POSTs to /_sec/api/v1/register-app with the daemon's HMAC scheme.
// Canonical signing input is "method\npath\nsha256hex(body)\ntimestamp", per
// lark-sec-cli/internal/proxy/admin_handler.go's verifyHMAC.
func registerApp(ctx context.Context, proxyURL string, hmacKey []byte, appID, appSecret, brand string) error {
const path = "/_sec/api/v1/register-app"
body, err := json.Marshal(map[string]string{
"app_id": appID,
"app_secret": appSecret,
"brand": brand,
})
if err != nil {
return err
}
ts := strconv.FormatInt(time.Now().Unix(), 10)
bodyHash := sha256.Sum256(body)
canonical := http.MethodPost + "\n" + path + "\n" + hex.EncodeToString(bodyHash[:]) + "\n" + ts
mac := hmac.New(sha256.New, hmacKey)
mac.Write([]byte(canonical))
sig := hex.EncodeToString(mac.Sum(nil))
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, proxyURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Lark-Admin-Signature", sig)
req.Header.Set("X-Lark-Admin-Timestamp", ts)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
}

33
cmd/sec/factory.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
intsec "github.com/larksuite/cli/internal/sec"
)
// installer wires up an internal/sec.Installer using the Factory's HTTP client,
// the default platform paths, and a lazy OAPI-client provider used to fetch
// the install manifest. APIClientFunc is a method value, not an eager call —
// commands that short-circuit (or that never install, like sec status / sec
// stop) avoid decrypting credentials from the keychain. Every cmd/sec
// subcommand starts here.
func installer(f *cmdutil.Factory) (*intsec.Installer, *intsec.Paths, error) {
paths, err := intsec.DefaultPaths()
if err != nil {
return nil, nil, fmt.Errorf("resolve sec paths: %w", err)
}
httpClient, err := f.HttpClient()
if err != nil {
return nil, nil, fmt.Errorf("resolve http client: %w", err)
}
return &intsec.Installer{
Paths: paths,
HTTPClient: httpClient,
APIClientFunc: f.NewAPIClient,
}, paths, nil
}

127
cmd/sec/run.go Normal file
View File

@@ -0,0 +1,127 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"fmt"
"os/exec"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
intsec "github.com/larksuite/cli/internal/sec"
)
// RunOptions holds inputs for `lark-cli sec run`.
type RunOptions struct {
Factory *cmdutil.Factory
ProxyPort int
// AutoInstall runs `sec install` first when no binary is recorded.
AutoInstall bool
}
// NewCmdSecRun starts lark-sec-cli as a user-level system service so it
// persists across logins and gets restarted by the OS supervisor if it
// crashes. Under the hood it shells out to `lark-sec-cli service enable`,
// which is the recommended startup path per the lark-sec-cli manual:
//
// - macOS → user-level launchd plist with KeepAlive=true
// - Linux → user systemd unit with Restart=always
// - Windows → registry autostart + a VBS watchdog loop
//
// Switching to this from a detached `exec.Command(... Setsid:true)` spawn
// fixes two latent issues at once: (1) daemon logs survive past lark-cli
// exit because the service supervisor — not our terminated pipes — owns
// the daemon's stdout, and (2) the daemon's own self-upgrade module can
// now fire (it gates on running-under-supervisor).
func NewCmdSecRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command {
opts := &RunOptions{Factory: f, AutoInstall: true}
cmd := &cobra.Command{
Use: "run",
Short: "Enable lark-sec-cli as a user system service (the daemon runs in the background)",
Long: `Install lark-sec-cli as a user-level system service so the proxy
daemon runs automatically, persists across logins, and is restarted by the
OS if it exits. The daemon writes its own log file (default: under
~/.lark-sec-cli/logs/daemon.log) so logs persist independently of this
command.
After enabling, the daemon writes ~/.lark-cli/sec_config.json itself with
the proxy port and CA path, so subsequent lark-cli runs route through the
sidecar without any further action.
To stop and remove the service: lark-cli sec stop.`,
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runRun(cmd, opts)
},
}
cmd.Flags().IntVar(&opts.ProxyPort, "proxy-port", 0, "force lark-sec-cli to bind this port (default: dynamic)")
cmd.Flags().BoolVar(&opts.AutoInstall, "auto-install", true, "bootstrap-install lark-sec-cli first when no binary is recorded")
return cmd
}
func runRun(cmd *cobra.Command, opts *RunOptions) error {
ctx := cmd.Context()
errOut := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, errOut)
tracef(trace, "sec run", "constructing installer (lazy credentials)")
inst, paths, err := installer(opts.Factory)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
// Make sure we have a binary on disk before asking it to install itself
// as a service.
tracef(trace, "sec run", "loading state from %s", paths.StateFile())
state, err := intsec.LoadState(paths.StateFile())
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
}
if state == nil {
tracef(trace, "sec run", "no install on disk (auto-install=%t)", opts.AutoInstall)
if !opts.AutoInstall {
return output.ErrWithHint(output.ExitValidation, "sec_not_installed",
"lark-sec-cli is not installed",
"Re-run `lark-cli sec run` with --auto-install (default on), or remove --auto-install=false.")
}
state, err = inst.Install(ctx, intsec.InstallOptions{Verbose: trace})
if err != nil {
return output.Errorf(output.ExitNetwork, "sec_install", "auto-install lark-sec-cli: %v", err)
}
} else {
tracef(trace, "sec run", "existing install: version=%s binary=%s", state.Version, state.BinaryPath)
}
args := []string{"service", "enable"}
if opts.ProxyPort > 0 {
args = append(args, fmt.Sprintf("--proxy-port=%d", opts.ProxyPort))
}
fmt.Fprintf(errOut, "Running: %s %v\n", state.BinaryPath, args)
tracef(trace, "sec run", "shelling out to %s %v", state.BinaryPath, args)
c := exec.CommandContext(ctx, state.BinaryPath, args...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
if err := c.Run(); err != nil {
return output.Errorf(output.ExitInternal, "sec_service_enable",
"`lark-sec-cli service enable` failed: %v\nstderr: %s", err, stderr.String())
}
tracef(trace, "sec run", "service enable returned ok (%d bytes stdout)", stdout.Len())
// Forward the installer's stdout to the user — it contains the launchd /
// systemd unit name, the registered executable path, and a confirmation
// that the supervisor will respawn the daemon on exit. Useful diagnostic
// output that's better seen than swallowed.
fmt.Fprint(errOut, stdout.String())
output.PrintSuccess(errOut,
"lark-sec-cli enabled as a user system service. Run `lark-cli sec status` to verify, `lark-cli sec stop` to disable.")
return nil
}

49
cmd/sec/sec.go Normal file
View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package sec exposes the `lark-cli sec` command tree that bootstraps the
// lark-sec-cli sidecar daemon: install, run, stop, status, and `config init`.
// The internal/sec package owns the implementation; this package is a thin
// Cobra wrapper that mirrors the conventions in cmd/auth.
//
// After bootstrap install, lark-sec-cli handles its own upgrade lifecycle —
// lark-cli is not in the update path, which is why there's no `sec update`
// subcommand here.
package sec
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
// NewCmdSec builds the parent `sec` command and registers all subcommands.
//
// The persistent --verbose / -v flag is inherited by every subcommand:
// `sec run -v`, `sec status -v`, etc. all emit step-by-step trace output to
// stderr.
//
// There is no `sec install` subcommand — `sec run` auto-installs lark-sec-cli
// if no binary is on disk, so a separate install verb was redundant.
func NewCmdSec(f *cmdutil.Factory) *cobra.Command {
var verbose bool
cmd := &cobra.Command{
Use: "sec",
Short: "Manage the lark-sec-cli security sidecar (run, status, stop, config)",
Long: `Manage the lark-sec-cli security sidecar.
lark-sec-cli is a local HTTPS proxy daemon that intercepts lark-cli's traffic,
injects BDMS risk-control signatures, and manages credentials via the OS
keychain. These subcommands handle the runtime lifecycle from lark-cli's side:
start the daemon (auto-installing on first run), inspect its state, register
an app with it, and stop it. Updates after the first install are managed by
lark-sec-cli itself.`,
}
cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
"print step-by-step pipeline output to stderr")
cmd.AddCommand(NewCmdSecRun(f, nil))
cmd.AddCommand(NewCmdSecStop(f, nil))
cmd.AddCommand(NewCmdSecStatus(f, nil))
cmd.AddCommand(NewCmdSecConfig(f))
return cmd
}

38
cmd/sec/sec_test.go Normal file
View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"sort"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// TestNewCmdSec_HasAllSubcommands locks in the public command surface so a
// future refactor doesn't silently drop run/status/etc. The `update` verb
// was intentionally removed when lark-sec-cli took over its own upgrade
// lifecycle; if it ever needs to come back, add it here too. `install` was
// removed because `sec run --auto-install` (default on) makes a standalone
// install verb redundant.
func TestNewCmdSec_HasAllSubcommands(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := NewCmdSec(f)
var got []string
for _, c := range cmd.Commands() {
got = append(got, c.Name())
}
sort.Strings(got)
want := []string{"config", "run", "status", "stop"}
if len(got) != len(want) {
t.Fatalf("subcommands = %v, want %v", got, want)
}
for i, name := range want {
if got[i] != name {
t.Errorf("subcommands[%d] = %q, want %q", i, got[i], name)
}
}
}

115
cmd/sec/status.go Normal file
View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"fmt"
"os/exec"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
intsec "github.com/larksuite/cli/internal/sec"
)
// StatusOptions holds inputs for `lark-cli sec status`.
type StatusOptions struct {
Factory *cmdutil.Factory
}
// NewCmdSecStatus shows install + runtime state. Implementation strategy:
//
// 1. Read lark-cli's local install record (state.json) — works even when the
// daemon's not installed, and gives the user a version/buildId/path
// fingerprint regardless of whether the service is up.
// 2. If the install exists, shell out to `lark-sec-cli status` for the
// live daemon view (service registration, pid liveness, proxy probe,
// sec_config.json contents). The daemon's own status command does a
// thorough check; we just pass it through.
func NewCmdSecStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
opts := &StatusOptions{Factory: f}
cmd := &cobra.Command{
Use: "status",
Short: "Show lark-sec-cli install and runtime state",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runStatus(cmd, opts)
},
}
return cmd
}
func runStatus(cmd *cobra.Command, opts *StatusOptions) error {
errOut := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, errOut)
tracef(trace, "sec status", "constructing installer (lazy credentials)")
_, paths, err := installer(opts.Factory)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
out := opts.Factory.IOStreams.Out
tracef(trace, "sec status", "loading state from %s", paths.StateFile())
state, err := intsec.LoadState(paths.StateFile())
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
}
if state == nil {
fmt.Fprintln(out, "lark-sec-cli: not installed")
fmt.Fprintln(out, " run: lark-cli sec run")
return nil
}
fmt.Fprintf(out, "lark-sec-cli %s\n", state.Version)
fmt.Fprintf(out, " binary: %s\n", state.BinaryPath)
// Daemon-side detail via `lark-sec-cli status`. The daemon's status
// command already covers service registration + pid + proxy reachability
// + bridge file — better than re-implementing those here.
tracef(trace, "sec status", "shelling out to %s status", state.BinaryPath)
c := exec.CommandContext(cmd.Context(), state.BinaryPath, "status")
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
runErr := c.Run()
tracef(trace, "sec status", "daemon status exit=%v stdout=%d bytes stderr=%d bytes", runErr, stdout.Len(), stderr.Len())
fmt.Fprintln(out, " --- lark-sec-cli status ---")
if stdout.Len() > 0 {
fmt.Fprint(out, indent(stdout.String(), " "))
}
if stderr.Len() > 0 {
fmt.Fprint(out, indent(stderr.String(), " "))
}
// `lark-sec-cli status` exits 1 when not running — that's diagnostic
// data, not a failure of OUR command. Surface it for the user but don't
// propagate the non-zero exit upward.
_ = runErr
return nil
}
// indent prefixes every line of s with prefix. Cheap pass-through formatter
// used to make the embedded `lark-sec-cli status` output read as a sub-block
// under our own header.
func indent(s, prefix string) string {
if s == "" {
return s
}
var buf bytes.Buffer
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
buf.WriteString(prefix)
buf.WriteString(s[start : i+1])
start = i + 1
}
}
if start < len(s) {
buf.WriteString(prefix)
buf.WriteString(s[start:])
}
return buf.String()
}

82
cmd/sec/stop.go Normal file
View File

@@ -0,0 +1,82 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"bytes"
"fmt"
"os/exec"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
intsec "github.com/larksuite/cli/internal/sec"
)
// StopOptions holds inputs for `lark-cli sec stop`.
type StopOptions struct {
Factory *cmdutil.Factory
}
// NewCmdSecStop disables and removes the lark-sec-cli user system service.
// Counterpart to `sec run` — internally invokes `lark-sec-cli service disable`,
// which uninstalls the launchd / systemd / VBS-watchdog registration.
//
// The daemon itself wipes ~/.lark-cli/sec_config.json on shutdown (see its
// --disable-on-exit flag, default true), so subsequent lark-cli runs route
// directly to the upstream API instead of dangling through a dead local proxy.
func NewCmdSecStop(f *cmdutil.Factory, runF func(*StopOptions) error) *cobra.Command {
opts := &StopOptions{Factory: f}
cmd := &cobra.Command{
Use: "stop",
Short: "Disable and remove the lark-sec-cli user system service",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return runStop(cmd, opts)
},
}
return cmd
}
func runStop(cmd *cobra.Command, opts *StopOptions) error {
out := opts.Factory.IOStreams.ErrOut
trace := verboseOut(cmd, out)
tracef(trace, "sec stop", "constructing installer (lazy credentials)")
_, paths, err := installer(opts.Factory)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
tracef(trace, "sec stop", "loading state from %s", paths.StateFile())
state, err := intsec.LoadState(paths.StateFile())
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "load sec state: %v", err)
}
if state == nil {
// Nothing on disk to stop — no-op.
tracef(trace, "sec stop", "no install on disk; nothing to stop")
output.PrintSuccess(out, "lark-sec-cli not installed; nothing to stop")
return nil
}
args := []string{"service", "disable"}
fmt.Fprintf(out, "Running: %s %v\n", state.BinaryPath, args)
tracef(trace, "sec stop", "shelling out to %s %v", state.BinaryPath, args)
c := exec.CommandContext(cmd.Context(), state.BinaryPath, args...)
var stdout, stderr bytes.Buffer
c.Stdout = &stdout
c.Stderr = &stderr
if err := c.Run(); err != nil {
return output.Errorf(output.ExitInternal, "sec_service_disable",
"`lark-sec-cli service disable` failed: %v\nstderr: %s", err, stderr.String())
}
tracef(trace, "sec stop", "service disable returned ok (%d bytes stdout)", stdout.Len())
fmt.Fprint(out, stdout.String())
output.PrintSuccess(out, "lark-sec-cli service disabled")
return nil
}

32
cmd/sec/verbose.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"fmt"
"io"
"github.com/spf13/cobra"
)
// verboseOut returns the trace destination for a sec subcommand: the given
// stderr writer when the inherited --verbose / -v flag is set, otherwise nil.
// Pair with tracef — a nil destination silently drops traces, so callers can
// emit unconditionally.
func verboseOut(cmd *cobra.Command, errOut io.Writer) io.Writer {
if v, _ := cmd.Flags().GetBool("verbose"); v {
return errOut
}
return nil
}
// tracef writes one trace line to w when w is non-nil. The prefix names the
// emitting subcommand (e.g. "sec run") so layered output from the install
// pipeline + the command itself stays distinguishable.
func tracef(w io.Writer, prefix, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, "[%s] "+format+"\n", append([]any{prefix}, args...)...)
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package secplugin provides a placeholder credential provider for SEC_AUTH mode.
//
// When ~/.lark-cli/sec_config.json has:
//
// LARKSUITE_CLI_SEC_ENABLE=true
// LARKSUITE_CLI_SEC_AUTH=true
//
// this provider returns a minimal Account and placeholder tokens. The proxy
// is expected to replace the placeholder tokens with real ones.
package secplugin
import (
"context"
"fmt"
"os"
"strings"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
internalsec "github.com/larksuite/cli/internal/secplugin"
)
// Provider supplies placeholder credentials when SEC_AUTH mode is enabled.
type Provider struct{}
// Name returns the registered credential provider name.
func (p *Provider) Name() string { return "secplugin" }
// Priority is higher than env (default 10) but lower than sidecar (0),
// so authsidecar builds keep sidecar semantics when both are present.
func (p *Provider) Priority() int { return 1 }
// loadSecConfig is replaceable in tests so provider behavior can be isolated
// from on-disk SEC configuration state.
var loadSecConfig = internalsec.Load
func validateDefaultAs(value string) error {
switch id := credential.Identity(strings.TrimSpace(value)); id {
case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot:
return nil
default:
return fmt.Errorf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id)
}
}
// ResolveAccount builds an account that advertises SEC_AUTH placeholder support.
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
cfg, err := loadSecConfig()
if err != nil {
return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()}
}
if cfg == nil || !cfg.AuthEnabled() {
return nil, nil
}
appID := strings.TrimSpace(os.Getenv(envvars.CliAppID))
brand := credential.Brand(strings.TrimSpace(os.Getenv(envvars.CliBrand)))
var defaultAs credential.Identity
// Prefer explicit env; if missing, allow sec_config.json to provide defaults.
if appID == "" && strings.TrimSpace(cfg.AppID) != "" {
appID = strings.TrimSpace(cfg.AppID)
}
if brand == "" && strings.TrimSpace(cfg.Brand) != "" {
brand = credential.Brand(strings.TrimSpace(cfg.Brand))
}
if defaultAs == "" && strings.TrimSpace(cfg.DefaultAs) != "" {
defaultAs = credential.Identity(strings.TrimSpace(cfg.DefaultAs))
if err := validateDefaultAs(string(defaultAs)); err != nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: err.Error(),
}
}
}
// Prefer explicit env for sandbox use; otherwise fall back to on-disk config
// without resolving any secrets.
if appID == "" || brand == "" {
multi, err := core.LoadMultiAppConfig()
if err != nil || multi == nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: "SEC_AUTH is enabled but no app config is available; run `lark-cli config init --new` (trusted env), or set " + envvars.CliAppID + " and " + envvars.CliBrand,
}
}
app := multi.CurrentAppConfig("") // profile override not available in provider API
if app == nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: "SEC_AUTH is enabled but no active profile is available in config.json",
}
}
if appID == "" {
appID = app.AppId
}
if brand == "" {
brand = credential.Brand(app.Brand)
}
if defaultAs == "" {
defaultAs = credential.Identity(app.DefaultAs)
}
// Map strict mode to supported identities (0 = allow all).
mode := multi.StrictMode
if app.StrictMode != nil {
mode = *app.StrictMode
}
switch mode {
case core.StrictModeBot:
// Keep sandbox locked down to bot.
return &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: credential.SupportsBot,
}, nil
case core.StrictModeUser:
return &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: credential.SupportsUser,
}, nil
}
}
if appID == "" {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: "SEC_AUTH is enabled but " + envvars.CliAppID + " is missing",
}
}
if brand == "" {
brand = credential.BrandFeishu
}
if brand != credential.BrandFeishu && brand != credential.BrandLark {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: fmt.Sprintf("invalid %s %q (want feishu or lark)", envvars.CliBrand, brand),
}
}
// DefaultAs comes from env if present (optional).
envDefaultAs := strings.TrimSpace(os.Getenv(envvars.CliDefaultAs))
if err := validateDefaultAs(envDefaultAs); err != nil {
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: err.Error(),
}
}
switch id := credential.Identity(envDefaultAs); id {
case "", credential.IdentityAuto:
// keep defaultAs from config/env; empty is allowed
case credential.IdentityUser, credential.IdentityBot:
defaultAs = id
}
// If STRICT_MODE env is not set, allow sec_config.json to provide a default.
strictModeRaw := strings.TrimSpace(os.Getenv(envvars.CliStrictMode))
if strictModeRaw == "" && strings.TrimSpace(cfg.StrictMode) != "" {
strictModeRaw = strings.TrimSpace(cfg.StrictMode)
}
// SupportedIdentities from STRICT_MODE (optional). Default: allow both.
support := credential.SupportsAll
switch strictMode := strictModeRaw; strictMode {
case "bot":
support = credential.SupportsBot
case "user":
support = credential.SupportsUser
case "off", "":
// Keep the default: allow both identities.
default:
return nil, &credential.BlockError{
Provider: p.Name(),
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
}
}
return &credential.Account{
AppID: appID,
AppSecret: credential.NoAppSecret,
Brand: brand,
DefaultAs: defaultAs,
SupportedIdentities: support,
}, nil
}
// ResolveToken returns placeholder tokens that a trusted proxy must replace.
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
cfg, err := internalsec.Load()
if err != nil {
return nil, &credential.BlockError{Provider: p.Name(), Reason: err.Error()}
}
if cfg == nil || !cfg.AuthEnabled() {
return nil, nil
}
switch req.Type {
case credential.TokenTypeUAT:
return &credential.Token{
Value: internalsec.SentinelUAT,
Scopes: "", // empty => skip scope pre-check
Source: "secplugin",
}, nil
case credential.TokenTypeTAT:
return &credential.Token{
Value: internalsec.SentinelTAT,
Scopes: "",
Source: "secplugin",
}, nil
default:
return nil, nil
}
}
// init registers the SEC_AUTH placeholder credential provider.
func init() {
credential.Register(&Provider{})
}

View File

@@ -0,0 +1,486 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
internalsec "github.com/larksuite/cli/internal/secplugin"
)
// TestProvider_Metadata verifies the registered provider metadata.
func TestProvider_Metadata(t *testing.T) {
p := &Provider{}
if p.Name() != "secplugin" {
t.Fatalf("Name() = %q, want secplugin", p.Name())
}
if p.Priority() != 1 {
t.Fatalf("Priority() = %d, want 1", p.Priority())
}
}
// TestProvider_UsesSecConfigDefaults verifies that SEC config defaults populate
// the placeholder account when env vars are absent.
func TestProvider_UsesSecConfigDefaults(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
AppID: "cli_test_app",
Brand: "lark",
DefaultAs: "bot",
StrictMode: "bot",
}, nil
}
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.AppID != "cli_test_app" {
t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_test_app")
}
if string(acct.Brand) != "lark" {
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark")
}
if string(acct.DefaultAs) != "bot" {
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "bot")
}
// StrictMode=bot => SupportsBot only.
if acct.SupportedIdentities != 2 {
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, 2)
}
}
// TestProvider_EnvOverridesSecConfigDefaults verifies that explicit environment
// variables override SEC config defaults.
func TestProvider_EnvOverridesSecConfigDefaults(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
AppID: "cli_test_app",
Brand: "feishu",
DefaultAs: "bot",
StrictMode: "bot",
}, nil
}
t.Setenv(envvars.CliAppID, "cli_env_app")
t.Setenv(envvars.CliBrand, "lark")
t.Setenv(envvars.CliDefaultAs, "user")
t.Setenv(envvars.CliStrictMode, "user")
p := &Provider{}
acct, err := p.ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.AppID != "cli_env_app" {
t.Fatalf("acct.AppID = %q, want %q", acct.AppID, "cli_env_app")
}
if string(acct.Brand) != "lark" {
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, "lark")
}
if string(acct.DefaultAs) != "user" {
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, "user")
}
// StrictMode=user => SupportsUser only (bit 1).
if acct.SupportedIdentities != 1 {
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsUser)", acct.SupportedIdentities, 1)
}
}
// TestProvider_ResolveAccount_ReturnsNilWhenDisabled verifies early nil returns
// when SEC_AUTH mode is unavailable.
func TestProvider_ResolveAccount_ReturnsNilWhenDisabled(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
cases := []struct {
name string
cfg *internalsec.Config
}{
{name: "nil config", cfg: nil},
{name: "auth disabled", cfg: &internalsec.Config{Enable: true, Auth: false}},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil }
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
})
}
}
// TestProvider_ResolveAccount_LoadErrorBlocks verifies that SEC config load failures
// stop provider resolution.
func TestProvider_ResolveAccount_LoadErrorBlocks(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return nil, context.DeadlineExceeded
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr, ok := err.(*credential.BlockError)
if !ok {
t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err)
}
if blockErr.Provider != "secplugin" {
t.Fatalf("blockErr.Provider = %q, want secplugin", blockErr.Provider)
}
if !strings.Contains(blockErr.Reason, context.DeadlineExceeded.Error()) {
t.Fatalf("blockErr.Reason = %q, want load error text", blockErr.Reason)
}
}
// TestProvider_ResolveAccount_DefaultsBrandAndSupport verifies fallback defaults
// for brand and supported identities.
func TestProvider_ResolveAccount_DefaultsBrandAndSupport(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
}, nil
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
DefaultAs: core.AsBot,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.Brand != credential.BrandFeishu {
t.Fatalf("acct.Brand = %q, want %q", acct.Brand, credential.BrandFeishu)
}
if acct.SupportedIdentities != credential.SupportsAll {
t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, credential.SupportsAll)
}
if acct.DefaultAs != credential.Identity("bot") {
t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs)
}
if acct.AppID != "app_from_disk" {
t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID)
}
}
// TestProvider_ResolveAccount_InvalidValuesBlock verifies validation failures for
// brand and identity-related settings.
func TestProvider_ResolveAccount_InvalidValuesBlock(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
cases := []struct {
name string
cfg *internalsec.Config
envKey string
envValue string
want string
}{
{
name: "invalid brand from config",
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "bad-brand"},
want: "invalid " + envvars.CliBrand,
},
{
name: "invalid default as from config",
cfg: &internalsec.Config{
Enable: true,
Auth: true,
AppID: "cli_test_app",
Brand: "lark",
DefaultAs: "bad",
},
want: "invalid " + envvars.CliDefaultAs,
},
{
name: "invalid default as from env",
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"},
envKey: envvars.CliDefaultAs,
envValue: "bad",
want: "invalid " + envvars.CliDefaultAs,
},
{
name: "invalid strict mode from env",
cfg: &internalsec.Config{Enable: true, Auth: true, AppID: "cli_test_app", Brand: "lark"},
envKey: envvars.CliStrictMode,
envValue: "bad",
want: "invalid " + envvars.CliStrictMode,
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
loadSecConfig = func() (*internalsec.Config, error) { return tt.cfg, nil }
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
if tt.envKey != "" {
t.Setenv(tt.envKey, tt.envValue)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr, ok := err.(*credential.BlockError)
if !ok {
t.Fatalf("ResolveAccount() error = %T, want *credential.BlockError", err)
}
if !strings.Contains(blockErr.Reason, tt.want) {
t.Fatalf("blockErr.Reason = %q, want substring %q", blockErr.Reason, tt.want)
}
})
}
}
// TestProvider_ResolveAccount_FallbackToDiskConfig verifies fallback behavior
// when SEC config omits app identity fields.
func TestProvider_ResolveAccount_FallbackToDiskConfig(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{Enable: true, Auth: true}, nil
}
t.Run("missing config blocks", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr := err.(*credential.BlockError)
if !strings.Contains(blockErr.Reason, "no app config is available") {
t.Fatalf("blockErr.Reason = %q, want missing app config message", blockErr.Reason)
}
})
t.Run("missing active profile blocks", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
Brand: core.LarkBrand("lark"),
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err == nil {
t.Fatal("ResolveAccount() error = nil, want block error")
}
if acct != nil {
t.Fatalf("ResolveAccount() = %#v, want nil", acct)
}
blockErr := err.(*credential.BlockError)
if !strings.Contains(blockErr.Reason, "no active profile") {
t.Fatalf("blockErr.Reason = %q, want no active profile message", blockErr.Reason)
}
})
t.Run("strict mode from disk", func(t *testing.T) {
cases := []struct {
name string
mode core.StrictMode
wantIDs credential.IdentitySupport
}{
{name: "bot", mode: core.StrictModeBot, wantIDs: credential.SupportsBot},
{name: "user", mode: core.StrictModeUser, wantIDs: credential.SupportsUser},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
mode := tt.mode
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
Brand: core.LarkBrand("lark"),
DefaultAs: core.AsBot,
StrictMode: &mode,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.AppID != "app_from_disk" {
t.Fatalf("acct.AppID = %q, want app_from_disk", acct.AppID)
}
if acct.Brand != credential.Brand("lark") {
t.Fatalf("acct.Brand = %q, want lark", acct.Brand)
}
if acct.DefaultAs != credential.Identity("bot") {
t.Fatalf("acct.DefaultAs = %q, want bot", acct.DefaultAs)
}
if acct.SupportedIdentities != tt.wantIDs {
t.Fatalf("acct.SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.wantIDs)
}
})
}
})
}
// TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs verifies
// cfg.DefaultAs is not overwritten by disk profile default in strict-mode path.
func TestProvider_ResolveAccount_StrictModePreservesConfiguredDefaultAs(t *testing.T) {
prev := loadSecConfig
t.Cleanup(func() { loadSecConfig = prev })
loadSecConfig = func() (*internalsec.Config, error) {
return &internalsec.Config{
Enable: true,
Auth: true,
Brand: "lark",
DefaultAs: "user",
StrictMode: "bot",
}, nil
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliAppID, "")
t.Setenv(envvars.CliBrand, "")
t.Setenv(envvars.CliDefaultAs, "")
t.Setenv(envvars.CliStrictMode, "")
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "default",
AppId: "app_from_disk",
AppSecret: core.PlainSecret("secret"),
Brand: core.LarkBrand("lark"),
DefaultAs: core.AsBot,
}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
acct, err := (&Provider{}).ResolveAccount(context.Background())
if err != nil {
t.Fatalf("ResolveAccount() error = %v", err)
}
if acct == nil {
t.Fatal("ResolveAccount() = nil, want account")
}
if acct.DefaultAs != credential.IdentityUser {
t.Fatalf("acct.DefaultAs = %q, want %q", acct.DefaultAs, credential.IdentityUser)
}
if acct.SupportedIdentities != credential.SupportsBot {
t.Fatalf("acct.SupportedIdentities = %d, want %d (SupportsBot)", acct.SupportedIdentities, credential.SupportsBot)
}
}
// TestProvider_ResolveToken_ReturnsSentinels verifies placeholder token behavior
// for SEC_AUTH mode.
func TestProvider_ResolveToken_ReturnsSentinels(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv(envvars.CliSecEnable, "true")
t.Setenv(envvars.CliSecAuth, "true")
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:3128")
t.Setenv(envvars.CliSecCA, "")
p := &Provider{}
uat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT})
if err != nil {
t.Fatalf("ResolveToken(UAT) error = %v", err)
}
if uat == nil || uat.Value != internalsec.SentinelUAT || uat.Source != "secplugin" {
t.Fatalf("ResolveToken(UAT) = %#v, want sentinel UAT token", uat)
}
tat, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT})
if err != nil {
t.Fatalf("ResolveToken(TAT) error = %v", err)
}
if tat == nil || tat.Value != internalsec.SentinelTAT || tat.Source != "secplugin" {
t.Fatalf("ResolveToken(TAT) = %#v, want sentinel TAT token", tat)
}
tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenType("other")})
if err != nil {
t.Fatalf("ResolveToken(other) error = %v", err)
}
if tok != nil {
t.Fatalf("ResolveToken(other) = %#v, want nil", tok)
}
}

View File

@@ -4,18 +4,45 @@
package envvars
const (
CliAppID = "LARKSUITE_CLI_APP_ID"
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
CliBrand = "LARKSUITE_CLI_BRAND"
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
// CliAppID is the app ID environment variable consumed by the CLI.
CliAppID = "LARKSUITE_CLI_APP_ID"
// CliAppSecret is the app secret environment variable consumed by the CLI.
CliAppSecret = "LARKSUITE_CLI_APP_SECRET"
// CliBrand selects the tenant brand environment variable consumed by the CLI.
CliBrand = "LARKSUITE_CLI_BRAND"
// CliUserAccessToken is the user access token override environment variable.
CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN"
// CliTenantAccessToken is the tenant access token override environment variable.
CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// Sidecar proxy (auth proxy mode)
// CliDefaultAs selects the default identity environment variable.
CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS"
// CliStrictMode selects the strict identity mode environment variable.
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// CliAuthProxy is the auth sidecar HTTP address environment variable.
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
// Content safety scanning mode
// CliProxyKey is the shared HMAC signing key environment variable for the sidecar.
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
// CliSecEnable enables sec plugin mode from the environment.
CliSecEnable = "LARKSUITE_CLI_SEC_ENABLE"
// CliSecProxy sets the fixed sec plugin HTTP proxy address.
CliSecProxy = "LARKSUITE_CLI_SEC_PROXY"
// CliSecCA points to an extra PEM bundle trusted by sec plugin mode.
CliSecCA = "LARKSUITE_CLI_SEC_CA"
// CliSecAuth enables placeholder-token auth mode for sec plugin flows.
CliSecAuth = "LARKSUITE_CLI_SEC_AUTH"
// CliContentSafetyMode selects the content safety scanning mode.
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
)

138
internal/sec/archive.go Normal file
View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
)
// maxArchiveBytes is a sanity ceiling for total uncompressed size to prevent
// a malicious or corrupt zip from filling the disk. The lark-sec-cli zip is a
// single binary plus one shared library; 1 GiB is several orders of magnitude
// over the real size and well under most users' free disk.
const maxArchiveBytes = 1 << 30
// ExtractZip unpacks src into dst, refusing entries whose target paths would
// escape dst (zip slip). Existing files inside dst are overwritten; dst must
// already exist.
//
// Executable permission is preserved when the zip stores POSIX mode bits;
// otherwise we apply 0o755 to suspected binaries (matching BinaryName() /
// legacy names or anything *.dylib/*.so/*.dll) and 0o644 to everything else.
func ExtractZip(src, dst string) error {
r, err := zip.OpenReader(src)
if err != nil {
return fmt.Errorf("open zip: %w", err)
}
defer r.Close()
dstAbs, err := filepath.Abs(dst)
if err != nil {
return err
}
var totalSize uint64
for _, f := range r.File {
totalSize += f.UncompressedSize64
if totalSize > maxArchiveBytes {
return fmt.Errorf("zip exceeds %d bytes; refusing", maxArchiveBytes)
}
if err := extractZipEntry(f, dstAbs); err != nil {
return err
}
}
return nil
}
func extractZipEntry(f *zip.File, dstAbs string) error {
// Reject absolute paths and any traversal segments. filepath.Clean
// collapses redundant separators but does NOT resolve symlinks or strip
// leading slashes — we have to do both explicitly.
name := f.Name
if strings.ContainsRune(name, 0) {
return fmt.Errorf("zip entry name contains NUL: %q", name)
}
cleaned := filepath.Clean(name)
if filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "..") ||
strings.Contains(cleaned, string(filepath.Separator)+".."+string(filepath.Separator)) {
return fmt.Errorf("zip entry escapes destination: %q", name)
}
target := filepath.Join(dstAbs, cleaned)
// Defense in depth: even if the checks above missed something, this rel
// check guarantees target is under dstAbs.
rel, err := filepath.Rel(dstAbs, target)
if err != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("zip entry escapes destination: %q", name)
}
if f.FileInfo().IsDir() {
return os.MkdirAll(target, 0o755)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
// Symlink support: zip entries can be symlinks (mode bit set). The
// lark-sec-cli artifact doesn't currently use them, but if it grows to
// (e.g. for shared library version aliases) we want graceful handling.
if f.Mode()&os.ModeSymlink != 0 {
rc, err := f.Open()
if err != nil {
return err
}
linkBytes, readErr := io.ReadAll(io.LimitReader(rc, 1024))
rc.Close()
if readErr != nil {
return readErr
}
os.Remove(target) // os.Symlink fails if target exists
return os.Symlink(string(linkBytes), target)
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
mode := f.Mode().Perm()
if mode == 0 {
mode = guessMode(cleaned)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, rc); err != nil {
out.Close()
return err
}
return out.Close()
}
// guessMode supplies executable bits for entries the zip writer didn't tag
// with POSIX mode info — typically the case for archives built on Windows.
func guessMode(name string) os.FileMode {
base := filepath.Base(name)
if base == BinaryName() {
return 0o755
}
ext := strings.ToLower(filepath.Ext(base))
switch ext {
case ".dylib", ".so", ".dll":
return 0o755
}
if runtime.GOOS != "windows" && !strings.ContainsRune(base, '.') {
// Plausibly an extra unix binary shipped alongside the sec-cli binary.
return 0o755
}
return 0o644
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"archive/zip"
"bytes"
"io"
"os"
"path/filepath"
"testing"
)
// makeZip builds an in-memory zip with the given entries, writes it to path,
// and returns nothing — convenience for table-driven tests.
type zipEntry struct {
name string
body string
mode os.FileMode
symlink string // when set, entry is a symlink with this target
}
func makeZip(t *testing.T, path string, entries []zipEntry) {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, e := range entries {
hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate}
if e.mode != 0 {
hdr.SetMode(e.mode)
}
if e.symlink != "" {
hdr.SetMode(os.ModeSymlink | 0o777)
}
w, err := zw.CreateHeader(hdr)
if err != nil {
t.Fatalf("zip header %q: %v", e.name, err)
}
body := e.body
if e.symlink != "" {
body = e.symlink
}
if _, err := io.WriteString(w, body); err != nil {
t.Fatalf("zip write %q: %v", e.name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
t.Fatalf("write zip: %v", err)
}
}
func TestExtractZip_HappyPath(t *testing.T) {
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "src.zip")
makeZip(t, zipPath, []zipEntry{
{name: "lark-sec-cli", body: "binary", mode: 0o755},
{name: "ca.crt", body: "cert"},
{name: "lib/libMetaSecML.dylib", body: "dylib", mode: 0o755},
})
dst := filepath.Join(tmp, "out")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := ExtractZip(zipPath, dst); err != nil {
t.Fatalf("ExtractZip: %v", err)
}
for name, want := range map[string]string{
"lark-sec-cli": "binary",
"ca.crt": "cert",
"lib/libMetaSecML.dylib": "dylib",
} {
got, err := os.ReadFile(filepath.Join(dst, name))
if err != nil {
t.Errorf("read %s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("%s body = %q, want %q", name, got, want)
}
}
if info, err := os.Stat(filepath.Join(dst, "lark-sec-cli")); err == nil {
if info.Mode().Perm()&0o100 == 0 {
t.Errorf("lark-sec-cli not executable: mode=%v", info.Mode())
}
}
}
func TestExtractZip_RejectsTraversal(t *testing.T) {
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "evil.zip")
makeZip(t, zipPath, []zipEntry{
{name: "../../../etc/passwd", body: "pwned"},
})
dst := filepath.Join(tmp, "out")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := ExtractZip(zipPath, dst); err == nil {
t.Fatal("ExtractZip accepted zip-slip entry")
}
}
func TestExtractZip_RejectsAbsolutePath(t *testing.T) {
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "abs.zip")
makeZip(t, zipPath, []zipEntry{
{name: "/etc/passwd", body: "pwned"},
})
dst := filepath.Join(tmp, "out")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := ExtractZip(zipPath, dst); err == nil {
t.Fatal("ExtractZip accepted absolute-path entry")
}
}

33
internal/sec/bootstrap.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
_ "embed"
"encoding/json"
"fmt"
)
// bootstrapManifestJSON is the lark-sec-cli release manifest shipped with this
// lark-cli build. It points directly at TOS so a fresh install does not depend
// on any external release-tracking service — first install is fully self-contained.
//
// Updating this file pins a new default version of lark-sec-cli for users who
// install via lark-cli. After install, lark-sec-cli is in charge of finding and
// applying its own updates; lark-cli does not consult any release server.
//
//go:embed bootstrap.json
var bootstrapManifestJSON []byte
// LoadBootstrap parses the embedded bootstrap manifest into a Manifest value.
func LoadBootstrap() (*Manifest, error) {
var entries []Entry
if err := json.Unmarshal(bootstrapManifestJSON, &entries); err != nil {
return nil, fmt.Errorf("decode embedded bootstrap manifest: %w", err)
}
if len(entries) == 0 {
return nil, fmt.Errorf("embedded bootstrap manifest is empty")
}
return &Manifest{Entries: entries}, nil
}

View File

@@ -0,0 +1,59 @@
[
{
"key": 0,
"buildPlatform": "linux",
"urls": [
{
"urls": {
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-amd64/linux-amd64-1.0.1-alpha.23.zip",
"arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/linux-arm64/linux-arm64-1.0.1-alpha.23.zip"
},
"region": "cn"
}
],
"branch": "dev",
"version": "1.0.1-alpha.23",
"extra": {
"pipeline_id": "367354993",
"upload_date": 1778487420795
}
},
{
"key": 1,
"buildPlatform": "win32",
"urls": [
{
"urls": {
"x86": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-386/windows-386-1.0.1-alpha.23.zip",
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/windows-amd64/windows-amd64-1.0.1-alpha.23.zip"
},
"region": "cn"
}
],
"branch": "dev",
"version": "1.0.1-alpha.23",
"extra": {
"pipeline_id": "367354993",
"upload_date": 1778487437393
}
},
{
"key": 2,
"buildPlatform": "darwin",
"urls": [
{
"urls": {
"amd64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-amd64/darwin-amd64-1.0.1-alpha.23.zip",
"arm64": "https://lf3-cdn-tos.bytegoofy.com/obj/tron-demo/lark-sec-cli/releases/1.0.1-alpha.23/367354993/darwin-arm64/darwin-arm64-1.0.1-alpha.23.zip"
},
"region": "cn"
}
],
"branch": "dev",
"version": "1.0.1-alpha.23",
"extra": {
"pipeline_id": "367354993",
"upload_date": 1778487395152
}
}
]

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"runtime"
"strings"
"testing"
)
// TestLoadBootstrap_DecodesAllPlatforms guards against the embedded
// manifest becoming malformed or losing an OS — both would break first
// install on whatever GOOS lost its entry.
func TestLoadBootstrap_DecodesAllPlatforms(t *testing.T) {
manifest, err := LoadBootstrap()
if err != nil {
t.Fatalf("LoadBootstrap: %v", err)
}
platforms := map[string]bool{}
for _, e := range manifest.Entries {
platforms[e.BuildPlatform] = true
if e.Version == "" {
t.Errorf("entry %s missing version", e.BuildPlatform)
}
if e.Extra.PipelineID == "" {
t.Errorf("entry %s missing extra.pipeline_id", e.BuildPlatform)
}
}
for _, want := range []string{"darwin", "linux", "win32"} {
if !platforms[want] {
t.Errorf("bootstrap missing platform %q", want)
}
}
}
// TestLoadBootstrap_PickArtifactForCurrentHost ensures the embedded manifest
// resolves to a real URL for whatever platform the test runner is on, so a
// developer fixing this code locally can still smoke-test their changes.
func TestLoadBootstrap_PickArtifactForCurrentHost(t *testing.T) {
manifest, err := LoadBootstrap()
if err != nil {
t.Fatalf("LoadBootstrap: %v", err)
}
art, err := manifest.PickArtifact(runtime.GOOS, runtime.GOARCH, "cn")
if err != nil {
t.Fatalf("PickArtifact for %s/%s: %v", runtime.GOOS, runtime.GOARCH, err)
}
if !strings.HasPrefix(art.URL, "https://") {
t.Errorf("URL is not https: %q", art.URL)
}
if !strings.HasSuffix(art.URL, ".zip") {
t.Errorf("URL is not a .zip: %q", art.URL)
}
if art.BuildID == "" {
t.Error("BuildID is empty")
}
}

156
internal/sec/download.go Normal file
View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"hash"
"io"
"net/http"
"os"
)
// downloadMaxBytes caps the artifact size we'll accept. Comfortably over the
// real lark-sec-cli zip (~tens of MB) and well under what a malicious mirror
// could use to exhaust local disk before we noticed.
const downloadMaxBytes = 512 * 1024 * 1024
// DownloadOptions controls Download.
type DownloadOptions struct {
URL string
Destination string // full path to the .zip we'll create
HTTPClient *http.Client
// ExpectedSHA256, if non-empty, is the hex SHA256 the artifact MUST
// match — verified after the full body has been streamed. Use this when
// the manifest publishes a hash for the artifact (e.g. bootstrap.json's
// `extra.sha256`). Any mismatch fails the download with the .part file
// removed.
//
// When empty (the manifest doesn't carry a hash), the only integrity
// check left is the CDN's own `Content-MD5` response header, applied
// opportunistically below.
ExpectedSHA256 string
}
// Download streams URL to Destination. Writes to a sibling .part file and
// renames into place on success so a crashed or aborted run leaves no
// half-written zip the next run might mistake for valid.
//
// Two layers of integrity check, both opt-in:
//
// 1. ExpectedSHA256 (strong, manifest-provided): cryptographic, fails the
// download on mismatch. Use whenever the release manifest carries a hash.
// 2. CDN `Content-MD5` header (opportunistic): non-cryptographic, catches
// edge replacement or transit corruption when the upstream CDN populates
// the header. Runs unconditionally — if the header is present we honour it.
//
// Neither check defends against a malicious upstream that controls both the
// artifact AND the manifest. That class of risk has to be handled by signing
// the release pipeline, which is out of scope for the client.
func Download(ctx context.Context, opts DownloadOptions) error {
if opts.HTTPClient == nil {
return fmt.Errorf("Download: HTTPClient is required")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, opts.URL, nil)
if err != nil {
return err
}
resp, err := opts.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("download %s: %w", opts.URL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: status %d", opts.URL, resp.StatusCode)
}
tmpPath := opts.Destination + ".part"
out, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
cleanup := func() { out.Close(); os.Remove(tmpPath) }
// Hash both ways during the single read pass. Both hashers are cheap and
// we don't know yet which check (or both) we'll actually need.
sha := sha256.New()
md := md5.New()
writer := io.MultiWriter(out, sha, md)
n, err := io.Copy(writer, io.LimitReader(resp.Body, downloadMaxBytes+1))
if err != nil {
cleanup()
return fmt.Errorf("download %s: %w", opts.URL, err)
}
if n > downloadMaxBytes {
cleanup()
return fmt.Errorf("download %s: exceeds %d bytes", opts.URL, downloadMaxBytes)
}
if err := out.Sync(); err != nil {
cleanup()
return err
}
if err := out.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := verifyChecksums(resp, opts.ExpectedSHA256, sha, md); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("download %s: %w", opts.URL, err)
}
if err := os.Rename(tmpPath, opts.Destination); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
// verifyChecksums applies the two-layer integrity check after the body has
// been fully streamed. Returns nil when both layers (whichever apply) agree.
func verifyChecksums(resp *http.Response, expectedSHA256 string, sha, md hash.Hash) error {
if expectedSHA256 != "" {
got := hex.EncodeToString(sha.Sum(nil))
if !equalFoldHex(got, expectedSHA256) {
return fmt.Errorf("sha256 mismatch: expected %s, got %s", expectedSHA256, got)
}
}
if cdnMD5 := resp.Header.Get("Content-MD5"); cdnMD5 != "" {
got := base64.StdEncoding.EncodeToString(md.Sum(nil))
if got != cdnMD5 {
return fmt.Errorf("content-md5 mismatch: cdn=%s, computed=%s", cdnMD5, got)
}
}
return nil
}
// equalFoldHex is a non-allocating ASCII case-insensitive compare for hex
// strings. SHA256 manifests sometimes ship uppercase, sometimes lowercase.
func equalFoldHex(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
ca, cb := a[i], b[i]
if 'A' <= ca && ca <= 'Z' {
ca += 'a' - 'A'
}
if 'A' <= cb && cb <= 'Z' {
cb += 'a' - 'A'
}
if ca != cb {
return false
}
}
return true
}

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
const bodyContent = "lark-sec-cli pretend zip bytes"
// fixtureSHA256 / fixtureMD5 are the hashes of bodyContent.
var fixtureSHA256 string
var fixtureMD5b64 string
func init() {
sum := sha256.Sum256([]byte(bodyContent))
fixtureSHA256 = hex.EncodeToString(sum[:])
m := md5.Sum([]byte(bodyContent))
fixtureMD5b64 = base64.StdEncoding.EncodeToString(m[:])
}
func newFixtureServer(t *testing.T, setContentMD5 bool) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if setContentMD5 {
w.Header().Set("Content-MD5", fixtureMD5b64)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(bodyContent))
}))
}
// TestDownload_HappyPath_NoChecksum confirms that a download with no manifest
// SHA and no CDN MD5 succeeds — the integrity hooks are opt-in, not required.
func TestDownload_HappyPath_NoChecksum(t *testing.T) {
srv := newFixtureServer(t, false)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
})
if err != nil {
t.Fatalf("Download: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatal(err)
}
if string(got) != bodyContent {
t.Errorf("body roundtrip mismatch")
}
}
// TestDownload_SHA256_Match confirms the manifest-provided SHA256 path
// passes for a correct hash. Tests both cases (with and without CDN MD5)
// so the second layer doesn't interfere.
func TestDownload_SHA256_Match(t *testing.T) {
for _, withMD5 := range []bool{false, true} {
name := "noMD5"
if withMD5 {
name = "withCDNMd5"
}
t.Run(name, func(t *testing.T) {
srv := newFixtureServer(t, withMD5)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
ExpectedSHA256: fixtureSHA256,
})
if err != nil {
t.Fatalf("Download: %v", err)
}
})
}
}
// TestDownload_SHA256_Mismatch is the safety property: a wrong manifest hash
// rejects the download AND removes the .part file so the next run doesn't
// pick up a poisoned zip.
func TestDownload_SHA256_Mismatch(t *testing.T) {
srv := newFixtureServer(t, false)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
ExpectedSHA256: "0000000000000000000000000000000000000000000000000000000000000000",
})
if err == nil {
t.Fatal("expected sha256 mismatch error")
}
if !strings.Contains(err.Error(), "sha256 mismatch") {
t.Errorf("error should mention sha256 mismatch: %v", err)
}
if _, statErr := os.Stat(dst); statErr == nil {
t.Errorf("dst should not exist after mismatch")
}
if _, statErr := os.Stat(dst + ".part"); statErr == nil {
t.Errorf(".part should not exist after mismatch")
}
}
// TestDownload_ContentMD5_Mismatch confirms the opportunistic check fires
// even when no manifest SHA was provided. Catches a CDN edge that returned
// content but a stale/wrong Content-MD5 header (or a poisoned proxy).
func TestDownload_ContentMD5_Mismatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-MD5", "Z3JhZmFuYTpyZWFsbHk/Pz8/Pz8/PzA9PT0=") // arbitrary
_, _ = w.Write([]byte(bodyContent))
}))
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
})
if err == nil {
t.Fatal("expected content-md5 mismatch error")
}
if !strings.Contains(err.Error(), "content-md5 mismatch") {
t.Errorf("error should mention content-md5 mismatch: %v", err)
}
}
// TestDownload_SHA256_CaseInsensitive guards the hex compare against case
// drift in the manifest (some publishers upper-case).
func TestDownload_SHA256_CaseInsensitive(t *testing.T) {
srv := newFixtureServer(t, false)
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
ExpectedSHA256: strings.ToUpper(fixtureSHA256),
})
if err != nil {
t.Fatalf("Download (uppercase sha): %v", err)
}
}
// TestDownload_404_NoPartFile confirms that a non-200 response leaves no
// .part file behind to confuse the next attempt.
func TestDownload_404_NoPartFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
dst := filepath.Join(t.TempDir(), "out.zip")
err := Download(context.Background(), DownloadOptions{
URL: srv.URL,
Destination: dst,
HTTPClient: srv.Client(),
})
if err == nil {
t.Fatal("expected error for 404")
}
if _, statErr := os.Stat(dst + ".part"); statErr == nil {
t.Errorf(".part should not exist after 404")
}
}

297
internal/sec/install.go Normal file
View File

@@ -0,0 +1,297 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
"github.com/larksuite/cli/internal/client"
)
// Installer orchestrates first-time install of lark-sec-cli:
// fetch remote manifest via OAPI → download zip → extract into
// versions/<version>/ → swap "current" → write state.json.
//
// After this first install, lark-sec-cli takes over its own updates and
// lark-cli is no longer in the update path. The installer therefore only
// knows about the bootstrap path — no Tron, no other release sources.
type Installer struct {
Paths *Paths
HTTPClient *http.Client
// APIClientFunc resolves the OAPI client lazily. It is invoked only when
// the install pipeline actually needs to fetch the remote manifest —
// short-circuits (and other callers of installer() that don't install,
// like sec status / sec stop) avoid keychain decryption entirely.
APIClientFunc func() (*client.APIClient, error)
}
// InstallOptions tunes a single Install call.
type InstallOptions struct {
// Force re-runs the pipeline even when an install already exists. Used by
// `sec install --force` for repair / re-pinning to the bundled bootstrap.
Force bool
// Region selects which region's URLs to pick from the manifest. Defaults to
// DefaultRegion ("cn"). Reserved for future brand split.
Region string
// Verbose, when non-nil, is the destination for step-by-step trace output.
// nil = silent (production default); typically set to stderr by `sec install -v`.
Verbose io.Writer
}
// tracef writes one trace line to w if w is non-nil.
func tracef(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, "[sec install] "+format+"\n", args...)
}
// Install runs the bootstrap pipeline and returns the new State on success.
// If a usable install already exists on disk and Force is false, returns the
// existing state unchanged (no network call).
func (i *Installer) Install(ctx context.Context, opts InstallOptions) (*State, error) {
v := opts.Verbose
tracef(v, "ensuring sec paths under %s", i.Paths.InstallDir())
if err := i.Paths.Ensure(); err != nil {
return nil, err
}
tracef(v, "loading existing state from %s", i.Paths.StateFile())
existing, err := LoadState(i.Paths.StateFile())
if err != nil {
return nil, fmt.Errorf("load sec state: %w", err)
}
if existing != nil {
tracef(v, "existing state: version=%s binary=%s", existing.Version, existing.BinaryPath)
} else {
tracef(v, "no existing state on disk")
}
// Idempotent short-circuit: nothing to do if an install is already on disk.
// Self-upgrades after bootstrap are lark-sec-cli's job, not ours — see the
// upgrade subsystem in lark-sec-cli/internal/upgrade/.
if !opts.Force && existing != nil && binaryReady(existing.BinaryPath) {
tracef(v, "binary exists at %s — short-circuiting (no network)", existing.BinaryPath)
return existing, nil
}
if opts.Force {
tracef(v, "--force set; running full install pipeline")
} else {
tracef(v, "no usable install on disk; running full install pipeline")
}
region := opts.Region
if region == "" {
region = DefaultRegion
}
tracef(v, "region=%s", region)
if i.APIClientFunc == nil {
return nil, errors.New("sec installer: APIClientFunc is required to fetch remote manifest")
}
tracef(v, "resolving OAPI client (will decrypt credentials)")
apiClient, err := i.APIClientFunc()
if err != nil {
return nil, fmt.Errorf("resolve api client: %w", err)
}
platform, arch, err := CurrentPlatformArch()
if err != nil {
return nil, err
}
tracef(v, "detected platform=%s arch=%s", platform, arch)
tracef(v, "fetching remote manifest from %s", secCliManifestPath)
rm, err := FetchRemoteManifest(ctx, apiClient, region, platform, arch, v)
if err != nil {
return nil, err
}
tracef(v, "manifest returned %d url(s): %v", len(rm.URLs), rm.URLs)
downloadURL := rm.URLs[0]
tracef(v, "picked downloadURL=%s", downloadURL)
version, err := versionFromURL(downloadURL)
if err != nil {
return nil, err
}
tracef(v, "parsed version=%s", version)
versionDir := i.Paths.VersionDir(version)
tracef(v, "creating versionDir=%s", versionDir)
if err := os.MkdirAll(versionDir, 0o755); err != nil {
return nil, err
}
zipPath := filepath.Join(i.Paths.VersionsDir(), version+".zip")
tracef(v, "downloading %s -> %s", downloadURL, zipPath)
if err := Download(ctx, DownloadOptions{
URL: downloadURL,
Destination: zipPath,
HTTPClient: i.HTTPClient,
}); err != nil {
return nil, err
}
if info, statErr := os.Stat(zipPath); statErr == nil {
tracef(v, "downloaded %d bytes", info.Size())
}
defer os.Remove(zipPath) // free disk; we keep the unpacked version dir
tracef(v, "extracting %s -> %s", zipPath, versionDir)
if err := ExtractZip(zipPath, versionDir); err != nil {
return nil, err
}
binaryPath, err := locateBinary(versionDir)
if err != nil {
return nil, err
}
tracef(v, "located binary at %s", binaryPath)
// Ensure executable bit on POSIX — some zips lose it.
if runtime.GOOS != "windows" {
if info, err := os.Stat(binaryPath); err == nil {
_ = os.Chmod(binaryPath, info.Mode()|0o100|0o010|0o001)
}
}
tracef(v, "swapping %s -> %s", i.Paths.CurrentLink(), versionDir)
if err := swapCurrent(i.Paths.CurrentLink(), versionDir); err != nil {
return nil, fmt.Errorf("swap current: %w", err)
}
tracef(v, "writing state.json to %s", i.Paths.StateFile())
state := &State{
Version: version,
InstalledAt: time.Now().UTC(),
BinaryPath: i.Paths.BinaryPath(),
}
if err := SaveState(i.Paths.StateFile(), state); err != nil {
return nil, err
}
return state, nil
}
// locateBinary handles two artifact layouts: flat (zip root has the binary)
// and nested (zip root is a single dir containing the binary). The bootstrap
// manifest's example payload uses nested ("linux-amd64-1.0.1-alpha.23/...");
// we accommodate either since the wrapping dir name could change per build.
func locateBinary(versionDir string) (string, error) {
name := BinaryName()
flat := filepath.Join(versionDir, name)
if _, err := os.Stat(flat); err == nil {
return flat, nil
}
var found string
walkErr := filepath.WalkDir(versionDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && d.Name() == name {
found = path
return fs.SkipAll
}
return nil
})
if walkErr != nil {
return "", walkErr
}
if found == "" {
return "", fmt.Errorf("binary %q not found under %s", name, versionDir)
}
// Promote the binary's parent to be versionDir so "current" → versionDir
// produces a predictable layout. Move the *contents* up rather than the
// binary alone, because shared libs may sit beside it.
parent := filepath.Dir(found)
if parent != versionDir {
entries, err := os.ReadDir(parent)
if err != nil {
return "", err
}
for _, e := range entries {
if err := os.Rename(filepath.Join(parent, e.Name()), filepath.Join(versionDir, e.Name())); err != nil {
return "", err
}
}
_ = os.Remove(parent)
}
return filepath.Join(versionDir, name), nil
}
// swapCurrent atomically points <install>/current at versionDir. On POSIX
// we use a symlink with the standard rename-into-place trick; on Windows we
// fall back to removing the directory and copying, since junctions need
// admin / developer-mode privileges we may not have.
func swapCurrent(link, versionDir string) error {
if runtime.GOOS == "windows" {
// Remove any existing target then copy. This is non-atomic, but
// concurrent installs on the same Windows host are not a use case
// we support — `sec install` runs interactively.
if err := os.RemoveAll(link); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return copyDir(versionDir, link)
}
tmp := link + ".new"
_ = os.Remove(tmp)
if err := os.Symlink(versionDir, tmp); err != nil {
return err
}
return os.Rename(tmp, link)
}
func copyDir(src, dst string) error {
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
info, err := d.Info()
if err != nil {
return err
}
in, err := os.Open(p)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
if err != nil {
return err
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
})
}
func binaryReady(path string) bool {
if path == "" {
return false
}
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}

139
internal/sec/manifest.go Normal file
View File

@@ -0,0 +1,139 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"fmt"
"runtime"
)
// Manifest describes a lark-sec-cli release set: one Entry per build platform,
// each carrying one or more region-scoped URL maps keyed by arch. It's what we
// embed at build time as the bootstrap manifest. After bootstrap, lark-sec-cli
// queries its own release source for updates — lark-cli is uninvolved.
type Manifest struct {
Entries []Entry
}
// Entry is one row of the bootstrap manifest, one per published platform.
type Entry struct {
Key int `json:"key"`
BuildPlatform string `json:"buildPlatform"` // "darwin" | "linux" | "win32"
URLs []RegionURLs `json:"urls"`
Branch string `json:"branch"`
Version string `json:"version"`
Extra EntryExtra `json:"extra"`
}
// RegionURLs maps an arch ("amd64", "arm64", "x86") to its download URL,
// scoped to a region ("cn" today; reserved for future brand split).
type RegionURLs struct {
URLs map[string]string `json:"urls"`
Region string `json:"region"`
}
// EntryExtra is metadata the release pipeline emits alongside each artifact.
// PipelineID is the build identifier lark-sec-cli will later forward to its
// own update server when checking for new versions. SHA256 (when present) is
// the hex-encoded hash of the zip artifact; the installer fails the download
// on mismatch. Manifests built before the release pipeline added the field
// leave it empty, in which case integrity falls back to the CDN's own
// Content-MD5 header.
type EntryExtra struct {
PipelineID string `json:"pipeline_id"`
UploadDate int64 `json:"upload_date"`
SHA256 string `json:"sha256,omitempty"`
}
// Artifact is the resolved download target after platform/arch/region selection.
type Artifact struct {
URL string
Version string
BuildID string // pipeline_id — recorded in state.json so lark-sec-cli knows what it was installed at
SHA256 string // hex-encoded; empty when the manifest doesn't carry one
}
// PickArtifact selects the right Entry for the current GOOS/GOARCH and the
// requested region. Returns a clear error explaining which combination was
// missing so users can tell whether the build was never published or just not
// for their platform.
func (m *Manifest) PickArtifact(goos, goarch, region string) (*Artifact, error) {
platform, err := platformKey(goos)
if err != nil {
return nil, err
}
arch, err := archKey(goos, goarch)
if err != nil {
return nil, err
}
for _, e := range m.Entries {
if e.BuildPlatform != platform {
continue
}
for _, ru := range e.URLs {
if ru.Region != region {
continue
}
url, ok := ru.URLs[arch]
if !ok || url == "" {
continue
}
return &Artifact{
URL: url,
Version: e.Version,
BuildID: e.Extra.PipelineID,
SHA256: e.Extra.SHA256,
}, nil
}
}
return nil, fmt.Errorf("no artifact for platform=%s arch=%s region=%s", platform, arch, region)
}
// platformKey maps Go's GOOS to the manifest's buildPlatform enum.
func platformKey(goos string) (string, error) {
switch goos {
case "darwin":
return "darwin", nil
case "linux":
return "linux", nil
case "windows":
return "win32", nil
default:
return "", fmt.Errorf("unsupported GOOS: %s", goos)
}
}
// archKey maps Go's GOARCH to the arch key the manifest uses inside RegionURLs.URLs.
// Windows 32-bit ships under "x86" while POSIX 32-bit (e.g. 386 on linux) is not
// currently published — surface that as an error rather than silently falling back.
func archKey(goos, goarch string) (string, error) {
switch goarch {
case "amd64":
return "amd64", nil
case "arm64":
return "arm64", nil
case "386":
if goos == "windows" {
return "x86", nil
}
return "", fmt.Errorf("32-bit %s is not published", goos)
default:
return "", fmt.Errorf("unsupported GOARCH: %s", goarch)
}
}
// CurrentPlatformArch is a convenience for the install flow.
func CurrentPlatformArch() (platform, arch string, err error) {
platform, err = platformKey(runtime.GOOS)
if err != nil {
return "", "", err
}
arch, err = archKey(runtime.GOOS, runtime.GOARCH)
return platform, arch, err
}
// DefaultRegion is the only region published today for bootstrap installs.
// Kept here for callers that still want a single source of truth.
const DefaultRegion = "cn"

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"testing"
)
// sampleManifest is the manifest example baked into bootstrap.json, trimmed to
// the three published platforms. PickArtifact must select the right URL for
// each GOOS/GOARCH combination.
func sampleManifest() *Manifest {
return &Manifest{Entries: []Entry{
{
Key: 0,
BuildPlatform: "linux",
Branch: "dev",
Version: "1.0.1-alpha.23",
Extra: EntryExtra{PipelineID: "367354993"},
URLs: []RegionURLs{{
Region: "cn",
URLs: map[string]string{
"amd64": "https://cdn/linux-amd64.zip",
"arm64": "https://cdn/linux-arm64.zip",
},
}},
},
{
Key: 1,
BuildPlatform: "win32",
Branch: "dev",
Version: "1.0.1-alpha.23",
Extra: EntryExtra{PipelineID: "367354993"},
URLs: []RegionURLs{{
Region: "cn",
URLs: map[string]string{
"x86": "https://cdn/win-386.zip",
"amd64": "https://cdn/win-amd64.zip",
},
}},
},
{
Key: 2,
BuildPlatform: "darwin",
Branch: "dev",
Version: "1.0.1-alpha.23",
Extra: EntryExtra{PipelineID: "367354993"},
URLs: []RegionURLs{{
Region: "cn",
URLs: map[string]string{
"amd64": "https://cdn/darwin-amd64.zip",
"arm64": "https://cdn/darwin-arm64.zip",
},
}},
},
}}
}
func TestPickArtifact_HappyPath(t *testing.T) {
m := sampleManifest()
cases := []struct {
goos, goarch string
wantURL string
}{
{"darwin", "arm64", "https://cdn/darwin-arm64.zip"},
{"darwin", "amd64", "https://cdn/darwin-amd64.zip"},
{"linux", "amd64", "https://cdn/linux-amd64.zip"},
{"linux", "arm64", "https://cdn/linux-arm64.zip"},
{"windows", "amd64", "https://cdn/win-amd64.zip"},
{"windows", "386", "https://cdn/win-386.zip"},
}
for _, c := range cases {
t.Run(c.goos+"/"+c.goarch, func(t *testing.T) {
art, err := m.PickArtifact(c.goos, c.goarch, "cn")
if err != nil {
t.Fatalf("PickArtifact: %v", err)
}
if art.URL != c.wantURL {
t.Errorf("URL = %q, want %q", art.URL, c.wantURL)
}
if art.Version != "1.0.1-alpha.23" {
t.Errorf("Version = %q", art.Version)
}
if art.BuildID != "367354993" {
t.Errorf("BuildID = %q", art.BuildID)
}
})
}
}
func TestPickArtifact_Linux386Rejected(t *testing.T) {
if _, err := sampleManifest().PickArtifact("linux", "386", "cn"); err == nil {
t.Fatal("expected error for linux/386 (not published)")
}
}
func TestPickArtifact_UnknownRegion(t *testing.T) {
if _, err := sampleManifest().PickArtifact("darwin", "arm64", "sg"); err == nil {
t.Fatal("expected error for region=sg (not present in fixture)")
}
}
func TestPickArtifact_UnsupportedOS(t *testing.T) {
if _, err := sampleManifest().PickArtifact("plan9", "amd64", "cn"); err == nil {
t.Fatal("expected error for plan9")
}
}

146
internal/sec/paths.go Normal file
View File

@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package sec manages the first-time bootstrap install of the lark-sec-cli
// sidecar from lark-cli's side: download the artifact, lay it out on disk,
// record what version landed. Runtime lifecycle (start / stop / status) is
// handled by shelling out to lark-sec-cli's own `service enable / disable /
// status` commands, so we don't need pid files / env capture / log tees here.
// Updates after install are lark-sec-cli's responsibility, not lark-cli's.
package sec
import (
"errors"
"os"
"path/filepath"
"runtime"
)
const (
// envInstallDirOverride lets tests and power users redirect the entire sec
// tree (install + data) to a single root. When set, install_dir is <root>
// and data_dir is <root>/data — no platform-conventional lookup happens.
envInstallDirOverride = "LARKSUITE_CLI_SEC_DIR"
)
// BinaryName returns the executable basename inside the sec-cli artifact for
// the current platform:
//
// darwin → libLarkEntCli.dylib
// linux → liblarkentcli.so
// windows → lark_enterprise_cli.exe
//
// The .dylib/.so extensions on POSIX are convention only — those files are
// normal Mach-O / ELF executables, not loadable libraries.
func BinaryName() string {
switch runtime.GOOS {
case "darwin":
return "libLarkEntCli.dylib"
case "windows":
return "lark_enterprise_cli.exe"
default:
return "liblarkentcli.so"
}
}
// Paths exposes the filesystem layout for the sec sidecar. All methods return
// absolute paths; nothing on disk is created — callers must call Ensure().
type Paths struct {
install string
data string
}
// DefaultPaths returns Paths rooted at the platform-conventional user data dir,
// or at $LARKSUITE_CLI_SEC_DIR when set.
func DefaultPaths() (*Paths, error) {
if root := os.Getenv(envInstallDirOverride); root != "" {
return &Paths{install: root, data: filepath.Join(root, "data")}, nil
}
install, data, err := platformDirs()
if err != nil {
return nil, err
}
return &Paths{install: install, data: data}, nil
}
// platformDirs returns (install_dir, data_dir) for the current OS, applying
// per-platform conventions:
//
// macOS install = data = ~/Library/Application Support/lark-cli/sec
// Linux install = $XDG_DATA_HOME/lark-cli/sec (fallback ~/.local/share/...)
// data = $XDG_STATE_HOME/lark-cli/sec (fallback ~/.local/state/...)
// Windows install = data = %LOCALAPPDATA%\lark-cli\sec
//
// Linux splits install/data along XDG lines; macOS and Windows colocate them
// because their conventions don't distinguish "share" from "state" at the
// per-user level.
func platformDirs() (install, data string, err error) {
home, err := os.UserHomeDir()
if err != nil {
return "", "", err
}
switch runtime.GOOS {
case "darwin":
base := filepath.Join(home, "Library", "Application Support", "lark-cli", "sec")
return base, filepath.Join(base, "data"), nil
case "windows":
appData := os.Getenv("LOCALAPPDATA")
if appData == "" {
return "", "", errors.New("LOCALAPPDATA is not set")
}
base := filepath.Join(appData, "lark-cli", "sec")
return base, filepath.Join(base, "data"), nil
case "linux":
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
dataHome = filepath.Join(home, ".local", "share")
}
stateHome := os.Getenv("XDG_STATE_HOME")
if stateHome == "" {
stateHome = filepath.Join(home, ".local", "state")
}
return filepath.Join(dataHome, "lark-cli", "sec"),
filepath.Join(stateHome, "lark-cli", "sec"),
nil
default:
base := filepath.Join(home, ".lark-cli", "sec")
return base, filepath.Join(base, "data"), nil
}
}
// Ensure creates the directories the installer writes into.
func (p *Paths) Ensure() error {
for _, d := range []string{p.install, p.data, p.VersionsDir()} {
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
return nil
}
// InstallDir is the root for binaries and version trees.
func (p *Paths) InstallDir() string { return p.install }
// DataDir is the root for state.json (and anything else lark-cli persists
// about the install — currently just state.json).
func (p *Paths) DataDir() string { return p.data }
// VersionsDir stores each unpacked release: versions/<version>/<files>.
func (p *Paths) VersionsDir() string { return filepath.Join(p.install, "versions") }
// VersionDir is the unpack target for a specific version string.
func (p *Paths) VersionDir(version string) string {
return filepath.Join(p.VersionsDir(), version)
}
// CurrentLink points to the active version (symlink on POSIX, plain copy on Windows).
func (p *Paths) CurrentLink() string { return filepath.Join(p.install, "current") }
// BinaryPath is the active sec-cli executable, addressed through the
// `current` symlink so it stays valid across version swaps.
func (p *Paths) BinaryPath() string {
return filepath.Join(p.CurrentLink(), BinaryName())
}
// StateFile records what version is installed and where its binary lives.
func (p *Paths) StateFile() string { return filepath.Join(p.data, "state.json") }

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDefaultPaths_OverrideViaEnv(t *testing.T) {
dir := t.TempDir()
t.Setenv(envInstallDirOverride, dir)
p, err := DefaultPaths()
if err != nil {
t.Fatalf("DefaultPaths: %v", err)
}
if p.InstallDir() != dir {
t.Errorf("InstallDir = %q, want %q", p.InstallDir(), dir)
}
if p.DataDir() != filepath.Join(dir, "data") {
t.Errorf("DataDir = %q, want %s/data", p.DataDir(), dir)
}
if !strings.HasPrefix(p.StateFile(), dir) {
t.Errorf("StateFile not under override root: %q", p.StateFile())
}
}
func TestPaths_Ensure(t *testing.T) {
dir := t.TempDir()
t.Setenv(envInstallDirOverride, dir)
p, err := DefaultPaths()
if err != nil {
t.Fatalf("DefaultPaths: %v", err)
}
if err := p.Ensure(); err != nil {
t.Fatalf("Ensure: %v", err)
}
for _, d := range []string{p.InstallDir(), p.DataDir(), p.VersionsDir()} {
info, err := os.Stat(d)
if err != nil {
t.Errorf("missing %s: %v", d, err)
continue
}
if !info.IsDir() {
t.Errorf("%s is not a directory", d)
}
}
}

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
// secCliManifestPath is the OAPI endpoint that returns the per-platform
// download URLs for lark-sec-cli, gated by tenant_access_token.
const secCliManifestPath = "/open-apis/security_plugin/v1/sec_cli/manifest"
// xTTEnvEnv, when set, injects an x-tt-env header on the manifest request.
// Used for BOE / sub-environment routing (e.g. value "boe_tns_api"). Unset
// in prod — the gateway treats absence as "no override". This is the only
// debug-routing knob in this file; brand/domain switching itself is handled
// at the network layer via the lark-env.sh Whistle pattern in the
// lark-cli maintainer doc.
const xTTEnvEnv = "LARKSUITE_CLI_X_TT_ENV"
// RemoteManifest is the payload returned by GET /open-apis/security_plugin/v1/sec_cli/manifest
// for a single (region, platform, arch) combination. The server returns only
// the download URLs; version metadata is parsed from the URL itself (see
// versionFromURL).
type RemoteManifest struct {
URLs []string `json:"urls"`
}
// FetchRemoteManifest calls the OAPI manifest endpoint with TAT (bot) auth
// and returns the typed payload for the given region/platform/arch. When the
// LARKSUITE_CLI_X_TT_ENV env var is set, its value is sent as an x-tt-env
// request header for sub-environment routing.
//
// Errors are returned as-is — there is no fallback to the embedded
// bootstrap manifest. Callers that need offline behavior must handle that
// explicitly.
func FetchRemoteManifest(
ctx context.Context,
ac *client.APIClient,
region, platform, arch string,
verbose io.Writer,
) (*RemoteManifest, error) {
req := &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: secCliManifestPath,
QueryParams: larkcore.QueryParams{
"region": []string{region},
"platform": []string{platform},
"arch": []string{arch},
},
}
tracef(verbose, "GET %s?region=%s&platform=%s&arch=%s as=bot", secCliManifestPath, region, platform, arch)
var extraOpts []larkcore.RequestOptionFunc
if v := os.Getenv(xTTEnvEnv); v != "" {
h := http.Header{}
h.Set("x-tt-env", v)
extraOpts = append(extraOpts, larkcore.WithHeaders(h))
tracef(verbose, "injecting header x-tt-env=%s (from %s)", v, xTTEnvEnv)
}
resp, err := ac.DoSDKRequest(ctx, req, core.AsBot, extraOpts...)
if err != nil {
return nil, fmt.Errorf("sec_cli manifest request: %w", err)
}
tracef(verbose, "response status=%d body-len=%d body=%q", resp.StatusCode, len(resp.RawBody), string(resp.RawBody))
var env struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data *RemoteManifest `json:"data"`
}
if err := json.Unmarshal(resp.RawBody, &env); err != nil {
// Print body unconditionally on decode failure — a malformed response is
// the most common case where the caller needs to see exactly what arrived.
fmt.Fprintf(os.Stderr, "[sec_cli manifest] decode failed; status=%d len=%d body=%q\n", resp.StatusCode, len(resp.RawBody), string(resp.RawBody))
return nil, fmt.Errorf("sec_cli manifest decode: %w", err)
}
if env.Code != 0 {
return nil, fmt.Errorf("sec_cli manifest error %d: %s", env.Code, env.Msg)
}
if env.Data == nil || len(env.Data.URLs) == 0 {
return nil, fmt.Errorf("sec_cli manifest: no urls for region=%s platform=%s arch=%s", region, platform, arch)
}
return env.Data, nil
}
// versionFromURL extracts the release version from a download URL of the form
// .../releases/<version>/<pipeline-id>/<platform-arch>/<archive>.zip
// The server-side manifest does not return version as a discrete field;
// state.json's Version needs *something* to disambiguate concurrent installs
// in versions/<version>/, so we parse it out here.
var releaseVersionRE = regexp.MustCompile(`/releases/([^/]+)/`)
func versionFromURL(u string) (string, error) {
m := releaseVersionRE.FindStringSubmatch(u)
if len(m) < 2 || m[1] == "" {
return "", fmt.Errorf("could not parse release version from URL %q", u)
}
return m[1], nil
}

79
internal/sec/state.go Normal file
View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"encoding/json"
"errors"
"fmt"
"os"
"time"
)
// State is the JSON document at <data>/state.json describing the currently
// installed lark-sec-cli artifact. It is the source of truth for what binary
// to launch. After bootstrap install lark-sec-cli may upgrade itself in
// place — when that happens this state file is informational only; the
// daemon owns its own canonical version state.
type State struct {
Version string `json:"version"`
BuildID string `json:"build_id"`
InstalledAt time.Time `json:"installed_at"`
BinaryPath string `json:"binary_path"`
}
// LoadState reads state.json. Returns (nil, nil) when the file is absent —
// callers treat that as "not yet installed". Decode errors are surfaced
// so a corrupt file is never silently overwritten.
func LoadState(path string) (*State, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var s State
if err := json.Unmarshal(data, &s); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &s, nil
}
// SaveState writes state.json atomically: a tmpfile next to the target is
// fsynced then renamed in, so concurrent readers either see the previous
// state or the new one — never a torn write.
func SaveState(path string, s *State) error {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(dirOf(path), ".state-*.json")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op after a successful Rename
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}
func dirOf(path string) string {
for i := len(path) - 1; i >= 0; i-- {
if path[i] == '/' || path[i] == '\\' {
return path[:i]
}
}
return "."
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sec
import (
"path/filepath"
"testing"
"time"
)
func TestSaveLoadState_Roundtrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "state.json")
in := &State{
Version: "1.2.3",
BuildID: "build-42",
InstalledAt: time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC),
BinaryPath: "/tmp/lark-sec-cli",
}
if err := SaveState(path, in); err != nil {
t.Fatalf("SaveState: %v", err)
}
got, err := LoadState(path)
if err != nil {
t.Fatalf("LoadState: %v", err)
}
if got == nil {
t.Fatal("LoadState returned nil")
}
if got.Version != in.Version || got.BuildID != in.BuildID || got.BinaryPath != in.BinaryPath {
t.Errorf("roundtrip mismatch: got=%+v want=%+v", got, in)
}
if !got.InstalledAt.Equal(in.InstalledAt) {
t.Errorf("InstalledAt mismatch: got=%v want=%v", got.InstalledAt, in.InstalledAt)
}
}
func TestLoadState_AbsentFile(t *testing.T) {
got, err := LoadState(filepath.Join(t.TempDir(), "missing.json"))
if err != nil {
t.Fatalf("expected nil error for missing file, got %v", err)
}
if got != nil {
t.Errorf("expected nil state for missing file, got %+v", got)
}
}

View File

@@ -0,0 +1,135 @@
# secplugin Usage Guide
Chinese version: see `README.zh-CN.md`.
`secplugin` enables a secure proxy mode for the CLI. It forces outbound HTTP(S)
requests to go through a local security proxy and can optionally trust an
additional CA certificate bundle.
It supports two configuration methods:
1. `sec_config.json`
2. `LARKSUITE_CLI_SEC_*` environment variables
## Config File Location
Default config file path:
```text
~/.lark-cli/sec_config.json
```
If `LARKSUITE_CLI_CONFIG_DIR` is set, the path becomes:
```text
$LARKSUITE_CLI_CONFIG_DIR/sec_config.json
```
## Option 1: Config File
Put the following content into `sec_config.json`:
```json
{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem",
"LARKSUITE_CLI_SEC_AUTH": true,
"LARKSUITE_CLI_APP_ID": "cli_xxx",
"LARKSUITE_CLI_BRAND": "feishu",
"LARKSUITE_CLI_DEFAULT_AS": "bot",
"LARKSUITE_CLI_STRICT_MODE": "bot"
}
```
Field descriptions:
- `LARKSUITE_CLI_SEC_ENABLE`: Enables secplugin. Boolean values are supported.
- `LARKSUITE_CLI_SEC_PROXY`: Local HTTP proxy address. It must be `http://127.0.0.1:<port>`.
- `LARKSUITE_CLI_SEC_CA`: Absolute path to an extra trusted root CA PEM file. Leave empty if not needed.
- `LARKSUITE_CLI_SEC_AUTH`: Enables proxy-injected token mode.
- `LARKSUITE_CLI_APP_ID`: Optional app ID used in `SEC_AUTH` mode.
- `LARKSUITE_CLI_BRAND`: Optional, must be `feishu` or `lark`.
- `LARKSUITE_CLI_DEFAULT_AS`: Optional, must be `user`, `bot`, or `auto`.
- `LARKSUITE_CLI_STRICT_MODE`: Optional, must be `user`, `bot`, or `off`.
## Option 2: Environment Variables
You can also enable secplugin directly with environment variables without
creating `sec_config.json`:
```bash
export LARKSUITE_CLI_SEC_ENABLE=true
export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128
export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem
export LARKSUITE_CLI_SEC_AUTH=true
```
If you want to provide app metadata in `SEC_AUTH` mode, set these as well:
```bash
export LARKSUITE_CLI_APP_ID=cli_xxx
export LARKSUITE_CLI_BRAND=feishu
export LARKSUITE_CLI_DEFAULT_AS=bot
export LARKSUITE_CLI_STRICT_MODE=bot
```
## Precedence
The following environment variables override the corresponding fields in
`sec_config.json` when they are present:
- `LARKSUITE_CLI_SEC_ENABLE`
- `LARKSUITE_CLI_SEC_PROXY`
- `LARKSUITE_CLI_SEC_CA`
- `LARKSUITE_CLI_SEC_AUTH`
- `LARKSUITE_CLI_APP_ID`
- `LARKSUITE_CLI_BRAND`
- `LARKSUITE_CLI_DEFAULT_AS`
- `LARKSUITE_CLI_STRICT_MODE`
This means:
- Put stable defaults in `sec_config.json`.
- Use environment variables for temporary overrides.
- SEC-related environment variables can work even without a config file.
## SEC_AUTH Mode
The CLI enters `SEC_AUTH` mode when both of the following are true:
```text
LARKSUITE_CLI_SEC_ENABLE=true
LARKSUITE_CLI_SEC_AUTH=true
```
In this mode, the CLI does not read real tokens directly. Instead, it returns
placeholder tokens and expects the proxy to replace them with real credentials.
App information is resolved in this order:
1. `LARKSUITE_CLI_APP_ID` and `LARKSUITE_CLI_BRAND` from environment variables
2. The same fields in `sec_config.json`
3. The active profile in the regular CLI `config.json`
If no valid app information can be resolved from any source, the command fails.
## Constraints
- `LARKSUITE_CLI_SEC_PROXY` must use the `http` scheme only.
- The host of `LARKSUITE_CLI_SEC_PROXY` must be `127.0.0.1`.
- `LARKSUITE_CLI_SEC_PROXY` must not contain a path.
- `LARKSUITE_CLI_SEC_CA` must be an absolute path to a PEM file.
- Boolean values support `true/false`, `1/0`, `on/off`, `yes/no`, and `y/n`.
## Recommendations
For long-term stable setup, prefer `sec_config.json`:
- Good for developer machines or controlled environments.
- Avoids repeatedly injecting environment variables into the shell.
For temporary debugging, prefer environment variables:
- Good for switching proxy or CA for just one session.
- No need to modify files on disk.

View File

@@ -0,0 +1,130 @@
# secplugin 使用说明
English version: see `README.md`.
`secplugin` 用于开启安全代理模式,让 CLI 的 HTTP(S) 请求固定走本地安全代理,并按需信任额外 CA 证书。
支持两种配置方式:
1. `sec_config.json`
2. `LARKSUITE_CLI_SEC_*` 环境变量
## 配置文件位置
默认配置文件路径:
```text
~/.lark-cli/sec_config.json
```
如果设置了 `LARKSUITE_CLI_CONFIG_DIR`,则配置文件路径变为:
```text
$LARKSUITE_CLI_CONFIG_DIR/sec_config.json
```
## 方式一:使用配置文件
`sec_config.json` 中写入:
```json
{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "/absolute/path/to/proxy-ca.pem",
"LARKSUITE_CLI_SEC_AUTH": true,
"LARKSUITE_CLI_APP_ID": "cli_xxx",
"LARKSUITE_CLI_BRAND": "feishu",
"LARKSUITE_CLI_DEFAULT_AS": "bot",
"LARKSUITE_CLI_STRICT_MODE": "bot"
}
```
字段说明:
- `LARKSUITE_CLI_SEC_ENABLE`: 是否启用 secplugin支持布尔值。
- `LARKSUITE_CLI_SEC_PROXY`: 本地 HTTP 代理地址,必须是 `http://127.0.0.1:<port>`
- `LARKSUITE_CLI_SEC_CA`: 额外信任的根证书 PEM 文件绝对路径;不需要时可留空。
- `LARKSUITE_CLI_SEC_AUTH`: 是否启用代理注入 token 模式。
- `LARKSUITE_CLI_APP_ID`: 可选,`SEC_AUTH` 模式下使用的应用 ID。
- `LARKSUITE_CLI_BRAND`: 可选,取值为 `feishu``lark`
- `LARKSUITE_CLI_DEFAULT_AS`: 可选,取值为 `user``bot``auto`
- `LARKSUITE_CLI_STRICT_MODE`: 可选,取值为 `user``bot``off`
## 方式二:使用环境变量
也可以不写 `sec_config.json`,直接通过环境变量启用:
```bash
export LARKSUITE_CLI_SEC_ENABLE=true
export LARKSUITE_CLI_SEC_PROXY=http://127.0.0.1:3128
export LARKSUITE_CLI_SEC_CA=/absolute/path/to/proxy-ca.pem
export LARKSUITE_CLI_SEC_AUTH=true
```
如果你在 `SEC_AUTH` 模式下希望同时提供应用信息,也可以继续设置:
```bash
export LARKSUITE_CLI_APP_ID=cli_xxx
export LARKSUITE_CLI_BRAND=feishu
export LARKSUITE_CLI_DEFAULT_AS=bot
export LARKSUITE_CLI_STRICT_MODE=bot
```
## 配置优先级
以下环境变量存在时,会覆盖 `sec_config.json` 中对应字段:
- `LARKSUITE_CLI_SEC_ENABLE`
- `LARKSUITE_CLI_SEC_PROXY`
- `LARKSUITE_CLI_SEC_CA`
- `LARKSUITE_CLI_SEC_AUTH`
- `LARKSUITE_CLI_APP_ID`
- `LARKSUITE_CLI_BRAND`
- `LARKSUITE_CLI_DEFAULT_AS`
- `LARKSUITE_CLI_STRICT_MODE`
也就是说:
- 你可以把默认值写进 `sec_config.json`
- 再用环境变量做临时覆盖。
- 如果没有配置文件,但设置了 SEC 相关环境变量,也可以正常工作。
## SEC_AUTH 模式说明
当同时满足以下条件时CLI 会进入 `SEC_AUTH` 模式:
```text
LARKSUITE_CLI_SEC_ENABLE=true
LARKSUITE_CLI_SEC_AUTH=true
```
此时 CLI 不直接读取真实 token而是返回占位 token由代理替换成真实凭证。
应用信息来源优先级如下:
1. 环境变量中的 `LARKSUITE_CLI_APP_ID``LARKSUITE_CLI_BRAND`
2. `sec_config.json` 中的同名字段
3. 常规 CLI 配置文件 `config.json` 的当前 profile
如果以上来源都拿不到可用应用信息,命令会报错。
## 参数约束
- `LARKSUITE_CLI_SEC_PROXY` 只允许 `http` 协议。
- `LARKSUITE_CLI_SEC_PROXY` 的 host 必须是 `127.0.0.1`
- `LARKSUITE_CLI_SEC_PROXY` 不能带路径。
- `LARKSUITE_CLI_SEC_CA` 必须是 PEM 文件的绝对路径。
- 布尔值支持 `true/false``1/0``on/off``yes/no``y/n`
## 推荐用法
长期固定配置建议使用 `sec_config.json`
- 适合开发机或受控环境的稳定配置。
- 避免在 shell 中反复注入环境变量。
临时调试建议使用环境变量:
- 适合本次会话临时切换代理或证书。
- 不需要修改磁盘上的配置文件。

View File

@@ -0,0 +1,277 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package secplugin implements the ~/.lark-cli/sec_config.json based security proxy plugin mode.
//
// It supports:
// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy
// - trusting an additional root CA PEM bundle for MITM/inspection proxies
// - optional "proxy injects token" mode via placeholder tokens (SEC_AUTH)
//
// In sec plugin mode, certain common CLI env vars (APP_ID / BRAND / DEFAULT_AS /
// STRICT_MODE) can also be set in sec_config.json so sandboxes can avoid
// environment injection. When both are present, environment variables win.
package secplugin
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
// SEC plugin constants cover the config file name and placeholder token values.
const (
// ConfigFileName is the fixed config file name under core.GetConfigDir().
ConfigFileName = "sec_config.json"
// SentinelUAT is the placeholder user access token used in SEC_AUTH mode.
SentinelUAT = "secplugin-managed-uat"
// SentinelTAT is the placeholder tenant access token used in SEC_AUTH mode.
SentinelTAT = "secplugin-managed-tat"
)
// Config is the on-disk config format. Keys intentionally mirror env var names.
type Config struct {
// Enable turns on sec plugin transport handling.
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
// Proxy is the fixed HTTP proxy address used for all outbound requests.
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
// CAPath points to an extra PEM bundle trusted for proxy TLS interception.
CAPath string `json:"LARKSUITE_CLI_SEC_CA"`
// Auth enables placeholder-token mode for proxy-side credential injection.
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
// Optional defaults for sec plugin mode; env vars override these.
// AppID supplies the app ID when the environment does not set one.
AppID string `json:"LARKSUITE_CLI_APP_ID,omitempty"`
// Brand supplies the tenant brand when the environment does not set one.
Brand string `json:"LARKSUITE_CLI_BRAND,omitempty"` // feishu | lark
// DefaultAs supplies the default identity when the environment does not set one.
DefaultAs string `json:"LARKSUITE_CLI_DEFAULT_AS,omitempty"` // user | bot | auto
// StrictMode supplies the strict mode when the environment does not set one.
StrictMode string `json:"LARKSUITE_CLI_STRICT_MODE,omitempty"` // user | bot | off
}
// Path returns the absolute path to the sec plugin config file.
func Path() string {
return filepath.Join(core.GetConfigDir(), ConfigFileName)
}
// loadOnce guards one-time SEC config loading for process-wide transport reuse.
var loadOnce sync.Once
// loadCfg stores the cached SEC config after the first successful Load call.
var loadCfg *Config
// loadErr stores the cached Load error observed during the first load attempt.
var loadErr error
// Load reads ~/.lark-cli/sec_config.json once and caches the parsed result.
// Environment variables (CliSec*) take precedence over config file values.
//
// Returns (nil, nil) only when:
// - the config file does not exist AND
// - none of the SEC-related env vars are present.
func Load() (*Config, error) {
loadOnce.Do(func() {
// Start from env-only config if any SEC env var is present.
cfg, hasEnv, err := loadFromEnv()
if err != nil {
loadErr = err
return
}
p := Path()
if _, err := vfs.Stat(p); err != nil {
if errors.Is(err, os.ErrNotExist) {
// No file: return env-only config (if any), else nil.
if hasEnv {
loadCfg = cfg
} else {
loadCfg = nil
}
loadErr = nil
return
}
loadErr = fmt.Errorf("failed to stat sec plugin config %q: %w", p, err)
return
}
b, err := vfs.ReadFile(p)
if err != nil {
loadErr = fmt.Errorf("failed to read sec plugin config %q: %w", p, err)
return
}
var fileCfg Config
if err := json.Unmarshal(b, &fileCfg); err != nil {
loadErr = fmt.Errorf("invalid sec plugin config %q: %w", p, err)
return
}
// Merge: file base + env overrides.
if cfg == nil {
cfg = &fileCfg
} else {
*cfg = fileCfg
applyEnvOverrides(cfg)
}
loadCfg = cfg
})
return loadCfg, loadErr
}
// Enabled reports whether SEC plugin mode is enabled.
func (c *Config) Enabled() bool { return c != nil && c.Enable }
// AuthEnabled reports whether SEC_AUTH token placeholder mode is enabled.
func (c *Config) AuthEnabled() bool { return c != nil && c.Enable && c.Auth }
// loadFromEnv builds a config from SEC-related environment variables only.
// It reports whether any SEC-related environment variable was present.
func loadFromEnv() (*Config, bool, error) {
_, hasEnable := os.LookupEnv(envvars.CliSecEnable)
_, hasProxy := os.LookupEnv(envvars.CliSecProxy)
_, hasCA := os.LookupEnv(envvars.CliSecCA)
_, hasAuth := os.LookupEnv(envvars.CliSecAuth)
hasAny := hasEnable || hasProxy || hasCA || hasAuth
if !hasAny {
return nil, false, nil
}
cfg := &Config{}
if err := applyEnvOverrides(cfg); err != nil {
return nil, true, err
}
return cfg, true, nil
}
// applyEnvOverrides copies SEC-related environment variable values into cfg.
func applyEnvOverrides(cfg *Config) error {
if v, ok := os.LookupEnv(envvars.CliSecEnable); ok {
b, err := parseBoolEnv(envvars.CliSecEnable, v)
if err != nil {
return err
}
cfg.Enable = b
}
if v, ok := os.LookupEnv(envvars.CliSecAuth); ok {
b, err := parseBoolEnv(envvars.CliSecAuth, v)
if err != nil {
return err
}
cfg.Auth = b
}
if v, ok := os.LookupEnv(envvars.CliSecProxy); ok {
cfg.Proxy = v
}
if v, ok := os.LookupEnv(envvars.CliSecCA); ok {
cfg.CAPath = v
}
return nil
}
// parseBoolEnv accepts common boolean spellings used in environment variables.
func parseBoolEnv(name, raw string) (bool, error) {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
// Treat empty as false when explicitly present.
return false, nil
}
switch s {
case "1", "true", "on", "yes", "y":
return true, nil
case "0", "false", "off", "no", "n":
return false, nil
}
if b, err := strconv.ParseBool(s); err == nil {
return b, nil
}
return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw)
}
// proxyURL validates the fixed SEC proxy configuration and returns its URL.
func (c *Config) proxyURL() (*url.URL, error) {
raw := strings.TrimSpace(c.Proxy)
if raw == "" {
return nil, fmt.Errorf("%s is empty", envvars.CliSecProxy)
}
redacted := redactProxyURL(raw)
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid %s %q: %w", envvars.CliSecProxy, redacted, err)
}
if u.Scheme != "http" {
return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliSecProxy, redacted)
}
if u.Host == "" {
return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliSecProxy, redacted)
}
// Security hardening: only allow a loopback proxy. This prevents accidental
// cross-machine proxying of credentials/traffic.
if u.Hostname() != "127.0.0.1" {
return nil, fmt.Errorf("invalid %s %q: host must be 127.0.0.1", envvars.CliSecProxy, redacted)
}
if u.Port() == "" {
return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliSecProxy, redacted)
}
if u.Path != "" && u.Path != "/" {
return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliSecProxy, redacted)
}
if u.RawQuery != "" {
return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliSecProxy, redacted)
}
if u.Fragment != "" {
return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliSecProxy, redacted)
}
return u, nil
}
// redactProxyURL masks userinfo (username:password) in a proxy URL.
// Handles both scheme-prefixed ("http://user:pass@host") and bare formats.
func redactProxyURL(raw string) string {
u, err := url.Parse(raw)
if err == nil && u.User != nil {
u.User = url.User("***")
return u.String()
}
// Fallback: handle "user:pass@proxy:8080"
if at := strings.LastIndex(raw, "@"); at > 0 {
return "***@" + raw[at+1:]
}
return raw
}
// ApplyToTransport clones base and applies SEC plugin settings to the clone.
// Caller owns the returned *http.Transport.
func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) {
if base == nil {
base = http.DefaultTransport.(*http.Transport)
}
u, err := c.proxyURL()
if err != nil {
return nil, err
}
t := base.Clone()
t.Proxy = http.ProxyURL(u) // fixed proxy overrides environment proxy vars
if err := applyExtraRootCA(t, c.CAPath); err != nil {
return nil, err
}
return t, nil
}

View File

@@ -0,0 +1,245 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
// unsetEnv clears key for the duration of the test and restores its original value.
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, had := os.LookupEnv(key)
_ = os.Unsetenv(key)
t.Cleanup(func() {
if had {
_ = os.Setenv(key, old)
} else {
_ = os.Unsetenv(key)
}
})
}
// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests.
func unsetSecPluginEnv(t *testing.T) {
t.Helper()
unsetEnv(t, envvars.CliSecEnable)
unsetEnv(t, envvars.CliSecProxy)
unsetEnv(t, envvars.CliSecCA)
unsetEnv(t, envvars.CliSecAuth)
}
// writeFile creates parent directories and writes test data for fixtures.
func writeFile(t *testing.T, path string, data []byte, perm os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(path, data, perm); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
// TestLoad_MissingFileReturnsNil verifies that Load reports no config when no file
// or SEC environment overrides exist.
func TestLoad_MissingFileReturnsNil(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetSecPluginEnv(t)
// TestLoad_MissingFileReturnsNil must reset loadOnce, loadCfg, and loadErr
// because multiple tests in this package share the package-level Load()
// cache via sync.Once.
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg != nil {
t.Fatalf("Load() = %#v, want nil (missing file)", cfg)
}
}
// TestApplyToTransport_SetsProxy verifies that a valid SEC config installs a fixed proxy.
func TestApplyToTransport_SetsProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetSecPluginEnv(t)
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "",
"LARKSUITE_CLI_SEC_AUTH": false
}`), 0600)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
base := http.DefaultTransport.(*http.Transport)
tr, err := cfg.ApplyToTransport(base)
if err != nil {
t.Fatalf("ApplyToTransport() error = %v", err)
}
if tr.Proxy == nil {
t.Fatal("Proxy func is nil, want fixed proxy")
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil {
t.Fatalf("Proxy() error = %v", err)
}
if u == nil || u.String() != "http://127.0.0.1:3128" {
t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u)
}
}
// TestLoad_RejectsNonLoopbackProxy verifies that SEC mode rejects non-loopback proxies.
func TestLoad_RejectsNonLoopbackProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
unsetSecPluginEnv(t)
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://10.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "",
"LARKSUITE_CLI_SEC_AUTH": false
}`), 0600)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
_, err = cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
if err == nil {
t.Fatal("ApplyToTransport() error = nil, want invalid proxy host error")
}
}
// TestConfig_ProxyURLRejectsUnsupportedParts verifies the SEC proxy validator
// rejects URLs with missing ports, queries, and fragments.
func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{
name: "missing explicit port",
raw: "http://127.0.0.1",
want: "explicit port is required",
},
{
name: "query string",
raw: "http://127.0.0.1:3128?foo=bar",
want: "query is not allowed",
},
{
name: "fragment",
raw: "http://127.0.0.1:3128#frag",
want: "fragment is not allowed",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
_, err := (&Config{Proxy: tt.raw}).proxyURL()
if err == nil {
t.Fatalf("proxyURL() error = nil, want substring %q", tt.want)
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("proxyURL() error = %q, want substring %q", err, tt.want)
}
})
}
}
// TestLoad_EnvOnlyConfig verifies that SEC settings can come entirely from environment variables.
func TestLoad_EnvOnlyConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
t.Setenv(envvars.CliSecEnable, "true")
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:7777")
t.Setenv(envvars.CliSecCA, "")
t.Setenv(envvars.CliSecAuth, "true")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil || !cfg.Enabled() {
t.Fatalf("cfg.Enabled() = %v, want true", cfg)
}
if !cfg.AuthEnabled() {
t.Fatalf("cfg.AuthEnabled() = false, want true")
}
tr, err := cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport))
if err != nil {
t.Fatalf("ApplyToTransport() error = %v", err)
}
u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}})
if err != nil {
t.Fatalf("Proxy() error = %v", err)
}
if u == nil || u.String() != "http://127.0.0.1:7777" {
t.Fatalf("Proxy() = %v, want http://127.0.0.1:7777", u)
}
}
// TestLoad_EnvOverridesFile verifies that SEC environment variables override file values.
func TestLoad_EnvOverridesFile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
loadOnce = sync.Once{}
loadCfg = nil
loadErr = nil
// File enables with one proxy.
cfgPath := Path()
writeFile(t, cfgPath, []byte(`{
"LARKSUITE_CLI_SEC_ENABLE": true,
"LARKSUITE_CLI_SEC_PROXY": "http://127.0.0.1:3128",
"LARKSUITE_CLI_SEC_CA": "",
"LARKSUITE_CLI_SEC_AUTH": false
}`), 0600)
// Env overrides: disable + different proxy (should be irrelevant once disabled).
t.Setenv(envvars.CliSecEnable, "false")
t.Setenv(envvars.CliSecProxy, "http://127.0.0.1:9999")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg == nil {
t.Fatalf("Load() = nil, want non-nil (file exists)")
}
if cfg.Enabled() {
t.Fatalf("cfg.Enabled() = true, want false (env override)")
}
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/vfs"
)
// applyExtraRootCA augments t with an additional PEM bundle used for SEC proxy
// TLS interception.
func applyExtraRootCA(t *http.Transport, caPath string) error {
caPath = strings.TrimSpace(caPath)
if caPath == "" {
return nil
}
if !filepath.IsAbs(caPath) {
return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliSecCA, caPath)
}
pemBytes, err := vfs.ReadFile(caPath)
if err != nil {
return fmt.Errorf("failed to read %s %q: %w", envvars.CliSecCA, caPath, err)
}
// Start from system pool when possible; if unavailable, create a new pool.
pool, _ := x509.SystemCertPool()
if pool == nil {
pool = x509.NewCertPool()
}
if ok := pool.AppendCertsFromPEM(pemBytes); !ok {
return fmt.Errorf("invalid %s %q: no certificates parsed from PEM", envvars.CliSecCA, caPath)
}
if t.TLSClientConfig == nil {
t.TLSClientConfig = &tls.Config{}
} else {
// Clone to avoid mutating shared config from the base transport.
t.TLSClientConfig = t.TLSClientConfig.Clone()
}
t.TLSClientConfig.MinVersion = tls.VersionTLS12
t.TLSClientConfig.RootCAs = pool
return nil
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package secplugin
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
)
// mustCreateTestCertPEM generates a short-lived self-signed CA certificate for tests.
func mustCreateTestCertPEM(t *testing.T) []byte {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("GenerateKey() error = %v", err)
}
der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "secplugin-test-ca",
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}, &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "secplugin-test-ca",
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
IsCA: true,
BasicConstraintsValid: true,
}, &key.PublicKey, key)
if err != nil {
t.Fatalf("CreateCertificate() error = %v", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
// TestApplyExtraRootCA_EmptyPathIsNoop verifies that an empty CA path leaves the transport unchanged.
func TestApplyExtraRootCA_EmptyPathIsNoop(t *testing.T) {
tr := &http.Transport{}
if err := applyExtraRootCA(tr, " "); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig != nil {
t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig)
}
}
// TestApplyExtraRootCA_RejectsRelativePath verifies that CA paths must be absolute.
func TestApplyExtraRootCA_RejectsRelativePath(t *testing.T) {
tr := &http.Transport{}
err := applyExtraRootCA(tr, "ca.pem")
if err == nil || !strings.Contains(err.Error(), "must be an absolute path") {
t.Fatalf("applyExtraRootCA() error = %v, want absolute-path error", err)
}
}
// TestApplyExtraRootCA_RejectsMissingFile verifies read errors for missing PEM bundles.
func TestApplyExtraRootCA_RejectsMissingFile(t *testing.T) {
tr := &http.Transport{}
err := applyExtraRootCA(tr, filepath.Join(t.TempDir(), "missing.pem"))
if err == nil || !strings.Contains(err.Error(), "failed to read") {
t.Fatalf("applyExtraRootCA() error = %v, want read error", err)
}
}
// TestApplyExtraRootCA_RejectsInvalidPEM verifies validation of malformed PEM bundles.
func TestApplyExtraRootCA_RejectsInvalidPEM(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "invalid.pem")
writeFile(t, caPath, []byte("not a pem"), 0600)
tr := &http.Transport{}
err := applyExtraRootCA(tr, caPath)
if err == nil || !strings.Contains(err.Error(), "no certificates parsed from PEM") {
t.Fatalf("applyExtraRootCA() error = %v, want invalid PEM error", err)
}
}
// TestApplyExtraRootCA_SetsTLSConfigWhenMissing verifies initialization of TLSClientConfig when absent.
func TestApplyExtraRootCA_SetsTLSConfigWhenMissing(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "ca.pem")
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
tr := &http.Transport{}
if err := applyExtraRootCA(tr, caPath); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig == nil {
t.Fatal("TLSClientConfig = nil, want initialized config")
}
if tr.TLSClientConfig.RootCAs == nil {
t.Fatal("RootCAs = nil, want cert pool")
}
}
// TestApplyExtraRootCA_ClonesExistingTLSConfig verifies cloning when the base transport already has TLS settings.
func TestApplyExtraRootCA_ClonesExistingTLSConfig(t *testing.T) {
caPath := filepath.Join(t.TempDir(), "ca.pem")
writeFile(t, caPath, mustCreateTestCertPEM(t), 0600)
original := &tls.Config{ServerName: "open.feishu.cn"}
tr := &http.Transport{TLSClientConfig: original}
if err := applyExtraRootCA(tr, caPath); err != nil {
t.Fatalf("applyExtraRootCA() error = %v", err)
}
if tr.TLSClientConfig == original {
t.Fatal("TLSClientConfig pointer reused, want clone")
}
if tr.TLSClientConfig.ServerName != original.ServerName {
t.Fatalf("ServerName = %q, want %q", tr.TLSClientConfig.ServerName, original.ServerName)
}
if tr.TLSClientConfig.RootCAs == nil {
t.Fatal("RootCAs = nil, want cert pool")
}
}

View File

@@ -11,8 +11,11 @@ import (
"os"
"strings"
"sync"
"github.com/larksuite/cli/internal/secplugin"
)
// Proxy environment constants control shared transport proxy behavior.
const (
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
EnvNoProxy = "LARK_CLI_NO_PROXY"
@@ -36,6 +39,7 @@ func DetectProxyEnv() (key, value string) {
return "", ""
}
// proxyWarningOnce ensures proxy environment warnings are emitted at most once.
var proxyWarningOnce sync.Once
// redactProxyURL masks userinfo (username:password) in a proxy URL.
@@ -84,6 +88,31 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport {
return t
})
// secProxyTransport is a fixed-proxy clone of http.DefaultTransport (with optional
// custom root CA), lazily built on first use when sec plugin mode is enabled.
var secProxyTransport = sync.OnceValue(func() *http.Transport {
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return &http.Transport{}
}
cfg, err := secplugin.Load()
if err != nil || cfg == nil || !cfg.Enabled() {
return def
}
t, err := cfg.ApplyToTransport(def)
if err != nil {
// Fail closed: do not silently fall back to direct egress when the
// operator explicitly enabled SEC plugin mode.
blocked := def.Clone()
blocked.Proxy = func(*http.Request) (*url.URL, error) {
return nil, fmt.Errorf("sec plugin enabled but config is invalid: %v", err)
}
return blocked
}
return t
})
// SharedTransport returns the base http.RoundTripper for CLI HTTP clients.
//
// By default it returns http.DefaultTransport — the stdlib-provided
@@ -99,6 +128,23 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport {
// goroutines are reused; cloning per call leaks them until IdleConnTimeout
// (~90s) fires.
func SharedTransport() http.RoundTripper {
// SEC plugin mode overrides all other proxy behavior (env proxies and
// LARK_CLI_NO_PROXY), per operator intent.
if cfg, err := secplugin.Load(); err != nil {
// Fail closed: if the config file exists but is malformed/unreadable,
// do not silently fall back to direct egress.
def, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return http.DefaultTransport
}
blocked := def.Clone()
blocked.Proxy = func(*http.Request) (*url.URL, error) {
return nil, fmt.Errorf("sec plugin config is invalid: %v", err)
}
return blocked
} else if cfg != nil && cfg.Enabled() {
return secProxyTransport()
}
if os.Getenv(EnvNoProxy) != "" {
return noProxyTransport()
}

View File

@@ -6,11 +6,43 @@ package util
import (
"bytes"
"net/http"
"os"
"sync"
"testing"
"github.com/larksuite/cli/internal/envvars"
)
// unsetEnv clears key for the duration of the test and restores its original value.
func unsetEnv(t *testing.T, key string) {
t.Helper()
old, had := os.LookupEnv(key)
_ = os.Unsetenv(key)
t.Cleanup(func() {
if had {
_ = os.Setenv(key, old)
} else {
_ = os.Unsetenv(key)
}
})
}
// unsetSecPluginEnv clears SEC-related environment variables for deterministic tests.
func unsetSecPluginEnv(t *testing.T) {
t.Helper()
// Ensure developer machine env doesn't accidentally enable SEC plugin mode
// and change expectations for SharedTransport().
unsetEnv(t, envvars.CliSecEnable)
unsetEnv(t, envvars.CliSecProxy)
unsetEnv(t, envvars.CliSecCA)
unsetEnv(t, envvars.CliSecAuth)
}
// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior.
func TestDetectProxyEnv(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
// Clear all proxy env vars first
for _, k := range proxyEnvKeys {
t.Setenv(k, "")
@@ -28,7 +60,10 @@ func TestDetectProxyEnv(t *testing.T) {
}
}
// TestSharedTransport_DefaultReturnsStdlibSingleton verifies the default shared transport.
func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv(EnvNoProxy, "")
tr := SharedTransport()
if tr != http.DefaultTransport {
@@ -36,7 +71,10 @@ func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) {
}
}
// TestSharedTransport_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport.
func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv(EnvNoProxy, "1")
tr := SharedTransport()
if tr == http.DefaultTransport {
@@ -51,7 +89,10 @@ func TestSharedTransport_NoProxyReturnsClone(t *testing.T) {
}
}
// TestSharedTransport_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport.
func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv(EnvNoProxy, "1")
a := SharedTransport()
b := SharedTransport()
@@ -60,7 +101,10 @@ func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) {
}
}
// TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib transport after unsetting EnvNoProxy.
func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
// Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating
// the no-proxy singleton), then unsets it. Subsequent calls must return
// http.DefaultTransport, NOT the cached no-proxy clone.
@@ -77,7 +121,10 @@ func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) {
}
}
// TestSharedTransport_NoProxyOverridesSystemProxy verifies that EnvNoProxy disables system proxies.
func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888")
t.Setenv(EnvNoProxy, "1")
@@ -90,7 +137,10 @@ func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) {
}
}
// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning.
func TestWarnIfProxied_WithProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
// Reset the once guard for this test
proxyWarningOnce = sync.Once{}
@@ -111,7 +161,10 @@ func TestWarnIfProxied_WithProxy(t *testing.T) {
}
}
// TestWarnIfProxied_WithoutProxy verifies that no warning is emitted without proxy settings.
func TestWarnIfProxied_WithoutProxy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
for _, k := range proxyEnvKeys {
@@ -126,7 +179,10 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) {
}
}
// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings.
func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
@@ -140,7 +196,10 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
}
}
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
t.Setenv("HTTP_PROXY", "http://proxy:1234")
@@ -160,7 +219,10 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) {
}
}
// TestRedactProxyURL verifies redaction of proxy credentials across supported formats.
func TestRedactProxyURL(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
tests := []struct {
input string
want string
@@ -183,7 +245,10 @@ func TestRedactProxyURL(t *testing.T) {
}
}
// TestWarnIfProxied_RedactsCredentials verifies that warning output never leaks credentials.
func TestWarnIfProxied_RedactsCredentials(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetSecPluginEnv(t)
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")

View File

@@ -9,7 +9,8 @@ import (
"github.com/larksuite/cli/cmd"
_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
_ "github.com/larksuite/cli/extension/credential/secplugin" // activate sec plugin credential provider (SEC_AUTH placeholder tokens)
)
func main() {

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.33",
"version": "1.0.34",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -6,6 +6,7 @@ package doc
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/spf13/cobra"
@@ -168,6 +169,16 @@ func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
}
// Overwrite replaces the entire document, silently discarding any
// whiteboard or file-attachment blocks that cannot be re-created from
// Markdown. Pre-fetch the current content and warn when such blocks
// are present so the caller can take a backup before proceeding.
if runtime.Str("mode") == "overwrite" {
if w := warnOverwriteResourceBlocks(runtime); w != "" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
}
}
// Surface callout type= hint so users know to switch to background-color/
// border-color when they want a colored callout. Non-blocking, advisory.
if md := runtime.Str("markdown"); md != "" {
@@ -205,3 +216,74 @@ func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
}
return args
}
// resourceBlockRe matches the opening of a <whiteboard …> or <file …> tag
// (followed by whitespace, > or /) to avoid false positives on tag names like
// <file-view> or prose that merely mentions the word "whiteboard".
var resourceBlockRe = regexp.MustCompile(`<(whiteboard|file)[\s/>]`)
// warnOverwriteResourceBlocks pre-fetches the current document and returns a
// non-empty warning string when the document contains whiteboard or file
// attachment blocks that would be permanently deleted by an overwrite. Returns
// an empty string (no warning) when the document is clean or the fetch fails
// (we never block the overwrite on a best-effort check).
//
// This function is not unit-tested because it depends on an external MCP call
// (fetch-doc). The pure detection logic lives in checkOverwriteResourceBlocks,
// which has full table-driven coverage.
//
// Performance: this adds one extra fetch-doc round-trip to every --mode overwrite
// call, even when the document has no resource blocks. The cost is intentional:
// the guard is best-effort and silent on failure, so the latency is bounded and
// the trade-off is acceptable to avoid silent data loss.
func warnOverwriteResourceBlocks(runtime *common.RuntimeContext) string {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// skip_task_detail reduces response payload by omitting per-block task
// metadata, making the pre-fetch faster and cheaper.
"skip_task_detail": true,
}
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
if err != nil {
// Fetch failed — silently skip the guard rather than blocking overwrite.
return ""
}
md, _ := result["markdown"].(string)
return checkOverwriteResourceBlocks(md)
}
// checkOverwriteResourceBlocks scans Markdown for resource block tags that
// cannot survive an overwrite: <whiteboard …> and <file …>. Returns a
// warning string listing the counts if any are found, empty string otherwise.
func checkOverwriteResourceBlocks(markdown string) string {
matches := resourceBlockRe.FindAllStringSubmatch(markdown, -1)
whiteboards, files := 0, 0
for _, m := range matches {
switch m[1] {
case "whiteboard":
whiteboards++
case "file":
files++
}
}
var found []string
if whiteboards == 1 {
found = append(found, "1 whiteboard block")
} else if whiteboards > 1 {
found = append(found, fmt.Sprintf("%d whiteboard blocks", whiteboards))
}
if files == 1 {
found = append(found, "1 file attachment block")
} else if files > 1 {
found = append(found, fmt.Sprintf("%d file attachment blocks", files))
}
if len(found) == 0 {
return ""
}
return fmt.Sprintf(
"the document contains %s that cannot be reconstructed from Markdown; "+
"overwrite will permanently delete them. "+
"Consider fetching a backup with `docs +fetch` before overwriting.",
strings.Join(found, " and "),
)
}

View File

@@ -83,6 +83,72 @@ func TestIsWhiteboardCreateMarkdown(t *testing.T) {
})
}
func TestCheckOverwriteResourceBlocks(t *testing.T) {
t.Parallel()
tests := []struct {
name string
markdown string
wantWarn bool
wantSubs []string
}{
{
name: "empty markdown is clean",
markdown: "",
wantWarn: false,
},
{
name: "plain prose is clean",
markdown: "## Heading\n\nsome text",
wantWarn: false,
},
{
name: "single whiteboard triggers warning",
markdown: `<whiteboard token="abc123"/>`,
wantWarn: true,
wantSubs: []string{"1 whiteboard block", "overwrite"},
},
{
name: "multiple whiteboards counted",
markdown: "<whiteboard token=\"a\"/>\n<whiteboard token=\"b\"/>",
wantWarn: true,
wantSubs: []string{"2 whiteboard blocks"},
},
{
name: "single file attachment triggers warning",
markdown: `<file token="tok" name="report.pdf"/>`,
wantWarn: true,
wantSubs: []string{"1 file attachment block"},
},
{
name: "multiple file attachments counted",
markdown: "<file token=\"a\"/>\n<file token=\"b\"/>\n<file token=\"c\"/>",
wantWarn: true,
wantSubs: []string{"3 file attachment blocks"},
},
{
name: "whiteboard and file together both counted",
markdown: "<whiteboard token=\"wb\"/>\n<file token=\"f\"/>",
wantWarn: true,
wantSubs: []string{"1 whiteboard block", "1 file attachment block"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := checkOverwriteResourceBlocks(tt.markdown)
if (got != "") != tt.wantWarn {
t.Fatalf("checkOverwriteResourceBlocks(%q) = %q, wantWarn=%v", tt.markdown, got, tt.wantWarn)
}
for _, sub := range tt.wantSubs {
if !strings.Contains(got, sub) {
t.Errorf("expected warning to contain %q, got: %s", sub, got)
}
}
})
}
}
func TestNormalizeWhiteboardResult(t *testing.T) {
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
result := map[string]interface{}{
@@ -129,3 +195,35 @@ func TestNormalizeWhiteboardResult(t *testing.T) {
}
})
}
func TestValidateSelectionByTitleV1(t *testing.T) {
t.Parallel()
tests := []struct {
name string
title string
wantErr bool
errSub string
}{
{name: "empty title is valid", title: "", wantErr: false},
{name: "single heading is valid", title: "## Section", wantErr: false},
{name: "h1 heading is valid", title: "# Top", wantErr: false},
{name: "deep heading is valid", title: "### Sub-section", wantErr: false},
{name: "missing hash prefix is invalid", title: "No hash", wantErr: true, errSub: "'#'"},
{name: "multiline title is invalid", title: "## First\n## Second", wantErr: true, errSub: "single"},
{name: "title with embedded carriage return is invalid", title: "## Title\r## Next", wantErr: true, errSub: "single"},
{name: "leading-space heading is valid after trim", title: " ## Section", wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateSelectionByTitleV1(tt.title)
if (err != nil) != tt.wantErr {
t.Fatalf("validateSelectionByTitleV1(%q) error = %v, wantErr = %v", tt.title, err, tt.wantErr)
}
if tt.wantErr && tt.errSub != "" && !strings.Contains(err.Error(), tt.errSub) {
t.Errorf("expected error to contain %q, got: %v", tt.errSub, err)
}
})
}
}

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -25,6 +26,7 @@ var DriveExport = common.Shortcut{
Scopes: []string{
"docs:document.content:read",
"docs:document:export",
"docx:document:readonly",
"drive:drive.metadata:readonly",
},
AuthTypes: []string{"user", "bot"},
@@ -52,16 +54,15 @@ var DriveExport = common.Shortcut{
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
}
// Markdown export is a special case: docx markdown comes from docs content
// directly instead of the Drive export task API.
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
GET("/open-apis/docs/v1/content").
Params(map[string]interface{}{
"doc_token": spec.Token,
"doc_type": "docx",
"content_type": "markdown",
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
@@ -101,23 +102,33 @@ var DriveExport = common.Shortcut{
overwrite := runtime.Bool("overwrite")
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk.
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
data, err := runtime.CallAPI(
"GET",
"/open-apis/docs/v1/content",
map[string]interface{}{
"doc_token": spec.Token,
"doc_type": "docx",
"content_type": "markdown",
},
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.DoAPIJSONWithLogID(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
return err
}
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return output.Errorf(output.ExitAPI, "api_error", "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
@@ -130,7 +141,7 @@ var DriveExport = common.Shortcut{
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(common.GetString(data, "content")), overwrite)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
return err
}
@@ -141,7 +152,7 @@ var DriveExport = common.Shortcut{
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len([]byte(common.GetString(data, "content"))),
"size_bytes": len(content),
}, nil)
return nil
}

View File

@@ -81,16 +81,19 @@ func TestValidateDriveExportSpec(t *testing.T) {
func TestDriveExportMarkdownWritesFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
fetchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# hello\n",
"document": map[string]interface{}{
"content": "# hello\n",
},
},
},
})
}
reg.Register(fetchStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
@@ -118,6 +121,14 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
}
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
@@ -132,16 +143,19 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
fetchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# custom\n",
"document": map[string]interface{}{
"content": "# custom\n",
},
},
},
})
}
reg.Register(fetchStub)
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
@@ -158,6 +172,14 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
}
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
@@ -179,7 +201,7 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
}{
{
name: "markdown",
wantURL: "/open-apis/docs/v1/content",
wantURL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
wantFileName: `"file_name": "notes.md"`,
args: []string{
"+export",
@@ -233,16 +255,19 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs/v1/content",
fetchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"content": "# fallback\n",
"document": map[string]interface{}{
"content": "# fallback\n",
},
},
},
})
}
reg.Register(fetchStub)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
@@ -267,6 +292,14 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
var reqBody map[string]interface{}
if err := json.Unmarshal(fetchStub.CapturedBody, &reqBody); err != nil {
t.Fatalf("unmarshal docs_ai fetch body: %v", err)
}
if reqBody["format"] != "markdown" {
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
@@ -279,6 +312,76 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
}
}
func TestDriveExportMarkdownRejectsMissingDocumentObject(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for missing document object, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if !strings.Contains(exitErr.Detail.Message, "missing document object") {
t.Fatalf("error message = %q, want mention of missing document object", exitErr.Detail.Message)
}
}
func TestDriveExportMarkdownRejectsMissingDocumentContent(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/docx123/fetch",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"document": map[string]interface{}{},
},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "markdown",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for missing document.content, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if !strings.Contains(exitErr.Detail.Message, "missing document.content") {
t.Fatalf("error message = %q, want mention of missing document.content", exitErr.Detail.Message)
}
}
func TestDriveExportAsyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -150,8 +150,6 @@ lark-cli drive +add-comment \
- `type=text` 的评论文本不能直接包含 `<``>`;应优先传 `&lt;``&gt;`。shortcut 在发送前也会自动将 `<``>` 转义为 `&lt;``&gt;` 作为兜底。
- **所有 `type=text` 元素的字符总和 ≤ 10000**(按字符算,中英文 / 符号一视同仁)。超过会被 shortcut 在发送前拒绝,并指出累计超长的元素。**拆成多个 text element 不能绕过这个上限**——上限是总额,不是每元素。需要更长内容就缩短或拆成多条评论。
- 长度限制只对 `type=text` 生效,`mention_user` / `link` 不计入。
- 局部评论走 `locate-doc` 时,内部固定使用 `limit=10`
-`locate-doc` 命中多处时shortcut 会中止并提示用户继续收窄 `--selection-with-ellipsis`,不支持手动指定匹配序号。
- 写入评论前会自动生成符合 OpenAPI 定义的请求体:
- 统一接口:`POST /new_comments`
- 统一字段:`file_type` + `reply_elements`

View File

@@ -25,8 +25,8 @@ lark-cli drive +export \
--doc-type doc \
--file-extension docx
# 导出 docx 为 markdown
# 注意markdown 只支持 docx,底层走 /open-apis/docs/v1/content
# 导出 docx 为 markdownLark-flavored Markdown
# 注意markdown 只支持 docx
lark-cli drive +export \
--token "<DOCX_TOKEN>" \
--doc-type docx \

View File

@@ -7,6 +7,12 @@
本 skill 对应 shortcut`lark-cli vc +search`(调用 `POST /open-apis/vc/v1/meetings/search`)。
## 关键词使用边界
`--query` 只用于真实会议关键词,例如会议主题、项目名、评审名、客户名。用户只是说"我这月参加的所有视频会议"、"最近两周我组织的所有视频会议"、"总结主要议题 / 看看参会情况"时,本质是历史会议列表和后续总结,不要把"回顾"、"所有视频会议"、"总结主要议题"等动作词放进 `--query`。这类请求应先用时间范围 + `--participant-ids` / `--organizer-ids` 搜全量候选,再按结果继续取纪要或录制信息。
列表阶段只负责找会议记录;总结阶段必须继续取证。若用户要求"主要议题"、"主要决策"、"参会情况",先确认搜索结果的 `meeting_id`、时间、组织者/参与者符合过滤条件,然后用 `vc +notes``vc +recording` / `minutes` 读取纪要、妙记或录制信息。没有纪要或妙记时,如实说明只能基于会议标题/参会数据汇总,不要编造议题。
## 典型触发表达
以下说法通常应优先使用 `vc +search`
@@ -42,6 +48,12 @@ lark-cli vc +search --organizer-ids "ou_a,ou_b"
# 按参与者过滤open_id逗号分隔
lark-cli vc +search --participant-ids "ou_x,ou_y"
# 查询我这个月参加过的历史会议,不带关键词
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --participant-ids "ou_me"
# 查询最近两周我组织的历史会议,不带关键词
lark-cli vc +search --start "<YYYY-MM-DD>" --end "<YYYY-MM-DD>" --organizer-ids "ou_me"
# 按会议室过滤
lark-cli vc +search --room-ids "123,456"
@@ -76,6 +88,10 @@ lark-cli vc +search --query "周会" --format json
所有参数均可选,但必须至少提供一个过滤条件:`--query``--start``--end``--organizer-ids``--participant-ids``--room-ids`
没有真实关键词时,时间范围或人员过滤已经满足这个约束,`--query` 可以省略。
涉及"本月"、"最近两周"这类相对时间时,先基于执行当天计算 `"<YYYY-MM-DD>"` 占位符,再运行命令;不要沿用文档示例生成时的具体日期。
### 2. 仅搜索历史会议
`vc +search` 只能搜索已结束的历史会议记录,不用于查询未来日程。查询未来会议安排请使用 [lark-calendar](../../lark-calendar/SKILL.md)。
@@ -128,7 +144,8 @@ lark-cli vc +search --query "周会" --format json
- 当结果中返回 `has_more=true` 时,说明还有更多页可继续获取。
- 继续翻页时,使用响应中的 `page_token` 搭配 `--page-token` 发起下一次查询。
- 不要假设调大 `--page-size` 就能拿全结果;分页遍历时应以 `has_more``page_token` 为准。
- `total` 数量小于 50 时,自动分页获取所有结果;`total` 数量大于 50 时,向用户确认是否获取全部结果。
- 未明确要求全量时,`total` 数量小于 50 自动分页获取所有结果;`total` 数量大于 50 时,向用户确认是否继续获取全部结果。
- 用户明确说"所有 / 全部 / 统计 / 按时间排序"时,该全量意图优先于 `total > 50` 的确认门槛;直接完成分页和去重,再排序或统计,不要只用第一页回答。
```bash
# First page

View File

@@ -60,3 +60,42 @@ func TestDriveExportDryRun_FileNameMetadata(t *testing.T) {
t.Fatalf("output_dir=%q, want ./exports\nstdout:\n%s", got, out)
}
}
func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+export",
"--token", "docxMdDryRun",
"--doc-type", "docx",
"--file-extension", "markdown",
"--file-name", "my-notes",
"--output-dir", "./md-exports",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/docxMdDryRun/fetch" {
t.Fatalf("url=%q, want docs_ai fetch\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.body.format").String(); got != "markdown" {
t.Fatalf("body.format=%q, want markdown\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "file_name").String(); got != "my-notes.md" {
t.Fatalf("file_name=%q, want my-notes.md\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "output_dir").String(); got != "./md-exports" {
t.Fatalf("output_dir=%q, want ./md-exports\nstdout:\n%s", got, out)
}
}