Compare commits

...

95 Commits

Author SHA1 Message Date
liangshuo-1
b25ff1ced5 chore(release): v1.0.40 (#1086)
Change-Id: I6dabe4c03e9b8daf0e0d8c175e0f2a4c4a9bfb0e
2026-05-25 21:31:27 +08:00
fangshuyu-768
aea9f37f58 feat(wiki): add exponential backoff retry for +node-create lock contention (#1012) (#1076)
When creating wiki nodes under the same parent concurrently, the API
returns error code 131009 (lock contention) ~5-15% of the time. This
adds automatic retry with exponential backoff (250ms, 500ms; max 2
retries) so callers no longer need to implement retry logic themselves.

- Retry loop in runWikiNodeCreate: only retries on code 131009, respects
  context cancellation, prints progress to stderr
- wrapWikiNodeCreateRetryError preserves Err/Raw/Detail.Code in ExitError
- 6 unit tests covering retry success, exhaustion, non-contention error,
  single-retry success, context cancellation, no-retry on success
- 8 dry-run E2E tests for wiki +node-create request shape and validation
2026-05-25 20:03:17 +08:00
liujinkun2025
ac06eaa0f4 fix(wiki): rename +node-get --token to --node-token, keep alias (#1074)
Per issue #1049 (third point), wiki +node-get used --token while sibling
commands (+node-delete / +node-copy / +move) use --node-token. The
inconsistency forced humans and AI agents to remember which adjacent
command takes which flag.

Make --node-token the canonical flag and keep --token as a hidden,
deprecated alias so existing scripts continue to work. pflag's
MarkDeprecated prints "Flag --token has been deprecated, use --node-token
instead" to stderr on use, guiding callers to migrate. Conflict between
the two with different values is rejected upfront.

Skills docs (lark-wiki, lark-base) updated to prefer --node-token.

Change-Id: I3415a98f079613c0b1a0b989cf54a09cbb8986fb
2026-05-25 17:28:45 +08:00
fangshuyu-768
282c27784d docs(skills): add 云盘/云存储 alias alongside 云空间 for agent clarity (#1073) 2026-05-25 17:15:50 +08:00
郭立lee
f2a4c95665 fix(output): classify wiki lock-contention error (131009) with retry hint (#1014)
Wiki write-path operations (most commonly `wiki +node-create` against the
same parent) surface code 131009 "lock contention" under concurrent calls.
Currently this falls through to the generic "api_error" classification,
giving users no hint that it is transient and safe to retry.

Mirror the existing `LarkErrDriveResourceContention` (1061045) treatment:
add a named constant, classify as "conflict", and emit a hint that points
the caller toward exponential backoff or serializing sibling-node writes.

Refs: #1012
2026-05-25 15:50:45 +08:00
JackZhao10086
cb5055eb46 feat(auth): add auth qrcode subcommand and update auth docs/hints (#968)
* feat(auth): add auth qrcode subcommand and update auth docs/hints

* refactor(auth/qrcode): improve qrcode command with validation and custom output
2026-05-25 15:34:00 +08:00
WJzz1
9d4233bfe3 fix(contact): add actionable hint when fanout search all-fail with no API code (#1054)
In buildFanoutResponse, when every fanout query fails AND the first failure
has no Lark API code (i.e. transport, parse, panic, or context-cancel),
the returned ExitError was carrying an empty Hint. This is the only
output.ErrWithHint call in shortcuts/ that ships an empty hint.

AGENTS.md states: "every error message you write will be parsed by an AI
to decide its next action. Make errors structured, actionable, and
specific." An empty hint gives the agent nothing to do.

Populate the hint with the actionable next step for this branch — retry,
and if it persists, narrow --queries to a single term to isolate the
failing input. The companion test exercises the no-code path and asserts
the hint is non-empty and mentions "retry".

Co-authored-by: Wang-Yeah623 <Wang-Yeah623@users.noreply.github.com>
2026-05-25 12:08:18 +08:00
zed
708cbc2b31 fix: use ErrValidation instead of fmt.Errorf in Validate paths (#1001)
Replace 8 bare fmt.Errorf calls with output.ErrValidation across 3 files
so validation errors consistently return structured JSON (type: validation,
exit 2) matching the rest of the codebase.

Affected functions: validateExpectedFlag (sheets), validateSendTime,
validateComposeInlineAndAttachments, validateEventFlags (mail),
validateSignatureWithPlainText (mail)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:52:51 +08:00
ZEden0
6d1f9980fa fix: annotate auto-grant permission failures with required_scope and console_url (#1045)
When AutoGrantCurrentUserDrivePermission encounters lark code 99991672/99991679,
extract permission_violations from the underlying ExitError and surface
lark_code, required_scope, and console_url on the result map. Override the
generic fallback hint with one pointing at the developer console — the
concrete next step a user can take.

Refactor extractRequiredScopes / SelectRecommendedScope wrapping / console URL
construction out of cmd/root.go into internal/registry/scope_hint.go so both
the top-level enrichPermissionError path and the best-effort sub-call path in
shortcuts/common share one implementation.

Change-Id: Ida63ed160d1167b7961b6faac5c2cf9b7f971c65
2026-05-25 11:01:01 +08:00
zero-my
6e3e120ec8 Docs/lark task shortcut doc refresh (#1057)
* docs: align lark-task attachment descriptions

* docs: restore lark-task attachment capability summary
2026-05-24 00:32:28 +08:00
liangshuo-1
ce5b4f24e1 chore(release): v1.0.39 (#1052)
Change-Id: I06bca4f3aedec1adee9ecd3d060c333cc6dd301e
2026-05-22 21:10:35 +08:00
MaxHuang22
4b2223194b fix: add 22 new scope entries to scope priorities (#1050)
Change-Id: I2e7bb2e2971bfb071c3976d349b2d2bc4cc485ae
2026-05-22 19:48:08 +08:00
zgz2048
4582dfd281 docs(base): update location full_address guidance (#754) 2026-05-22 18:05:35 +08:00
ethan-zhx
5c01a7f7f0 feat(slides): export slides (#988)
Change-Id: Ice3e8784e78986d427c4c94664e1e5edff2a4fcd
2026-05-22 17:19:49 +08:00
raistlin042
d5d2fee848 chore(apps): refine lark-apps skill description and surface (#1040)
- description: switch from trigger-word enumeration to a general
  principle (any HTML artifact intended to be independently accessible
  falls under this skill; defer the deploy-vs-demo decision to the
  skill body)
- surface apps +access-scope-get in prerequisites list and Shortcuts
  table so agents can find the read side of access-scope
- add "writing HTML hard constraints" section: index.html is the
  required entry filename, --path cannot equal cwd (both are CLI-side
  hard rejects that previously only lived in the html-publish ref)
2026-05-22 16:39:36 +08:00
hGrany
ffcf7781b4 feat(sidecar): support multi-client identity isolation in server-demo (#934)
* feat(sidecar): support multi-client identity isolation in server-demo

When multiple CLI sandbox environments share a single sidecar instance,
user tokens (UAT) were not isolated -- the last user to log in would
overwrite previous users' tokens, causing identity cross-contamination.

This change introduces per-client HMAC key isolation:
- Each client gets a unique client-*.key file for data-plane HMAC signing,
  allowing the sidecar to identify request origin.
- A new auth_bridge.go handles management endpoints (login/poll/status)
  with explicit client-to-feishuOpenId binding.
- User token resolution is strictly bound to the matched client -- no
  fallback to other users' tokens when a client has no mapping.
- The shared proxy.key is reused across restarts instead of regenerated,
  fixing a race condition when multiple sidecar instances start together.

Wire protocol (sidecar package) is unchanged; existing single-client
deployments are fully backward compatible.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* fix(sidecar): address review feedback on filesystem and safety

- Replace os.ReadFile/WriteFile/ReadDir with vfs.* equivalents for test
  mockability, consistent with project coding guidelines.
- Limit auth bridge request body to 64KB to prevent memory exhaustion.
- Log errors in saveUserMap instead of silently discarding them.
- Reject client keys that collide with the shared proxy key.
- Reject duplicate client keys instead of silently overwriting.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* refactor(sidecar): remove workspace-specific naming and backward compat

- parseClientID: only accept "client_id" field, remove legacy fallback
- loadClientKeys: scan all *.key (excluding proxy.key), no prefix required
- Remove legacy file migration logic in newAuthBridge
- Update flag description to reflect generic key scanning

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* refactor(sidecar): extract multi-tenant demo and add unit tests

Address review feedback from sang-neo03:

1. Extract multi-client code into sidecar/server-multi-tenant-demo/,
   keeping server-demo as the minimal single-tenant reference.

2. Add unit tests for the isolation guarantee:
   - loadClientKeys: shared-key collision and duplicate keyHex are skipped
   - verifyWithClientKeys: correct client matched, unknown key rejected
   - loadUserMap/saveUserMap: round-trip persistence across restart

3. Cross-link READMEs between server-demo and server-multi-tenant-demo.

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* docs(sidecar): rewrite multi-tenant demo README with problem statement and client guide

- Explain the multi-app credential isolation problem (app_secret must
  not be exposed to client environments)
- Document typical deployment topology with multiple sidecar instances
- Add complete client setup guide: env vars, multi-app switching, login
  flow, and end-to-end workflow example
- Document design decisions and management endpoint details

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

* fix(sidecar): address CodeRabbit review feedback on tests and docs

- Make TestProxyHandler_AcceptsAllowedAuthHeaders fully offline by using
  httptest.NewTLSServer instead of depending on open.feishu.cn
- Isolate TestRun_RejectsSelfProxy config state with t.Setenv and temp dirs
- Check os.MkdirAll error in test fixture setup
- Add language identifiers to fenced code blocks (MD040)
- Validate user-supplied CLI paths with validate.SafeInputPath

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)

---------

Signed-off-by: Gao Yang <grany@yeah.net> (topwin.tech)
2026-05-22 15:25:00 +08:00
liujiashu-shiro
fbe4cc689a feat(im): support Markdown image rendering in post content (#893)
add documentation for sending Markdown images, and align image handling guidance with actual runtime behavior
2026-05-22 10:44:10 +08:00
liangshuo-1
ac85c3e34d chore(release): v1.0.38 (#1026)
- Bump version to 1.0.38
- Update CHANGELOG.md with the apps brand gating change since v1.0.37
- Backfill the [v1.0.38] link reference at the bottom of CHANGELOG.md

Change-Id: I6fd0d1243e2219a1eaa1fae5fae4ff6d8de361da
2026-05-22 03:20:21 +08:00
liangshuo-1
daba3c9afd feat(apps): gate apps domain off on Lark brand (#1025)
* feat(apps): gate apps domain off on Lark brand

The Miaoda apps OpenAPI is Feishu-only. On Lark brand:

- shortcut subtree is registered + hidden, RunE returns a structured
  brand-restriction error so users see a clear message instead of
  cobra's generic "unknown command"
- auth login `--domain apps` is treated as unknown; `--domain all`
  skips apps; help text omits it
- scope collection skips apps shortcuts so spark:* scopes are never
  requested

The leaf-stub pattern mirrors internal/cmdpolicy/apply.go::installDenyStub
(DisableFlagParsing + ArbitraryArgs + leaf-level PersistentPreRunE
override) so cobra can't short-circuit the stub with a missing-flag or
parent-PreRunE detour.

Change-Id: I5817e87ae6fedabdb5faf05d0d32ea988f7effc9
2026-05-22 03:03:41 +08:00
wangweiming-01
e54220ade1 feat: support files in drive +add-comment (#975)
* feat: support markdown files in drive +add-comment

Change-Id: Id9a87706a1e43756d8142637be9ec1e0748d4ddf

* fix: use markdown file comment anchor placeholder

Change-Id: Ifffc4cdd963c13e53f4cad154aebe11ae309df9e

* fix: gate drive file comments by supported extensions

Change-Id: Ie6c7f38dbbea1f87a81600da71180627b53a2355
2026-05-21 21:40:27 +08:00
liangshuo-1
d3fbc88527 chore(release): v1.0.37 (#1021)
Change-Id: Ifcc78649e294d516015846d746bb2bc65b239eb3
2026-05-21 20:44:23 +08:00
liujinkun2025
652e96906c feat(wiki): add +member-add / +member-remove / +member-list shortcuts (#997)
- +member-add: wrap POST /spaces/{id}/members; --member-type / --member-role
  enums, optional --need-notification query (omitted entirely when the flag
  is unset, instead of forcing need_notification=false), my_library
  resolution under --as user, flattened single-member output
- +member-remove: wrap DELETE /spaces/{id}/members/{member_id}; surfaces the
  required member_type + member_role body the API expects, my_library
  resolution, fallback to echoing the caller's inputs when the API omits
  the member echo
- +member-list: wrap GET /spaces/{id}/members; reuses the +space-list /
  +node-list pagination contract (single page by default, --page-all walks
  every page capped by --page-limit, --page-token resumes a cursor)
- All three reject bot identity + my_library upfront with a clear hint and
  declare the narrowest scope the API accepts (wiki:member:create /
  wiki:member:update / wiki:member:retrieve) so tokens carrying only the
  narrow scope are not false-rejected by the exact-string preflight
- skill docs: reference pages for the three new shortcuts + SKILL.md
  shortcuts table; switch the membership flow guidance from raw
  `wiki members create` to the new +member-add path

Change-Id: I158a86aa7f00bb7cecc7a4e99346f3fb151b3c09
2026-05-21 20:40:55 +08:00
raistlin042
6cea6c9af0 feat(apps): add miaoda apps domain (6 shortcuts + dry-run e2e) (#1002)
Adds the apps domain to lark-cli for managing Miaoda (妙搭) applications: 6 shortcuts covering the full lifecycle (+create / +update / +list / +access-scope-set / +access-scope-get / +html-publish). Aligned with the OAPI v2 design — app_type enum (currently HTML), string scope enum (All / Tenant / Range), cursor pagination, in-memory tar.gz multipart publish flow. Namespace registered at /open-apis/spark/v1/ with spark:app.* scopes.

---------

Co-authored-by: wangjiangwen-gif <286006750+wangjiangwen-gif@users.noreply.github.com>
2026-05-21 20:30:42 +08:00
fangshuyu-768
816927f8b8 fix: surface auto-grant failures via stderr and JSON hint (#1015)
When a resource is created with bot identity, the CLI attempts to
auto-grant full_access to the current user. If the user open_id is
missing or the grant API call fails, the result was only written to
the JSON permission_grant field and easily overlooked.

Changes:
- Add stderr warnings when auto-grant is skipped or fails
- Add 'hint' field to permission_grant JSON output with failure reason
  and actionable next step (e.g. auth login, check scope, retry)
- Add end-to-end skipped/failed tests across all affected shortcuts
  (doc, drive, sheets, slides, wiki, markdown, base)

Closes #963
2026-05-21 18:17:24 +08:00
caojie0621
56749e70cb fix(sheets): use FileIO for write-image input (#996) 2026-05-21 15:53:44 +08:00
liangshuo-1
8c700aea00 chore(release): v1.0.36 (#1011)
Change-Id: Ifb0b6bf05d486943d9a689bf63dde2251dcd3500
2026-05-21 12:24:14 +08:00
MaxHuang22
42746d6c9d fix: revert incremental skills sync (#965) (#1008)
Change-Id: Ic95e8a74a0d6fc7f89782dccde867fd794cfcf46
2026-05-21 12:08:27 +08:00
zed
94b103dbf6 fix(auth): return validation error when --scope is empty in auth check (#999)
strings.Fields("") returns an empty slice, causing --scope "" to bypass
validation and return ok: true. Replace the false-positive success path
with an ErrValidation error so callers correctly detect the invalid input.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:52:05 +08:00
wangweiming-01
e19e09019c feat: return real tenant URLs for drive +upload and markdown +create (#992)
Change-Id: I6b513eef57a3479c8971b3bb6cbf005cad3f8040
2026-05-21 11:07:37 +08:00
search_zhuhao
3bab9a0692 docs(lark-drive): improve search evidence guidance (#864)
Change-Id: I000c2d56962e6da2a7ef77d986c2eb73ec286546
2026-05-20 20:45:41 +08:00
liangshuo-1
6840bb7415 chore(release): v1.0.35 (#995)
Change-Id: I6ddc8cfc029c684deb5de4f210357e19ade083e1
2026-05-20 19:46:10 +08:00
caojie0621
ce485eb3f5 fix(sheets): declare metadata scope for info shortcut (#994) 2026-05-20 19:43:21 +08:00
YangJunzhou-01
c98a49f2a3 docs(im): clarify media key formats for message media flags (#991)
* docs(im): clarify media path restrictions

* docs(im): clarify file key formats for message file flags

Change-Id: I329ca0db9e7a01b774846d522d1b2a64da74233c

---------

Co-authored-by: mtsui-cmyk <mervyntsui@gmail.com>
2026-05-20 17:39:14 +08:00
wangweiming-01
c02a38f077 feat: support wiki node target in markdown +create (#883)
Change-Id: Idb89464344599571cda3d27d136727553dcf0e7e
2026-05-20 17:03:32 +08:00
zhangheng023
3a3fc31d0b feat: add incremental skills sync (#965)
* feat: add incremental skills sync

* fix: address skills sync review feedback
2026-05-20 16:27:07 +08:00
wangweiming-01
8c73f49e91 docs: add media-preview reference (#990)
Change-Id: I5ba1991874e262fb98f3421e61503b58bb71d861
2026-05-20 15:59:39 +08:00
liujinkun2025
9272b9da99 docs(skills): migrate docs +search to drive +search and fix creator_ids owner semantic (#951)
docs +search is in maintenance and will be removed; cloud-space resource
discovery is consolidated onto drive +search. Two related doc/help fixes:

1. Redirect guidance: docs +search -> drive +search
   - skill-template/domains/{doc,sheets}.md
   - lark-base/SKILL.md: --filter '{"doc_types":["BITABLE"]}' -> --doc-types bitable
   - lark-sheets/SKILL.md: body + frontmatter description, add drive-search ref link
   Same server API, equivalent capability; only flattens the entry from
   nested --filter JSON to flags. reference links repointed to lark-drive.

2. Fix creator_ids/--mine semantic: creator -> owner
   The server matches creator_ids (incl. --mine / --creator-ids) by owner
   (document owner), not original creator, despite the OpenAPI field name.
   - shortcuts/drive/drive_search.go: --help Desc and Tip
   - lark-drive/references/lark-drive-search.md: identity section, params, rules, examples
   - lark-drive/SKILL.md: top-level guidance
   - lark-doc/references/lark-doc-search.md: creator_ids usage note (now self-consistent)
   Wire field name creator_ids kept (aligned with the server).

Docs/help strings only, no logic change; gofmt / go vet / package build pass.

Change-Id: If3ebf5a247b7e38b58050c677dc888a310f1c6b6
2026-05-20 15:08:50 +08:00
wangweiming-01
27a5eeddcc docs: prefer local comments for drive reviews (#981)
* docs: prefer local comments for drive reviews

Change-Id: Ie2eaa54320cd2612b66b2d617750d23b950e38db

* docs: align drive comment fallback guidance

Change-Id: Ia7512babe3656b57374c86068198c8192871ff81
2026-05-20 14:32:18 +08:00
zgz2048
0c4eadd41e docs: add wiki base fast path (#982) 2026-05-20 14:31:45 +08:00
yballul-bytedance
69c34481f5 feat: Product CLI 4no-meego (#759)
Change-Id: If08f236c8ae351f92683f2b861cc999eb6f1d22d
2026-05-20 14:02:03 +08:00
wangweiming-01
fa45e1c7e4 feat: add markdown +diff shortcut (#876)
* feat: add markdown +diff shortcut

Change-Id: I7da27889517707ac6f1d5e8c429e4bdfb49fdcf8

* fix: harden markdown diff downloads

Change-Id: I0020e14ebee780617d790836af1368db851b8cf1

* refactor: address markdown diff review feedback

Change-Id: I0ddb852218ec4784c0f9491896796c3007f04122
2026-05-20 12:20:51 +08:00
河伯
d793790807 feat(doc): warn before overwrite when document contains whiteboard or file blocks (#825)
* feat(doc): warn before overwrite when document contains whiteboard or file blocks

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

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

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

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

Change-Id: I997ec0654b9448eb0cc6ed7c15493dd2316ffa39

* docs(lark-vc): clarify pagination precedence

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

- Update DryRun and Execute paths to use V2 endpoint with JSON body
- Add docx:document:readonly scope for the new API
- Validate V2 response structure (fail fast on missing document/content)
- Encode token in URL path via validate.EncodePathSegment
- Update unit tests and add V2 response validation error path tests
- Add E2E dry-run test for markdown export path
- Update skill documentation
2026-05-19 17:53:54 +08:00
liangshuo-1
e6bc292575 fix(identitydiag): harden verify path and tighten status semantics (#961)
* fix(identitydiag): harden verify path and tighten status semantics

Follow-ups to #957:

- bound bot/user verify calls with a 10s timeout (mirrors the doctor
  endpoint probe) so a hanging server cannot wedge `auth status --verify`
  or `doctor`
- return StatusNotConfigured (not StatusMissing) when the user-identity
  path is blocked by missing app config, matching the bot side
- surface the `{code, msg}` envelope on bot-info HTTP 4xx responses so
  callers see why bot auth was rejected, not just the bare HTTP code
- introduce identity{User,Bot,None} constants in cmd/auth/status.go and
  use the exported StatusMessage() in the human-readable note instead of
  raw status codes like "not_configured"
- collapse the duplicated verify-failed identity construction in the
  user path into a local helper
- cover the new failure paths with unit tests (HTTP 4xx with envelope,
  business error code, user server-rejected, expired user token,
  strict-mode user-only, missing app config for user)

Change-Id: I581348a65f15b1452a6f48a3e3245d09257314ac

* fix(identitydiag): decode bot/v3/info from "bot" field, not "data"

`/open-apis/bot/v3/info` returns `{code, msg, bot: {...}}` — the bot
payload is under `bot`, not `data` as the newer Lark API convention
would suggest. The decoder was reading from a non-existent `data`
field, so `envelope.Data.OpenID` was always empty and every successful
verify was reported as `Bot identity: verify failed: open_id is empty`.

The pre-existing test mocks used `{"data": {...}}` matching the buggy
decoder, so unit tests passed while production reads of every Lark
account failed verification.

Fix:
- change the JSON tag on the envelope from `json:"data"` to `json:"bot"`
- update mocks in identitydiag and cmd/auth/status tests to emit `bot`

Verified locally: `lark-cli doctor` now reports `bot_identity: pass`
for both a normal account and a bot-only profile, restoring the
behavior that #957 set out to deliver.

Change-Id: Ib26dfdd5a0cc37d2d62537ae2bf5e854e67cb83c

* fix(shortcuts/common): decode bot/v3/info from "bot" field, not "data"

Same schema bug as the one fixed in identitydiag — `RuntimeContext.
fetchBotInfo` reads from a non-existent "data" key, so every successful
call would report "open_id is empty" once a caller starts depending on
it.

There are no production callers of `RuntimeContext.BotInfo()` yet
(only tests + the `TestNewRuntimeContextWithBotInfo` helper), so this
bug is dormant — but the pre-existing tests pass with the same wrong
schema in their mocks, so the first real consumer would silently break.

Fix: tag `json:"data"` → `json:"bot"` plus aligning the four mock
fixtures in runner_botinfo_test.go. The Go field name `Data` is kept
to minimize the diff; only the JSON contract is corrected.

Change-Id: I11e1e871603e5349f8df29b1d58e35d07b628dfd
2026-05-19 15:50:40 +08:00
fangshuyu-768
4aa61db8b2 feat(drive): add +inspect shortcut for document URL inspection with wiki unwrapping (#947)
* feat(drive): add +inspect shortcut for document URL inspection with wiki unwrapping

Implements #662: `lark-cli drive +inspect --url <url>` inspects any
Lark/Feishu document URL to get its type, title, and canonical token,
with automatic wiki URL unwrapping via get_node API.

- Add ParseResourceURL (inverse of BuildResourceURL) in common
- Extract FetchDriveMetaTitle as public shared helper
- Add drive +inspect shortcut with wiki unwrapping support
- Add skill reference docs and update SKILL.md
- Dry-run E2E tests for docx URL, wiki URL, and bare token

* refactor: move host validation from ParseResourceURL to +inspect

ParseResourceURL is a general-purpose URL parser that should not
hardcode domain lists — future Lark domains would silently break.
Move isLarkHost/larkHostSuffixes to drive_inspect.go where host
validation is a business decision of the +inspect command.
Add E2E test for non-Lark host with Lark-like path.

* refactor: remove host validation from +inspect

Lark supports custom enterprise domains, so a hardcoded suffix list
can never be exhaustive and would falsely reject valid URLs.
Path-based matching in ParseResourceURL is sufficient; invalid URLs
will fail naturally at the API call stage.
2026-05-19 15:19:35 +08:00
liujinkun2025
28c66be199 fix(wiki): surface real node url for +node-create / +node-copy (#960)
* fix(wiki): surface real node url for +node-create / +node-copy

The create-node and copy-node OpenAPI responses carry a real `url`
field (present in practice though absent from the documented schema).
Both shortcuts ignored it: +node-create synthesized a link via
BuildResourceURL, and +node-copy emitted no URL at all.

Parse `url` into the shared wikiNodeRecord and add a wikiNodeURL helper
that prefers the response url, falling back to BuildResourceURL only
when it is blank. Wire +node-create and +node-copy to the helper so
both surface the canonical link when available.

Change-Id: I0ca5f91b02c24e81d083793e6a8e4f8c966aeec3

* refactor(wiki): move wikiNodeURL to shared wiki_helpers.go

The helper is consumed by both +node-create and +node-copy, so its
placement should reflect the broader usage rather than living in the
create command's file. Pure move; no behavior change.

Change-Id: I9990c12da042f631fe2519911c6a9d663fd5c22b
2026-05-19 15:19:15 +08:00
xzcong0820
0e70b056f8 feat(mail): bot+mailbox=me validation and dynamic --as help tests (#895)
* feat(mail): bot+mailbox=me validation and dynamic --as help tests

Add validateBotMailboxNotMe helper to shortcuts/mail/helpers.go and
wire it as a Validate callback into +message, +messages, +thread and
+triage, so bot identity combined with the default --mailbox me is
rejected early with a clear fixup hint instead of a late opaque API
error.

The --as help text was already dynamic via AddShortcutIdentityFlag;
add TC-10/TC-11 tests in internal/cmdutil/identity_flag_test.go to
pin that behaviour, and TC-1 through TC-9 in
shortcuts/mail/mail_shortcut_validation_test.go to cover the new
Validate callbacks.

+watch is excluded: its AuthTypes is ["user"], so bot is never valid.

sprint: S2

* test(cmdutil): add Hidden and DefValue assertions to identity flag tests

* fix(mail): add bot+mailbox=me validation to +template-create and +template-update

* fix(mail): add bot+mailbox=me validation to +template-update

* fix(mail): gofmt mail_template_create.go

* fix(mail): gofmt mail_template_update.go

* fix(mail): skip bot+mailbox=me check for print-patch-template local path
2026-05-19 15:07:43 +08:00
search_zhuhao
95ffff4212 docs(lark-im): clarify message activity search (#865)
* docs(lark-im): clarify message activity search

Change-Id: I2a9a928aab2354dfaf103cdf53add435088ff9e2

* docs(lark-im): keep bot history guidance additive

Change-Id: I6d89610db9f9d1488f207dcc6b92f7aada839f8b
2026-05-19 14:37:28 +08:00
xzcong0820
e511404065 feat(mail): expose draft priority in --inspect projection and document --set-priority (#779)
Add a Priority field to DraftProjection populated from the EML header pair
X-Cli-Priority (CLI/OAPI primary) → X-Priority (RFC fallback for IMAP-回灌
historical drafts), with case-insensitive lookup via the existing
headerValue helper and a local mapping table aligned with the backend
gopkg/mail_priority.PriorityValueToType vocabulary. When neither header is
present (the symmetric read of --set-priority normal=remove_header) the
projection emits "unknown" so agents have a stable read-side surface.

Append one notes entry to buildDraftEditPatchTemplate documenting the
--set-priority flag and the X-Cli-Priority translation contract.

The write-side (--set-priority flag, parsePriority helper, translation
branch in mail_draft_edit.go, EML header target) is unchanged — already
shipped on master.

sprint: S4
2026-05-19 14:02:01 +08:00
RZERO
b8469d2dc6 fix(auth): split bot and user identity diagnostics (#957) 2026-05-19 13:46:57 +08:00
liangshuo-1
afa084e7a4 chore(lint): exclude bidichk from test files (#959)
Test files legitimately need to construct dangerous Unicode inputs
(RLO, ZWSP, BOM, etc.) to verify validation logic rejects them.
bidichk treats decoded \u escape literals as Trojan Source risks,
which is a false positive for intentional test data.

Change-Id: I555028a992ab008da16129eb41075c333d0099b8
2026-05-19 13:26:39 +08:00
zgz2048
3354494579 fix: address Base attachment review follow-ups (#958) 2026-05-19 13:20:07 +08:00
zgz2048
2bb69d1942 feat: support Base attachment APIs (#887)
* feat: support base attachment APIs

* fix: handle duplicate base attachment downloads

* fix: remove unused attachment token helper
2026-05-19 11:52:47 +08:00
liujinkun2025
c4fb7006d2 feat(wiki): add +node-get / +node-delete / +space-create shortcuts (#904)
- +node-get: wrap wiki.spaces.get_node; accepts node_token, obj_token,
  or a Lark URL (URL path auto-infers obj_type); formatted output with
  creator / updated_at. No synthesized url — get_node returns none and a
  BuildResourceURL fallback is a non-canonical link that misleads in a
  read/confirm command (sibling read shortcuts omit it too)
- +node-delete: wrap space.node delete; high-risk-write (--yes gated),
  async delete-node task polling, auto-resolves space_id via get_node
  when --space-id omitted, actionable hints for codes 131011 / 131003.
  The delete-node task result lives under the gateway's generic
  `simple_task_result` key (NOT `delete_node_result`)
- +space-create: wrap spaces.create; user-only identity, --name
  required (no empty-name spaces), flattened space output, no url
- factor the shared wiki async-task poll loop into wiki_async_task.go;
  preserve upstream Lark Detail.Code on poll exhaustion (no longer
  rebuilt via lossy ErrWithHint)
- drive +task_result: add wiki_delete_node scenario so +node-delete's
  async-timeout next_command actually resolves
- skill docs: reference pages for the 3 new shortcuts + SKILL.md
  shortcuts table (no raw nodes.delete API exists — it's shortcut-only,
  so it is intentionally absent from API Resources / permission table);
  drop the circular TestWikiShortcutsIncludeAllCommands change-detector

Change-Id: I316f78290cec5bc50f80d629173e3bf2a35dd005
2026-05-19 11:21:54 +08:00
afengzi
583349e572 fix(docs): clarify replace_all selection errors (#954) 2026-05-19 10:54:49 +08:00
Yuxuan Zhao
315e0ab50c test: verify e2e resource cleanup (#949)
Change-Id: I3e04a82f622853549f11ac49cbd6fefa194c7c56
2026-05-18 22:35:10 +08:00
liangshuo-1
ef89d1fd40 chore(release): v1.0.33 (#952)
Change-Id: Iea77769a6a0f4e77e8946b72ddb619782be3ea42
2026-05-18 22:25:05 +08:00
JackZhao10086
c8b9809f96 Revert "feat(auth): add QR code support for device auth flow (#942)" (#950)
This reverts commit 7af616b9e5.
2026-05-18 22:12:03 +08:00
wangweiming-01
de00343063 feat: add markdown +patch shortcut (#857)
* feat: add markdown +patch shortcut

Change-Id: I8159941ff9dec4e5cbf0c757ec19ee172b302224

* fix: align markdown patch validation and dry-run

Change-Id: I98079901e980b74998938afc4917b91a79689948
2026-05-18 20:54:11 +08:00
ethan-zhx
67b16c5ec3 feat(slides): improve slide planning and validation guidance (#847)
refactor(slides): rename slide layout lint scope

Change-Id: I1b0e42b6508ec2c5f6ae6dc0d1b7ac23c5bbe2e3

feat(slides): improve lark slides skill guidance

Change-Id: I49563da4ca623a89f5391f36ceb8f5a31417e321

feat(slides): strengthen lark slides planning guidance

Change-Id: If49330e1f9b779bc76a919565ed61a31c255f508

feat(slides): remove lark slides layout lint rules

Change-Id: I64f1fc3b33d05c069c9ef58e61d00aa57ac18ecd

refactor(slides): streamline skill guidance

Change-Id: I3b39faaab7dcac52fac1572590fc5d8934428da5

feat(slides): add slides asset planning guidance

Change-Id: I37303043f7704e4ba484552158390a4e24bf9c42

feat(slides): add visual planning guidance

Change-Id: Idee7c392d41ff02124313d572c547d0a086d9c35

feat(slides): add lark slides planning layer

Change-Id: I3f0765aa53656070d9ba9b388dade19355e7bc6f
2026-05-18 20:44:50 +08:00
JackZhao10086
7af616b9e5 feat(auth): add QR code support for device auth flow (#942)
* feat(auth): add QR code support for device auth flow

* docs: update login QR code display hints for AI agent

* feat(auth): add ASCII QR code support for auth flow

* docs: add comments for login and auth helper functions

* chore: remove unused qrCodeToBase64 helper function

* fix(auth/login): clarify verification_url handling in login hint
2026-05-18 20:17:15 +08:00
fangshuyu-768
df4b657737 feat(drive): add +sync workflow for Drive directories (#873)
Bidirectional sync between a local directory and a Drive folder with
diff detection (new_local, new_remote, modified, unchanged) and
conflict resolution strategies (--on-conflict: remote-wins, local-wins,
keep-both, ask).

Key behaviors:
- Type conflict detection: hard-fail when local file vs remote non-file
  or local directory vs remote file
- Keep-both: rename local with __lark_<hash> suffix, then pull remote;
  occupied map includes localDirs to prevent suffix collision
- Local-wins partial-success: prefer returned file_token on upload failure
- Empty directory mirroring: pre-create local dirs on Drive via
  drivePushWalkLocal before scope preflight
- Structured errors throughout (output.Errorf / output.ErrWithHint)

Includes unit tests and E2E tests (dry-run + live workflow).
2026-05-18 19:56:43 +08:00
史启明(QimingShi)
4b721c0410 fix(sheets): explicitly document safe JSON unmarshal ignore in DryRun (#935)
Two DryRun functions in the sheets shortcuts called json.Unmarshal without
checking the return value. This looks like a bug, but Validate already
parses and validates the same --style / --data JSON before DryRun runs,
so the error is structurally impossible at this point.

Use _ = assignment + comment to silence the unchecked-error lint warning
and make the safety invariant explicit to future readers.

Co-authored-by: KhanCold <KhanCold@users.noreply.github.com>
2026-05-18 17:34:18 +08:00
wangweiming-01
241952459d feat: add drive version shortcut (#841)
Change-Id: I87bb32c86e3c3362f541ccc6320c656eb795ec9b
2026-05-18 16:44:10 +08:00
sang-neo03
33c292c05e feat(extension): Plugin / Hook framework with command pruning (#910)
* feat(extension): introduce Plugin / Hook framework with command pruning

Add a single public extension contract under extension/platform: integrators
implement the Plugin interface and register Observers, Wrappers, Lifecycle
handlers, and pruning Rules through the Registrar in one Install call.

Command pruning:
  - Rule (Allow / Deny / MaxRisk / Identities) with doublestar globs
  - 4-axis AND evaluation, parent-group aggregation, unknown-risk allow
  - Sources: Plugin.Restrict (single-rule) and ~/.lark-cli/policy.yml
  - Plugin path is fail-closed (envelope on rule error / multiple Restrict);
    yaml path is fail-open (warning, CLI continues)
  - strict-mode stubs now also write the denial annotation so the hook
    layer's denial guard physically isolates Wrap chains on them
  - HOME path never leaked through policy_source label

Hook framework:
  - Observer (panic-safe, Before/After), Wrapper (middleware, may short-circuit
    via AbortError), Lifecycle (Startup + Shutdown only)
  - Recover guards every plugin entry point: Capabilities(), Install(),
    Wrapper factory composition AND inner Handler, Lifecycle handlers
  - namespacedWrap copies AbortError so a plugin's package-level sentinel
    is never mutated across concurrent invocations
  - Selector unknown-risk uniform: ByExactRisk / ByWrite / ByReadOnly never
    match unannotated commands; safety-side hooks opt in via
    ByWrite().Or(ByUnknownRisk())

Bootstrap orchestration (cmd/build.go + cmd/policy.go):
  - InstallAll uses a staging Registrar + atomic commit
  - FailClosed plugin install / Plugin.Restrict conflict / Startup handler
    failure each install a structured envelope guard at every dispatch path
  - walkGuard neutralises every cobra bypass we know of (PersistentPreRunE
    first-wins, ValidateArgs, ParseFlags, legacyArgs, __complete /
    __completeNoDesc, non-runnable groups, required-arg subcommands)
  - cmd/root.go::Execute calls hook.Emit(Shutdown, runErr) after
    rootCmd.Execute; isCompletionCommand skips both __complete and
    __completeNoDesc so Tab completion never triggers Shutdown handlers

Capabilities consistency:
  - Restricts=true must declare FailurePolicy=FailClosed
  - RequiredCLIVersion (semver constraint) is validated against build.Version;
    a malformed constraint is treated as untrusted-config and aborts
    unconditionally, regardless of FailurePolicy (DEV builds included)

JSON envelope contract:
  - error.type closed enum: pruning / strict_mode / hook / plugin_install /
    plugin_conflict / plugin_lifecycle
  - reason_code closed enums per type, all referenced by structured tests

Bootstrap surfaces (new user commands):
  - lark-cli config policy show     -- JSON view of the active Rule + source
  - lark-cli config policy validate -- parse + schema + glob check, no apply

Coverage:
  - extension/platform: every public type has a unit test
  - internal/{pruning,hook,platformhost,policydecision,cmdmeta}: full coverage
    of denial guard isolation, AbortError sentinel safety, observer panic
    safety, lifecycle error/panic typing, staging atomic rollback
  - cmd/plugin_integration_test.go: end-to-end through buildInternal with
    synthetic and real command trees
  - cmd/install_guard_test.go: walkGuard covers auth / config / __complete /
    __completeNoDesc / non-runnable parents

* fix(pruning): deny stub must override Args + PersistentPreRunE

The pruning denyStub and the strict-mode stub previously only swapped
RunE plus Hidden + DisableFlagParsing. Cobra's dispatch order means
several pre-RunE gates can fire BEFORE the stub's RunE ever runs:

  1. Args validator: shortcut commands often declare cobra.NoArgs.
     With DisableFlagParsing=true the user's `--doc xxx --mode append`
     looks like positional args, so ValidateArgs surfaces a usage
     error instead of the pruning / strict_mode envelope. Observer
     hooks also miss the dispatch entirely.

  2. Parent PersistentPreRunE: cmd/auth/auth.go declares a
     PersistentPreRunE that returns external_provider when env
     credentials are set. Cobra's "first PersistentPreRunE wins
     walking up from the leaf" then short-circuits with
     external_provider instead of the leaf's denial envelope.

Both stubs now also set:

  - Args               = cobra.ArbitraryArgs   (bypass gate 1)
  - PersistentPreRunE  = no-op leaf hook       (bypass gate 2)
  - PreRunE / PreRun / PersistentPreRun = nil  (defensive)

Effect: dispatch reaches the wrapped RunE, observers fire, the real
pruning / strict_mode envelope is emitted regardless of credential
provider or flag count.

Adds regression tests covering both gates on both stub paths.

* fix(config): policy subcommand bypasses parent's credential check

cmd/config/config.go::NewCmdConfig declares a PersistentPreRunE that
calls f.RequireBuiltinCredentialProvider; with env credentials set,
it returns external_provider for every config subcommand.

`config policy show` and `config policy validate` are READ-ONLY
diagnostic commands -- they inspect or parse the user-layer rule
without touching credentials. They MUST work regardless of which
credential provider is active, otherwise users on env-credential
deployments cannot debug their policy.

Same shape as the codex C11/C13 fix: install a no-op leaf-level
PersistentPreRunE on the `policy` group so cobra's "first walking up
from leaf" rule picks ours over the config parent's.

Regression caught by divergent e2e (F1-F6 all returned external_provider
before this fix; all pass after). Adds a unit test pinning the
PersistentPreRunE override.

* feat(shortcuts): tag service groups with cmdmeta.Domain

RegisterShortcutsWithContext now calls cmdmeta.SetDomain on each
service-level cobra.Command (im, docs, drive, calendar, ...) so the
business-domain axis is actually populated on every shortcut leaf via
parent-chain inheritance.

Before this change, platform.ByDomain("docs") never matched any
command: the domain annotation was unset across the entire shortcut
tree, so the selector's d != "" guard always failed and risk-style
selectors silently degraded to no-op.

The SetDomain call is placed AFTER the create-or-reuse branch so it
fires whether the service command was freshly created here or had
already been added by cmd/service/service.go's OpenAPI auto-
registration (which runs first and creates im, drive, calendar, etc.).
Without this placement only pure-shortcut services like docs would
have been tagged.

Adds a regression test asserting:
  - service-group cobra.Command carries the cmdmeta.domain annotation
  - leaf shortcuts inherit the domain via parent-chain walk

* feat(diagnostic): add unconditionally allowed command paths for introspection

* feat(plugins): add diagnostic command to inspect installed plugins and their contributions

* fix(cli): surface unknown_subcommand error instead of silent help fallback

When a user passed an unknown subcommand or shortcut (e.g. `lark-cli drive
+bogus`), cobra returned `flag.ErrHelp` for the non-runnable group command,
printed the parent help, and exited 0. AI agents couldn't distinguish a
typo from an intentional help request.

Install a tree-wide guard that attaches a RunE to every group command
without its own Run/RunE. The RunE forwards no-args invocations to help
(preserving prior behavior) and emits a structured unknown_subcommand
ExitError (exit 2) listing available subcommands when args are present.

* refactor(envelope): rename error.type pruning/strict_mode to command_denied

The envelope's `type` field was leaking implementation terms ("pruning",
"strict_mode") that describe enforcement mechanism rather than the user-
facing semantic. It also duplicated `detail.layer`, and forced consumers
to branch on two values for the same conceptual error ("a command was
denied by policy").

Collapse both into a single semantic type "command_denied". The
enforcement layer ("pruning" / "strict_mode") is preserved in
`detail.layer` so debugging and per-layer diagnostics still work.

* feat(platform): fail closed on unannotated/invalid risk when a Rule is active

The pruning engine used to treat any command without a risk annotation as
ALLOW even when a Rule with MaxRisk was set, and would silently skip the
MaxRisk comparison whenever the command's risk string was outside the
closed taxonomy. Both gaps let an unannotated or typo'd write command
slip past an "agent read-only" pruning rule.

Engine now denies before any other axis when a Rule is registered:
  - reason_code "risk_not_annotated" for commands with no risk
  - reason_code "risk_invalid"        for commands whose risk is outside
                                      the read | write | high-risk-write
                                      taxonomy (e.g. typo "wrtie")

Main-flow is preserved: a nil Rule still returns Allowed=true
unconditionally, so a CLI with no pruning plugin behaves identically to
before. ByUnknownRisk() is removed from the public surface since the
Unknown state is no longer reachable through risk-based selectors when
any Rule is active; safety-side widening composition is no longer needed.

* chore(config): hide diagnostic policy/plugins commands from --help

`config policy show`, `config policy validate`, and `config plugins show`
are local-introspection-only commands kept behind the pruning
diagnostic whitelist so operators can always inspect why a command was
denied. They do not need to surface in `--help` for AI agents and were
contributing to help noise.

Hide the `policy` and `plugins` parent groups and both `show` /
`validate` leaves. Commands remain callable by exact name and continue
to bypass user-layer pruning via diagnosticPaths.

* style: gofmt

* fix(platform): nil Selector honours None contract; reject multi-doc policy yaml

- selector.go: And/Or/Not now treat nil Selector as None() per godoc,
  preventing runtime panic when composed selectors are invoked.
- schema.go: Parse rejects multi-document YAML input so a stray '---'
  separator can't silently drop trailing policy constraints.

* chore: go mod tidy

* feat(extension/platform): plugin SDK with policy engine, hooks, and Builder

Introduces extension/platform — the in-process plugin SDK external
Go forks of lark-cli use to extend or restrict the command surface.
Plugins compile in via blank import; there is no dynamic loading
and no RPC isolation.

Public SDK (extension/platform):

  - Plugin interface (Name / Version / Capabilities / Install).
  - Registrar verbs: Observe, Wrap, On, Restrict.
  - Hook types: Observer (side-effect, panic-safe, fires Before/After
    RunE), Wrapper (middleware, may short-circuit via AbortError),
    LifecycleHandler (Startup / Shutdown), Selector with nil-safe
    And/Or/Not composition.
  - Risk / Identity are defined string types with closed taxonomies;
    ParseRisk / ParseIdentity convert raw strings with the
    absent-vs-invalid distinction the engine relies on.
  - Builder ergonomic constructor (NewPlugin().Observer().Wrap()
    ...MustBuild()) that enforces name/hookName grammar, hookName
    uniqueness, and the Restrict ↔ FailClosed pairing regardless of
    call order.
  - Invocation is a read-only interface; the framework's concrete
    invocation type lives in internal/hook so plugins cannot
    fabricate denial / strict-mode / identity state. Args() returns
    a defensive copy on every call so hook mutation cannot leak
    into the original RunE.
  - CommandDeniedError + AbortError carry structured fields for the
    closed `command_denied` / `hook` envelope contract.
  - ResetForTesting gated behind //go:build testing.
  - README + godoc examples (Observer / Wrapper / Restrict) + two
    runnable example forks (audit-observer, readonly-policy).

Host (internal/platform, internal/hook, internal/cmdpolicy):

  - InstallAll: staged plugin registration with atomic commit, panic
    isolation, FailOpen / FailClosed semantics, RequiredCLIVersion
    semver check, single-Restrict invariant, duplicate-plugin-name
    detection.
  - hook.Install wraps every runnable cmd.RunE with:
    Before observers (panic-safe) → denial guard → composed Wrap
    chain → original RunE → After observers (always fire, even on
    err). Denied commands physically bypass the Wrap chain so a
    plugin Wrapper cannot suppress or rewrite a denial; observers
    still see the attempt for audit.
  - Recover shim around plugin Wrappers converts panics (including
    the factory call) into a structured `hook` envelope with
    reason_code=panic; namespacing shim attributes AbortError to
    the namespaced hook name.
  - cmdpolicy (renamed from internal/pruning) is the user-layer
    command policy engine: walks the cobra tree, evaluates each
    runnable command against a Rule's four-axis filter (Allow /
    Deny / MaxRisk / Identities), produces parent-group aggregate
    denials, and installs denyStubs. Rule.AllowUnannotated opts out
    of the unannotated-deny gate for gradual adoption; risk_invalid
    typos always deny with an edit-distance "did you mean"
    suggestion.
  - Strict-mode stub in cmd/prune.go composes the shared
    detail.* / wrapped CommandDeniedError shape via cmdpolicy
    helpers (BuildDenialError / CommandDeniedFromDenial /
    DenialDetailMap), so command_denied envelopes from strict-mode
    and user-layer policy carry the same closed-enum fields
    (detail.layer / reason_code / policy_source). The historical
    short Message + independent Hint are preserved unchanged.
  - cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml
    with KnownFields strict mode, including allow_unannotated.
  - `config policy show` / `config policy validate` and the plugin
    inventory diagnostic surface the resolved Rule (allow,
    deny, max_risk, identities, allow_unannotated) and the hook
    contributions per plugin.

Envelope contract (docs/extension/reason-codes.md):

  - error.type is a closed set: command_denied, hook, plugin_install,
    plugin_conflict, plugin_lifecycle.
  - reason_code is a closed enum per error.type, dispatched on by
    external agents and CI integrations.
  - detail.layer = "policy" | "strict_mode" attributes the rejection.

Build / CI:

  - Makefile unit-test / vet / coverage and ci.yml fast-gate +
    unit-test + coverage now pass -tags testing so register_testing.go
    is visible; ./extension/... is in the package list so the SDK's
    own tests actually run.
  - fmt-check and examples-build Makefile targets.
  - bmatcuk/doublestar/v4 added as a direct dependency for `**` glob
    matching in Rule.Allow / Rule.Deny.

Author-facing material:

  - docs/extension/ (quickstart, plugin-author-guide, reason-codes)
    is provided in the working tree but kept out of git tracking
    per repo convention (.gitignore covers docs/).

Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703

* feat(extension/platform): plugin SDK with policy engine, hooks, and Builder

Introduces extension/platform — the in-process plugin SDK external
Go forks of lark-cli use to extend or restrict the command surface.
Plugins compile in via blank import; there is no dynamic loading
and no RPC isolation.

Public SDK (extension/platform):

  - Plugin interface (Name / Version / Capabilities / Install).
  - Registrar verbs: Observe, Wrap, On, Restrict.
  - Hook types: Observer (side-effect, panic-safe, fires Before/After
    RunE), Wrapper (middleware, may short-circuit via AbortError),
    LifecycleHandler (Startup / Shutdown), Selector with nil-safe
    And/Or/Not composition.
  - Risk / Identity are defined string types with closed taxonomies;
    ParseRisk / ParseIdentity convert raw strings with the
    absent-vs-invalid distinction the engine relies on.
  - Builder ergonomic constructor (NewPlugin().Observer().Wrap()
    ...MustBuild()) that enforces name/hookName grammar, hookName
    uniqueness, and the Restrict ↔ FailClosed pairing regardless of
    call order.
  - Invocation is a read-only interface; the framework's concrete
    invocation type lives in internal/hook so plugins cannot
    fabricate denial / strict-mode / identity state. Args() returns
    a defensive copy on every call so hook mutation cannot leak
    into the original RunE.
  - CommandDeniedError + AbortError carry structured fields for the
    closed `command_denied` / `hook` envelope contract.
  - ResetForTesting gated behind //go:build testing.
  - README + godoc examples (Observer / Wrapper / Restrict) + two
    runnable example forks (audit-observer, readonly-policy).

Host (internal/platform, internal/hook, internal/cmdpolicy):

  - InstallAll: staged plugin registration with atomic commit, panic
    isolation, FailOpen / FailClosed semantics, RequiredCLIVersion
    semver check, single-Restrict invariant, duplicate-plugin-name
    detection.
  - hook.Install wraps every runnable cmd.RunE with:
    Before observers (panic-safe) → denial guard → composed Wrap
    chain → original RunE → After observers (always fire, even on
    err). Denied commands physically bypass the Wrap chain so a
    plugin Wrapper cannot suppress or rewrite a denial; observers
    still see the attempt for audit.
  - Recover shim around plugin Wrappers converts panics (including
    the factory call) into a structured `hook` envelope with
    reason_code=panic; namespacing shim attributes AbortError to
    the namespaced hook name.
  - cmdpolicy (renamed from internal/pruning) is the user-layer
    command policy engine: walks the cobra tree, evaluates each
    runnable command against a Rule's four-axis filter (Allow /
    Deny / MaxRisk / Identities), produces parent-group aggregate
    denials, and installs denyStubs. Rule.AllowUnannotated opts out
    of the unannotated-deny gate for gradual adoption; risk_invalid
    typos always deny with an edit-distance "did you mean"
    suggestion.
  - Strict-mode stub in cmd/prune.go composes the shared
    detail.* / wrapped CommandDeniedError shape via cmdpolicy
    helpers (BuildDenialError / CommandDeniedFromDenial /
    DenialDetailMap), so command_denied envelopes from strict-mode
    and user-layer policy carry the same closed-enum fields
    (detail.layer / reason_code / policy_source). The historical
    short Message + independent Hint are preserved unchanged.
  - cmdpolicy/yaml: structural parsing of ~/.lark-cli/policy.yml
    with KnownFields strict mode, including allow_unannotated.
  - `config policy show` / `config policy validate` and the plugin
    inventory diagnostic surface the resolved Rule (allow,
    deny, max_risk, identities, allow_unannotated) and the hook
    contributions per plugin.

Envelope contract (docs/extension/reason-codes.md):

  - error.type is a closed set: command_denied, hook, plugin_install,
    plugin_conflict, plugin_lifecycle.
  - reason_code is a closed enum per error.type, dispatched on by
    external agents and CI integrations.
  - detail.layer = "policy" | "strict_mode" attributes the rejection.

Build / CI:

  - Makefile unit-test / vet / coverage and ci.yml fast-gate +
    unit-test + coverage now pass -tags testing so register_testing.go
    is visible; ./extension/... is in the package list so the SDK's
    own tests actually run.
  - fmt-check and examples-build Makefile targets.
  - bmatcuk/doublestar/v4 added as a direct dependency for `**` glob
    matching in Rule.Allow / Rule.Deny.

Author-facing material:

  - docs/extension/ (quickstart, plugin-author-guide, reason-codes)
    is provided in the working tree but kept out of git tracking
    per repo convention (.gitignore covers docs/).

Change-Id: I3b8ecc2923bd54c2dff19e5dce8a0855a6f9e703

* refactor(policy): remove validate command and update diagnostics

* fix(extension/platform): address PR review must-fix items

- cmdpolicy: skip AnnotationPureGroup commands in EvaluateAll,
  aggregateParents, and hasRunnableDescendant so user-layer policy
  no longer blocks `<group> --help` after the unknown-subcommand
  guard attaches RunE to every parent
- cmd/root: tag guarded parent groups with AnnotationPureGroup
- extension/platform: drop `//go:build testing` from register_testing.go
  so `go test ./...` works without an extra build tag
- extension/platform/README: inline reason_code reference, fix plugin
  lifecycle diagram order (init/Register precede RegisteredPlugins)
- cmd/platform_bootstrap: route userPolicyPath through
  core.GetBaseConfigDir so LARKSUITE_CLI_CONFIG_DIR is honoured
- cmdpolicy: add RedactHomeDir helper, fold base config dir and
  $HOME prefixes for config policy show + resolver errors
- internal/platform: reject unrecognised FailurePolicy values with
  invalid_capability instead of silently fail-open
- cmd/config: surface diagnostic policy/plugins commands in
  `config --help` Long text
- CHANGELOG: document command_denied error.type rename and
  unknown_subcommand exit-2 behavior change

* fix(extension/platform): address CodeRabbit review comments + CI gofmt

- hook/install: propagate wrapper-injected ctx to invokeOriginal so
  RunE/Run see context values added by upstream Wrappers
- hook/testing: SetStderrForTesting returns a restore func; tests now
  defer it via t.Cleanup to avoid cross-test sink leakage
- cmdpolicy/active: deep-copy ActivePolicy.Rule on SetActive/GetActive
  so callers can't mutate the stored global through shared slices
- platform/inventory: deep-copy Inventory + nested Plugins / HookEntry
  / RuleView slices on SetActiveInventory / GetActiveInventory
- platform/staging: Restrict clones the plugin-supplied Rule before
  retaining it so the plugin can't mutate it after Install returns
- platform/version: reject RequiredCLIVersion with more than three
  numeric components instead of silently truncating 1.2.3.4 to 1.2.3
- cmd/platform_bootstrap: clear cmdpolicy.SetActive on yaml resolver
  error so config policy show doesn't surface a stale rule
- cmd/platform_bootstrap_test: tmpHome pins LARKSUITE_CLI_CONFIG_DIR
  so host env can't bleed into the policy test fixtures
- cmdpolicy/apply: installDenyStub returns bool; Apply count no longer
  over-reports when strict-mode short-circuits the install
- cmdpolicy/engine: aggregateParents now returns the runnable hybrid's
  own denial status when all children are placeholder branches
- cmdpolicy/resolver_test: use t.TempDir()-rooted missing path instead
  of hardcoded /nonexistent for hermetic missing-file assertion
- cmd/config/plugins: empty-inventory branch emits total: 0 so the
  JSON schema stays stable across populated/empty cases
- cmd/platform_guards_test: select leaf by RunE != nil (not Runnable)
  so the test doesn't nil-deref on Run-only commands
- gofmt run on previously committed cmdpolicy/path*.go (CI fast-gate)

* fix(cmdpolicy): replace filepath.Abs with filepath.Clean for lint policy

The depguard / forbidigo rule blocks filepath.Abs in internal/ on the
grounds that it accesses the filesystem (Getwd) directly. Switch
RedactHomeDir + foldPrefix to operate on filepath.Clean strings; real
callers pass already-absolute paths (resolver builds yamlPath via
filepath.Join on the absolute config root), so the redaction outcome
is unchanged for production inputs. Relative inputs fall through to
the unchanged branch — filepath.Rel rejects the mixed-absoluteness
case with an error, which the foldPrefix helper already treats as
"not a hit".

* refactor(cmdpolicy): pure Resolve + drop path redaction & verbose comments

- Resolve becomes a pure function; I/O moves to LoadYAMLPolicy so
  precedence selection can be unit-tested without vfs mocks
- ActivePolicy drops YAMLPath; config policy show JSON loses yaml_path
  and yaml_shadowed (and the TOCTOU stat that surfaced them)
- RedactHomeDir and path_test.go removed: the home-dir folding was only
  earning its keep through the now-deleted yaml_path field
- cmd/build.go bootstrap block trimmed from 71 to 39 lines by cutting
  PR-rationale comments; one note kept for the fail-CLOSED-vs-fail-OPEN
  business rule
- cmd/config/config.go: parent Long no longer hard-codes hidden command
  hints, matching their Hidden:true intent

Change-Id: Icfbb818ce3ef523c63286bfbed34c49be08ed6a2

* refactor(platform): drop StrictMode/Identity from Invocation interface

These two accessors were documented in the public SDK as "After observers
always see ok=true" but the framework never plumbed values to them, so they
always returned ("", false). Zero internal/example/test callers; a plugin
author trusting the doc would silently get wrong behaviour.

Identity is also fundamentally unsuited for Before observers (per-command
identity resolves inside RunE via f.AuthFor, after Before fires). StrictMode
is a global value better placed on a Framework/Environment interface than
per-Invocation. Removing is non-breaking now (no callers); adding later is
non-breaking too.

Change-Id: Ice200543e9bca3bda759ad98a6e34a56df69e915

* fix(prune): preserve original metadata on strict-mode denial stubs

strictModeStubFrom built a fresh *cobra.Command from scratch, dropping
the original command's annotations (risk_level, lark:supportedIdentities,
cmdmeta.domain) and help text. cobraCommandView is a live proxy walking
parent annotations, so after the Remove+Add replacement, audit observers
firing on a strict-mode-denied command saw Cmd().Risk()=("",false) and
Cmd().Identities()=nil -- breaking the first-class use case for
audit/compliance plugins.

Copy child.Annotations into the stub (stamping the denial annotations on
top) and propagate Short/Long for help-text parity with
cmdpolicy/apply.go::installDenyStub, which preserves these by virtue of
mutating in place.

Regression test asserts risk_level / supportedIdentities / Short / Long
all survive replacement, alongside the denial annotations.

Change-Id: I19810a34575996344b63e839066888c154d69335

* chore(platform): align docs with implementation; fold home in yaml warnings

Followup cleanup to the previous three refactor commits, addressing review
fallout where public docs / examples / contract notes still pointed at
deleted symbols or unimplemented designs:

- cmd/build.go: Build() docstring now mentions the plugin install + Startup
  emit side effects; Shutdown only fires on Execute path
- extension/platform/doc.go, lifecycle.go, invocation.go: drop references
  to the deleted StrictMode/Identity methods, restore minimal Godoc on
  Cmd/Args/Started
- extension/platform/view.go, cmd/platform_bootstrap.go,
  internal/hook/install.go: rewrite "snapshot before pruning" promise to
  match the actual contract (live view + strict-mode stub metadata
  preservation)
- cmd/platform_guards_test.go: stubInvocation drops the two old methods
- cmd/platform_bootstrap.go: redactHome() last-mile folds $HOME -> ~ in
  warnPolicyError so an os.PathError carrying the absolute policy path
  does not leak the user's home dir to stderr / agent / CI logs
- examples/readonly-policy/README.md: drop yaml_path from the sample
  `config policy show` envelope (the field was removed in 52cbb92)

Change-Id: I2874cc2cf9225dfa44a9c07b2449149181b387cb

* chore(build): drop vestigial -tags testing from Makefile and CI

The `testing` build tag was introduced in 461e3c6 to gate
extension/platform/register_testing.go (ResetForTesting); PR review
0efee93 then dropped the //go:build testing directive from that file
so downstream `go test ./...` would work without the tag, but never
cleaned the matching tag references out of Makefile and ci.yml.

The result: 8 places passing -tags testing for a tag that nothing in
the repo actually gates, plus a Makefile comment that confidently
claims a gate exists. Net behaviour is identical to omitting the flag;
the only effect is misleading developers into believing there is a
test-only surface separation.

Drop the flag from vet / unit-test / lint / coverage / deadcode (head
+ base worktree) and remove the misleading comment. ResetForTesting's
public-API exposure was the conscious trade-off taken in 0efee93 and
is left untouched.

Change-Id: If0cd78c87d4aec2a2533419fe75b01aae6b165fd

* feat(cmdpolicy): enrich denial Reason with attempted value + rule constraint

The envelope reason for command_denied previously told the caller WHAT
axis failed but not the concrete values on each side, so an AI agent
reading the envelope could not tell which command identity / risk /
path was attempted vs. which the rule permits. The natural temptation
was then to recommend modifying the rule -- exactly the wrong nudge,
since policy exists to prevent the agent from rewriting its own limits.

Each Reason now carries both the attempted value and the rule's
constraint:

  identity_mismatch:
    "command supports identities [user]; rule allows [bot]"
  domain_not_allowed:
    "command path \"drive/+upload\" not in allow list [docs/** contact/**]"
  command_denylisted:
    "command path \"docs/+delete-doc\" matched deny pattern \"docs/+delete-*\""
  risk_too_high / write_not_allowed:
    "command risk \"high-risk-write\" exceeds rule max_risk \"write\""
  risk_not_annotated:
    "command has no risk_level annotation; rule denies unannotated commands"
    (drops the prescriptive "set allow_unannotated=true" hint -- that
     belongs in docs, not in the engine's denial path)

Adds firstMatch() helper so command_denylisted can name the specific
glob that fired; matchesAny() now wraps firstMatch.

Regression test pins the substring contract per reason_code so future
"comment cleanup" cannot silently strip the values out again.

Change-Id: I17c7cc9411f58e3e43ade5e1ce875f3b7fe3e5ea

* fix(cmdpolicy): gofmt engine_test.go

CI fast-gate flagged the test added in 2eb0c2b as unformatted. Local
make unit-test had it cached; should have run `make vet` (which runs
gofmt-equivalent check via fmt-check) before pushing. Trivial 3-line
indent fix.

Change-Id: I42297ae59f607b97b32e976c9ec1c9ec4ab7de21

* feat(cmd): annotate risk_level on all hand-written cobra commands

Without this, any non-empty user-layer policy.yml (default
allow_unannotated=false) denies these commands with reason_code
risk_not_annotated -- bricking auth login, config init, profile use
etc. on first contact with a policy.

cmdpolicy/engine evaluation now resolves to the intended axis (deny
list / allow list / max_risk / identities) instead of failing closed
on the unannotated gate. Policy authors can write `max_risk: write`
or `allow: [auth/** config/** ...]` to express real intent.

Classification:
  read              auth status/check/list/scopes, config show /
                    policy show / plugins show, doctor, completion,
                    schema, profile list, event list/status/schema/
                    consume
  write             auth login/logout, config init/bind/remove/
                    default-as/strict-mode, profile add/remove/
                    rename/use, event stop/_bus, api (raw transit)
  high-risk-write   update (replaces the CLI binary; failure can
                    leave the install broken)

Notes:
- api standalone is conservatively `write`; per-call risk is unknown
  at parse time (raw transit), so static gating only enforces the
  write-class minimum.
- event _bus is the hidden IPC daemon forked by consume; standalone
  invocation by users is not expected, but the annotation keeps
  policy evaluation consistent with the other event subcommands.
- The two diagnostic-allowlisted commands (config policy show /
  plugins show) still bypass the engine via diagnosticPaths; the
  read annotation is for consistency with surrounding leaves.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-05-18 15:25:02 +08:00
zgz2048
ca6c6c3e29 fix: mark base field update high risk (#936) 2026-05-18 13:34:31 +08:00
吕盈辉律师
7bad9f2656 fix: guide agents to yield during auth device flow (#933) 2026-05-18 11:48:09 +08:00
liujinkun2025
898e0eebfd docs(lark-wiki): correct the --as default-identity claim (#919)
The skill doc claimed wiki list/copy shortcuts default to --as user, but
the CLI --as default is `auto` (no --as commonly resolves to bot, listing
the app's spaces instead of the user's). Running `wiki +space-list`
without --as therefore returns app-scoped data, contradicting the doc.

Following the established lark-mail convention (concise user-centric
guidance, not a precedence essay):
- add a short "优先使用 user 身份" section to SKILL.md
- fix the --as rows in lark-wiki-space-list / node-list / node-copy
  references to show the real `auto` default and steer to --as user

Change-Id: I539f8d622c1bbad57f8a64c2fc7b7ecc0dfe2116
2026-05-15 22:21:32 +08:00
Yuxuan Zhao
0b7215637f test: drop stale e2e yes flags (#920) 2026-05-15 22:10:56 +08:00
liangshuo-1
14a3213038 chore(release): v1.0.32 (#918)
Change-Id: I3d1a8ec4faf1ce585fb9eae45287bf02586e3e90
2026-05-15 20:55:43 +08:00
mazhe-nerd
caff780c17 feat(config): lark-channel secret supports SecretInput protocol (#912) 2026-05-15 20:53:59 +08:00
fangshuyu-768
5778adfefa fix(drive): preserve parent token on nested overwrite (#908)
* fix(drive): preserve parent token on nested overwrite

Ensure drive +push overwrite requests for nested files keep parent_node aligned with the actual remote parent folder and report parent resolution failures explicitly.

* test(drive): cover nested overwrite push workflow

Add a live drive +push workflow case for overwriting a nested remote file so the PR parent-token fix is exercised against the real backend and verified to converge via +status.
2026-05-15 18:32:58 +08:00
songyoung77
7400226e34 feat(doc): add --width/--height flags to docs +media-insert (#832)
* feat(doc): add width/height params to buildBatchUpdateData

Extend buildBatchUpdateData signature with width and height int params.
When mediaType is "image" and either dimension is positive, the value is
included in the replace_image payload. Existing call sites pass 0, 0.

* feat(doc): add --width/--height flags with validation to docs +media-insert

* feat(doc): add aspect-ratio auto-calculation helpers

Add computeMissingDimension (pure ratio math) and detectImageDimensions
(header-only image.DecodeConfig) with PNG/JPEG/GIF blank-import decoders,
plus imageDimensions struct; drive with two new TDD tests.

* feat(doc): wire --width/--height into Execute with aspect-ratio calculation

* feat(doc): add best-effort dimension computation to DryRun

* docs: add --width/--height to docs +media-insert SKILL.md

* fix: add SafeInputPath validation to detectImageDimensionsFromPath

* fix: guard computeMissingDimension against division by zero and add rounding

* fix: add dimension upper bound, fix err variable reuse in Execute

* refactor: use early-return guard for zero native dimensions per review

* fix: add pixels unit to dimension validation error messages

* fix: surface dimension detection failures in dry-run to match Execute behavior

* fix: move dimension detection before upload to fail fast

* fix: restore withRollbackWarning on dimension detection errors in Execute

Dimension detection runs after the placeholder block is created (Step 2),
so failures must clean up the block to avoid leaving an empty placeholder
in the document.
2026-05-15 18:28:56 +08:00
SunPeiYang996
4a45e00139 docs: add svg whiteboard support to doc v2 skill (#901)
Change-Id: Icada6fb894aaf9a00187fa68c132d3ade8223b99
2026-05-15 16:18:49 +08:00
河伯
f03138b9f0 feat(wiki): add +space-list / +node-list / +node-copy shortcuts (#392)
Introduce three new wiki shortcuts that wrap the corresponding raw APIs
with structured flags, formatted output, my_library alias handling, and
unified envelope shape, replacing the bare `lark-cli wiki spaces list`
/ `wiki nodes list` / `wiki nodes copy` flows for the common cases.

Shortcuts
- wiki +space-list (read, scopes: wiki:space:retrieve):
  lists wiki spaces. Default fetches a single page; --page-all walks
  every page capped by --page-limit (default 10, 0 = unlimited).
  Supports --page-size / --page-token / --format json|pretty|table|csv|ndjson.
  Output: {spaces, has_more, page_token} + Meta.Count. Pretty mode
  distinguishes "no spaces" from "empty page with has_more" and hints
  the caller to resume.

- wiki +node-list (read, scopes: wiki:node:retrieve):
  lists nodes in a space or under a parent. Same pagination + format
  story as +space-list. Accepts the my_library alias for --space-id
  with --as user (resolved via a shared resolveMyLibrarySpaceID helper
  extracted from +node-create); rejects my_library upfront for --as bot.

- wiki +node-copy (high-risk-write, scopes: wiki:node:copy):
  copies a node into a target space or parent. --target-space-id and
  --target-parent-node-token are mutually exclusive. Risk is marked
  high-risk-write to match the upstream API's danger: true flag, so the
  framework requires --yes. Source is preserved; subtree is copied.

Both list shortcuts pick the narrowest scope the upstream API accepts.
The framework's preflight (internal/auth/scope.go MissingScopes) does
exact-string scope matching, so declaring the broader wiki:wiki:readonly
form would wrongly reject tokens that carry only the per-API scope —
which the API itself accepts — and emit a misleading missing-scope hint.

Shared changes
- shortcuts/wiki/wiki_node_create.go: factor out resolveMyLibrarySpaceID
  so +node-list and +node-create share one my_library resolution path.
- shortcuts/wiki/shortcuts.go: register the three new shortcuts.
- skills/lark-wiki/SKILL.md and references/lark-wiki-{space,node-list,
  node-copy}.md: documentation for the new shortcuts.

Tooling
- scripts/check-doc-tokens.sh + Makefile gitleaks target:
  pre-commit check that scans skill reference docs for realistic-looking
  Lark token values without the _EXAMPLE_TOKEN placeholder convention,
  preventing gitleaks false positives.
- .gitleaks.toml: allowlist tuning.
- .gitignore: ignore .tmp/.

Tests
- shortcuts/wiki/wiki_list_copy_test.go: unit tests covering registry
  membership, declared-narrow-scope pinning, flag validation (page-size
  range, page-limit >= 0, target flag exclusivity, my_library + bot
  rejection), auto-pagination merging, --page-limit truncation
  surfacing next cursor, --page-token single-page mode, empty-slice
  serialisation, has_more hint pretty rendering, my_library user-path
  resolution, +node-copy copy-to-space / copy-to-parent + body shape,
  pretty rendering, and the high-risk-write --yes gate.
- tests/cli_e2e/wiki/wiki_shortcut_workflow_test.go: live end-to-end
  workflow exercising the shortcut layer against a real tenant.
  Reuses an existing my_library node as a host so the test never adds
  to the top-layer quota; the copy is placed under the same host node.
- tests/cli_e2e/wiki/coverage.md: shortcut coverage entries added.

Minor cleanups
- skills/lark-doc/references/lark-doc-search.md and
  skills/lark-minutes/references/lark-minutes-search.md: replace
  realistic-looking example ou_ tokens with _EXAMPLE_ placeholders so
  scripts/check-doc-tokens.sh passes.

Change-Id: I9efb0557f477d369d7f26a09c1e154d4ab15b253

Co-authored-by: liujinkun <liujinkun@bytedance.com>
2026-05-15 14:38:18 +08:00
Cato
ed9eecf94f fix(selfupdate): use LookPath instead of Executable for binary verification (fixes #836) (#886)
* fix(selfupdate): use LookPath instead of Executable for binary verification (fixes #836)

VerifyBinary was using vfs.Executable() to find the binary to run --version against.
On Linux with global npm install, this returns the inode of the running binary (old version),
not the newly installed one that sits behind npm's bin symlink.

Switch to exec.LookPath("lark-cli") which resolves the PATH entry and follows npm's
bin symlink to the correct newly installed version, matching what the user actually runs.

* test(selfupdate): add LookPath-based tests for VerifyBinary

Add TestVerifyBinaryLookPath, TestVerifyBinaryLookPathNotFound, and
TestVerifyBinaryEmptyOutput. Expose execLookPath variable so tests can
inject a mock LookPath and cover the full VerifyBinary execution path
including version parsing and error branches.

* test(selfupdate): add os/exec import and isolate config dir in VerifyBinary tests

CodeRabbit feedback:
- Add missing os/exec import for execLookPath variable
- Add t.Setenv(LARKSUITE_CLI_CONFIG_DIR, ...) to each new test for config isolation

* test(selfupdate): extract execLookPath to separate lookpath.go

Move the execLookPath variable declaration to its own file so it is
accessible to updater.go without the test-only import cycle.

* fix(selfupdate): remove unused os/exec import from test file

* fix(selfupdate): gofmt + fold lookpath hook and restore version fences

- Move execLookPath into updater.go (drops redundant lookpath.go)
- Document package-level mock: no t.Parallel()
- Extend TestVerifyBinaryLookPath with exact-match regressions (0.0, 12.1.0 vs 2.1.0)

Co-authored-by: CatfishGG <catfishgg@users.noreply.github.com>
2026-05-14 23:30:30 +08:00
liangshuo-1
f49a2f7e14 fix(registry): wait for background meta refresh before test reset (#894)
* fix(registry): wait for background meta refresh before test reset

TestComputeMinimumScopeSet can start doBackgroundRefresh via Init() while
the next test's resetInit() mutates package-level globals the goroutine
still reads (e.g. remoteMetaURL / configuredBrand), causing data races under
-race in the coverage job.

Track the refresh goroutine with a WaitGroup and drain it at the start of
resetInit() in tests.
2026-05-14 22:33:21 +08:00
caojie0621
a93fb2d6b3 docs: add drive permission public patch error guidance (#863) 2026-05-14 21:57:55 +08:00
SunPeiYang996
7acf64c3ef docs: add v2 api version to docs fetch examples (#891)
Change-Id: I130e6e02c0b7594a05bdda6c9bf552fb15572791
2026-05-14 20:50:55 +08:00
fangshuyu-768
52e0129078 feat(drive): add quick mode to status diff (#870) 2026-05-14 20:37:39 +08:00
liangshuo-1
8a8dff47ce chore(release): v1.0.31 (#889)
Change-Id: I1609f900c4b5dc219e1e58aecb642928d418c5b3
2026-05-14 20:19:31 +08:00
SunPeiYang996
1c2d3d7679 docs: update lark-doc skill description (#890)
Change-Id: I77e2ae690b8976e37f69ae5d581fccc13917ec5e
2026-05-14 20:17:48 +08:00
wangweiming-01
0d20f88453 feat: support file-token overwrite and version output for drive +upload (#885)
Change-Id: I76c334578fc2fa5cfd2eedb4525b0d9d735f610e
2026-05-14 19:50:51 +08:00
MaxHuang22
b0bd9b0258 feat(install): skip interactive prompts in non-TTY environments (#888)
* feat(install): skip interactive prompts in non-TTY environments

Change-Id: Ieb6ffef54d3118088f16728933c55d1b21a8abfb

* docs: simplify install instructions to use npx install wizard

Change-Id: Ic970d2c879fd649c2dbd6ddf9a259bc64eb1a384
2026-05-14 19:40:14 +08:00
MaxHuang22
ba6edb84e4 feat: recommend lark-cli update over npm install for AI agents (#884)
* docs: rewrite lark-shared update section to recommend lark-cli update

Change-Id: Ie043b1a32675dcd041f9123503fcccb791cccd07

* feat: add command field to _notice JSON for AI agents

Change-Id: I04b069880f7dca8db384ba8a6919e5682c0382be

* feat: demote npm install to fallback with skills-not-synced warning

Change-Id: If21c3ef6cd1818b28f5578078a04c3627128c6d0

* fix: address CodeRabbit review — guard type assertions, remove npm fallback from SKILL.md

- Add t.Fatalf guards before type-asserting notice sub-maps in
  TestSetupNotices_BothUpdateAndSkills to prevent nil-panic on
  unexpected shapes.
- Remove the npm fallback section from SKILL.md entirely so AI agents
  only see `lark-cli update` as the update path.
- Strip remaining npm mentions from the "重要" note.

Change-Id: Ieb124763b918093e1dcae06f5ea7428dbc248d5f

* fix: add npx skills add hint alongside npm fallback in update paths

When npm is shown as a fallback (manual update path and rollback hint),
append the npx skills add command so users know how to sync skills
separately.

Change-Id: I454172be51073d35def635613a23ad35ba68b5fb
2026-05-14 19:09:10 +08:00
shifengjuan-dev
a54a879330 feat(im): add --exclude-muted to +chat-search and new +chat-list (#820)
Add im +chat-list shortcut wrapping GET /open-apis/im/v1/chats (previously not exposed via lark-cli).
Add --exclude-muted to both +chat-search and +chat-list: client-side filter that calls POST /open-apis/im/v1/chat_user_setting/batch_get_mute_status after each page and drops is_muted=true chats.
Introduce shortcuts/im/mute_filter.go with pure helpers and an orchestrator (MaybeApplyMuteFilter) shared by both shortcuts.

Change-Id: I22221ac5835667f58cbd40b34de75825d2445d1c
2026-05-14 17:47:34 +08:00
Paulazaaza-dev
a27c636131 add addsign and rollback method (#867)
Change-Id: I0a50796cf33fd59e4222f26003efd43aa7c5896a
2026-05-14 15:13:30 +08:00
JackZhao10086
37459b60ec feat(auth): support --exclude flag and combine --scope with --domain/… (#844)
* fix(auth/login): 增加exclude参数使用校验逻辑

当使用--exclude参数时,必须同时指定--scope、--domain或--recommend中的至少一个,避免非法参数调用

* feat(auth/login): add --exclude flag and support combining scope options

1. 新增--exclude命令行标志用于排除指定的授权范围
2. 移除--scope与--domain/--recommend的互斥限制,改为叠加使用
3. 重构范围合并与排除逻辑,增加校验和辅助工具函数
4. 更新--scope参数的帮助文档说明叠加行为

* fix(auth/login): 修复登录命令scope参数描述重复的问题

移除了重复的参数说明文本,整理冗余的注释内容,让帮助文档更清晰易读

* fix(auth/login): 修复exclude参数校验逻辑

添加--exclude参数必须配合其他可选参数使用的校验,避免无效的exclude参数调用

---------

Co-authored-by: cqc-a11y <chengqingchun@bytedance.com>
2026-05-14 14:12:29 +08:00
fangshuyu-768
f1aa7d8f42 feat(drive): add modified-time smart sync mode (#859) 2026-05-14 14:10:35 +08:00
liangshuo-1
a18504b1f9 chore(release): v1.0.30 (#871)
Change-Id: Iaa769f2ddc98ece7bf36efe821d4eb192f7fc727
2026-05-13 20:11:06 +08:00
shifengjuan-dev
5e0ac02f08 feat(im): add --chat-mode topic to +chat-create (#790)
Adds --chat-mode group|topic to lark-cli im +chat-create so users and AI agents can create 话题群 (topic chats) directly via the CLI. Without this, requests to create a topic chat silently fall back to a normal conversation group. Default remains group; chat_mode is now always emitted in the POST /open-apis/im/v1/chats request body.

Change-Id: I79385e2e8606f84e3f27de240d1b41037bf51261
2026-05-13 18:03:58 +08:00
aj
b0c9a4d74e fix(auth): support comma-separated --scope in auth login (#764)
`lark-cli auth login --scope "a,b"` previously sent the raw comma-joined
string to the device authorization endpoint, which treats it as a single
malformed scope and fails with:

  device authorization failed: The provided scope list contains invalid
  or malformed scopes.

OAuth 2.0 (RFC 6749 §3.3) requires space-delimited scopes on the wire,
but commas are the more natural separator for users typing on a shell
(quoting whitespace is awkward, especially for AI-agent generated
commands). Accept both: split on commas/whitespace, trim, dedupe, then
re-join with single spaces.

Also adds unit tests covering single, comma, space, mixed, dedupe, and
trailing-separator inputs.

Co-authored-by: aj <2072584+meijing0114@users.noreply.github.com>
2026-05-13 14:27:55 +08:00
481 changed files with 51452 additions and 2662 deletions

View File

@@ -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

5
.gitignore vendored
View File

@@ -34,8 +34,13 @@ 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
lark-env.sh

View File

@@ -14,3 +14,4 @@ id = "lark-session-token"
description = "Detect Lark session tokens"
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
keywords = ["XN0YXJ0-", "-WVuZA"]

View File

@@ -45,6 +45,7 @@ linters:
- path: _test\.go$
linters:
- bodyclose
- bidichk
- gocritic
- depguard
- forbidigo

View File

@@ -2,6 +2,190 @@
All notable changes to this project will be documented in this file.
## [v1.0.40] - 2026-05-25
### Features
- **wiki**: Add exponential backoff retry for `+node-create` lock contention (#1012)
- **auth**: Add `auth qrcode` subcommand and update auth docs/hints (#968)
### Bug Fixes
- **wiki**: Rename `+node-get --token` to `--node-token`, keep alias (#1074)
- **output**: Classify wiki lock-contention error (131009) with retry hint (#1014)
- **contact**: Add actionable hint when fanout search all-fail with no API code (#1054)
- **permission**: Annotate auto-grant permission failures with `required_scope` and `console_url` (#1045)
- **validation**: Use `ErrValidation` instead of `fmt.Errorf` in `Validate` paths (#1001)
### Documentation
- **skills**: Add 云盘/云存储 alias alongside 云空间 for agent clarity (#1073)
- **task**: Refresh `lark-task` shortcut docs (#1057)
## [v1.0.39] - 2026-05-22
### Features
- **slides**: Add `+export` shortcut to export slides (#988)
- **sidecar**: Support multi-client identity isolation in `server-demo` via per-client HMAC keys, preventing UAT cross-contamination when multiple CLI sandboxes share one sidecar (#934)
- **im**: Support Markdown image rendering in post content (#893)
### Bug Fixes
- **scope**: Add 22 new scope entries to scope priorities (#1050)
### Documentation
- **base**: Update location `full_address` guidance (#754)
- **apps**: Refine `lark-apps` skill description and surface, document `index.html` / `--path` hard constraints (#1040)
## [v1.0.38] - 2026-05-22
### Features
- **apps**: Gate the Miaoda apps domain off on the Lark brand — the `apps` shortcut subtree returns a structured brand-restriction error, `auth login --domain apps` is rejected, `--domain all` skips it, and `spark:*` scopes are no longer requested (#1025)
## [v1.0.37] - 2026-05-21
### Features
- **apps**: Add miaoda apps domain with 6 shortcuts covering `+create` / `+update` / `+list` / `+access-scope-get` / `+access-scope-set` / `+html-publish` (#1002)
### Bug Fixes
- **permission**: Surface auto-grant skipped/failed cases via stderr warnings and a `hint` field in the `permission_grant` JSON output (#1015)
- **sheets**: Use `FileIO` for `+write-image` input so stdin / `-` works consistently (#996)
## [v1.0.36] - 2026-05-21
### Features
- **drive/markdown**: Return real tenant URLs for `drive +upload` and `markdown +create` (#992)
### Bug Fixes
- **auth**: Return validation error when `--scope` is empty in `auth check` (#999)
### Documentation
- **lark-drive**: Improve search evidence guidance (#864)
## [v1.0.35] - 2026-05-20
### Features
- **markdown**: Support wiki node target in `+create` (#883)
- **markdown**: Add `+diff` shortcut (#876)
- **base**: Add form `+detail` / `+submit` shortcuts (#759)
- **skills**: Add incremental skills sync (#965)
- **doc**: Warn before overwrite when document contains whiteboard or file blocks (#825)
### Documentation
- **im**: Clarify media key formats for message media flags (#991)
- **im**: Add media-preview reference (#990)
- **drive**: Migrate `docs +search` to `drive +search` and fix `creator_ids` owner semantic (#951)
- **drive**: Prefer local comments for drive reviews (#981)
- **wiki**: Add wiki base fast path (#982)
## [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
- **im**: Add `--chat-mode topic` to `+chat-create` (#790)
### Bug Fixes
- **auth**: Support comma-separated `--scope` in `auth login` (#764)
- **auth**: Clarify URL handling in auth messages and docs (#856)
- **bind**: Accept `~/` paths in OpenClaw secret references (#839)
### Tests
- **update**: Isolate stamp writes from real `~/.lark-cli/skills.stamp` (#858)
## [v1.0.29] - 2026-05-12
### Features
@@ -676,6 +860,17 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
[v1.0.37]: https://github.com/larksuite/cli/releases/tag/v1.0.37
[v1.0.36]: https://github.com/larksuite/cli/releases/tag/v1.0.36
[v1.0.35]: https://github.com/larksuite/cli/releases/tag/v1.0.35
[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
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27

View File

@@ -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

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 26 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
- **Wide Coverage** — 18 business domains, 200+ curated commands, 26 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -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 |
@@ -41,6 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
## Installation & Quick Start
@@ -62,11 +63,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 +99,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 +133,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 |

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 26 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 26 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 18 大业务域、200+ 精选命令、26 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -28,7 +28,7 @@
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📝 Markdown | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
@@ -41,6 +41,7 @@
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
## 安装与快速开始
@@ -62,11 +63,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 +99,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 +134,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` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |

View File

@@ -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
}

View File

@@ -43,6 +43,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
return cmd
}

View File

@@ -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 (or QR code) 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,

View File

@@ -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
}
@@ -46,8 +47,7 @@ func authCheckRun(opts *CheckOptions) error {
required := strings.Fields(opts.Scope)
if len(required) == 0 {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
return nil
return output.ErrValidation("--scope cannot be empty")
}
config, err := f.Config()

View File

@@ -34,6 +34,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts)
},
}
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -30,6 +30,7 @@ type LoginOptions struct {
Scope string
Recommend bool
Domains []string
Exclude []string
NoWait bool
DeviceCode string
}
@@ -46,10 +47,13 @@ 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 or agent tool only delivers final turn messages, use --no-wait --json,
send the verification URL (or QR code) to the user as your final message, end the turn, then
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
to generate QR codes (supports ASCII and PNG formats).`,
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 +65,21 @@ 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-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()
var helpBrand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
}
}
available := sortedKnownDomains(helpBrand)
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")
@@ -133,14 +146,14 @@ func authLoginRun(opts *LoginOptions) error {
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = sortedKnownDomains()
selectedDomains = sortedKnownDomains(config.Brand)
break
}
}
// Validate domain names and suggest corrections for unknown ones
if len(selectedDomains) > 0 {
knownDomains := allKnownDomains()
knownDomains := allKnownDomains(config.Brand)
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
@@ -158,9 +171,13 @@ 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)
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
if err != nil {
return err
}
@@ -180,25 +197,28 @@ 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")
}
}
finalScope := opts.Scope
// Normalize --scope so users can pass either OAuth-standard space-separated
// values or the more natural comma-separated list. RFC 6749 §3.3 mandates
// space-delimited scopes in the wire request, so the device authorization
// 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")
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
} else {
// --recommend without --domain: all domains
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
}
// Filter to auto-approve scopes if --recommend or interactive "common"
@@ -206,11 +226,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
@@ -232,7 +276,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("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL so the user can either scan or copy the link.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. Prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the QR code image (or 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)
@@ -415,6 +459,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return nil
}
// syncLoginUserToProfile persists the logged-in user info into the named profile.
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
@@ -440,6 +485,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
return nil
}
// findProfileByName returns the AppConfig matching profileName, or nil.
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
for i := range multi.Apps {
if multi.Apps[i].ProfileName() == profileName {
@@ -453,7 +499,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
// shortcut scopes for the given domain names.
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func collectScopesForDomains(domains []string, identity string) []string {
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
scopeSet := make(map[string]bool)
// 1. API scopes from from_meta projects
@@ -472,8 +518,11 @@ 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 !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.ScopesForIdentity(identity) {
for _, s := range sc.DeclaredScopesForIdentity(identity) {
scopeSet[s] = true
}
}
@@ -491,7 +540,7 @@ func collectScopesForDomains(domains []string, identity string) []string {
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
func allKnownDomains() map[string]bool {
func allKnownDomains(brand core.LarkBrand) map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
if !registry.HasAuthDomain(p) {
@@ -499,6 +548,9 @@ func allKnownDomains() map[string]bool {
}
}
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if !registry.HasAuthDomain(sc.Service) {
domains[sc.Service] = true
}
@@ -507,8 +559,8 @@ func allKnownDomains() map[string]bool {
}
// sortedKnownDomains returns all valid domain names sorted alphabetically.
func sortedKnownDomains() []string {
m := allKnownDomains()
func sortedKnownDomains(brand core.LarkBrand) []string {
m := allKnownDomains(brand)
domains := make([]string, 0, len(m))
for d := range m {
domains = append(domains, d)
@@ -532,6 +584,40 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
return false
}
// normalizeScopeInput accepts a user-supplied --scope value that may use
// commas, spaces, tabs, or newlines (or any mix) as separators and returns the
// canonical OAuth 2.0 wire form: a single space-joined string with empties
// trimmed and duplicates removed (first occurrence wins; order preserved).
//
// Examples:
//
// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read"
// "a, b , c" -> "a b c"
// "a b a" -> "a b"
// "" -> ""
func normalizeScopeInput(raw string) string {
if raw == "" {
return ""
}
// Treat both commas and any whitespace as separators.
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
if len(fields) == 0 {
return ""
}
seen := make(map[string]struct{}, len(fields))
out := make([]string, 0, len(fields))
for _, f := range fields {
if _, ok := seen[f]; ok {
continue
}
seen[f] = struct{}{}
out = append(out, f)
}
return strings.Join(out, " ")
}
// suggestDomain finds the best "did you mean" match for an unknown domain.
func suggestDomain(input string, known map[string]bool) string {
// Check common cases: prefix match or input is a substring
@@ -542,3 +628,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
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
feishuDomains := allKnownDomains(core.BrandFeishu)
if !feishuDomains["apps"] {
t.Errorf("expected apps domain to be known on Feishu brand")
}
larkDomains := allKnownDomains(core.BrandLark)
if larkDomains["apps"] {
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
}
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
if len(feishuScopes) == 0 {
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
}
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
if len(larkScopes) != 0 {
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -105,7 +106,7 @@ func buildDomainMeta(name, lang string) domainMeta {
}
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) {
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
allDomains := getDomainMetadata(lang)
// Build multi-select options
@@ -165,7 +166,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
}
// Compute scope summary
scopes := collectScopesForDomains(selectedDomains, "user")
scopes := collectScopesForDomains(selectedDomains, "user", brand)
if permLevel == "common" {
scopes = registry.FilterAutoApproveScopes(scopes)
}

View File

@@ -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 或 agent 工具只会把最终回复发给用户,请改用 \"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导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL再将二维码图片置于 URL 下方完整展示,确保用户可扫码或复制链接。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string不要做任何修改(包括 URL 编码/解码、添加空格或标点),建议用包含该 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 or agent tool 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.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL so the user can either scan or copy the link.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. 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)",
@@ -114,6 +114,7 @@ var loginMsgEn = &loginMsg{
HintFooter: " lark-cli auth login --help",
}
// getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
return loginMsgEn
@@ -125,5 +126,5 @@ func getLoginMsg(lang string) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown"}
return []string{"base", "contact", "docs", "markdown", "apps"}
}

View File

@@ -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)
}

View File

@@ -70,6 +70,32 @@ func TestSuggestDomain_ExactMatch(t *testing.T) {
}
}
func TestNormalizeScopeInput(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"single", "vc:note:read", "vc:note:read"},
{"comma", "vc:note:read,vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"space", "vc:note:read vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"comma_and_spaces", "vc:note:read, vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
{"mixed_separators", "a, b\tc\nd e", "a b c d e"},
{"trim_and_dedup", " a , b , a ", "a b"},
{"trailing_separators", "a,b,,", "a b"},
{"only_separators", " , , ", ""},
{"tab_separated", "im:message:send\toffline_access", "im:message:send offline_access"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeScopeInput(tc.in); got != tc.want {
t.Errorf("normalizeScopeInput(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
// Empty AuthTypes defaults to ["user"]
sc := common.Shortcut{AuthTypes: nil}
@@ -145,7 +171,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) {
}
func TestAllKnownDomains(t *testing.T) {
domains := allKnownDomains()
domains := allKnownDomains("")
if len(domains) == 0 {
t.Fatal("expected non-empty known domains")
}
@@ -159,7 +185,7 @@ func TestAllKnownDomains(t *testing.T) {
}
func TestSortedKnownDomains(t *testing.T) {
sorted := sortedKnownDomains()
sorted := sortedKnownDomains("")
if len(sorted) == 0 {
t.Fatal("expected non-empty sorted domains")
}
@@ -169,7 +195,7 @@ func TestSortedKnownDomains(t *testing.T) {
}
// Should match allKnownDomains
known := allKnownDomains()
known := allKnownDomains("")
if len(sorted) != len(known) {
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
}
@@ -194,7 +220,7 @@ func TestCollectScopesForDomains(t *testing.T) {
t.Skip("no from_meta data available")
}
scopes := collectScopesForDomains([]string{"calendar"}, "user")
scopes := collectScopesForDomains([]string{"calendar"}, "user", "")
if len(scopes) == 0 {
t.Fatal("expected non-empty scopes for calendar domain")
}
@@ -221,7 +247,7 @@ func TestCollectScopesForDomains(t *testing.T) {
}
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "")
if len(scopes) != 0 {
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
}
@@ -289,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)
}
}
}
@@ -917,17 +945,38 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
}
hint, _ := data["hint"].(string)
for _, want := range []string{
"exactly as returned by the CLI",
"MUST generate QR code AND display it",
"lark-cli auth qrcode",
"Prefer PNG QR code (--output)",
"use ASCII (--ascii) only when the user explicitly requests it",
"This is a required step, do NOT skip it",
"CRITICAL",
"You MUST include the QR image in your response",
"Generating the file alone is NOT enough",
"image tags, inline images, or file attachments",
"Display order",
"place the QR code image below the URL",
"opaque string",
"Do not URL-encode or decode it",
"do not add %20, spaces, or punctuation",
"do not wrap it as Markdown link text",
"fenced code block containing only the raw URL",
"cannot be modified",
"Prefer a fenced code block",
"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) {
@@ -1009,12 +1058,22 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
hint, _ := data["agent_hint"].(string)
for _, want := range []string{
"timeout >= 600s",
"逐字原样转发 CLI 返回的 URL",
"本轮最终消息",
"结束本轮",
"用户回复已完成授权",
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
"必须生成二维码并展示",
"lark-cli auth qrcode",
"优先生成 PNG 二维码(--output",
"仅当用户明确要求时才使用 ASCII--ascii",
"生成后必须在回复中展示图片",
"仅生成文件不算完成",
"image 标签或内联图片",
"二维码图片置于 URL 下方完整展示",
"URL 输出规则",
"opaque string",
"不要做 URL 编码或解码",
"不要补 `%20`、空格或标点",
"不要改写成 Markdown 链接",
"只包含该 URL 的代码块单独输出",
"不要做任何修改",
"仅包含该 URL 的代码块",
} {
if !strings.Contains(hint, want) {
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
@@ -1032,7 +1091,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
}
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
domains := allKnownDomains()
domains := allKnownDomains("")
if domains["whiteboard"] {
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
}
@@ -1042,7 +1101,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
}
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
scopes := collectScopesForDomains([]string{"docs"}, "user")
scopes := collectScopesForDomains([]string{"docs"}, "user", "")
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
found := false
for _, s := range scopes {

View File

@@ -33,6 +33,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts)
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}

142
cmd/auth/qrcode.go Normal file
View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"github.com/skip2/go-qrcode"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// QRCodeOptions holds inputs for auth qrcode command.
type QRCodeOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
URL string
Size int
ASCII bool
Output string
}
// NewCmdAuthQRCode creates the auth qrcode subcommand.
func NewCmdAuthQRCode(f *cmdutil.Factory, runF func(*QRCodeOptions) error) *cobra.Command {
opts := &QRCodeOptions{Factory: f, Size: 256}
cmd := &cobra.Command{
Use: "qrcode <url>",
Short: "Generate QR code for verification URL",
Long: `Generate a QR code image or ASCII representation for a verification URL.
This command is designed for AI agents to generate QR codes for OAuth authorization URLs.
For PNG output, the --output flag is required to specify the output file path (must be a relative path within the current directory).
For ASCII output, the result is printed to stdout with fixed size.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.URL = args[0]
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
return runQRCode(opts)
},
}
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
return cmd
}
// runQRCode executes the auth qrcode command.
func runQRCode(opts *QRCodeOptions) error {
if opts.URL == "" {
return output.Errorf(output.ExitValidation, "missing_url", "url is required")
}
if opts.ASCII {
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
return generateASCIIQRCode(opts.URL, out)
}
if opts.Output == "" {
return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.")
}
if opts.Size < 32 {
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
}
if opts.Size > 1024 {
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size))
}
safePath, err := validate.SafeOutputPath(opts.Output)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
return err
}
result := map[string]interface{}{
"ok": true,
"file_path": safePath,
"hint": "You MUST include the QR image in your response. Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.",
}
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
encoder := json.NewEncoder(out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(result); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err)
}
return nil
}
// generateImageQRCode encodes the URL as a PNG QR code and writes it to outputPath.
func generateImageQRCode(url string, size int, outputPath string) error {
png, err := qrcode.Encode(url, qrcode.Medium, size)
if err != nil {
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
}
err = vfs.WriteFile(outputPath, png, 0644)
if err != nil {
return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err))
}
return nil
}
// generateASCIIQRCode encodes the URL as an ASCII QR code and prints it to stdout.
func generateASCIIQRCode(url string, w io.Writer) error {
q, err := qrcode.New(url, qrcode.Medium)
if err != nil {
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
}
fmt.Fprint(w, q.ToSmallString(false))
return nil
}

368
cmd/auth/qrcode_test.go Normal file
View File

@@ -0,0 +1,368 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png", "--size", "128"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.URL != "https://example.com" {
t.Errorf("URL = %q, want %q", gotOpts.URL, "https://example.com")
}
if gotOpts.Size != 128 {
t.Errorf("Size = %d, want %d", gotOpts.Size, 128)
}
if gotOpts.Output != "qr.png" {
t.Errorf("Output = %q, want %q", gotOpts.Output, "qr.png")
}
if gotOpts.ASCII {
t.Error("ASCII should be false by default")
}
}
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gotOpts.ASCII {
t.Error("ASCII should be true when --ascii is passed")
}
}
func TestNewCmdAuthQRCode_DefaultSize(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Size != 256 {
t.Errorf("default Size = %d, want 256", gotOpts.Size)
}
}
func TestNewCmdAuthQRCode_ExactOneArg(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when no URL argument provided")
}
}
func TestNewCmdAuthQRCode_RunE_PNGEndToEnd(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
var result map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", err, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
hint, _ := result["hint"].(string)
if hint == "" {
t.Error("hint is empty")
}
if !strings.Contains(hint, "MUST include") {
t.Errorf("hint missing 'MUST include', got: %s", hint)
}
if !strings.Contains(hint, "NOT enough") {
t.Errorf("hint missing 'NOT enough', got: %s", hint)
}
}
func TestNewCmdAuthQRCode_RunE_MissingOutput(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"https://example.com"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when --output is missing in PNG mode")
}
}
func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, 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{
"qrcode <url>",
"QR code",
"--output",
"--ascii",
"relative path",
} {
if !strings.Contains(got, want) {
t.Errorf("help missing %q", want)
}
}
}
func TestRunQRCode_MissingURL(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: ""})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_url" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url")
}
}
func TestRunQRCode_MissingOutput(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_output" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output")
}
}
func TestRunQRCode_InvalidSize(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 16,
Output: "qr.png",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
}
}
func TestRunQRCode_SizeTooLarge(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 2048,
Output: "qr.png",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
}
}
func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "/etc/passwd",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
}
func TestRunQRCode_PNGWritesFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "qr.png",
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
info, err := os.Stat("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if info.Size() == 0 {
t.Error("output file is empty")
}
var result map[string]interface{}
if jsonErr := json.Unmarshal(stdout.Bytes(), &result); jsonErr != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", jsonErr, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
}
func TestRunQRCode_ASCIIOutputsToStdout(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
ASCII: true,
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stdout.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateImageQRCode_Success(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "test-qr.png")
if err := generateImageQRCode("https://example.com", 256, outputPath); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("failed to read output file: %v", err)
}
if len(data) == 0 {
t.Error("output file is empty")
}
if len(data) < 8 {
t.Error("output too small to be a valid PNG")
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
}
func TestGenerateImageQRCode_WriteError(t *testing.T) {
err := generateImageQRCode("https://example.com", 256, "/nonexistent/deep/nested/dir/qr.png")
if err == nil {
t.Fatal("expected error writing to nonexistent directory")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitInternal {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
}
if exitErr.Detail.Type != "write_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error")
}
}
func TestGenerateASCIIQRCode_Success(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("https://example.com", &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if buf.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("", &buf)
if err == nil {
t.Fatal("expected error for empty string")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail.Type != "encode_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error")
}
}

View File

@@ -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
}

View File

@@ -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
View 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"`
}

View File

@@ -19,7 +19,9 @@ import (
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 +61,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{}
@@ -124,10 +136,42 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
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
}

View File

@@ -37,5 +37,6 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}

View File

@@ -52,5 +52,6 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
return nil
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -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
View 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
View 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
View 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)
}
}

View File

@@ -32,6 +32,7 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro
return configRemoveRun(opts)
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -34,6 +34,7 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
return configShowRun(opts)
},
}
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)")}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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(&current, "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
}

View File

@@ -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
}

View File

@@ -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
View 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
}

View 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
View 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
View 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 "" }

View 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
}

View File

@@ -45,6 +45,7 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
_ = cmd.MarkFlagRequired("name")
_ = cmd.MarkFlagRequired("app-id")
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -34,6 +34,7 @@ func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
return profileListRun(f)
},
}
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -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
}

View File

@@ -24,6 +24,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
return profileRenameRun(f, args[0], args[1])
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -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
}

View File

@@ -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,
}
},
}
}

View File

@@ -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")
}
}

View File

@@ -10,14 +10,18 @@ import (
"errors"
"fmt"
"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 +92,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 +104,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 +155,7 @@ func setupNotices() {
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
}
if stale := skillscheck.GetPending(); stale != nil {
@@ -147,6 +163,7 @@ func setupNotices() {
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if len(notice) == 0 {
@@ -157,11 +174,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 +284,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)
@@ -301,8 +388,8 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
return
}
// Extract required scopes from API error detail
scopes := extractRequiredScopes(exitErr.Detail.Detail)
// Extract required scopes from API error detail (shared helper)
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
if len(scopes) == 0 {
return
}
@@ -313,21 +400,10 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
}
// Select the recommended (least-privilege) scope
scopeIfaces := make([]interface{}, len(scopes))
for i, s := range scopes {
scopeIfaces[i] = s
}
recommended := registry.SelectRecommendedScope(scopeIfaces, "tenant")
if recommended == "" {
recommended = scopes[0]
}
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
// Build admin console URL with the recommended scope
host := "open.feishu.cn"
if cfg.Brand == "lark" {
host = "open.larksuite.com"
}
consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", host, url.QueryEscape(cfg.AppID), url.QueryEscape(recommended))
consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended)
// Clear raw API detail — useful info is now in message/hint/console_url
exitErr.Detail.Detail = nil
@@ -364,26 +440,3 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
exitErr.Detail.ConsoleURL = consoleURL
}
}
// extractRequiredScopes extracts scope names from the API error's permission_violations field.
func extractRequiredScopes(detail interface{}) []string {
m, ok := detail.(map[string]interface{})
if !ok {
return nil
}
violations, ok := m["permission_violations"].([]interface{})
if !ok {
return nil
}
var scopes []string
for _, v := range violations {
vm, ok := v.(map[string]interface{})
if !ok {
continue
}
if subject, ok := vm["subject"].(string); ok {
scopes = append(scopes, subject)
}
}
return scopes
}

View File

@@ -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

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View 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)
}
}
}

View File

@@ -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

View File

@@ -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) {

View 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.

View 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 }

View 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")
}
}

View 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
}

View 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)
}
}

View 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
View 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

View 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)
}

View 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")
}
}

View 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
}

View File

@@ -0,0 +1,2 @@
audit-observer/audit-observer
readonly-policy/readonly-policy

View 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.

View 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.

View 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())
}

View 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.

View 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())
}

View 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

View 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) }

View 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
}

View 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
}

View 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
}

View 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 = &registry{}
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
}

View 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")
}
}

View 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() }

View 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)
}

View 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) }

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform_test
import (
"testing"
"github.com/larksuite/cli/extension/platform"
)
func TestRisk_Rank_orderedTaxonomy(t *testing.T) {
cases := []struct {
level platform.Risk
want int
}{
{platform.RiskRead, 0},
{platform.RiskWrite, 1},
{platform.RiskHighRiskWrite, 2},
}
for _, c := range cases {
got, ok := c.level.Rank()
if !ok || got != c.want {
t.Errorf("Risk(%q).Rank() = (%d,%v), want (%d,true)", c.level, got, ok, c.want)
}
}
if _, ok := platform.Risk("unknown-level").Rank(); ok {
t.Fatalf("unknown-level.Rank() ok should be false")
}
if _, ok := platform.Risk("").Rank(); ok {
t.Fatalf("empty.Rank() ok should be false (signals 'no risk annotation')")
}
}
// The Risk ordering must be strict: read < write < high-risk-write. The
// policy engine compares ranks; a regression that swaps the order would
// silently let high-risk commands pass under MaxRisk=write.
func TestRisk_Rank_strictlyMonotonic(t *testing.T) {
r1, _ := platform.RiskRead.Rank()
r2, _ := platform.RiskWrite.Rank()
r3, _ := platform.RiskHighRiskWrite.Rank()
if !(r1 < r2 && r2 < r3) {
t.Fatalf("Risk ranks not monotonic: read=%d write=%d high=%d", r1, r2, r3)
}
}
func TestRisk_IsValid(t *testing.T) {
valid := []platform.Risk{platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite}
for _, r := range valid {
if !r.IsValid() {
t.Errorf("%q.IsValid() = false, want true", r)
}
}
invalid := []platform.Risk{"", "wrtie", "Read", "READ", " read "}
for _, r := range invalid {
if r.IsValid() {
t.Errorf("%q.IsValid() = true, want false", r)
}
}
}
// ParseRisk distinguishes absent (empty input) from invalid (typo).
// The absent / invalid split mirrors the cmdpolicy engine's
// risk_not_annotated vs risk_invalid reason codes.
func TestParseRisk(t *testing.T) {
// Empty -> ("", nil) — "not specified"
got, err := platform.ParseRisk("")
if err != nil || got != "" {
t.Errorf(`ParseRisk("") = (%q,%v), want ("",nil)`, got, err)
}
// Valid values pass through
for _, want := range []platform.Risk{platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite} {
got, err := platform.ParseRisk(string(want))
if err != nil || got != want {
t.Errorf("ParseRisk(%q) = (%q,%v), want (%q,nil)", want, got, err, want)
}
}
// Typo -> error, strict matching (case-sensitive, no trim)
bad := []string{"wrtie", "Read", "READ", " read ", "high_risk_write"}
for _, s := range bad {
got, err := platform.ParseRisk(s)
if err == nil {
t.Errorf("ParseRisk(%q) succeeded (got %q), want error", s, got)
}
if got != "" {
t.Errorf("ParseRisk(%q) returned %q, want empty Risk on error", s, got)
}
}
}
func TestParseIdentity(t *testing.T) {
got, err := platform.ParseIdentity("")
if err != nil || got != "" {
t.Errorf(`ParseIdentity("") = (%q,%v), want ("",nil)`, got, err)
}
for _, want := range []platform.Identity{platform.IdentityUser, platform.IdentityBot} {
got, err := platform.ParseIdentity(string(want))
if err != nil || got != want {
t.Errorf("ParseIdentity(%q) = (%q,%v)", want, got, err)
}
}
if _, err := platform.ParseIdentity("admin"); err == nil {
t.Fatalf(`ParseIdentity("admin") want error`)
}
}
func TestIdentity_IsValid(t *testing.T) {
if !platform.IdentityUser.IsValid() {
t.Error("user.IsValid() = false")
}
if !platform.IdentityBot.IsValid() {
t.Error("bot.IsValid() = false")
}
if platform.Identity("admin").IsValid() {
t.Error("admin.IsValid() = true")
}
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
// Rule is the declarative policy rule data structure. yaml files and
// Plugin.Restrict() both produce the same Rule.
//
// At any moment there is at most one effective Rule -- the resolver decides
// which source wins (Plugin > yaml > none). This package only defines the
// shape; selection lives in internal/cmdpolicy.
//
// The four filter fields are joined by AND. See the engine's Evaluate for
// the full semantics. JSON tags are used by `config policy show`; yaml
// parsing lives in internal/cmdpolicy/yaml so the public API does not
// depend on a yaml library.
type Rule struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
// Allow is a list of doublestar globs (slash-separated paths). An empty
// slice means "no path restriction"; a non-empty slice means "command
// path must match at least one glob".
Allow []string `json:"allow,omitempty"`
// Deny is a list of doublestar globs. A path that matches any Deny glob
// is rejected regardless of Allow.
Deny []string `json:"deny,omitempty"`
// MaxRisk is the highest allowed risk level (inclusive). Empty string
// means "no risk restriction". Comparison uses the closed taxonomy
// read < write < high-risk-write.
MaxRisk Risk `json:"max_risk,omitempty"`
// Identities is the allowed identity whitelist. A command passes when
// the intersection with the command's own supported identities is
// non-empty. Empty slice means "no identity restriction".
Identities []Identity `json:"identities,omitempty"`
// AllowUnannotated controls how commands missing a risk_level
// annotation are handled when this Rule is active.
//
// Default (false, fail-closed): unannotated commands are rejected
// with reason_code=risk_not_annotated. This is the safe default
// — a typo'd or forgotten annotation cannot slip past an
// "agent read-only" rule.
//
// Set to true to opt out during gradual adoption: lark-cli main
// has hundreds of service commands that may not yet carry
// risk_level annotations, and a brand-new policy plugin would
// otherwise lock the binary to nothing.
//
// This flag does NOT affect risk_invalid (typos): a command that
// claims a risk but mis-spells it is always denied, regardless of
// AllowUnannotated. Typo is a code bug, not a migration phase.
//
// No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so
// platform stays free of a yaml library dependency.
AllowUnannotated bool `json:"allow_unannotated,omitempty"`
}

View File

@@ -0,0 +1,133 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
import "github.com/bmatcuk/doublestar/v4"
// Selector picks the commands a hook fires on. A nil Selector is
// equivalent to None() -- safer than an "always-match" default because
// it forces every hook to declare its scope explicitly. Compose
// selectors with And / Or / Not.
type Selector func(cmd CommandView) bool
// All matches every command. Use for audit / metrics observers that
// must run on the whole surface.
func All() Selector { return func(CommandView) bool { return true } }
// None matches no command. Useful as a "disabled" placeholder.
func None() Selector { return func(CommandView) bool { return false } }
// ByDomain matches a command whose Domain() is one of the supplied
// names. Commands with unknown (empty-string) Domain never match this
// selector -- the caller should pair it with a Selector that handles
// unknown explicitly when that case matters.
func ByDomain(domains ...string) Selector {
wanted := newStringSet(domains)
return func(cmd CommandView) bool {
d := cmd.Domain()
return d != "" && wanted[d]
}
}
// ByCommandPath matches against the canonical slash-form path. Patterns
// are doublestar globs ("docs/+update", "im/*", "**"). Invalid patterns
// never match; ValidateRule's twin check catches them at the source.
func ByCommandPath(patterns ...string) Selector {
return func(cmd CommandView) bool {
path := cmd.Path()
for _, p := range patterns {
if ok, err := doublestar.Match(p, path); err == nil && ok {
return true
}
}
return false
}
}
// ByIdentity matches when the command's supported identities include
// the supplied id. Unknown identities never match.
func ByIdentity(id Identity) Selector {
return func(cmd CommandView) bool {
for _, x := range cmd.Identities() {
if x == id {
return true
}
}
return false
}
}
// Risk-based selectors below match only commands whose declared risk
// equals the selector's target level. The closed taxonomy is read /
// write / high-risk-write — there is no "unknown" branch in the public
// API. When a Rule without AllowUnannotated=true is registered, the
// policy engine treats unannotated commands as implicit deny, so risk-
// based selectors never see them in hook dispatch under that
// configuration.
// ByExactRisk matches commands whose declared risk level is exactly level.
func ByExactRisk(level Risk) Selector {
return func(cmd CommandView) bool {
v, ok := cmd.Risk()
return ok && v == level
}
}
// ByWrite matches commands whose risk is "write" or "high-risk-write".
func ByWrite() Selector {
return func(cmd CommandView) bool {
v, ok := cmd.Risk()
return ok && (v == RiskWrite || v == RiskHighRiskWrite)
}
}
// ByReadOnly matches commands whose risk is "read".
func ByReadOnly() Selector {
return func(cmd CommandView) bool {
v, ok := cmd.Risk()
return ok && v == RiskRead
}
}
// normalize maps a nil Selector to None() so combinators honour the
// "nil == None()" contract documented on the Selector type.
func normalize(s Selector) Selector {
if s == nil {
return None()
}
return s
}
// And composes selectors with AND semantics.
func (s Selector) And(other Selector) Selector {
left, right := normalize(s), normalize(other)
return func(cmd CommandView) bool {
return left(cmd) && right(cmd)
}
}
// Or composes selectors with OR semantics.
func (s Selector) Or(other Selector) Selector {
left, right := normalize(s), normalize(other)
return func(cmd CommandView) bool {
return left(cmd) || right(cmd)
}
}
// Not negates the selector. A nil receiver is treated as None(), so
// nil.Not() behaves as All().
func (s Selector) Not() Selector {
inner := normalize(s)
return func(cmd CommandView) bool {
return !inner(cmd)
}
}
func newStringSet(items []string) map[string]bool {
out := make(map[string]bool, len(items))
for _, x := range items {
out[x] = true
}
return out
}

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform_test
import (
"testing"
"github.com/larksuite/cli/extension/platform"
)
// fakeView is a minimal CommandView for unit-testing selectors.
type fakeView struct {
path string
domain string
risk string
riskOK bool
identities []string
}
func (v fakeView) Path() string { return v.path }
func (v fakeView) Domain() string { return v.domain }
func (v fakeView) Risk() (platform.Risk, bool) { return platform.Risk(v.risk), v.riskOK }
func (v fakeView) Identities() []platform.Identity {
out := make([]platform.Identity, len(v.identities))
for i, x := range v.identities {
out[i] = platform.Identity(x)
}
return out
}
func (v fakeView) Annotation(key string) (string, bool) { return "", false }
func TestAll_None(t *testing.T) {
cmd := fakeView{}
if !platform.All()(cmd) {
t.Errorf("All() must match every command")
}
if platform.None()(cmd) {
t.Errorf("None() must match no command")
}
}
func TestByDomain(t *testing.T) {
sel := platform.ByDomain("docs", "im")
if !sel(fakeView{domain: "docs"}) {
t.Errorf("docs should match")
}
if sel(fakeView{domain: "vc"}) {
t.Errorf("vc must not match docs/im selector")
}
// Unknown domain (empty) must not match.
if sel(fakeView{domain: ""}) {
t.Errorf("unknown domain must not match ByDomain (use ByDomainOrUnknown style if desired)")
}
}
// Risk-based selectors match only against the closed taxonomy
// (read / write / high-risk-write). Commands without a risk annotation
// never match; the policy engine guarantees such commands cannot reach
// hook dispatch when a Rule without AllowUnannotated=true is registered.
func TestByExactRisk_unknownDoesNotMatch(t *testing.T) {
sel := platform.ByExactRisk("write")
if !sel(fakeView{risk: "write", riskOK: true}) {
t.Errorf("exact write should match")
}
if sel(fakeView{riskOK: false}) {
t.Errorf("unknown must not match ByExactRisk")
}
if sel(fakeView{risk: "read", riskOK: true}) {
t.Errorf("read must not match ByExactRisk(write)")
}
}
func TestByWrite_byReadOnly(t *testing.T) {
if !platform.ByWrite()(fakeView{risk: "write", riskOK: true}) {
t.Errorf("write should match ByWrite")
}
if !platform.ByWrite()(fakeView{risk: "high-risk-write", riskOK: true}) {
t.Errorf("high-risk-write should match ByWrite")
}
if platform.ByWrite()(fakeView{risk: "read", riskOK: true}) {
t.Errorf("read must not match ByWrite")
}
if platform.ByWrite()(fakeView{riskOK: false}) {
t.Errorf("unknown must not match ByWrite")
}
if !platform.ByReadOnly()(fakeView{risk: "read", riskOK: true}) {
t.Errorf("read should match ByReadOnly")
}
if platform.ByReadOnly()(fakeView{riskOK: false}) {
t.Errorf("unknown must not match ByReadOnly")
}
}
func TestByCommandPath(t *testing.T) {
sel := platform.ByCommandPath("docs/**", "im/+send")
if !sel(fakeView{path: "docs/+update"}) {
t.Errorf("docs/+update should match docs/**")
}
if !sel(fakeView{path: "im/+send"}) {
t.Errorf("im/+send should match")
}
if sel(fakeView{path: "contact/+search"}) {
t.Errorf("contact/+search must not match")
}
}
func TestByIdentity(t *testing.T) {
sel := platform.ByIdentity("bot")
if !sel(fakeView{identities: []string{"user", "bot"}}) {
t.Errorf("ids containing bot should match")
}
if sel(fakeView{identities: []string{"user"}}) {
t.Errorf("user-only ids must not match bot selector")
}
}
func TestSelector_AndOrNot(t *testing.T) {
docsAndWrite := platform.ByDomain("docs").And(platform.ByExactRisk("write"))
if !docsAndWrite(fakeView{domain: "docs", risk: "write", riskOK: true}) {
t.Errorf("AND of matching selectors should match")
}
if docsAndWrite(fakeView{domain: "docs", risk: "read", riskOK: true}) {
t.Errorf("AND fails when one side fails")
}
docsOrIm := platform.ByDomain("docs").Or(platform.ByDomain("im"))
if !docsOrIm(fakeView{domain: "im"}) {
t.Errorf("OR should match either side")
}
notRead := platform.ByReadOnly().Not()
if notRead(fakeView{risk: "read", riskOK: true}) {
t.Errorf("Not(ByReadOnly) must reject read commands")
}
if !notRead(fakeView{risk: "write", riskOK: true}) {
t.Errorf("Not(ByReadOnly) should match write")
}
}
func TestSelector_NilSafeWhenComposed(t *testing.T) {
// A nil Selector is equivalent to None() per the Selector godoc.
// Composition must honour that contract: the resulting selector
// must not panic when invoked and must produce the documented
// boolean outcome (nil-as-None propagates through AND/OR/NOT).
var s platform.Selector
cmd := fakeView{domain: "docs"}
if got := s.And(platform.All())(cmd); got {
t.Errorf("nil.And(All) should match None semantics (false), got true")
}
if got := s.Or(platform.All())(cmd); !got {
t.Errorf("nil.Or(All) should match (true), got false")
}
if got := platform.All().And(s)(cmd); got {
t.Errorf("All.And(nil) should be None (false), got true")
}
if got := s.Not()(cmd); !got {
t.Errorf("(nil).Not() should be Not(None) = true, got false")
}
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
// CommandView is the read-only view of a cobra.Command exposed to plugins
// and the policy engine. *cobra.Command is deliberately NOT reachable
// through this interface -- a plugin should never mutate the command tree.
//
// View semantics:
//
// - The view is a live proxy over the underlying *cobra.Command and its
// annotation chain. Strict-mode replaces nodes via RemoveCommand+
// AddCommand; the replacement stub explicitly carries the original
// command's annotations and help text forward so audit / compliance
// observers still see Risk / Identities / Domain after a denial.
// User-layer policy mutates in place, so its denyStubs preserve the
// original metadata by construction.
//
// - Path() is the canonical slash form ("docs/+fetch"), matching the
// doublestar glob semantics used by Rule.Allow / Rule.Deny.
//
// - Risk() returns ok=false when the command is unannotated. The policy
// engine treats an unannotated command as implicit deny whenever any
// Rule without AllowUnannotated=true is registered, so risk-based
// Selectors never see unannotated commands during normal hook dispatch
// under that configuration.
type CommandView interface {
// Path is the canonical slash-separated path, rootless ("docs/+update").
Path() string
// Domain returns the business domain ("docs", "im", "") inherited from
// the nearest ancestor with a cmdmeta.domain annotation. Empty string
// when no ancestor declares one.
Domain() string
// Risk returns the static risk level. ok=false signals "no risk_level
// annotation found in the parent chain" (unknown).
Risk() (level Risk, ok bool)
// Identities returns the supported identities. nil signals "no
// supportedIdentities annotation in the parent chain".
Identities() []Identity
// Annotation exposes the raw cobra annotation map for plugins that
// need a tag the framework does not surface.
Annotation(key string) (string, bool)
}

Some files were not shown because too many files have changed in this diff Show More