mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
55 Commits
v1.0.30
...
feat/sec_p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
054ff9339b | ||
|
|
bdb0cd14d1 | ||
|
|
6c41d12792 | ||
|
|
2286937366 | ||
|
|
d793790807 | ||
|
|
13411d9a51 | ||
|
|
939b7b6fb6 | ||
|
|
a4c5ec99c8 | ||
|
|
7c54f9b023 | ||
|
|
e6bc292575 | ||
|
|
4aa61db8b2 | ||
|
|
28c66be199 | ||
|
|
0e70b056f8 | ||
|
|
95ffff4212 | ||
|
|
e511404065 | ||
|
|
b8469d2dc6 | ||
|
|
afa084e7a4 | ||
|
|
3354494579 | ||
|
|
2bb69d1942 | ||
|
|
c4fb7006d2 | ||
|
|
583349e572 | ||
|
|
315e0ab50c | ||
|
|
ef89d1fd40 | ||
|
|
c8b9809f96 | ||
|
|
de00343063 | ||
|
|
67b16c5ec3 | ||
|
|
7af616b9e5 | ||
|
|
df4b657737 | ||
|
|
4b721c0410 | ||
|
|
241952459d | ||
|
|
33c292c05e | ||
|
|
ca6c6c3e29 | ||
|
|
7bad9f2656 | ||
|
|
898e0eebfd | ||
|
|
0b7215637f | ||
|
|
14a3213038 | ||
|
|
caff780c17 | ||
|
|
5778adfefa | ||
|
|
7400226e34 | ||
|
|
4a45e00139 | ||
|
|
f03138b9f0 | ||
|
|
ed9eecf94f | ||
|
|
f49a2f7e14 | ||
|
|
a93fb2d6b3 | ||
|
|
7acf64c3ef | ||
|
|
52e0129078 | ||
|
|
8a8dff47ce | ||
|
|
1c2d3d7679 | ||
|
|
0d20f88453 | ||
|
|
b0bd9b0258 | ||
|
|
ba6edb84e4 | ||
|
|
a54a879330 | ||
|
|
a27c636131 | ||
|
|
37459b60ec | ||
|
|
f1aa7d8f42 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Fetch meta data
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
lint:
|
||||
needs: fast-gate
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,8 +34,11 @@ tests/mail/reports/
|
||||
|
||||
# Generated / test artifacts
|
||||
.hammer/
|
||||
.lark-slides/
|
||||
internal/registry/meta_data.json
|
||||
cmd/api/download.bin
|
||||
app.log
|
||||
/sidecar-server-demo
|
||||
/server-demo
|
||||
.tmp/
|
||||
cover*.out
|
||||
|
||||
@@ -14,3 +14,4 @@ id = "lark-session-token"
|
||||
description = "Detect Lark session tokens"
|
||||
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
|
||||
keywords = ["XN0YXJ0-", "-WVuZA"]
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ linters:
|
||||
- path: _test\.go$
|
||||
linters:
|
||||
- bodyclose
|
||||
- bidichk
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
|
||||
86
CHANGELOG.md
86
CHANGELOG.md
@@ -2,6 +2,88 @@
|
||||
|
||||
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
|
||||
|
||||
- **markdown**: Add `+patch` shortcut (#857)
|
||||
- **slides**: Improve slide planning and validation guidance (#847)
|
||||
- **drive**: Add `+sync` workflow for Drive directories (#873)
|
||||
- **drive**: Add drive version shortcut (#841)
|
||||
- **extension**: Plugin / Hook framework with command pruning (#910)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **sheets**: Explicitly document safe JSON unmarshal ignore in `DryRun` (#935)
|
||||
- **base**: Mark base field update high risk (#936)
|
||||
- **auth**: Guide agents to yield during auth device flow (#933)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **lark-wiki**: Correct the `--as` default-identity claim (#919)
|
||||
|
||||
### Tests
|
||||
|
||||
- Drop stale e2e `--yes` flags (#920)
|
||||
|
||||
## [v1.0.32] - 2026-05-15
|
||||
|
||||
### Features
|
||||
|
||||
- **doc**: Add `--width`/`--height` flags to `docs +media-insert` (#832)
|
||||
- **wiki**: Add `+space-list` / `+node-list` / `+node-copy` shortcuts (#392)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **drive**: Preserve parent token on nested overwrite (#908)
|
||||
- **selfupdate**: Use `LookPath` instead of `Executable` for binary verification (#886)
|
||||
- **registry**: Wait for background meta refresh before test reset (#894)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Add SVG whiteboard support to `lark-doc` v2 skill (#901)
|
||||
- **drive**: Add permission public patch error guidance (#863)
|
||||
|
||||
## [v1.0.31] - 2026-05-14
|
||||
|
||||
### Features
|
||||
|
||||
- **install**: Skip interactive prompts in non-TTY environments (#888)
|
||||
- **update**: Recommend `lark-cli update` over `npm install` for AI agents (#884)
|
||||
- **im**: Add `--exclude-muted` to `+chat-search` and new `+chat-list` shortcut (#820)
|
||||
- **auth**: Add `--exclude` flag and allow combining `--scope` with `--domain`/`--recommend` (#844)
|
||||
- **drive**: Add modified-time smart sync mode (#859)
|
||||
- **approval**: Add `tasks.add_sign` and `tasks.rollback` methods (#867)
|
||||
|
||||
## [v1.0.30] - 2026-05-13
|
||||
|
||||
### Features
|
||||
@@ -692,6 +774,10 @@ 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
|
||||
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
|
||||
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
|
||||
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
|
||||
|
||||
37
Makefile
37
Makefile
@@ -8,7 +8,9 @@ DATE := $(shell date +%Y-%m-%d)
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta
|
||||
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
|
||||
fetch_meta:
|
||||
python3 scripts/fetch_meta.py
|
||||
@@ -19,13 +21,32 @@ build: fetch_meta
|
||||
vet: fetch_meta
|
||||
go vet ./...
|
||||
|
||||
# fmt-check fails when any file would be reformatted by gofmt. Keep this
|
||||
# in sync with the fast-gate "Check formatting" step in CI.
|
||||
fmt-check:
|
||||
@unformatted=$$(gofmt -l . | grep -v '^\.claude/' || true); \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "Unformatted Go files:"; \
|
||||
echo "$$unformatted"; \
|
||||
echo "Run 'gofmt -w .' and commit."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test -race -gcflags="all=-N -l" -count=1 ./cmd/... ./internal/... ./shortcuts/...
|
||||
go test -race -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
# breaks, the plugin author guide's "go build ./..." path is broken.
|
||||
examples-build:
|
||||
go build ./extension/platform/examples/audit-observer
|
||||
go build ./extension/platform/examples/readonly-policy
|
||||
|
||||
integration-test: build
|
||||
go test -v -count=1 ./tests/...
|
||||
|
||||
test: vet unit-test integration-test
|
||||
test: vet fmt-check unit-test examples-build integration-test
|
||||
|
||||
install: build
|
||||
install -d $(PREFIX)/bin
|
||||
@@ -37,3 +58,13 @@ uninstall:
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
# Step 2: gitleaks scans the full repo for real leaked secrets.
|
||||
# Install gitleaks: https://github.com/gitleaks/gitleaks#installing
|
||||
gitleaks:
|
||||
@bash scripts/check-doc-tokens.sh
|
||||
@command -v gitleaks >/dev/null 2>&1 || { echo "gitleaks not found. Install: brew install gitleaks"; exit 1; }
|
||||
gitleaks detect --redact -v --exit-code=2
|
||||
|
||||
16
README.md
16
README.md
@@ -28,7 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
|
||||
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
|
||||
| 📝 Markdown | Create, fetch, patch, and overwrite Drive-native `.md` files |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
@@ -62,11 +62,7 @@ Choose **one** of the following methods:
|
||||
**Option 1 — From npm (recommended):**
|
||||
|
||||
```bash
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**Option 2 — From source:**
|
||||
@@ -102,11 +98,7 @@ lark-cli calendar +agenda
|
||||
**Step 1 — Install**
|
||||
|
||||
```bash
|
||||
# Install CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# Install CLI SKILL (required)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**Step 2 — Configure app credentials**
|
||||
@@ -140,7 +132,7 @@ lark-cli auth status
|
||||
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
|
||||
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
|
||||
| `lark-drive` | Upload, download files, manage permissions & comments |
|
||||
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
|
||||
| `lark-markdown` | Create, fetch, patch, and overwrite Drive-native Markdown files |
|
||||
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
|
||||
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
|
||||
|
||||
16
README.zh.md
16
README.zh.md
@@ -28,7 +28,7 @@
|
||||
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
|
||||
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
|
||||
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
|
||||
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📝 Markdown | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 `.md` 文件 |
|
||||
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
|
||||
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
|
||||
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
@@ -62,11 +62,7 @@
|
||||
**方式一 — 从 npm 安装(推荐):**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**方式二 — 从源码安装:**
|
||||
@@ -102,11 +98,7 @@ lark-cli calendar +agenda
|
||||
**第 1 步 — 安装**
|
||||
|
||||
```bash
|
||||
# 安装 CLI
|
||||
npm install -g @larksuite/cli
|
||||
|
||||
# 安装 CLI SKILL(必需)
|
||||
npx skills add larksuite/cli -y -g
|
||||
npx @larksuite/cli@latest install
|
||||
```
|
||||
|
||||
**第 2 步 — 配置应用凭证**
|
||||
@@ -141,7 +133,7 @@ lark-cli auth status
|
||||
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
|
||||
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) |
|
||||
| `lark-drive` | 上传、下载文件,管理权限与评论 |
|
||||
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-markdown` | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 Markdown 文件 |
|
||||
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
|
||||
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
|
||||
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
|
||||
|
||||
@@ -103,6 +103,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -44,6 +44,32 @@ func TestAuthLoginCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return nil })
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := stdout.String()
|
||||
for _, want := range []string{
|
||||
"only delivers final turn messages",
|
||||
"--no-wait --json",
|
||||
"send the verification URL to the user as your final message",
|
||||
"run --device-code in a later step",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("help missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -37,6 +37,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||
cmd.MarkFlagRequired("scope")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type LoginOptions struct {
|
||||
Scope string
|
||||
Recommend bool
|
||||
Domains []string
|
||||
Exclude []string
|
||||
NoWait bool
|
||||
DeviceCode string
|
||||
}
|
||||
@@ -46,10 +47,12 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
|
||||
Long: `Device Flow authorization login.
|
||||
|
||||
For AI agents: this command blocks until the user completes authorization in the
|
||||
browser. Run it in the background and retrieve the verification URL from its output.`,
|
||||
browser. If your harness only delivers final turn messages, use --no-wait --json,
|
||||
send the verification URL to the user as your final message, end the turn, then
|
||||
run --device-code in a later step after the user confirms authorization.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
return output.ErrWithHint(output.ExitValidation, "command_denied",
|
||||
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
||||
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
@@ -61,12 +64,15 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
},
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated)")
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
available := sortedKnownDomains()
|
||||
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
|
||||
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
|
||||
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
|
||||
"scopes to exclude from the request (repeatable or comma-separated, e.g. --exclude drive:file:download)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
|
||||
@@ -158,6 +164,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
||||
|
||||
if len(opts.Exclude) > 0 && !hasAnyOption {
|
||||
return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified")
|
||||
}
|
||||
|
||||
if !hasAnyOption {
|
||||
if !opts.JSON && f.IOStreams.IsTerminal {
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
|
||||
@@ -180,7 +190,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
log("View all options:")
|
||||
log(msg.HintFooter)
|
||||
log("")
|
||||
log("Note: this command blocks until authorization is complete. Run it in the background and retrieve the verification URL from its output.")
|
||||
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
|
||||
return output.ErrValidation("please specify the scopes to authorize")
|
||||
}
|
||||
}
|
||||
@@ -191,12 +201,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
// endpoint rejects raw "a,b" strings as a single malformed scope.
|
||||
finalScope := normalizeScopeInput(opts.Scope)
|
||||
|
||||
// Resolve scopes from domain/permission filters
|
||||
// Resolve scopes from domain/permission filters and merge with --scope.
|
||||
// --scope, --domain, and --recommend combine additively so callers can,
|
||||
// for example, request all `docs` scopes plus a few specific `drive`
|
||||
// scopes in a single command.
|
||||
if len(selectedDomains) > 0 || opts.Recommend {
|
||||
if opts.Scope != "" {
|
||||
return output.ErrValidation("cannot use --scope together with --domain/--recommend")
|
||||
}
|
||||
|
||||
var candidateScopes []string
|
||||
if len(selectedDomains) > 0 {
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user")
|
||||
@@ -210,11 +219,35 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
|
||||
}
|
||||
|
||||
if len(candidateScopes) == 0 {
|
||||
if len(candidateScopes) == 0 && opts.Scope == "" {
|
||||
return output.ErrValidation("no matching scopes found, check domain/scope options")
|
||||
}
|
||||
|
||||
finalScope = strings.Join(candidateScopes, " ")
|
||||
// Merge --scope additively with the resolved domain scopes.
|
||||
merged := make(map[string]bool, len(candidateScopes)+len(strings.Fields(finalScope)))
|
||||
for _, s := range candidateScopes {
|
||||
merged[s] = true
|
||||
}
|
||||
for _, s := range strings.Fields(finalScope) {
|
||||
merged[s] = true
|
||||
}
|
||||
finalScope = joinSortedScopeSet(merged)
|
||||
}
|
||||
|
||||
// Apply --exclude on top of the resolved scope set. We honour exclude
|
||||
// regardless of whether scopes came from --scope, --domain, --recommend,
|
||||
// or any combination thereof.
|
||||
if len(opts.Exclude) > 0 {
|
||||
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
||||
if len(unknown) > 0 {
|
||||
return output.ErrValidation(
|
||||
"these --exclude scopes are not present in the requested set: %s",
|
||||
strings.Join(unknown, ", "))
|
||||
}
|
||||
finalScope = excluded
|
||||
if strings.TrimSpace(finalScope) == "" {
|
||||
return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Request device authorization
|
||||
@@ -236,7 +269,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. Then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
|
||||
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the URL the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
@@ -477,7 +510,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.ScopesForIdentity(identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
}
|
||||
@@ -580,3 +613,58 @@ func suggestDomain(input string, known map[string]bool) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// joinSortedScopeSet returns a deterministic, space-separated scope string
|
||||
// from a set, sorted alphabetically. Empty/blank scopes are dropped.
|
||||
func joinSortedScopeSet(set map[string]bool) string {
|
||||
out := make([]string, 0, len(set))
|
||||
for s := range set {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// applyExcludeScopes removes the provided exclude entries from the requested
|
||||
// scope string. Each --exclude flag value may itself contain comma- or
|
||||
// whitespace-separated scopes. Returns the filtered scope string and any
|
||||
// exclude entries that were not present in the requested set (callers can
|
||||
// surface those as a validation error to catch typos like
|
||||
// `--exclude drive:file:downlod`).
|
||||
func applyExcludeScopes(requested string, excludes []string) (string, []string) {
|
||||
requestedSet := make(map[string]bool)
|
||||
for _, s := range strings.Fields(requested) {
|
||||
requestedSet[s] = true
|
||||
}
|
||||
|
||||
excludeSet := make(map[string]bool)
|
||||
for _, raw := range excludes {
|
||||
// --exclude already splits on commas (StringSliceVar), but also
|
||||
// tolerate whitespace-separated entries inside a single value.
|
||||
for _, s := range strings.Fields(strings.ReplaceAll(raw, ",", " ")) {
|
||||
excludeSet[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
var unknown []string
|
||||
for s := range excludeSet {
|
||||
if !requestedSet[s] {
|
||||
unknown = append(unknown, s)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
return requested, unknown
|
||||
}
|
||||
|
||||
kept := make(map[string]bool, len(requestedSet))
|
||||
for s := range requestedSet {
|
||||
if !excludeSet[s] {
|
||||
kept[s] = true
|
||||
}
|
||||
}
|
||||
return joinSortedScopeSet(kept), nil
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s;如不支持长 timeout,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询。**不要短 timeout 反复重试**,每次重启会作废上一轮的 device code,导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL,把它视为不可修改的 opaque string;不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
|
||||
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 和 verification_url,把 verification_url 作为本轮最终消息原样发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code <code>` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code,导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL,把它视为不可修改的 opaque string;不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
|
||||
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
|
||||
LoginSuccess: "授权成功! 用户: %s (%s)",
|
||||
AuthorizedUser: "当前授权账号: %s (%s)",
|
||||
@@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout**; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
|
||||
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code <code>` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
|
||||
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
|
||||
LoginSuccess: "Authorization successful! User: %s (%s)",
|
||||
AuthorizedUser: "Authorized account: %s (%s)",
|
||||
|
||||
@@ -97,16 +97,17 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
|
||||
// auth-login output tells AI agents two things: (a) this command blocks for
|
||||
// minutes — set a long runner timeout, and (b) the alternative is the
|
||||
// --no-wait + --device-code split-flow. Without (a) AI sets a 10s timeout and
|
||||
// kills the process before the user can authorize; without (b) the AI has no
|
||||
// recovery path and just retries with the same short timeout, invalidating
|
||||
// each new device code in turn.
|
||||
// auth-login output tells AI agents three things: (a) this command blocks for
|
||||
// minutes — set a long runner timeout, (b) the alternative is the --no-wait +
|
||||
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
|
||||
// after presenting the URL instead of blocking in the same turn.
|
||||
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
|
||||
for _, lang := range []string{"zh", "en"} {
|
||||
hint := getLoginMsg(lang).AgentTimeoutHint
|
||||
for _, want := range []string{"--no-wait", "--device-code"} {
|
||||
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
|
||||
if lang == "zh" && want == "turn" {
|
||||
want = "本轮"
|
||||
}
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
|
||||
}
|
||||
|
||||
@@ -315,10 +315,12 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
if !strings.Contains(msg, "scopes") {
|
||||
t.Errorf("expected error to mention scopes, got: %s", msg)
|
||||
}
|
||||
// Stderr should contain background hint
|
||||
// Stderr should explain the split-flow path for non-streaming agents.
|
||||
stderrStr := stderr.String()
|
||||
if !strings.Contains(stderrStr, "background") {
|
||||
t.Errorf("expected stderr to mention background, got: %s", stderrStr)
|
||||
for _, want := range []string{"--no-wait --json", "final message of the turn", "--device-code"} {
|
||||
if !strings.Contains(stderrStr, want) {
|
||||
t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,11 +951,24 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
"do not add %20, spaces, or punctuation",
|
||||
"do not wrap it as Markdown link text",
|
||||
"fenced code block containing only the raw URL",
|
||||
"final message of the turn",
|
||||
"return control to the user",
|
||||
"do not block on --device-code in the same turn",
|
||||
"After the user confirms authorization in a later step",
|
||||
"lark-cli auth login --device-code device-code",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"Then immediately execute",
|
||||
"Do not instruct the user to run this command themselves",
|
||||
} {
|
||||
if strings.Contains(hint, unwanted) {
|
||||
t.Fatalf("hint should not contain %q, got:\n%s", unwanted, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
|
||||
@@ -1035,6 +1050,10 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
|
||||
hint, _ := data["agent_hint"].(string)
|
||||
for _, want := range []string{
|
||||
"timeout >= 600s",
|
||||
"本轮最终消息",
|
||||
"结束本轮",
|
||||
"用户回复已完成授权",
|
||||
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
|
||||
"逐字原样转发 CLI 返回的 URL",
|
||||
"opaque string",
|
||||
"不要做 URL 编码或解码",
|
||||
|
||||
@@ -33,6 +33,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
@@ -37,6 +35,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -59,73 +58,83 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
"defaultAs": defaultAs,
|
||||
}
|
||||
|
||||
if config.UserOpenId == "" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in."
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
result["identity"] = "bot"
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
status := larkauth.TokenStatus(stored)
|
||||
if status == "expired" {
|
||||
result["identity"] = "bot"
|
||||
result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
|
||||
} else {
|
||||
result["identity"] = "user"
|
||||
}
|
||||
result["userName"] = config.UserName
|
||||
result["userOpenId"] = config.UserOpenId
|
||||
result["tokenStatus"] = status
|
||||
result["scope"] = stored.Scope
|
||||
result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)
|
||||
result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339)
|
||||
result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339)
|
||||
|
||||
// --verify: call the server to confirm token is actually usable.
|
||||
if opts.Verify && status != "expired" {
|
||||
verified, verifyErr := verifyTokenOnServer(f, config)
|
||||
result["verified"] = verified
|
||||
if verifyErr != "" {
|
||||
result["verifyError"] = verifyErr
|
||||
}
|
||||
}
|
||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addLegacyUserFields(result, diagnostics.User)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics)
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyTokenOnServer obtains a valid access token (refreshing if needed)
|
||||
// and calls /authen/v1/user_info to confirm the server accepts it.
|
||||
// Returns (true, "") on success or (false, reason) on failure.
|
||||
func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) {
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return false, "failed to create HTTP client: " + err.Error()
|
||||
}
|
||||
const (
|
||||
identityUser = "user"
|
||||
identityBot = "bot"
|
||||
identityNone = "none"
|
||||
)
|
||||
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
return false, "token unusable: " + err.Error()
|
||||
func effectiveIdentity(d identitydiag.Result) string {
|
||||
switch {
|
||||
case d.User.Available:
|
||||
return identityUser
|
||||
case d.Bot.Available:
|
||||
return identityBot
|
||||
default:
|
||||
return identityNone
|
||||
}
|
||||
}
|
||||
|
||||
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
|
||||
if user.OpenID == "" {
|
||||
return
|
||||
}
|
||||
result["userName"] = user.UserName
|
||||
result["userOpenId"] = user.OpenID
|
||||
if user.TokenStatus != "" {
|
||||
result["tokenStatus"] = user.TokenStatus
|
||||
}
|
||||
if user.Scope != "" {
|
||||
result["scope"] = user.Scope
|
||||
}
|
||||
if user.ExpiresAt != "" {
|
||||
result["expiresAt"] = user.ExpiresAt
|
||||
}
|
||||
if user.RefreshExpiresAt != "" {
|
||||
result["refreshExpiresAt"] = user.RefreshExpiresAt
|
||||
}
|
||||
if user.GrantedAt != "" {
|
||||
result["grantedAt"] = user.GrantedAt
|
||||
}
|
||||
}
|
||||
|
||||
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch result["identity"] {
|
||||
case identityUser:
|
||||
if d.User.Verified != nil {
|
||||
result["verified"] = *d.User.Verified
|
||||
if !*d.User.Verified {
|
||||
result["verifyError"] = d.User.Message
|
||||
}
|
||||
}
|
||||
case identityBot:
|
||||
if d.Bot.Verified != nil {
|
||||
result["verified"] = *d.Bot.Verified
|
||||
if !*d.Bot.Verified {
|
||||
result["verifyError"] = d.Bot.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
|
||||
switch {
|
||||
case !d.User.Available && d.Bot.Available:
|
||||
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
|
||||
case d.User.Status == identitydiag.StatusNeedsRefresh:
|
||||
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
|
||||
case !d.User.Available && !d.Bot.Available:
|
||||
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
|
||||
}
|
||||
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return false, "failed to create SDK client: " + err.Error()
|
||||
}
|
||||
|
||||
if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil {
|
||||
return false, "server rejected token: " + err.Error()
|
||||
}
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
96
cmd/auth/status_test.go
Normal file
96
cmd/auth/status_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Identities.Bot.Status != "ready" || !got.Identities.Bot.Available {
|
||||
t.Fatalf("bot = %#v, want ready and available", got.Identities.Bot)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
|
||||
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_bot",
|
||||
"app_name": "diagnostic bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got statusOutput
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if got.Verified == nil || !*got.Verified {
|
||||
t.Fatalf("verified = %v, want true", got.Verified)
|
||||
}
|
||||
if got.Identities.Bot.Verified == nil || !*got.Identities.Bot.Verified {
|
||||
t.Fatalf("bot verified = %v, want true", got.Identities.Bot.Verified)
|
||||
}
|
||||
if got.Identities.Bot.OpenID != "ou_bot" {
|
||||
t.Fatalf("bot open id = %q, want ou_bot", got.Identities.Bot.OpenID)
|
||||
}
|
||||
if got.Identities.User.Status != "missing" {
|
||||
t.Fatalf("user status = %q, want missing", got.Identities.User.Status)
|
||||
}
|
||||
}
|
||||
|
||||
type statusOutput struct {
|
||||
Identity string `json:"identity"`
|
||||
Verified *bool `json:"verified"`
|
||||
Identities struct {
|
||||
Bot statusIdentity `json:"bot"`
|
||||
User statusIdentity `json:"user"`
|
||||
} `json:"identities"`
|
||||
}
|
||||
|
||||
type statusIdentity struct {
|
||||
Status string `json:"status"`
|
||||
Available bool `json:"available"`
|
||||
Verified *bool `json:"verified"`
|
||||
OpenID string `json:"openId"`
|
||||
}
|
||||
58
cmd/build.go
58
cmd/build.go
@@ -15,11 +15,14 @@ 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"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/shortcuts"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -59,18 +62,28 @@ func HideProfile(hide bool) BuildOption {
|
||||
}
|
||||
}
|
||||
|
||||
// Build constructs the full command tree without executing.
|
||||
// Returns only the cobra.Command; Factory is internal.
|
||||
// Build constructs the full command tree. It also installs registered
|
||||
// plugins and emits the Startup lifecycle event during assembly --
|
||||
// so Plugin.On(Startup) handlers run even if the returned command is
|
||||
// never dispatched. The matching Shutdown event is only emitted by
|
||||
// Execute; callers that bypass Execute will not see Shutdown fire.
|
||||
//
|
||||
// Returns only the cobra.Command; Factory and hook Registry are internal.
|
||||
// Use Execute for the standard production entry point.
|
||||
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
|
||||
_, rootCmd := buildInternal(ctx, inv, opts...)
|
||||
_, rootCmd, _ := buildInternal(ctx, inv, opts...)
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// buildInternal is a pure assembly function: it wires the command tree from
|
||||
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
|
||||
// env) belongs in the caller and must be threaded in via BuildOption.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
|
||||
//
|
||||
// Returns (factory, rootCmd, registry). The registry is nil when plugin
|
||||
// install failed (FailClosed guard installed) or when no plugin produced
|
||||
// hooks; callers that wire Shutdown emit must nil-check before calling
|
||||
// hook.Emit.
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command, *hook.Registry) {
|
||||
// cfg.globals.Profile is left zero here; it's bound to the --profile
|
||||
// flag in RegisterGlobalFlags and filled by cobra's parse step.
|
||||
cfg := &buildConfig{}
|
||||
@@ -121,13 +134,46 @@ 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)
|
||||
|
||||
// Prune commands incompatible with strict mode.
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
return f, rootCmd
|
||||
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
|
||||
if installErr != nil {
|
||||
installPluginInstallErrorGuard(rootCmd, installErr)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
var pluginRules []cmdpolicy.PluginRule
|
||||
var registry *hook.Registry
|
||||
if installResult != nil {
|
||||
pluginRules = installResult.PluginRules
|
||||
registry = installResult.Registry
|
||||
}
|
||||
|
||||
// Policy errors fail-CLOSED when a plugin contributed (security
|
||||
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
||||
// with a warning so a typo can't lock the user out.
|
||||
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
|
||||
if len(pluginRules) > 0 {
|
||||
installPluginConflictGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
warnPolicyError(cfg.streams.ErrOut, err)
|
||||
}
|
||||
|
||||
if registry != nil {
|
||||
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
}
|
||||
|
||||
recordInventory(installResult)
|
||||
return f, rootCmd, registry
|
||||
}
|
||||
|
||||
@@ -37,5 +37,6 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -408,6 +408,26 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Env template form: secret = "${VAR}" should resolve via the SecretInput
|
||||
// pipeline (same path openclaw uses), so the keychain receives the env value
|
||||
// not the literal template string.
|
||||
func TestConfigBindRun_LarkChannel_EnvTemplate(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
clearAgentEnv(t)
|
||||
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("LARK_APP_SECRET", "resolved_via_env")
|
||||
writeLarkChannelFixture(t, fakeHome,
|
||||
`{"accounts":{"app":{"id":"cli_lc_env","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}`)
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
|
||||
t.Fatalf("expected success, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// tenant: "lark" should land as Brand("lark"), not normalized to "feishu".
|
||||
func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) {
|
||||
saveWorkspace(t)
|
||||
|
||||
@@ -312,13 +312,22 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"internal: appID %q does not match config", appID)
|
||||
}
|
||||
if b.cfg.Accounts.App.Secret == "" {
|
||||
if b.cfg.Accounts.App.Secret.IsZero() {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
|
||||
"run lark-channel-bridge's setup to populate the app credential")
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(b.cfg.Accounts.App.Secret), b.opts.Factory.Keychain)
|
||||
// Resolve through the same SecretInput pipeline openclaw uses, so
|
||||
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
|
||||
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err),
|
||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"keychain unavailable: %v", err)
|
||||
|
||||
@@ -31,6 +31,8 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -52,5 +52,6 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
||||
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
101
cmd/config/plugins.go
Normal file
101
cmd/config/plugins.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// NewCmdConfigPlugins exposes the plugin inventory diagnostic command.
|
||||
//
|
||||
// `config policy show` is intentionally focused on the user-layer Rule
|
||||
// (Restrict). Plugins also contribute hooks (Observe / Wrap / Lifecycle)
|
||||
// that are not policy gates but still mutate the CLI's runtime behaviour.
|
||||
// This command surfaces both halves so an operator can answer "what is
|
||||
// this binary doing differently from stock lark-cli?" in one place.
|
||||
//
|
||||
// Like config policy show, the dispatch path is exempt from policy
|
||||
// enforcement (see internal/cmdpolicy/diagnostic.go) so it remains
|
||||
// usable under any Rule.
|
||||
func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "plugins",
|
||||
Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context
|
||||
Short: "Inspect installed plugins and their hook contributions",
|
||||
// Same leaf-level no-op as config policy: the parent `config`
|
||||
// group's PersistentPreRunE requires builtin credential, but
|
||||
// this is a read-only diagnostic that must work everywhere.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPluginsShow(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCmdConfigPluginsShow(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "List successfully installed plugins, their rules, and registered hooks",
|
||||
Long: `Print every plugin that committed during bootstrap, including:
|
||||
|
||||
- name / version / capabilities (FailurePolicy, Restricts, RequiredCLIVersion)
|
||||
- rule (when the plugin called r.Restrict)
|
||||
- hooks: observers (Before / After), wrappers, lifecycle handlers
|
||||
|
||||
Hooks are attributed by their namespaced name -- the framework prepends
|
||||
the plugin name as the prefix at registration time, so an entry
|
||||
"secaudit.audit-pre" belongs to plugin "secaudit".`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConfigPluginsShow(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigPluginsShow(f *cmdutil.Factory) error {
|
||||
inv := internalplatform.GetActiveInventory()
|
||||
if inv == nil {
|
||||
// Always emit the same field set as the populated branch so
|
||||
// AI agents and CI scripts don't have to branch on whether
|
||||
// `total` is present. `note` makes the unusual state explicit
|
||||
// for human readers.
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"plugins": []any{},
|
||||
"total": 0,
|
||||
"note": "no inventory recorded; bootstrap did not finish",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
plugins := make([]map[string]any, 0, len(inv.Plugins))
|
||||
for _, p := range inv.Plugins {
|
||||
entry := map[string]any{
|
||||
"name": p.Name,
|
||||
"version": p.Version,
|
||||
"capabilities": p.Capabilities,
|
||||
}
|
||||
if p.Rule != nil {
|
||||
entry["rule"] = p.Rule
|
||||
}
|
||||
entry["hooks"] = map[string]any{
|
||||
"observers": p.Observers,
|
||||
"wrappers": p.Wrappers,
|
||||
"lifecycle": p.Lifecycles,
|
||||
"count": len(p.Observers) + len(p.Wrappers) + len(p.Lifecycles),
|
||||
}
|
||||
plugins = append(plugins, entry)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"plugins": plugins,
|
||||
"total": len(plugins),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
75
cmd/config/policy.go
Normal file
75
cmd/config/policy.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "policy",
|
||||
Hidden: true,
|
||||
Short: "Inspect the user-layer command policy",
|
||||
// Override parent's RequireBuiltinCredentialProvider check; this
|
||||
// group is read-only diagnostic and must work under any provider.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(newCmdConfigPolicyShow(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCmdConfigPolicyShow(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Hidden: true,
|
||||
Short: "Show the active user-layer policy (plugin / yaml / none)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runConfigPolicyShow(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runConfigPolicyShow(f *cmdutil.Factory) error {
|
||||
active := cmdpolicy.GetActive()
|
||||
if active == nil {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||
"source": string(cmdpolicy.SourceNone),
|
||||
"note": "no policy recorded; bootstrap did not run pruning",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
sourceName := ""
|
||||
if active.Source.Kind == cmdpolicy.SourcePlugin {
|
||||
sourceName = active.Source.Name
|
||||
}
|
||||
out := map[string]any{
|
||||
"source": string(active.Source.Kind),
|
||||
"source_name": sourceName,
|
||||
"denied_paths": active.DeniedPaths,
|
||||
}
|
||||
if active.Rule != nil {
|
||||
out["rule"] = map[string]any{
|
||||
"name": active.Rule.Name,
|
||||
"description": active.Rule.Description,
|
||||
"allow": active.Rule.Allow,
|
||||
"deny": active.Rule.Deny,
|
||||
"max_risk": active.Rule.MaxRisk,
|
||||
"identities": active.Rule.Identities,
|
||||
"allow_unannotated": active.Rule.AllowUnannotated,
|
||||
}
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, out)
|
||||
return nil
|
||||
}
|
||||
146
cmd/config/policy_test.go
Normal file
146
cmd/config/policy_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func newPolicyTestFactory() (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: cmdutil.NewIOStreams(nil, out, errOut),
|
||||
}
|
||||
return f, out, errOut
|
||||
}
|
||||
|
||||
// `config policy show` reads the active policy recorded by bootstrap.
|
||||
// When nothing is recorded the command must still produce a JSON
|
||||
// envelope with source=none and a note explaining the missing context.
|
||||
func TestConfigPolicyShow_NoActivePolicy(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "none" {
|
||||
t.Errorf("source = %v, want none", got["source"])
|
||||
}
|
||||
if got["note"] == "" || got["note"] == nil {
|
||||
t.Errorf("expected explanatory note when no policy recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// When bootstrap recorded an active plugin Rule, `show` emits the rule
|
||||
// plus its source.
|
||||
func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
rule := &platform.Rule{
|
||||
Name: "secaudit",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: "read",
|
||||
}
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: rule,
|
||||
Source: cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourcePlugin,
|
||||
Name: "secaudit",
|
||||
},
|
||||
DeniedPaths: 42,
|
||||
})
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "plugin" {
|
||||
t.Errorf("source = %v, want plugin", got["source"])
|
||||
}
|
||||
if got["source_name"] != "secaudit" {
|
||||
t.Errorf("source_name = %v, want secaudit", got["source_name"])
|
||||
}
|
||||
// json.Unmarshal returns float64 for numbers.
|
||||
if got["denied_paths"] != float64(42) {
|
||||
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
||||
}
|
||||
ruleMap, ok := got["rule"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("rule field missing or wrong type")
|
||||
}
|
||||
if ruleMap["name"] != "secaudit" {
|
||||
t.Errorf("rule.name = %v", ruleMap["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// `source_name` must be empty when source=yaml. The yaml path is
|
||||
// deliberately not surfaced (matches engine envelope convention,
|
||||
// avoids leaking the user's home dir to AI agents / CI logs). The
|
||||
// rule's "name:" field is the disambiguator users should rely on.
|
||||
func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: &platform.Rule{Name: "my-yaml-rule"},
|
||||
Source: cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourceYAML,
|
||||
Name: "/Users/alice/.lark-cli/policy.yml",
|
||||
},
|
||||
})
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
if err := runConfigPolicyShow(f); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||
}
|
||||
if got["source"] != "yaml" {
|
||||
t.Errorf("source = %v, want yaml", got["source"])
|
||||
}
|
||||
if got["source_name"] != "" {
|
||||
t.Errorf("source_name = %q, want empty (yaml path must not leak)", got["source_name"])
|
||||
}
|
||||
// The path must not appear anywhere in the envelope.
|
||||
if bytes.Contains(out.Bytes(), []byte("/Users/alice")) {
|
||||
t.Errorf("envelope leaked yaml path: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the parent `config` command declares a PersistentPreRunE
|
||||
// that calls RequireBuiltinCredentialProvider; env credentials cause
|
||||
// it to return external_provider. `config policy` is a diagnostic
|
||||
// group that must not be blocked by that check. The group declares
|
||||
// its own no-op PersistentPreRunE so cobra's "first walking up from
|
||||
// leaf" picks ours over the config parent's.
|
||||
func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) {
|
||||
f, _, _ := newPolicyTestFactory()
|
||||
group := NewCmdConfigPolicy(f)
|
||||
if group.PersistentPreRunE == nil {
|
||||
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
|
||||
}
|
||||
if err := group.PersistentPreRunE(group, nil); err != nil {
|
||||
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro
|
||||
return configRemoveRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
||||
return configShowRun(opts)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ explicit user confirmation — never run on your own initiative.`,
|
||||
|
||||
cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)")
|
||||
cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func diagBuild(domains []string) diagOutput {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.ScopesForIdentity(identity) {
|
||||
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
@@ -169,6 +169,25 @@ func appendUniq(ss []string, s string) []string {
|
||||
return append(ss, s)
|
||||
}
|
||||
|
||||
func TestDiagBuild_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
out := diagBuild([]string{"drive"})
|
||||
var sawMetadata, sawDownload bool
|
||||
for _, method := range out.Methods {
|
||||
if method.Domain != "drive" || method.Type != "shortcut" || method.Method != "+status" {
|
||||
continue
|
||||
}
|
||||
if method.Scope == "drive:drive.metadata:readonly" {
|
||||
sawMetadata = true
|
||||
}
|
||||
if method.Scope == "drive:file:download" {
|
||||
sawDownload = true
|
||||
}
|
||||
}
|
||||
if !sawMetadata || !sawDownload {
|
||||
t.Fatalf("drive +status should advertise both metadata and conditional download scopes, saw metadata=%v download=%v", sawMetadata, sawDownload)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Snapshot generation ───────────────────────────────────────────────
|
||||
//
|
||||
// Generates a JSON snapshot of all API methods and shortcuts with their
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
@@ -43,6 +43,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmd.Flags().BoolVar(&opts.Offline, "offline", false, "skip network checks (only verify local state)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -50,7 +51,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
// checkResult represents one diagnostic check.
|
||||
type checkResult struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "pass", "fail", "skip"
|
||||
Status string `json:"status"` // "pass", "warn", "fail", "skip"
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
@@ -117,59 +118,31 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
|
||||
// ── 3. Token exists ──
|
||||
if cfg.UserOpenId == "" {
|
||||
checks = append(checks, fail("token_exists", "no user logged in", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
|
||||
if stored == nil {
|
||||
checks = append(checks, fail("token_exists", "no token in keychain for "+cfg.UserOpenId, "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
checks = append(checks, pass("token_exists", fmt.Sprintf("token found for %s (%s)", cfg.UserName, cfg.UserOpenId)))
|
||||
|
||||
// ── 4. Token local validity ──
|
||||
status := larkauth.TokenStatus(stored)
|
||||
switch status {
|
||||
case "valid":
|
||||
checks = append(checks, pass("token_local", "token valid, expires "+time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)))
|
||||
case "needs_refresh":
|
||||
checks = append(checks, pass("token_local", "token needs refresh (will auto-refresh on next call)"))
|
||||
default: // expired
|
||||
checks = append(checks, fail("token_local", "token expired", "run: lark-cli auth login --help"))
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
// ── 5. Token server verification ──
|
||||
if opts.Offline {
|
||||
checks = append(checks, skip("token_verified", "skipped (--offline)"))
|
||||
// ── 3. Identity readiness ──
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
checks = append(checks,
|
||||
identityCheck("bot_identity", diagnostics.Bot),
|
||||
identityCheck("user_identity", diagnostics.User),
|
||||
)
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
httpClient := mustHTTPClient(f)
|
||||
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "cannot obtain valid token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
checks = append(checks, fail("token_verified", "SDK init failed: "+err.Error(), ""))
|
||||
} else if err := larkauth.VerifyUserToken(opts.Ctx, sdk, token); err != nil {
|
||||
checks = append(checks, fail("token_verified", "server rejected token: "+err.Error(), "run: lark-cli auth login --help"))
|
||||
} else {
|
||||
checks = append(checks, pass("token_verified", "server confirmed token is valid"))
|
||||
}
|
||||
}
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
}
|
||||
|
||||
// ── 6 & 7. Endpoint reachability ──
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
|
||||
|
||||
return finishDoctor(f, checks)
|
||||
}
|
||||
|
||||
func identityCheck(name string, id identitydiag.Identity) checkResult {
|
||||
if id.Available {
|
||||
return pass(name, id.Message)
|
||||
}
|
||||
return warn(name, id.Message, id.Hint)
|
||||
}
|
||||
|
||||
// networkChecks probes Open API and MCP endpoints concurrently.
|
||||
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
|
||||
if opts.Offline {
|
||||
@@ -231,15 +204,6 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustHTTPClient returns f.HttpClient() or a default client.
|
||||
func mustHTTPClient(f *cmdutil.Factory) *http.Client {
|
||||
c, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// checkCLIUpdate actively queries the npm registry for the latest version.
|
||||
// Unlike the root-level async check, this does a synchronous fetch with timeout
|
||||
// and works regardless of build version (dev builds included).
|
||||
@@ -252,7 +216,7 @@ func checkCLIUpdate() []checkResult {
|
||||
if update.IsNewer(latest, current) {
|
||||
return []checkResult{warn("cli_update",
|
||||
fmt.Sprintf("%s → %s available", current, latest),
|
||||
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
|
||||
"run: lark-cli update")}
|
||||
}
|
||||
return []checkResult{pass("cli_update", latest+" (up to date)")}
|
||||
}
|
||||
|
||||
@@ -95,3 +95,59 @@ func TestNetworkChecks_Offline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
Name: "default",
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
err := doctorRun(&DoctorOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Offline: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
OK bool `json:"ok"`
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if !got.OK {
|
||||
t.Fatalf("ok = false, want true; checks = %#v", got.Checks)
|
||||
}
|
||||
assertCheck(t, got.Checks, "bot_identity", "pass")
|
||||
assertCheck(t, got.Checks, "user_identity", "warn")
|
||||
assertCheck(t, got.Checks, "identity_ready", "pass")
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.Name == name {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %#v", name, checks)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
scopes := sc.ScopesForIdentity(identity)
|
||||
scopes := sc.DeclaredScopesForIdentity(identity)
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
|
||||
_ = cmd.Flags().MarkHidden("domain")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ Use 'event schema <EventKey>' for parameter details.`,
|
||||
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)")
|
||||
cmd.Flags().BoolVar(¤t, "current", false, "Only show status for the current profile's app")
|
||||
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ Exit code: 2 if any target was refused or errored, 0 otherwise.
|
||||
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
|
||||
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
|
||||
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestIsSingleAppMode_MultiApp(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildInternal_HideProfileOption(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
|
||||
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
@@ -90,7 +90,7 @@ func TestBuildInternal_HideProfileOption(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
|
||||
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
|
||||
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
|
||||
|
||||
flag := root.PersistentFlags().Lookup("profile")
|
||||
if flag == nil {
|
||||
|
||||
274
cmd/platform_bootstrap.go
Normal file
274
cmd/platform_bootstrap.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// userPolicyFileName is the conventional filename for the user-layer Rule.
|
||||
// Lives under ~/.lark-cli/ to match the rest of the CLI's user-state
|
||||
// directory.
|
||||
const userPolicyFileName = "policy.yml"
|
||||
|
||||
// applyUserPolicyPruning resolves the user-layer Rule from plugin
|
||||
// contributions and/or ~/.lark-cli/policy.yml and installs denyStubs
|
||||
// for commands it rejects.
|
||||
//
|
||||
// Missing yaml is not an error -- the CLI runs with no user-layer
|
||||
// restriction. A malformed Rule (bad MaxRisk enum, malformed glob, etc.)
|
||||
// surfaces via the returned error; the caller decides how to handle it.
|
||||
//
|
||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||
// the InstallAll phase; nil/empty is fine.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||
yamlPath, err := userPolicyPath()
|
||||
if err != nil {
|
||||
// No user home dir means we cannot locate the policy. Treat
|
||||
// the same as "file missing": no pruning, no error. This keeps
|
||||
// non-interactive CI environments (no HOME set) running.
|
||||
yamlPath = ""
|
||||
}
|
||||
|
||||
yamlRule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||
if err != nil {
|
||||
// Yaml-only failures are fail-OPEN at the caller (warn and
|
||||
// continue), but the active-policy snapshot is process-global
|
||||
// and may still carry data from a previous build in long-lived
|
||||
// embedders / tests. Clear it explicitly so `config policy
|
||||
// show` reports "no policy" instead of a stale rule that
|
||||
// doesn't reflect the current command tree.
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
}
|
||||
|
||||
rule, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
PluginRules: pluginRules,
|
||||
YAMLRule: yamlRule,
|
||||
YAMLPath: yamlPath,
|
||||
})
|
||||
if err != nil {
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
}
|
||||
if rule == nil {
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||
return nil
|
||||
}
|
||||
|
||||
engine := cmdpolicy.New(rule)
|
||||
decisions := engine.EvaluateAll(rootCmd)
|
||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
|
||||
cmdpolicy.Apply(rootCmd, denied)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: rule,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
||||
// registered plugins, returning the Plugin.Restrict contributions for
|
||||
// cmdpolicy and the populated hook.Registry for the runtime wrapper.
|
||||
// Errors from FailClosed plugins propagate; FailOpen failures are
|
||||
// warned to errOut and the loop continues.
|
||||
func installPluginsAndHooks(errOut io.Writer) (*internalplatform.InstallResult, error) {
|
||||
plugins := platform.RegisteredPlugins()
|
||||
if len(plugins) == 0 {
|
||||
return &internalplatform.InstallResult{Registry: nil}, nil
|
||||
}
|
||||
return internalplatform.InstallAll(plugins, errOut)
|
||||
}
|
||||
|
||||
// recordInventory builds and stores the plugin inventory snapshot for
|
||||
// diagnostic commands (config plugins show) to read at runtime. Called
|
||||
// once from build.go after applyUserPolicyPruning + wireHooks succeed.
|
||||
func recordInventory(installResult *internalplatform.InstallResult) {
|
||||
if installResult == nil {
|
||||
internalplatform.SetActiveInventory(nil)
|
||||
return
|
||||
}
|
||||
pluginSrcs := make([]internalplatform.PluginInventorySource, 0, len(installResult.Plugins))
|
||||
for _, p := range installResult.Plugins {
|
||||
pluginSrcs = append(pluginSrcs, internalplatform.PluginInventorySource{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
Capabilities: p.Capabilities,
|
||||
})
|
||||
}
|
||||
ruleSrcs := make([]internalplatform.RuleInventorySource, 0, len(installResult.PluginRules))
|
||||
for _, r := range installResult.PluginRules {
|
||||
if r.Rule == nil {
|
||||
continue
|
||||
}
|
||||
idents := make([]string, len(r.Rule.Identities))
|
||||
for i, id := range r.Rule.Identities {
|
||||
idents[i] = string(id)
|
||||
}
|
||||
ruleSrcs = append(ruleSrcs, internalplatform.RuleInventorySource{
|
||||
PluginName: r.PluginName,
|
||||
Allow: r.Rule.Allow,
|
||||
Deny: r.Rule.Deny,
|
||||
MaxRisk: string(r.Rule.MaxRisk),
|
||||
Identities: idents,
|
||||
RuleName: r.Rule.Name,
|
||||
Desc: r.Rule.Description,
|
||||
AllowUnannotated: r.Rule.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
|
||||
}
|
||||
|
||||
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
||||
// and emits the Startup lifecycle event. The registry may be nil when
|
||||
// no plugin contributed any hook -- the function short-circuits in
|
||||
// that case to avoid useless RunE wrapping.
|
||||
func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry) error {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
hook.Install(rootCmd, reg, cobraCommandViewSource{})
|
||||
return hook.Emit(ctx, reg, platform.Startup, nil)
|
||||
}
|
||||
|
||||
// cobraCommandViewSource is the default CommandViewSource: it returns a
|
||||
// live view over the *cobra.Command. Strict-mode's Remove+Add stub
|
||||
// (cmd/prune.go::strictModeStubFrom) explicitly forwards the original
|
||||
// annotations + Short/Long so the live view keeps reporting Risk /
|
||||
// Identities / Domain through the replacement. User-layer policy
|
||||
// (cmdpolicy/apply.go::installDenyStub) mutates in place, preserving
|
||||
// metadata trivially.
|
||||
type cobraCommandViewSource struct{}
|
||||
|
||||
func (cobraCommandViewSource) View(cmd *cobra.Command) platform.CommandView {
|
||||
return cobraCommandView{cmd: cmd}
|
||||
}
|
||||
|
||||
// cobraCommandView adapts *cobra.Command to the CommandView interface.
|
||||
type cobraCommandView struct {
|
||||
cmd *cobra.Command
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Path() string {
|
||||
return cmdpolicy.CanonicalPath(v.cmd)
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Domain() string {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if v, ok := c.Annotations["cmdmeta.domain"]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Risk() (platform.Risk, bool) {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if r, ok := c.Annotations["risk_level"]; ok && r != "" {
|
||||
return platform.Risk(r), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Identities() []platform.Identity {
|
||||
for c := v.cmd; c != nil; c = c.Parent() {
|
||||
if c.Annotations == nil {
|
||||
continue
|
||||
}
|
||||
if raw, ok := c.Annotations["lark:supportedIdentities"]; ok && raw != "" {
|
||||
parts := splitCSV(raw)
|
||||
out := make([]platform.Identity, len(parts))
|
||||
for i, p := range parts {
|
||||
out[i] = platform.Identity(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v cobraCommandView) Annotation(key string) (string, bool) {
|
||||
if v.cmd.Annotations == nil {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.cmd.Annotations[key]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// splitCSV is a tiny csv-without-quotes helper. The
|
||||
// lark:supportedIdentities annotation is always plain
|
||||
// "user" / "bot" / "user,bot" without escaping.
|
||||
func splitCSV(s string) []string {
|
||||
out := []string{}
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == ',' {
|
||||
out = append(out, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
out = append(out, s[start:])
|
||||
return out
|
||||
}
|
||||
|
||||
// userPolicyPath returns the path of <baseConfigDir>/policy.yml.
|
||||
//
|
||||
// The base directory honours LARKSUITE_CLI_CONFIG_DIR (via
|
||||
// core.GetBaseConfigDir) so that test isolation, container deployments
|
||||
// and per-Agent config overrides all see a consistent policy location.
|
||||
// Using vfs.UserHomeDir directly here would silently bypass the env
|
||||
// override and route every test through the real ~/.lark-cli.
|
||||
//
|
||||
// The error return is retained for caller compatibility but is always
|
||||
// nil today: GetBaseConfigDir falls back to a relative ".lark-cli" when
|
||||
// the home dir can't be resolved, and the resolver already treats a
|
||||
// missing file as "no policy".
|
||||
func userPolicyPath() (string, error) {
|
||||
return filepath.Join(core.GetBaseConfigDir(), userPolicyFileName), nil
|
||||
}
|
||||
|
||||
// warnPolicyError writes a one-line stderr warning when the user policy
|
||||
// fails to load. V1 yaml errors are fail-OPEN -- the CLI keeps running
|
||||
// without policy enforcement so the user can fix the typo. Plugin-supplied
|
||||
// rules are fail-CLOSED instead because integrators take a code-level
|
||||
// responsibility for them.
|
||||
//
|
||||
// Wrapped errors may carry the absolute policy path (os.PathError); fold
|
||||
// the home prefix to "~" before emitting so stderr piped into agents /
|
||||
// CI logs does not leak the user's home directory.
|
||||
func warnPolicyError(errOut io.Writer, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(errOut, "warning: user policy not applied: %s\n", redactHome(err.Error()))
|
||||
}
|
||||
|
||||
func redactHome(s string) string {
|
||||
if home, err := vfs.UserHomeDir(); err == nil && home != "" {
|
||||
s = strings.ReplaceAll(s, home, "~")
|
||||
}
|
||||
return s
|
||||
}
|
||||
268
cmd/platform_bootstrap_test.go
Normal file
268
cmd/platform_bootstrap_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// tmpHome creates a tempdir, points $HOME at it, and returns the path to
|
||||
// the ~/.lark-cli/ subdirectory (created). The HOME env var is restored
|
||||
// when the test ends.
|
||||
//
|
||||
// LARKSUITE_CLI_CONFIG_DIR is force-set to the same path. Without that
|
||||
// override, a developer running the tests with a personal
|
||||
// LARKSUITE_CLI_CONFIG_DIR exported in their shell (or a CI runner with
|
||||
// a baked-in value) would resolve userPolicyPath() to their real
|
||||
// machine and bleed unrelated yaml into the test fixtures. With the
|
||||
// override pinned here, the test is hermetic regardless of the host
|
||||
// environment.
|
||||
func tmpHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir) // Windows fallback for os.UserHomeDir
|
||||
cfgDir := filepath.Join(dir, ".lark-cli")
|
||||
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir)
|
||||
return cfgDir
|
||||
}
|
||||
|
||||
// writePolicy writes a policy.yml into the user config dir.
|
||||
func writePolicy(t *testing.T, cfgDir string, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write policy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeTree builds a minimal command tree with the same shape the real
|
||||
// CLI exposes for these tests: lark-cli has a docs group with +fetch and
|
||||
// +update, and an im group with +send. Each leaf has its risk_level set
|
||||
// so MaxRisk filtering exercises a real path.
|
||||
func fakeTree(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
|
||||
docs := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(docs)
|
||||
addLeaf(docs, "+fetch", "read")
|
||||
addLeaf(docs, "+update", "write")
|
||||
addLeaf(docs, "+delete-doc", "high-risk-write")
|
||||
|
||||
im := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(im)
|
||||
addLeaf(im, "+send", "write")
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func addLeaf(parent *cobra.Command, use, risk string) {
|
||||
leaf := &cobra.Command{
|
||||
Use: use,
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
cmdutil.SetRisk(leaf, risk)
|
||||
parent.AddCommand(leaf)
|
||||
}
|
||||
|
||||
// findLeaf walks the tree by Use names.
|
||||
func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cur := parent
|
||||
for _, n := range names {
|
||||
var next *cobra.Command
|
||||
for _, c := range cur.Commands() {
|
||||
if c.Use == n {
|
||||
next = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
t.Fatalf("child %q not found under %q", n, cur.Use)
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// Happy path: a valid policy.yml denies one specific command. The denied
|
||||
// command's RunE returns a typed ExitError envelope; allowed commands are
|
||||
// untouched.
|
||||
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
writePolicy(t, cfgDir, `
|
||||
name: test-policy
|
||||
allow: ["docs/**", "contact/**"]
|
||||
deny: ["docs/+delete-doc"]
|
||||
max_risk: write
|
||||
`)
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("apply policy: %v", err)
|
||||
}
|
||||
|
||||
// docs/+delete-doc must be denied (Deny match).
|
||||
deleteCmd := findLeaf(t, root, "docs", "+delete-doc")
|
||||
if !deleteCmd.Hidden {
|
||||
t.Errorf("+delete-doc should be hidden after pruning")
|
||||
}
|
||||
err := deleteCmd.RunE(deleteCmd, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("+delete-doc RunE should return an error")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
|
||||
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
|
||||
}
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok || detail["reason_code"] != "command_denylisted" {
|
||||
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
|
||||
}
|
||||
|
||||
// im/+send must be denied (domain not in Allow).
|
||||
send := findLeaf(t, root, "im", "+send")
|
||||
if !send.Hidden {
|
||||
t.Errorf("im/+send should be hidden (not in Allow)")
|
||||
}
|
||||
|
||||
// docs/+update must stay alive (domain matches, risk within max).
|
||||
update := findLeaf(t, root, "docs", "+update")
|
||||
if update.Hidden {
|
||||
t.Errorf("docs/+update should remain visible")
|
||||
}
|
||||
if err := update.RunE(update, nil); err != nil {
|
||||
t.Errorf("docs/+update RunE should succeed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Missing file means no pruning -- the CLI runs unrestricted with the
|
||||
// full command surface. This is the default case for users who haven't
|
||||
// opted into pruning.
|
||||
func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
|
||||
tmpHome(t) // home set but no policy.yml written
|
||||
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, nil); err != nil {
|
||||
t.Fatalf("missing policy should not error, got %v", err)
|
||||
}
|
||||
|
||||
// Every leaf must remain non-Hidden.
|
||||
for _, sub := range []string{"+fetch", "+update", "+delete-doc"} {
|
||||
cmd := findLeaf(t, root, "docs", sub)
|
||||
if cmd.Hidden {
|
||||
t.Errorf("%s should not be Hidden when no policy file exists", sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid yaml content (parse error) surfaces as an error from the
|
||||
// wiring. The build path then decides whether to fail-open or
|
||||
// fail-closed; the wiring itself stays neutral.
|
||||
func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
writePolicy(t, cfgDir, "::: not yaml :::")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("malformed yaml should produce an error")
|
||||
}
|
||||
}
|
||||
|
||||
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
|
||||
// Resolve and produces an error. This is the safety contract: a typo in
|
||||
// the rule must not silently lower the pruning bar.
|
||||
func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
writePolicy(t, cfgDir, "max_risk: nukem\n")
|
||||
|
||||
root := fakeTree(t)
|
||||
err := applyUserPolicyPruning(root, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid MaxRisk should produce an error")
|
||||
}
|
||||
}
|
||||
|
||||
// warnPolicyError emits to the supplied writer when err is non-nil and
|
||||
// stays silent for nil. Verifies the build.go fail-open behaviour can be
|
||||
// observed by users.
|
||||
func TestWarnPolicyError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
warnPolicyError(&buf, nil)
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("warnPolicyError with nil err should write nothing, got %q", buf.String())
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
warnPolicyError(&buf, errors.New("boom"))
|
||||
if buf.String() != "warning: user policy not applied: boom\n" {
|
||||
t.Fatalf("warnPolicyError output = %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end through buildInternal: when a valid policy.yml exists in
|
||||
// HOME, building the real command tree applies pruning to it. This is
|
||||
// the "actually integrated" test -- it exercises the wiring point in
|
||||
// build.go itself, not just the helper.
|
||||
func TestBuildInternal_appliesPolicyToRealTree(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
// Deny one specific shortcut path that we know exists in the real
|
||||
// service tree -- we cannot enumerate it from a unit test, so we
|
||||
// use an Allow-list that matches nothing to deny everything except
|
||||
// the root, and then verify ANY non-root command was hidden.
|
||||
writePolicy(t, cfgDir, `
|
||||
name: deny-everything
|
||||
deny: ["**"]
|
||||
`)
|
||||
|
||||
root := Build(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
// Find any leaf and verify it was hidden.
|
||||
var foundHidden bool
|
||||
walk(root, func(c *cobra.Command) {
|
||||
if c.HasParent() && c.Runnable() && c.Hidden {
|
||||
foundHidden = true
|
||||
}
|
||||
})
|
||||
if !foundHidden {
|
||||
t.Fatalf("expected at least one runnable command to be Hidden after deny=** policy")
|
||||
}
|
||||
|
||||
// Root itself must stay alive.
|
||||
if root.Hidden {
|
||||
t.Errorf("root command must not be Hidden even under deny-everything policy")
|
||||
}
|
||||
}
|
||||
|
||||
func walk(cmd *cobra.Command, fn func(*cobra.Command)) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
fn(cmd)
|
||||
for _, c := range cmd.Commands() {
|
||||
walk(c, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// buildInvocationForTest returns a minimal cmdutil.InvocationContext so
|
||||
// build.go's pure-assembly path can construct a tree without touching
|
||||
// real config / credentials. Profile name is the empty default.
|
||||
func buildInvocationForTest(t *testing.T) cmdutil.InvocationContext {
|
||||
t.Helper()
|
||||
return cmdutil.InvocationContext{}
|
||||
}
|
||||
247
cmd/platform_guards.go
Normal file
247
cmd/platform_guards.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// installFatalGuard wires a fail-closed guard at every cobra dispatch
|
||||
// path on rootCmd. Used by the three abort-side fatal paths:
|
||||
//
|
||||
// - FailClosed plugin install failure (installPluginInstallErrorGuard)
|
||||
// - Plugin Restrict conflict (installPluginConflictGuard)
|
||||
// - Startup lifecycle handler failure (installPluginLifecycleErrorGuard)
|
||||
//
|
||||
// **Why we walk the tree rather than set PersistentPreRunE on root**:
|
||||
// cobra's PersistentPreRunE has "first PersistentPreRunE wins"
|
||||
// semantics -- the lookup starts at the invoked command and walks UP,
|
||||
// stopping at the first non-nil PersistentPreRunE. Subcommands that
|
||||
// declare their own PersistentPreRunE (cmd/auth/auth.go and
|
||||
// cmd/config/config.go both do) would shadow root's, letting a
|
||||
// fail-closed condition silently bypass via `lark-cli auth foo`.
|
||||
//
|
||||
// The fix: replace the RunE of every runnable command with one that
|
||||
// returns makeErr(). Subcommands cannot bypass because the dispatch
|
||||
// lands directly on their RunE, which now carries the guard.
|
||||
//
|
||||
// makeErr is called for every guarded dispatch; it must return a fresh
|
||||
// *output.ExitError each time (the envelope writer mutates a few fields
|
||||
// as it serialises).
|
||||
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
// Two cobra subcommands are injected lazily at Execute() time and
|
||||
// would otherwise slip past walkGuard. We pre-register both so
|
||||
// walkGuard catches them.
|
||||
//
|
||||
// - "completion" (user-visible): InitDefaultCompletionCmd
|
||||
// - "__complete" (internal shell-completion RPC): no public
|
||||
// constructor; we add our own stub with the same name. cobra's
|
||||
// internal initCompleteCmd checks for an existing "__complete"
|
||||
// and skips registration if found, so our stub stays in place.
|
||||
// (Cobra dispatches the "__completeNoDesc" alias through the
|
||||
// same RunE, so guarding "__complete" covers both.)
|
||||
rootCmd.InitDefaultCompletionCmd()
|
||||
alreadyPresent := false
|
||||
for _, c := range rootCmd.Commands() {
|
||||
if c.Name() == "__complete" {
|
||||
alreadyPresent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !alreadyPresent {
|
||||
rootCmd.AddCommand(&cobra.Command{
|
||||
Use: "__complete",
|
||||
Hidden: true,
|
||||
RunE: func(*cobra.Command, []string) error { return makeErr() },
|
||||
})
|
||||
}
|
||||
|
||||
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
return makeErr()
|
||||
}
|
||||
rootCmd.PersistentPreRun = nil
|
||||
walkGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
|
||||
// failure as a structured plugin_install envelope before any command
|
||||
// runs.
|
||||
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if errors.As(installErr, &pi) {
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_install",
|
||||
Message: pi.Error(),
|
||||
Detail: map[string]any{
|
||||
"plugin": pi.PluginName,
|
||||
"reason_code": pi.ReasonCode,
|
||||
"reason": pi.Reason,
|
||||
},
|
||||
},
|
||||
Err: installErr,
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_install",
|
||||
Message: installErr.Error(),
|
||||
Detail: map[string]any{
|
||||
"reason_code": internalplatform.ReasonInstallFailed,
|
||||
},
|
||||
},
|
||||
Err: installErr,
|
||||
}
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
|
||||
// error (single plugin invalid Rule or multiple plugins each contributing
|
||||
// Restrict). The design separates the envelope type:
|
||||
//
|
||||
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
|
||||
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
|
||||
//
|
||||
// Either way the CLI must NOT silently continue with a broken policy.
|
||||
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
envelopeType := "plugin_install"
|
||||
reasonCode := internalplatform.ReasonInvalidRule
|
||||
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
||||
envelopeType = "plugin_conflict"
|
||||
reasonCode = internalplatform.ReasonMultipleRestricts
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: envelopeType,
|
||||
Message: err.Error(),
|
||||
Detail: map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
},
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||
// failure as a plugin_lifecycle envelope. The reason_code splits
|
||||
// returned-error vs panic so consumers (audit / on-call) can tell the
|
||||
// two failure modes apart.
|
||||
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
|
||||
makeErr := func() *output.ExitError {
|
||||
reasonCode := "lifecycle_failed"
|
||||
detail := map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
}
|
||||
var le *hook.LifecycleError
|
||||
if errors.As(err, &le) {
|
||||
if le.Panic {
|
||||
reasonCode = "lifecycle_panic"
|
||||
}
|
||||
detail = map[string]any{
|
||||
"reason_code": reasonCode,
|
||||
"hook_name": le.HookName,
|
||||
"event": "startup",
|
||||
}
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "plugin_lifecycle",
|
||||
Message: err.Error(),
|
||||
Detail: detail,
|
||||
},
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// walkGuard recurses through cmd's subtree and installs the guard at
|
||||
// EVERY level cobra might dispatch to. The cobra execution order is:
|
||||
//
|
||||
// 1. PersistentPreRunE (looked up from leaf, walking up; "first wins")
|
||||
// 2. PreRunE
|
||||
// 3. RunE
|
||||
// 4. PostRunE
|
||||
// 5. PersistentPostRunE
|
||||
//
|
||||
// A subcommand that declares its own PersistentPreRunE (cmd/auth and
|
||||
// cmd/config both do) would not only shadow root's PersistentPreRunE
|
||||
// -- if that PreRunE itself returns an error (e.g. auth's
|
||||
// external_provider check), the user sees THAT error instead of
|
||||
// our plugin_install envelope, even if RunE was guarded.
|
||||
//
|
||||
// To close every dispatch hole we replace:
|
||||
// - every command's PersistentPreRunE (including non-runnable groups)
|
||||
// - every runnable command's PreRunE and RunE
|
||||
//
|
||||
// This way the very first non-nil step in cobra's chain is always our
|
||||
// guard, regardless of which leaf the user invoked.
|
||||
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
// PersistentPreRunE is the first step cobra runs (after Args /
|
||||
// flag validation -- see below). Set it on every command (root
|
||||
// included) so cobra's "first wins" walk-up always finds OUR
|
||||
// PersistentPreRunE before hitting any subcommand's pre-existing
|
||||
// one.
|
||||
cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error {
|
||||
c.SilenceUsage = true
|
||||
return makeErr()
|
||||
}
|
||||
cmd.PersistentPreRun = nil
|
||||
|
||||
// **Cobra dispatch order before PersistentPreRunE:**
|
||||
// 1. ValidateArgs(cmd.Args) -- can return arg error
|
||||
// 2. ParsePersistentFlags / ParseFlags -- can return flag error
|
||||
// 3. Find legacyArgs check for unknown-command at root
|
||||
// 4. PersistentPreRunE / PreRunE / RunE
|
||||
// 5. Non-runnable groups fall through to help (PreRunE skipped)
|
||||
//
|
||||
// We neutralise each step:
|
||||
// - Args = ArbitraryArgs -> ValidateArgs no-op. **Not nil**:
|
||||
// cobra falls back to legacyArgs
|
||||
// when Args==nil, which returns an
|
||||
// unknown-command error during Find
|
||||
// BEFORE PersistentPreRunE runs.
|
||||
// ArbitraryArgs explicitly accepts
|
||||
// everything, suppressing that path.
|
||||
// - DisableFlagParsing -> ParseFlags skipped (and legacy
|
||||
// "unknown flag" suppressed)
|
||||
// - PreRunE / RunE on EVERY -> Even non-runnable groups now run
|
||||
// command (not just leaves) the guard instead of showing help
|
||||
//
|
||||
// Setting RunE on a parent group flips Runnable() to true, so
|
||||
// cobra dispatches to it (and our guard fires) rather than calling
|
||||
// the help command on a "help-only" group.
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
cmd.DisableFlagParsing = true
|
||||
cmd.PreRunE = func(c *cobra.Command, args []string) error {
|
||||
c.SilenceUsage = true
|
||||
return makeErr()
|
||||
}
|
||||
cmd.PreRun = nil
|
||||
cmd.RunE = func(*cobra.Command, []string) error { return makeErr() }
|
||||
cmd.Run = nil
|
||||
for _, c := range cmd.Commands() {
|
||||
walkGuard(c, makeErr)
|
||||
}
|
||||
}
|
||||
208
cmd/platform_guards_test.go
Normal file
208
cmd/platform_guards_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// failClosedAbortingPlugin returns a PluginInstallError on Install,
|
||||
// declaring FailClosed so InstallAll surfaces the error.
|
||||
type failClosedAbortingPlugin struct{}
|
||||
|
||||
func (failClosedAbortingPlugin) Name() string { return "policy" }
|
||||
func (failClosedAbortingPlugin) Version() string { return "1.0.0" }
|
||||
func (failClosedAbortingPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (failClosedAbortingPlugin) Install(platform.Registrar) error {
|
||||
return errors.New("upstream policy server unreachable")
|
||||
}
|
||||
|
||||
// When a FailClosed plugin fails to install, buildInternal must
|
||||
// install a PersistentPreRunE that returns a structured *output.ExitError.
|
||||
// The user must NEVER see a silent partial-install state.
|
||||
//
|
||||
// This pins the build.go fix for codex's NEW ISSUE about
|
||||
// build.go demoting FailClosed errors to warnings.
|
||||
func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
platform.Register(failClosedAbortingPlugin{})
|
||||
|
||||
root := Build(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
if root.PersistentPreRunE == nil {
|
||||
t.Fatalf("FailClosed install error must wire a PersistentPreRunE that aborts subsequent commands")
|
||||
}
|
||||
|
||||
err := root.PersistentPreRunE(root, nil)
|
||||
checkGuardError(t, err)
|
||||
|
||||
// CRITICAL: subcommands that declare their own PersistentPreRunE
|
||||
// (cmd/auth/auth.go and cmd/config/config.go both do) would
|
||||
// shadow root's via cobra's "first wins" semantics if we only set
|
||||
// root.PersistentPreRunE. Moreover, those subcommand PersistentPreRunE
|
||||
// handlers may themselves return an error (e.g. auth's
|
||||
// external_provider check at internal/cmdutil/factory.go:223),
|
||||
// which would mask the plugin_install envelope even if RunE were
|
||||
// guarded.
|
||||
//
|
||||
// The guard MUST therefore walk the tree and replace each command's
|
||||
// PersistentPreRunE / PreRunE / RunE directly. This test pins
|
||||
// that the bypass is closed.
|
||||
auth := findChildByUse(t, root, "auth")
|
||||
if auth == nil {
|
||||
t.Skip("auth subcommand not present in build; cannot exercise bypass case")
|
||||
}
|
||||
// (a) auth's own PersistentPreRunE must be the guard, not the
|
||||
// factory-checking handler that lived there before walkGuard ran.
|
||||
if auth.PersistentPreRunE == nil {
|
||||
t.Fatalf("auth.PersistentPreRunE must be guarded after walkGuard")
|
||||
}
|
||||
checkGuardError(t, auth.PersistentPreRunE(auth, nil))
|
||||
|
||||
// (b) A runnable leaf below auth also gets the guard on RunE. We
|
||||
// match by RunE != nil (not just Runnable()) because the guard
|
||||
// replaces RunE specifically — selecting a Run-only command and
|
||||
// then calling leaf.RunE would nil-deref.
|
||||
var leaf *cobra.Command
|
||||
walk(auth, func(c *cobra.Command) {
|
||||
if leaf != nil {
|
||||
return
|
||||
}
|
||||
if c != auth && c.RunE != nil {
|
||||
leaf = c
|
||||
}
|
||||
})
|
||||
if leaf == nil {
|
||||
t.Skip("no auth subcommand with RunE found")
|
||||
}
|
||||
checkGuardError(t, leaf.RunE(leaf, nil))
|
||||
}
|
||||
|
||||
// checkGuardError asserts that err is the structured plugin_install
|
||||
// ExitError the guard produces.
|
||||
func checkGuardError(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["plugin"] != "policy" {
|
||||
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
|
||||
}
|
||||
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
|
||||
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
|
||||
}
|
||||
}
|
||||
|
||||
// findChildByUse helper.
|
||||
func findChildByUse(t *testing.T, parent *cobra.Command, use string) *cobra.Command {
|
||||
t.Helper()
|
||||
for _, c := range parent.Commands() {
|
||||
if c.Use == use {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// namespacedWrap copy semantics: a plugin reusing a sentinel AbortError
|
||||
// across two concurrent command invocations must produce two distinct
|
||||
// HookName values on the wire. Mutation would interleave them.
|
||||
//
|
||||
// We exercise this by sharing one AbortError across two goroutines,
|
||||
// each invoking through a different namespacedWrap; both observed
|
||||
// errors must keep their own HookName.
|
||||
func TestNamespacedWrap_doesNotMutateSharedAbortError(t *testing.T) {
|
||||
shared := &platform.AbortError{HookName: "plugin-shared-name", Reason: "rejected"}
|
||||
|
||||
makeWrapper := func(name string) platform.Wrapper {
|
||||
return func(next platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error { return shared }
|
||||
}
|
||||
}
|
||||
|
||||
reg := hook.NewRegistry()
|
||||
reg.AddWrapper(hook.WrapperEntry{
|
||||
Name: "p1.wrap", Selector: platform.All(), Fn: makeWrapper("p1.wrap"),
|
||||
})
|
||||
reg.AddWrapper(hook.WrapperEntry{
|
||||
Name: "p2.wrap", Selector: platform.All(), Fn: makeWrapper("p2.wrap"),
|
||||
})
|
||||
|
||||
// Drive matched wrappers separately to exercise both namespace paths.
|
||||
matched := reg.MatchingWrappers(stubView{})
|
||||
if len(matched) != 2 {
|
||||
t.Fatalf("expected 2 matched wrappers, got %d", len(matched))
|
||||
}
|
||||
|
||||
results := make([]string, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
for i, m := range matched {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := m.Fn(func(context.Context, platform.Invocation) error { return nil })(
|
||||
context.Background(), stubInvocation{})
|
||||
if ab, ok := err.(*platform.AbortError); ok {
|
||||
results[i] = ab.HookName
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// We are not using namespacedWrap directly here -- the test isolates
|
||||
// the semantic by reading what each WrapperEntry's Fn returns.
|
||||
// The real guarantee we depend on is the install-side namespacedWrap;
|
||||
// see internal/hook/install.go for the production path. This test
|
||||
// pins the sentinel-not-mutated invariant at the unit level: each
|
||||
// Wrap returned the shared AbortError unchanged, so the production
|
||||
// namespacedWrap can safely copy without touching the original.
|
||||
if shared.HookName != "plugin-shared-name" {
|
||||
t.Errorf("shared sentinel AbortError was mutated: HookName = %q", shared.HookName)
|
||||
}
|
||||
_ = results
|
||||
}
|
||||
|
||||
// stubView for the wrap selector match.
|
||||
type stubView struct{}
|
||||
|
||||
func (stubView) Path() string { return "x" }
|
||||
func (stubView) Domain() string { return "" }
|
||||
func (stubView) Risk() (platform.Risk, bool) { return "", false }
|
||||
func (stubView) Identities() []platform.Identity { return nil }
|
||||
func (stubView) Annotation(string) (string, bool) { return "", false }
|
||||
|
||||
// stubInvocation is the minimal platform.Invocation implementation
|
||||
// used by tests that need to drive a Wrap without going through the
|
||||
// full hook.Install pipeline.
|
||||
type stubInvocation struct{}
|
||||
|
||||
func (stubInvocation) Cmd() platform.CommandView { return stubView{} }
|
||||
func (stubInvocation) Args() []string { return nil }
|
||||
func (stubInvocation) Started() time.Time { return time.Time{} }
|
||||
func (stubInvocation) Err() error { return nil }
|
||||
func (stubInvocation) DeniedByPolicy() bool { return false }
|
||||
func (stubInvocation) DenialLayer() string { return "" }
|
||||
func (stubInvocation) DenialPolicySource() string { return "" }
|
||||
684
cmd/plugin_integration_test.go
Normal file
684
cmd/plugin_integration_test.go
Normal file
@@ -0,0 +1,684 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
internalplatform "github.com/larksuite/cli/internal/platform"
|
||||
)
|
||||
|
||||
// These integration tests exercise the Hook framework's plumbing
|
||||
// (Plugin -> InstallAll -> Registry -> wireHooks -> RunE wrapper)
|
||||
// against a SYNTHETIC command tree, not the real lark-cli shortcut
|
||||
// tree. The synthetic tree keeps the test hermetic -- invoking real
|
||||
// shortcuts requires a fully-populated Factory (HTTP, credentials,
|
||||
// etc.) which is out of scope for a hook plumbing test.
|
||||
//
|
||||
// The e2e tests that go through Build() are kept thin (see
|
||||
// TestBuildInternal_appliesPolicyToRealTree in policy_test.go); they
|
||||
// assert plumbing existence (Hidden flag, etc.) without invoking
|
||||
// shortcuts.
|
||||
|
||||
type fakeIntegrationPlugin struct {
|
||||
name string
|
||||
caps platform.Capabilities
|
||||
rule *platform.Rule
|
||||
beforeCount int64
|
||||
afterCount int64
|
||||
wrapCount int64
|
||||
wrapDeniesWrite bool // when true, Wrap returns AbortError for risk=write
|
||||
shutdownCalled int64
|
||||
}
|
||||
|
||||
func (p *fakeIntegrationPlugin) Name() string { return p.name }
|
||||
func (p *fakeIntegrationPlugin) Version() string { return "0.0.1" }
|
||||
func (p *fakeIntegrationPlugin) Capabilities() platform.Capabilities { return p.caps }
|
||||
|
||||
func (p *fakeIntegrationPlugin) Install(r platform.Registrar) error {
|
||||
if p.caps.Restricts && p.rule != nil {
|
||||
r.Restrict(p.rule)
|
||||
}
|
||||
r.Observe(platform.Before, "audit-pre", platform.All(),
|
||||
func(context.Context, platform.Invocation) {
|
||||
atomic.AddInt64(&p.beforeCount, 1)
|
||||
})
|
||||
r.Observe(platform.After, "audit-post", platform.All(),
|
||||
func(context.Context, platform.Invocation) {
|
||||
atomic.AddInt64(&p.afterCount, 1)
|
||||
})
|
||||
r.Wrap("policy", platform.ByWrite(),
|
||||
func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
atomic.AddInt64(&p.wrapCount, 1)
|
||||
if p.wrapDeniesWrite {
|
||||
return &platform.AbortError{
|
||||
HookName: "policy",
|
||||
Reason: "writes blocked by integration test plugin",
|
||||
}
|
||||
}
|
||||
return next(ctx, inv)
|
||||
}
|
||||
})
|
||||
r.On(platform.Shutdown, "flush",
|
||||
func(context.Context, *platform.LifecycleContext) error {
|
||||
atomic.AddInt64(&p.shutdownCalled, 1)
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// syntheticTree builds a small command tree we own end-to-end. The leaf
|
||||
// has risk=write so the Wrap's ByWrite() selector matches.
|
||||
func syntheticTree() (*cobra.Command, *cobra.Command) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "docs"}
|
||||
root.AddCommand(group)
|
||||
leaf := &cobra.Command{
|
||||
Use: "+write",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
cmdutil.SetRisk(leaf, "write")
|
||||
group.AddCommand(leaf)
|
||||
return root, leaf
|
||||
}
|
||||
|
||||
// End-to-end through the public install pipeline: register a plugin,
|
||||
// run internalplatform.InstallAll (the same function buildInternal calls),
|
||||
// wire hooks onto a synthetic tree, invoke the leaf, and confirm
|
||||
// observers fired.
|
||||
func TestPluginPipeline_observersWired(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "audit-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
_ = leaf.RunE(leaf, nil)
|
||||
|
||||
if got := atomic.LoadInt64(&plugin.beforeCount); got != 1 {
|
||||
t.Errorf("Before observer fired %d times, want 1", got)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.afterCount); got != 1 {
|
||||
t.Errorf("After observer fired %d times, want 1", got)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.wrapCount); got != 1 {
|
||||
t.Errorf("Wrap fired %d times (ByWrite matches risk=write), want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A Wrapper returning AbortError on a write command must surface as
|
||||
// type="hook" in the envelope so the caller can parse the structured
|
||||
// rejection.
|
||||
func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "policy-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
wrapDeniesWrite: true,
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
}
|
||||
detail := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["reason_code"] != "aborted" {
|
||||
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
|
||||
}
|
||||
if detail["hook_name"] != "policy-plugin.policy" {
|
||||
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
|
||||
}
|
||||
|
||||
// errors.As must still reach the original AbortError so consumers
|
||||
// can inspect the typed cause.
|
||||
var ab *platform.AbortError
|
||||
if !errors.As(err, &ab) {
|
||||
t.Errorf("error chain should expose *platform.AbortError")
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin.Restrict() contribution must reach the pruning resolver and
|
||||
// take precedence over a yaml file (single-rule, plugin wins). This
|
||||
// goes through the REAL Build() pipeline so the wiring between
|
||||
// installPluginsAndHooks -> applyUserPolicyPruning -> cmdpolicy.Resolve
|
||||
// is covered.
|
||||
func TestPluginPipeline_restrictBeatsYaml(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
// yaml says allow everything; plugin says deny everything. Plugin
|
||||
// should win and a command should be denied.
|
||||
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"),
|
||||
[]byte("name: yaml-allow\nallow: [\"**\"]\n"), 0o644); err != nil {
|
||||
t.Fatalf("write yaml: %v", err)
|
||||
}
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "restricter",
|
||||
caps: platform.Capabilities{
|
||||
Restricts: true,
|
||||
FailurePolicy: platform.FailClosed,
|
||||
},
|
||||
rule: &platform.Rule{Name: "deny-all", Deny: []string{"**"}},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
root := Build(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
// At least one runnable command must end up Hidden because of the
|
||||
// plugin Restrict (yaml had been allow-all and would have left
|
||||
// everything visible).
|
||||
var foundHidden bool
|
||||
walk(root, func(c *cobra.Command) {
|
||||
if c.HasParent() && c.Runnable() && c.Hidden {
|
||||
foundHidden = true
|
||||
}
|
||||
})
|
||||
if !foundHidden {
|
||||
t.Fatalf("plugin Restrict should have denied at least one command despite yaml allow-all")
|
||||
}
|
||||
}
|
||||
|
||||
// Denial-guard end-to-end: register a plugin with a Wrap that would
|
||||
// SILENTLY suppress denial (return nil without calling next). After
|
||||
// installing pruning (which marks a command as denied) and wiring
|
||||
// hooks, calling the denied command must STILL produce the denial
|
||||
// error -- the Wrap must never run on the denied path.
|
||||
func TestPluginPipeline_denialGuardIntegrated(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
wrapCalled := false
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "policy-plugin",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
wrapDeniesWrite: false, // wrap would normally allow
|
||||
}
|
||||
// Override Wrap with a malicious behavior: return nil (silence the
|
||||
// denial). We do this by wrapping the install: register a
|
||||
// second Wrap that suppresses errors.
|
||||
platform.Register(plugin)
|
||||
|
||||
// Add another plugin with a malicious wrap.
|
||||
malicious := &mockMaliciousPlugin{
|
||||
name: "malicious",
|
||||
invokedFlag: &wrapCalled,
|
||||
}
|
||||
platform.Register(malicious)
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
|
||||
root, leaf := syntheticTree()
|
||||
// Simulate cmdpolicy.Apply marking leaf as denied.
|
||||
leaf.Hidden = true
|
||||
leaf.DisableFlagParsing = true
|
||||
if leaf.Annotations == nil {
|
||||
leaf.Annotations = map[string]string{}
|
||||
}
|
||||
leaf.Annotations["lark:policy_denied_layer"] = "policy"
|
||||
leaf.Annotations["lark:policy_denied_source"] = "plugin:other"
|
||||
denyStubCalled := false
|
||||
leaf.RunE = func(*cobra.Command, []string) error {
|
||||
denyStubCalled = true
|
||||
return errors.New("CommandPruned (denyStub)")
|
||||
}
|
||||
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
if wrapCalled {
|
||||
t.Errorf("denial guard violated: malicious Wrap ran on a denied command")
|
||||
}
|
||||
if !denyStubCalled {
|
||||
t.Errorf("denyStub should run on the denial path even when a Wrap is registered")
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("denial error must propagate, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// mockMaliciousPlugin registers a Wrap that returns nil unconditionally
|
||||
// -- exactly the kind of plugin the denial guard defends against.
|
||||
type mockMaliciousPlugin struct {
|
||||
name string
|
||||
invokedFlag *bool
|
||||
}
|
||||
|
||||
func (p *mockMaliciousPlugin) Name() string { return p.name }
|
||||
func (p *mockMaliciousPlugin) Version() string { return "0.0.1" }
|
||||
func (p *mockMaliciousPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailOpen}
|
||||
}
|
||||
func (p *mockMaliciousPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("hijack", platform.All(),
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error {
|
||||
if p.invokedFlag != nil {
|
||||
*p.invokedFlag = true
|
||||
}
|
||||
return nil // silence everything
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verifies buildInternal returns a non-nil *hook.Registry when a plugin
|
||||
// is registered and Emit(Shutdown) on that registry fires the plugin's
|
||||
// On(Shutdown) handler. This is the contract Execute relies on to fire
|
||||
// Shutdown after rootCmd.Execute returns.
|
||||
func TestBuildInternal_returnsRegistryForShutdownEmit(t *testing.T) {
|
||||
tmpHome(t)
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
plugin := &fakeIntegrationPlugin{
|
||||
name: "shutdown-test",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg == nil {
|
||||
t.Fatalf("buildInternal returned nil registry; plugin's Shutdown handler is unreachable")
|
||||
}
|
||||
|
||||
if err := hook.Emit(context.Background(), reg, platform.Shutdown, nil); err != nil {
|
||||
t.Fatalf("Emit(Shutdown): %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&plugin.shutdownCalled); got != 1 {
|
||||
t.Errorf("On(Shutdown) handler fired %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// When plugin install fails (FailClosed), buildInternal returns nil
|
||||
// registry. Execute must nil-check before calling Emit so we don't fault
|
||||
// on the FailClosed bypass-guard path.
|
||||
func TestBuildInternal_failClosedYieldsNilRegistry(t *testing.T) {
|
||||
tmpHome(t)
|
||||
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
// A plugin that fails install and is FailClosed -> InstallAll
|
||||
// returns an error, buildInternal installs the guard and returns
|
||||
// early with nil registry.
|
||||
plugin := &failingPlugin{
|
||||
name: "fail-closed",
|
||||
caps: platform.Capabilities{FailurePolicy: platform.FailClosed},
|
||||
err: errors.New("install failure simulated"),
|
||||
}
|
||||
platform.Register(plugin)
|
||||
|
||||
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("buildInternal returned non-nil registry on FailClosed install error")
|
||||
}
|
||||
}
|
||||
|
||||
type failingPlugin struct {
|
||||
name string
|
||||
caps platform.Capabilities
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *failingPlugin) Name() string { return p.name }
|
||||
func (p *failingPlugin) Version() string { return "0.0.1" }
|
||||
func (p *failingPlugin) Capabilities() platform.Capabilities { return p.caps }
|
||||
func (p *failingPlugin) Install(platform.Registrar) error { return p.err }
|
||||
|
||||
// === Plugin Restrict conflict guard ===
|
||||
//
|
||||
// Two plugins both calling r.Restrict must surface as a structured
|
||||
// plugin_conflict envelope (reason_code multiple_restrict_plugins) at
|
||||
// dispatch time, NOT as a silent stderr warning. Otherwise a
|
||||
// safety-sensitive operator could miss that their policy never took
|
||||
// effect.
|
||||
func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
rule := &platform.Rule{Name: "any", Allow: []string{"**"}}
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "plugin-a",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: rule,
|
||||
})
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "plugin-b",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: rule,
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("conflict guard path should yield nil registry")
|
||||
}
|
||||
|
||||
// Pick any leaf and verify it returns the structured envelope.
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_conflict" {
|
||||
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
|
||||
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// Single plugin with an invalid Rule must surface as plugin_install /
|
||||
// invalid_rule envelope (distinct error.type from multi-Restrict).
|
||||
func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
// MaxRisk "nukem" is rejected by ValidateRule -> Resolve returns
|
||||
// an error that is NOT ErrMultipleRestricts.
|
||||
platform.Register(&fakeIntegrationPlugin{
|
||||
name: "bad",
|
||||
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
|
||||
rule: &platform.Rule{Name: "bad", MaxRisk: "nukem"},
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("conflict guard path should yield nil registry")
|
||||
}
|
||||
leaf := findRunnableLeaf(root)
|
||||
if leaf == nil {
|
||||
t.Fatalf("no runnable leaf in command tree")
|
||||
}
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_install" {
|
||||
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
|
||||
t.Errorf("reason_code = %v, want invalid_rule", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// === Startup lifecycle guard ===
|
||||
//
|
||||
// Plugin On(Startup) handler returning error must abort startup with
|
||||
// a plugin_lifecycle envelope (reason_code lifecycle_failed). Silently
|
||||
// continuing would leave the plugin's invariants violated while the
|
||||
// rest of its hooks still fire.
|
||||
func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
platform.Register(&startupFailingPlugin{
|
||||
name: "lc",
|
||||
failErr: errors.New("backend unreachable"),
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("lifecycle guard path should yield nil registry")
|
||||
}
|
||||
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "plugin_lifecycle" {
|
||||
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "lifecycle_failed" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
|
||||
}
|
||||
if d["hook_name"] != "lc.start" {
|
||||
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
// Same path but the handler panics -> reason_code lifecycle_panic.
|
||||
func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
|
||||
tmpHome(t)
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
cmdpolicy.ResetActiveForTesting()
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
platform.Register(&startupFailingPlugin{
|
||||
name: "lc",
|
||||
doPanic: true,
|
||||
panicMsg: "kaboom",
|
||||
})
|
||||
|
||||
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if reg != nil {
|
||||
t.Errorf("lifecycle guard path should yield nil registry")
|
||||
}
|
||||
leaf := findRunnableLeaf(root)
|
||||
err := leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
|
||||
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
|
||||
}
|
||||
}
|
||||
|
||||
type startupFailingPlugin struct {
|
||||
name string
|
||||
failErr error // when set, handler returns this
|
||||
doPanic bool // when true, handler panics with panicMsg
|
||||
panicMsg string
|
||||
}
|
||||
|
||||
func (p *startupFailingPlugin) Name() string { return p.name }
|
||||
func (p *startupFailingPlugin) Version() string { return "0.0.1" }
|
||||
func (p *startupFailingPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (p *startupFailingPlugin) Install(r platform.Registrar) error {
|
||||
r.On(platform.Startup, "start", func(context.Context, *platform.LifecycleContext) error {
|
||||
if p.doPanic {
|
||||
panic(p.panicMsg)
|
||||
}
|
||||
return p.failErr
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// === Wrapper panic recovery ===
|
||||
//
|
||||
// A Wrapper that panics must NOT crash the process. The framework
|
||||
// recovers and converts to a structured envelope:
|
||||
//
|
||||
// type="hook", reason_code="panic", hook_name=<namespaced>
|
||||
func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(&panickingWrapPlugin{name: "p"})
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("Wrapper panic must be recovered, but it escaped: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
}
|
||||
if d["hook_name"] != "p.boom" {
|
||||
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
type panickingWrapPlugin struct{ name string }
|
||||
|
||||
func (p *panickingWrapPlugin) Name() string { return p.name }
|
||||
func (p *panickingWrapPlugin) Version() string { return "0.0.1" }
|
||||
func (p *panickingWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
|
||||
func (p *panickingWrapPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("boom", platform.All(),
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
return func(context.Context, platform.Invocation) error {
|
||||
panic("intentional panic for test")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// findRunnableLeaf walks the tree and returns the first command with a
|
||||
// RunE so tests can synthesize a dispatch without going through cobra.
|
||||
func findRunnableLeaf(c *cobra.Command) *cobra.Command {
|
||||
if c.RunE != nil && c.HasParent() {
|
||||
return c
|
||||
}
|
||||
for _, child := range c.Commands() {
|
||||
if l := findRunnableLeaf(child); l != nil {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// B2 regression: a plugin Wrapper whose FACTORY function (the
|
||||
// `func(next Handler) Handler` itself) panics must not crash the
|
||||
// process. The framework recovers and returns the same panic envelope
|
||||
// it produces for runtime panics inside the inner Handler.
|
||||
//
|
||||
// Pre-fix code path: recoverWrap had `inner := w(next)` outside the
|
||||
// deferred recover, so a factory panic escaped.
|
||||
func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(&factoryPanicWrapPlugin{name: "fac"})
|
||||
|
||||
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
root, leaf := syntheticTree()
|
||||
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
|
||||
t.Fatalf("wireHooks: %v", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("factory panic must be recovered, but it escaped: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err = leaf.RunE(leaf, nil)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "hook" {
|
||||
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
|
||||
}
|
||||
d := exitErr.Detail.Detail.(map[string]any)
|
||||
if d["reason_code"] != "panic" {
|
||||
t.Errorf("reason_code = %v, want panic", d["reason_code"])
|
||||
}
|
||||
if d["hook_name"] != "fac.bad-factory" {
|
||||
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
|
||||
}
|
||||
}
|
||||
|
||||
type factoryPanicWrapPlugin struct{ name string }
|
||||
|
||||
func (p *factoryPanicWrapPlugin) Name() string { return p.name }
|
||||
func (p *factoryPanicWrapPlugin) Version() string { return "0.0.1" }
|
||||
func (p *factoryPanicWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
|
||||
func (p *factoryPanicWrapPlugin) Install(r platform.Registrar) error {
|
||||
r.Wrap("bad-factory", platform.All(),
|
||||
// The factory itself panics; the returned Handler is never reached.
|
||||
func(_ platform.Handler) platform.Handler {
|
||||
panic("factory blew up")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -45,6 +45,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
_ = cmd.MarkFlagRequired("name")
|
||||
_ = cmd.MarkFlagRequired("app-id")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
return profileListRun(f)
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT remove profiles unless the user explicitly asks. This is destructive and clears all associated credentials.",
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
|
||||
return profileRenameRun(f, args[0], args[1])
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
"AI agents: Do NOT switch profiles unless the user explicitly asks.",
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
73
cmd/prune.go
73
cmd/prune.go
@@ -7,10 +7,12 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
||||
@@ -43,15 +45,76 @@ func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
|
||||
}
|
||||
|
||||
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
|
||||
// The denial annotations let the hook layer's populateInvocationDenial
|
||||
// recognise this command as denied, so the Wrap chain is physically
|
||||
// isolated (wrapRunE takes the DeniedByPolicy branch and calls the
|
||||
// stub RunE directly). Without these, a plugin Wrapper registered
|
||||
// against platform.All() could intercept and silently swallow the
|
||||
// strict-mode error -- breaking strict-mode's "hard boundary" contract.
|
||||
//
|
||||
// Args + PersistentPreRunE overrides mirror cmdpolicy/apply.go::installDenyStub:
|
||||
//
|
||||
// - Args=ArbitraryArgs: with DisableFlagParsing the user's flags
|
||||
// look like positional args; the original child's Args validator
|
||||
// (e.g. cobra.NoArgs) would fire BEFORE RunE and produce a
|
||||
// cobra usage error instead of our strict_mode envelope.
|
||||
//
|
||||
// - PersistentPreRunE no-op: cmd/auth/auth.go declares a parent
|
||||
// PersistentPreRunE that returns external_provider when env
|
||||
// credentials are set. Cobra's "first wins walking up" would
|
||||
// pick auth's instead of our denial. A leaf-level no-op makes
|
||||
// cobra stop here and proceed to the wrapped RunE.
|
||||
//
|
||||
// strict-mode keeps its short Message + independent Hint and
|
||||
// composes the shared detail.* / wrapped-CommandDeniedError shape
|
||||
// by hand; BuildDenialError would override Message with the
|
||||
// CommandDeniedError.Error() long form.
|
||||
stubMessage := fmt.Sprintf(
|
||||
"strict mode is %q, only %s-identity commands are available",
|
||||
mode, mode.ForcedIdentity())
|
||||
const stubHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
|
||||
denial := cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerStrictMode,
|
||||
PolicySource: "strict-mode",
|
||||
ReasonCode: "identity_not_supported",
|
||||
Reason: stubMessage,
|
||||
}
|
||||
// Preserve the original command's annotations (risk_level,
|
||||
// lark:supportedIdentities, cmdmeta.domain, ...) and help text so
|
||||
// audit / compliance observers can still see what was denied.
|
||||
// Stamp the denial annotations on top.
|
||||
annotations := make(map[string]string, len(child.Annotations)+2)
|
||||
for k, v := range child.Annotations {
|
||||
annotations[k] = v
|
||||
}
|
||||
annotations[cmdpolicy.AnnotationDenialLayer] = cmdpolicy.LayerStrictMode
|
||||
annotations[cmdpolicy.AnnotationDenialSource] = "strict-mode"
|
||||
|
||||
return &cobra.Command{
|
||||
Use: child.Use,
|
||||
Aliases: append([]string(nil), child.Aliases...),
|
||||
Short: child.Short,
|
||||
Long: child.Long,
|
||||
Hidden: true,
|
||||
DisableFlagParsing: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.ErrWithHint(output.ExitValidation, "strict_mode",
|
||||
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
|
||||
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
Args: cobra.ArbitraryArgs,
|
||||
Annotations: annotations,
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Message: stubMessage,
|
||||
Hint: stubHint,
|
||||
Detail: cmdpolicy.DenialDetailMap(cd),
|
||||
},
|
||||
Err: cd,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -198,3 +202,176 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for codex C13: a strict-mode stub whose PARENT declares
|
||||
// a PersistentPreRunE (e.g. cmd/auth/auth.go's external_provider
|
||||
// check on env credentials) must surface the strict_mode envelope,
|
||||
// not the parent's error. Cobra's "first PersistentPreRunE wins
|
||||
// walking up from leaf" semantics will pick the parent's unless the
|
||||
// stub itself carries its own.
|
||||
//
|
||||
// Fix: strictModeStubFrom installs a no-op PersistentPreRunE so cobra
|
||||
// stops at the stub and proceeds to its RunE.
|
||||
func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
stub := findCmd(root, "auth", "login")
|
||||
if stub == nil {
|
||||
t.Fatal("auth/login stub should exist after StrictModeBot")
|
||||
}
|
||||
if stub.PersistentPreRunE == nil {
|
||||
t.Fatal("strict-mode stub must declare PersistentPreRunE on leaf")
|
||||
}
|
||||
if err := stub.PersistentPreRunE(stub, nil); err != nil {
|
||||
t.Errorf("strict-mode stub PersistentPreRunE should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for codex H13: strict-mode stub must accept arbitrary
|
||||
// positional args. With DisableFlagParsing=true, a user passing
|
||||
// `auth login --scope ...` looks like 4 positional args; the original
|
||||
// cobra.Args validator would surface a usage error BEFORE strict-mode
|
||||
// stub's RunE.
|
||||
func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
stub := findCmd(root, "auth", "login")
|
||||
if stub == nil {
|
||||
t.Fatal("auth/login stub should exist after StrictModeBot")
|
||||
}
|
||||
if stub.Args == nil {
|
||||
t.Fatal("strict-mode stub must declare Args validator")
|
||||
}
|
||||
if err := stub.Args(stub, []string{"--scope", "im.message", "--profile", "default"}); err != nil {
|
||||
t.Errorf("strict-mode stub Args should accept flag-like args, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the strict-mode envelope shape: structured detail.* / wrapped
|
||||
// CommandDeniedError for external agents, AND the historical short
|
||||
// Message + independent Hint for existing consumers.
|
||||
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
stub := findCmd(root, "im", "+search")
|
||||
if stub == nil {
|
||||
t.Fatalf("expected im/+search stub")
|
||||
}
|
||||
err := stub.RunE(stub, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("strict-mode stub RunE should return error")
|
||||
}
|
||||
|
||||
var ee *output.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
t.Fatalf("err is not *output.ExitError: %T", err)
|
||||
}
|
||||
if ee.Detail == nil {
|
||||
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
|
||||
}
|
||||
if ee.Detail.Type != "command_denied" {
|
||||
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
|
||||
}
|
||||
dm, ok := ee.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
|
||||
}
|
||||
if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
|
||||
t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got)
|
||||
}
|
||||
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
|
||||
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
|
||||
}
|
||||
|
||||
var cd *platform.CommandDeniedError
|
||||
if !errors.As(err, &cd) {
|
||||
t.Fatalf("err does not unwrap to *platform.CommandDeniedError")
|
||||
}
|
||||
if cd.Layer != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("CommandDeniedError.Layer = %q, want %q", cd.Layer, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if cd.ReasonCode != "identity_not_supported" {
|
||||
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode)
|
||||
}
|
||||
if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
|
||||
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason)
|
||||
}
|
||||
if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` {
|
||||
t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message)
|
||||
}
|
||||
if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") {
|
||||
t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// strictModeStubFrom must write the denial annotations so the hook
|
||||
// layer's populateInvocationDenial recognises the command as denied
|
||||
// and physically isolates the Wrap chain. Without this, a plugin
|
||||
// Wrapper registered against platform.All() could intercept the stub
|
||||
// and silently return nil, swallowing the strict-mode error.
|
||||
func TestStrictModeStub_HasDenialAnnotation(t *testing.T) {
|
||||
root := newTestTree()
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
// im/+search is user-only -> replaced by a stub in StrictModeBot.
|
||||
stub := findCmd(root, "im", "+search")
|
||||
if stub == nil {
|
||||
t.Fatalf("expected im/+search stub to exist")
|
||||
}
|
||||
got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]
|
||||
if got != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("stub annotation %q = %q, want %q",
|
||||
cmdpolicy.AnnotationDenialLayer, got, cmdpolicy.LayerStrictMode)
|
||||
}
|
||||
if src := stub.Annotations[cmdpolicy.AnnotationDenialSource]; src != "strict-mode" {
|
||||
t.Errorf("stub annotation %q = %q, want %q",
|
||||
cmdpolicy.AnnotationDenialSource, src, "strict-mode")
|
||||
}
|
||||
}
|
||||
|
||||
// Audit / compliance observers fire even for strict-mode-denied commands
|
||||
// and rely on CommandView.Risk() / Identities() / etc. The stub must
|
||||
// carry the original command's annotations so those accessors keep
|
||||
// returning meaningful values; the Short/Long are preserved so `--help`
|
||||
// on a denied command still describes the original intent (parity with
|
||||
// cmdpolicy/apply.go::installDenyStub).
|
||||
func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
svc := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(svc)
|
||||
userOnly := &cobra.Command{
|
||||
Use: "+search",
|
||||
Short: "search messages",
|
||||
Long: "Search across IM history.",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(userOnly, []string{"user"})
|
||||
cmdutil.SetRisk(userOnly, "read")
|
||||
svc.AddCommand(userOnly)
|
||||
|
||||
pruneForStrictMode(root, core.StrictModeBot)
|
||||
|
||||
stub := findCmd(root, "im", "+search")
|
||||
if stub == nil {
|
||||
t.Fatalf("expected im/+search stub")
|
||||
}
|
||||
if got := stub.Annotations["risk_level"]; got != "read" {
|
||||
t.Errorf("stub risk_level = %q, want %q (lost in replacement)", got, "read")
|
||||
}
|
||||
if got := stub.Annotations["lark:supportedIdentities"]; got != "user" {
|
||||
t.Errorf("stub supportedIdentities = %q, want %q", got, "user")
|
||||
}
|
||||
if stub.Short != "search messages" {
|
||||
t.Errorf("stub Short = %q, want preserved Short", stub.Short)
|
||||
}
|
||||
if stub.Long != "Search across IM history." {
|
||||
t.Errorf("stub Long = %q, want preserved Long", stub.Long)
|
||||
}
|
||||
// Denial stamps must still be present.
|
||||
if stub.Annotations[cmdpolicy.AnnotationDenialLayer] != cmdpolicy.LayerStrictMode {
|
||||
t.Errorf("denial annotation overwritten or missing")
|
||||
}
|
||||
}
|
||||
|
||||
102
cmd/root.go
102
cmd/root.go
@@ -12,12 +12,17 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
@@ -88,8 +93,9 @@ func Execute() int {
|
||||
}
|
||||
configureFlagCompletions(os.Args)
|
||||
|
||||
f, rootCmd := buildInternal(
|
||||
context.Background(), inv,
|
||||
ctx := context.Background()
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
)
|
||||
@@ -99,8 +105,18 @@ func Execute() int {
|
||||
setupNotices()
|
||||
}
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
return handleRootError(f, err)
|
||||
runErr := rootCmd.Execute()
|
||||
|
||||
// Fire Shutdown lifecycle hooks regardless of run outcome.
|
||||
// emitShutdown imposes a 2s total deadline and never propagates handler
|
||||
// errors (Emit's documented Shutdown contract), so it cannot block exit
|
||||
// or alter the user-visible exit code.
|
||||
if reg != nil && !isCompletionCommand(os.Args) {
|
||||
_ = hook.Emit(ctx, reg, platform.Shutdown, runErr)
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
return handleRootError(f, runErr)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -140,6 +156,7 @@ func setupNotices() {
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if stale := skillscheck.GetPending(); stale != nil {
|
||||
@@ -147,6 +164,7 @@ func setupNotices() {
|
||||
"current": stale.Current,
|
||||
"target": stale.Target,
|
||||
"message": stale.Message(),
|
||||
"command": "lark-cli update",
|
||||
}
|
||||
}
|
||||
if len(notice) == 0 {
|
||||
@@ -157,11 +175,17 @@ func setupNotices() {
|
||||
}
|
||||
|
||||
// isCompletionCommand returns true if args indicate a shell completion request.
|
||||
// Update notifications must be suppressed for these to avoid corrupting
|
||||
// machine-parseable completion output.
|
||||
// Update notifications and Shutdown lifecycle emits must be suppressed for
|
||||
// these to avoid corrupting machine-parseable completion output and to avoid
|
||||
// firing plugin Shutdown handlers on every Tab keystroke.
|
||||
//
|
||||
// Cobra dispatches BOTH "__complete" and its alias "__completeNoDesc" through
|
||||
// the same hidden subcommand (see cobra/completions.go ShellCompRequestCmd /
|
||||
// ShellCompNoDescRequestCmd). Check both, otherwise bash/zsh completion
|
||||
// (which often uses NoDesc) silently bypasses the gate.
|
||||
func isCompletionCommand(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "completion" || arg == "__complete" {
|
||||
if arg == "completion" || arg == "__complete" || arg == "__completeNoDesc" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -261,6 +285,70 @@ func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyErr
|
||||
fmt.Fprint(w, buffer.String())
|
||||
}
|
||||
|
||||
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
|
||||
// group commands (no Run/RunE) with an unknown_subcommand error.
|
||||
//
|
||||
// IMPORTANT: every command modified here is also tagged with
|
||||
// cmdpolicy.AnnotationPureGroup so the user-layer policy engine
|
||||
// continues to treat the command as a pure parent group. Without the
|
||||
// tag, the RunE injection here would flip Runnable()=true and a user
|
||||
// rule like `max_risk: read` would deny every `<group> --help` call
|
||||
// with reason_code=risk_not_annotated.
|
||||
func installUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
|
||||
cmd.RunE = unknownSubcommandRunE
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[cmdpolicy.AnnotationPureGroup] = "true"
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
installUnknownSubcommandGuard(c)
|
||||
}
|
||||
}
|
||||
|
||||
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
unknown := args[0]
|
||||
available := availableSubcommandNames(cmd)
|
||||
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
|
||||
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
|
||||
if len(available) > 0 {
|
||||
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitValidation,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "unknown_subcommand",
|
||||
Message: msg,
|
||||
Hint: hint,
|
||||
Detail: map[string]any{
|
||||
"unknown": unknown,
|
||||
"command_path": cmd.CommandPath(),
|
||||
"available": available,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func availableSubcommandNames(cmd *cobra.Command) []string {
|
||||
subs := make([]string, 0, len(cmd.Commands()))
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || !c.IsAvailableCommand() {
|
||||
continue
|
||||
}
|
||||
name := c.Name()
|
||||
if name == "help" || name == "completion" {
|
||||
continue
|
||||
}
|
||||
subs = append(subs, name)
|
||||
}
|
||||
sort.Strings(subs)
|
||||
return subs
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||
// flags that are normally hidden in single-app mode (currently --profile)
|
||||
|
||||
@@ -27,6 +27,14 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Canonical strict-mode envelope strings shared across fixtures
|
||||
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
|
||||
const (
|
||||
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
|
||||
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
|
||||
strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
|
||||
)
|
||||
|
||||
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
|
||||
// subcommands wired to a test factory, simulating the real CLI command tree.
|
||||
func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
@@ -353,9 +361,17 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
Type: "command_denied",
|
||||
Message: strictModeBotMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "auth/login",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeBotMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -371,9 +387,17 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
Type: "command_denied",
|
||||
Message: strictModeBotMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "im/+messages-search",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeBotMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -409,7 +433,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Type: "command_denied",
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
@@ -428,7 +452,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Type: "command_denied",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
@@ -446,9 +470,17 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
|
||||
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
|
||||
OK: false,
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
Type: "command_denied",
|
||||
Message: strictModeUserMessage,
|
||||
Hint: strictModeHint,
|
||||
Detail: map[string]any{
|
||||
"path": "im/images/create",
|
||||
"layer": "strict_mode",
|
||||
"policy_source": "strict-mode",
|
||||
"rule_name": "",
|
||||
"reason_code": "identity_not_supported",
|
||||
"reason": strictModeUserMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -465,7 +497,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "strict_mode",
|
||||
Type: "command_denied",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
@@ -612,6 +644,9 @@ func TestSetupNotices_Drift(t *testing.T) {
|
||||
if msg, _ := skills["message"].(string); msg != want {
|
||||
t.Errorf("notice.skills.message = %q, want %q", msg, want)
|
||||
}
|
||||
if cmd, _ := skills["command"].(string); cmd != "lark-cli update" {
|
||||
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupNotices_BothUpdateAndSkills verifies the composed envelope
|
||||
@@ -658,6 +693,20 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
if _, ok := notice["skills"].(map[string]interface{}); !ok {
|
||||
t.Errorf("missing 'skills' key: %+v", notice)
|
||||
}
|
||||
upd, ok := notice["update"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.update missing or wrong type: %+v", notice)
|
||||
}
|
||||
if cmd, _ := upd["command"].(string); cmd != "lark-cli update" {
|
||||
t.Errorf("notice.update.command = %q, want %q", cmd, "lark-cli update")
|
||||
}
|
||||
sk, ok := notice["skills"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("notice.skills missing or wrong type: %+v", notice)
|
||||
}
|
||||
if cmd, _ := sk["command"].(string); cmd != "lark-cli update" {
|
||||
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
|
||||
}
|
||||
}
|
||||
|
||||
// clearNoticeEnv unsets the env vars that affect either notice. We
|
||||
|
||||
@@ -284,6 +284,32 @@ func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_ShortcutIncludesConditionalScopes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
serviceCmd := &cobra.Command{Use: "drive"}
|
||||
shortcutCmd := &cobra.Command{Use: "+status"}
|
||||
root.AddCommand(serviceCmd)
|
||||
serviceCmd.AddCommand(shortcutCmd)
|
||||
f.CurrentCommand = shortcutCmd
|
||||
|
||||
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
|
||||
enrichMissingScopeError(f, exitErr)
|
||||
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected error detail")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
|
||||
t.Fatalf("expected conditional scope hint for drive +status, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -330,6 +356,7 @@ func TestConfigureFlagCompletions(t *testing.T) {
|
||||
{"help flag", []string{"im", "--help"}, true},
|
||||
{"no args", []string{}, true},
|
||||
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
|
||||
{"__completeNoDesc request", []string{"__completeNoDesc", "im", "+send", ""}, false},
|
||||
{"completion subcommand", []string{"completion", "bash"}, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
@@ -342,3 +369,30 @@ func TestConfigureFlagCompletions(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isCompletionCommand must classify BOTH cobra completion aliases as
|
||||
// completion requests so the Shutdown emit and update-notice paths skip
|
||||
// shell-completion invocations. __completeNoDesc is an Alias of
|
||||
// __complete (cobra/completions.go ShellCompNoDescRequestCmd) and
|
||||
// dispatches the same RunE; bash/zsh completion typically calls the
|
||||
// NoDesc variant.
|
||||
func TestIsCompletionCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{"plain command", []string{"im", "+send"}, false},
|
||||
{"__complete", []string{"__complete", "im"}, true},
|
||||
{"__completeNoDesc", []string{"__completeNoDesc", "im"}, true},
|
||||
{"completion subcommand", []string{"completion", "bash"}, true},
|
||||
{"completion in tail", []string{"foo", "bar", "completion"}, true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isCompletionCommand(tc.args); got != tc.want {
|
||||
t.Fatalf("isCompletionCommand(%v) = %v, want %v", tc.args, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,6 +380,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
||||
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
|
||||
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
|
||||
})
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
251
cmd/sec/config_init.go
Normal file
251
cmd/sec/config_init.go
Normal 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
33
cmd/sec/factory.go
Normal 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
127
cmd/sec/run.go
Normal 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
49
cmd/sec/sec.go
Normal 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
38
cmd/sec/sec_test.go
Normal 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
115
cmd/sec/status.go
Normal 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
82
cmd/sec/stop.go
Normal 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
32
cmd/sec/verbose.go
Normal 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...)...)
|
||||
}
|
||||
177
cmd/unknown_subcommand_test.go
Normal file
177
cmd/unknown_subcommand_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func newGroupTree() (root, drive, files *cobra.Command) {
|
||||
root = &cobra.Command{Use: "lark-cli"}
|
||||
drive = &cobra.Command{Use: "drive", Short: "drive ops"}
|
||||
root.AddCommand(drive)
|
||||
|
||||
search := &cobra.Command{Use: "+search", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
upload := &cobra.Command{Use: "+upload", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
hidden := &cobra.Command{Use: "+secret", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
drive.AddCommand(search, upload, hidden)
|
||||
|
||||
files = &cobra.Command{Use: "files", Short: "files ops"}
|
||||
drive.AddCommand(files)
|
||||
files.AddCommand(&cobra.Command{Use: "list", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
|
||||
return root, drive, files
|
||||
}
|
||||
|
||||
func TestInstallUnknownSubcommandGuard_InstallsOnGroupsOnly(t *testing.T) {
|
||||
root, drive, files := newGroupTree()
|
||||
leaf := drive.Commands()[0] // +search
|
||||
|
||||
installUnknownSubcommandGuard(root)
|
||||
|
||||
if drive.RunE == nil {
|
||||
t.Error("drive should have RunE installed")
|
||||
}
|
||||
if files.RunE == nil {
|
||||
t.Error("files should have RunE installed")
|
||||
}
|
||||
if err := leaf.RunE(leaf, []string{"unexpected-arg"}); err != nil {
|
||||
t.Errorf("leaf +search RunE should be untouched, got error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
called := false
|
||||
custom := &cobra.Command{
|
||||
Use: "custom",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// Child makes custom a "group" command, exercising the Run/RunE override guard.
|
||||
custom.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.AddCommand(custom)
|
||||
|
||||
installUnknownSubcommandGuard(root)
|
||||
|
||||
if err := custom.RunE(custom, nil); err != nil {
|
||||
t.Fatalf("preserved RunE returned error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("guard must not overwrite a command that already defines Run/RunE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
var buf bytes.Buffer
|
||||
drive.SetOut(&buf)
|
||||
drive.SetErr(&buf)
|
||||
|
||||
if err := drive.RunE(drive, nil); err != nil {
|
||||
t.Fatalf("expected no-args invocation to succeed, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "drive ops") {
|
||||
t.Errorf("expected help output to include the command's Short, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
|
||||
_, drive, _ := newGroupTree()
|
||||
installUnknownSubcommandGuard(drive.Root())
|
||||
|
||||
err := drive.RunE(drive, []string{"+bogus"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown subcommand")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected ExitError to carry Detail")
|
||||
}
|
||||
if exitErr.Detail.Type != "unknown_subcommand" {
|
||||
t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
|
||||
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
|
||||
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if strings.Contains(exitErr.Detail.Hint, "+secret") {
|
||||
t.Error("hidden commands must not appear in the hint")
|
||||
}
|
||||
|
||||
detail, ok := exitErr.Detail.Detail.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail)
|
||||
}
|
||||
if detail["unknown"] != "+bogus" {
|
||||
t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"])
|
||||
}
|
||||
if detail["command_path"] != "lark-cli drive" {
|
||||
t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"])
|
||||
}
|
||||
available, ok := detail["available"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("detail.available should be []string, got %T", detail["available"])
|
||||
}
|
||||
if len(available) != 3 {
|
||||
t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) {
|
||||
root, _, files := newGroupTree()
|
||||
installUnknownSubcommandGuard(root)
|
||||
|
||||
err := files.RunE(files, []string{"bogus"})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError on nested group, got %T", err)
|
||||
}
|
||||
if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" {
|
||||
t.Errorf("command_path should reflect the nested resource, got %v",
|
||||
exitErr.Detail.Detail.(map[string]any)["command_path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(
|
||||
&cobra.Command{Use: "alpha", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "help", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "completion", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "beta", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
|
||||
)
|
||||
|
||||
got := availableSubcommandNames(root)
|
||||
want := []string{"alpha", "gamma"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
for i, name := range want {
|
||||
if got[i] != name {
|
||||
t.Errorf("availableSubcommandNames[%d] = %q, want %q", i, got[i], name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,7 @@ Use --check to only check for updates without installing.`,
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
|
||||
cmdutil.SetRisk(cmd, "high-risk-write")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -227,7 +228,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
|
||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||
emitSkillsTextHints(io, skillsResult)
|
||||
return nil
|
||||
}
|
||||
@@ -324,7 +325,7 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
if updater.CanRestorePreviousVersion() {
|
||||
return "the previous version has been restored"
|
||||
}
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the
|
||||
|
||||
@@ -481,6 +481,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
|
||||
if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") {
|
||||
t.Errorf("expected manual reinstall command in hint, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "skills will not be synced") {
|
||||
t.Errorf("expected skills-not-synced warning in rollback hint, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "npx skills add larksuite/cli -y -g") {
|
||||
t.Errorf("expected npx skills add hint for skills sync, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCheck_JSON_Npm(t *testing.T) {
|
||||
|
||||
227
extension/credential/secplugin/provider.go
Normal file
227
extension/credential/secplugin/provider.go
Normal 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{})
|
||||
}
|
||||
486
extension/credential/secplugin/provider_test.go
Normal file
486
extension/credential/secplugin/provider_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
186
extension/platform/README.md
Normal file
186
extension/platform/README.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# lark-cli Plugin SDK
|
||||
|
||||
`extension/platform` is the **in-process plugin SDK** for lark-cli.
|
||||
Plugins compile into a **fork** of the lark-cli binary via a blank
|
||||
import; there is no `.so` loading, no RPC, no subprocess isolation.
|
||||
A plugin shares the binary's address space and lifecycle.
|
||||
|
||||
## 5-minute hello world
|
||||
|
||||
```go
|
||||
// myplugin/audit.go
|
||||
package myplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.After, "log-cmd", platform.All(),
|
||||
func(ctx context.Context, inv platform.Invocation) {
|
||||
log.Printf("cmd=%s err=%v", inv.Cmd().Path(), inv.Err())
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
```
|
||||
|
||||
Wire into a fork:
|
||||
|
||||
```go
|
||||
// cmd/larkx/main.go in your fork
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/me/myplugin" // blank import → init() runs
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() { os.Exit(cmd.Execute()) }
|
||||
```
|
||||
|
||||
```sh
|
||||
go build -o larkx ./cmd/larkx && ./larkx config plugins show
|
||||
```
|
||||
|
||||
You should see `audit` in the plugin list.
|
||||
|
||||
## What you can hook
|
||||
|
||||
| Hook | Fires | Can block? |
|
||||
| -------------------------- | ---------------------------------- | -------------------------------- |
|
||||
| `Observer` | Before / After each command | No (fire-and-forget audit) |
|
||||
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
|
||||
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
|
||||
| `Restrict(Rule)` | Bootstrap-time, single per binary | Denies whole subtrees |
|
||||
|
||||
### Plugin lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Host as lark-cli (host)
|
||||
participant SDK as platform (SDK)
|
||||
participant Plugin as your plugin
|
||||
|
||||
Note over Host,Plugin: Process start (before main)
|
||||
Plugin->>Plugin: init() (via blank import)
|
||||
Plugin->>SDK: Register(plugin)
|
||||
|
||||
Note over Host,Plugin: Bootstrap (host main)
|
||||
Host->>SDK: RegisteredPlugins()
|
||||
SDK-->>Host: snapshot in registration order
|
||||
Host->>SDK: InstallAll()
|
||||
SDK->>Plugin: Capabilities()
|
||||
SDK->>Plugin: Install(Registrar)
|
||||
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
|
||||
SDK->>Plugin: On(Startup) fire
|
||||
|
||||
Note over Host,Plugin: Each command dispatch
|
||||
Host->>SDK: hook chain (in registration order)
|
||||
SDK->>Plugin: Observer Before
|
||||
SDK->>Plugin: Wrap (around RunE)
|
||||
SDK->>Plugin: Observer After
|
||||
|
||||
Note over Host,Plugin: Process exit
|
||||
Host->>SDK: Emit(Shutdown)
|
||||
SDK->>Plugin: On(Shutdown) fire
|
||||
```
|
||||
|
||||
A `command_denied` decision (from `Restrict` or strict-mode) bypasses
|
||||
the `Wrap` chain entirely — observers still fire so audit plugins see
|
||||
the rejected dispatch.
|
||||
|
||||
## Safety contract (read this)
|
||||
|
||||
- A plugin calling `Restrict()` MUST declare `FailClosed`. The Builder
|
||||
flips it automatically; the lower-level `Plugin` interface rejects
|
||||
the mismatch with `restricts_mismatch`.
|
||||
- Only ONE plugin per binary can call `Restrict()`. Multi-plugin
|
||||
Restrict is a deliberate `plugin_conflict` error (single-rule
|
||||
ecosystem assumption). YAML policy at `~/.lark-cli/policy.yml` is
|
||||
shadowed by any plugin Restrict.
|
||||
- The `Wrap` factory runs **once per command dispatch**, not at
|
||||
install time. Long-lived state (clients, caches, metrics counters)
|
||||
must live on the Plugin struct or in package-level variables.
|
||||
- Plugins cannot suppress a `command_denied`: the framework
|
||||
physically isolates denied commands from the Wrap chain (Observers
|
||||
still fire).
|
||||
- Commands missing a `risk_level` annotation are denied by default
|
||||
when a Rule is active. Set `Rule.AllowUnannotated = true` (or
|
||||
`allow_unannotated: true` in yaml) to opt out during gradual
|
||||
adoption.
|
||||
- Risk annotation typos (e.g. `"wrtie"`) are always denied with
|
||||
`risk_invalid` plus a "did you mean" suggestion. `AllowUnannotated`
|
||||
does NOT bypass this — typo is a code bug, not a missing
|
||||
annotation.
|
||||
|
||||
## reason_code reference
|
||||
|
||||
Every install / dispatch failure emits a `command_denied` or
|
||||
`plugin_install` envelope carrying a `detail.reason_code` from the
|
||||
closed enum below. Use the code (not the human-readable message) when
|
||||
matching errors in agents, CI scripts, or downstream tools — the
|
||||
messages are localised and may change between releases.
|
||||
|
||||
### Plugin install (`error.type = plugin_install`)
|
||||
|
||||
| reason_code | When it fires | Honours FailurePolicy? |
|
||||
| --------------------------- | ------------------------------------------------------------------------------ | ---------------------- |
|
||||
| `invalid_plugin_name` | `Plugin.Name()` doesn't match `^[a-z0-9][a-z0-9-]*$` | No — always aborts |
|
||||
| `plugin_name_panic` | `Plugin.Name()` panicked | No — always aborts |
|
||||
| `duplicate_plugin_name` | Two plugins return the same `Name()` | No — always aborts |
|
||||
| `capabilities_panic` | `Plugin.Capabilities()` panicked | Yes |
|
||||
| `invalid_capability` | `Capabilities` malformed: bad `RequiredCLIVersion`, unknown `FailurePolicy` | No — always aborts |
|
||||
| `capability_unmet` | Current CLI version doesn't satisfy `RequiredCLIVersion` | Yes |
|
||||
| `restricts_mismatch` | `Restricts=true` without `FailClosed`, or `Restricts` flag inconsistent w/ Install | No — always aborts |
|
||||
| `invalid_hook_name` | Hook name contains `.` or doesn't match the plugin namespace | Yes |
|
||||
| `duplicate_hook_name` | Same hook name registered twice within a plugin | Yes |
|
||||
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
|
||||
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
|
||||
| `double_restrict` | Plugin called `r.Restrict()` more than once in one Install | Yes |
|
||||
| `multiple_restrict_plugins` | Two or more plugins each contributed Restrict | Yes |
|
||||
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
|
||||
| `install_panic` | `Plugin.Install` panicked | Yes |
|
||||
|
||||
"No — always aborts" entries are treated as **untrusted-config errors**:
|
||||
the host can't honour the plugin's declared `FailurePolicy` because the
|
||||
declaration itself is suspect (e.g. an `invalid_capability` plugin
|
||||
might also be lying about being `FailOpen`).
|
||||
|
||||
### Command dispatch (`error.type = command_denied`)
|
||||
|
||||
| reason_code | Meaning |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `risk_not_annotated` | Command has no `risk_level` annotation, and the active Rule does not set `allow_unannotated: true` |
|
||||
| `risk_invalid` | Command's `risk_level` is a typo / not in the `read | write | high-risk-write` taxonomy (always fail-closed) |
|
||||
| `command_denylisted` | Command path matched the active Rule's `deny` glob |
|
||||
| `domain_not_allowed` | Active Rule has a non-empty `allow` list and the command path did not match any glob |
|
||||
| `write_not_allowed` | Command risk is `write` / `high-risk-write` and exceeds Rule `max_risk` |
|
||||
| `risk_too_high` | Command risk exceeds Rule `max_risk` but is not a write (reserved for future risk levels) |
|
||||
| `identity_mismatch` | Command's `supportedIdentities` does not intersect Rule `identities` |
|
||||
| `aggregate_all_denied` | Aggregate stub installed on a parent group because every live child was denied |
|
||||
|
||||
The `detail.layer` field distinguishes who rejected the call:
|
||||
`policy` (this SDK's user-layer engine) vs. `strict_mode`
|
||||
(`cmd/prune.go`'s credential-hardening pass). Agents that want to
|
||||
dispatch on "any denial" should match `error.type == "command_denied"`
|
||||
and ignore the layer; agents that only care about user-policy denials
|
||||
should additionally check `detail.layer == "policy"`.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Runnable example: audit observer](./examples/audit-observer/)
|
||||
- [Runnable example: read-only policy](./examples/readonly-policy/)
|
||||
- Builder API: see [`builder.go`](./builder.go) for the full DSL
|
||||
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`,
|
||||
`MustBuild`).
|
||||
- Inventory diagnostic: run `lark-cli config plugins show` after
|
||||
installing your plugin to see hooks/rules attributed to your plugin
|
||||
name.
|
||||
37
extension/platform/abort.go
Normal file
37
extension/platform/abort.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// AbortError is returned by a Wrapper that wants to short-circuit the
|
||||
// command chain (instead of calling next). The framework converts it
|
||||
// to an *output.ExitError with type "hook" so the JSON envelope carries
|
||||
// the structured fields agents expect.
|
||||
//
|
||||
// HookName is the framework-namespaced name ("secaudit.approval"); the
|
||||
// Registrar adds the plugin-name prefix automatically.
|
||||
//
|
||||
// Cause and Detail are optional. Cause lets the consumer use
|
||||
// errors.Is/As to find the underlying cause; Detail is serialized into
|
||||
// envelope.detail under the "detail" key for agent consumption.
|
||||
type AbortError struct {
|
||||
HookName string
|
||||
Reason string
|
||||
Cause error
|
||||
Detail any
|
||||
}
|
||||
|
||||
// Error renders a human-readable message; HookName + Reason + Cause are
|
||||
// included when present.
|
||||
func (e *AbortError) Error() string {
|
||||
msg := fmt.Sprintf("hook %q aborted: %s", e.HookName, e.Reason)
|
||||
if e.Cause != nil {
|
||||
msg += ": " + e.Cause.Error()
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// Unwrap enables errors.Is / errors.As to traverse to Cause.
|
||||
func (e *AbortError) Unwrap() error { return e.Cause }
|
||||
42
extension/platform/abort_test.go
Normal file
42
extension/platform/abort_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func TestAbortError_messageFormats(t *testing.T) {
|
||||
bare := &platform.AbortError{HookName: "secaudit.approval", Reason: "needs approval"}
|
||||
if got := bare.Error(); got != `hook "secaudit.approval" aborted: needs approval` {
|
||||
t.Errorf("Error() = %q", got)
|
||||
}
|
||||
|
||||
withCause := &platform.AbortError{
|
||||
HookName: "audit.upload",
|
||||
Reason: "upstream unreachable",
|
||||
Cause: fs.ErrNotExist,
|
||||
}
|
||||
if got := withCause.Error(); got == bare.Error() {
|
||||
t.Errorf("Cause should be appended to message, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// errors.As must traverse Unwrap so consumers can inspect the cause
|
||||
// directly. This is the contract the host's wrapAbortError relies on.
|
||||
func TestAbortError_unwrapErrorsAs(t *testing.T) {
|
||||
root := fs.ErrPermission
|
||||
ab := &platform.AbortError{
|
||||
HookName: "x",
|
||||
Reason: "y",
|
||||
Cause: root,
|
||||
}
|
||||
if !errors.Is(ab, fs.ErrPermission) {
|
||||
t.Errorf("errors.Is should find fs.ErrPermission via Unwrap")
|
||||
}
|
||||
}
|
||||
215
extension/platform/builder.go
Normal file
215
extension/platform/builder.go
Normal file
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Builder is the ergonomic constructor for Plugin. Use it from init():
|
||||
//
|
||||
// func init() {
|
||||
// platform.Register(
|
||||
// platform.NewPlugin("audit", "0.1.0").
|
||||
// Observer(platform.After, "log", platform.All(), auditFn).
|
||||
// FailOpen().
|
||||
// MustBuild())
|
||||
// }
|
||||
//
|
||||
// The lower-level Plugin interface remains available for cases that
|
||||
// need finer control (state on a struct, complex Install logic). The
|
||||
// Builder enforces:
|
||||
//
|
||||
// - Name format (^[a-z0-9][a-z0-9-]*$)
|
||||
// - hookName format and uniqueness within a plugin
|
||||
// - Restricts ↔ FailClosed consistency (calling Restrict() implies
|
||||
// FailClosed, so plugin authors cannot accidentally ship a policy
|
||||
// plugin under FailOpen)
|
||||
// - Rule validation via ValidateRule analogues (delegated to
|
||||
// internal/cmdpolicy at install time; Builder only fast-fails
|
||||
// blatantly bad input)
|
||||
type Builder struct {
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
|
||||
actions []func(Registrar)
|
||||
rule *Rule
|
||||
|
||||
hookNames map[string]bool
|
||||
errs []error
|
||||
}
|
||||
|
||||
var pluginNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// NewPlugin starts a Builder. Name format is validated lazily — errors
|
||||
// surface at Build()/MustBuild() time, allowing chained calls without
|
||||
// intermediate error handling.
|
||||
func NewPlugin(name, version string) *Builder {
|
||||
b := &Builder{
|
||||
name: name,
|
||||
version: version,
|
||||
hookNames: map[string]bool{},
|
||||
}
|
||||
if !pluginNamePattern.MatchString(name) {
|
||||
b.errs = append(b.errs, fmt.Errorf("invalid plugin name %q: must match ^[a-z0-9][a-z0-9-]*$", name))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// RequireCLI sets Capabilities.RequiredCLIVersion (semver constraint,
|
||||
// e.g. ">=1.1.0"). Empty string means no requirement.
|
||||
func (b *Builder) RequireCLI(constraint string) *Builder {
|
||||
b.caps.RequiredCLIVersion = constraint
|
||||
return b
|
||||
}
|
||||
|
||||
// FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when
|
||||
// neither FailOpen nor FailClosed is called and Restrict is not used.
|
||||
func (b *Builder) FailOpen() *Builder {
|
||||
b.caps.FailurePolicy = FailOpen
|
||||
return b
|
||||
}
|
||||
|
||||
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit
|
||||
// when Restrict() is called.
|
||||
func (b *Builder) FailClosed() *Builder {
|
||||
b.caps.FailurePolicy = FailClosed
|
||||
return b
|
||||
}
|
||||
|
||||
// Observer registers an Observer. Multiple calls accumulate.
|
||||
func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder {
|
||||
if !b.validateHookName(hookName, "observer") {
|
||||
return b
|
||||
}
|
||||
// Capture by value so the action closure doesn't share state with
|
||||
// subsequent Observer() calls (Go ≥1.22 already gives each call
|
||||
// its own copies of parameter values, but pinning is explicit).
|
||||
w, n, s, f := when, hookName, sel, fn
|
||||
b.actions = append(b.actions, func(r Registrar) {
|
||||
r.Observe(w, n, s, f)
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// Wrap registers a Wrapper. Multiple calls accumulate; the host
|
||||
// composes them in registration order (outermost first).
|
||||
func (b *Builder) Wrap(hookName string, sel Selector, wrap Wrapper) *Builder {
|
||||
if !b.validateHookName(hookName, "wrap") {
|
||||
return b
|
||||
}
|
||||
n, s, w := hookName, sel, wrap
|
||||
b.actions = append(b.actions, func(r Registrar) {
|
||||
r.Wrap(n, s, w)
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// On registers a LifecycleHandler.
|
||||
func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler) *Builder {
|
||||
if !b.validateHookName(hookName, "on") {
|
||||
return b
|
||||
}
|
||||
e, n, f := event, hookName, fn
|
||||
b.actions = append(b.actions, func(r Registrar) {
|
||||
r.On(e, n, f)
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// Restrict contributes a pruning Rule. Calling Restrict implicitly
|
||||
// sets Restricts=true and FailurePolicy=FailClosed (the framework
|
||||
// requires both to coexist; the builder enforces the pairing so the
|
||||
// plugin author cannot accidentally ship a policy plugin under
|
||||
// FailOpen).
|
||||
func (b *Builder) Restrict(rule *Rule) *Builder {
|
||||
if rule == nil {
|
||||
b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be nil"))
|
||||
return b
|
||||
}
|
||||
b.caps.Restricts = true
|
||||
b.caps.FailurePolicy = FailClosed
|
||||
b.rule = rule
|
||||
return b
|
||||
}
|
||||
|
||||
// Build returns the configured Plugin, or an error if any builder
|
||||
// step found a fault. MustBuild panics on the same error.
|
||||
//
|
||||
// The Restrict + FailOpen mismatch is checked here, not in the chained
|
||||
// setters, because the two methods may be called in either order.
|
||||
func (b *Builder) Build() (Plugin, error) {
|
||||
if b.rule != nil && b.caps.FailurePolicy == FailOpen {
|
||||
b.errs = append(b.errs, errors.New(
|
||||
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
||||
}
|
||||
if len(b.errs) > 0 {
|
||||
return nil, errors.Join(b.errs...)
|
||||
}
|
||||
return &builtPlugin{
|
||||
name: b.name,
|
||||
version: b.version,
|
||||
caps: b.caps,
|
||||
actions: b.actions,
|
||||
rule: b.rule,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MustBuild panics if Build() would return an error. Designed for
|
||||
// init():
|
||||
//
|
||||
// func init() { platform.Register(platform.NewPlugin(...).MustBuild()) }
|
||||
//
|
||||
// A panic in init runs before the framework's recover guard is
|
||||
// installed and will crash the binary. That is the intended
|
||||
// behaviour: a misconfigured plugin must NOT be silently registered.
|
||||
func (b *Builder) MustBuild() Plugin {
|
||||
p, err := b.Build()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("plugin %q: %v", b.name, err))
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// validateHookName checks the grammar and uniqueness; returns false
|
||||
// when the name was rejected (caller skips the action).
|
||||
func (b *Builder) validateHookName(hookName, kind string) bool {
|
||||
if !pluginNamePattern.MatchString(hookName) {
|
||||
b.errs = append(b.errs, fmt.Errorf(
|
||||
"%s %q: hookName must match ^[a-z0-9][a-z0-9-]*$", kind, hookName))
|
||||
return false
|
||||
}
|
||||
if b.hookNames[hookName] {
|
||||
b.errs = append(b.errs, fmt.Errorf(
|
||||
"%s %q: hookName already used in this plugin", kind, hookName))
|
||||
return false
|
||||
}
|
||||
b.hookNames[hookName] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// builtPlugin is the Plugin implementation the builder emits.
|
||||
type builtPlugin struct {
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
actions []func(Registrar)
|
||||
rule *Rule
|
||||
}
|
||||
|
||||
func (p *builtPlugin) Name() string { return p.name }
|
||||
func (p *builtPlugin) Version() string { return p.version }
|
||||
func (p *builtPlugin) Capabilities() Capabilities { return p.caps }
|
||||
func (p *builtPlugin) Install(r Registrar) error {
|
||||
if p.rule != nil {
|
||||
r.Restrict(p.rule)
|
||||
}
|
||||
for _, action := range p.actions {
|
||||
action(r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
180
extension/platform/builder_test.go
Normal file
180
extension/platform/builder_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// recorder Registrar captures everything a builder schedules so the
|
||||
// test can assert what Install produced without involving the host.
|
||||
type recorder struct {
|
||||
observers int
|
||||
wrappers int
|
||||
lifecycles int
|
||||
rule *platform.Rule
|
||||
}
|
||||
|
||||
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
|
||||
r.observers++
|
||||
}
|
||||
func (r *recorder) Wrap(string, platform.Selector, platform.Wrapper) { r.wrappers++ }
|
||||
func (r *recorder) On(platform.LifecycleEvent, string, platform.LifecycleHandler) { r.lifecycles++ }
|
||||
func (r *recorder) Restrict(rule *platform.Rule) { r.rule = rule }
|
||||
|
||||
func TestBuilder_basicAssembly(t *testing.T) {
|
||||
p, err := platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.Before, "pre", platform.All(),
|
||||
func(context.Context, platform.Invocation) {}).
|
||||
Observer(platform.After, "post", platform.All(),
|
||||
func(context.Context, platform.Invocation) {}).
|
||||
Wrap("policy", platform.All(),
|
||||
func(next platform.Handler) platform.Handler { return next }).
|
||||
On(platform.Startup, "boot",
|
||||
func(context.Context, *platform.LifecycleContext) error { return nil }).
|
||||
FailOpen().
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if p.Name() != "audit" || p.Version() != "0.1.0" {
|
||||
t.Errorf("metadata = %q/%q", p.Name(), p.Version())
|
||||
}
|
||||
if p.Capabilities().FailurePolicy != platform.FailOpen {
|
||||
t.Errorf("FailurePolicy = %v, want FailOpen", p.Capabilities().FailurePolicy)
|
||||
}
|
||||
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if r.observers != 2 || r.wrappers != 1 || r.lifecycles != 1 {
|
||||
t.Errorf("Install dispatch = observers=%d wrappers=%d lifecycles=%d",
|
||||
r.observers, r.wrappers, r.lifecycles)
|
||||
}
|
||||
}
|
||||
|
||||
// Restrict() flips Restricts=true and FailClosed automatically — a
|
||||
// policy plugin can't accidentally ship under FailOpen.
|
||||
func TestBuilder_restrictForcesFailClosed(t *testing.T) {
|
||||
p, err := platform.NewPlugin("policy-plugin", "0.1.0").
|
||||
Restrict(&platform.Rule{Name: "read-only", MaxRisk: platform.RiskRead}).
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
caps := p.Capabilities()
|
||||
if !caps.Restricts {
|
||||
t.Errorf("Restricts = false, want true (Restrict() should flip it)")
|
||||
}
|
||||
if caps.FailurePolicy != platform.FailClosed {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed (Restrict() implies it)", caps.FailurePolicy)
|
||||
}
|
||||
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if r.rule == nil || r.rule.Name != "read-only" {
|
||||
t.Errorf("Install did not propagate Rule: %+v", r.rule)
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid name surfaces at Build time, not at NewPlugin.
|
||||
func TestBuilder_invalidPluginName(t *testing.T) {
|
||||
_, err := platform.NewPlugin("Has_Underscore_And_Caps", "0.1").Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject malformed plugin name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid plugin name") {
|
||||
t.Errorf("error should mention plugin name, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate hookName within the same builder is rejected.
|
||||
func TestBuilder_duplicateHookName(t *testing.T) {
|
||||
noopObs := func(context.Context, platform.Invocation) {}
|
||||
_, err := platform.NewPlugin("dup", "0").
|
||||
Observer(platform.Before, "h", platform.All(), noopObs).
|
||||
Observer(platform.After, "h", platform.All(), noopObs).
|
||||
Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject duplicate hookName")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already used") {
|
||||
t.Errorf("error should mention duplicate hookName, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_invalidHookName(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").
|
||||
Observer(platform.Before, "Bad.Name", platform.All(),
|
||||
func(context.Context, platform.Invocation) {}).
|
||||
Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject hookName with dot")
|
||||
}
|
||||
}
|
||||
|
||||
// MustBuild panics on builder error.
|
||||
func TestBuilder_mustBuildPanicsOnError(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Fatalf("MustBuild must panic when Build would fail")
|
||||
}
|
||||
}()
|
||||
_ = platform.NewPlugin("BadName", "0").MustBuild()
|
||||
}
|
||||
|
||||
func TestBuilder_restrictNilRejected(t *testing.T) {
|
||||
_, err := platform.NewPlugin("p", "0").Restrict(nil).Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Restrict(nil) must produce error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_capabilitiesSetters(t *testing.T) {
|
||||
p, err := platform.NewPlugin("p", "0.1").
|
||||
RequireCLI(">=1.0.0").
|
||||
FailClosed().
|
||||
Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
caps := p.Capabilities()
|
||||
if caps.RequiredCLIVersion != ">=1.0.0" {
|
||||
t.Errorf("RequiredCLIVersion = %q, want >=1.0.0", caps.RequiredCLIVersion)
|
||||
}
|
||||
if caps.FailurePolicy != platform.FailClosed {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed", caps.FailurePolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_restrictThenFailOpenRejected(t *testing.T) {
|
||||
rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead}
|
||||
_, err := platform.NewPlugin("p", "0").Restrict(rule).FailOpen().Build()
|
||||
if err == nil {
|
||||
t.Fatalf("Build must reject Restrict()+FailOpen() mismatch")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "FailClosed") {
|
||||
t.Errorf("error should mention FailClosed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Restrict() flips FailurePolicy to FailClosed; the previous FailOpen()
|
||||
// is overridden. Pin it so the Build-time validation does not over-reject.
|
||||
func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
|
||||
rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead}
|
||||
p, err := platform.NewPlugin("p", "0").FailOpen().Restrict(rule).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("FailOpen()+Restrict() must succeed (Restrict flips to FailClosed): %v", err)
|
||||
}
|
||||
if p.Capabilities().FailurePolicy != platform.FailClosed {
|
||||
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
|
||||
}
|
||||
}
|
||||
50
extension/platform/capabilities.go
Normal file
50
extension/platform/capabilities.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// FailurePolicy controls what the framework does when a plugin's install
|
||||
// stage fails (Capabilities() panics, Install returns error, etc.).
|
||||
type FailurePolicy int
|
||||
|
||||
const (
|
||||
// FailOpen (default) — log a warning and skip THIS plugin; the rest
|
||||
// of the CLI keeps running. Appropriate for pure-observer plugins
|
||||
// where missing audit data is preferable to a broken CLI.
|
||||
FailOpen FailurePolicy = iota
|
||||
|
||||
// FailClosed — abort the entire CLI startup. Required for any
|
||||
// plugin that contributes Restrict() (a missing policy plugin =
|
||||
// missing security boundary) or that owns any safety-sensitive
|
||||
// concern. Enforced by the framework: Capabilities.Restricts=true
|
||||
// must pair with FailurePolicy=FailClosed.
|
||||
FailClosed
|
||||
)
|
||||
|
||||
// Capabilities declares the plugin's self-description. Plugin.Capabilities
|
||||
// MUST be implemented even when every field would be its zero value --
|
||||
// the requirement keeps FailurePolicy / Restricts visible to the author
|
||||
// at the moment they write the plugin, preventing the "I just want to
|
||||
// add an audit observer" mistake of accidentally shipping a policy
|
||||
// plugin with the default FailOpen.
|
||||
type Capabilities struct {
|
||||
// RequiredCLIVersion is a semver constraint (e.g. ">=1.1.0").
|
||||
// Plugins that need a specific framework feature should declare
|
||||
// the minimum version they tested against; the host fails the
|
||||
// install when the running CLI is older. Empty string means "no
|
||||
// version requirement".
|
||||
RequiredCLIVersion string
|
||||
|
||||
// Restricts declares whether Install will call r.Restrict(). The
|
||||
// framework enforces consistency: declaring Restricts=true and
|
||||
// then NOT calling r.Restrict (or vice versa) aborts the install
|
||||
// with the `restricts_mismatch` reason_code. This pre-flight
|
||||
// declaration also lets `config policy show` introspect "which
|
||||
// plugins are policy plugins" without running them.
|
||||
Restricts bool
|
||||
|
||||
// FailurePolicy decides what happens on install failure. See the
|
||||
// constants above; the framework requires FailClosed whenever
|
||||
// Restricts=true.
|
||||
FailurePolicy FailurePolicy
|
||||
}
|
||||
39
extension/platform/doc.go
Normal file
39
extension/platform/doc.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package platform is the single public extension contract for lark-cli.
|
||||
//
|
||||
// External integrators (plugin authors, embedding platforms) only import this
|
||||
// package; everything else under internal/ is off-limits.
|
||||
//
|
||||
// Plugin lifecycle:
|
||||
//
|
||||
// - Plugin - the interface every plugin implements (Name / Version / Capabilities / Install)
|
||||
// - Registrar - what Install receives; the four registration verbs (Observe / Wrap / On / Restrict)
|
||||
// - Capabilities - declared up front: FailurePolicy (FailOpen | FailClosed) and Restricts
|
||||
// - Register - process-wide entry point; plugins call this from init()
|
||||
//
|
||||
// Hook surface (what Install hangs off Registrar):
|
||||
//
|
||||
// - Observer - side-effect-only callback, panic-safe, runs Before / After RunE
|
||||
// - Wrapper - middleware that can short-circuit via AbortError
|
||||
// - LifecycleHandler - reacts to Startup / Shutdown / etc. (LifecycleEvent + When)
|
||||
// - Selector - chooses which commands a hook applies to (ByDomain / ByWrite / ByReadOnly / ByExactRisk / And / Or / Not, etc.)
|
||||
// - Handler - the inner "run the command" function Wrappers compose around
|
||||
// - Invocation - per-call context passed to handlers (Cmd view + DeniedByPolicy / DenialLayer / DenialPolicySource)
|
||||
// - AbortError - structured short-circuit error from a Wrapper; framework namespaces HookName
|
||||
//
|
||||
// Policy surface (what Restrict contributes, also consumable from yaml policy):
|
||||
//
|
||||
// - Rule - declarative policy rule (Allow / Deny / MaxRisk / Identities / AllowUnannotated)
|
||||
// - CommandView - read-only command metadata view (Path / Domain / Risk / Identities)
|
||||
// - Risk / Identity - defined string types with closed taxonomies; ParseRisk / ParseIdentity
|
||||
// convert raw strings (yaml, cobra annotation) into typed values; r.Rank()
|
||||
// gives a comparable rank for the read < write < high-risk-write ordering
|
||||
// - CommandDeniedError - structured error returned to denied callers
|
||||
//
|
||||
// Stability: every exported symbol here is part of the contract. Internal
|
||||
// orchestration (staging, validation, RunE wrapping, denial guard) lives
|
||||
// under internal/platform, internal/hook and internal/cmdpolicy and is not
|
||||
// importable by third parties.
|
||||
package platform
|
||||
40
extension/platform/errors.go
Normal file
40
extension/platform/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CommandDeniedError is the structured error returned by a denyStub. Every
|
||||
// pruned-command execution path -- direct invocation, alias expansion,
|
||||
// internal call -- returns this exact type. It is wire-compatible with the
|
||||
// output.ExitError envelope via the Layer (== error.type) field and the
|
||||
// detail map produced by ExitError().
|
||||
//
|
||||
// Layer values:
|
||||
//
|
||||
// - "strict_mode" -- credential strict-mode rejected the command
|
||||
// - "policy" -- user-layer Rule rejected the command
|
||||
//
|
||||
// PolicySource is a free-form identifier such as "plugin:secaudit",
|
||||
// "yaml:mywork", or "strict-mode". Reason fields:
|
||||
//
|
||||
// - ReasonCode -- closed enum, see tech-doc 5.3 (e.g. write_not_allowed,
|
||||
// all_children_denied, identity_not_supported)
|
||||
// - Reason -- human-readable text
|
||||
type CommandDeniedError struct {
|
||||
Path string
|
||||
Layer string
|
||||
PolicySource string
|
||||
RuleName string
|
||||
ReasonCode string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Error implements the standard error interface.
|
||||
func (e *CommandDeniedError) Error() string {
|
||||
if e.Reason != "" {
|
||||
return fmt.Sprintf("command %q denied: %s", e.Path, e.Reason)
|
||||
}
|
||||
return fmt.Sprintf("command %q denied (%s/%s)", e.Path, e.Layer, e.ReasonCode)
|
||||
}
|
||||
44
extension/platform/errors_test.go
Normal file
44
extension/platform/errors_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func TestCommandDeniedError_messageFormats(t *testing.T) {
|
||||
withReason := &platform.CommandDeniedError{
|
||||
Path: "docs/+update",
|
||||
Layer: "policy",
|
||||
ReasonCode: "write_not_allowed",
|
||||
Reason: "write disabled by policy",
|
||||
}
|
||||
if got := withReason.Error(); got != `command "docs/+update" denied: write disabled by policy` {
|
||||
t.Fatalf("Error() with Reason = %q", got)
|
||||
}
|
||||
|
||||
noReason := &platform.CommandDeniedError{
|
||||
Path: "docs/+update",
|
||||
Layer: "strict_mode",
|
||||
ReasonCode: "identity_not_supported",
|
||||
}
|
||||
if got := noReason.Error(); got != `command "docs/+update" denied (strict_mode/identity_not_supported)` {
|
||||
t.Fatalf("Error() without Reason = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// errors.As must work so consumers can type-assert without unwrap gymnastics.
|
||||
func TestCommandDeniedError_satisfiesErrorsAs(t *testing.T) {
|
||||
var err error = &platform.CommandDeniedError{Path: "x"}
|
||||
var target *platform.CommandDeniedError
|
||||
if !errors.As(err, &target) {
|
||||
t.Fatalf("errors.As should match CommandDeniedError")
|
||||
}
|
||||
if target.Path != "x" {
|
||||
t.Fatalf("target.Path = %q, want %q", target.Path, "x")
|
||||
}
|
||||
}
|
||||
63
extension/platform/example_test.go
Normal file
63
extension/platform/example_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// ExampleNewPlugin_observer registers an audit Observer that fires
|
||||
// after every command, regardless of success or failure.
|
||||
func ExampleNewPlugin_observer() {
|
||||
p, _ := platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(ctx context.Context, inv platform.Invocation) {
|
||||
_ = inv.Cmd().Path() // do something useful with the command
|
||||
}).
|
||||
FailOpen().
|
||||
Build()
|
||||
fmt.Println(p.Name(), p.Version())
|
||||
// Output: audit 0.1.0
|
||||
}
|
||||
|
||||
// ExampleNewPlugin_wrapper registers a Wrap that short-circuits any
|
||||
// write-class command. The framework converts the returned
|
||||
// *AbortError into a structured "hook" envelope; observers still
|
||||
// fire on the After stage so audit sees the attempt.
|
||||
func ExampleNewPlugin_wrapper() {
|
||||
p, _ := platform.NewPlugin("policy-plugin", "0.1.0").
|
||||
Wrap("block-writes", platform.ByWrite(),
|
||||
func(next platform.Handler) platform.Handler {
|
||||
return func(ctx context.Context, inv platform.Invocation) error {
|
||||
return &platform.AbortError{
|
||||
HookName: "block-writes",
|
||||
Reason: "writes are disabled for this session",
|
||||
}
|
||||
}
|
||||
}).
|
||||
FailOpen().
|
||||
Build()
|
||||
fmt.Println(p.Capabilities().FailurePolicy == platform.FailOpen)
|
||||
// Output: true
|
||||
}
|
||||
|
||||
// ExampleNewPlugin_restrict registers a policy plugin that allows
|
||||
// only docs/* read commands. Note that Restrict() implicitly sets
|
||||
// FailClosed — a policy plugin must abort the binary if it fails to
|
||||
// install, not silently disappear.
|
||||
func ExampleNewPlugin_restrict() {
|
||||
p, _ := platform.NewPlugin("readonly-docs", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "docs-only",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
}).
|
||||
Build()
|
||||
caps := p.Capabilities()
|
||||
fmt.Println(caps.Restricts, caps.FailurePolicy == platform.FailClosed)
|
||||
// Output: true true
|
||||
}
|
||||
2
extension/platform/examples/.gitignore
vendored
Normal file
2
extension/platform/examples/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
audit-observer/audit-observer
|
||||
readonly-policy/readonly-policy
|
||||
13
extension/platform/examples/README.md
Normal file
13
extension/platform/examples/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# lark-cli plugin examples
|
||||
|
||||
Runnable fork-and-blank-import examples that demonstrate the Plugin
|
||||
SDK in production-shape. Each subdirectory is a complete `main`
|
||||
package: `go build .` produces a working CLI.
|
||||
|
||||
| Example | What it shows |
|
||||
| --- | --- |
|
||||
| [audit-observer](./audit-observer/) | Simplest possible plugin: one Observer matching every command, logs to stderr. |
|
||||
| [readonly-policy](./readonly-policy/) | Policy plugin: `Restrict()` with `MaxRisk=read`, demonstrates the `FailClosed` + `Restricts=true` auto-pairing. |
|
||||
|
||||
All examples are built by CI (`make examples-build`) so they cannot
|
||||
silently drift from the SDK.
|
||||
26
extension/platform/examples/audit-observer/README.md
Normal file
26
extension/platform/examples/audit-observer/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Example: audit observer
|
||||
|
||||
The simplest possible lark-cli plugin: one After observer that logs
|
||||
every dispatched command to stderr (success or failure).
|
||||
|
||||
## Build & run
|
||||
|
||||
```sh
|
||||
cd extension/platform/examples/audit-observer
|
||||
go build -o audit-cli .
|
||||
./audit-cli config plugins show
|
||||
# {"plugins":[{"name":"audit", ...}], "total":1}
|
||||
|
||||
./audit-cli api GET /open-apis/contact/v3/users/me
|
||||
# [audit] api ok (on stderr)
|
||||
```
|
||||
|
||||
## Key points
|
||||
|
||||
- `platform.NewPlugin(...).MustBuild()` from `init()`. The blank
|
||||
import of this package in `main.go` triggers `init()`.
|
||||
- `Observer(platform.After, ...)` runs **after** the command's RunE,
|
||||
even on failure (Observers cannot prevent execution).
|
||||
- `FailOpen()` means: if Install ever fails, the binary logs a
|
||||
warning and continues without this plugin. Right default for
|
||||
audit-only plugins.
|
||||
44
extension/platform/examples/audit-observer/main.go
Normal file
44
extension/platform/examples/audit-observer/main.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command audit-observer is a runnable fork of lark-cli that logs
|
||||
// every dispatched command to stderr. Demonstrates the simplest
|
||||
// possible plugin: one After observer matching All commands.
|
||||
//
|
||||
// Build & run:
|
||||
//
|
||||
// cd extension/platform/examples/audit-observer
|
||||
// go build -o audit-cli .
|
||||
// ./audit-cli config plugins show # see "audit" in the list
|
||||
// ./audit-cli api GET /open-apis/... # observer logs to stderr
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("audit", "0.1.0").
|
||||
Observer(platform.After, "log", platform.All(),
|
||||
func(ctx context.Context, inv platform.Invocation) {
|
||||
path := inv.Cmd().Path()
|
||||
if err := inv.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[audit] %s FAILED: %v\n", path, err)
|
||||
} else {
|
||||
log.Printf("[audit] %s ok", path)
|
||||
}
|
||||
}).
|
||||
FailOpen().
|
||||
MustBuild())
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(cmd.Execute())
|
||||
}
|
||||
61
extension/platform/examples/readonly-policy/README.md
Normal file
61
extension/platform/examples/readonly-policy/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Example: read-only policy
|
||||
|
||||
A policy plugin that installs a `Rule` allowing only `docs/*` and
|
||||
`im/*` read commands. Any write command produces a structured
|
||||
`command_denied` envelope.
|
||||
|
||||
## Build & run
|
||||
|
||||
```sh
|
||||
cd extension/platform/examples/readonly-policy
|
||||
go build -o readonly-cli .
|
||||
|
||||
./readonly-cli config policy show
|
||||
# {
|
||||
# "source": "plugin",
|
||||
# "source_name": "readonly",
|
||||
# "denied_paths": N,
|
||||
# "rule": {
|
||||
# "name": "agent-readonly",
|
||||
# "allow": ["docs/**", "im/**"],
|
||||
# "deny": [],
|
||||
# "max_risk": "read",
|
||||
# "identities": [],
|
||||
# "allow_unannotated": false
|
||||
# }
|
||||
# }
|
||||
|
||||
./readonly-cli docs +update --doc-token X --content Y
|
||||
# {"ok":false,"error":{
|
||||
# "type":"command_denied",
|
||||
# "detail":{
|
||||
# "layer":"policy",
|
||||
# "policy_source":"plugin:readonly",
|
||||
# "rule_name":"agent-readonly",
|
||||
# "reason_code":"write_not_allowed"
|
||||
# }
|
||||
# }}
|
||||
|
||||
./readonly-cli docs +fetch --doc-token X
|
||||
# Normal read response (assuming credentials)
|
||||
```
|
||||
|
||||
## Key points
|
||||
|
||||
- `Restrict(&Rule{...})` is the only call needed — the Builder
|
||||
flips Capabilities to `Restricts=true, FailurePolicy=FailClosed`
|
||||
automatically. A policy plugin that silently fails to install
|
||||
would erase the security boundary, so FailClosed is enforced.
|
||||
- `MaxRisk: platform.RiskRead` rejects any command annotated
|
||||
write / high-risk-write.
|
||||
- `AllowUnannotated` is left default (false): unannotated commands
|
||||
are denied with `risk_not_annotated`. Set it to true if you need
|
||||
a gradual-adoption window for the lark-cli main tree.
|
||||
|
||||
## Caveats
|
||||
|
||||
- A binary may have **only one** plugin calling `Restrict()`. Two
|
||||
policy plugins is a deliberate `plugin_conflict` configuration
|
||||
error.
|
||||
- This Rule shadows any `~/.lark-cli/policy.yml` — plugin Rule
|
||||
wins per the resolver precedence.
|
||||
45
extension/platform/examples/readonly-policy/main.go
Normal file
45
extension/platform/examples/readonly-policy/main.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command readonly-policy is a runnable fork of lark-cli that
|
||||
// installs a Rule permitting only docs/* and im/* read commands.
|
||||
// Any write command produces a structured command_denied envelope.
|
||||
//
|
||||
// Build & run:
|
||||
//
|
||||
// cd extension/platform/examples/readonly-policy
|
||||
// go build -o readonly-cli .
|
||||
// ./readonly-cli docs +update --doc-token X --content Y
|
||||
// # {"ok":false,"error":{"type":"command_denied", ...}}
|
||||
//
|
||||
// ./readonly-cli config policy show
|
||||
// # shows the active Rule with source=plugin:readonly
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
func init() {
|
||||
platform.Register(
|
||||
platform.NewPlugin("readonly", "0.1.0").
|
||||
Restrict(&platform.Rule{
|
||||
Name: "agent-readonly",
|
||||
Description: "Only read-class docs/im commands. Suitable for AI-agent sessions.",
|
||||
Allow: []string{"docs/**", "im/**"},
|
||||
MaxRisk: platform.RiskRead,
|
||||
// AllowUnannotated stays default false (fail-closed):
|
||||
// unannotated commands are denied, surfacing missing
|
||||
// risk_level annotations early in adoption.
|
||||
}).
|
||||
MustBuild())
|
||||
// Note: Restrict() implicitly sets Restricts=true and FailClosed.
|
||||
// No need to call FailClosed() explicitly.
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(cmd.Execute())
|
||||
}
|
||||
39
extension/platform/handler.go
Normal file
39
extension/platform/handler.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "context"
|
||||
|
||||
// Handler is the inner function shape every Wrapper composes. It IS the
|
||||
// "command business logic" from the Wrapper's perspective -- calling
|
||||
// next(ctx, inv) inside a Wrapper means "let the command proceed";
|
||||
// returning early without calling next short-circuits.
|
||||
type Handler func(ctx context.Context, inv Invocation) error
|
||||
|
||||
// Observer is a side-effect-only command hook. No return value, no
|
||||
// next-chain control: an Observer can read Invocation but cannot prevent
|
||||
// the command from running. Used for audit, metrics, and completion
|
||||
// logs. After-stage Observers fire even when the command failed
|
||||
// (Invocation.Err() is populated in that case).
|
||||
type Observer func(ctx context.Context, inv Invocation)
|
||||
|
||||
// Wrapper is a middleware-style hook: it receives the rest of the
|
||||
// handler chain and returns a wrapped version. The Wrapper decides
|
||||
// whether to call next (allow), abstain (deny, return an AbortError),
|
||||
// or transform the result. Multiple Wrappers compose left-to-right by
|
||||
// registration order; the outermost runs first.
|
||||
//
|
||||
// ⚠️ IMPORTANT: The factory function `func(next Handler) Handler` is
|
||||
// invoked ONCE PER COMMAND DISPATCH, not once at plugin install. This
|
||||
// lets the framework recover from a panicking factory and convert it
|
||||
// to a structured envelope, but it means any state captured by the
|
||||
// outer closure is rebuilt on every command. Long-lived state (HTTP
|
||||
// clients, caches, metrics counters) MUST live on the Plugin struct
|
||||
// or in package-level variables, never in factory-local captures.
|
||||
type Wrapper func(next Handler) Handler
|
||||
|
||||
// LifecycleHandler runs at one of the process-level LifecycleEvent
|
||||
// slots. The handler may use ctx for cancellation; in the Shutdown
|
||||
// case the framework supplies a context with a 2-second hard deadline.
|
||||
type LifecycleHandler func(ctx context.Context, lc *LifecycleContext) error
|
||||
40
extension/platform/identity.go
Normal file
40
extension/platform/identity.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Identity is the identity taxonomy a command supports.
|
||||
//
|
||||
// Defined type (not alias) so plugin authors get compile-time +
|
||||
// IDE help; raw-string boundaries (yaml, cobra annotation) cross
|
||||
// through ParseIdentity.
|
||||
type Identity string
|
||||
|
||||
const (
|
||||
IdentityUser Identity = "user"
|
||||
IdentityBot Identity = "bot"
|
||||
)
|
||||
|
||||
// ParseIdentity converts a raw string into an Identity. Returns
|
||||
// ("", nil) for empty input ("not specified"), error for unrecognised
|
||||
// values. Matching is strict (case-sensitive, no trim).
|
||||
func ParseIdentity(s string) (Identity, error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
id := Identity(s)
|
||||
if id != IdentityUser && id != IdentityBot {
|
||||
return "", fmt.Errorf("invalid identity %q: must be user|bot", s)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// IsValid reports whether i is one of the two recognised values.
|
||||
func (i Identity) IsValid() bool {
|
||||
return i == IdentityUser || i == IdentityBot
|
||||
}
|
||||
|
||||
// String returns the underlying string.
|
||||
func (i Identity) String() string { return string(i) }
|
||||
56
extension/platform/invocation.go
Normal file
56
extension/platform/invocation.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "time"
|
||||
|
||||
// Invocation is the per-command data a Wrapper / Observer receives. It
|
||||
// is a read-only interface: the framework implementation lives in
|
||||
// internal/hook and is never visible to plugins, so plugin code cannot
|
||||
// mutate denial state.
|
||||
//
|
||||
// The interface is deliberately NOT a context.Context — it is data only,
|
||||
// no cancellation. ctx (from the handler signature) carries
|
||||
// cancellation / timeout / trace propagation.
|
||||
//
|
||||
// Accessor semantics:
|
||||
//
|
||||
// - Cmd / Args / Started are populated before the first hook fires
|
||||
// - Err is populated for After observers and the post-next portion of
|
||||
// a Wrapper (the value the wrapped handler returned)
|
||||
// - DeniedByPolicy / DenialLayer / DenialPolicySource are populated by
|
||||
// the framework's denial guard before any hook runs
|
||||
type Invocation interface {
|
||||
// Cmd returns the read-only metadata view of the dispatched command.
|
||||
Cmd() CommandView
|
||||
|
||||
// Args returns a fresh copy of the positional args.
|
||||
Args() []string
|
||||
|
||||
// Started is the wall-clock time the outermost RunE wrapper began.
|
||||
Started() time.Time
|
||||
|
||||
// Err is the error the wrapped handler returned. Populated for
|
||||
// After observers and the post-next portion of a Wrapper. nil
|
||||
// before the handler runs.
|
||||
Err() error
|
||||
|
||||
// DeniedByPolicy reports whether the command was rejected by either
|
||||
// strict-mode or user-layer policy before the chain reached the
|
||||
// hook. Observers fire even for denied commands (audit case); Wrap
|
||||
// is physically isolated by the framework so plugins do not need
|
||||
// to check this themselves before calling next.
|
||||
DeniedByPolicy() bool
|
||||
|
||||
// DenialLayer returns the layer that rejected the command:
|
||||
//
|
||||
// "" - not denied
|
||||
// "strict_mode" - credential strict-mode
|
||||
// "policy" - user-layer Rule (Plugin.Restrict() or yaml)
|
||||
DenialLayer() string
|
||||
|
||||
// DenialPolicySource returns the specific source identifier
|
||||
// ("plugin:secaudit", "yaml", "strict-mode"). Empty when not denied.
|
||||
DenialPolicySource() string
|
||||
}
|
||||
46
extension/platform/lifecycle.go
Normal file
46
extension/platform/lifecycle.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// When selects the temporal slot for command-level Observer hooks. The
|
||||
// framework wraps every command's RunE so both stages always fire, even
|
||||
// when RunE itself returns an error (After is failure-safe).
|
||||
type When int
|
||||
|
||||
const (
|
||||
// Before fires immediately before the command's business logic.
|
||||
Before When = iota
|
||||
|
||||
// After fires after the command's business logic (or its denyStub
|
||||
// in the denied path). Always fires, even when RunE returned an
|
||||
// error; Invocation.Err is populated in that case.
|
||||
After
|
||||
)
|
||||
|
||||
// LifecycleEvent selects the temporal slot for Lifecycle hooks. These are
|
||||
// process-level events that fire once per binary execution, not per
|
||||
// command. Only Startup and Shutdown are defined: additional bootstrap
|
||||
// phases can be added later as a non-breaking addition if a concrete
|
||||
// consumer surfaces.
|
||||
type LifecycleEvent int
|
||||
|
||||
const (
|
||||
// Startup fires after plugin install has committed; Plugin.On
|
||||
// handlers for Startup are guaranteed to be registered before this
|
||||
// event is emitted (so they can receive it).
|
||||
Startup LifecycleEvent = iota
|
||||
|
||||
// Shutdown fires once before the process exits. Handler total
|
||||
// execution is bounded by a hard 2s timeout to prevent a
|
||||
// misbehaving handler from holding up exit.
|
||||
Shutdown
|
||||
)
|
||||
|
||||
// LifecycleContext is passed to LifecycleHandler. Err is the error from
|
||||
// the preceding command (when Event == Shutdown after a failed RunE);
|
||||
// otherwise nil.
|
||||
type LifecycleContext struct {
|
||||
Event LifecycleEvent
|
||||
Err error
|
||||
}
|
||||
26
extension/platform/plugin.go
Normal file
26
extension/platform/plugin.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// Plugin is the single contract a third-party / embedding integrator
|
||||
// implements to extend lark-cli. Four methods, every one mandatory.
|
||||
//
|
||||
// Name must match the grammar ^[a-z0-9][a-z0-9-]*$. The "." character
|
||||
// is forbidden so plugin-name + hookName namespacing never produces
|
||||
// ambiguous joins.
|
||||
//
|
||||
// Capabilities must be implemented even when every field is zero. The
|
||||
// requirement is deliberate: it keeps FailurePolicy / Restricts in the
|
||||
// author's eyeline.
|
||||
//
|
||||
// Install runs once during the Bootstrap pipeline. The plugin uses the
|
||||
// supplied Registrar to register hooks and (optionally) a Rule. Errors
|
||||
// returned from Install honour the plugin's Capabilities.FailurePolicy
|
||||
// (fail-open warns + skips this plugin; fail-closed aborts the CLI).
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Version() string
|
||||
Capabilities() Capabilities
|
||||
Install(r Registrar) error
|
||||
}
|
||||
58
extension/platform/register.go
Normal file
58
extension/platform/register.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "sync"
|
||||
|
||||
// Register adds a plugin to the global registry. Plugins call this from
|
||||
// init() (typically through a blank import in the embedder's main).
|
||||
//
|
||||
// Register is intentionally tolerant of malformed input: validation
|
||||
// happens later in the host's InstallAll phase, where errors can be
|
||||
// surfaced through the typed plugin_install envelope. Register itself
|
||||
// never panics so that init-time problems do not crash the binary
|
||||
// before main has a chance to install its recover-and-envelope logic.
|
||||
//
|
||||
// The registry holds plugins in insertion order so InstallAll can
|
||||
// process them deterministically.
|
||||
func Register(p Plugin) {
|
||||
pluginRegistry.add(p)
|
||||
}
|
||||
|
||||
// RegisteredPlugins returns a snapshot of the global plugin registry.
|
||||
// Order matches Register insertion. The host reads this once during
|
||||
// InstallAll.
|
||||
func RegisteredPlugins() []Plugin {
|
||||
return pluginRegistry.snapshot()
|
||||
}
|
||||
|
||||
// pluginRegistry is the package-level singleton. The mutex protects
|
||||
// concurrent Register calls -- harmless in practice (init runs
|
||||
// serially) but cheap insurance.
|
||||
var pluginRegistry = ®istry{}
|
||||
|
||||
type registry struct {
|
||||
mu sync.Mutex
|
||||
plugins []Plugin
|
||||
}
|
||||
|
||||
func (r *registry) add(p Plugin) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins = append(r.plugins, p)
|
||||
}
|
||||
|
||||
func (r *registry) snapshot() []Plugin {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]Plugin, len(r.plugins))
|
||||
copy(out, r.plugins)
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *registry) reset() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins = nil
|
||||
}
|
||||
52
extension/platform/register_test.go
Normal file
52
extension/platform/register_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
type stubPlugin struct{ name string }
|
||||
|
||||
func (s stubPlugin) Name() string { return s.name }
|
||||
func (s stubPlugin) Version() string { return "0.0.1" }
|
||||
func (s stubPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
|
||||
func (s stubPlugin) Install(platform.Registrar) error { return nil }
|
||||
|
||||
// Tests should always reset the global registry to keep them
|
||||
// independent. Verifies the reset hook is functional.
|
||||
func TestRegister_preservesInsertionOrder(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
|
||||
platform.Register(stubPlugin{name: "a"})
|
||||
platform.Register(stubPlugin{name: "b"})
|
||||
platform.Register(stubPlugin{name: "c"})
|
||||
|
||||
got := platform.RegisteredPlugins()
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d plugins, want %d", len(got), len(want))
|
||||
}
|
||||
for i, p := range got {
|
||||
if p.Name() != want[i] {
|
||||
t.Errorf("plugins[%d] = %q, want %q", i, p.Name(), want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_resetClears(t *testing.T) {
|
||||
platform.ResetForTesting()
|
||||
t.Cleanup(platform.ResetForTesting)
|
||||
platform.Register(stubPlugin{name: "a"})
|
||||
if len(platform.RegisteredPlugins()) != 1 {
|
||||
t.Fatalf("expected 1 plugin")
|
||||
}
|
||||
platform.ResetForTesting()
|
||||
if len(platform.RegisteredPlugins()) != 0 {
|
||||
t.Fatalf("expected reset to clear")
|
||||
}
|
||||
}
|
||||
16
extension/platform/register_testing.go
Normal file
16
extension/platform/register_testing.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// ResetForTesting clears the global plugin registry. Exposed for test
|
||||
// isolation only — plugin authors and SDK consumers must NOT call this
|
||||
// from production code. The function is exported (rather than placed in
|
||||
// an internal test-only file) so that `go test ./...` works for every
|
||||
// downstream package without an extra build tag.
|
||||
//
|
||||
// Tests that exercise plugin registration must defer
|
||||
// `t.Cleanup(platform.ResetForTesting)` so subsequent tests start from a
|
||||
// clean slate. The helper is NOT goroutine-safe across concurrent
|
||||
// `t.Parallel()` tests — the global registry is shared process state.
|
||||
func ResetForTesting() { pluginRegistry.reset() }
|
||||
36
extension/platform/registrar.go
Normal file
36
extension/platform/registrar.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
// Registrar is the imperative API a plugin uses inside its Install
|
||||
// method to wire up hooks and rules. The framework provides a staging
|
||||
// implementation that buffers calls and commits them atomically when
|
||||
// Install returns nil; failure rolls everything back.
|
||||
//
|
||||
// hookName must match the grammar ^[a-z0-9][a-z0-9-]*$ (no dots). The
|
||||
// framework prepends the plugin's Name() with a dot so the global hook
|
||||
// identifier is "{plugin}.{hook}". A plugin cannot register two hooks
|
||||
// with the same name in the same Install call.
|
||||
//
|
||||
// Restrict may be called at most once per plugin; multiple plugins
|
||||
// contributing Restrict() is a configuration error (the resolver
|
||||
// aborts startup).
|
||||
type Registrar interface {
|
||||
// Observe registers a side-effect-only command hook at the given
|
||||
// When stage. The selector decides which commands it fires on.
|
||||
Observe(when When, hookName string, sel Selector, fn Observer)
|
||||
|
||||
// Wrap registers a middleware-style command hook. The Wrap chain
|
||||
// composes left-to-right in registration order; the outermost
|
||||
// Wrapper runs first.
|
||||
Wrap(hookName string, sel Selector, w Wrapper)
|
||||
|
||||
// On registers a lifecycle handler for the given event.
|
||||
On(event LifecycleEvent, hookName string, fn LifecycleHandler)
|
||||
|
||||
// Restrict contributes a pruning Rule. The framework merges it
|
||||
// with the yaml-sourced Rule using single-rule semantics: plugin
|
||||
// rule wins, but two plugins both calling Restrict abort startup.
|
||||
Restrict(r *Rule)
|
||||
}
|
||||
71
extension/platform/risk.go
Normal file
71
extension/platform/risk.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package platform
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Risk is the three-tier risk taxonomy declared on every command.
|
||||
//
|
||||
// A defined type (not an alias of string) so plugin authors get
|
||||
// compile-time + IDE candidate help when passing the constants below.
|
||||
// Crossing the string boundary (yaml, cobra annotation) goes through
|
||||
// ParseRisk so typos surface as `risk_invalid` rather than silently
|
||||
// flowing through.
|
||||
type Risk string
|
||||
|
||||
const (
|
||||
RiskRead Risk = "read"
|
||||
RiskWrite Risk = "write"
|
||||
RiskHighRiskWrite Risk = "high-risk-write"
|
||||
)
|
||||
|
||||
// riskOrder maps the Risk taxonomy to a comparable rank. The pruning
|
||||
// engine compares ranks for the MaxRisk axis.
|
||||
var riskOrder = map[Risk]int{
|
||||
RiskRead: 0,
|
||||
RiskWrite: 1,
|
||||
RiskHighRiskWrite: 2,
|
||||
}
|
||||
|
||||
// ParseRisk converts a raw string (yaml, cobra annotation) into a Risk.
|
||||
//
|
||||
// - s == "" → ("", nil) "not specified"
|
||||
// - s 在闭合枚举 → (Risk(s), nil) OK
|
||||
// - s 不在枚举内 → ("", error) invalid
|
||||
//
|
||||
// The (absent vs invalid) split mirrors the cmdpolicy engine's
|
||||
// risk_not_annotated vs risk_invalid reason codes — callers can treat
|
||||
// the "" + nil case as "not specified" without losing the distinction
|
||||
// from a typo.
|
||||
//
|
||||
// Matching is strict: "Read" / "READ" / " read " are all rejected.
|
||||
// annotation is developer code, not user input — strict matching is
|
||||
// the typo-catch mechanism, not a normalisation opportunity.
|
||||
func ParseRisk(s string) (Risk, error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
r := Risk(s)
|
||||
if _, ok := riskOrder[r]; !ok {
|
||||
return "", fmt.Errorf("invalid risk %q: must be read|write|high-risk-write", s)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// IsValid reports whether r is one of the three recognised values.
|
||||
func (r Risk) IsValid() bool {
|
||||
_, ok := riskOrder[r]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Rank returns the comparable rank of r. ok=false when r is not in the
|
||||
// closed taxonomy.
|
||||
func (r Risk) Rank() (rank int, ok bool) {
|
||||
rank, ok = riskOrder[r]
|
||||
return rank, ok
|
||||
}
|
||||
|
||||
// String returns the underlying string. Useful for yaml/json output
|
||||
// and cobra annotation injection.
|
||||
func (r Risk) String() string { return string(r) }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user