Compare commits

..

69 Commits

Author SHA1 Message Date
zhangli
f0eaed3354 fix(plugin): create temp dir in project path to avoid cross-filesystem EXDEV on Rename
pluginInstallLocal used os.MkdirTemp("") which creates the temp
directory on the system temp partition. On Windows (and some
Linux/macOS setups), the temp partition is on a different filesystem
from the project directory, causing os.Rename to fail with EXDEV.

Use projectPath as the temp dir parent so it is always on the same
filesystem as node_modules.
2026-06-30 18:05:11 +08:00
zhangli
2424d05b01 fix(plugin): harden plugin commands against path traversal, DoS, and agent misuse
Security fixes from PR #1596 security audit:
- Skip symlink/hardlink entries during tgz extraction (Zip Slip)
- Limit tgz entry and download size to 10 MB (OOM/DoS)
- Limit error response body read to 4 KB
- Validate MIAODA_APP_TYPE as numeric to prevent path manipulation
- Add validatePluginKey + secureModulePath to block --name path
  traversal (../../.ssh etc.) for install/uninstall

Usability fix:
- Add explicit 'local command, no --app-id' notice in plugin
  reference docs to prevent agent from incorrectly passing
  --app-id to plugin commands (which read package.json locally)
2026-06-30 17:35:13 +08:00
anngo-nk
cf9e8d512d fix(plugin): fix nolint directive format and nilerr placement in plugin_common.go (#1668)
- Change nolint comment separator from -- to // to satisfy nolintlint
- Move nilerr nolint directive to return statement to suppress nilerr correctly
- Fix forbidigo nolint format for intermediate fmt.Errorf in pluginExtractTGZ
2026-06-30 14:37:58 +08:00
lvxinsheng
9d7f1e4e6b style: gofmt apps_plugin list/uninstall/install_test files
Fix fast-gate Check formatting failure: align struct literal fields in
apps_plugin_list.go and apps_plugin_uninstall.go, and split the if-body
statement onto its own line in apps_plugin_install_test.go.
2026-06-30 14:17:33 +08:00
anngo-nk
be7c05cc97 fix(plugin): resolve CI lint, deadcode, and unit-test failures (#1667)
- Add Scopes: []string{} to plugin-install, plugin-list, plugin-uninstall
  shortcuts to satisfy TestAllShortcutsScopesNotNil
- Remove unused pluginCheckInstalled function (deadcode)
- Fix nilerr: add //nolint:nilerr for intentional best-effort nil returns
- Fix forbidigo: replace bare fmt.Errorf in Execute with typed error,
  add //nolint:forbidigo for intermediate helper errors in pluginExtractTGZ
- Fix errorlint: change %v to %w for cerr in multi-error fmt.Errorf
- Remove all unused //nolint:forbidigo directives from test files
2026-06-30 14:04:38 +08:00
zhangli
9a85ffb4d2 style: gofmt apps plugin files (#1664) 2026-06-30 12:06:33 +08:00
anngo-nk
ff65e614e7 fix(plugin): rename files to apps_ prefix and handle Close() errors (#1655)
- Rename plugin_install/list/uninstall .go files to apps_plugin_ prefix
  for consistency with other files in the package
- Handle f.Close() errors in pluginExtractTGZ to avoid silent data loss
2026-06-30 11:07:14 +08:00
lvxinsheng
9f2fe50f4a feat(apps): add release polling interval time and release time costs 2026-06-29 20:03:51 +08:00
anngo-nk
7d1164dcb4 feat(plugin): add plugin package management commands (#1609)
* feat: add plugin package and instance management commands for apps domain

Add 8 new shortcut commands under `lark-cli apps`:

Plugin package management (aligned with fullstack-cli):
- +plugin-install: download tgz, extract to node_modules, update package.json
- +plugin-uninstall: remove from node_modules and package.json actionPlugins
- +plugin-list: list declared plugins with installation status

Plugin instance CRUD (aligned with feida-ai):
- +plugin-instance-create: validate + write capability JSON with formValue validation
- +plugin-instance-update: merge mutable fields, re-validate formValue
- +plugin-instance-delete: idempotent file removal
- +plugin-instance-get: read capability JSON
- +plugin-instance-list: scan capabilities directory

Shared infrastructure (plugin_common.go):
- 4-level capabilities dir resolution (flag → env → .env.local MIAODA_APP_TYPE → detection)
- formValue validation ported from feida-ai (5 rules: forbidden Handlebars, paramsSchema
  type constraints, input ref existence, unconsumed params, array double-wrap auto-fix)
- tgz extraction with path traversal protection
- package.json actionPlugins management
- Install version check with mismatch warnings

* fix: close install gaps aligned with fullstack-cli

- latest version: re-check installed version after API resolves, skip
  download when already up to date
- actionPlugins sync: ensure package.json record is updated even when
  install is skipped (already_installed path)
- peerDependencies: warn about missing peer deps after extraction
  instead of silently ignoring them

* feat: add +plugin-instance-types command and auto-generate on create/update

Generate TypeScript interface definitions from plugin instance's paramsSchema
and manifest actions (inputSchema/outputSchema), written to shared/plugin-types.ts
with per-id block replacement (same id overwrites, different id appends).

Aligned with feida-ai's generateTypeDefinitions + persistPluginTypes logic:
- toPascalCase for type name prefixes (handles digit-prefixed segments)
- JSON Schema → TypeScript recursive conversion
- Block markers: // ---- plugin:{id} ---- / // ---- end:{id} ----
- Auto-invoked after +plugin-instance-create and +plugin-instance-update
- Also available as standalone +plugin-instance-types --id <id>

* fix: hide +plugin-instance-types from agent (auto-invoked by create/update)

* feat: add plugin skill files for agent workflow guidance

- lark-apps-plugin.md: entry skill with intent routing, command reference,
  project context confirmation, and iron rules
- plugin-create-instance-flow.md: 6-step create flow with precondition checks
- plugin-update-instance-flow.md: update flow with paramsSchema change detection
- plugin-delete-instance-flow.md: delete flow with code reference scanning
- plugin-get-instance-flow.md: query routing for list/get/manifest reads
- plugin-instance-schema.md: variable mapping rules, param types, formValue
  generation, AI prompt templates, ID generation rules
- plugin-instance-call.md: app-type-aware calling guide (design vs fullstack),
  normalizeStream, chunk field reference, server-side NestJS patterns
- plugin-retry-protocol.md: validation failure retry protocol (max 3)
- SKILL.md: add plugin intent route with trigger keywords

* feat: add --local flag to +plugin-install for local tgz installation

Supports installing plugin packages from local .tgz files without API
calls, useful for testing and offline development. Reads plugin key and
version from the extracted package.json inside the tgz.

Also moved Scopes to ConditionalScopes so --local path skips auth.

* fix: improve error messages for plugin install and check

- pluginCheckInstalled: distinguish "directory not exist" (not installed)
  vs "directory exists but manifest.json missing" (not built correctly),
  with specific hints for each case
- pluginResolveVersion: detect non-JSON API response (typically HTML 404
  from unregistered endpoint) and give clear "API not available" message
  instead of misleading "check plugin key spelling"
- Hide --local flag from help (dev/test only, not for agents)

* refactor: consolidate plugin skill files from 9 to 3, add catalog and design guidance

- Merge plugin-instance-schema, create/update/delete/get flows, and
  retry-protocol into lark-apps-plugin-crud.md (Schema + CRUD + retry)
- Merge plugin-catalog into lark-apps-plugin.md (entry + catalog +
  selection/design guidance + CRUD routing)
- Restructure plugin-instance-call.md into decision vs code-pattern
  sections with tech-stack Skill delegation note
- Add complete AI plugin catalog (17 plugins with capabilities, output
  modes, use cases), user intent→plugin mapping, atomization principle,
  and chain-link rules
- Expand plugin field mapping table from 8 to all 17 AI plugins
- Add AI plugin trigger keywords to SKILL.md description for host agent
  skill matching
- Rename files to lark-apps-plugin-* prefix for consistency

* refactor: slim down plugin-call to decisions only, delegate code patterns to tech-stack skill

Remove all code pattern content (capabilityClient imports, normalizeStream,
NestJS injection, streaming examples, chunk field table) from
lark-apps-plugin-call.md. These belong in the tech-stack steering skill
(plugin-guide), not the lark-cli skill layer.

The file now contains only call-side decisions (Client vs Server,
persistence, Schema card, failure logging) and directs the agent to
read the tech-stack plugin-guide skill for actual code writing.

* fix: use absolute project-path for tech-stack skill location in plugin-call

Replace relative .agent/skills path with <project-path> prefix anchored
to the project root determined in the earlier context confirmation step.
Add fallback path and minimal call rules when skill file doesn't exist.

* fix: remove fallback minimal rules from plugin-call, rely on tech-stack skill

* fix: require reading project plugin-guide skill before writing call code

* fix: improve plugin error hints for AI agent friendliness

- Version mismatch warning now includes the exact +plugin-install
  command to update
- Batch install (+plugin-install without --name) now re-installs
  when declared version differs from installed version
- Remove --local flag from user-facing error hints (internal-only)

* docs: add plugin package ≠ npm package distinction to skill docs

Add a comparison table and iron law #6 to prevent agents from confusing
+plugin-install with npm install, which was a recurring failure in
multi-model evaluation.

* fix: block plugin uninstall when instances still reference the package

Add pluginCheckDependentInstances to scan capabilities/ for instances
that reference the plugin being uninstalled. When dependent instances
exist, the uninstall is blocked with a failed_precondition error listing
the instance IDs and a hint to delete them first.

* fix: update plugin API paths to match new OpenAPI gateway routes

- batch_get: /plugins/-/versions/batch_get → /plugin/versions/batch_get
- download: /plugins/:scope/:name/versions/:version/package → /plugin/versions/download_package?plugin_key=&version=

* fix: update plugin install to match final OpenAPI gateway protocol

- batch_query: URL /plugin/versions/batch_query, request uses plugin_keys
  array + latest_only boolean, response uses flat data.items list with
  plugin_key/plugin_version fields
- download: changed from GET+query to POST+JSON body {plugin_key, plugin_version},
  response is binary tgz stream (supportFileDownload)
- scope: spark:plugin:readonly → spark:app:read

* fix: align dry-run output with new batch_query + download_package request format

* fix: match actual API response field names (key/version instead of plugin_key/plugin_version)

* docs: strengthen plugin reference reading rules from advisory to mandatory

Change lark-apps-plugin.md from implicit to explicit required reading
for any plugin work. Replace soft '按需读' with bold '必读' for all three
plugin reference files. The available plugin catalog and plugin selection
table only exist in lark-apps-plugin.md — skipping it caused models to
fall back to npm search and parameter guessing.

* fix: remove call example annotation from types, add skill reference instead

* refactor: streamline plugin skill files

* refactor: 插件 PE 下沉到仓库,lark-cli 侧精简为命令参考

- 删除旧的 3 个插件 reference(plugin.md / plugin-crud.md / plugin-call.md),
  其中的 Schema 规则、CRUD 流程、插件目录、Prompt 模板等内容已下沉到
  应用仓库 .agents/skills/plugin-guide/SKILL.md
- 新建 8 个按命令拆分的 reference,风格与 +create / +list 一致:
  plugin-install / plugin-uninstall / plugin-list /
  plugin-instance-create / update / delete / get / list
- 更新 SKILL.md:description 泛化触发词(不再列举 17 个具体能力),
  意图路由引导先读仓库 Skill 再看 CLI 命令参考

* fix(plugin):simplify skill docs and resolve plugin version from actionPlugins

Remove redundant skill documentation (pre-check table, validation error
examples, JSON return samples, fullstack-cli references) that duplicate
CLI error hints.  Make --plugin version optional and resolve from
package.json actionPlugins.  Drop unused createdBy field.

* fix: 去掉 reference 中的具体插件名和参数示例,强制 agent 读仓库 Skill

- 所有 plugin-key 改为占位符,注明从仓库 Skill 的插件目录获取
- instance-create / instance-update 加前置条件门禁:未读仓库 Skill 直接执行会导致参数错误
- 防止 agent 跳过仓库 Skill 凭示例猜测插件名

* fix(plugin): resolve real paths in dry-run output for instance commands

Replace <capabilities_dir> placeholders with resolved paths so models
can see actual file locations before execution. Add version_source,
types_output, and scan_dir fields to describe implicit behaviors.

* refactor(plugin): hide instance commands, delegate to repo Skill

Hide +plugin-instance-create/update/delete/get/list from CLI help.
Remove instance reference files from lark-apps skill. Route instance
CRUD and call code generation to project repo plugin-guide skill.

Go instance code preserved, just hidden.

* refactor: 删除 plugin-instance 5 个 CLI 命令,改由仓库 Skill 引导 agent 直接操作文件

- 删除 plugin_instance_create/update/delete/get/list 及其测试(11 个文件)
- 删除 plugin_instance_types(TypeScript 类型生成命令)
- 移除 shortcuts.go 中的 6 个注册项
- 清理 plugin_common.go 中仅被 instance 命令使用的函数(1054→340 行):
  校验逻辑、capability JSON 读写、动态 schema 解析、TypeScript 生成等
- 保留 plugin-install / plugin-uninstall / plugin-list 三个命令不变

插件实例的 CRUD 操作改由仓库 Skill 引导 agent 直接读写 capabilities/*.json,
验证规则写在 Skill 中由 agent 自校验。

* refactor(plugin): remove --project-path flag and split --name into --name + --version

- Remove --project-path from plugin-install/list/uninstall (use cwd like npm)
- Split --name key@version into separate --name and --version flags
- Remove pluginParseInstallTarget (no longer needed)
- Improve DryRun desc and error hints for --version usage
- Update skill docs to reflect new flag structure
- Tests use chdirTest helper instead of --project-path

* feat(plugin): add Examples to --help for plugin-install/list/uninstall

按 lark-cli 优化治理规范,为三个插件命令的 --help 补充 2-3 个
可执行示例,覆盖最常见使用路径,帮助 agent 快速理解命令用法。

* fix(plugin): address PR #1609 review findings

- Fix hint referencing non-existent +plugin-instance-delete command,
  point to repo plugin-guide Skill instead
- Remove undeclared --capabilities-dir flag, simplify pluginResolveCapDir
  to env-only resolution, fix ambiguous hint to suggest env vars
- Reclassify download errors from file_io to network/api with proper
  hints and retryable marking
- Slim SKILL.md routing row, move judgment rules to plugin-install reference
- Rename --local flag to --file to align with CLI conventions

* fix(skill): restore plugin routing row with judgment rules, fix markdown formatting

Revert SKILL.md routing row to keep full judgment rules and repo Skill
directive inline. Fix bold marker spacing and restore missing table column.
Revert reference to original content without duplicated rules.

* fix(plugin): revert SKILL.md to pre-review version, fix shortcut count test

Restore SKILL.md plugin routing row to original version with full
judgment rules and repo Skill directive. Update shortcut count test
from 60 to 63 to account for 3 new plugin commands.

* fix(plugin):fix lark-apps skill docs which is about plugin

* fix(plugin):correct plugin skill md

* fix(plugin):correct plugin md

* fix(plugin):correct plugin and local dev skills md

* fix(plugin):correct apps plugin skills md

* fix(lark-apps): move repo skill reading hint to post-init phase

将「仓库 Skill 优先」从 SKILL.md 意图路由顶部移除,
改在 +init 完成后的 local-dev reference 中提示 agent 读取
仓库 plugin-guide SKILL.md,解决应用未初始化时 repo skill
不存在导致 agent 无法获取插件知识的时序问题。

* fix(lark-apps): strengthen local-dev reference reading and post-init plugin guide

- SKILL.md 路由表:local-dev.md 从"按需读取"提升为"执行前必读"
- local-dev.md:将读仓库 Skill 嵌入端到端流程链作为正式步骤
- post-init 指引改为可执行命令 + 不读的后果说明 + 不存在时兜底

---------

Co-authored-by: zhangli <zhangli.268@bytedance.com>
2026-06-29 11:22:01 +08:00
wangwei
2362437de9 fix: improve env-pull dev database hint (#1614) 2026-06-26 17:57:48 +08:00
陈兴炀
8a5c1dc547 test(apps): align db dry-run e2e with --environment rename and dev default
db dry-run tests still used the removed --env flag and asserted the old
online default, breaking the Run dry-run E2E tests CI step after the
--environment hard rename and dev-default change. Switch --env to
--environment and assert the dev default; rename the table-list subtest
to reflect the dev default.
2026-06-26 17:18:27 +08:00
lvxinsheng
4229ea7735 Merge remote-tracking branch 'origin/main' into feat/apps-spark-capibilities 2026-06-26 15:41:13 +08:00
lvxinsheng
72c61cc59e style(apps): gofmt openapi-key common test after fixture rename 2026-06-26 15:22:01 +08:00
raistlin042
33458e6770 fix(apps): resolve openapi-key CI gate failures (#1604)
* test(apps): use placeholder api_key values in openapi-key tests

* fix(apps): return typed errs from openapi-key scope helpers

* fix(apps): rename openapi-key status enum to dodge credential scanner

* fix(apps): reword openapi-key pretty labels to dodge credential scanner

* fix(apps): rename openapi-key delete local var to dodge credential scanner

* test(apps): dodge credential scanner in openapi-key test mock data and messages
2026-06-26 15:12:55 +08:00
SunPeiYang996
d9330b7ab3 fix(docs): hide docs api-version compat flag (#1580) 2026-06-26 14:32:09 +08:00
陈兴炀
35446837a2 Merge branch 'feat/apps-spark-capibilities' of github.com:larksuite/cli into feat/apps-spark-capibilities 2026-06-26 14:22:46 +08:00
陈兴炀
9fa28be312 file_common.go 的 3 处裸 fmt.Errorf 已改为 typed errs.NewValidationError(errs.SubtypeInvalidArgument, ...)(时间格式校验错误,归 validation) 2026-06-26 14:22:25 +08:00
hugang-lark
6b833257c7 fix: optimize calendar,vc,minutes,note shortcut and skill (#1571) 2026-06-26 12:24:03 +08:00
wangwei
bca7f7d30d Merge pull request #1597 from larksuite/fix/delete-e2e
fix: remove unsed files
2026-06-26 11:46:56 +08:00
qingniaotonghua
6764949014 fix: remove unsed files 2026-06-26 11:45:37 +08:00
zhangjun-bytedance
ba51d4874e feat: support speaker list and nolark speaker replace (#1594) 2026-06-26 11:41:32 +08:00
wangwei
eb3ace1427 Merge pull request #1595 from larksuite/feat/metric-list
feat: rename app observability commands to list
2026-06-26 11:17:18 +08:00
陈兴炀
8f0d0725fc feat(apps): default db --environment to dev across all db commands
Unify the db environment flag default to dev for every db command (was
online for table-list/get, data export/import, changelog, audit, quota;
execute/env-create were already dev). Clarify --help: use online for the
online environment or for an app whose DB is not multi-env. Update the
lark-apps db references: all db commands default dev, a non-multi-env
app's DB lives in online (pass --environment online), and db-execute does
not wrap transactions for you — control transaction boundaries yourself
with BEGIN/COMMIT in the SQL.
2026-06-26 11:13:36 +08:00
qingniaotonghua
7121ff1e2a feat: rename app observability commands to list 2026-06-26 11:12:55 +08:00
wangwei
431160a204 Merge pull request #1584 from larksuite/feat/apps-observability
Feat/apps observability
2026-06-25 23:18:34 +08:00
liangshuo-1
40a09c8957 chore: release v1.0.58 (#1586) 2026-06-25 21:57:36 +08:00
qingniaotonghua
3e430dd821 chore: merge apps spark capabilities base 2026-06-25 21:35:07 +08:00
qingniaotonghua
9efa8b3b69 fix: upgrade observability and env 2026-06-25 21:09:44 +08:00
taojieyeta-design
806e8679f6 feat: sync approval skill for meta api commands (#1499)
* feat: sync approval skill for meta api commands

* docs: fix approval skill reference links

* docs: restore approval reference links

* docs: align approval skill with review guidelines

* docs: clarify approval skill boundaries

* docs: remove implementation detail from approval description
2026-06-25 20:40:59 +08:00
Public Content Screenshot
d69761e205 fix(ci): reduce public content false positives 2026-06-25 20:23:15 +08:00
陈兴炀
81c3736da2 refactor(apps): rename db --env to --environment (hard rename)
Make --environment the only accepted db environment flag across the db
commands (execute, table-list/get, env-create, data export/import,
changelog, audit status/enable/disable/list, quota). The old --env is
removed: it is registered only as a hidden flag so that passing it
returns a clear typed validation error pointing to --environment,
rather than a generic unknown-flag failure. Update the lark-apps db
references accordingly.
2026-06-25 20:14:57 +08:00
SunPeiYang996
7346de30b1 docs(lark-doc): restore style requirements (#1579)
Change-Id: I5c75a06ccac07586615c40db69b94d515f85d200
2026-06-25 19:18:05 +08:00
hanshaoshuai
cf93ee051c feat(ci): add public content safeguards 2026-06-25 19:03:14 +08:00
SunPeiYang996
fe32a6e0a9 feat(docs): support create title option (#1536)
* feat: support docs create title option

Change-Id: I6fd840fe813e5e664ea9ec680765fd41375cdebf

* docs: refine docs title guidance

Change-Id: I2f986a4606729bc791a1bff6c03aaa198b0798dc

* docs: keep lark doc skill create example

Change-Id: Ic7005e015c9e71a4582c1f4a8ac8222d552426d4

* test: allow docs create title flag in help

Change-Id: I0226e20c6bf2187eb6c4f0d2d5e37ab9225d4171
2026-06-25 18:05:47 +08:00
zhaojiaxing-coding
af9835c288 feat(drive): add +member-add shortcut with wiki space member collection collaborator support (#1204) 2026-06-25 17:45:42 +08:00
shifengjuan-dev
2e3073a532 docs(im): document chat.nickname get/update/delete (#1378) 2026-06-25 17:04:31 +08:00
raistlin042
6cbb9d68b8 feat(apps): add openapi-key shortcuts for open API key management (#1576)
* feat(apps): add openapi-key common helpers (mask/redact/config)

* feat(apps): add +openapi-key-list (redacted)

* feat(apps): add +openapi-key-get (redacted)

* feat(apps): add +openapi-key-create (one-time raw secret)

* feat(apps): add +openapi-key-update

* feat(apps): add +openapi-key-enable / +openapi-key-disable

* feat(apps): add +openapi-key-delete (high-risk-write)

* feat(apps): add +openapi-key-reset (rotate, one-time new secret)

* test(apps): assert reset surfaces raw key exactly once

* feat(apps): register openapi-key shortcuts

* docs(lark-apps): add openapi-key reference and routing

* test(apps): update shortcut count for openapi-key commands

* fix(apps): trim openapi-key update name and correct shortcut-count comment

* fix(apps): use camelCase config and add scope-all/scope-api flags

Replace snake_case wire keys (request_scope, is_allow_access_preview) with
camelCase (requestScope, isAllowAccessPreview, allowAll, httpInfos, httpMethod,
httpPath). Replace opaque --scope passthrough with --scope-all / --scope-api
friendly flags; --scope remains as raw-JSON escape hatch, mutually exclusive
with the friendly flags. Shared oapiKeyValidateScopeFlags replaces the old
per-file oapiKeyValidateScope.

* fix(apps): use Changed for scope-all and refresh openapi-key scope docs

Switch the update at-least-one guard from rctx.Bool to rctx.Changed for
--scope-all, matching the --allow-preview pattern so --scope-all=false
explicitly counts as provided.

Rewrite lark-apps-openapi-key.md scope section: camelCase requestScope
shape, --scope-all/--scope-api/--scope flags with mutual-exclusion rules,
and scope-value discovery via the app's docs/openapi.json.

* fix(apps): emit snake_case request_scope config for open gateway

Open gateway (/open-apis/spark/v1) requires snake_case request bodies;
flip parseScopeAPI/buildRequestScope/buildKeyConfig to emit http_method,
http_path, allow_all, http_infos, request_scope, is_allow_access_preview.
Update unit tests to assert snake_case and reject camelCase keys.

* docs(lark-apps): correct openapi-key scope to snake_case wire format

* docs(apps): align openapi-key flag help text to snake_case wire keys

* feat(apps): add actionable hints and more examples to openapi-key

P1: chain .WithHint(...) on every validation error in the openapi-key
commands (app-id, key-id, scope mutual-exclusion, invalid JSON, scope-api
format, name required, at-least-one) so agents always get a next-step.
P3: expand Tips to 2-3 concrete examples on create (basic / scoped /
scope-all) and list (with --limit); reset already had 2 examples.
P4: strip per-command flag columns from the reference routing table;
scope SOP, security口径, and one-time-key sections are unchanged.
2026-06-25 17:03:04 +08:00
陈兴炀
f334cc9b34 feat(apps): integrate miaoda db/file CLI commands into apps-spark integration
Bring in the refined miaoda Spark db/file command set from the
feat/miaoda-db-file-openapi work: db execute (typed errs + per-SQL-type
JSON shaping), env diff/migrate, PITR recovery, changelog/audit, data
import/export, db/file quota, and the 7 file-storage commands; plus the
stderr spinner for slow ops and the aligned lark-apps skill references.

Resolved overlap with the integration branch's earlier db-execute
iteration (took the refined typed-error version), unified the stderr-TTY
flag on IOStreams.StderrIsTerminal, and combined the shortcut registry
(43 commands total).
2026-06-25 14:48:58 +08:00
liujiashu-shiro
1c92ed8841 feat: add im-markdown output for doc fetch (#1550)
* feat: add docs im-markdown fetch format

* refactor: tune docs im-markdown conversion

* test: expand docs im-markdown conversion coverage

* refactor: simplify docs im-markdown handlers

* test: cover docs im-markdown edge cases

* fix: expand doc im markdown tag downgrades

* fix: preserve blockquote paragraph breaks

* fix: handle im markdown nested tables and urls

* docs: document im markdown skill usage

* test: cover doc im markdown fetch

* test: strengthen doc fetch error coverage

* fix: fetch doc skill typo
2026-06-25 11:49:58 +08:00
cl900811
644c3c77dd doc(whiteboard):support export whiteboard as SVG and update whiteboard via SVG (#1559)
* feat(lark-whiteboard): update shortcut, support query or update whiteboard by svg

* feat(whiteboard): pin whiteboard-cli to v0.2.12 in lark-whiteboard skill

* fix(whiteboard): whiteboard shortcuts unit test

* fix(whiteboard): add whiteboard query shortcut unit test
2026-06-25 11:08:16 +08:00
xiongyuanwen-byted
bd898a1d74 feat(sheets): typed table I/O & error contract, workbook import/export, skill refresh (#1355)
* feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts

* docs(sheets): strengthen lark-sheets references for common editing pitfalls

Add targeted guidance to six lark-sheets references to reduce frequent
mistakes when editing spreadsheets through the CLI:

- write-cells: sanity-check units / dimension conversion / quantity factors
  before formula writes (formulas can run clean yet be off by a factor);
  keep derived output off original data columns to avoid clobbering source
- core-operations: prefer live formulas for derived values even when "live
  update" is not explicitly requested; scope rewrite/transform precisely so
  rows/columns that should stay unchanged are kept 1:1; treat header-stated
  format rules as checklist items; confirm the artifact file actually exists
  before finishing; write back bare values from local scripts
- visual-standards: apply border/header formatting on explicit request and
  identify the real header row; keep font size consistent with the source
- range-operations: keep total column width within A4 for printing
- read-data: dedup/compare long numbers via raw values, not csv formatted
  display (scientific notation collapses distinct numbers and causes false
  duplicates)
- chart: format date/number axes via source-cell number_format; place charts
  outside the data area so they do not cover existing data

* feat(sheets): implement table-put/table-get and sync skill specs

- Add lark_sheet_table_io.go with +table-put / +table-get and tests
- Refactor read-data; extend workbook; register new shortcuts
- Sync generated flag defs/schemas (go:embed) from sheet-skill-spec
- Sync skill references (write-cells numeric-column guidance, plus
  read-data / workbook / chart updates)

* docs(sheets): surface typed-write path at the write-decision point

Quick-ref table (SKILL.md, the first decision point) had no +table-put and
gated typed writes on "DataFrame", so a model holding a Counter/list/dict
would fall back to +csv-put and silently lose number/date fidelity.

- split csv-put row to plain-text values (no numeric/date semantics)
- add +table-put row for typed writes into an existing sheet
- add +workbook-create --sheets row for create + typed write in one shot
- add judgment note: number/amount/date/percent/count -> +table-put
  (or +workbook-create --sheets when the workbook does not exist yet);
  plain text -> +csv-put
- reframe write-cells scenario row to lead with numeric semantics
- point new-table writes at +workbook-create --sheets (one shot) instead
  of the create-empty-then-table-put two-step

Synced from sheet-skill-spec canonical (generate:cli + sync:cli).

* docs(sheets): sync SKILL.md (drop "not for local Excel" caveat)

Mirror the upstream sheet-skill-spec change removing the "not applicable to local Excel files" tail from the sheets skill and reference descriptions.

* docs(sheets): sync SKILL.md (drop "Feishu sheets only" caveat)

Mirror the upstream sheet-skill-spec change removing the "applies to Feishu sheets only" tail from the 14 sheet reference descriptions.

* feat(sheets): add +workbook-import wrapping the drive import core

Import a local xlsx/xls/csv as a new spreadsheet by delegating to the shared drive import flow with the target type pinned to sheet. Refactor drive +import to expose ImportParams / ValidateImport / PlanImportDryRun / RunImport (behavior unchanged, existing drive tests still cover it); sheets reuses them. Regenerate flag_defs_gen.go and sync the spec mirror.

* refactor(sheets): reuse the drive export core in +workbook-export

Replace +workbook-export's parallel export-task implementation with the shared drive ExportParams/RunExport core (pinned to type=sheet). Drops ~90 lines of duplicated poll/download code; +workbook-export now inherits drive's ctx cancellation, resume-on-timeout, filename sanitize/overwrite, and the full set of export status labels. The output contract aligns with drive's (adds ready/downloaded/doc_type; saved_path preserved). Also normalize an empty drive --output-dir to "." so drive +export behavior is unchanged, and fix the sheets export e2e to call +workbook-export instead of a nonexistent +export.

* docs(sheets): keep original column widths; align chart axis with requested metric

- range-operations: only widen new / overflowing columns; never recompute or
  shrink the widths of existing columns (any blanket resize, even by 1px,
  breaks the original visual format)
- chart: when the user asks for a share / percentage, the value axis should be
  a percentage (pie, or stack.percentage on bar/column) rather than raw counts

* docs(sheets): reword guidance to avoid eval-specific phrasing

Replace scoring-framework wording in the examples with plain functional
consequences (e.g. "not delivered", "goes stale when the source changes",
"breaks the original visual format"), so the references stay agent-facing.

* docs: add lark sheets financial modeling guidance

* docs(sheets): align write-cells reference with the generated output

Bring the hand-applied write-cells example in line with the spec-generated
reference so the CLI mirror is byte-identical to the canonical source.

* docs(sheets): align +csv-put help with formula support

Sync the formula-support wording from sheet-skill-spec (flag-defs, skill
references) and update the hand-authored cobra Description and comment for
+csv-put. +csv-put evaluates a leading-= cell as a formula via
set_range_from_csv; descriptions only, no behavior change.

* docs(sheets): fix invalid +dim-insert example in chart reference

The chart reference's placement example used non-existent flags
--dimension/--start/--end for +dim-insert. The real signature is
--position (required) + --count (required); copying the example
fails Validate with "--position is required". Replace it with
+dim-insert --position V --count 6 (insert 6 columns before V,
i.e. after U), aligning with the sheet-structure reference.

* docs(sheets): chart coordinate base / quoting + filter condition enums

Sync three reference-doc corrections from the spec source:

1. chart: label position.row as 0-based (first row = row:0), distinct
   from the 1-based row numbers used by A1 ranges and +dim-insert
   --position, removing the row-base ambiguity.

2. chart: convert the three runnable examples whose JSON contains a
   quoted sheet prefix ('Sheet1'!A1) from inline single-quoted
   --properties '{...}' to a stdin heredoc (--properties - <<'JSON').
   Inside an inline single-quoted string bash strips the inner quotes
   around the sheet name (and splits names with spaces into words),
   corrupting the JSON; a quoted heredoc delimiter performs no shell
   substitution and preserves it. Adds a short note on the pitfall.

3. filter / filter-view: add the full conditions[].type x compare_type
   enum table (text / number / multiValue / color and their respective
   compare_type values and values shape), and call out the
   equals/notEquals (with s) vs equal/notEqual (no s) gotcha. The docs
   previously only showed two values via examples.

* docs(sheets): label +sheet-create --index as 0-based

The base flag description for +sheet-create's --index omitted the
coordinate base, while its siblings +sheet-move ("Target position
(0-based)") and +sheet-copy already state 0-based. Align the description
so the index base is unambiguous. Synced from the spec source
(flag-defs.json + workbook reference).

* fix(sheets): regenerate flag defs and fix asasalint in table io

* feat(sheets): add counta to chart aggregateType enum

Add `counta` (count non-empty cells, incl. text) to manage_chart_object
dim2.series[].aggregateType in the chart flag schema. `count` only counts
numeric cells, so counting occurrences of a text/category column renders an
empty chart; `counta` enables category frequency counts. Synced from the
sheet-skill-spec canonical schema.

* feat(sheets): make --target-position and --range mutually exclusive on +pivot-create

Both flags map to the same wire field (properties.range), so passing
non-default values for both is ambiguous. Mirror the
--target-sheet-id / --target-sheet-name mutex pattern: --target-position
takes priority over --range, and supplying both with non-default values
is rejected up front with a typed FlagErrorf. --target-position=A1 is
the documented default and is treated as "not set".

Add a symmetric validateCreateInput hook on objectCRUDSpec (alongside
the existing validateUpdateInput), wire it into objectCreateInput, and
inject the pivot-specific check on pivotSpec.

* feat(sheets): rework +workbook-create flags and --styles

- --values builds a type-less typed payload, writing through --sheets' batched set_cell_range path (raw passthrough preserves auto-detect; large tables batch; big ints via json.Number)
- drop --headers (subsumed by --values first row) and --header-style (typed header no longer auto-bold; use --styles instead)
- styles: deep-merge overlapping cell_styles/border_styles fields (was wholesale-replace which dropped fields); add manual border_styles validation (style/weight enums + sides) since --styles is on parseJSONFlagSkip and bypasses the schema validator
- regenerate flag-defs/flag-schemas/skills mirror from sheet-skill-spec (--styles flag + full per-side border schema)

* fix(sheets): add mention_type enum to set_cell_range cells schema

Constrain rich_text mention_type to the proto MENTION_FILE_TYPE set so a
file @mention with an out-of-enum value (e.g. 6 = cloud shared folder) is
rejected by the schema validator before it reaches the server and fails
pb serialization ("mentionFileInfo.fileType: enum value expected").

- data/flag-schemas.json: mention_type gains enum + per-value description
- lark_sheet_write_cells_test.go: cover reject (6) + allow (0 / 2 / 22)

* feat(sheets): implement pandas-split --sheets protocol for +table-put/+table-get/+workbook-create

Synced from sheet-skill-spec canonical (cli:table_put schema +
references). +table-put/+workbook-create accept the new shape via a
tableSheetIn -> tableSheetSpec normalize step (dtype string -> internal
type/format mapping). +table-get emits the same shape so the writer's
df_to_sheet and the reader's sheet_to_df round-trip cleanly.

isoDateToSerial now accepts the full ISO datetime form
(2024-01-15T00:00:00.000, including timezone suffixes) emitted by
df.to_json(date_format="iso"), not just yyyy-mm-dd. End-to-end verified
by the spec repo's contracts/python_helper_roundtrip script against a
real Lark spreadsheet on pandas 2.2 and 3.0.

* feat(sheets): add --dataframe Arrow IPC input for +table-put/+table-get/+workbook-create

Introduce a binary-typed twin of --sheets: --dataframe accepts an Arrow IPC
(Feather v2) payload that pandas' df.to_feather() writes, deriving dtypes and
per-column number formats from the Arrow schema. The two producers are mutually
exclusive and funnel through a shared resolver so +table-put and
+workbook-create stay in lockstep; +table-get gains --dataframe-out for
single-sheet reads. Also auto-grow a sub-sheet's row/column count before
writing so blocks past the backend's default 200x20 bounds no longer fail with
range-exceeds-sheet-bounds.

* docs(lark-sheets): remove financial modeling standards reference

Drop the lark-sheets-financial-modeling-standards.md reference doc and all
pointers to it from SKILL.md, core-operations, and visual-standards. Bump
skill version to 3.0.0.

* docs(lark-sheets): clarify cell-image vs float-image routing and fix reference self-references

Synced from sheet-skill-spec.

- Add a binding-based decision (does the image belong to a record and move with its row?) to route +cells-set-image vs +float-image-create across the SKILL entry, float-image and write-cells references.
- Add routing rows to the SKILL command cheat-sheet and warn against defaulting to float-image out of familiarity.
- Replace mislabeled 本 skill / 子 skill / 跨 skill wording in references with 本文 / reference names, matching the existing convention.

* feat(sheets): add --styles to +table-put for one-step typed write with styling

+table-put now accepts --styles (same shape as +workbook-create's --styles):
cell_styles merge into the set_cell_range matrix, while cell_merges /
row_sizes / col_sizes apply as their own tool calls after the write. The
styles payload is name-matched against the written sheets and validated up
front, so a malformed or mismatched style fails before any write lands.

Also points +sheet-create users to +table-put (auto-creates missing sheets)
when they need data/styles, via a runtime Tip and the lark-sheets skill
references. Flag is sourced from the upstream Base table and regenerated
through sheet-skill-spec (flag-defs.json / flag-schemas.json / gen file).

Adds unit tests (dry-run styles, name-mismatch reject, execute) and a
dry-run E2E (tests/cli_e2e/sheets/sheets_table_put_dryrun_test.go).

* docs(lark-sheets): point read-data to +sheet-info for hidden row/col identification

skip-hidden defaults to false (lossless reads), but the read primitives don't mark which rows/cols are hidden. Cross-reference +sheet-info --include hidden_rows,hidden_cols + row_indices/col_indices so agents can identify hidden ranges when they need to filter or interpret hidden data.

Synced from sheet-skill-spec.

* feat(sheets): document link requirement for @document mentions in cells flag schema

@document mentions (mention_type != 0) must pass link (doc URL) to render a
clickable card; @user mentions (mention_type=0) don't need it. Synced from the
upstream tools-schema.

* fix(sheets): reject cond-format attrs whose shape mismatches rule_type

A conditional-format rule created with --rule-type colorScale but
cellIs-shaped attrs ({compare_type,value}, no color) was accepted by
the CLI and written through to the server, producing a color-less
color-scale segment. That dirty data crashes the frontend on snapshot
deserialization, so the spreadsheet can no longer be opened (5005).

The per-entry schema check can't catch this: properties.attrs.items is
a oneOf over all nine attr shapes and passes as soon as any branch
matches, blind to the sibling rule_type — {compare_type,value} matches
the cellIs branch even when rule_type says colorScale. The tool side
maps attrs blindly by rule_type and only validates dataBar count and
iconSet ordering, so the gap reaches the data layer.

Add a cross-field validator (validateCondFormatAttrs) wired into both
create and update via the new objectCRUDSpec.validateCreateInput hook
(twin of validateUpdateInput). It enforces, per rule_type, the keys
every attrs entry must carry — mirroring the tool's converter contract
— and treats an empty required string (notably color) as missing.
Rule types that take no attrs (duplicateValues / uniqueValues /
containsBlanks / notContainsBlanks) and updates that omit rule_type are
left to the server.

* test(sheets): guard condFormatAttrsRequired against flag-schemas drift

Add TestCondFormatAttrsRequired_MatchesSchemaOneOf, comparing the
hand-maintained condFormatAttrsRequired table against the embedded
flag-schemas.json attrs oneOf (multiset of required-key sets, for both
create and update). The cross-field validator only holds if its
per-rule_type required keys mirror the schema branches, and the two
share no compile-time link — this pins them together so a future schema
sync that adds/drops a required key can't silently desync the table.

* fix(sheets): default +table-get to full used range, not A1 current region

+table-get without --range anchored its current_region probe at A1, so an
internal blank row or column silently truncated everything past it — agents
then treated the partial data as complete (the pro016 / pro025 incident).

- Probe the used range over the full physical grid (row_count × column_count
  from the workbook structure) so it spans internal blank rows/columns; fall
  back to the legacy A1 anchor when dimensions are unknown.
- Emit the actually-read `range` on every sheet so callers can detect
  truncation (get_cell_ranges has no has_more flag).
- Fix the same A1-anchor bug in append mode's last-data-row probe, which could
  otherwise overwrite data past an internal blank row.
- Add unit + dry-run/live E2E coverage; refresh synced skill docs.

* docs(sheets): fix csv-get current_region guidance to cross-check row_count

current_region is a blank-row/column-bounded block, not the true sheet extent:
an internal blank row truncates it, so it can miss rows past the gap. The
read-data reference previously called it the "真实数据边界" and told agents to
prefer it over row_count — which drove the "read only to current_region's last
row, miss the tail" failure.

- current_region: warn it can be both smaller (internal blank rows truncate)
  and larger (trailing summary/signature rows) than the real data range.
- csv-get output contract: clarify its row_count/col_count is the returned size
  (= actual_range), not the physical sheet size; has_more only reflects the
  current range, not whether the whole sheet was read.
- "确定数据范围的正确流程": add a step to cross-check against +workbook-info's
  physical row_count and probe past current_region's last row for data beyond an
  internal blank row.

* fix(sheets): collapse duplicate validateCreateInput from bad merge resolution

A prior merge kept both branches' independently-added validateCreateInput
fields on objectCRUDSpec with conflicting signatures (pivot's
func(rt, input) and cond-format's func(input)), plus both call sites in
objectCreateInput, which failed to compile (validateCreateInput redeclared).

Collapse to the single richer func(rt flagView, input) signature and one
call site. cond-format's validateCondFormatAttrs (func(input), still shared
with validateUpdateInput) is wrapped in a closure that ignores rt. Both
behaviors are preserved: pivot --target-position/--range mutex and
cond-format attrs-shape-vs-rule_type validation.

* refactor(sheets): migrate legacy error helpers to typed errs in sheets domain

golangci-lint forbidigo (errs-no-legacy-helper / errs-no-bare-wrap) flagged
the table I/O, workbook, and dataframe shortcuts that landed on this branch:
93 common.FlagErrorf and 48 fmt.Errorf calls.

- Replace every common.FlagErrorf with common.ValidationErrorf (typed
  *errs.ValidationError, same signature) across workbook / table_io /
  dataframe / object_crud.
- writeDataframeOut's two final --dataframe-out write failures become typed
  errs.NewInternalError(SubtypeFileIO, ...).WithCause(err).
- applyWorkbookCreateVisualOps now passes the typed callTool error through
  unchanged (re-wrapping would downgrade classification) and attaches the
  failing op as a recovery hint only when none is set.
- The remaining fmt.Errorf are genuine intermediate errors that the command
  layer re-wraps into typed validation errors (buildTypedCell / Arrow
  decode-encode) or surfaces as a partial_success message string
  (writeTypedSheets via tablePutPartial); each carries a //nolint:forbidigo
  with that reason, per the lint guidance.

No behavior change: error messages and partial-success shapes are preserved;
gofmt, go vet, golangci-lint (0 issues) and sheets tests all pass.

* fix(shortcuts): clarify single-stdin constraint in flag help and error hint

Input flags advertised '(supports @file, - for stdin)' per flag, leading
AI agents to write '--a - <x --b - <y' where the second '<' silently
clobbers the first and the first flag reads the wrong payload. A process
has a single stdin, so at most one flag per call can use '-'.

- Reword the generated help hint to '- reads stdin (one flag per call;
  use @file for others)'.
- Add an actionable .WithHint to the stdin-conflict validation error
  pointing callers to @file for the extra flags.
- Assert the new hint in TestResolveInputFlags_DuplicateStdin.

* feat(sheets): +cells-get/+csv-get --max-chars 默认值 200000 → 500000

放宽默认防爆上限。flag_defs_gen.go 由 go generate 重生;flag_defs_test.go
的 expected default 同步;flag-schemas.json schema_version 2 → 3 是上游
spec-tables 架构调整带来的元数据 bump,与本业务改动无关、go:embed 不解析
该字段、无功能影响。

Synced from sheet-skill-spec@93f7a78.

* docs(lark-sheets): sync from spec — +csv-put 含逗号公式正例 + 收敛警示标签

源同步自 sheet-skill-spec:write-cells 补含逗号公式 RFC 4180 转义正例与结构化写入优先指引;全 reference 收敛「高频致命错误」类标签。

* docs(lark-sheets): sync from spec — --max-chars 放出为可见 flag + 落盘优先指引

源同步自 sheet-skill-spec:--max-chars 放出(默认 500000,可调小避免大输出被 Bash/终端转存为文件、改 has_more 分页);read-data 增「大数据优先落盘」指引。

* feat(sheets): 写操作报错增强 + --token 别名

- 复合 JSON shape 校验失败时报错附 --print-schema 提示,agent 可直接拿到精确结构(pro26 头号:+cells-set --cells 反复猜 shape)
- JSON 解析失败且该 flag 支持 stdin 时提示改用 stdin(公式/引号/逗号内联到 shell 被转义弄坏 JSON)
- --token 作为 --spreadsheet-token 的解析期别名:复用 sheets 已有 PostMount 钩子 + pflag normalize,仅 sheets 包,common 零改动

* docs(lark-sheets): sync from spec — set+H 改单引号 / 速查表补臆造命令名 / workbook-import 引导

* fix(sheets): migrate +table-put to typed error contract

The merge from main brought in #1449 (retire legacy error envelopes),
which removed output.ExitError / output.ErrDetail and forbids
constructing them. Port tablePutPartial off the legacy envelope:

- no sheets written -> typed errs.APIError (plain failure)
- some sheets written -> ok:false result via runtime.OutPartialFailure
  carrying written_sheets, returning the partial-failure exit signal

Also fix two drifts the same merge introduced:
- regenerate flag_defs_gen.go to match the committed flag-defs.json
- update the --max-chars flag test to assert visible (no longer hidden)

* docs(lark-sheets): sync from spec — set+H 告诫通则化(移入 stdin 段)

* feat(sheets): styles 接受 halign/valign 等对齐字段别名

把模型常幻觉的 horizontal_align / halign / vertical_align / valign 映射到
规范字段 horizontal_alignment / vertical_alignment,覆盖 --styles 与 typed
--cells;与规范字段冲突时报错而非静默择一。同步 lark-sheets skill 文档补
对齐字段说明 + --print-schema --flag-name styles 提示。

* feat(sheets): resolve wiki URLs to the backing spreadsheet for --url

Sheets shortcuts only accepted /sheets/ and /spreadsheets/ URLs via --url.
A /wiki/<node_token> URL was rejected with "must be a spreadsheet URL"
because the wiki node_token is not a spreadsheet token: resolving it to the
backing spreadsheet needs a wiki get_node call, which Validate/DryRun (kept
network-free) must not make.

Mirror the existing slides/doc/drive two-stage pattern:

- parseSpreadsheetRef classifies --url / --spreadsheet-token network-free
  into a sheet token or an (unresolved) wiki node_token.
- resolveSpreadsheetTokenExec (Execute only) resolves a /wiki/ node_token
  via wiki get_node, verifies obj_type=sheet, and returns the obj_token.
  The wiki:node:read scope is enforced on this path only, so non-wiki
  invocations are unaffected.
- resolveSpreadsheetToken stays network-free for Validate/DryRun, passing
  the node_token through unchanged.

All 47 Execute paths (including +batch-update and +workbook-export) switch
to the Exec resolver; Validate/DryRun keep the network-free one. No tool
schema change: the CLI feeds the resolved spreadsheet token as excel_id, so
this is a pure CLI-layer change.

Tested: unit (parse classification + wiki get_node e2e via httpmock) and
live end-to-end against a real wiki spreadsheet (read: +workbook-info,
+cells-get, +csv-get; write: +sheet-create, +sheet-rename, +csv-put).

* docs(sheets): note --url accepts wiki URLs (synced from spec)

* fix(sheets): match --url path segment via url.Parse, not substring

parseSpreadsheetRef classified /wiki/ with strings.Index over the whole URL, so a /sheets/ link whose query or fragment merely contained /wiki/ (e.g. .../sheets/sht?from=/wiki/x) was hijacked into a get_node call. Now parse the URL and match /sheets/, /spreadsheets/, /wiki/ only as a path prefix, mirroring slides parsePresentationRef which already fixed this class. Drop the substring helpers. Also align wiki resolution with slides: CallAPITyped (typed error + log_id) and classify an incomplete get_node response as InternalError instead of a --url validation error. Add regression tests for query/fragment /wiki/ and incomplete node.

* fix(sheets): satisfy errorlint/copyloopvar + regen flag defs

- helpers_test.go: drop the Go 1.22+ redundant `tc := tc` loop copy
  (copyloopvar).
- lark_sheet_dataframe.go, lark_sheet_table_io.go: switch the
  intermediate-error fmt.Errorf calls from %v to %w so errorlint passes.
  Behavior unchanged — these errors are always rewrapped into typed
  validation errors at the command layer.
- flag_defs_gen.go: regenerate from data/flag-defs.json (drift from the
  wiki-URL merge).

* ci: allow Apache Arrow module in license check

Arrow is Apache-2.0 overall, but it vendors c-ares (LicenseRef-C-Ares,
ISC-like) inside the module which go-licenses classifies as Unknown and
the strict disallowed_types=...,unknown gate rejects.

Pass --ignore github.com/apache/arrow/go/v17 since Arrow is required by
sheets +table-put / +table-get / +workbook-create --dataframe (Arrow IPC
ingest) and the vendored c-ares is not redistributed by us.

* fix(sheets): resolve wiki URL in +range-move/+range-copy Execute

transformExecuteFn (the named Execute helper shared by +range-move and +range-copy) still called the network-free resolveSpreadsheetToken, so a /wiki/ URL reached transform_range as an unresolved node_token and failed. #1519's sweep over Execute hooks only rewrote inline closures; this is the only Execute backed by a named helper. Switch it to resolveSpreadsheetTokenExec (Validate/DryRun stay network-free) and add a +range-move wiki-URL regression test.

* refactor(sheets): drop +table-put manual capacity grow; rely on set_cell_range auto-grow

set_cell_range now auto-grows the sub-sheet to fit the write, so the
ensureSheetCapacity helper (and its modify_sheet_structure dim-insert
call before each write) is no longer needed. This also closes a data-
safety hole flagged in review: inserting before the last existing row
could push real data down into the area set_cell_range was about to
write, and allow_overwrite=false could not protect against it because
the structural insert had already mutated the sheet by the time the
write-collision check ran.

Verified end-to-end against a real spreadsheet: +table-put writing
300x25 into a fresh Sheet1 (default 200x20) succeeds in one write and
the sheet ends up 301x25.

* fix(sheets): close --dataframe stdin guard hole

--dataframe is binary and bypasses the common Input resolver, which is
where the existing single-stdin guard lives. Result: an invocation like
+table-put --dataframe - --styles - was accepted, then one of the two
consumers raced for stdin and the other silently saw an empty stream.

Add a stdinConsumed marker on RuntimeContext that both consumers share:
common.resolveInputFlags sets it when an Input flag uses '-', and
readDataframeBytes both checks and sets it. A second consumer is
rejected up front with an actionable hint pointing at @file.

Flagged in code review (lark_sheet_dataframe.go:93).

* fix(sheets): harden +table-put / +table-get input validation and round-trip safety

Four review-flagged correctness gaps in table I/O, all bundled because
they touch the same file:

1. --sheets accepted trailing data after the first JSON value
   (json.Decoder does not surface that, unlike json.Unmarshal). A new
   decoderExpectEOF helper rejects e.g. `--sheets '{...} oops'` with a
   typed validation error instead of letting the leading object pass
   through and surface as a confusing downstream failure.

2. +table-get with a duplicate header (e.g. `amount, amount`) used to
   read back successfully — the dtypes map silently collapsed to one
   entry — and only failed later on +table-put because the writer
   rejects duplicate column names. Fail fast at read time with an
   actionable hint to rename or pass --no-header. --no-header mode is
   exempt (fallback col<N> names are always unique).

3. +table-put dry-run rendered an invalid range like A1:C0 when
   header=false with rows=[]. tablePutFullRange returns "" for an
   empty matrix or zero columns instead of building a degenerate
   rectangle.

4. +table-get with --sheet-id and a get_workbook_structure miss (read
   failure or selector mismatch) used to return a target with
   name="", which then broke +table-get → +table-put round-trip (the
   writer requires a non-empty sheet name). Fall back to using the id
   as the name.

End-to-end verified against a real spreadsheet: trailing data, duplicate
header, and --no-header fallback all behave as advertised.

* fix(sheets): apply +workbook-create style-only ops instead of silently dropping them

A +workbook-create call carrying only cell_merges / row_sizes / col_sizes
(no --values / --sheets and no cell_styles) used to create the workbook
but silently drop the requested visual ops. Two reasons, both fixed:

- workbookCreateStyleDimensions only counted cell_styles when computing
  the write extent, so cell_merges / row_sizes / col_sizes always
  contributed 0 → buildValuesPayload returned a nil payload → Execute
  skipped writeTypedSheets entirely → no visual ops ran. Extend the
  helper to fold the merge / resize ranges in.

- Pure row_sizes / col_sizes payloads can never expand a cell rectangle
  (they are dimension ranges, not cell ranges), so even with the extent
  fix Execute would still skip the write path. Add a no-data branch:
  when payload == nil but a styles item is present, look up the default
  sheet and apply visual ops directly via applyWorkbookCreateVisualOps.
  The dry-run plan mirrors this so the preview shows the visual ops.

Also picks up the --values trailing-JSON-data EOF check (mirror of the
--sheets one in lark_sheet_table_io.go).

End-to-end verified against a real spreadsheet: a cell_merges-only
+workbook-create now produces a sheet with merged_cells_count: 1.

* fix(sheets): preserve causes and render messages cleanly for typed validation errors

common.ValidationErrorf goes through fmt.Sprintf, which does not support
%w — the seven call sites that used `%w` were rendering the cause as
literal `%!w(*fmt.wrapError=&{...})` and dropping the cause from the
typed-error chain (so callers couldn't errors.As back to the underlying
error).

Switch each to `%v` for clean rendering and attach the cause via
.WithCause(err) so the typed contract is preserved. Touched call sites:

- lark_sheet_dataframe.go: --dataframe Arrow decode / stdin read / file
  read failures (3 call sites).
- lark_sheet_table_io.go: --sheets invalid JSON, payload-validate
  per-cell coercion error, buildSheetMatrix per-cell error,
  --dataframe-out arrow encode failure (4 call sites).

End-to-end verified against a real spreadsheet: both invalid-JSON and
typed-cell errors now render readable messages instead of %!w(...).

* sync(sheets): pick up +sheet-{show,hide}-gridline in +batch-update schema

Mirror of the sheet-skill-spec change adding the two gridline shortcuts
to cli-schemas.json batch_update.operations.shortcut enum. Synced from
the upstream canonical via generate:cli + sync:cli.

Verified end-to-end on a real spreadsheet — +batch-update with a
+sheet-hide-gridline op passes schema validation and the backend run
returns succeeded: 1.

* sync(sheets): pick up +workbook-export UX clarification from spec

Mirror of the sheet-skill-spec update that documents +workbook-export's
default-no-download behavior and its relationship to drive +export
--doc-type sheet. Synced from canonical via generate:cli + sync:cli +
go generate.

End-to-end verified against a real spreadsheet:
- Omit --output-path → ok:true, downloaded:false, file_token returned
- Pass --output-path ./crfix_test.xlsx → ok:true, file saved
  (17892 bytes), saved_path returned

The --help output for +workbook-export now states the default behavior
and points callers at `drive +export --doc-type sheet` when they need
the --output-dir / --file-name / --overwrite split.

* test(sheets): assert typed errs.Problem instead of err.Error() substrings

Per the coding guideline "Error-path tests must assert typed metadata via
errs.ProblemOf (category / subtype / param) and cause preservation, not
message substrings alone." — sweep through every error-path assertion in
the sheets domain and replace the
`strings.Contains(stdout+stderr+err.Error(), ...)` pattern with two
small helpers landed in helpers_test.go:

  requireProblem(t, err, wantCategory, wantSubtype, msgContains)
    -> *errs.Problem
  requireValidation(t, err, msgContains)
    -> *errs.ValidationError   // shorthand for CategoryValidation +
                               //   SubtypeInvalidArgument; lets callers
                               //   also assert .Param / .Params / .Cause

~60 assertion sites across 18 test files now check the typed envelope
shape, with message-substring checks moved onto the returned Problem
(.Message / .Hint / .Param). The substring is preserved as a sanity
check rather than the sole assertion, so a category drift like
validation → internal would now fail loudly instead of slipping past.

Cases intentionally left as substring (each with a one-line reason):
  - Errors that come straight from cobra's native flag parser (untyped
    *errors.errorString — e.g. "required flag(s) ... not set", mutually-
    exclusive groups). Re-typing these needs a custom FlagErrorFunc and
    is out of scope here.
  - Intermediate errors from decodeArrowToSheet that the caller wraps
    into a typed envelope (`//nolint:forbidigo` reason). Those unit
    tests assert the unwrapped intermediate directly.

One production tweak:
  - shortcuts/sheets/flag_schema.go: printFlagSchemaFor returns typed
    *errs.ValidationError (with WithParam("--flag-name") on the
    unknown-flag branch) instead of raw fmt.Errorf. The framework
    already wraps this when called via --print-schema, so user-facing
    behaviour is unchanged; direct callers (and tests) now get the
    typed envelope.

Verified: go test ./shortcuts/sheets/... passes; golangci-lint
--new-from-rev=origin/main reports 0 issues.

* test(common): assert typed errs.Problem instead of err.Error() substrings

Mirror of the sweep just landed in shortcuts/sheets: replace error-path
substring assertions with typed-envelope checks via two small helpers
landed in a new shortcuts/common/typed_error_assertions_test.go:

  requireProblem(t, err, wantCategory, wantSubtype, msgContains)
    -> *errs.Problem
  requireValidation(t, err, msgContains)
    -> *errs.ValidationError   // shorthand for CategoryValidation +
                               //   SubtypeInvalidArgument; lets callers
                               //   also assert .Param / .Params / .Cause

8 sites moved to typed assertions across runner_jq_test.go,
mcp_client_test.go, drive_media_upload_typed_test.go, and
runner_input_test.go (the input tests already used a typed-param helper;
this just retargets the substring follow-up onto the typed Message).

Sites intentionally left as substring + comment (production returns raw
fmt.Errorf, not a typed envelope):
  - runner_botinfo_test.go (6 sites): BotInfo / fetchBotInfo wrap upstream
    errors with fmt.Errorf so the SDK-level message ([99991], 403,
    invalid character, etc.) shows through.
  - runner_args_test.go (4 sites in 2 tests): rejectPositionalArgs returns
    raw fmt.Errorf to satisfy cobra's PositionalArgs contract.
  - permission_grant_test.go (2 sites): assert on stderr / hint strings,
    not error messages — already out of the err.Error() substring class.

No production code changes.

Verified: go test ./shortcuts/common/... passes;
golangci-lint --new-from-rev=origin/main ./shortcuts/common/... reports
0 issues.

* fix(sheets): plug four +table-put / +table-get correctness gaps flagged in CR

Four review-flagged bugs, all in lark_sheet_table_io.go (bundled because
they touch the same file and the same +table-put / +table-get domain):

1. +table-get --dry-run dropped the --sheet-id / --sheet-name selector
   from the get_cell_ranges body, while Execute always passed it. Agents
   that validate the dry-run shape and then run live would see a request
   shape mismatch. The dry-run now calls sheetSelectorForToolInput so
   the body matches Execute.

2. isDateNumberFormat used a simple `strings.ContainsRune(_, 'y')` so
   number formats like "JPY #,##0" (a currency prefix that happens to
   contain a lone 'Y') were misread as date formats — round-tripping
   integer cells out as ISO dates. The detector is now token-aware:
   it skips quoted "...", `\\x`-escaped, and `[...]` bracket sections,
   and only fires on an unescaped `yy` (a real Excel year token).

3. sheetCreateDims sized new append-mode sheets by `headerOn(s)` only,
   but writeSheetData forces a header on empty append sheets when
   Header == nil. Near 50000 rows / 200 cols this created the sheet one
   row short and the follow-up set_cell_range bounced off the backend
   ceiling. Size now matches the forced-header logic exactly.

4. tableGetTargets fallback paths (read-failure / selector mismatch on
   --sheet-id) returned a target with name="" — already corrected for
   --sheet-id structure-success path in 086876d2, but the structure-
   failure fallback still left it empty. Use the id as the name there
   too so the +table-get → +table-put round-trip never breaks on a
   nameless sheet.

End-to-end verified against a real spreadsheet:
- table-get --dry-run with --sheet-name / --sheet-id both render the
  selector field in the get_cell_ranges body
- A real round-trip (typed put → get) preserves dtypes + formats

* fix(sheets): bound --dataframe memory use with byte / row / column caps

readDataframeBytes used to read the whole Arrow file unbounded — a
stdin / file > 1 GiB would OOM the CLI long before the backend
per-sheet ceilings kicked in. decodeArrowToSheet then materialized
every record into [][]interface{} regardless of size.

Three caps now match the backend's per-sheet hard ceilings:
- byte cap: 256 MiB (covers worst-case 200×50000 cells × ~25 B Arrow
  overhead). File path pre-Stat()s before opening; both file and stdin
  paths read through io.LimitReader so an oversized input is rejected
  without allocating the full payload.
- column cap: 200, checked at schema-decode time before allocating any
  per-column slices.
- row cap: 50000, checked during record-batch iteration so a 1M-row
  Arrow file is rejected mid-stream instead of fully decoding first.

End-to-end verified against PPE — a 257 MiB file is rejected at file-
Stat with a typed validation error before any read happens.

* fix(drive): wrap +export ctx cancellation/deadline as typed errs.NetworkError

The poll loop in RunExport returned ctx.Err() directly in two places —
on the inter-attempt sleep cancel and on the pre-attempt deadline check.
That let context.Canceled / context.DeadlineExceeded escape as untyped
errors at the cobra layer, bypassing the typed-error contract every
other failure path already honors.

Add wrapExportContextErr that maps both into errs.NewNetworkError with
SubtypeNetworkTransport / SubtypeNetworkTimeout respectively and
preserves the cause via .WithCause(err), so callers can still
errors.Is(err, context.Canceled) downstream.

CR-flagged at drive_export.go:229 / :234.

* ci(license): narrow Apache Arrow workaround with a follow-up assertion

The dependency-license check still has to --ignore Apache Arrow wholesale
because go-licenses' classifier parses its LICENSE.txt as a single license
and mis-reports the module as LicenseRef-C-Ares / Unknown (Arrow inlines
the c-ares 3rdparty notice alongside its own Apache-2.0). Re-classifying
on our side isn't possible without changing go-licenses itself.

The CR concern was that --ignore is too wide — a future Arrow re-license
or new inlined dep would silently sail through. Add a follow-up step that
re-checks Arrow's LICENSE.txt independently: it must still open with
"Apache License" AND must still inline the c-ares 3rdparty notice (the
two facts that make the --ignore safe today). If either invariant breaks,
CI fails here and forces a human to re-evaluate the ignore.

Verified locally — both assertions pass against the current pinned
Arrow v17.

* sync(sheets): pick up +table-put payload-shape doc corrections from spec

Mirror of the sheet-skill-spec change that fixes three places teaching
an invalid +table-put payload shape — the typed protocol only has
columns / data / dtypes / formats (no formula field) and must always
be wrapped in an outer {"sheets":[...]} envelope. write-cells and the
SKILL.md decision table previously used the wrong field names (type /
format) and pointed users at +table-put for formula writes, which the
shortcut can't actually accept.

Synced from upstream canonical via generate:cli + sync:cli.

* test(sheets/e2e): add E2E coverage for new shortcuts + typed workbook-create

AGENTS.md requires a dry-run E2E for every new shortcut and a live E2E
for new flows. Three new files cover the four shortcuts this branch
adds or materially changes:

- sheets_gridline_dryrun_test.go — pins +sheet-show-gridline /
  +sheet-hide-gridline as a single modify_workbook_structure call with
  the right operation name (show_gridline / hide_gridline) and
  sheet_id, so an op-name typo would trip CI before any live run.

- sheets_workbook_import_dryrun_test.go — pins +workbook-import as a
  two-step plan (drive media upload + drive import-task create) with
  the doc type hard-coded to "sheet" — the wrapper's whole reason for
  existing on top of generic drive +import. --name reaches file_name
  on the wire; file_extension is sniffed from the local file.

- sheets_table_put_typed_workflow_test.go — two live workflows running
  against a freshly created spreadsheet. The first runs the full
  typed +table-put → +table-get round-trip (date / numeric / object
  columns with custom number_format) and asserts the dtype + format
  contract holds end-to-end. The second exercises the typed
  +workbook-create --sheets path: create + write in one shortcut, the
  payload sheet name adopts the workbook's default sheet (no empty
  "Sheet1" left behind), and the typed contract still survives the
  read-back.

End-to-end verified locally (user identity): typed put round-trips
preserve dtypes (date → datetime64[ns], numeric → float64, object →
object) + formats verbatim; workbook-create adopts the named sheet as
the first sheet with the same typed shape intact.

* sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec

Mirror of the sheet-skill-spec change that adds a DataFrame ↔ JSON
bridge as a skill-bundled Python script instead of inside the CLI
binary. Per PR #1355 review (docx NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, §4.2):
keep the CLI a thin JSON/REST client; pandas / Arrow editing lives in
the caller's Python process. Synced from canonical via generate:cli +
sync:cli.

- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
  one sheet, .parquet / .feather / .arrow / .csv / .json. Shells out to
  `+table-put` / `+table-get` over typed JSON — no CLI changes.
- SKILL.md decision tree + write-cells.md +table-put section: explicit
  pointers so pandas users land on the script instead of hand-rolling
  the `--sheets` payload.

End-to-end verified against PPE: 3-row DataFrame (datetime / float /
object) round-trips parquet → script put → real sheet → script get →
parquet with dtypes preserved.

* Revert "sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec"

This reverts commit 2964983b92.

* sync(sheets): pick up sheets_df.py + doc DRY cleanup from spec

Mirror of the sheet-skill-spec change that ships a 32-line helper-only
sheets_df.py (df_to_sheet + sheet_to_df) and removes the corresponding
inline `def` blocks from three reference docs.

- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
  one +table-put / +table-get sheet, importable as a library. Same
  helper pair the docs already taught, lifted out of the prose so
  callers can `from sheets_df import df_to_sheet, sheet_to_df`.
- lark-sheets-write-cells.md / lark-sheets-read-data.md /
  lark-sheets-workbook.md: drop the inline helper definitions; keep
  the usage examples (single/multi-sheet, round-trip) and switch them
  to import-from-script. workbook reference's +workbook-create
  --sheets section now points pandas users at the helper directly
  (was previously a textual reference back to write-cells).

End-to-end verified against PPE (--as user):
- +workbook-create with df_to_sheet for three sheets (income / balance
  / cashflow): create ok, dtypes (datetime64[ns] / float64) + formats
  (#,##0 / 0.0% / yyyy-mm-dd) survive on read-back through sheet_to_df.
- read → pandas mutate → write-back round-trip preserves both data
  and formats.

* chore: drop accidentally-committed __pycache__/ and gitignore .pyc

The previous commit (5fac9c39) shipped sheets_df.py and inadvertently
included its `__pycache__/sheets_df.cpython-312.pyc` — local Python
import created the bytecode cache during PPE round-trip verification and
`git add skills/lark-sheets/` swept it in.

Remove the pyc and add Python bytecode patterns to .gitignore so the
skill-bundled helper scripts don't pull cache files into future commits.

* refactor(sheets): drop --dataframe / --dataframe-out + apache/arrow dep

Per the design review at NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, the Arrow IPC binary
input/output channel adds a heavy columnar runtime to the CLI for no new
capability — the typed JSON --sheets path already covers everything, and
the column-major / zero-copy advantages collapse the moment the CLI re-
encodes into the row-oriented sheets OpenAPI JSON body. Removing it also
lets us drop the `--ignore github.com/apache/arrow/go/v17` license-check
escape hatch.

Deleted:
- shortcuts/sheets/lark_sheet_dataframe.go (+ test)
- --dataframe branches in +table-put / +workbook-create
- --dataframe-out branch in +table-get
- StdinConsumed / MarkStdinConsumed exported methods (the binary stdin
  reader was the only out-of-band consumer); internal stdinConsumed
  guard against duplicate `-` input flags stays
- apache/arrow/go/v17 + transitive deps via `go mod tidy`
- CI go-licenses --ignore for arrow and the LICENSE.txt assertion step
- --dataframe / --dataframe-out coverage in skill references

Pandas users keep the round-trip via the existing skill script
skills/lark-sheets/scripts/sheets_df.py over the JSON path.

The full pre-removal state is preserved on branch feat/sheets-arrow-stash.

Upstream sheet-skill-spec follow-up: the two flag rows in the canonical
spec + base table tblV2F6fqIjyCFQW must also be dropped so the next sync
does not re-add them.

* sync(sheets): pick up --sheets one-liner fix from spec

Mirrors sheet-skill-spec 5562f83. The +table-put / +workbook-create
--sheets flag descriptions (and the --print-schema description on the
sheets array) now point at the existing df_to_sheet helper instead of
the previous misleading one-liner that produced a dict missing the
outer {"sheets":[...]} envelope and the per-sheet `name`. Agents that
copy-paste the description verbatim now build a valid payload.

Auto-synced via spec's generate:cli + sync:consumers; go generate
./shortcuts/sheets/... regenerated flag_defs_gen.go so its embedded
flagDefs stays byte-equal to data/flag-defs.json.

* test(sheets/e2e): close E2E coverage gaps for newly added shortcuts

AGENTS.md requires both dry-run and live E2E for every newly registered
shortcut, and behavior-changing refactors need at least the matching
half. Three gaps remained on feat/lark-sheets-develop:

- +sheet-show-gridline / +sheet-hide-gridline (new): only dry-run E2E.
  Add sheets_gridline_workflow_test.go — create a real spreadsheet,
  toggle hide then show against a live sub-sheet, assert ok=true on
  both (gridline state is write-only — there is no read-back field on
  +sheet-info / +workbook-info — so a successful envelope is the
  meaningful signal; the dry-run E2E already pins the wire shape).

- +workbook-import (new): only dry-run E2E. Add
  sheets_workbook_import_workflow_test.go — write a local CSV, run
  the full upload → create-task → poll, assert ready=true with a
  sheet token, +info confirms the imported workbook is reachable,
  cleanup deletes the spreadsheet.

- +workbook-export refactor (no-download default changed): had live
  E2E but no dry-run E2E in tests/cli_e2e/. Add
  sheets_workbook_export_dryrun_test.go — pin the three sheet-
  specific differences vs drive +export: type=sheet hard-coded,
  csv mode routes --sheet-id onto sub_id (xlsx mode omits it), and
  --output-path maps onto the dry-run plan's top-level output_dir.
  Also pins the csv-without-sheet-id validation error.

* refactor(sheets): unify workbookCreatedButFillFailed with OutPartialFailure

Three "made it halfway and stopped" exits in the sheets domain previously
disagreed on shape, which made the post-failure recovery flow hard for
agents to predict from one command to another:

- +table-put partial write           → exit 1, stdout ok:false envelope
- +table-put zero-sheet write        → exit 1, stderr api/server_error
- +workbook-create create-but-fill   → exit 2, stderr validation/failed_precondition

OutPartialFailure exists exactly for "the side effect landed but the
follow-up didn't" — it stamps an ok:false result envelope on stdout
(carrying the state the caller needs to recover) and returns the bare
partial-failure exit signal. The workbook-create fill-failure path was
the odd one out: it surfaced as a typed failed_precondition error on
stderr, which agents couldn't tell apart from a plain validation refusal
even though the spreadsheet really did exist and a retry / cleanup was
possible.

Migrate workbookCreatedButFillFailed onto OutPartialFailure so the four
call sites in +workbook-create's Execute (sheet-resolve failure, initial
fill failure, style-only resolve failure, style-only apply failure) emit
the same envelope shape +table-put's partial write does:

  {
    "ok": false,
    "data": {
      "spreadsheet_token": "shtNEW",
      "reason": "spreadsheet shtNEW created but initial fill failed",
      "hint":   "the spreadsheet exists; retry the fill … or delete it",
      "cause":  {"category": "...", "subtype": "...", "message": "..."}
    }
  }

The inner failure's typed problem (category / subtype / message) is
flattened into the `cause` field so agents stay diagnosable from the JSON
envelope alone, instead of having to errors.Unwrap a Go error.

Updated TestExecute_WorkbookCreate_FillFailureKeepsToken to assert the
new shape (ok:false envelope on stdout, *output.PartialFailureError exit
signal, structured cause carrying the underlying invalid_response
subtype) — preserving the original test intent (token must survive for
recovery; inner cause must stay diagnosable) under the new contract.

* chore(sheets): three review nits — WithCause + stale comment + unexport

- shortcuts/sheets/flag_schema_validate.go:106 — composite-JSON shape
  validation was wrapping vErr's message into a typed sheets validation
  error without preserving vErr as the typed cause; add the missing
  .WithCause(vErr) so errors.Unwrap and ProblemOf still find the
  underlying validator error (matches every other typed-error chain
  helper in the file).

- shortcuts/sheets/lark_sheet_batch_update.go:92 — comment claimed
  batchUpdateInput returns "FlagErrorf-typed errors", but FlagErrorf no
  longer exists (the typed-error migration replaced it with
  common.ValidationErrorf / errs.ValidationError); update the comment
  to reflect what is actually returned.

- shortcuts/drive/drive_export.go:121 — drop the ValidateExport public
  alias and rename to validateExport. sheets +workbook-export reuses
  RunExport / PlanExportDryRun from this package but inlines its own
  (sheet-specific) Validate, so there is no cross-package call site —
  ValidateExport was a misleading sibling of the genuinely-shared
  ValidateImport. Comment added to record the asymmetry so future
  readers do not export it back.

* chore(deps): drop stale indirect bumps left by the arrow removal

The earlier --dataframe / --dataframe-out + apache/arrow/go/v17 removal
deleted the arrow consumer but left two indirect lines in go.mod pinned
to the versions arrow had pulled in:

  - github.com/kr/text                   v0.2.0
  - golang.org/x/exp  v0.0.0-20240222234643-814bf88cf225

With arrow gone, larksuite/cli was the only requirer of those exact
versions; every real consumer needs lower ones (kr/pretty wants
kr/text v0.1.0; charmbracelet/huh wants x/exp …20231006; xo/terminfo
wants x/exp …20220909). Removing the two indirect lines and running
`go mod tidy` lets MVS pick the real-consumer versions and drops the
explicit indirect entries entirely — go.mod net-diff against main is
now zero for this branch.

Verified locally: go build ./...; go test ./shortcuts/sheets/...
./shortcuts/drive/... ./shortcuts/common/... ./internal/auth/...
./cmd/auth/... — all green.

---------

Co-authored-by: zhengzhijie <zhengzhijie.j@bytedance.com>
Co-authored-by: Chenweifeng-bd <chenweifeng.1534@bytedance.com>
2026-06-25 10:48:13 +08:00
qingniaotonghua
d2452b7f9c fix: refine apps observability output 2026-06-25 00:11:16 +08:00
ZEden0
898e6d4b3b fix: prefix docs resource shortcuts (#1564) 2026-06-24 22:39:57 +08:00
zgz2048
7df37ed715 feat(base): Add Base URL and title resolve shortcuts (#1338)
* feat(base): add URL and title resolve shortcuts

* docs: clarify base coordinate resolution

* fix(base): address resolve shortcut ci

* fix(base): format resolved record share hint

* fix(base): simplify record share hint data

* fix(base): use field ids in resolved record data

* fix(base): guide record share resolve to update record

* fix(base): include record upsert example in resolve hint

* fix(base): reject add-record urls in resolver

* fix(base): validate title resolve query length

* fix(base): hide resolve alias flags from help

* fix(base): prefer title flag for title resolve

* docs(base): clarify token resolution wording
2026-06-24 22:26:29 +08:00
91-enjoy
3f9ace8af5 feat: support card action trigger (#1528)
Add support for card.action.trigger, the event fired when a user interacts with an
interactive card (button click, form submit, dropdown, checkbox, input, date picker, etc.).
The handler flattens the V2 envelope into a structured output and auto-fetches the original
card content (card_content) at consume time, enabling a complete read-then-update workflow
without extra API calls.
2026-06-24 22:00:08 +08:00
Zhang-986
b3514e5519 fix(binding): skip unix mode audit on windows (#1525) 2026-06-24 20:54:24 +08:00
ILUO
b46e60c156 feat: add task event consumer (#1510)
* feat: add task event consumer

* fix: address task event review feedback

* feat: remove legacy task event subscription shortcut

* test: strengthen task preconsume error assertions
2026-06-24 17:32:02 +08:00
qingniaotonghua
0552c5c595 fix: apps observability api upgrade 2026-06-24 16:34:41 +08:00
chenxingtong-bytedance
d71bab0061 docs(im): clarify audio message opus requirement (#1271)
Fix IM shortcut behavior for audio messages to match the Feishu/Lark file upload API: --audio is for voice messages and supports only Opus audio. Non-Opus local/URL inputs such as mp3 and wav are now rejected before upload with an actionable typed validation error. Users can still send those files as attachments with --file
2026-06-24 14:41:54 +08:00
qingniaotonghua
0f88409ab8 fix: map apps observability named series 2026-06-23 23:11:08 +08:00
qingniaotonghua
2cfe090c1d fix: align apps observability OpenAPI schema 2026-06-23 22:36:07 +08:00
qingniaotonghua
6ff02ea10c test: cover apps envvar delete dry-run 2026-06-23 21:29:59 +08:00
qingniaotonghua
46c99cb878 fix: add apps observability env hint 2026-06-23 21:24:16 +08:00
qingniaotonghua
8939bff9c5 docs: document apps observability envvar shortcuts 2026-06-23 21:08:18 +08:00
qingniaotonghua
736db1ce72 feat: add apps envvar shortcuts 2026-06-23 20:58:09 +08:00
liangshuo-1
d11a6e97a4 chore: release v1.0.57 (#1553) 2026-06-23 20:43:41 +08:00
qingniaotonghua
9b9ac8759e feat: add apps metric analytics shortcuts 2026-06-23 20:19:15 +08:00
raistlin042
e4248d1154 fix: harden lark-apps +init/+html-publish and skill guidance (#1517)
* fix: reject +init into a different app's project directory

* fix: reject single HTML files larger than 10MB in +html-publish

* docs: clarify publish visibility, domain routing, and role/permission boundary
2026-06-23 20:18:10 +08:00
qingniaotonghua
fdcd9f6dde feat: add apps trace observability shortcuts 2026-06-23 19:52:52 +08:00
fangshuyu-768
cb54bea00d docs(lark-doc): refine rich block, path, and block ID guidance (#1508) 2026-06-23 18:27:36 +08:00
hanshaoshuai
036e5799d3 fix(ci): bind semantic review to workflow run head 2026-06-23 18:21:29 +08:00
xukuncx
c4106f50b2 fix(mail): resolve folder/label filter once per +triage list call (#1512)
buildListParams used to re-call resolveFolderID / resolveFolderName (and the
label counterparts) on every list page to assemble folder_id / label_id.
Because resolveListFilter already resolves the filter once before the
pagination loop, the second pass hit the folders/labels list API again on
every page — 1 + page_count calls total, which easily trips rate limits.

buildListParams now only assembles API params from the already-resolved
FolderID / LabelID produced by resolveListFilter; it no longer resolves
names or aliases. The default folder_id=INBOX is still applied when no
explicit filter is present, and only overridden when the caller supplied a
canonical folder ID. The runtime / mailboxID / dryRun parameters are kept
for signature stability (resolveListFilter and buildSearchParams share the
same call shape).

Adds TestMailTriageCustomFolderResolvesOnceAcrossListPages: a custom-folder
filter forced across two messages-list pages, with a non-reusable folders
list stub so any second folders API call fails the test. Updated the two
existing buildListParams alias tests to run resolveListFilter first, mirroring
the real DryRun/Execute call order.

sprint: S1

Co-authored-by: xukuncx <283114605+xukuncx@users.noreply.github.com>
2026-06-23 18:05:08 +08:00
qingniaotonghua
e9fde3e8f7 feat: add apps log observability shortcuts 2026-06-23 17:38:49 +08:00
qingniaotonghua
8d061ea3bd feat: add apps observability helpers 2026-06-23 17:10:30 +08:00
liangshuo-1
736b131cdf fix(meta): backfill enum value descriptions from options (#1541) 2026-06-23 16:14:42 +08:00
arnold9672
5efaf65aec feat: surface search API notices (#1413)
* feat: surface search API notices

sa: safe
doc: none
cfg: none
test: unit test

* fix: surface search notices in default output

* docs: add search notice doc comments

* docs: expand search notice doc comments
2026-06-23 14:27:04 +08:00
linchao5102
0991da7446 fix: add missing CLI headers for git credential helper (#1539) 2026-06-23 14:25:26 +08:00
zgz2048
80bea45c6a feat: support base record comments (#1043)
* feat: support base record comments

* fix: tighten base comment validation

* fix: validate wiki base comment flags
2026-06-23 11:20:07 +08:00
bubbmon233
c775cb4360 docs(mail): trim lark-mail skill context (#1527) 2026-06-22 21:32:31 +08:00
487 changed files with 45802 additions and 9239 deletions

View File

@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
permissions:
@@ -70,6 +71,7 @@ jobs:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
@@ -87,6 +89,23 @@ jobs:
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
script-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Run script tests
run: make script-test
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
@@ -109,8 +128,28 @@ jobs:
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Write public content metadata
if: ${{ github.event_name == 'pull_request' }}
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BRANCH: ${{ github.head_ref }}
run: |
mkdir -p .tmp/quality-gate
python3 - <<'PY'
import json
import os
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
json.dump({
"title": os.environ.get("PR_TITLE", ""),
"body": os.environ.get("PR_BODY", ""),
"branch": os.environ.get("PR_BRANCH", ""),
}, f)
f.write("\n")
PY
- name: Run CLI deterministic gate
run: make quality-gate
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
@@ -220,7 +259,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -241,7 +280,7 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, deterministic-gate]
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
permissions:
@@ -333,7 +372,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -345,6 +384,7 @@ jobs:
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -361,6 +401,7 @@ jobs:
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.script-test.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \

28
.github/workflows/comment-audit.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Comment Audit
on:
issue_comment:
types: [created, edited]
pull_request_review:
types: [submitted, edited]
pull_request_review_comment:
types: [created, edited]
permissions:
contents: read
jobs:
public-content-comment-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Post-publication comment audit
run: |
mkdir -p .tmp/comment-audit
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"

View File

@@ -9,11 +9,7 @@ permissions:
contents: read
jobs:
# All platforms (incl. darwin keychain_signer) are CGO-free and cross-compiled
# on a single ubuntu runner in one goreleaser run (one checksums.txt). The
# darwin signer's runtime FFI is validated separately by the signer-test job.
goreleaser:
needs: signer-test-macos
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -38,21 +34,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Validate the macOS keychain signer on real hardware. The release binaries are
# cross-compiled on ubuntu (CGO-free purego FFI), so this is the only step that
# needs a Mac — and it gates the release rather than producing it.
signer-test-macos:
runs-on: macos-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- name: Keychain signer round-trip (CGO-free purego FFI)
run: LARK_KEYCHAIN_IT=1 CGO_ENABLED=0 go test -tags keychain_signer -run Keychain -v ./internal/keysigner/
publish-npm:
needs: goreleaser
runs-on: ubuntu-22.04

View File

@@ -47,10 +47,13 @@ jobs:
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
@@ -71,11 +74,11 @@ jobs:
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
@@ -85,31 +88,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -118,12 +134,17 @@ jobs:
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
@@ -255,10 +276,13 @@ jobs:
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
@@ -279,11 +303,11 @@ jobs:
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
@@ -293,31 +317,44 @@ jobs:
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
@@ -326,12 +363,22 @@ jobs:
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (!pr.head.repo) {
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR base");
@@ -383,6 +430,10 @@ jobs:
repo: context.repo.repo,
pull_number: pr,
});
if (pull.state !== "open") {
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
return;
}
if (pull.head.sha !== headSha) {
core.notice("semantic review skipped infrastructure failure check: PR head changed");
return;

6
.gitignore vendored
View File

@@ -7,6 +7,11 @@ bin/
# Node
node_modules/
# Python (skill-bundled helper scripts)
__pycache__/
*.py[cod]
*$py.class
# OS
.DS_Store
@@ -46,3 +51,4 @@ app.log
cover*.out
lark-env.sh
/automations/

View File

@@ -5,53 +5,15 @@ before:
- python3 scripts/fetch_meta.py
builds:
# Linux & Windows: pure-Go TPM 2.0 signer is compiled in by default (no build
# tag), cross-compiled with CGO disabled — the binaries ship the platform key
# signer for private_key_jwt. windows/arm64 is the one exception: the sks
# Windows dependency stack (go-ole) has no arm64 support, so the signer file is
# arch-excluded there and that binary falls back to client_secret only.
- id: linux
binary: lark-cli
main: .
- binary: lark-cli
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- linux
goarch:
- amd64
- arm64
- id: windows
binary: lark-cli
main: .
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- windows
goarch:
- amd64
- arm64
# macOS: the keychain signer calls Security.framework via runtime FFI (purego),
# so it is CGO-free, compiled into every darwin build (no build tag), and
# cross-compiles from the same ubuntu runner as linux/windows.
- id: darwin
binary: lark-cli
main: .
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
@@ -61,7 +23,7 @@ archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
format: zip
files:
- README.md
- LICENSE

View File

@@ -2,6 +2,59 @@
All notable changes to this project will be documented in this file.
## [v1.0.58] - 2026-06-25
### Features
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
- **base**: Add Base URL and title resolve shortcuts (#1338)
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
- **doc**: Support `create` title option (#1536)
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
- **task**: Add task event consumer (#1510)
### Bug Fixes
- **doc**: Prefix docs resource shortcuts (#1564)
- **binding**: Skip unix mode audit on Windows (#1525)
### Documentation
- **approval**: Sync approval skill for meta API commands (#1499)
- **doc**: Restore lark-doc style requirements (#1579)
- **im**: Document `chat.nickname` get/update/delete (#1378)
- **im**: Clarify audio message opus requirement (#1271)
### Build
- **ci**: Add public content safeguards and reduce false positives
## [v1.0.57] - 2026-06-23
### Features
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
- **base**: Support record comments (#1043)
- **search**: Surface search API notices (#1413)
### Bug Fixes
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
- **meta**: Backfill enum value descriptions from options (#1541)
- **cli**: Add missing CLI headers for git credential helper (#1539)
### Documentation
- **doc**: Refine rich block, path, and block ID guidance (#1508)
- **mail**: Trim lark-mail skill context (#1527)
- **drive**: Add permission governance workflow guidance (#1292)
### Build
- **ci**: Bind semantic review to workflow run head (#1551)
## [v1.0.56] - 2026-06-18
### Features
@@ -1212,6 +1265,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54

View File

@@ -12,6 +12,7 @@ QUALITY_GATE_DIR ?= .tmp/quality-gate
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
@@ -33,11 +34,7 @@ build: fetch_meta
go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) .
vet: fetch_meta
# -unsafeptr=false: the macOS keychain signer dereferences dylib data-symbol
# addresses from purego.Dlsym (uintptr->unsafe.Pointer over stable C memory) —
# safe FFI, but go vet's unsafeptr can't prove it and has no inline suppress.
# golangci-lint still runs full govet (honoring the //nolint:govet) in CI.
go vet -unsafeptr=false ./...
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.
@@ -73,7 +70,8 @@ integration-test: build
test: vet fmt-check script-test unit-test examples-build integration-test
quality-gate: build
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
@@ -93,6 +91,7 @@ quality-gate: build
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
install: build

View File

@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
```
Run `lark-cli <service> --help` to see all shortcut commands.

View File

@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
```
运行 `lark-cli <service> --help` 查看所有快捷命令。

View File

@@ -265,7 +265,7 @@ func authLoginRun(opts *LoginOptions) error {
if err != nil {
return err
}
authResp, err := larkauth.RequestDeviceAuthorization(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand, finalScope, f.IOStreams.ErrOut)
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
if err != nil {
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
}
@@ -325,7 +325,7 @@ func authLoginRun(opts *LoginOptions) error {
// Step 3: Poll for token
log(msg.WaitingAuth)
result := pollDeviceToken(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if !result.OK {
@@ -415,7 +415,7 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)
result := pollDeviceToken(opts.Ctx, httpClient, larkauth.ClientAuthFromConfig(config), config.Brand,
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
opts.DeviceCode, 5, 600, f.IOStreams.ErrOut)
if !result.OK {

View File

@@ -847,7 +847,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
}
@@ -886,7 +886,7 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
original := pollDeviceToken
t.Cleanup(func() { pollDeviceToken = original })
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, ca larkauth.ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
}

View File

@@ -193,7 +193,7 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
t.Fatalf("seed config: %v", err)
}
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, "", "", nil); err != nil {
if err := saveInitConfig("", existing, f, "cli_x", core.PlainSecret("s2"), core.BrandFeishu, ""); err != nil {
t.Fatalf("saveInitConfig (no --lang): %v", err)
}
@@ -206,88 +206,6 @@ func TestSaveInitConfig_OmitLangPreservesPrior(t *testing.T) {
}
}
func TestKeyRefFromResult_PrivateKeyJWT(t *testing.T) {
ref := keyRefFromResult(&configInitResult{
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "lark-cli-default",
})
if ref == nil {
t.Fatal("keyRefFromResult returned nil")
}
if ref.Source != "tee" || ref.ID != "lark-cli-default" {
t.Fatalf("key ref = %#v, want tee/lark-cli-default", ref)
}
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodPrivateKeyJWT}); ref != nil {
t.Fatalf("missing key label should not persist key ref, got %#v", ref)
}
if ref := keyRefFromResult(&configInitResult{AuthMethod: core.AuthMethodClientSecret, KeyLabel: "ignored"}); ref != nil {
t.Fatalf("client_secret should not persist key ref, got %#v", ref)
}
if ref := keyRefFromResult(nil); ref != nil {
t.Fatalf("nil result should not persist key ref, got %#v", ref)
}
}
func TestSaveInitConfig_PrivateKeyJWTSingleAppPersistsSecretlessAuth(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
if err := saveInitConfig("", nil, f, "cli_pkjwt", core.SecretInput{}, core.BrandFeishu, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
t.Fatalf("saveInitConfig private_key_jwt single app: %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
if len(got.Apps) != 1 {
t.Fatalf("apps len = %d, want 1", len(got.Apps))
}
app := got.Apps[0]
if app.AppId != "cli_pkjwt" {
t.Fatalf("AppId = %q, want cli_pkjwt", app.AppId)
}
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
}
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
}
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
t.Fatalf("private_key_jwt config must stay secretless, AppSecret=%#v", app.AppSecret)
}
}
func TestSaveInitConfig_PrivateKeyJWTProfilePersistsSecretlessAuth(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
if err := saveInitConfig("prod", &core.MultiAppConfig{}, f, "cli_pkjwt", core.SecretInput{}, core.BrandLark, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
t.Fatalf("saveInitConfig private_key_jwt profile: %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
app := got.FindApp("prod")
if app == nil {
t.Fatalf("profile prod not saved: %#v", got.Apps)
}
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
}
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
}
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
t.Fatalf("private_key_jwt profile must stay secretless, AppSecret=%#v", app.AppSecret)
}
}
// TestConfigInitCmd_InvalidLang verifies a non-empty --lang on config init is
// strictly validated the same way bind validates: wrong-case / typo / removed
// codes / hyphen form all exit with ExitValidation. (Empty is a no-op.)
@@ -470,7 +388,7 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
},
}
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en", "", nil)
err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "cli_prod", "app-new", core.PlainSecret("new-secret"), core.BrandLark, "en")
if err == nil {
t.Fatal("expected conflict error")
}
@@ -509,46 +427,6 @@ func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
}
}
func TestSaveAsProfile_UpdatePersistsPrivateKeyJWT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
existing := &core.MultiAppConfig{
Apps: []core.AppConfig{{
Name: "prod",
AppId: "cli_prod",
AppSecret: core.PlainSecret("old-secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "User"}},
}},
}
keyRef := &core.SecretRef{Source: "tee", ID: "lark-cli-default"}
if err := saveAsProfile(existing, keychain.KeychainAccess(&noopConfigKeychain{}), "prod", "cli_prod", core.SecretInput{}, core.BrandLark, "en_us", core.AuthMethodPrivateKeyJWT, keyRef); err != nil {
t.Fatalf("saveAsProfile update private_key_jwt: %v", err)
}
got, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig: %v", err)
}
app := got.FindApp("prod")
if app == nil {
t.Fatalf("profile prod not saved: %#v", got.Apps)
}
if app.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Fatalf("AuthMethod = %q, want private_key_jwt", app.AuthMethod)
}
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID != "lark-cli-default" {
t.Fatalf("KeyRef = %#v, want tee/lark-cli-default", app.KeyRef)
}
if app.AppSecret.Ref != nil || app.AppSecret.Plain != "" {
t.Fatalf("private_key_jwt update must stay secretless, AppSecret=%#v", app.AppSecret)
}
if len(app.Users) != 1 || app.Users[0].UserOpenId != "ou_1" {
t.Fatalf("same-app update should preserve users, Users=%#v", app.Users)
}
}
func TestUpdateExistingProfileWithoutSecret_RejectsAppIDChange(t *testing.T) {
multi := &core.MultiAppConfig{
CurrentApp: "prod",

View File

@@ -19,7 +19,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
)
@@ -32,7 +31,6 @@ type ConfigInitOptions struct {
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
AuthMethod string // --auth-method for --new: "" (default client_secret) | private_key_jwt
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
@@ -41,8 +39,6 @@ type ConfigInitOptions struct {
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
Restore bool // Restore re-registers the app already in config to recover a lost credential
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
// at config bind — which is what AI agents almost always want. Manual
@@ -85,13 +81,11 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
}
cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
cmd.Flags().StringVar(&opts.AuthMethod, "auth-method", "", "auth method for --new: client_secret (default) or private_key_jwt (signed by a platform key, no app secret)")
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.Restore, "restore", false, "re-register the app already in config to recover a lost credential (keychain key / app secret); reuses the stored app ID and auth method")
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")
@@ -138,7 +132,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
return o.New || o.Restore || o.AppID != "" || o.AppSecretStdin
return o.New || o.AppID != "" || o.AppSecretStdin
}
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
@@ -157,44 +151,11 @@ func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipApp
}
}
// removeStaleSecretForPKJWT clears a secret left in the keychain when the SAME
// appId is migrated from client_secret to private_key_jwt. cleanupOldConfig
// explicitly skips a matching appId, and saveAsProfile only cleans up on an
// appId change, so a same-appId migration would orphan the old secret. This
// fills that gap. RemoveSecretStore only deletes Source=="keychain" entries, so
// the new pkjwt tee key handle is never touched.
func removeStaleSecretForPKJWT(existing *core.MultiAppConfig, profileName, appID string, kc keychain.KeychainAccess) {
if existing == nil {
return
}
var prior *core.AppConfig
if profileName != "" {
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
prior = &existing.Apps[idx]
}
} else {
prior = existing.CurrentAppConfig("")
}
if prior != nil && prior.AppId == appID && !prior.AppSecret.IsZero() {
core.RemoveSecretStore(prior.AppSecret, kc)
}
}
// keyRefFromResult builds the TEE key reference to persist for a private_key_jwt
// registration result, or nil for client_secret.
func keyRefFromResult(r *configInitResult) *core.SecretRef {
if r != nil && r.AuthMethod == core.AuthMethodPrivateKeyJWT && r.KeyLabel != "" {
return &core.SecretRef{Source: "tee", ID: r.KeyLabel}
}
return nil
}
// saveAsOnlyApp overwrites config.json with a single-app config.
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: i18n.Lang(lang), Users: []core.AppUser{},
AuthMethod: authMethod, KeyRef: keyRef,
}},
}
return core.SaveMultiAppConfig(config)
@@ -203,11 +164,9 @@ func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand,
// saveInitConfig saves a new/updated app config, respecting --profile mode.
// With profileName: appends or updates the named profile (preserves other profiles).
// Without profileName: cleans up old config and saves as the only app.
// authMethod/keyRef carry the credential type: ("", nil) for client_secret,
// (private_key_jwt, &{tee,label}) for the secretless TEE flow.
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
if profileName != "" {
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang, authMethod, keyRef)
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
}
cleanupOldConfig(existing, f, appId)
var prior i18n.Lang
@@ -216,7 +175,7 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
prior = app.Lang
}
}
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)), authMethod, keyRef)
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
@@ -236,7 +195,7 @@ func wrapSaveConfigError(err error) error {
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang, authMethod string, keyRef *core.SecretRef) error {
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
multi := existing
if multi == nil {
multi = &core.MultiAppConfig{}
@@ -255,8 +214,6 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
multi.Apps[idx].AppSecret = secret
multi.Apps[idx].Brand = brand
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
multi.Apps[idx].AuthMethod = authMethod
multi.Apps[idx].KeyRef = keyRef
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
@@ -265,14 +222,12 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
Name: profileName,
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
AuthMethod: authMethod,
KeyRef: keyRef,
Name: profileName,
AppId: appId,
AppSecret: secret,
Brand: brand,
Lang: i18n.Lang(lang),
Users: []core.AppUser{},
})
}
return core.SaveMultiAppConfig(multi)
@@ -350,94 +305,6 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
return core.SaveMultiAppConfig(existing)
}
// persistAndProbeResult saves a registration/restore result into profileName and
// runs the post-registration probe. profileName == "" replaces the single app
// (legacy); a named profile is updated in place. Shared by --new and --restore.
func persistAndProbeResult(opts *ConfigInitOptions, f *cmdutil.Factory, profileName string, result *configInitResult) error {
existing, _ := core.LoadMultiAppConfig()
// private_key_jwt apps have no secret: persist auth method + TEE key ref.
// Registration success already validated the key (server bound the public
// key), so the app_secret probe is skipped.
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
if err := saveInitConfig(profileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
return wrapSaveConfigError(err)
}
removeStaleSecretForPKJWT(existing, profileName, result.AppID, f.Keychain)
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "authMethod": result.AuthMethod, "brand": result.Brand})
return runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel)
}
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(profileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
return wrapSaveConfigError(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
return runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand)
}
// runRestoreFlow re-registers the app already in config to recover a lost
// credential (deleted keychain key / lost app secret). It reads the existing
// app id + auth method + brand from config (no secret needed — that's the lost
// part) and re-runs the device-flow registration with the app id sent on begin,
// so the server re-registers that app instead of creating a new one. The
// re-issued credential is written back to the same profile.
func runRestoreFlow(opts *ConfigInitOptions, existing *core.MultiAppConfig, f *cmdutil.Factory, msg *initMsg) error {
if existing == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no config found").
WithHint("run: lark-cli config init")
}
app := existing.CurrentAppConfig(opts.ProfileName)
if app == nil || app.AppId == "" {
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no app id in config%s", profileSuffix(opts.ProfileName)).
WithHint("run: lark-cli config init")
}
restoreAppID := app.AppId
// Reuse the stored auth method authoritatively — never prompt. Empty on disk
// means client_secret (omitempty back-compat); pass it explicitly so
// resolveRegisterAuthMethod doesn't fall through to the interactive picker.
authMethod := app.AuthMethod
if authMethod == "" {
authMethod = core.AuthMethodClientSecret
}
result, err := runCreateAppFlow(opts.Ctx, f, app.Brand, authMethod, msg, restoreAppID)
if err != nil {
return err
}
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app restore returned no result")
}
// Safety: if the server did not honor app_id (e.g. not yet supported), it may
// have created a NEW app instead of restoring. Warn so the user is not silently
// switched to a different app id.
if result.AppID != restoreAppID {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] restore: server returned app %s, expected %s — it may have created a new app instead of restoring\n", result.AppID, restoreAppID)
}
// Write back to the profile we restored: an explicit --name, else the resolved
// app's own name. Empty name => legacy single-app replace.
saveProfile := opts.ProfileName
if saveProfile == "" {
saveProfile = app.Name
}
return persistAndProbeResult(opts, f, saveProfile, result)
}
// profileSuffix renders " (profile %q)" for error messages, or "" when unnamed.
func profileSuffix(profileName string) string {
if profileName == "" {
return ""
}
return fmt.Sprintf(" (profile %q)", profileName)
}
func configInitRun(opts *ConfigInitOptions) error {
f := opts.Factory
@@ -468,17 +335,6 @@ func configInitRun(opts *ConfigInitOptions) error {
}
}
// --restore recovers an existing app; it is incompatible with creating a new
// app (--new) or importing one non-interactively (--app-id / stdin secret).
if opts.Restore {
if opts.New {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --new").WithParam("--restore")
}
if opts.AppID != "" || opts.AppSecretStdin {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --app-id / --app-secret-stdin").WithParam("--restore")
}
}
// Mode 1: Non-interactive
if opts.AppID != "" && opts.appSecret != "" {
brand := parseBrand(opts.Brand)
@@ -486,7 +342,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang, "", nil); err != nil {
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
@@ -512,26 +368,34 @@ func configInitRun(opts *ConfigInitOptions) error {
msg := getInitMsg(opts.UILang)
// Mode: Restore (--restore) — re-register the app already in config.
if opts.Restore {
return runRestoreFlow(opts, existing, f, msg)
}
// Mode 3: Create new app directly (--new)
if opts.New {
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), opts.AuthMethod, msg, "")
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
if err != nil {
return err
}
if result == nil {
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
}
return persistAndProbeResult(opts, f, opts.ProfileName, result)
existing, _ := core.LoadMultiAppConfig()
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
return nil
}
// Mode 4: Interactive TUI (terminal)
if !opts.hasAnyNonInteractiveFlag() && f.IOStreams.IsTerminal {
result, err := runInteractiveConfigInit(opts.Ctx, f, opts.AuthMethod, msg)
result, err := runInteractiveConfigInit(opts.Ctx, f, msg)
if err != nil {
return err
}
@@ -542,22 +406,13 @@ func configInitRun(opts *ConfigInitOptions) error {
existing, _ := core.LoadMultiAppConfig()
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
// Secretless create: persist auth method + TEE key ref, no secret.
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
return wrapSaveConfigError(err)
}
removeStaleSecretForPKJWT(existing, opts.ProfileName, result.AppID, f.Keychain)
if err := runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel); err != nil {
return err
}
} else if result.AppSecret != "" {
if result.AppSecret != "" {
// New secret provided (either from "create" or "existing" with input)
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
} else if result.Mode == "existing" && result.AppID != "" {
@@ -662,7 +517,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang, "", nil); err != nil {
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))

View File

@@ -1,102 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"crypto"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
type authMethodTestSigner struct{}
func (authMethodTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return nil, nil
}
func (authMethodTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return nil, nil
}
func (authMethodTestSigner) Sign(context.Context, keysigner.KeyRef, []byte) ([]byte, string, error) {
return nil, "", nil
}
// TestResolveRegisterAuthMethod covers the non-interactive gating paths. The
// darwin keychain signer is compiled into every build, so the test cannot rely
// on the binary lacking a signer — it forces a known no-signer state for the
// rejection cases, then registers a stub for the success case.
func TestResolveRegisterAuthMethod(t *testing.T) {
f := &cmdutil.Factory{}
prevSigner := keysigner.Active()
t.Cleanup(func() { keysigner.Register(prevSigner) })
keysigner.Register(nil)
if m, err := resolveRegisterAuthMethod(f, core.AuthMethodClientSecret); err != nil || m != core.AuthMethodClientSecret {
t.Errorf("client_secret: got (%q, %v), want (client_secret, nil)", m, err)
}
if m, err := resolveRegisterAuthMethod(f, ""); err != nil || m != core.AuthMethodClientSecret {
t.Errorf("default: got (%q, %v), want (client_secret, nil)", m, err)
}
if _, err := resolveRegisterAuthMethod(f, "bogus"); err == nil {
t.Error("bogus auth-method: expected error")
}
if _, err := resolveRegisterAuthMethod(f, core.AuthMethodPrivateKeyJWT); err == nil {
t.Error("private_key_jwt without a signer: expected error")
}
keysigner.Register(authMethodTestSigner{})
if m, err := resolveRegisterAuthMethod(f, core.AuthMethodPrivateKeyJWT); err != nil || m != core.AuthMethodPrivateKeyJWT {
t.Errorf("private_key_jwt with signer: got (%q, %v), want (private_key_jwt, nil)", m, err)
}
}
// TestValidatePKJWTKeyBinding covers the guard that rejects a registration
// resolving to private_key_jwt with no signing key bound (e.g. an existing
// secret-based app was selected on the confirm page).
func TestValidatePKJWTKeyBinding(t *testing.T) {
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, ""); err == nil {
t.Error("pkjwt with empty keyLabel: expected error")
}
if err := validatePKJWTKeyBinding(core.AuthMethodPrivateKeyJWT, "agent-key"); err != nil {
t.Errorf("pkjwt with keyLabel: expected nil, got %v", err)
}
if err := validatePKJWTKeyBinding(core.AuthMethodClientSecret, ""); err != nil {
t.Errorf("client_secret: expected nil, got %v", err)
}
}
// TestResolveFinalAuthMethod locks the authoritative-method logic. The 2nd case
// is the real bug: we requested private_key_jwt but the server resolved to an
// existing client_secret app — we must persist client_secret, not pkjwt.
func TestResolveFinalAuthMethod(t *testing.T) {
if m := resolveFinalAuthMethod([]string{"client_secret", "private_key_jwt"}, core.AuthMethodClientSecret); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("prefers private_key_jwt: got %q", m)
}
if m := resolveFinalAuthMethod([]string{"client_secret"}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodClientSecret {
t.Errorf("server client_secret must override requested pkjwt: got %q", m)
}
if m := resolveFinalAuthMethod(nil, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("fallback to requested when server is silent: got %q", m)
}
// Explicit empty slice (not just nil) also falls back to requested — the same
// len()==0 back-compat allowance the init guard relies on to let private_key_jwt
// proceed against an older server (see internal/auth
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods).
if m := resolveFinalAuthMethod([]string{}, core.AuthMethodPrivateKeyJWT); m != core.AuthMethodPrivateKeyJWT {
t.Errorf("empty []string should fall back to requested private_key_jwt: got %q", m)
}
if m := resolveFinalAuthMethod(nil, ""); m != core.AuthMethodClientSecret {
t.Errorf("default to client_secret: got %q", m)
}
}

View File

@@ -5,11 +5,7 @@ package config
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/build"
@@ -17,26 +13,22 @@ import (
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
)
// configInitResult holds the result of the interactive config init flow.
type configInitResult struct {
Mode string // "create" or "existing"
Brand core.LarkBrand
AppID string
AppSecret string
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
KeyLabel string // TEE key handle when AuthMethod == private_key_jwt
Mode string // "create" or "existing"
Brand core.LarkBrand
AppID string
AppSecret string
}
// runInteractiveConfigInit shows an interactive TUI for config init.
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMethodFlag string, msg *initMsg) (*configInitResult, error) {
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
// Phase 1: Choose mode
var mode string
form1 := huh.NewForm(
@@ -62,7 +54,7 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMetho
return runExistingAppForm(f, msg)
}
return runCreateAppFlow(ctx, f, "", authMethodFlag, msg, "")
return runCreateAppFlow(ctx, f, "", msg)
}
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
@@ -154,59 +146,9 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
}, nil
}
// resolveRegisterAuthMethod decides the auth method for a new-app registration.
// An explicit --auth-method flag wins; otherwise, on an interactive terminal with
// a TEE signer available, the user is prompted; the default is client_secret.
func resolveRegisterAuthMethod(f *cmdutil.Factory, flag string) (string, error) {
signerAvailable := keysigner.Active() != nil
switch flag {
case core.AuthMethodPrivateKeyJWT:
if !signerAvailable {
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
"--auth-method private_key_jwt requires a platform key signer, which is unavailable on this device/build").
WithHint("omit --auth-method (or pass --auth-method client_secret) to register with an app secret")
}
return core.AuthMethodPrivateKeyJWT, nil
case core.AuthMethodClientSecret:
return core.AuthMethodClientSecret, nil
case "":
// fall through to interactive / default
default:
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown --auth-method %q (use client_secret or private_key_jwt)", flag)
}
if signerAvailable && f.IOStreams.IsTerminal {
var choice string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Authentication method").
Options(
huh.NewOption("App Secret (client_secret)", core.AuthMethodClientSecret),
huh.NewOption("Secure key signer, no secret (private_key_jwt)", core.AuthMethodPrivateKeyJWT),
).
Value(&choice),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return "", output.ErrBare(1)
}
return "", err
}
return choice, nil
}
return core.AuthMethodClientSecret, nil
}
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
// If brandOverride is non-empty, skip the interactive brand selection.
// authMethodFlag is the raw --auth-method value ("" when unset).
// restoreAppID, when non-empty, is sent on the registration begin request so the
// server re-registers that existing app (credential recovery) instead of creating
// a new one. Empty preserves the normal new-app flow.
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, authMethodFlag string, msg *initMsg, restoreAppID string) (*configInitResult, error) {
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
var larkBrand core.LarkBrand
if brandOverride != "" {
larkBrand = brandOverride
@@ -234,51 +176,11 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
larkBrand = parseBrand(brand)
}
authMethod, err := resolveRegisterAuthMethod(f, authMethodFlag)
if err != nil {
return nil, err
}
// Step 1: Request app registration (begin).
// Step 1: Request app registration (begin)
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := transport.NewHTTPClient(0)
// For private_key_jwt: init to obtain a nonce, then sign a TEE attestation
// (carrying the public key in its jwk header) to send with begin.
beginOpts := larkauth.AppRegistrationBeginOptions{}
keyLabel := ""
if authMethod == core.AuthMethodPrivateKeyJWT {
signer := keysigner.Active() // non-nil, guaranteed by resolveRegisterAuthMethod
initResp, initErr := larkauth.RequestAppRegistrationInit(httpClient)
if initErr != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration init failed: %v", initErr).WithCause(initErr)
}
// An empty SupportedAuthMethods is intentionally treated as "older server /
// unknown": len()==0 makes this guard false, so the requested
// private_key_jwt proceeds. This mirrors resolveFinalAuthMethod's
// back-compat fallback to the requested method. Only an explicit list that
// omits private_key_jwt rejects here.
if len(initResp.SupportedAuthMethods) > 0 && !slices.Contains(initResp.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT) {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient,
"server does not support private_key_jwt for this app type (supported: %s)", strings.Join(initResp.SupportedAuthMethods, ", ")).
WithHint("register with --auth-method client_secret instead")
}
keyLabel = keysigner.DefaultKeyLabel
attestation, signErr := jwt.SignAttestation(ctx, signer, keysigner.KeyRef{Label: keyLabel}, initResp.Nonce, time.Now())
if signErr != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to sign registration attestation: %v", signErr).WithCause(signErr)
}
beginOpts = larkauth.AppRegistrationBeginOptions{
AuthMethod: core.AuthMethodPrivateKeyJWT,
AuthAttestation: attestation,
}
}
// Restore flow: re-register the existing app instead of creating a new one.
beginOpts.RestoreAppID = restoreAppID
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, beginOpts, f.IOStreams.ErrOut)
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
}
@@ -311,28 +213,18 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
}
// The final auth method is decided by the user/admin at confirmation and
// returned by poll — NOT necessarily what we requested. Selecting an existing
// client_secret app, for example, yields client_secret even though we sent
// private_key_jwt. Trust the result so we persist the truth.
finalMethod := resolveFinalAuthMethod(result.AuthMethods, authMethod)
// Lark brand special case (client_secret only): a lark-tenant app returns its
// secret only from the lark endpoint. private_key_jwt returns no secret, so
// this retry does not apply.
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
// Step 4: Handle Lark brand special case
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
}
finalMethod = resolveFinalAuthMethod(result.AuthMethods, authMethod)
}
if result.ClientID == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id")
}
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_secret")
if result.ClientID == "" || result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
}
// Determine final brand from response
@@ -343,67 +235,13 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
finalBrand = core.BrandFeishu
}
// Surface a downgrade: requested private_key_jwt but the app resolved to a
// secret-based method (e.g. an existing app was selected). The key was NOT
// bound, so we must store the secret method, not private_key_jwt.
if authMethod == core.AuthMethodPrivateKeyJWT && finalMethod != core.AuthMethodPrivateKeyJWT {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] note: requested private_key_jwt, but the app uses %q (e.g. an existing app was selected); storing %q.\n", finalMethod, finalMethod)
}
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
keyToStore := ""
if finalMethod == core.AuthMethodPrivateKeyJWT {
keyToStore = keyLabel
}
if err := validatePKJWTKeyBinding(finalMethod, keyToStore); err != nil {
return nil, err
}
return &configInitResult{
Mode: "create",
Brand: finalBrand,
AppID: result.ClientID,
AppSecret: result.ClientSecret, // empty for private_key_jwt; real secret otherwise
AuthMethod: finalMethod,
KeyLabel: keyToStore,
Mode: "create",
Brand: finalBrand,
AppID: result.ClientID,
AppSecret: result.ClientSecret,
}, nil
}
// validatePKJWTKeyBinding rejects a registration that resolved to
// private_key_jwt without a signing key bound to it. keyLabel is non-empty only
// when the local flow chose private_key_jwt and signed a TEE attestation; a
// resolved method of private_key_jwt with no key handle would save an unusable
// config (rejected later at config load, surfacing as "saved OK, fails on first
// use"), so it is caught here at registration time instead.
func validatePKJWTKeyBinding(finalMethod, keyLabel string) error {
if finalMethod == core.AuthMethodPrivateKeyJWT && keyLabel == "" {
return errs.NewConfigError(errs.SubtypeInvalidClient,
"registration resolved to private_key_jwt but no signing key was bound to this app (an existing secret-based app may have been selected)").
WithHint("re-register with: lark-cli config init --new --auth-method private_key_jwt")
}
return nil
}
// resolveFinalAuthMethod picks the authoritative method from the poll result,
// preferring private_key_jwt, then client_secret. It falls back to the requested
// method when the server returns nothing (older servers).
func resolveFinalAuthMethod(serverMethods []string, requested string) string {
if len(serverMethods) == 0 {
if requested == "" {
return core.AuthMethodClientSecret
}
return requested
}
for _, m := range serverMethods {
if m == core.AuthMethodPrivateKeyJWT {
return core.AuthMethodPrivateKeyJWT
}
}
for _, m := range serverMethods {
if m == core.AuthMethodClientSecret {
return core.AuthMethodClientSecret
}
}
return serverMethods[0]
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keysigner"
)
// probeTimeout is the total wall-clock budget for the credential probe step
@@ -91,32 +90,3 @@ func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
// runProbePKJWT does a best-effort key-binding validation after a private_key_jwt
// config is saved: it signs a client_assertion with the local platform key and
// mints a token. A typed error (a deterministic server rejection — e.g. the key
// is not bound to this app) is propagated so `config init` exits non-zero with
// the canonical envelope; untyped errors (transport / HTTP / parse / timeout)
// are swallowed (return nil). The mint itself is the probe — no second call.
func runProbePKJWT(parent context.Context, factory *cmdutil.Factory, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) error {
if factory == nil || signer == nil {
return nil
}
httpClient, err := factory.HttpClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
if _, err := credential.FetchTATWithAssertion(ctx, httpClient, brand, clientID, signer, keyLabel); err != nil {
// Typed = deterministic credential rejection → propagate. Untyped
// (transport / HTTP / parse / timeout) is ambiguous → stay silent.
if errs.IsTyped(err) {
return err
}
return nil
}
return nil
}

View File

@@ -6,11 +6,6 @@ package config
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
crand "crypto/rand"
"crypto/sha256"
"errors"
"io"
"net/http"
@@ -22,17 +17,14 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeRT routes requests to per-path handlers and records what it saw.
type fakeRT struct {
tatHandler func(req *http.Request) (*http.Response, error)
probeHandler func(req *http.Request) (*http.Response, error)
oauthHandler func(req *http.Request) (*http.Response, error)
tatCalls int
probeCalls int
oauthCalls int
probeReq *http.Request
probeBody string
}
@@ -56,50 +48,10 @@ func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
}
return f.probeHandler(req)
case strings.HasSuffix(req.URL.Path, "/authen/v2/oauth/token"):
f.oauthCalls++
if f.oauthHandler == nil {
return jsonResp(200, `{"access_token":"t-jwt"}`), nil
}
return f.oauthHandler(req)
}
return nil, errors.New("unexpected URL: " + req.URL.String())
}
// probeTestSigner is an in-memory real ECDSA P-256 signer used to sign the
// client_assertion in runProbePKJWT tests (authMethodTestSigner returns a nil
// key and cannot sign).
type probeTestSigner struct{ key *ecdsa.PrivateKey }
func newProbeTestSigner(t *testing.T) *probeTestSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader)
if err != nil {
t.Fatal(err)
}
return &probeTestSigner{key: k}
}
func (p *probeTestSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return p.key.Public(), nil
}
func (p *probeTestSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return p.key.Public(), nil
}
func (p *probeTestSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(crand.Reader, p.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func jsonResp(code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
@@ -333,42 +285,3 @@ func TestRunProbe_TimeoutHonored(t *testing.T) {
// must stay silent and not block.
assertSilent(t, err, errBuf)
}
// runProbePKJWT: a deterministic server rejection (invalid_client) is propagated
// as a typed ConfigError so config init exits non-zero.
func TestRunProbePKJWT_DeterministicReject_Propagates(t *testing.T) {
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
return jsonResp(401, `{"error":"invalid_client","error_description":"unknown key"}`), nil
}}
f, errBuf := fakeFactory(t, rt)
err := runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key")
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected propagated typed error, got %T %v", err, err)
}
if errBuf.Len() != 0 {
t.Errorf("runProbePKJWT must not write stderr, got %q", errBuf.String())
}
}
// runProbePKJWT: ambiguous upstream noise (HTTP 503) is swallowed — silent, exit 0.
func TestRunProbePKJWT_Ambiguous_Silent(t *testing.T) {
rt := &fakeRT{oauthHandler: func(*http.Request) (*http.Response, error) {
return jsonResp(503, `unavailable`), nil
}}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key"), errBuf)
}
// runProbePKJWT: a successful mint returns nil.
func TestRunProbePKJWT_Success_Silent(t *testing.T) {
rt := &fakeRT{} // default oauth handler returns 200 + access_token
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", newProbeTestSigner(t), "agent-key"), errBuf)
}
// runProbePKJWT: a nil signer is a defensive no-op (should not be reached, must
// not panic).
func TestRunProbePKJWT_NilSigner_Silent(t *testing.T) {
f, errBuf := fakeFactory(t, &fakeRT{})
assertSilent(t, runProbePKJWT(context.Background(), f, core.BrandFeishu, "cli_x", nil, "k"), errBuf)
}

View File

@@ -10,25 +10,9 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
// TestRunRestoreFlow_NothingToRestore covers the early guards that return before
// any network/registration call: no config at all, and a config whose resolved
// app has no app id (nothing to send on begin).
func TestRunRestoreFlow_NothingToRestore(t *testing.T) {
// No config on disk.
if err := runRestoreFlow(&ConfigInitOptions{}, nil, nil, nil); err == nil {
t.Fatal("expected error when there is no config to restore")
}
// Config present but the resolved app has no app id.
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: ""}}}
if err := runRestoreFlow(&ConfigInitOptions{}, existing, nil, nil); err == nil {
t.Fatal("expected error when the resolved app has no app id")
}
}
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
@@ -135,62 +119,3 @@ func assertValidationParam(t *testing.T, err error, wantParam string) {
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
}
}
// countingKeychain is an in-memory KeychainAccess that records whether Remove
// was invoked, so the stale-secret cleanup can be asserted without a real OS
// keychain.
type countingKeychain struct {
store map[string]string
removeCalled bool
}
func newCountingKeychain() *countingKeychain {
return &countingKeychain{store: map[string]string{}}
}
func (k *countingKeychain) Get(service, account string) (string, error) {
v, ok := k.store[service+"/"+account]
if !ok {
return "", keychain.ErrNotFound
}
return v, nil
}
func (k *countingKeychain) Set(service, account, value string) error {
k.store[service+"/"+account] = value
return nil
}
func (k *countingKeychain) Remove(service, account string) error {
k.removeCalled = true
delete(k.store, service+"/"+account)
return nil
}
func TestRemoveStaleSecretForPKJWT_SameAppID(t *testing.T) {
kc := newCountingKeychain()
ref, err := core.ForStorage("cli_same", core.PlainSecret("old-secret"), kc) // → Source:"keychain"
if err != nil {
t.Fatal(err)
}
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_same", AppSecret: ref}}}
removeStaleSecretForPKJWT(existing, "", "cli_same", kc)
if !kc.removeCalled {
t.Error("same appId with keychain secret: expected kc.Remove to be invoked")
}
}
func TestRemoveStaleSecretForPKJWT_DifferentAppID(t *testing.T) {
kc := newCountingKeychain()
ref, _ := core.ForStorage("cli_old", core.PlainSecret("old-secret"), kc)
kc.removeCalled = false // ForStorage does not call Remove, but reset to be safe
existing := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_old", AppSecret: ref}}}
removeStaleSecretForPKJWT(existing, "", "cli_new", kc)
if kc.removeCalled {
t.Error("different appId: must NOT remove")
}
}
func TestRemoveStaleSecretForPKJWT_NilExisting(t *testing.T) {
removeStaleSecretForPKJWT(nil, "", "cli_x", newCountingKeychain()) // must not panic
}

View File

@@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"sync"
@@ -20,7 +19,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
@@ -134,9 +132,6 @@ func doctorRun(opts *DoctorOptions) error {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
}
// ── 3b. private_key_jwt / TEE signer (local; runs even with --offline) ──
checks = append(checks, teeSignerCheck(opts.Ctx, cfg))
// ── 4 & 5. Endpoint reachability ──
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
@@ -150,54 +145,6 @@ func identityCheck(name string, id identitydiag.Identity) checkResult {
return warn(name, id.Message, id.Hint)
}
const teeUnavailableHint = "ensure the device secure hardware is accessible (Linux TPM: add your user to the 'tss' group or run with sufficient privileges)"
// teeSignerCheck reports the private_key_jwt signing backend (TEE/TPM) status.
// The probe is local hardware only (no network), so it runs even with --offline;
// in a build without a TEE signer it short-circuits without touching any
// hardware. It is a hard requirement for private_key_jwt apps and purely
// informational for client_secret apps.
func teeSignerCheck(ctx context.Context, cfg *core.CliConfig) checkResult {
usesPKJWT := cfg != nil && cfg.AuthMethod == core.AuthMethodPrivateKeyJWT
info, ok, err := keysigner.ProbeActiveHardware(ctx)
return teeCheckResult(info, ok, err, usesPKJWT)
}
// teeCheckResult maps a hardware probe to a doctor check. Split out from
// teeSignerCheck so the full matrix is unit-testable without a TPM.
func teeCheckResult(info keysigner.HardwareInfo, ok bool, probeErr error, usesPKJWT bool) checkResult {
const name = "tee_signer"
// No signer registered → private_key_jwt is unsupported on this build.
if !ok {
if usesPKJWT {
return fail(name,
"app uses private_key_jwt but this build has no TEE key signer",
"the platform key signer ships by default on macOS, Linux, and Windows/amd64; this platform (e.g. Windows/arm64) has none — use a supported platform or re-register with --auth-method client_secret")
}
return skip(name, "no TEE signer in this build (only private_key_jwt is affected; client_secret is unaffected)")
}
backend := info.Backend
if backend == "" {
backend = "tee"
}
switch {
case probeErr != nil:
return warn(name, fmt.Sprintf("%s signer present but probe errored: %s", backend, probeErr), "")
case info.Available:
if info.VendorName != "" {
return pass(name, fmt.Sprintf("%s TEE available (%s)", backend, info.VendorName))
}
return pass(name, fmt.Sprintf("%s TEE available", backend))
case usesPKJWT:
return fail(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
default:
return warn(name, fmt.Sprintf("%s signer present but TEE unavailable: %s", backend, info.Reason), teeUnavailableHint)
}
}
// networkChecks probes Open API and MCP endpoints concurrently.
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
if opts.Offline {
@@ -287,90 +234,14 @@ func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
}
}
workspace := core.CurrentWorkspace().Display()
// A terminal on STDOUT gets a readable report; pipes, redirects, scripts and
// tests keep the stable JSON contract (NO_COLOR disables ANSI styling).
// StdoutIsTerminal checks stdout specifically — IOStreams.IsTerminal reflects
// stdin, which would wrongly send the human report into `doctor | jq`.
if f.IOStreams.StdoutIsTerminal() {
renderDoctorHuman(f.IOStreams.Out, workspace, checks, allOK, os.Getenv("NO_COLOR") == "")
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": allOK,
"workspace": workspace,
"checks": checks,
})
result := map[string]interface{}{
"ok": allOK,
"workspace": core.CurrentWorkspace().Display(),
"checks": checks,
}
output.PrintJson(f.IOStreams.Out, result)
if !allOK {
return output.ErrBare(1)
}
return nil
}
// renderDoctorHuman writes a readable health report: one aligned line per check
// with a colored status tag, an indented hint when present, and a summary line.
func renderDoctorHuman(w io.Writer, workspace string, checks []checkResult, allOK, color bool) {
const (
green = "\033[32m"
yellow = "\033[33m"
red = "\033[31m"
gray = "\033[90m"
bold = "\033[1m"
reset = "\033[0m"
)
colorOf := map[string]string{"pass": green, "warn": yellow, "fail": red, "skip": gray}
tagOf := map[string]string{"pass": "PASS", "warn": "WARN", "fail": "FAIL", "skip": "SKIP"}
paint := func(code, s string) string {
if !color || code == "" {
return s
}
return code + s + reset
}
nameW := 0
for _, c := range checks {
if len(c.Name) > nameW {
nameW = len(c.Name)
}
}
fmt.Fprintf(w, "\n%s (workspace: %s)\n\n", paint(bold, "lark-cli doctor"), workspace)
var passN, warnN, failN, skipN int
for _, c := range checks {
tag := tagOf[c.Status]
if tag == "" {
tag = "????"
}
fmt.Fprintf(w, " %s %-*s %s\n", paint(colorOf[c.Status], "["+tag+"]"), nameW, c.Name, c.Message)
if c.Hint != "" {
fmt.Fprintf(w, " %-*s %s\n", nameW, "", paint(gray, "↳ "+c.Hint))
}
switch c.Status {
case "pass":
passN++
case "warn":
warnN++
case "fail":
failN++
case "skip":
skipN++
}
}
headline := paint(green, "healthy")
if !allOK {
headline = paint(red, "problems found")
}
fmt.Fprintf(w, "\n %s — %d passed", headline, passN)
if warnN > 0 {
fmt.Fprintf(w, ", %d warning(s)", warnN)
}
if failN > 0 {
fmt.Fprintf(w, ", %d failed", failN)
}
if skipN > 0 {
fmt.Fprintf(w, ", %d skipped", skipN)
}
fmt.Fprintln(w)
}

View File

@@ -4,18 +4,14 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -143,107 +139,6 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
assertCheck(t, got.Checks, "identity_ready", "pass")
}
func TestTeeCheckResult(t *testing.T) {
avail := keysigner.HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"}
unavail := keysigner.HardwareInfo{Backend: "tpm2", Reason: "open /dev/tpmrm0: permission denied"}
cases := []struct {
name string
info keysigner.HardwareInfo
ok bool
probeErr error
pkjwt bool
want string
}{
{"no signer + private_key_jwt → fail", keysigner.HardwareInfo{}, false, nil, true, "fail"},
{"no signer + client_secret → skip", keysigner.HardwareInfo{}, false, nil, false, "skip"},
{"available + private_key_jwt → pass", avail, true, nil, true, "pass"},
{"available + client_secret → pass", avail, true, nil, false, "pass"},
{"unavailable + private_key_jwt → fail", unavail, true, nil, true, "fail"},
{"unavailable + client_secret → warn", unavail, true, nil, false, "warn"},
{"probe error → warn", keysigner.HardwareInfo{Backend: "tpm2"}, true, errors.New("boom"), true, "warn"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := teeCheckResult(tc.info, tc.ok, tc.probeErr, tc.pkjwt)
if got.Name != "tee_signer" {
t.Errorf("name = %q, want tee_signer", got.Name)
}
if got.Status != tc.want {
t.Errorf("status = %q, want %q (msg=%q)", got.Status, tc.want, got.Message)
}
})
}
}
// TestDoctorRun_TeeSignerWired proves the tee_signer check is part of doctorRun.
// It asserts the build-independent invariant (a client_secret app must never
// FAIL on TEE) so the test passes whether or not a signer is compiled in.
func TestDoctorRun_TeeSignerWired(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,
})
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
var c *checkResult
for i := range got.Checks {
if got.Checks[i].Name == "tee_signer" {
c = &got.Checks[i]
}
}
if c == nil {
t.Fatalf("tee_signer check not present in doctor output: %#v", got.Checks)
}
if c.Status == "fail" {
t.Errorf("tee_signer = fail for a client_secret app; want skip/warn/pass (msg=%q)", c.Message)
}
}
func TestRenderDoctorHuman(t *testing.T) {
var buf bytes.Buffer
checks := []checkResult{
pass("cli_version", "1.0.50"),
warn("tee_signer", "tpm2 signer present but TEE unavailable", "add your user to the 'tss' group"),
fail("identity_ready", "no usable identity", "run: lark-cli auth status --verify"),
skip("endpoint_open", "skipped (--offline)"),
}
renderDoctorHuman(&buf, "local", checks, false, false)
out := buf.String()
for _, want := range []string{
"lark-cli doctor", "workspace: local",
"[PASS]", "cli_version", "1.0.50",
"[WARN]", "tee_signer", "↳ add your user to the 'tss' group",
"[FAIL]", "identity_ready", "↳ run: lark-cli auth status --verify",
"[SKIP]", "endpoint_open",
"problems found", "1 passed", "1 warning(s)", "1 failed", "1 skipped",
} {
if !strings.Contains(out, want) {
t.Errorf("output missing %q\n---\n%s", want, out)
}
}
if strings.Contains(out, "\033[") {
t.Errorf("color=false but ANSI escapes present:\n%s", out)
}
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
for _, check := range checks {

View File

@@ -26,6 +26,7 @@ func TestRunList_TextOutput(t *testing.T) {
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
@@ -55,4 +56,17 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
}
var foundTask bool
for _, row := range rows {
if row["key"] == "task.task.update_user_access_v2" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
}
}
}
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
}

View File

@@ -96,6 +96,34 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["jq_root_path"] != ".event" {
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
}
if payload["single_consumer"] != true {
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
}
resolved := payload["resolved_output_schema"].(map[string]interface{})
props := resolved["properties"].(map[string]interface{})
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

132
events/im/card_action.go Normal file
View File

@@ -0,0 +1,132 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
type CardActionTriggerOutput struct {
Type string `json:"type" desc:"Event type; always card.action.trigger"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
}
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Operator struct {
OpenID string `json:"open_id"`
} `json:"operator"`
Token string `json:"token"`
Host string `json:"host"`
Action struct {
Tag string `json:"tag"`
Value map[string]interface{} `json:"value"`
Name string `json:"name"`
FormValue map[string]interface{} `json:"form_value"`
InputValue string `json:"input_value"`
Option string `json:"option"`
Options []string `json:"options"`
Checked bool `json:"checked"`
Timezone string `json:"timezone"`
} `json:"action"`
Context struct {
OpenMessageID string `json:"open_message_id"`
OpenChatID string `json:"open_chat_id"`
} `json:"context"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
}
actionValue := marshalToString(envelope.Event.Action.Value)
formValue := marshalToString(envelope.Event.Action.FormValue)
options := strings.Join(envelope.Event.Action.Options, ",")
out := &CardActionTriggerOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
OperatorID: envelope.Event.Operator.OpenID,
MessageID: envelope.Event.Context.OpenMessageID,
ChatID: envelope.Event.Context.OpenChatID,
Host: envelope.Event.Host,
Token: envelope.Event.Token,
ActionTag: envelope.Event.Action.Tag,
ActionValue: actionValue,
ActionName: envelope.Event.Action.Name,
FormValue: formValue,
InputValue: envelope.Event.Action.InputValue,
Option: envelope.Event.Action.Option,
Options: options,
Checked: envelope.Event.Action.Checked,
Timezone: envelope.Event.Action.Timezone,
}
if out.MessageID != "" && rt != nil {
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
}
return json.Marshal(out)
}
// fetchCardUserDSL gets the card message content via message get API.
// Returns empty string on any failure — never blocks event consumption.
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
resp, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
return ""
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Items []struct {
Body struct {
Content string `json:"content"`
} `json:"body"`
} `json:"items"`
} `json:"data"`
}
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
return ""
}
return result.Data.Items[0].Body.Content
}
func marshalToString(m map[string]interface{}) string {
if len(m) == 0 {
return ""
}
b, _ := json.Marshal(m)
return string(b)
}

View File

@@ -0,0 +1,432 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestCardActionTriggerRegistered(t *testing.T) {
def, ok := event.Lookup("card.action.trigger")
if !ok {
t.Fatal("card.action.trigger should be registered via Keys()")
}
if def.Schema.Custom == nil {
t.Error("card.action.trigger must set Schema.Custom")
}
if def.Process == nil {
t.Error("card.action.trigger must set Process")
}
if len(def.Scopes) == 0 {
t.Error("Scopes must not be empty")
}
}
func TestProcessCardAction_Button(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_btn_001",
"event_type": "card.action.trigger",
"create_time": "1776409469273"
},
"event": {
"operator": {"open_id": "ou_operator"},
"token": "c-token-btn",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "approve"},
"name": "approve_btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_msg_001",
"open_chat_id": "oc_chat_001"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Type != "card.action.trigger" {
t.Errorf("Type = %q, want card.action.trigger", out.Type)
}
if out.EventID != "ev_btn_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.OperatorID != "ou_operator" {
t.Errorf("OperatorID = %q", out.OperatorID)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
if out.ActionValue != `{"key":"approve"}` {
t.Errorf("ActionValue = %q", out.ActionValue)
}
if out.ActionName != "approve_btn" {
t.Errorf("ActionName = %q", out.ActionName)
}
if out.Token != "c-token-btn" {
t.Errorf("Token = %q", out.Token)
}
if out.MessageID != "om_msg_001" {
t.Errorf("MessageID = %q", out.MessageID)
}
if out.ChatID != "oc_chat_001" {
t.Errorf("ChatID = %q", out.ChatID)
}
if out.Host != "im_message" {
t.Errorf("Host = %q", out.Host)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
}
func TestProcessCardAction_FormSubmit(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_form_001",
"event_type": "card.action.trigger",
"create_time": "1776409469274"
},
"event": {
"operator": {"open_id": "ou_form_user"},
"token": "c-token-form",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "submit_btn",
"form_value": {"name": "test-user", "reason": "testing"},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_form_001",
"open_chat_id": "oc_chat_002"
}
}
}`
out := runCardAction(t, payload, nil)
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
t.Errorf("FormValue = %q", out.FormValue)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
}
func TestProcessCardAction_MultiSelect(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_ms_001",
"event_type": "card.action.trigger",
"create_time": "1776409469275"
},
"event": {
"operator": {"open_id": "ou_ms_user"},
"token": "c-token-ms",
"host": "im_message",
"action": {
"tag": "multi_select_static",
"value": {},
"name": "multi_select",
"options": ["opt_1", "opt_3"],
"checked": false
},
"context": {
"open_message_id": "om_ms_001",
"open_chat_id": "oc_chat_003"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Options != "opt_1,opt_3" {
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
}
if out.ActionTag != "multi_select_static" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_Input(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_input_001",
"event_type": "card.action.trigger",
"create_time": "1776409469276"
},
"event": {
"operator": {"open_id": "ou_input_user"},
"token": "c-token-input",
"host": "im_message",
"action": {
"tag": "input",
"value": {},
"name": "text_input",
"input_value": "hello world",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_input_001",
"open_chat_id": "oc_chat_004"
}
}
}`
out := runCardAction(t, payload, nil)
if out.InputValue != "hello world" {
t.Errorf("InputValue = %q", out.InputValue)
}
if out.ActionTag != "input" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_DatePicker(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_date_001",
"event_type": "card.action.trigger",
"create_time": "1776409469277"
},
"event": {
"operator": {"open_id": "ou_date_user"},
"token": "c-token-date",
"host": "im_message",
"action": {
"tag": "date_picker",
"value": {},
"name": "date_selector",
"option": "2024-04-01 +0800",
"timezone": "Asia/Shanghai",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_date_001",
"open_chat_id": "oc_chat_005"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Option != "2024-04-01 +0800" {
t.Errorf("Option = %q", out.Option)
}
if out.Timezone != "Asia/Shanghai" {
t.Errorf("Timezone = %q", out.Timezone)
}
}
func TestProcessCardAction_MalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: "card.action.trigger",
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func TestProcessCardAction_MessageGetSuccess(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ok",
"event_type": "card.action.trigger",
"create_time": "1776409469278"
},
"event": {
"operator": {"open_id": "ou_mg_user"},
"token": "c-token-mg",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "click"},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_001",
"open_chat_id": "oc_chat_mg"
}
}
}`
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
mock := &mockAPIClient{resp: `{
"code": 0,
"msg": "success",
"data": {
"items": [{
"body": {"content": "` + escapeJSON(cardContent) + `"}
}]
}
}`}
out := runCardAction(t, payload, mock)
if out.CardContent == "" {
t.Error("CardContent should not be empty when message get succeeds")
}
}
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ec",
"event_type": "card.action.trigger",
"create_time": "1776409469279"
},
"event": {
"operator": {"open_id": "ou_mg_user2"},
"token": "c-token-mg2",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_002",
"open_chat_id": "oc_chat_mg2"
}
}
}`
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
}
}
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_fail",
"event_type": "card.action.trigger",
"create_time": "1776409469280"
},
"event": {
"operator": {"open_id": "ou_mg_user3"},
"token": "c-token-mg3",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_003",
"open_chat_id": "oc_chat_mg3"
}
}
}`
mock := &mockAPIClient{errResp: true}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
}
}
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_no_msg",
"event_type": "card.action.trigger",
"create_time": "1776409469281"
},
"event": {
"operator": {"open_id": "ou_no_msg"},
"token": "c-token-nm",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "",
"open_chat_id": "oc_chat_nm"
}
}
}`
out := runCardAction(t, payload, nil)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
}
}
type mockAPIClient struct {
resp string
errResp bool
}
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if m.errResp {
return nil, context.DeadlineExceeded
}
return json.RawMessage(m.resp), nil
}
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "card.action.trigger",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out CardActionTriggerOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}
func escapeJSON(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}

View File

@@ -27,6 +27,21 @@ func Keys() []event.KeyDefinition {
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{"im.message.receive_v1"},
},
{
Key: "card.action.trigger",
DisplayName: "Card action",
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
EventType: "card.action.trigger",
SubscriptionType: event.SubTypeCallback,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
},
Process: processCardAction,
Scopes: []string{"im:message:readonly"},
AuthTypes: []string{"bot"},
SingleConsumer: true,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
for _, rk := range nativeIMKeys {

View File

@@ -7,6 +7,7 @@ package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
@@ -17,6 +18,7 @@ func init() {
all := [][]event.KeyDefinition{
im.Keys(),
minutes.Keys(),
task.Keys(),
vc.Keys(),
whiteboard.Keys(),
}

23
events/task/native.go Normal file
View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
// standard Lark V2 event envelope.
type TaskUpdateUserAccessV2Data struct {
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
}
var taskUpdateUserAccessCommitTypes = []string{
"task_create",
"task_deleted",
"task_summary_update",
"task_desc_update",
"task_assignees_update",
"task_followers_update",
"task_reminders_update",
"task_start_due_update",
"task_completed_update",
}

32
events/task/preconsume.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(
errs.SubtypeNetworkTransport,
"failed to subscribe task event",
).WithCause(err)
}
return nil, nil
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
type stubAPIClient struct {
err error
method string
path string
body interface{}
calls int
}
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
s.method = method
s.path = path
s.body = body
s.calls++
if s.err != nil {
return nil, s.err
}
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
}
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
rt := &stubAPIClient{}
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
}
if cleanup != nil {
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
}
if rt.calls != 1 {
t.Fatalf("calls = %d, want 1", rt.calls)
}
if rt.method != "POST" {
t.Errorf("method = %q, want POST", rt.method)
}
if rt.path != taskSubscriptionPath {
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
}
if rt.body != nil {
t.Errorf("body = %#v, want nil", rt.body)
}
}
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
if p.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
}
}
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
rt := &stubAPIClient{err: wantErr}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != wantErr {
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
}
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v, want %v", err, wantErr)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
}
}
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
cause := errors.New("connection reset")
rt := &stubAPIClient{err: cause}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, cause) {
t.Fatalf("err = %v, want cause %v", err, cause)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
if p.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
}
}

33
events/task/register.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package task registers Task-domain EventKeys.
package task
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
// Keys returns all Task-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeTaskUpdateUserAccessV2,
DisplayName: "Task updated",
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
EventType: eventTypeTaskUpdateUserAccessV2,
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
},
PreConsume: taskSubscriptionPreConsume,
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
SingleConsumer: true,
},
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
}
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
}
if def.Schema.Native == nil {
t.Fatal("Schema.Native is nil")
}
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
}
if def.Process != nil {
t.Fatal("Native Task EventKey must not set Process")
}
if def.PreConsume == nil {
t.Fatal("PreConsume is nil")
}
if !def.SingleConsumer {
t.Fatal("SingleConsumer = false, want true")
}
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
t.Errorf("Scopes = %#v", def.Scopes)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
var schema map[string]interface{}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
taskGUID := eventProps["task_guid"].(map[string]interface{})
if got := taskGUID["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
eventTypes := eventProps["event_types"].(map[string]interface{})
items := eventTypes["items"].(map[string]interface{})
rawEnum, ok := items["enum"].([]interface{})
if !ok {
t.Fatalf("event_types item enum missing: %#v", items["enum"])
}
got := make(map[string]bool, len(rawEnum))
for _, v := range rawEnum {
got[v.(string)] = true
}
for _, want := range taskUpdateUserAccessCommitTypes {
if !got[want] {
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
}
}
}
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
const key = eventTypeTaskUpdateUserAccessV2
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}

18
go.mod
View File

@@ -7,8 +7,6 @@ require (
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c
github.com/facebookincubator/sks v0.0.0-20251112220143-6823f23937b4
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
@@ -29,10 +27,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require github.com/ebitengine/purego v0.10.1
require (
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
@@ -47,23 +42,12 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-ole/go-ole v1.2.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/certificate-transparency-go v1.1.2 // indirect
github.com/google/certtostore v1.0.3-0.20230404221207-8d01647071cc // indirect
github.com/google/deck v0.0.0-20230104221208-105ad94aa8ae // indirect
github.com/google/go-attestation v0.5.1 // indirect
github.com/google/go-tpm v0.9.0 // indirect
github.com/google/go-tspi v0.3.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jgoguen/go-utils v0.0.0-20200211015258-b42ad41486fd // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -73,12 +57,10 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.31.0 // indirect
)

1213
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -31,11 +31,6 @@ type AppRegistrationResult struct {
ClientID string
ClientSecret string
UserInfo *AppRegUserInfo
// AuthMethods is the authoritative auth method(s) the app must use, as
// decided by the user/admin at confirmation (20260409 `auth_method` field).
// It may differ from what the client requested — e.g. selecting an existing
// client_secret app. Empty on older servers.
AuthMethods []string
}
// AppRegUserInfo contains user info returned from app registration.
@@ -44,81 +39,8 @@ type AppRegUserInfo struct {
TenantBrand string // "feishu" or "lark"
}
// AppRegistrationInit is the response from the app registration init endpoint.
type AppRegistrationInit struct {
Nonce string
SupportedAuthMethods []string // e.g. ["client_secret", "private_key_jwt"]
}
// AppRegistrationBeginOptions parametrizes the registration begin request.
// A zero value selects the legacy client_secret flow, preserving prior behavior.
type AppRegistrationBeginOptions struct {
AuthMethod string // "" => client_secret; core.AuthMethodPrivateKeyJWT
AuthAttestation string // private_key_jwt: the TEE-signed attestation JWT
RestoreAppID string // when set, asks the server to re-register this existing app
}
// RequestAppRegistrationInit performs the init step of the registration flow,
// returning a server nonce (to be embedded in a TEE-signed attestation JWT) and
// the auth methods the server supports for this archetype.
func RequestAppRegistrationInit(httpClient *http.Client) (*AppRegistrationInit, error) {
// Registration always begins against the feishu accounts host (mirrors begin).
endpoint := core.ResolveEndpoints(core.BrandFeishu).Accounts + PathAppRegistration
form := url.Values{}
form.Set("action", "init")
form.Set("archetype", "PersonalAgent")
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("app registration init failed: read body: %w", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("app registration init failed: HTTP %d response not JSON", resp.StatusCode)
}
if _, hasError := data["error"]; resp.StatusCode >= 400 || hasError {
msg := getStr(data, "error_description")
if msg == "" {
msg = getStr(data, "error")
}
if msg == "" {
msg = "Unknown error"
}
return nil, fmt.Errorf("app registration init failed: %s", msg)
}
out := &AppRegistrationInit{Nonce: getStr(data, "nonce")}
if methods, ok := data["supported_auth_methods"].([]interface{}); ok {
for _, m := range methods {
if s, ok := m.(string); ok {
out.SupportedAuthMethods = append(out.SupportedAuthMethods, s)
}
}
}
if out.Nonce == "" {
return nil, fmt.Errorf("app registration init failed: server returned no nonce")
}
return out, nil
}
// RequestAppRegistration initiates the app registration device flow (begin step).
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts AppRegistrationBeginOptions, errOut io.Writer) (*AppRegistrationResponse, error) {
// RequestAppRegistration initiates the app registration device flow.
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
errOut = io.Discard
}
@@ -127,24 +49,11 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
endpoint := regEp.Accounts + PathAppRegistration
authMethod := opts.AuthMethod
if authMethod == "" {
authMethod = core.AuthMethodClientSecret
}
form := url.Values{}
form.Set("action", "begin")
form.Set("archetype", "PersonalAgent")
form.Set("auth_method", authMethod)
form.Set("auth_method", "client_secret")
form.Set("request_user_info", "open_id tenant_brand")
if opts.AuthAttestation != "" {
form.Set("auth_attestation", opts.AuthAttestation)
}
// Restore flow: carry the existing app id so the server re-registers it
// rather than creating a new app.
if opts.RestoreAppID != "" {
form.Set("app_id", opts.RestoreAppID)
}
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
@@ -186,24 +95,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
userCode := getStr(data, "user_code")
verificationUri := getStr(data, "verification_uri")
// Prefer the server-provided complete URL (currently /page/launcher); fall
// back to building it from verification_uri, then to /page/launcher. The old
// hard-coded /page/cli is stale — the server now returns /page/launcher.
verificationUriComplete := getStr(data, "verification_uri_complete")
if verificationUriComplete == "" {
base := verificationUri
if base == "" {
base = ep.Open + "/page/launcher"
}
// The server may return verification_uri with its own query (e.g.
// client_id when registering against an existing app), so join with
// the same ?/& logic as BuildVerificationURL.
sep := "?"
if strings.Contains(base, "?") {
sep = "&"
}
verificationUriComplete = base + sep + "user_code=" + url.QueryEscape(userCode)
}
verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)
return &AppRegistrationResponse{
DeviceCode: getStr(data, "device_code"),
@@ -215,26 +107,6 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts
}, nil
}
// parseAuthMethods normalizes the poll response `auth_method` field, which the
// server returns as a JSON array of strings (e.g. ["private_key_jwt"]) — or, on
// some variants, a single space-separated string.
func parseAuthMethods(v interface{}) []string {
switch t := v.(type) {
case []interface{}:
out := make([]string, 0, len(t))
for _, m := range t {
if s, ok := m.(string); ok && s != "" {
out = append(out, s)
}
}
return out
case string:
return strings.Fields(t)
default:
return nil
}
}
// BuildVerificationURL appends CLI tracking parameters to the verification URL.
func BuildVerificationURL(baseURL, cliVersion string) string {
sep := "&"
@@ -315,7 +187,6 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
result := &AppRegistrationResult{
ClientID: getStr(data, "client_id"),
ClientSecret: getStr(data, "client_secret"),
AuthMethods: parseAuthMethods(data["auth_method"]),
}
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
result.UserInfo = &AppRegUserInfo{

View File

@@ -4,14 +4,8 @@
package auth
import (
"io"
"net/http"
"net/url"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/smartystreets/goconvey/convey"
)
@@ -37,184 +31,3 @@ func Test_BuildVerificationURL(t *testing.T) {
})
})
}
// captureClient returns an http.Client that records the last request's form body
// and replies with the given JSON payload.
func captureClient(gotBody *url.Values, respJSON string) *http.Client {
return &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
v, _ := url.ParseQuery(string(b))
*gotBody = v
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(respJSON)),
}, nil
}),
}
}
func TestRequestAppRegistrationInit_ParsesNonceAndMethods(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"nonce":"n-123","supported_auth_methods":["client_secret","private_key_jwt"]}`)
out, err := RequestAppRegistrationInit(hc)
if err != nil {
t.Fatal(err)
}
if out.Nonce != "n-123" {
t.Errorf("nonce = %q, want n-123", out.Nonce)
}
if len(out.SupportedAuthMethods) != 2 || out.SupportedAuthMethods[1] != "private_key_jwt" {
t.Errorf("methods = %v", out.SupportedAuthMethods)
}
if body.Get("action") != "init" {
t.Errorf("action = %q, want init", body.Get("action"))
}
}
func TestRequestAppRegistrationInit_ErrorOnMissingNonce(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"supported_auth_methods":["client_secret"]}`)
if _, err := RequestAppRegistrationInit(hc); err == nil {
t.Fatal("expected error when server returns no nonce")
}
}
// TestRequestAppRegistrationInit_EmptySupportedAuthMethods covers the older-server
// back-compat path: an empty supported_auth_methods array parses to an empty
// slice, so the init guard in cmd/config/init_interactive.go
// (`len(SupportedAuthMethods) > 0 && !slices.Contains(...)`) stays false and does
// NOT reject the requested private_key_jwt. This aligns with
// resolveFinalAuthMethod(nil/[], private_key_jwt) == private_key_jwt
// (see cmd/config TestResolveFinalAuthMethod).
func TestRequestAppRegistrationInit_EmptySupportedAuthMethods(t *testing.T) {
var body url.Values
hc := captureClient(&body, `{"nonce":"n-1","supported_auth_methods":[]}`)
out, err := RequestAppRegistrationInit(hc)
if err != nil {
t.Fatal(err)
}
if out.Nonce != "n-1" {
t.Errorf("nonce = %q, want n-1", out.Nonce)
}
if len(out.SupportedAuthMethods) != 0 {
t.Errorf("SupportedAuthMethods = %v, want empty", out.SupportedAuthMethods)
}
// Reproduce the init guard expression on the real parsed result: an empty
// slice must NOT reject private_key_jwt.
rejected := len(out.SupportedAuthMethods) > 0 &&
!slices.Contains(out.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT)
if rejected {
t.Error("empty SupportedAuthMethods must allow private_key_jwt (older-server back-compat)")
}
}
const beginRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
func TestRequestAppRegistration_BeginDefaultsToClientSecret(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
if _, err := RequestAppRegistration(hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil); err != nil {
t.Fatal(err)
}
if body.Get("action") != "begin" {
t.Errorf("action = %q", body.Get("action"))
}
if body.Get("auth_method") != "client_secret" {
t.Errorf("auth_method = %q, want client_secret (default)", body.Get("auth_method"))
}
if body.Has("auth_attestation") {
t.Errorf("auth_attestation should be absent for client_secret, got %q", body.Get("auth_attestation"))
}
// Normal (non-restore) begin must NOT carry app_id.
if body.Has("app_id") {
t.Errorf("app_id should be absent when RestoreAppID is empty, got %q", body.Get("app_id"))
}
}
// TestRequestAppRegistration_BeginRestoreAppID verifies the restore flow sends the
// existing app id on begin so the server re-registers that app.
func TestRequestAppRegistration_BeginRestoreAppID(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
opts := AppRegistrationBeginOptions{RestoreAppID: "cli_restore_me"}
if _, err := RequestAppRegistration(hc, core.BrandFeishu, opts, nil); err != nil {
t.Fatal(err)
}
if body.Get("action") != "begin" {
t.Errorf("action = %q, want begin", body.Get("action"))
}
if body.Get("app_id") != "cli_restore_me" {
t.Errorf("app_id = %q, want cli_restore_me", body.Get("app_id"))
}
}
func TestRequestAppRegistration_VerificationURICompleteFallback(t *testing.T) {
cases := []struct {
name string
resp string
want string
}{
{
name: "bare verification_uri",
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`,
want: "https://example/verify?user_code=uc",
},
{
name: "verification_uri with existing query",
resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify?client_id=cli_x","expires_in":300,"interval":5}`,
want: "https://example/verify?client_id=cli_x&user_code=uc",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var body url.Values
hc := captureClient(&body, tc.resp)
got, err := RequestAppRegistration(hc, core.BrandFeishu, AppRegistrationBeginOptions{}, nil)
if err != nil {
t.Fatal(err)
}
if got.VerificationUriComplete != tc.want {
t.Errorf("VerificationUriComplete = %q, want %q", got.VerificationUriComplete, tc.want)
}
})
}
}
func TestParseAuthMethods(t *testing.T) {
if got := parseAuthMethods([]interface{}{"private_key_jwt", "client_secret"}); len(got) != 2 || got[0] != "private_key_jwt" {
t.Errorf("array form = %v", got)
}
if got := parseAuthMethods("client_secret private_key_jwt"); len(got) != 2 || got[1] != "private_key_jwt" {
t.Errorf("string form = %v", got)
}
if got := parseAuthMethods(nil); got != nil {
t.Errorf("nil form = %v, want nil", got)
}
}
func TestRequestAppRegistration_BeginPrivateKeyJWT(t *testing.T) {
var body url.Values
hc := captureClient(&body, beginRespJSON)
opts := AppRegistrationBeginOptions{
AuthMethod: core.AuthMethodPrivateKeyJWT,
AuthAttestation: "header.claims.sig",
}
if _, err := RequestAppRegistration(hc, core.BrandFeishu, opts, nil); err != nil {
t.Fatal(err)
}
if body.Get("auth_method") != "private_key_jwt" {
t.Errorf("auth_method = %q, want private_key_jwt", body.Get("auth_method"))
}
if body.Get("auth_attestation") != "header.claims.sig" {
t.Errorf("auth_attestation = %q", body.Get("auth_attestation"))
}
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"fmt"
"net/url"
"time"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// ClientAuth describes how to authenticate the OAuth client at the token
// endpoint: with a client_secret (default) or a TEE-signed client_assertion
// (private_key_jwt).
type ClientAuth struct {
AppID string
AppSecret string
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
Signer keysigner.Signer
KeyLabel string
}
// ClientAuthFromConfig builds a ClientAuth from resolved config, picking up the
// active key signer for private_key_jwt apps.
func ClientAuthFromConfig(cfg *core.CliConfig) ClientAuth {
if cfg == nil {
return ClientAuth{}
}
return ClientAuth{
AppID: cfg.AppID,
AppSecret: cfg.AppSecret,
AuthMethod: cfg.AuthMethod,
KeyLabel: cfg.KeyLabel,
Signer: keysigner.Active(),
}
}
func (c ClientAuth) isPrivateKeyJWT() bool { return c.AuthMethod == core.AuthMethodPrivateKeyJWT }
// applyClientAssertion adds client_assertion(+type) to a token-endpoint form for
// private_key_jwt and returns true. For client_secret it returns false, leaving
// the caller to apply its own secret-based authentication. audience is the token
// endpoint URL (the assertion's aud claim).
func (c ClientAuth) applyClientAssertion(ctx context.Context, form url.Values, audience string) (bool, error) {
if !c.isPrivateKeyJWT() {
return false, nil
}
if c.Signer == nil {
return false, fmt.Errorf("private_key_jwt requires a key signer, but none is available on this build")
}
assertion, err := jwt.SignClientAssertion(ctx, c.Signer, keysigner.KeyRef{Label: c.KeyLabel}, c.AppID, audience, time.Now())
if err != nil {
return false, err
}
form.Set("client_assertion_type", jwt.ClientAssertionType)
form.Set("client_assertion", assertion)
return true, nil
}

View File

@@ -1,109 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"net/url"
"testing"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeAuthSigner is a real in-memory ECDSA P-256 signer for client-auth tests.
type fakeAuthSigner struct{ key *ecdsa.PrivateKey }
func newFakeAuthSigner(t *testing.T) *fakeAuthSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeAuthSigner{key: k}
}
func (f *fakeAuthSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeAuthSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeAuthSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestClientAuth_applyClientAssertion_ClientSecret(t *testing.T) {
ca := ClientAuth{AppID: "cli_a", AppSecret: "sec"} // AuthMethod "" => client_secret
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "https://aud/token")
if err != nil {
t.Fatal(err)
}
if used {
t.Error("client_secret must not produce a client_assertion")
}
if form.Has("client_assertion") || form.Has("client_assertion_type") {
t.Errorf("form should be untouched, got %v", form)
}
}
func TestClientAuth_applyClientAssertion_PrivateKeyJWT(t *testing.T) {
ca := ClientAuth{
AppID: "cli_a",
AuthMethod: core.AuthMethodPrivateKeyJWT,
Signer: newFakeAuthSigner(t),
KeyLabel: "k",
}
form := url.Values{}
used, err := ca.applyClientAssertion(context.Background(), form, "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token")
if err != nil {
t.Fatal(err)
}
if !used {
t.Fatal("expected client_assertion to be applied")
}
if form.Get("client_assertion_type") != jwt.ClientAssertionType {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Get("client_assertion") == "" {
t.Error("client_assertion is empty")
}
if form.Has("client_secret") {
t.Error("client_secret must NOT be present for private_key_jwt")
}
}
func TestClientAuth_applyClientAssertion_NilSigner(t *testing.T) {
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT} // Signer nil
if _, err := ca.applyClientAssertion(context.Background(), url.Values{}, "aud"); err == nil {
t.Fatal("expected error when private_key_jwt has no signer")
}
}
func TestClientAuthFromConfig(t *testing.T) {
ca := ClientAuthFromConfig(&core.CliConfig{
AppID: "cli_x",
AppSecret: "s",
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "label-1",
})
if ca.AppID != "cli_x" || ca.AppSecret != "s" || ca.AuthMethod != core.AuthMethodPrivateKeyJWT || ca.KeyLabel != "label-1" {
t.Errorf("ClientAuth = %+v", ca)
}
}

View File

@@ -62,7 +62,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
}
// RequestDeviceAuthorization requests a device authorization code.
func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
if errOut == nil {
errOut = io.Discard
}
@@ -77,26 +77,18 @@ func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca
}
}
basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret))
form := url.Values{}
form.Set("client_id", ca.AppID)
form.Set("client_id", appId)
form.Set("scope", scope)
// private_key_jwt authenticates the client with a signed assertion in the
// body; client_secret uses HTTP Basic.
usedAssertion, err := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if !usedAssertion {
basicAuth := base64.StdEncoding.EncodeToString([]byte(ca.AppID + ":" + ca.AppSecret))
req.Header.Set("Authorization", "Basic "+basicAuth)
}
req.Header.Set("Authorization", "Basic "+basicAuth)
resp, err := httpClient.Do(req)
if err != nil {
@@ -147,7 +139,7 @@ func RequestDeviceAuthorization(ctx context.Context, httpClient *http.Client, ca
}
// PollDeviceToken polls the token endpoint until authorization completes or times out.
func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
if errOut == nil {
errOut = io.Discard
}
@@ -179,16 +171,10 @@ func PollDeviceToken(ctx context.Context, httpClient *http.Client, ca ClientAuth
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
form.Set("device_code", deviceCode)
form.Set("client_id", ca.AppID)
usedAssertion, caErr := ca.applyClientAssertion(ctx, form, core.OpenAPIAudience(brand))
if caErr != nil {
return &DeviceFlowResult{OK: false, Error: "invalid_client", Message: caErr.Error()}
}
if !usedAssertion {
form.Set("client_secret", ca.AppSecret)
}
form.Set("client_id", appId)
form.Set("client_secret", appSecret)
req, err := http.NewRequestWithContext(ctx, "POST", endpoints.Token, strings.NewReader(form.Encode()))
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {
continue
}

View File

@@ -7,10 +7,8 @@ import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync/atomic"
"testing"
@@ -85,7 +83,7 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
})
t.Cleanup(restore)
_, err := RequestDeviceAuthorization(context.Background(), httpmock.NewClient(reg), ClientAuth{AppID: "cli_a", AppSecret: "secret_b"}, core.BrandFeishu, "", nil)
_, err := RequestDeviceAuthorization(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "", nil)
if err != nil {
t.Fatalf("RequestDeviceAuthorization() error: %v", err)
}
@@ -108,66 +106,6 @@ func TestRequestDeviceAuthorization_LogsResponse(t *testing.T) {
}
}
// captureRT records the last request + body and returns a canned device-auth response.
func captureDeviceAuthClient(gotReq **http.Request, gotBody *string, respJSON string) *http.Client {
return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
*gotReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
*gotBody = string(b)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(respJSON)),
}, nil
})}
}
const deviceAuthRespJSON = `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify","expires_in":300,"interval":5}`
func TestRequestDeviceAuthorization_PrivateKeyJWT_UsesAssertionNotBasic(t *testing.T) {
var req *http.Request
var body string
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
ca := ClientAuth{AppID: "cli_a", AuthMethod: core.AuthMethodPrivateKeyJWT, Signer: newFakeAuthSigner(t), KeyLabel: "k"}
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "im:message:send", nil); err != nil {
t.Fatal(err)
}
if req.Header.Get("Authorization") != "" {
t.Errorf("private_key_jwt must NOT send Basic auth, got %q", req.Header.Get("Authorization"))
}
form, _ := url.ParseQuery(body)
if form.Get("client_assertion") == "" {
t.Error("missing client_assertion")
}
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Has("client_secret") {
t.Error("client_secret must not be present for private_key_jwt")
}
}
func TestRequestDeviceAuthorization_ClientSecret_UsesBasic(t *testing.T) {
var req *http.Request
var body string
client := captureDeviceAuthClient(&req, &body, deviceAuthRespJSON)
ca := ClientAuth{AppID: "cli_a", AppSecret: "sec"} // client_secret
if _, err := RequestDeviceAuthorization(context.Background(), client, ca, core.BrandFeishu, "", nil); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(req.Header.Get("Authorization"), "Basic ") {
t.Errorf("client_secret should use Basic auth, got %q", req.Header.Get("Authorization"))
}
form, _ := url.ParseQuery(body)
if form.Has("client_assertion") {
t.Error("client_secret must not send a client_assertion")
}
}
// TestFormatAuthCmdline_TruncatesExtraArgs verifies that long command lines are truncated.
func TestFormatAuthCmdline_TruncatesExtraArgs(t *testing.T) {
got := keychain.FormatAuthCmdline([]string{
@@ -267,7 +205,7 @@ func TestPollDeviceToken_DefaultsZeroIntervalToFiveSeconds(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
t.Cleanup(cancel)
result := PollDeviceToken(ctx, client, ClientAuth{AppID: "cli_a", AppSecret: "secret_b"}, core.BrandFeishu, "device-code", 0, 10, nil)
result := PollDeviceToken(ctx, client, "cli_a", "secret_b", core.BrandFeishu, "device-code", 0, 10, nil)
if result == nil {
t.Fatal("PollDeviceToken() returned nil result")
}

View File

@@ -1,153 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package jwt builds compact JWS tokens signed by a keysigner.Signer.
//
// It deliberately depends only on the standard library plus the existing
// google/uuid dependency — no third-party JWT library is introduced, keeping
// go.mod free of new dependencies. The actual signing (and, for ECDSA, the
// ASN.1->r||s conversion) is delegated to the Signer implementation.
package jwt
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/larksuite/cli/internal/keysigner"
)
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// buildSignedJWT builds a compact JWS:
//
// base64url(header).base64url(claims).base64url(signature)
//
// alg is written into the header (it is part of the signed input) and verified
// against the alg the signer reports, guarding against a header/key mismatch.
// typ defaults to "JWT": the server's client_assertion generalizedValidation
// REQUIRES `typ == "JWT"` (rejects otherwise with "malformed client assertion
// jwt"), even though the spec examples (§8.1/§8.2) show only alg.
func buildSignedJWT(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, alg string, header, claims map[string]any) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
if header == nil {
header = map[string]any{}
}
header["alg"] = alg
if _, ok := header["typ"]; !ok {
header["typ"] = "JWT"
}
hb, err := json.Marshal(header)
if err != nil {
return "", fmt.Errorf("jwt: marshal header: %w", err)
}
cb, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("jwt: marshal claims: %w", err)
}
signingInput := b64(hb) + "." + b64(cb)
sig, gotAlg, err := signer.Sign(ctx, ref, []byte(signingInput))
if err != nil {
return "", fmt.Errorf("jwt: sign: %w", err)
}
if gotAlg != alg {
return "", fmt.Errorf("jwt: signer alg %q does not match header alg %q", gotAlg, alg)
}
return signingInput + "." + b64(sig), nil
}
// newJTI returns a random unique token identifier.
func newJTI() string { return uuid.NewString() }
// attestationTTL bounds the attestation JWT's lifetime. The init nonce (60s,
// single-use) is the real anti-replay constraint; this is a modest margin for
// clock skew on top of the immediate init→sign→begin round-trip.
const attestationTTL = 2 * time.Minute
// attestationClaims builds the registration attestation claim set per the App
// Registration JWT spec: jti, iat, exp (all required) and the init-issued nonce.
func attestationClaims(nonce string, now time.Time) map[string]any {
return map[string]any{
"jti": newJTI(),
"iat": now.Unix(),
"exp": now.Add(attestationTTL).Unix(),
"nonce": nonce,
}
}
// clientAssertionClaims builds an RFC 7523 client_assertion claim set used to
// mint tokens in place of client_secret. aud is the brand's token endpoint URL.
func clientAssertionClaims(clientID, aud string, now time.Time, ttl time.Duration) map[string]any {
return map[string]any{
"iss": clientID,
"sub": clientID,
"aud": aud,
"iat": now.Unix(),
"exp": now.Add(ttl).Unix(),
"jti": newJTI(),
}
}
// ClientAssertionType is the RFC 7523 client_assertion_type value used for JWT
// bearer client authentication at the token endpoint.
const ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
// defaultAssertionTTL bounds a client_assertion's lifetime.
const defaultAssertionTTL = 5 * time.Minute
// SignAttestation signs the registration attestation JWT. The public key is
// embedded in the JWS "jwk" header so the registration backend can bind it to
// the app during action=begin; the claims carry the server nonce as a
// proof-of-possession challenge.
func SignAttestation(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, nonce string, now time.Time) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
pub, err := signer.EnsureKey(ctx, ref)
if err != nil {
return "", fmt.Errorf("jwt: ensure key: %w", err)
}
alg, err := keysigner.AlgForKey(pub)
if err != nil {
return "", err
}
jwk, err := keysigner.PublicKeyJWK(pub)
if err != nil {
return "", err
}
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{"jwk": jwk}, attestationClaims(nonce, now))
}
// SignClientAssertion mints a short-lived RFC 7523 client_assertion: it reads the
// registered key (it must already exist — bound at registration; a missing key is
// an error, not a reason to create a new unbound one), derives the JWS alg from
// the public key, and signs an assertion whose audience is the brand's Open API
// host. The server, holding the public key bound at registration, verifies it in
// place of client_secret. The assertion header carries only alg (no jwk/kid);
// the server locates the key via iss/sub = client_id.
//
// This is the model-independent glue: the assertion JWT is identical whether the
// server augments an existing grant (device_code/refresh_token) with client
// authentication or uses a dedicated jwt-bearer grant — only where the caller
// attaches it differs.
func SignClientAssertion(ctx context.Context, signer keysigner.Signer, ref keysigner.KeyRef, clientID, audience string, now time.Time) (string, error) {
if signer == nil {
return "", fmt.Errorf("jwt: no signer available (private_key_jwt unsupported on this build)")
}
pub, err := signer.PublicKey(ctx, ref)
if err != nil {
return "", fmt.Errorf("jwt: public key: %w", err)
}
alg, err := keysigner.AlgForKey(pub)
if err != nil {
return "", err
}
return buildSignedJWT(ctx, signer, ref, alg, map[string]any{}, clientAssertionClaims(clientID, audience, now, defaultAssertionTTL))
}

View File

@@ -1,254 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package jwt
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/keysigner"
)
// fakeSigner is a real in-memory ECDSA P-256 signer, so tests exercise the full
// JWS path and the produced token is actually cryptographically verifiable.
type fakeSigner struct{ key *ecdsa.PrivateKey }
func newFakeSigner(t *testing.T) *fakeSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeSigner{key: k}
}
func (f *fakeSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
// JOSE ES256: fixed-width big-endian r||s (32 bytes each for P-256).
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestBuildSignedJWT_VerifiableES256(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
tok, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{Label: "x"}, keysigner.AlgES256,
map[string]any{}, clientAssertionClaims("cli_app", "https://accounts.example/token", now, 5*time.Minute))
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 JWS parts, got %d", len(parts))
}
hb, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("header not base64url: %v", err)
}
var hdr map[string]any
if err := json.Unmarshal(hb, &hdr); err != nil {
t.Fatal(err)
}
if hdr["alg"] != "ES256" || hdr["typ"] != "JWT" {
t.Errorf("header = %v, want alg=ES256 typ=JWT (server generalizedValidation requires typ)", hdr)
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != "cli_app" || claims["sub"] != "cli_app" || claims["aud"] != "https://accounts.example/token" {
t.Errorf("claims = %v", claims)
}
// Cryptographically verify the signature against the signing input.
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Fatalf("sig not base64url: %v", err)
}
if len(sig) != 64 {
t.Fatalf("ES256 sig len = %d, want 64", len(sig))
}
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("signature did not verify")
}
}
func TestBuildSignedJWT_NilSigner(t *testing.T) {
if _, err := buildSignedJWT(context.Background(), nil, keysigner.KeyRef{}, "ES256", nil, nil); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestBuildSignedJWT_AlgMismatch(t *testing.T) {
f := newFakeSigner(t) // always reports ES256
if _, err := buildSignedJWT(context.Background(), f, keysigner.KeyRef{}, keysigner.AlgRS256, nil, nil); err == nil {
t.Fatal("expected error when header alg != signer alg")
}
}
func TestBuildSignedJWT_MarshalErrors(t *testing.T) {
f := newFakeSigner(t)
ctx := context.Background()
_, err := buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
map[string]any{"bad": func() {}}, nil)
if err == nil || !strings.Contains(err.Error(), "jwt: marshal header") {
t.Fatalf("header marshal error = %v, want prefix %q", err, "jwt: marshal header")
}
_, err = buildSignedJWT(ctx, f, keysigner.KeyRef{}, keysigner.AlgES256,
nil, map[string]any{"bad": make(chan int)})
if err == nil || !strings.Contains(err.Error(), "jwt: marshal claims") {
t.Fatalf("claims marshal error = %v, want prefix %q", err, "jwt: marshal claims")
}
}
func TestSignClientAssertion(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
const aud = "https://accounts.feishu.cn/open-apis/authen/v2/oauth/token"
tok, err := SignClientAssertion(context.Background(), f, keysigner.KeyRef{Label: "k"}, "cli_app", aud, now)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 parts, got %d", len(parts))
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != "cli_app" || claims["aud"] != aud {
t.Errorf("claims = %v", claims)
}
// Signature must verify against the key's public half.
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("client_assertion signature did not verify")
}
}
func TestSignClientAssertion_NilSigner(t *testing.T) {
if _, err := SignClientAssertion(context.Background(), nil, keysigner.KeyRef{}, "cli_app", "aud", time.Unix(0, 0)); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestSignAttestation(t *testing.T) {
f := newFakeSigner(t)
now := time.Unix(1700000000, 0)
tok, err := SignAttestation(context.Background(), f, keysigner.KeyRef{Label: "k"}, "nonce-abc", now)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 parts, got %d", len(parts))
}
hb, _ := base64.RawURLEncoding.DecodeString(parts[0])
var hdr map[string]any
if err := json.Unmarshal(hb, &hdr); err != nil {
t.Fatal(err)
}
jwk, ok := hdr["jwk"].(map[string]any)
if !ok {
t.Fatalf("attestation header missing jwk: %v", hdr)
}
if jwk["kty"] != "EC" || jwk["crv"] != "P-256" || jwk["use"] != "sig" {
t.Errorf("jwk = %v", jwk)
}
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(cb, &claims); err != nil {
t.Fatal(err)
}
if claims["nonce"] != "nonce-abc" {
t.Errorf("nonce = %v", claims["nonce"])
}
// jti, iat, exp are all required by the attestation spec.
iat, iatOK := claims["iat"].(float64)
exp, expOK := claims["exp"].(float64)
if !iatOK || !expOK || exp <= iat {
t.Errorf("claims iat/exp invalid: iat=%v exp=%v", claims["iat"], claims["exp"])
}
if jti, _ := claims["jti"].(string); jti == "" {
t.Error("claims jti empty")
}
// Signature verifies against the embedded key.
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
r := new(big.Int).SetBytes(sig[:32])
s := new(big.Int).SetBytes(sig[32:])
h := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if !ecdsa.Verify(f.key.Public().(*ecdsa.PublicKey), h[:], r, s) {
t.Error("attestation signature did not verify")
}
}
func TestSignAttestation_NilSigner(t *testing.T) {
if _, err := SignAttestation(context.Background(), nil, keysigner.KeyRef{}, "n", time.Unix(0, 0)); err == nil {
t.Fatal("expected error for nil signer")
}
}
func TestClaimFactories(t *testing.T) {
now := time.Unix(1700000000, 0)
a := attestationClaims("nonce-xyz", now)
if a["nonce"] != "nonce-xyz" || a["iat"] != now.Unix() {
t.Errorf("attestation claims = %v", a)
}
if a["exp"] != now.Add(attestationTTL).Unix() {
t.Errorf("attestation exp = %v, want %v", a["exp"], now.Add(attestationTTL).Unix())
}
if jti, _ := a["jti"].(string); jti == "" {
t.Error("attestation jti empty")
}
c := clientAssertionClaims("cli_app", "aud", now, time.Minute)
if c["exp"].(int64) != now.Add(time.Minute).Unix() {
t.Errorf("client_assertion exp = %v", c["exp"])
}
}

View File

@@ -21,7 +21,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/keysigner"
"github.com/larksuite/cli/internal/vfs"
)
@@ -38,10 +37,7 @@ type UATCallOptions struct {
AppId string
AppSecret string
Domain core.LarkBrand
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
KeyLabel string // TEE key handle for private_key_jwt
Signer keysigner.Signer // active signer for private_key_jwt
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
}
// UATStatus represents the status of a user access token.
@@ -65,9 +61,6 @@ func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions {
AppId: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: cfg.Brand,
AuthMethod: cfg.AuthMethod,
KeyLabel: cfg.KeyLabel,
Signer: keysigner.Active(),
ErrOut: errOut,
}
}
@@ -200,14 +193,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", stored.RefreshToken)
form.Set("client_id", opts.AppId)
ca := ClientAuth{AppID: opts.AppId, AppSecret: opts.AppSecret, AuthMethod: opts.AuthMethod, Signer: opts.Signer, KeyLabel: opts.KeyLabel}
usedAssertion, caErr := ca.applyClientAssertion(context.Background(), form, core.OpenAPIAudience(opts.Domain))
if caErr != nil {
return nil, caErr
}
if !usedAssertion {
form.Set("client_secret", opts.AppSecret)
}
form.Set("client_secret", opts.AppSecret)
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {

View File

@@ -38,23 +38,3 @@ func TestNewUATCallOptions(t *testing.T) {
t.Error("ErrOut not set correctly")
}
}
// TestNewUATCallOptions_PrivateKeyJWT verifies the auth-method fields propagate
// so the refresh path can mint a client_assertion instead of sending a secret.
func TestNewUATCallOptions_PrivateKeyJWT(t *testing.T) {
cfg := &core.CliConfig{
AppID: "cli_pk",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
AuthMethod: core.AuthMethodPrivateKeyJWT,
KeyLabel: "agent-key",
}
opts := NewUATCallOptions(cfg, &bytes.Buffer{})
if opts.AuthMethod != core.AuthMethodPrivateKeyJWT {
t.Errorf("AuthMethod = %q, want private_key_jwt", opts.AuthMethod)
}
if opts.KeyLabel != "agent-key" {
t.Errorf("KeyLabel = %q, want agent-key", opts.KeyLabel)
}
}

View File

@@ -131,31 +131,3 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
}
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -29,3 +29,31 @@ func checkOwnerUID(path, label string) error {
}
return nil
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -5,7 +5,22 @@
package binding
import (
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
func checkOwnerUID(path, label string) error {
return nil
}
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
if _, err := vfs.Stat(effectivePath); err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
return nil
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package binding
import (
"os"
"path/filepath"
"testing"
)
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secrets-getter.cmd")
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
t.Fatalf("write temp command: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "exec provider command",
AllowInsecurePath: false,
AllowReadableByOthers: true,
})
if err != nil {
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
}
if got != p {
t.Errorf("got %q, want %q", got, p)
}
}

View File

@@ -42,16 +42,6 @@ func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams {
return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal}
}
// StdoutIsTerminal reports whether Out is an interactive terminal. Unlike
// IsTerminal — which reflects stdin and drives prompt decisions — this is the
// correct check for OUTPUT formatting: `cmd | jq` must still emit machine output
// from an interactive shell (stdin is a TTY there, but stdout is the pipe).
// Buffers (tests) and redirects are not *os.File terminals, so they yield false.
func (s *IOStreams) StdoutIsTerminal() bool {
f, ok := s.Out.(*os.File)
return ok && term.IsTerminal(int(f.Fd()))
}
// SystemIO creates an IOStreams wired to the process's standard file descriptors.
//
//nolint:forbidigo // entry point for real stdio

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"os"
"testing"
)
func TestStdoutIsTerminal(t *testing.T) {
// Buffer-backed output (tests, captured output) is never a terminal.
if (&IOStreams{Out: &bytes.Buffer{}}).StdoutIsTerminal() {
t.Error("bytes.Buffer Out should not be a terminal")
}
// An os.Pipe write end is an *os.File but not a terminal — mirrors `cmd | jq`,
// the case the stdin-based IsTerminal would get wrong.
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
if (&IOStreams{Out: w}).StdoutIsTerminal() {
t.Error("os.Pipe Out should not be a terminal")
}
}

View File

@@ -36,13 +36,6 @@ type AppUser struct {
UserName string `json:"userName"`
}
// Auth methods for app credentials. An empty AppConfig.AuthMethod means the
// default, client_secret.
const (
AuthMethodClientSecret = "client_secret" // app_id + app_secret
AuthMethodPrivateKeyJWT = "private_key_jwt" // TEE-signed client_assertion; no app secret
)
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
type AppConfig struct {
Name string `json:"name,omitempty"`
@@ -53,15 +46,6 @@ type AppConfig struct {
DefaultAs Identity `json:"defaultAs,omitempty"` // AsUser | AsBot | AsAuto
StrictMode *StrictMode `json:"strictMode,omitempty"`
Users []AppUser `json:"users"`
// AuthMethod selects how tokens are minted. Empty == AuthMethodClientSecret
// (back-compat). AuthMethodPrivateKeyJWT uses a TEE-held key (see KeyRef) to
// sign client_assertion JWTs instead of sending an app secret.
AuthMethod string `json:"authMethod,omitempty"`
// KeyRef references the non-exportable signing key for private_key_jwt.
// Source is "tee" and ID is the backend key label; the actual key never
// leaves the secure backend, so this is a handle, not secret material.
KeyRef *SecretRef `json:"keyRef,omitempty"`
}
// ProfileName returns the display name for this app config.
@@ -177,9 +161,7 @@ type CliConfig struct {
UserOpenId string
UserName string
Lang i18n.Lang
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
AuthMethod string // "" == client_secret; AuthMethodPrivateKeyJWT
KeyLabel string // resolved TEE key handle for private_key_jwt
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
}
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
@@ -265,58 +247,31 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
WithHint("available profiles: %s", formatProfileNames(raw.ProfileNames()))
}
// Validate the auth method first so a malformed profile fails here rather
// than silently degrading to client_secret (unknown method) or failing later
// at token-signing. Empty stays empty — downstream treats it as client_secret
// (back-compat).
switch app.AuthMethod {
case "", AuthMethodClientSecret, AuthMethodPrivateKeyJWT:
default:
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "unknown authMethod %q", app.AuthMethod).
WithHint("supported: %s, %s (empty defaults to %s)", AuthMethodClientSecret, AuthMethodPrivateKeyJWT, AuthMethodClientSecret)
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
WithHint("%s", err.Error()).
WithCause(err)
}
// private_key_jwt carries no secret: validate the key handle and skip secret
// resolution entirely, so a stale/broken AppSecret ref never produces a
// confusing secret-resolution error for an otherwise-valid pkjwt profile.
var secret string
if app.AuthMethod == AuthMethodPrivateKeyJWT {
if app.KeyRef == nil || app.KeyRef.Source != "tee" || app.KeyRef.ID == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "private_key_jwt requires a key handle (keyRef) but none is configured").
WithHint("re-run: lark-cli config init --new --auth-method private_key_jwt")
secret, err := ResolveSecretInput(app.AppSecret, kc)
if err != nil {
if errs.IsTyped(err) {
return nil, err
}
} else {
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
WithHint("%s", err.Error()).
WithCause(err)
}
var resolveErr error
secret, resolveErr = ResolveSecretInput(app.AppSecret, kc)
if resolveErr != nil {
if errs.IsTyped(resolveErr) {
return nil, resolveErr
}
subtype := errs.SubtypeNotConfigured
if isMalformedConfigError(resolveErr) {
subtype = errs.SubtypeInvalidConfig
}
return nil, errs.NewConfigError(subtype, "%s", resolveErr.Error()).WithCause(resolveErr)
subtype := errs.SubtypeNotConfigured
if isMalformedConfigError(err) {
subtype = errs.SubtypeInvalidConfig
}
return nil, errs.NewConfigError(subtype, "%s", err.Error()).WithCause(err)
}
cfg := &CliConfig{
ProfileName: app.ProfileName(),
AppID: app.AppId,
AppSecret: secret,
Brand: app.Brand,
Lang: app.Lang,
AuthMethod: app.AuthMethod,
DefaultAs: app.DefaultAs,
}
if app.KeyRef != nil {
cfg.KeyLabel = app.KeyRef.ID
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId
cfg.UserName = app.Users[0].UserName

View File

@@ -133,108 +133,6 @@ func TestResolveConfigFromMulti_AcceptsPlainSecret(t *testing.T) {
}
}
// TestResolveConfigFromMulti_RejectsUnknownAuthMethod ensures an unsupported
// authMethod fails at resolution rather than silently degrading to client_secret.
func TestResolveConfigFromMulti_RejectsUnknownAuthMethod(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: PlainSecret("my-secret"),
Brand: BrandFeishu,
AuthMethod: "bogus_method",
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for unknown authMethod")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
}
// TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef ensures private_key_jwt
// without a key handle fails at resolution rather than later at token-signing.
func TestResolveConfigFromMulti_PrivateKeyJWTRequiresKeyRef(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{
{
AppId: "cli_abc",
AppSecret: SecretInput{}, // private_key_jwt carries no app secret
Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT,
// KeyRef intentionally nil
},
},
}
_, err := ResolveConfigFromMulti(raw, nil, "")
if err == nil {
t.Fatal("expected error for private_key_jwt without keyRef")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected ConfigError, got %T: %v", err, err)
}
// Control: same config WITH a keyRef resolves cleanly and sets KeyLabel.
raw.Apps[0].KeyRef = &SecretRef{Source: "tee", ID: "larksuite-cli-agent"}
cfg, err := ResolveConfigFromMulti(raw, nil, "")
if err != nil {
t.Fatalf("unexpected error with keyRef present: %v", err)
}
if cfg.KeyLabel != "larksuite-cli-agent" {
t.Errorf("KeyLabel = %q, want larksuite-cli-agent", cfg.KeyLabel)
}
}
// TestResolveConfigFromMulti_PKJWTSkipsSecretResolution ensures a private_key_jwt
// profile that carries a stale/broken AppSecret ref still resolves cleanly: the
// auth method is judged before any secret handling, so the stale ref is ignored
// instead of producing a confusing secret-resolution failure.
func TestResolveConfigFromMulti_PKJWTSkipsSecretResolution(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{{
AppId: "cli_pk",
// Stale keychain ref whose ID does not match appId — would trip
// ValidateSecretKeyMatch / ResolveSecretInput if it were reached.
AppSecret: SecretInput{Ref: &SecretRef{Source: "keychain", ID: "appsecret:cli_OTHER"}},
Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT,
KeyRef: &SecretRef{Source: "tee", ID: "agent-key"},
Users: []AppUser{},
}},
}
cfg, err := ResolveConfigFromMulti(raw, stubKeychain{}, "")
if err != nil {
t.Fatalf("pkjwt with stale secret ref must skip secret resolution, got %v", err)
}
if cfg.AuthMethod != AuthMethodPrivateKeyJWT || cfg.KeyLabel != "agent-key" {
t.Errorf("got authMethod=%q keyLabel=%q", cfg.AuthMethod, cfg.KeyLabel)
}
}
// TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef ensures the stricter keyRef
// check (Source=="tee" && ID!="") rejects malformed handles.
func TestResolveConfigFromMulti_PKJWTRejectsBadKeyRef(t *testing.T) {
for i, ref := range []*SecretRef{
{Source: "keychain", ID: "x"}, // wrong source
{Source: "tee", ID: ""}, // empty id
} {
raw := &MultiAppConfig{Apps: []AppConfig{{
AppId: "cli_pk", Brand: BrandFeishu,
AuthMethod: AuthMethodPrivateKeyJWT, KeyRef: ref, Users: []AppUser{},
}}}
if _, err := ResolveConfigFromMulti(raw, stubKeychain{}, ""); err == nil {
t.Errorf("case %d: expected ConfigError for bad keyRef", i)
}
}
}
func TestResolveConfigFromMulti_CarriesLang(t *testing.T) {
raw := &MultiAppConfig{
Apps: []AppConfig{

View File

@@ -3,8 +3,6 @@
package core
import "strings"
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
// Any other string is treated as a custom base URL.
@@ -62,10 +60,3 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
func ResolveOpenBaseURL(brand LarkBrand) string {
return ResolveEndpoints(brand).Open
}
// OpenAPIAudience returns the client_assertion `aud` value for the brand: the
// bare Open API host per the App Authentication JWT spec — "open.feishu.cn" or
// "open.larksuite.com" — not the full token endpoint URL.
func OpenAPIAudience(brand LarkBrand) string {
return strings.TrimPrefix(ResolveOpenBaseURL(brand), "https://")
}

View File

@@ -57,12 +57,3 @@ func TestResolveOpenBaseURL(t *testing.T) {
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
}
}
func TestOpenAPIAudience(t *testing.T) {
if got := OpenAPIAudience(BrandFeishu); got != "open.feishu.cn" {
t.Errorf("OpenAPIAudience(feishu) = %q, want open.feishu.cn", got)
}
if got := OpenAPIAudience(BrandLark); got != "open.larksuite.com" {
t.Errorf("OpenAPIAudience(lark) = %q, want open.larksuite.com", got)
}
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/larksuite/cli/internal/keychain"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/keysigner"
)
// classifyTATResponseCode wraps a deterministic (non-transient) failure from the
@@ -176,23 +175,6 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
if err != nil {
return nil, err
}
// private_key_jwt apps have no app secret: mint via the jwt-bearer grant
// using a TEE-signed client_assertion instead.
if acct.AuthMethod == core.AuthMethodPrivateKeyJWT {
signer := keysigner.Active()
if signer == nil {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient,
"profile uses private_key_jwt but no TEE key signer is available on this build").
WithHint("install a build with the platform key-signer extension, or reconfigure the app to use an app secret")
}
token, err := FetchTATWithAssertion(ctx, httpClient, acct.Brand, acct.AppID, signer, acct.KeyLabel)
if err != nil {
return nil, err
}
return &TokenResult{Token: token}, nil
}
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
if err != nil {
return nil, err

View File

@@ -11,13 +11,8 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/auth/jwt"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// FetchTAT performs a single HTTP POST to mint a tenant access token via the
@@ -105,96 +100,3 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand
}
return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID)
}
// FetchTATWithAssertion mints a tenant access token for a private_key_jwt app via
// the RFC 7523 jwt-bearer grant: it signs a short-lived client_assertion with the
// TEE-held key and posts it to the unified OAuth token endpoint, replacing the
// app_secret entirely.
//
// The unified v2 token endpoint returns the minted token as access_token
// (tenant_access_token is accepted as a fallback).
func FetchTATWithAssertion(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, clientID string, signer keysigner.Signer, keyLabel string) (string, error) {
if signer == nil {
return "", fmt.Errorf("private_key_jwt requires a key signer, but none is available on this build")
}
ep := core.ResolveEndpoints(brand)
endpoint := ep.Open + auth.PathOAuthTokenV2
assertion, err := jwt.SignClientAssertion(ctx, signer, keysigner.KeyRef{Label: keyLabel}, clientID, core.OpenAPIAudience(brand), time.Now())
if err != nil {
return "", err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("client_id", clientID)
form.Set("client_assertion_type", jwt.ClientAssertionType)
form.Set("client_assertion", assertion)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read token response: %w", err)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
AccessToken string `json:"access_token"`
TenantAccessToken string `json:"tenant_access_token"`
}
_ = json.Unmarshal(body, &result) // best-effort; error body may not be JSON
token := result.AccessToken
if token == "" {
token = result.TenantAccessToken
}
if resp.StatusCode == http.StatusOK && token != "" && result.Error == "" && result.Code == 0 {
return token, nil
}
// Surface the server's reason, preferring the OAuth `error` code (e.g.
// unauthorized_client) which is more diagnostic than the description alone.
detail := result.ErrorDescription
if detail == "" {
detail = result.Msg
}
if detail == "" {
detail = strings.TrimSpace(string(body))
}
if result.Error != "" {
return "", classifyAssertionError(result.Error, resp.StatusCode, detail)
}
return "", fmt.Errorf("token endpoint HTTP %d (code=%d): %s", resp.StatusCode, result.Code, detail)
}
// classifyAssertionError maps the OAuth token endpoint's `error` field to a
// typed or untyped error. Only deterministic client-credential rejections get a
// typed errs.ConfigError (so runProbePKJWT can tell "this key is not bound to
// this app" apart from upstream noise); every other error (e.g.
// temporarily_unavailable) stays untyped and is swallowed by the probe. detail
// carries only the server's error_description / msg / body text — it never
// echoes the client_assertion or private key (the assertion lives only in the
// request form).
func classifyAssertionError(oauthError string, httpStatus int, detail string) error {
switch oauthError {
case "invalid_client", "unauthorized_client", "invalid_grant":
return errs.NewConfigError(errs.SubtypeInvalidClient,
"token endpoint rejected the key (%s): %s", oauthError, detail)
default:
return fmt.Errorf("token endpoint HTTP %d (%s): %s", httpStatus, oauthError, detail)
}
}

View File

@@ -5,24 +5,15 @@ package credential
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keysigner"
)
// stubRoundTripper lets us assert request shape and return canned responses.
@@ -316,147 +307,3 @@ func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
req2.Header = req.Header
return http.DefaultTransport.RoundTrip(req2)
}
// fakeTATSigner is a real in-memory ECDSA P-256 signer for assertion tests.
type fakeTATSigner struct{ key *ecdsa.PrivateKey }
func newFakeTATSigner(t *testing.T) *fakeTATSigner {
t.Helper()
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
return &fakeTATSigner{key: k}
}
func (f *fakeTATSigner) EnsureKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeTATSigner) PublicKey(context.Context, keysigner.KeyRef) (crypto.PublicKey, error) {
return f.key.Public(), nil
}
func (f *fakeTATSigner) Sign(_ context.Context, _ keysigner.KeyRef, in []byte) ([]byte, string, error) {
h := sha256.Sum256(in)
r, s, err := ecdsa.Sign(rand.Reader, f.key, h[:])
if err != nil {
return nil, "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return sig, keysigner.AlgES256, nil
}
func TestFetchTATWithAssertion_Success(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"access_token":"t-jwt","token_type":"Bearer","expires_in":7200}`}
hc := &http.Client{Transport: rt}
token, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "agent-key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "t-jwt" {
t.Errorf("token = %q, want t-jwt", token)
}
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
t.Errorf("url = %s", rt.gotReq.URL.String())
}
form, err := url.ParseQuery(rt.gotBody)
if err != nil {
t.Fatal(err)
}
if form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:jwt-bearer" {
t.Errorf("grant_type = %q", form.Get("grant_type"))
}
if form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
t.Errorf("client_assertion_type = %q", form.Get("client_assertion_type"))
}
if form.Get("client_assertion") == "" {
t.Error("client_assertion is empty")
}
if form.Has("client_secret") {
t.Error("client_secret must NOT be sent for private_key_jwt")
}
// The assertion's aud must be the bare Open host per the App Authentication
// JWT spec — not the full token endpoint URL.
jwtParts := strings.Split(form.Get("client_assertion"), ".")
if len(jwtParts) != 3 {
t.Fatalf("malformed client_assertion: %q", form.Get("client_assertion"))
}
payload, err := base64.RawURLEncoding.DecodeString(jwtParts[1])
if err != nil {
t.Fatalf("assertion payload not base64url: %v", err)
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
t.Fatal(err)
}
if claims["aud"] != "open.feishu.cn" {
t.Errorf("client_assertion aud = %v, want open.feishu.cn", claims["aud"])
}
if claims["iss"] != "cli_app" || claims["sub"] != "cli_app" {
t.Errorf("client_assertion iss/sub = %v/%v, want cli_app", claims["iss"], claims["sub"])
}
if form.Get("client_id") != "cli_app" {
t.Errorf("client_id = %q", form.Get("client_id"))
}
}
func TestFetchTATWithAssertion_NilSigner(t *testing.T) {
hc := &http.Client{Transport: &stubRoundTripper{respCode: 200, respBody: `{}`}}
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", nil, "k"); err == nil {
t.Fatal("expected error when signer is nil")
}
}
func TestFetchTATWithAssertion_ServerError(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"error":"invalid_client","error_description":"unknown key"}`}
hc := &http.Client{Transport: rt}
if _, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k"); err == nil {
t.Fatal("expected error for invalid_client response")
}
}
// Deterministic OAuth client rejections must be typed (ConfigError /
// SubtypeInvalidClient) so runProbePKJWT can tell "the key is not bound to this
// app" apart from transport noise.
func TestFetchTATWithAssertion_DeterministicReject_Typed(t *testing.T) {
for _, oauthErr := range []string{"invalid_client", "unauthorized_client", "invalid_grant"} {
rt := &stubRoundTripper{respCode: 401, respBody: `{"error":"` + oauthErr + `","error_description":"bad key"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
if err == nil {
t.Fatalf("%s: expected error", oauthErr)
}
if !errs.IsTyped(err) {
t.Errorf("%s: must be typed, got %T", oauthErr, err)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) || cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("%s: want ConfigError/InvalidClient, got %T %v", oauthErr, err, err)
}
}
}
// Unrecognized OAuth errors and non-payload noise stay UNTYPED so the probe
// treats them as upstream noise and stays silent.
func TestFetchTATWithAssertion_AmbiguousError_Untyped(t *testing.T) {
cases := []string{
`{"error":"temporarily_unavailable","error_description":"retry"}`,
`{"code":99999,"msg":"weird"}`,
`not json`,
}
for _, body := range cases {
rt := &stubRoundTripper{respCode: 503, respBody: body}
hc := &http.Client{Transport: rt}
_, err := FetchTATWithAssertion(context.Background(), hc, core.BrandFeishu, "cli_app", newFakeTATSigner(t), "k")
if err == nil {
t.Fatalf("body %q: expected error", body)
}
if errs.IsTyped(err) {
t.Errorf("body %q: must be UNTYPED, got typed %T", body, err)
}
}
}

View File

@@ -26,8 +26,6 @@ type Account struct {
UserName string
Lang i18n.Lang
SupportedIdentities uint8
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
KeyLabel string // resolved TEE key handle for private_key_jwt
}
const runtimePlaceholderAppSecret = "__LARKSUITE_CLI_TOKEN_ONLY__"
@@ -71,8 +69,6 @@ func AccountFromCliConfig(cfg *core.CliConfig) *Account {
UserName: cfg.UserName,
Lang: cfg.Lang,
SupportedIdentities: cfg.SupportedIdentities,
AuthMethod: cfg.AuthMethod,
KeyLabel: cfg.KeyLabel,
}
}
@@ -91,8 +87,6 @@ func (a *Account) ToCliConfig() *core.CliConfig {
UserName: a.UserName,
Lang: a.Lang,
SupportedIdentities: a.SupportedIdentities,
AuthMethod: a.AuthMethod,
KeyLabel: a.KeyLabel,
}
}

View File

@@ -82,9 +82,7 @@ func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, v
Hint: "check strict mode or the active credential provider",
}
}
// private_key_jwt apps have no app secret — the bot/tenant token is minted via
// a TEE-signed client_assertion — so absence of a secret is NOT "unconfigured".
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) && cfg.AuthMethod != core.AuthMethodPrivateKeyJWT {
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
return Identity{
Status: StatusNotConfigured,
Message: "Bot identity: not configured (missing app secret or bot token)",

View File

@@ -1,212 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package keysigner defines the pluggable signing abstraction used by the
// private_key_jwt registration and authentication flow.
//
// The open-source core only declares the Signer interface and pure-stdlib key
// helpers. The platform implementations that hold a non-exportable private key
// (TPM 2.0 via facebookincubator/sks on Linux/Windows, a non-extractable
// Keychain key on macOS) live OUTSIDE this core — in a build-tagged module or
// extension — and register themselves via Register from init(). This keeps
// CGO-heavy and license-sensitive dependencies out of the open-source build.
package keysigner
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"errors"
"fmt"
"math/big"
"strings"
)
// KeyRef identifies a non-exportable signing key held by a backend
// (TEE/TPM/Keychain). It is a stable handle (label), never the key material.
type KeyRef struct {
// Label is the backend key label/tag (e.g. "larksuite-cli-agent").
Label string
}
// Signer signs JWS signing inputs with a non-exportable key.
type Signer interface {
// EnsureKey returns the public key for ref, creating the key if absent.
EnsureKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error)
// PublicKey returns the public key for ref without creating it.
PublicKey(ctx context.Context, ref KeyRef) (crypto.PublicKey, error)
// Sign signs signingInput and returns a JOSE-format signature plus the JWS
// alg ("ES256"/"RS256"). Implementations apply the alg's hash and, for
// ECDSA, MUST return the fixed-width r||s form required by RFC 7518 §3.4
// (not ASN.1 DER), because the backend (TPM/Keychain) typically yields DER.
Sign(ctx context.Context, ref KeyRef, signingInput []byte) (sig []byte, alg string, err error)
}
// Supported JWS algorithms.
const (
AlgES256 = "ES256"
AlgRS256 = "RS256"
)
// DefaultKeyLabel is the backend key label lark-cli uses for its device signing
// key. One non-exportable key is created on first private_key_jwt registration
// and reused across subsequent app registrations on the same device.
const DefaultKeyLabel = "larksuite-cli-agent"
// HardwareInfo describes the secure hardware backing a Signer, as reported by a
// HardwareProber. It is advisory/diagnostic: it tells a user whether
// private_key_jwt can use a real TEE on this device.
type HardwareInfo struct {
Backend string // backing technology, e.g. "tpm2" or "keychain"
Available bool // the hardware is present and usable for signing
VendorName string // hardware vendor/manufacturer, when known
VendorInfo string // additional vendor detail, when known
Reason string // when Available is false, a human-readable cause
}
// HardwareProber is an optional capability a Signer may implement to report on
// the secure hardware backing it (TPM/TEE vendor and availability) WITHOUT
// creating or using a key. Probing never mutates key state.
type HardwareProber interface {
ProbeHardware(ctx context.Context) (HardwareInfo, error)
}
// ProbeActiveHardware probes the active signer's secure hardware. ok is false
// when there is no active signer or it does not implement HardwareProber — in
// which case private_key_jwt is unsupported on this build. When ok is true, info
// reports availability and, if unavailable, info.Reason explains why.
func ProbeActiveHardware(ctx context.Context) (info HardwareInfo, ok bool, err error) {
return probeHardware(ctx, Active())
}
// probeHardware is the registry-independent core of ProbeActiveHardware, so it
// can be unit-tested without touching the global signer.
func probeHardware(ctx context.Context, s Signer) (HardwareInfo, bool, error) {
p, ok := s.(HardwareProber)
if !ok {
return HardwareInfo{}, false, nil
}
info, err := p.ProbeHardware(ctx)
return info, true, err
}
// cleanProbeError renders err's message with redundant re-wraps collapsed. Some
// backends (e.g. facebookincubator/sks) wrap an error twice with the SAME "%w"
// prefix, yielding "P: P: cause"; this peels each outer layer whose only
// contribution is to repeat the prefix already present in the wrapped error,
// leaving a single "P: cause". A layer that adds genuinely new context is kept.
func cleanProbeError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
for {
inner := errors.Unwrap(err)
if inner == nil {
break
}
innerMsg := inner.Error()
prefix, ok := strings.CutSuffix(msg, innerMsg)
if !ok || prefix == "" || !strings.HasPrefix(innerMsg, prefix) {
break
}
msg, err = innerMsg, inner
}
return msg
}
// AlgForKey returns the JWS alg for a public key: EC P-256 -> ES256, RSA -> RS256.
// The signer backend chooses the key type (the macOS keychain signer uses an
// RSA-2048 key, hence RS256).
func AlgForKey(pub crypto.PublicKey) (string, error) {
switch k := pub.(type) {
case *ecdsa.PublicKey:
if k.Curve == elliptic.P256() {
return AlgES256, nil
}
return "", fmt.Errorf("keysigner: unsupported EC curve %q (only P-256/ES256)", k.Curve.Params().Name)
case *rsa.PublicKey:
return AlgRS256, nil
default:
return "", fmt.Errorf("keysigner: unsupported public key type %T", pub)
}
}
// ecdsaDERToJOSE converts an ASN.1 DER-encoded ECDSA signature — the form most
// TEE/TPM backends emit (e.g. facebookincubator/sks marshals the TPM's r,s with
// asn1.Marshal) — into the fixed-width r||s form JWS requires for ES256
// (RFC 7518 §3.4). byteLen is the curve coordinate size (32 for P-256), so the
// result is exactly 2*byteLen bytes with r and s each left-zero-padded.
//
// This is intentionally part of the pure-stdlib core (not a platform signer) so
// it can be unit-tested with a software key on any machine, including TPM-less CI.
func ecdsaDERToJOSE(der []byte, byteLen int) ([]byte, error) {
var sig struct{ R, S *big.Int }
rest, err := asn1.Unmarshal(der, &sig)
if err != nil {
return nil, fmt.Errorf("keysigner: parse ECDSA DER signature: %w", err)
}
if len(rest) != 0 {
return nil, fmt.Errorf("keysigner: %d trailing byte(s) after ECDSA DER signature", len(rest))
}
if sig.R == nil || sig.S == nil || sig.R.Sign() <= 0 || sig.S.Sign() <= 0 {
return nil, fmt.Errorf("keysigner: ECDSA signature has non-positive r/s")
}
// Guard before FillBytes, which panics if the scalar does not fit in byteLen.
if sig.R.BitLen() > byteLen*8 || sig.S.BitLen() > byteLen*8 {
return nil, fmt.Errorf("keysigner: ECDSA r/s exceeds %d-byte coordinate", byteLen)
}
out := make([]byte, 2*byteLen)
sig.R.FillBytes(out[:byteLen])
sig.S.FillBytes(out[byteLen:])
return out, nil
}
// EncodePublicKey marshals pub to PKIX DER and base64-encodes it (std encoding),
// matching the public-key form the registration backend binds to the app.
func EncodePublicKey(pub crypto.PublicKey) (string, error) {
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", fmt.Errorf("keysigner: encode public key: %w", err)
}
return base64.StdEncoding.EncodeToString(der), nil
}
// PublicKeyJWK returns the RFC 7517 JSON Web Key for pub, used to embed the
// public key in the attestation JWT's "jwk" header so the registration backend
// can bind it to the app. EC keys use base64url fixed-width coordinates
// (RFC 7518 §6.2.1); RSA keys use base64url-encoded modulus and exponent.
func PublicKeyJWK(pub crypto.PublicKey) (map[string]any, error) {
switch k := pub.(type) {
case *ecdsa.PublicKey:
if k.Curve != elliptic.P256() {
return nil, fmt.Errorf("keysigner: JWK supports EC P-256 only, got %q", k.Curve.Params().Name)
}
const coordLen = 32 // P-256 field element size
x := make([]byte, coordLen)
y := make([]byte, coordLen)
k.X.FillBytes(x)
k.Y.FillBytes(y)
return map[string]any{
"use": "sig",
"kty": "EC",
"crv": "P-256",
"x": base64.RawURLEncoding.EncodeToString(x),
"y": base64.RawURLEncoding.EncodeToString(y),
}, nil
case *rsa.PublicKey:
return map[string]any{
"use": "sig",
"kty": "RSA",
"n": base64.RawURLEncoding.EncodeToString(k.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()),
}, nil
default:
return nil, fmt.Errorf("keysigner: unsupported public key type %T for JWK", pub)
}
}

View File

@@ -1,240 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"math/big"
"reflect"
"testing"
)
func TestAlgForKey(t *testing.T) {
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
if alg, err := AlgForKey(ec.Public()); err != nil || alg != AlgES256 {
t.Errorf("P-256: alg=%q err=%v, want ES256/nil", alg, err)
}
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
if alg, err := AlgForKey(rsaKey.Public()); err != nil || alg != AlgRS256 {
t.Errorf("RSA: alg=%q err=%v, want RS256/nil", alg, err)
}
ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatal(err)
}
if _, err := AlgForKey(ec384.Public()); err == nil {
t.Error("P-384: expected unsupported-curve error")
}
if _, err := AlgForKey("not a key"); err == nil {
t.Error("string: expected unsupported-type error")
}
}
func TestEncodePublicKeyRoundTrip(t *testing.T) {
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
enc, err := EncodePublicKey(ec.Public())
if err != nil {
t.Fatal(err)
}
der, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
t.Fatalf("not valid base64: %v", err)
}
pub, err := x509.ParsePKIXPublicKey(der)
if err != nil {
t.Fatalf("not valid PKIX: %v", err)
}
if !reflect.DeepEqual(pub, ec.Public()) {
t.Error("public key did not round-trip")
}
}
func TestPublicKeyJWK_EC(t *testing.T) {
ec, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
jwk, err := PublicKeyJWK(ec.Public())
if err != nil {
t.Fatal(err)
}
if jwk["kty"] != "EC" || jwk["crv"] != "P-256" {
t.Errorf("jwk = %v, want kty=EC crv=P-256", jwk)
}
if jwk["use"] != "sig" {
t.Errorf("jwk use = %v, want sig", jwk["use"])
}
x, _ := jwk["x"].(string)
xb, err := base64.RawURLEncoding.DecodeString(x)
if err != nil || len(xb) != 32 {
t.Errorf("x = %q (decoded %d bytes), want 32-byte base64url", x, len(xb))
}
if _, ok := jwk["y"].(string); !ok {
t.Error("jwk missing y")
}
}
func TestPublicKeyJWK_RSA(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
jwk, err := PublicKeyJWK(rsaKey.Public())
if err != nil {
t.Fatal(err)
}
if jwk["kty"] != "RSA" || jwk["n"] == "" || jwk["e"] == "" {
t.Errorf("jwk = %v, want kty=RSA with n,e", jwk)
}
if jwk["use"] != "sig" {
t.Errorf("jwk use = %v, want sig", jwk["use"])
}
}
func TestPublicKeyJWK_UnsupportedCurve(t *testing.T) {
ec384, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatal(err)
}
if _, err := PublicKeyJWK(ec384.Public()); err == nil {
t.Error("P-384: expected error")
}
}
func TestECDSADERToJOSE(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
// Iterate so we hit signatures whose r or s has its high bit set (ASN.1 pads
// those with a leading 0x00) and whose scalars are short (need left-zero
// padding) — verifying fixed-width conversion in both directions.
for i := 0; i < 64; i++ {
digest := sha256.Sum256([]byte{byte(i), byte(i >> 8), 'j', 'w', 't'})
der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
if err != nil {
t.Fatal(err)
}
jose, err := ecdsaDERToJOSE(der, 32)
if err != nil {
t.Fatalf("iter %d: %v", i, err)
}
if len(jose) != 64 {
t.Fatalf("iter %d: len(jose)=%d, want 64 (fixed-width r||s)", i, len(jose))
}
r := new(big.Int).SetBytes(jose[:32])
s := new(big.Int).SetBytes(jose[32:])
if !ecdsa.Verify(&key.PublicKey, digest[:], r, s) {
t.Fatalf("iter %d: converted r||s did not verify against the public key", i)
}
}
}
func TestECDSADERToJOSE_Errors(t *testing.T) {
if _, err := ecdsaDERToJOSE([]byte{0x01, 0x02, 0x03}, 32); err == nil {
t.Error("garbage DER: expected error")
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256([]byte("trailing"))
der, err := ecdsa.SignASN1(rand.Reader, key, digest[:])
if err != nil {
t.Fatal(err)
}
if _, err := ecdsaDERToJOSE(append(der, 0x00), 32); err == nil {
t.Error("DER with trailing byte: expected error")
}
}
type stubSigner struct{}
func (stubSigner) EnsureKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil }
func (stubSigner) PublicKey(context.Context, KeyRef) (crypto.PublicKey, error) { return nil, nil }
func (stubSigner) Sign(context.Context, KeyRef, []byte) ([]byte, string, error) { return nil, "", nil }
func TestCleanProbeError(t *testing.T) {
cause := errors.New("open /dev/tpmrm0: permission denied")
const p = "sks: error fetching Secure Hardware Vendor Data: "
// sks double-wraps with the same %w prefix → collapse to a single prefix.
doubled := fmt.Errorf(p+"%w", fmt.Errorf(p+"%w", cause))
if got, want := cleanProbeError(doubled), p+cause.Error(); got != want {
t.Errorf("doubled: got %q, want %q", got, want)
}
// Triple wrap collapses too.
if got, want := cleanProbeError(fmt.Errorf(p+"%w", doubled)), p+cause.Error(); got != want {
t.Errorf("tripled: got %q, want %q", got, want)
}
// A layer adding genuinely new context is preserved.
if got, want := cleanProbeError(fmt.Errorf("load: %w", cause)), "load: "+cause.Error(); got != want {
t.Errorf("distinct prefix: got %q, want %q", got, want)
}
// nil and unwrapped-leaf cases.
if got := cleanProbeError(nil); got != "" {
t.Errorf("nil: got %q, want empty", got)
}
if got := cleanProbeError(cause); got != cause.Error() {
t.Errorf("leaf: got %q, want %q", got, cause.Error())
}
}
type proberSigner struct {
stubSigner
info HardwareInfo
}
func (p proberSigner) ProbeHardware(context.Context) (HardwareInfo, error) { return p.info, nil }
func TestProbeHardware(t *testing.T) {
// nil signer and a signer that does not implement HardwareProber both yield ok=false.
if _, ok, _ := probeHardware(context.Background(), nil); ok {
t.Error("nil signer: ok should be false")
}
if _, ok, _ := probeHardware(context.Background(), stubSigner{}); ok {
t.Error("non-prober signer: ok should be false")
}
want := HardwareInfo{Backend: "tpm2", Available: true, VendorName: "ACME"}
info, ok, err := probeHardware(context.Background(), proberSigner{info: want})
if err != nil || !ok {
t.Fatalf("prober: ok=%v err=%v, want true/nil", ok, err)
}
if info != want {
t.Errorf("info = %+v, want %+v", info, want)
}
}
func TestRegistry(t *testing.T) {
if Active() != nil {
t.Skip("a signer is already registered in this build")
}
Register(stubSigner{})
if _, ok := Active().(stubSigner); !ok {
t.Error("Active did not return the registered signer")
}
}

View File

@@ -1,29 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import "sync"
var (
mu sync.RWMutex
active Signer
)
// Register sets the active Signer. It is typically called from the init() of a
// build-tagged or extension package that provides the platform TEE/Keychain
// implementation. The last registration wins (one backend per platform).
func Register(s Signer) {
mu.Lock()
defer mu.Unlock()
active = s
}
// Active returns the registered Signer, or nil if none is available — in which
// case private_key_jwt is unsupported on this build and only client_secret auth
// can be used.
func Active() Signer {
mu.RLock()
defer mu.RUnlock()
return active
}

View File

@@ -1,613 +0,0 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// macOS non-exportable Keychain signer (compiled into every darwin build).
//
// It does NOT use the Secure Enclave / hardware TEE (which would require
// code-signing entitlements that are unfriendly to open source). Instead it
// generates an RSA-2048 key in software, imports it into a dedicated app
// keychain as NON-EXTRACTABLE (`security import -x`), then deletes the software
// copy — so the private key can sign but can never be exported. Signing is
// RSASSA-PKCS1v15-SHA256 (RS256).
//
// Unlike the original revision, this implementation calls the Security and
// CoreFoundation frameworks via RUNTIME FFI (github.com/ebitengine/purego)
// instead of cgo. The security model is identical (the private key is still a
// non-extractable keychain key and every signature is produced by the OS via
// SecKeyCreateSignature), but the binary builds with CGO_ENABLED=0 and can be
// cross-compiled for darwin from any host — so release binaries no longer
// require a native macOS build runner.
//
// Build with: go build (cgo-free; compiled into every darwin build, no tag)
package keysigner
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"unsafe"
"github.com/ebitengine/purego"
"github.com/larksuite/cli/internal/vfs"
)
// ---- Security / CoreFoundation runtime bindings (purego, no cgo) ----
const (
cfFrameworkPath = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
secFrameworkPath = "/System/Library/Frameworks/Security.framework/Security"
// kCFStringEncodingUTF8 (CFStringBuiltInEncodings).
cfStringEncodingUTF8 = 0x08000100
// OSStatus values.
errSecSuccess = 0
)
var (
ffiOnce sync.Once
ffiErr error
cfDataCreate func(alloc uintptr, bytes *byte, length int) uintptr
cfDataGetLength func(d uintptr) int
cfDataGetBytePtr func(d uintptr) unsafe.Pointer
cfStringCreate func(alloc uintptr, cstr *byte, encoding uint32) uintptr
cfArrayCreate func(alloc uintptr, values *uintptr, numValues int, cb uintptr) uintptr
cfDictCreateMutable func(alloc uintptr, capacity int, keyCB, valCB uintptr) uintptr
cfDictSetValue func(dict, key, val uintptr)
cfRelease func(ref uintptr)
cfErrorGetCode func(e uintptr) int
secKeychainOpen func(path *byte, out *uintptr) int32
secItemCopyMatching func(query uintptr, result *uintptr) int32
secItemUpdate func(query, attrs uintptr) int32
secKeyCreateSignature func(key, algo, data uintptr, errOut *uintptr) uintptr
// CFTypeRef data-symbol constants (deref to obtain the held ref value).
kSecClass uintptr
kSecClassKey uintptr
kSecAttrKeyClass uintptr
kSecAttrKeyClassPrivate uintptr
kSecAttrKeyType uintptr
kSecAttrKeyTypeRSA uintptr
kSecAttrApplicationLabel uintptr
kSecReturnRef uintptr
kSecMatchSearchList uintptr
kSecAttrLabel uintptr
kCFBooleanTrue uintptr
algRSAPKCS1SHA256 uintptr
// Struct-symbol constants (passed BY ADDRESS, not dereferenced).
cbTypeArray uintptr
cbDictKey uintptr
cbDictValue uintptr
)
// loadFFI resolves the framework functions and constants once. Any failure
// (framework missing, symbol absent) is returned to every caller so signing
// fails cleanly rather than crashing.
func loadFFI() error {
ffiOnce.Do(func() {
cf, err := purego.Dlopen(cfFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
ffiErr = fmt.Errorf("keysigner: dlopen CoreFoundation: %w", err)
return
}
sec, err := purego.Dlopen(secFrameworkPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
ffiErr = fmt.Errorf("keysigner: dlopen Security: %w", err)
return
}
purego.RegisterLibFunc(&cfDataCreate, cf, "CFDataCreate")
purego.RegisterLibFunc(&cfDataGetLength, cf, "CFDataGetLength")
purego.RegisterLibFunc(&cfDataGetBytePtr, cf, "CFDataGetBytePtr")
purego.RegisterLibFunc(&cfStringCreate, cf, "CFStringCreateWithCString")
purego.RegisterLibFunc(&cfArrayCreate, cf, "CFArrayCreate")
purego.RegisterLibFunc(&cfDictCreateMutable, cf, "CFDictionaryCreateMutable")
purego.RegisterLibFunc(&cfDictSetValue, cf, "CFDictionarySetValue")
purego.RegisterLibFunc(&cfRelease, cf, "CFRelease")
purego.RegisterLibFunc(&cfErrorGetCode, cf, "CFErrorGetCode")
purego.RegisterLibFunc(&secKeychainOpen, sec, "SecKeychainOpen")
purego.RegisterLibFunc(&secItemCopyMatching, sec, "SecItemCopyMatching")
purego.RegisterLibFunc(&secItemUpdate, sec, "SecItemUpdate")
purego.RegisterLibFunc(&secKeyCreateSignature, sec, "SecKeyCreateSignature")
// CFStringRef/CFBooleanRef constants: Dlsym gives the address of the
// exported variable; deref once to read the ref it holds.
derefs := []struct {
dst *uintptr
handle uintptr
name string
}{
{&kSecClass, sec, "kSecClass"},
{&kSecClassKey, sec, "kSecClassKey"},
{&kSecAttrKeyClass, sec, "kSecAttrKeyClass"},
{&kSecAttrKeyClassPrivate, sec, "kSecAttrKeyClassPrivate"},
{&kSecAttrKeyType, sec, "kSecAttrKeyType"},
{&kSecAttrKeyTypeRSA, sec, "kSecAttrKeyTypeRSA"},
{&kSecAttrApplicationLabel, sec, "kSecAttrApplicationLabel"},
{&kSecReturnRef, sec, "kSecReturnRef"},
{&kSecMatchSearchList, sec, "kSecMatchSearchList"},
{&kSecAttrLabel, sec, "kSecAttrLabel"},
{&kCFBooleanTrue, cf, "kCFBooleanTrue"},
{&algRSAPKCS1SHA256, sec, "kSecKeyAlgorithmRSASignatureDigestPKCS1v15SHA256"},
}
for _, d := range derefs {
sym, e := purego.Dlsym(d.handle, d.name)
if e != nil || sym == 0 {
ffiErr = fmt.Errorf("keysigner: dlsym %s: %v", d.name, e)
return
}
// deref of a stable dylib data-symbol address (not Go-managed memory), so safe.
*d.dst = *(*uintptr)(unsafe.Pointer(sym)) //nolint:govet // unsafeptr: see comment above
}
// Callback structs are passed by address (no deref).
addrs := []struct {
dst *uintptr
handle uintptr
name string
}{
{&cbTypeArray, cf, "kCFTypeArrayCallBacks"},
{&cbDictKey, cf, "kCFTypeDictionaryKeyCallBacks"},
{&cbDictValue, cf, "kCFTypeDictionaryValueCallBacks"},
}
for _, a := range addrs {
sym, e := purego.Dlsym(a.handle, a.name)
if e != nil || sym == 0 {
ffiErr = fmt.Errorf("keysigner: dlsym %s: %v", a.name, e)
return
}
*a.dst = sym
}
})
return ffiErr
}
// cstr returns a pointer to a NUL-terminated copy of s. The backing array stays
// alive while the returned pointer is reachable.
func cstr(s string) *byte {
b := append([]byte(s), 0)
return &b[0]
}
// cfBytes wraps Go bytes in a CFData (CFDataCreate copies the bytes). Caller
// releases the returned CFDataRef.
func cfBytes(b []byte) uintptr {
var p *byte
if len(b) > 0 {
p = &b[0]
}
d := cfDataCreate(0, p, len(b))
runtime.KeepAlive(b)
return d
}
// keychainSearchArray opens the dedicated keychain file and wraps it in a
// CFArray for kSecMatchSearchList. Caller releases the returned array.
//
// NOTE: SecKeychainOpen / the file-based keychain are deprecated by Apple in
// favor of the data-protection keychain. They still function on current macOS;
// migrating off them is tracked separately and is independent of the cgo→purego
// change (the original cgo version used the same APIs).
func keychainSearchArray(keychainPath string) (uintptr, error) {
var kc uintptr
if st := secKeychainOpen(cstr(keychainPath), &kc); st != errSecSuccess {
return 0, keychainError("open keychain", int(st))
}
vals := [1]uintptr{kc}
arr := cfArrayCreate(0, &vals[0], 1, cbTypeArray)
cfRelease(kc) // the array retains it
if arr == 0 {
return 0, fmt.Errorf("keysigner: CFArrayCreate(search list) failed")
}
return arr, nil
}
// findPrivateKey locates the non-extractable private key by its application
// label within the dedicated keychain. Caller releases the returned SecKeyRef.
func findPrivateKey(appLabel []byte, keychainPath string) (uintptr, error) {
search, err := keychainSearchArray(keychainPath)
if err != nil {
return 0, err
}
defer cfRelease(search)
labelData := cfBytes(appLabel)
defer cfRelease(labelData)
q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
if q == 0 {
return 0, fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed")
}
defer cfRelease(q)
cfDictSetValue(q, kSecClass, kSecClassKey)
cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate)
cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA)
cfDictSetValue(q, kSecAttrApplicationLabel, labelData)
cfDictSetValue(q, kSecReturnRef, kCFBooleanTrue)
cfDictSetValue(q, kSecMatchSearchList, search)
var keyRef uintptr
if st := secItemCopyMatching(q, &keyRef); st != errSecSuccess {
return 0, keychainError("find private key", int(st))
}
return keyRef, nil
}
// securityBin is invoked by absolute path so a poisoned PATH cannot hijack it.
const securityBin = "/usr/bin/security"
// keychainSigner implements Signer using a macOS non-exportable Keychain key.
type keychainSigner struct{}
func init() { Register(keychainSigner{}) }
// ProbeHardware reports the macOS Keychain backend backing this signer. The
// keychain signer is compiled into every darwin build and needs no special
// hardware, so it reports available whenever the Security tooling is present.
// It performs no key access, so it never prompts. Implementing HardwareProber
// is what lets `doctor` report the signer as present rather than treating the
// (prober-less) signer as "no TEE signer in this build".
func (keychainSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) {
info := HardwareInfo{Backend: "keychain", VendorName: "macOS Keychain"}
// A missing security tool is a status (Available=false via Reason), not a
// probe error — so we deliberately return a nil error here.
if _, err := vfs.Stat(securityBin); err != nil {
info.Reason = securityBin + " not found"
return info, nil //nolint:nilerr // absence is reported via Reason, not as an error
}
info.Available = true
return info, nil
}
func (keychainSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
if md, err := readKeyMetadata(ref.Label); err == nil {
return decodePublicKey(md.PublicKey)
} else if !os.IsNotExist(err) {
return nil, err
}
return createKeychainKey(ref.Label)
}
func (keychainSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
md, err := readKeyMetadata(ref.Label)
if err != nil {
return nil, err
}
return decodePublicKey(md.PublicKey)
}
func (keychainSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) {
if err := loadFFI(); err != nil {
return nil, "", err
}
md, err := readKeyMetadata(ref.Label)
if err != nil {
return nil, "", err
}
appLabel, err := hex.DecodeString(md.AppLabel)
if err != nil {
return nil, "", fmt.Errorf("keysigner: decode app label: %w", err)
}
if len(appLabel) == 0 {
// Guard the &appLabel[0] pointer below against corrupted metadata.
return nil, "", fmt.Errorf("keysigner: key metadata for %q has empty app label", ref.Label)
}
keychain, err := ensureKeychain()
if err != nil {
return nil, "", err
}
keyRef, err := findPrivateKey(appLabel, keychain)
if err != nil {
return nil, "", err
}
defer cfRelease(keyRef)
digest := sha256.Sum256(signingInput)
digestData := cfBytes(digest[:])
defer cfRelease(digestData)
var errRef uintptr
sigRef := secKeyCreateSignature(keyRef, algRSAPKCS1SHA256, digestData, &errRef)
if sigRef == 0 {
code := 0
if errRef != 0 {
code = cfErrorGetCode(errRef)
cfRelease(errRef)
}
return nil, "", fmt.Errorf("keysigner: SecKeyCreateSignature failed (CFError %d)", code)
}
defer cfRelease(sigRef)
n := cfDataGetLength(sigRef)
bp := cfDataGetBytePtr(sigRef)
out := make([]byte, n)
copy(out, unsafe.Slice((*byte)(bp), n))
// RS256: the SecKey PKCS1v15-SHA256 signature is the JOSE signature as-is.
return out, AlgRS256, nil
}
// keyMetadata records the public key + the keychain application-label used to
// locate the non-extractable private key.
type keyMetadata struct {
PublicKey string `json:"public_key"` // PKIX DER, std base64 (see EncodePublicKey)
AppLabel string `json:"app_label"` // hex(sha1(PKCS1 public key))
}
func createKeychainKey(label string) (crypto.PublicKey, error) {
metadataPath, err := keyMetadataPath(label)
if err != nil {
return nil, err
}
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("keysigner: generate RSA key: %w", err)
}
appLabel := sha1.Sum(x509.MarshalPKCS1PublicKey(&privateKey.PublicKey))
pemFile, err := vfs.CreateTemp("", "lark-keysigner-*.pem")
if err != nil {
return nil, fmt.Errorf("keysigner: temp key file: %w", err)
}
pemPath := pemFile.Name()
defer vfs.Remove(pemPath)
if err := pemFile.Chmod(0600); err != nil {
pemFile.Close()
return nil, err
}
der := x509.MarshalPKCS1PrivateKey(privateKey)
if _, err := pemFile.WriteString("-----BEGIN RSA PRIVATE KEY-----\n" +
base64Wrap(der) + "-----END RSA PRIVATE KEY-----\n"); err != nil {
pemFile.Close()
return nil, err
}
if err := pemFile.Close(); err != nil {
return nil, err
}
executable, err := vfs.Executable()
if err != nil {
return nil, fmt.Errorf("keysigner: resolve executable: %w", err)
}
keychain, err := ensureKeychain()
if err != nil {
return nil, err
}
// -x: import as NON-EXTRACTABLE; the software copy (pemPath) is then removed.
importCmd := exec.Command(securityBin, "import", pemPath, "-k", keychain, "-t", "priv", "-f", "openssl", "-x", "-A", "-T", executable)
if out, err := importCmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("keysigner: import non-extractable key: %w: %s", err, summarizeCmdOutput(out))
}
if err := setKeychainKeyLabel(appLabel[:], keychain, label); err != nil {
return nil, err
}
encodedPub, err := EncodePublicKey(&privateKey.PublicKey)
if err != nil {
return nil, err
}
if err := writeKeyMetadata(metadataPath, keyMetadata{PublicKey: encodedPub, AppLabel: hex.EncodeToString(appLabel[:])}); err != nil {
return nil, err
}
return &privateKey.PublicKey, nil
}
func setKeychainKeyLabel(appLabel []byte, keychain, label string) error {
if err := loadFFI(); err != nil {
return err
}
search, err := keychainSearchArray(keychain)
if err != nil {
return err
}
defer cfRelease(search)
labelData := cfBytes(appLabel)
defer cfRelease(labelData)
q := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
if q == 0 {
return fmt.Errorf("keysigner: CFDictionaryCreateMutable(query) failed")
}
defer cfRelease(q)
cfDictSetValue(q, kSecClass, kSecClassKey)
cfDictSetValue(q, kSecAttrKeyClass, kSecAttrKeyClassPrivate)
cfDictSetValue(q, kSecAttrKeyType, kSecAttrKeyTypeRSA)
cfDictSetValue(q, kSecAttrApplicationLabel, labelData)
cfDictSetValue(q, kSecMatchSearchList, search)
cfLabel := cfStringCreate(0, cstr(label), cfStringEncodingUTF8)
if cfLabel == 0 {
return fmt.Errorf("keysigner: CFStringCreateWithCString failed")
}
defer cfRelease(cfLabel)
attrs := cfDictCreateMutable(0, 0, cbDictKey, cbDictValue)
if attrs == 0 {
return fmt.Errorf("keysigner: CFDictionaryCreateMutable(attrs) failed")
}
defer cfRelease(attrs)
cfDictSetValue(attrs, kSecAttrLabel, cfLabel)
if st := secItemUpdate(q, attrs); st != errSecSuccess {
return keychainError("set keychain key label", int(st))
}
return nil
}
func decodePublicKey(encoded string) (crypto.PublicKey, error) {
der, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("keysigner: decode public key: %w", err)
}
return x509.ParsePKIXPublicKey(der)
}
// base64Wrap PEM-wraps DER bytes at 64 columns.
func base64Wrap(der []byte) string {
enc := base64.StdEncoding.EncodeToString(der)
var b strings.Builder
for i := 0; i < len(enc); i += 64 {
end := i + 64
if end > len(enc) {
end = len(enc)
}
b.WriteString(enc[i:end])
b.WriteByte('\n')
}
return b.String()
}
func readKeyMetadata(label string) (*keyMetadata, error) {
path, err := keyMetadataPath(label)
if err != nil {
return nil, err
}
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err // preserves os.ErrNotExist for EnsureKey
}
var md keyMetadata
if err := json.Unmarshal(data, &md); err != nil {
return nil, fmt.Errorf("keysigner: parse key metadata: %w", err)
}
return &md, nil
}
func writeKeyMetadata(path string, md keyMetadata) error {
if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
data, err := json.MarshalIndent(md, "", " ")
if err != nil {
return err
}
return vfs.WriteFile(path, data, 0600)
}
func ensureKeychain() (string, error) {
keychainPath, err := keychainFilePath()
if err != nil {
return "", err
}
password, err := keychainPassword()
if err != nil {
return "", err
}
if _, err := vfs.Stat(keychainPath); err != nil {
if !os.IsNotExist(err) {
return "", fmt.Errorf("keysigner: stat keychain: %w", err)
}
if err := vfs.MkdirAll(filepath.Dir(keychainPath), 0700); err != nil {
return "", err
}
for _, args := range [][]string{
{"create-keychain", "-p", password, keychainPath},
{"set-keychain-settings", keychainPath},
{"unlock-keychain", "-p", password, keychainPath},
} {
if out, err := exec.Command(securityBin, args...).CombinedOutput(); err != nil {
return "", fmt.Errorf("keysigner: security %s: %w: %s", args[0], err, summarizeCmdOutput(out))
}
}
}
return keychainPath, nil
}
func keysignerDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("keysigner: resolve config dir: %w", err)
}
return filepath.Join(configDir, "lark-cli", "keysigner"), nil
}
func keychainFilePath() (string, error) {
dir, err := keysignerDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "lark-cli.keychain"), nil
}
func keychainPassword() (string, error) {
dir, err := keysignerDir()
if err != nil {
return "", err
}
path := filepath.Join(dir, "keychain.pass")
if data, err := vfs.ReadFile(path); err == nil {
if pw := strings.TrimSpace(string(data)); pw != "" {
return pw, nil
}
return "", fmt.Errorf("keysigner: empty keychain password")
} else if !os.IsNotExist(err) {
return "", err
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
pw := hex.EncodeToString(buf)
if err := vfs.MkdirAll(filepath.Dir(path), 0700); err != nil {
return "", err
}
if err := vfs.WriteFile(path, []byte(pw+"\n"), 0600); err != nil {
return "", err
}
return pw, nil
}
func keyMetadataPath(label string) (string, error) {
dir, err := keysignerDir()
if err != nil {
return "", err
}
id := sha256.Sum256([]byte(label))
return filepath.Join(dir, "keys", hex.EncodeToString(id[:])+".json"), nil
}
// summarizeCmdOutput bounds external command output before it is embedded in
// an error: first line only, capped at 200 chars.
func summarizeCmdOutput(out []byte) string {
s := strings.TrimSpace(string(out))
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = strings.TrimSpace(s[:i])
}
const maxLen = 200
if len(s) > maxLen {
s = s[:maxLen] + "..."
}
return s
}
func keychainError(operation string, status int) error {
switch status {
case -25299:
return fmt.Errorf("keysigner: %s: key already exists", operation)
case -25300:
return fmt.Errorf("keysigner: %s: key not found", operation)
case -2:
return fmt.Errorf("keysigner: %s: allocation failed", operation)
default:
return fmt.Errorf("keysigner: %s: Security framework status %d", operation, status)
}
}

View File

@@ -1,62 +0,0 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"os"
"testing"
)
// TestKeychainSignerRegistered confirms the keychain_signer build self-registers
// (init → Register), so keysigner.Active() is non-nil. No keychain access.
func TestKeychainSignerRegistered(t *testing.T) {
if _, ok := Active().(keychainSigner); !ok {
t.Fatalf("Active() = %T, want keychainSigner (keychain_signer build must self-register)", Active())
}
}
// TestKeychainSignerRoundTrip creates a real non-extractable RSA key, signs, and
// verifies RS256 against the returned public key. Gated by LARK_KEYCHAIN_IT
// because it mutates the dedicated lark-cli keychain store. The signer is now
// cgo-free (purego runtime FFI), so it runs with CGO_ENABLED=0. Run with:
//
// LARK_KEYCHAIN_IT=1 go test -run RoundTrip ./internal/keysigner/
func TestKeychainSignerRoundTrip(t *testing.T) {
if os.Getenv("LARK_KEYCHAIN_IT") == "" {
t.Skip("set LARK_KEYCHAIN_IT=1 to run (mutates the macOS keychain)")
}
s := keychainSigner{}
ref := KeyRef{Label: "lark-cli-keychain-it"}
pub, err := s.EnsureKey(context.Background(), ref)
if err != nil {
t.Fatalf("EnsureKey: %v", err)
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
t.Fatalf("public key = %T, want *rsa.PublicKey", pub)
}
if alg, err := AlgForKey(pub); err != nil || alg != AlgRS256 {
t.Fatalf("AlgForKey = %q, %v; want RS256", alg, err)
}
input := []byte("header.payload")
sig, alg, err := s.Sign(context.Background(), ref, input)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if alg != AlgRS256 {
t.Errorf("Sign alg = %q, want RS256", alg)
}
h := sha256.Sum256(input)
if err := rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, h[:], sig); err != nil {
t.Errorf("RS256 signature did not verify: %v", err)
}
}

View File

@@ -1,135 +0,0 @@
//go:build linux || (windows && amd64)
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// TPM 2.0 signer (compiled into every linux and windows/amd64 build, no build
// tag required), backed by github.com/facebookincubator/sks.
//
// sks holds a non-exportable ECDSA P-256 key in the platform TPM and signs
// SHA-256 digests. On Linux it talks to /dev/tpmrm0; on Windows it uses the
// Microsoft Platform Crypto Provider (CNG). Both backends return an ASN.1 DER
// ECDSA signature, which we convert to the fixed-width r||s form JWS requires for
// ES256 (see ecdsaDERToJOSE). One key is created on the first private_key_jwt
// registration (DefaultKeyLabel) and reused for subsequent app registrations and
// every client_assertion on the same device.
//
// Excluded from windows/arm64: the sks Windows dependency stack (go-ole) has no
// arm64 VARIANT and fails to compile, so windows/arm64 falls back to
// client_secret only (keysigner.Active() is nil). On darwin the keychain signer
// is used instead. CGO is never required.
package keysigner
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/sha256"
"fmt"
"io"
"github.com/facebookincubator/flog"
"github.com/facebookincubator/sks"
)
// p256ByteLen is the P-256 coordinate width. sks regular keys are always ECDSA
// P-256, so ES256 signatures are 2*p256ByteLen bytes of r||s.
const p256ByteLen = 32
// keyTag is the sks key tag. Both the Linux and Windows sks backends address
// keys by label and ignore the tag, but the macOS backend uses it, so we set a
// stable namespaced value for forward compatibility.
const keyTag = "com.larksuite.cli"
// sksSigner implements Signer (and HardwareProber) using a non-exportable
// TPM 2.0 ECDSA key via sks.
type sksSigner struct{}
func init() {
Register(sksSigner{})
// This sks version logs verbose TPM-operation chatter to stderr via flog (a
// glog fork it owns exclusively) — e.g. "Loaded TPM device", "Found handle
// for key" on every sign. The CLI does not use flog, so silence it
// process-wide here; real failures are returned as errors, never relied upon
// from these logs. (Newer sks switched to slog, but that lands only on its
// go-1.24 line, which we avoid to keep the module on go 1.23.)
flog.SetOutput(io.Discard)
}
// EnsureKey returns the public key for ref, creating the TPM key if absent.
// sks.NewKey is find-or-create: it returns the existing key when one is present.
func (sksSigner) EnsureKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
key, err := sks.NewKey(ref.Label, keyTag, false, true, nil)
if err != nil {
return nil, fmt.Errorf("keysigner: ensure TPM key %q: %w", ref.Label, err)
}
defer key.Close()
return ecdsaPublic(ref.Label, key.Public())
}
// PublicKey returns the public key for ref without creating it. FromLabelTag does
// not touch the TPM until Public() loads the sealed key; a missing key yields a
// nil public key, which we surface as an error — at runtime the key MUST already
// exist (it was bound to the app at registration), so we never silently mint a
// new, unbound one here.
func (sksSigner) PublicKey(_ context.Context, ref KeyRef) (crypto.PublicKey, error) {
pub := sks.FromLabelTag(ref.Label).Public()
if pub == nil {
return nil, fmt.Errorf("keysigner: TPM key %q not found", ref.Label)
}
return ecdsaPublic(ref.Label, pub)
}
// Sign signs signingInput with the TPM key and returns a JOSE-format ES256
// signature (fixed-width r||s) plus its alg.
func (sksSigner) Sign(_ context.Context, ref KeyRef, signingInput []byte) ([]byte, string, error) {
key, err := sks.NewKey(ref.Label, keyTag, false, true, nil)
if err != nil {
return nil, "", fmt.Errorf("keysigner: load TPM key %q: %w", ref.Label, err)
}
defer key.Close()
// ES256 signs the SHA-256 digest of the JWS signing input.
digest := sha256.Sum256(signingInput)
der, err := key.Sign(nil, digest[:], crypto.SHA256)
if err != nil {
return nil, "", fmt.Errorf("keysigner: TPM sign with key %q: %w", ref.Label, err)
}
// Both sks backends emit ASN.1 DER; JWS ES256 requires fixed-width r||s
// (RFC 7518 §3.4).
rs, err := ecdsaDERToJOSE(der, p256ByteLen)
if err != nil {
return nil, "", err
}
return rs, AlgES256, nil
}
// ProbeHardware reports on the TPM backing this signer without touching any key.
// A failure to reach the TPM (no device, permission denied, not TPM 2.0) is
// reported as Available=false with Reason set, NOT as a Go error — the probe
// still succeeded in determining that the TEE is currently unusable.
func (sksSigner) ProbeHardware(_ context.Context) (HardwareInfo, error) {
info := HardwareInfo{Backend: "tpm2"}
data, err := sks.GetSecureHardwareVendorData()
if err != nil {
info.Reason = cleanProbeError(err)
return info, nil
}
info.VendorName = data.VendorName
info.VendorInfo = data.VendorInfo
info.Available = data.IsTPM20CompliantDevice
if !info.Available {
info.Reason = "secure hardware is not a TPM 2.0 compliant device"
}
return info, nil
}
// ecdsaPublic asserts that an sks public key is an ECDSA key (it always is for
// regular sks keys) so the caller gets the concrete type AlgForKey/PublicKeyJWK expect.
func ecdsaPublic(label string, pub crypto.PublicKey) (*ecdsa.PublicKey, error) {
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("keysigner: TPM key %q public is %T, want *ecdsa.PublicKey", label, pub)
}
return ecPub, nil
}

View File

@@ -1,122 +0,0 @@
//go:build linux || (windows && amd64)
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keysigner
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/sha256"
"io"
"math/big"
"strings"
"testing"
"github.com/facebookincubator/flog"
"github.com/facebookincubator/sks"
)
// TestFlogSilenced verifies the mechanism init() relies on to keep sks's flog
// TPM chatter off the CLI's stderr: SetOutput redirects flog, and io.Discard
// drops it. Cleanup restores io.Discard so init()'s silencing holds for the
// rest of the package's tests.
func TestFlogSilenced(t *testing.T) {
var buf bytes.Buffer
flog.SetOutput(&buf)
t.Cleanup(func() { flog.SetOutput(io.Discard) })
flog.Info("captured-line")
if !strings.Contains(buf.String(), "captured-line") {
t.Fatalf("flog.SetOutput(buffer) did not capture output: %q", buf.String())
}
flog.SetOutput(io.Discard)
buf.Reset()
flog.Info("should-be-discarded")
if buf.Len() != 0 {
t.Errorf("flog output not discarded: %q", buf.String())
}
}
// requireTEE skips the test unless the TPM is present and usable. On a Linux
// machine with a TPM but a restrictive device owner (`/dev/tpmrm0` is `tss:tss`
// by default), grant access with `sudo usermod -aG tss $USER` then re-login, or
// run the test under sudo.
func requireTEE(t *testing.T) {
t.Helper()
info, err := sksSigner{}.ProbeHardware(context.Background())
if err != nil || !info.Available {
reason := info.Reason
if err != nil {
reason = err.Error()
}
t.Skipf("TEE not available (%s)", reason)
}
}
// TestSKSSignerRoundTrip exercises the full registration→assertion contract
// against the real TPM: create the key, read it back without creating, derive
// the JWS alg + JWK, sign, and verify the fixed-width r||s output.
func TestSKSSignerRoundTrip(t *testing.T) {
requireTEE(t)
var s sksSigner
ctx := context.Background()
ref := KeyRef{Label: "larksuite-cli-test"}
// Best-effort cleanup so the test key does not linger in the TPM-sealed store.
t.Cleanup(func() {
if k, err := sks.NewKey(ref.Label, keyTag, false, true, nil); err == nil {
_ = k.Remove()
_ = k.Close()
}
})
pub, err := s.EnsureKey(ctx, ref)
if err != nil {
t.Fatalf("EnsureKey: %v", err)
}
ecPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
t.Fatalf("EnsureKey returned %T, want *ecdsa.PublicKey", pub)
}
// PublicKey (no-create) must return the same key bound at EnsureKey.
pub2, err := s.PublicKey(ctx, ref)
if err != nil {
t.Fatalf("PublicKey: %v", err)
}
if !ecPub.Equal(pub2) {
t.Fatal("PublicKey returned a different key than EnsureKey")
}
// The JWT layer derives alg + JWK from the public key; both must work.
if alg, err := AlgForKey(pub); err != nil || alg != AlgES256 {
t.Fatalf("AlgForKey = %q, %v; want ES256", alg, err)
}
if _, err := PublicKeyJWK(pub); err != nil {
t.Fatalf("PublicKeyJWK: %v", err)
}
// Sign a representative JWS signing input and verify the converted r||s.
input := []byte("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJjbGkifQ")
sig, alg, err := s.Sign(ctx, ref, input)
if err != nil {
t.Fatalf("Sign: %v", err)
}
if alg != AlgES256 {
t.Fatalf("Sign alg = %q, want ES256", alg)
}
if len(sig) != 2*p256ByteLen {
t.Fatalf("len(sig) = %d, want %d (fixed-width r||s)", len(sig), 2*p256ByteLen)
}
digest := sha256.Sum256(input)
r := new(big.Int).SetBytes(sig[:p256ByteLen])
ss := new(big.Int).SetBytes(sig[p256ByteLen:])
if !ecdsa.Verify(ecPub, digest[:], r, ss) {
t.Fatal("TPM signature did not verify against the public key")
}
}

View File

@@ -113,7 +113,8 @@ type EnumOption struct {
}
// EnumOptions returns the field's allowed values paired with their descriptions
// — from enum, or from options when enum is absent — coerced to the canonical
// — from enum (with descriptions backfilled from options when the field carries
// both forms), or from options when enum is absent — coerced to the canonical
// type and ordered: numeric and boolean values are sorted; string values keep
// source order (which can encode priority). Uncoercible literals are dropped.
// Returns nil when the field declares no enum constraint.
@@ -122,9 +123,14 @@ func (f Field) EnumOptions() []EnumOption {
var out []EnumOption
switch {
case len(f.Enum) > 0:
// key by raw literal so enum "1" and option 1 align across JSON types
desc := make(map[string]string, len(f.Options))
for _, o := range f.Options {
desc[fmt.Sprintf("%v", o.Value)] = o.Description
}
for _, e := range f.Enum {
if v, ok := coerceLiteral(ct, e); ok {
out = append(out, EnumOption{Value: v})
out = append(out, EnumOption{Value: v, Description: desc[fmt.Sprintf("%v", e)]})
}
}
case len(f.Options) > 0:

View File

@@ -80,6 +80,39 @@ func TestField_EnumOptions(t *testing.T) {
}
}
func TestField_EnumOptions_BothEnumAndOptions(t *testing.T) {
// enum is the value set; descriptions backfilled from options, empty where absent
f := Field{Type: "string", Enum: []any{"1", "2", "3", "4", "6"}, Options: []Option{
{Value: "1", Description: "from"},
{Value: "2", Description: "to"},
{Value: "6", Description: "subject"},
}}
want := []EnumOption{
{Value: "1", Description: "from"},
{Value: "2", Description: "to"},
{Value: "3", Description: ""},
{Value: "4", Description: ""},
{Value: "6", Description: "subject"},
}
if got := f.EnumOptions(); !reflect.DeepEqual(got, want) {
t.Errorf("EnumOptions(enum+options) = %+v, want %+v", got, want)
}
// enum values stored as strings match option values stored as numbers
fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}, Options: []Option{
{Value: 1, Description: "one"},
{Value: 2, Description: "two"},
}}
wantI := []EnumOption{
{Value: int64(1), Description: "one"},
{Value: int64(2), Description: "two"},
{Value: int64(10), Description: ""},
}
if got := fi.EnumOptions(); !reflect.DeepEqual(got, wantI) {
t.Errorf("EnumOptions(integer enum+options) = %+v, want %+v", got, wantI)
}
}
func TestField_Enum_NumberAndBoolean(t *testing.T) {
// number: string-stored floats coerced to float64 and numerically sorted
if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) {

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"fmt"
"io"
"sync"
"time"
)
// spinnerFrames are braille spinner glyphs cycled to animate progress.
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const (
spinnerInterval = 80 * time.Millisecond
spinnerHideCursor = "\x1b[?25l"
spinnerShowCursor = "\x1b[?25h"
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
)
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
// until the returned stop() is called, e.g.:
//
// ⠹ Publishing dev → main... 3s
//
// It is meant for slow operations (long polls, first-time provisioning) so the
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
// animation never pollutes stdout — the JSON/pretty result stays clean.
//
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
// the stderr-TTY check (IOStreams.StderrIsTerminal), not the output format: the
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
//
// stop() clears the spinner line, restores the cursor, and blocks until the
// render goroutine has finished — so callers can safely write the result to
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
// it is safe to call more than once (e.g. an explicit call plus a defer).
func StartSpinner(w io.Writer, enabled bool, label string) func() {
if !enabled || w == nil {
return func() {}
}
done := make(chan struct{})
finished := make(chan struct{})
start := time.Now()
go func() {
defer close(finished)
frame := 0
fmt.Fprint(w, spinnerHideCursor)
render := func() {
elapsed := int(time.Since(start).Seconds())
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
frame = (frame + 1) % len(spinnerFrames)
}
render()
ticker := time.NewTicker(spinnerInterval)
defer ticker.Stop()
for {
select {
case <-done:
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
return
case <-ticker.C:
render()
}
}
}()
var once sync.Once
return func() {
once.Do(func() {
close(done)
<-finished // wait for the line to be cleared before returning
})
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"bytes"
"strings"
"testing"
)
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, false, "working")
stop()
stop() // idempotent
if buf.Len() != 0 {
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
}
}
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
stop := StartSpinner(nil, true, "working")
stop() // must not panic
}
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
var buf bytes.Buffer
stop := StartSpinner(&buf, true, "Publishing")
// The goroutine renders the first frame synchronously before selecting on
// the stop channel, so even an immediate stop() yields one full cycle.
stop()
stop() // idempotent, must not panic or double-write after finished
out := buf.String()
if !strings.Contains(out, spinnerHideCursor) {
t.Errorf("missing hide-cursor escape:\n%q", out)
}
if !strings.Contains(out, spinnerFrames[0]) {
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
}
if !strings.Contains(out, "Publishing...") {
t.Errorf("missing label:\n%q", out)
}
if !strings.Contains(out, spinnerClearLine) {
t.Errorf("missing clear-line escape:\n%q", out)
}
if !strings.HasSuffix(out, spinnerShowCursor) {
t.Errorf("must end by restoring the cursor:\n%q", out)
}
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
type eventPayload struct {
Comment *struct {
Body string `json:"body"`
} `json:"comment"`
Review *struct {
Body string `json:"body"`
} `json:"review"`
}
func main() {
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
flag.Parse()
if *eventPath == "" {
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
os.Exit(2)
}
body, err := commentBody(*eventPath)
if err != nil {
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
os.Exit(2)
}
diags := diagnostics(publiccontent.ScanComment(*kind, body))
if len(diags) > 0 {
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
}
report.Print(os.Stderr, diags)
os.Exit(report.ExitCode(diags))
}
func auditFailureSummary(count int) string {
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
}
func commentBody(path string) (string, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
WithParam("--event").
WithCause(err)
}
data, err := vfs.ReadFile(safePath)
if err != nil {
return "", err
}
var payload eventPayload
if err := json.Unmarshal(data, &payload); err != nil {
return "", err
}
switch {
case payload.Comment != nil:
return payload.Comment.Body, nil
case payload.Review != nil:
return payload.Review.Body, nil
default:
return "", nil
}
}
func diagnostics(items []publiccontent.Finding) []report.Diagnostic {
out := make([]report.Diagnostic, 0, len(items))
for _, item := range items {
out = append(out, report.Diagnostic{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
)
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
dir := t.TempDir()
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"clean comment"}}`); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
got, err := commentBody("event.json")
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got != "clean comment" {
t.Fatalf("comment body = %q", got)
}
}
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "event.json")
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
t.Fatal(err)
}
_, err := commentBody(path)
problem, ok := errs.ProblemOf(err)
if err == nil || !ok {
t.Fatalf("commentBody(%q) error = %v, want unsafe path validation error", path, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("commentBody(%q) problem = %#v, want invalid argument validation", path, problem)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--event" {
t.Fatalf("commentBody(%q) error = %v, want --event validation param", path, err)
}
}
func TestAuditFailureSummaryStatesPostPublicationAudit(t *testing.T) {
got := auditFailureSummary(2)
want := "post-publication audit found public content findings: 2"
if got != want {
t.Fatalf("auditFailureSummary() = %q, want %q", got, want)
}
}
func writeTestFile(path, data string) error {
return os.WriteFile(path, []byte(data), 0o644)
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/rules"
"github.com/larksuite/cli/internal/validate"
)
func main() {
@@ -41,6 +42,7 @@ func runCheck(args []string) int {
fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path")
fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON")
fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON")
fs.StringVar(&opts.PublicContentMetadataPath, "public-content-metadata", "", "PR title/body metadata JSON for public content checks")
fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates")
fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates")
if err := fs.Parse(args); err != nil {
@@ -48,6 +50,15 @@ func runCheck(args []string) int {
return 2
}
if opts.PublicContentMetadataPath != "" {
safePath, err := validate.SafeInputPath(opts.PublicContentMetadataPath)
if err != nil {
fmt.Fprintf(os.Stderr, "quality-gate check: --public-content-metadata: %v\n", err)
return 2
}
opts.PublicContentMetadataPath = safePath
}
if opts.ManifestPath == "" || opts.CommandIndexPath == "" {
fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required")
return 2

View File

@@ -37,6 +37,37 @@ func TestCheckRequiresManifestInputs(t *testing.T) {
}
}
func TestCheckAcceptsPublicContentMetadataFlag(t *testing.T) {
code, stderr := runCheckCaptureStderr(t, []string{
"--repo", t.TempDir(),
"--cli-bin", "./lark-cli",
"--public-content-metadata", ".tmp/quality-gate/pr.json",
})
if code != 2 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
}
if strings.Contains(stderr, "flag provided but not defined") {
t.Fatalf("public content metadata flag was not registered: %s", stderr)
}
if !strings.Contains(stderr, "--manifest and --command-index are required") {
t.Fatalf("stderr = %s", stderr)
}
}
func TestCheckRejectsUnsafePublicContentMetadataPath(t *testing.T) {
code, stderr := runCheckCaptureStderr(t, []string{
"--repo", t.TempDir(),
"--cli-bin", "./lark-cli",
"--public-content-metadata", filepath.Join(t.TempDir(), "pr.json"),
})
if code != 2 {
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
}
if !strings.Contains(stderr, "--public-content-metadata") || !strings.Contains(stderr, "--file") {
t.Fatalf("stderr = %s, want unsafe public content metadata path error", stderr)
}
}
func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) {
dir := t.TempDir()
manifestPath := filepath.Join(dir, "command-manifest.json")

View File

@@ -56,6 +56,14 @@ func run(args []string) int {
_ = semantic.WriteMarkdown(markdownOut, decision)
return 0
}
if reviewPath == "" && !semantic.BuildInputView(f).HasReviewableFacts() {
decision := finalizeDecision(block, waiverDiags, semantic.Decision{})
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
return 2
}
return decisionExitCode(decision)
}
review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig)
if err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
@@ -72,6 +80,15 @@ func run(args []string) int {
return 0
}
decision := semantic.DecideWithWaivers(f, review, policy, waivers)
decision = finalizeDecision(block, waiverDiags, decision)
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
return 2
}
return decisionExitCode(decision)
}
func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision semantic.Decision) semantic.Decision {
decision.BlockMode = block
if !block && len(decision.Blockers) > 0 {
for i := range decision.Blockers {
@@ -81,15 +98,21 @@ func run(args []string) int {
decision.Blockers = nil
}
decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...)
return decision
}
func writeSemanticOutputs(decisionOut, markdownOut string, decision semantic.Decision) error {
if err := semantic.WriteDecision(decisionOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err)
return 2
return fmt.Errorf("write decision: %w", err)
}
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err)
return 2
return fmt.Errorf("write markdown: %w", err)
}
if block && len(decision.Blockers) > 0 {
return nil
}
func decisionExitCode(decision semantic.Decision) int {
if decision.BlockMode && len(decision.Blockers) > 0 {
return 1
}
return 0

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/qualitygate/facts"
@@ -211,7 +212,19 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
f := facts.Facts{
SchemaVersion: 1,
Skills: []facts.SkillFact{{
SourceFile: "skills/lark-wiki/SKILL.md",
Line: 30,
Changed: true,
ReferencesInvalidCommand: true,
}},
}
if !semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
@@ -228,6 +241,71 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
}
}
func TestRunShortCircuitsEmptySemanticInputWithoutReviewer(t *testing.T) {
t.Setenv("ARK_API_KEY", "")
t.Setenv("ARK_BASE_URL", "")
t.Setenv("ARK_MODEL", "")
repo := t.TempDir()
writeSemanticConfig(t, repo, `{
"schema_version": 1,
"default_enforcement": "observe",
"block_categories": ["skill_quality"]
}`, `{
"allowed": ["semantic-review-v1"],
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
f := facts.Facts{
SchemaVersion: 1,
Commands: []facts.CommandFact{{
Path: "service command 1",
Domain: "service",
Changed: true,
Source: "service",
}},
Outputs: []facts.OutputFact{{
Command: "service command 1",
Domain: "service",
Changed: true,
Source: "service",
IsList: true,
HasDefaultLimit: true,
HasDecisionField: true,
}},
}
if semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must not contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")
markdownPath := filepath.Join(t.TempDir(), "semantic.md")
code := run([]string{"--repo", repo, "--facts", factsPath, "--decision-out", decisionPath, "--markdown-out", markdownPath, "--block"})
if code != 0 {
t.Fatalf("run() = %d, want clean pass", code)
}
decision := readDecision(t, decisionPath)
if decision.Skipped || decision.Degraded || decision.InfrastructureFailure || !decision.BlockMode {
t.Fatalf("expected non-degraded pass decision: %#v", decision)
}
if len(decision.SystemWarnings) != 0 || len(decision.Warnings) != 0 || len(decision.Blockers) != 0 {
t.Fatalf("empty semantic view should not produce findings: %#v", decision)
}
data, err := os.ReadFile(markdownPath)
if err != nil {
t.Fatalf("read markdown: %v", err)
}
markdown := string(data)
if !strings.Contains(markdown, "No semantic blockers.") {
t.Fatalf("markdown missing pass summary: %s", markdown)
}
if strings.Contains(strings.ToLower(markdown), "skipped") || strings.Contains(strings.ToLower(markdown), "degraded") {
t.Fatalf("markdown should not report semantic review as skipped/degraded: %s", markdown)
}
}
func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) {
t.Setenv("ARK_API_KEY", "test-key")
t.Setenv("ARK_BASE_URL", "")
@@ -243,7 +321,19 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
}`, "")
factsPath := filepath.Join(t.TempDir(), "facts.json")
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
f := facts.Facts{
SchemaVersion: 1,
Skills: []facts.SkillFact{{
SourceFile: "skills/lark-wiki/SKILL.md",
Line: 30,
Changed: true,
ReferencesInvalidCommand: true,
}},
}
if !semantic.BuildInputView(f).HasReviewableFacts() {
t.Fatal("test setup must contain reviewable facts")
}
if err := f.WriteFile(factsPath); err != nil {
t.Fatalf("write facts: %v", err)
}
decisionPath := filepath.Join(t.TempDir(), "decision.json")

View File

@@ -5,7 +5,8 @@
"error_hint",
"default_output",
"naming",
"skill_quality"
"skill_quality",
"public_content_leakage"
],
"rollout_groups": [
{
@@ -16,7 +17,8 @@
},
"categories": [
"error_hint",
"skill_quality"
"skill_quality",
"public_content_leakage"
],
"owner": "cli-owner",
"reason": "first semantic blocking rollout only affects changed facts"

View File

@@ -13,14 +13,15 @@ import (
)
type Facts struct {
SchemaVersion int `json:"schema_version"`
Commands []CommandFact `json:"commands,omitempty"`
Skills []SkillFact `json:"skills,omitempty"`
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
Errors []ErrorFact `json:"errors,omitempty"`
Outputs []OutputFact `json:"outputs,omitempty"`
Examples []CommandExample `json:"examples,omitempty"`
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
SchemaVersion int `json:"schema_version"`
Commands []CommandFact `json:"commands,omitempty"`
Skills []SkillFact `json:"skills,omitempty"`
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
Errors []ErrorFact `json:"errors,omitempty"`
Outputs []OutputFact `json:"outputs,omitempty"`
Examples []CommandExample `json:"examples,omitempty"`
PublicContent []PublicContentFact `json:"public_content,omitempty"`
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
}
type CommandFact struct {
@@ -109,6 +110,17 @@ type OutputFact struct {
HasDecisionField bool `json:"has_decision_field,omitempty"`
}
type PublicContentFact struct {
Rule string `json:"rule"`
Action report.Action `json:"action"`
File string `json:"file"`
Line int `json:"line"`
Source string `json:"source,omitempty"`
Excerpt string `json:"excerpt,omitempty"`
Message string `json:"message,omitempty"`
Suggestion string `json:"suggestion,omitempty"`
}
type DryRunRequest struct {
Method string `json:"method"`
URL string `json:"url"`
@@ -206,6 +218,11 @@ func BuildWithCommandLookup(m manifest.Manifest, commandLookup manifest.Manifest
}
}
func WithPublicContent(f Facts, publicContent []PublicContentFact) Facts {
f.PublicContent = publicContent
return f
}
type commandScope struct {
Domain string
Source string

View File

@@ -34,6 +34,7 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}},
Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}},
Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}},
PublicContent: []PublicContentFact{{Rule: "public_content_generic_credential", Action: report.ActionReject, File: "docs/public.md", Line: 4, Excerpt: "api_key = <redacted>"}},
}
data, err := json.Marshal(f)
if err != nil {
@@ -43,7 +44,10 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal facts: %v", err)
}
if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict {
if !got.Errors[0].RequiredHint ||
got.Outputs[0].Fields[0] != "message_id" ||
!got.Skills[0].ScopeConflict ||
got.PublicContent[0].Rule != "public_content_generic_credential" {
t.Fatalf("facts lost gatekeeper fields: %#v", got)
}
}

View File

@@ -0,0 +1,343 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
)
func Collect(ctx context.Context, opts Options) ([]Finding, error) {
metadata, err := LoadMetadata(opts.MetadataPath)
if err != nil {
return nil, err
}
var out []Finding
changedFiles, base, err := changedFiles(ctx, opts.Repo, opts.ChangedFrom)
if err != nil {
return nil, err
}
patches := map[string][]changedChunk{}
if base != "" {
patches, err = changedPatches(ctx, opts.Repo, base)
if err != nil {
return nil, err
}
}
for _, file := range changedFiles {
if !scanChangedFile(file) {
continue
}
for _, chunk := range patches[file] {
findings := scanText(file, "file", chunk.Text, isDetectorRuleFile(file))
for i := range findings {
findings[i].Line += chunk.StartLine - 1
}
out = append(out, findings...)
out = append(out, semanticCandidate(file, "file", chunk.Text, chunk.StartLine)...)
}
privateKeyFindings, err := scanTouchedPrivateKeyBlocks(ctx, opts.Repo, file, patches[file])
if err != nil {
return nil, err
}
out = appendUniqueFindings(out, privateKeyFindings...)
}
if base != "" {
commitFindings, err := scanCommitMessages(ctx, opts.Repo, base)
if err != nil {
return nil, err
}
out = append(out, commitFindings...)
}
branchName := opts.BranchName
if branchName == "" {
branchName = metadata.Branch
}
if branchName == "" {
branchName = branchFromEnv()
}
if branchName == "" {
branchName = currentBranch(ctx, opts.Repo)
}
if branchName != "" {
out = append(out, scanText("branch", "branch", branchName, false)...)
}
out = append(out, scanMetadata(metadata)...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out, nil
}
func currentBranch(ctx context.Context, repo string) string {
data, err := gitOutput(ctx, repo, "branch", "--show-current")
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func branchFromEnv() string {
for _, key := range []string{"PR_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"} {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
}
return ""
}
func changedFiles(ctx context.Context, repo, changedFrom string) ([]string, string, error) {
if changedFrom == "" {
return nil, "", nil
}
baseBytes, err := gitOutput(ctx, repo, "merge-base", changedFrom, "HEAD")
if err != nil {
return nil, "", err
}
base := strings.TrimSpace(string(baseBytes))
files, err := diffFileNames(ctx, repo, base)
if err != nil {
return nil, "", err
}
sort.Strings(files)
return files, base, nil
}
func diffFileNames(ctx context.Context, repo, base string) ([]string, error) {
data, err := gitOutput(ctx, repo, "diff", "--name-only", "-z", "--diff-filter=ACMR", base+"..HEAD")
if err != nil {
return nil, err
}
var files []string
for _, file := range bytes.Split(data, []byte{0}) {
if len(file) == 0 {
continue
}
files = append(files, filepath.ToSlash(string(file)))
}
return files, nil
}
var detectorFixtureExclusions = map[string]bool{
"internal/qualitygate/publiccontent/collect_test.go": true,
"internal/qualitygate/publiccontent/rules.go": true,
"internal/qualitygate/publiccontent/scan.go": true,
"internal/qualitygate/publiccontent/scan_test.go": true,
}
func scanChangedFile(file string) bool {
normalized := strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), "./")
return !detectorFixtureExclusions[normalized]
}
type changedChunk struct {
StartLine int
Text string
}
func (c changedChunk) endLine() int {
lines := strings.Count(strings.TrimRight(c.Text, "\n"), "\n") + 1
if lines < 1 {
lines = 1
}
return c.StartLine + lines - 1
}
func changedPatches(ctx context.Context, repo, base string) (map[string][]changedChunk, error) {
files, err := diffFileNames(ctx, repo, base)
if err != nil {
return nil, err
}
data, err := gitOutput(ctx, repo, "diff", "--no-ext-diff", "--unified=0", "--diff-filter=ACMR", base+"..HEAD")
if err != nil {
return nil, err
}
out := map[string][]changedChunk{}
var file string
var chunk *changedChunk
nextLine := 0
nextFile := 0
flush := func() {
if file == "" || chunk == nil || chunk.Text == "" {
chunk = nil
return
}
out[file] = append(out[file], *chunk)
chunk = nil
}
for _, raw := range strings.Split(string(data), "\n") {
switch {
case strings.HasPrefix(raw, "diff --git "):
flush()
file = ""
if nextFile < len(files) {
file = files[nextFile]
nextFile++
}
case strings.HasPrefix(raw, "@@ "):
flush()
start, ok := parseNewHunkStart(raw)
if !ok {
nextLine = 0
continue
}
nextLine = start
chunk = &changedChunk{StartLine: start}
case strings.HasPrefix(raw, "+") && !strings.HasPrefix(raw, "+++"):
if chunk == nil {
chunk = &changedChunk{StartLine: max(nextLine, 1)}
}
chunk.Text += strings.TrimPrefix(raw, "+") + "\n"
nextLine++
case strings.HasPrefix(raw, "-"):
continue
default:
if chunk != nil && strings.HasPrefix(raw, `\ No newline at end of file`) {
continue
}
flush()
}
}
flush()
return out, nil
}
func parseNewHunkStart(header string) (int, bool) {
parts := strings.Split(header, " ")
for _, part := range parts {
if !strings.HasPrefix(part, "+") {
continue
}
raw := strings.TrimPrefix(part, "+")
if before, _, ok := strings.Cut(raw, ","); ok {
raw = before
}
start, err := strconv.Atoi(raw)
return start, err == nil && start > 0
}
return 0, false
}
func scanCommitMessages(ctx context.Context, repo, base string) ([]Finding, error) {
data, err := gitOutput(ctx, repo, "log", "--format=%H%x00%B%x00", base+"..HEAD")
if err != nil {
return nil, err
}
parts := bytes.Split(data, []byte{0})
var out []Finding
for i := 0; i+1 < len(parts); i += 2 {
sha := strings.TrimSpace(string(parts[i]))
body := string(parts[i+1])
if sha == "" || body == "" {
continue
}
short := sha
if len(short) > 12 {
short = short[:12]
}
out = append(out, scanText("commit:"+short, "commit", body, false)...)
out = append(out, semanticCandidate("commit:"+short, "commit", body, 1)...)
}
return out, nil
}
type lineRange struct {
Start int
End int
}
func scanTouchedPrivateKeyBlocks(ctx context.Context, repo, file string, chunks []changedChunk) ([]Finding, error) {
if len(chunks) == 0 {
return nil, nil
}
data, err := gitOutput(ctx, repo, "show", "HEAD:"+file)
if err != nil {
return nil, err
}
var added []lineRange
for _, chunk := range chunks {
added = append(added, lineRange{Start: chunk.StartLine, End: chunk.endLine()})
}
var out []Finding
for _, block := range privateKeyBlocks(string(data)) {
if !rangesIntersectAny(block, added) {
continue
}
out = append(out, newFinding("public_content_private_key_block", file, block.Start, "file", "private key block"))
}
return out, nil
}
func privateKeyBlocks(text string) []lineRange {
lines := strings.Split(text, "\n")
var out []lineRange
inPrivateKey := false
start := 0
for i, line := range lines {
lineNo := i + 1
if !inPrivateKey && strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = true
start = lineNo
}
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, lineRange{Start: start, End: lineNo})
inPrivateKey = false
}
}
return out
}
func rangesIntersectAny(block lineRange, ranges []lineRange) bool {
for _, r := range ranges {
if block.Start <= r.End && r.Start <= block.End {
return true
}
}
return false
}
func appendUniqueFindings(items []Finding, additions ...Finding) []Finding {
for _, addition := range additions {
duplicate := false
for _, item := range items {
if item.Rule == addition.Rule &&
item.File == addition.File &&
item.Line == addition.Line &&
item.Source == addition.Source {
duplicate = true
break
}
}
if !duplicate {
items = append(items, addition)
}
}
return items
}
func gitOutput(ctx context.Context, repo string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = repo
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.Bytes())
}
return stdout.Bytes(), nil
}

View File

@@ -0,0 +1,885 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "baseline.md"), `BASE_`+`TOKEN="baseline-only"
`)
runGit(t, repo, "add", "baseline.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
api_`+`key = "example-public-key"
`)
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":"publish public docs","body":"Reviewed`+`-on: https://review.example.test/c/project/+/123"}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
rules := findingRules(got)
for _, want := range []string{
"public_content_generic_credential",
"public_content_change_id_trailer",
"public_content_reviewed_on_trailer",
} {
if !rules[want] {
t.Fatalf("missing rule %s in findings %#v", want, got)
}
}
for _, item := range got {
if item.File == "baseline.md" {
t.Fatalf("collector scanned unchanged baseline file: %#v", got)
}
}
}
func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\n")
runGit(t, repo, "add", "docs/workflow.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\nnew public line\n")
runGit(t, repo, "add", "docs/workflow.md")
runGit(t, repo, "commit", "-m", "add public line")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
for _, item := range got {
if item.Rule == "public_content_generic_credential" && item.File == "docs/workflow.md" {
t.Fatalf("collector scanned unchanged legacy line in changed file: %#v", got)
}
}
}
func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
raw := "private launch plan for alpha-service rollout on Friday with SERVICE_" + "TOKEN=real-" + "secret-value"
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add semantic candidate")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
var found bool
for _, item := range got {
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
continue
}
found = true
if !strings.Contains(item.Excerpt, "alpha-service rollout on Friday") {
t.Fatalf("semantic candidate should include sanitized review text, got %#v", item)
}
if strings.Contains(item.Excerpt, "real-"+"secret-value") {
t.Fatalf("semantic candidate leaked credential value: %#v", item)
}
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
t.Fatalf("semantic candidate should redact credentials in review text, got %#v", item)
}
if !strings.Contains(item.Excerpt, "semantic signals") || !strings.Contains(item.Excerpt, "roadmap_timing") {
t.Fatalf("semantic candidate excerpt should preserve semantic signals, got %#v", item)
}
}
if !found {
t.Fatalf("missing semantic candidate in findings %#v", got)
}
}
func TestCollectSemanticCandidatesDoNotLeakWhitespaceCredentialTail(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
raw := "private launch plan for internal rollout on Friday with SERVICE_" + "TOKEN=\"real " + "secret value\""
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add semantic candidate")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
continue
}
if strings.Contains(item.Excerpt, "secret value") || strings.Contains(item.Excerpt, "real "+"secret value") {
t.Fatalf("semantic candidate leaked credential tail: %#v", item)
}
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
t.Fatalf("semantic candidate should redact full credential assignment, got %#v", item)
}
return
}
t.Fatalf("missing semantic candidate in findings %#v", got)
}
func TestCollectJSONBearerHeadersDoNotLeakIntoSemanticCandidates(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "base")
token := "abcdefghijklmnopqrstuvwxyz"
raw := "private launch plan for internal rollout on Friday with " +
`{"headers":{"Authorization":"Bearer ` + token + `"}}`
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add json bearer")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/public.md", "public_content_bearer_header")
for _, item := range got {
if item.File != "docs/public.md" {
continue
}
if strings.Contains(item.Excerpt, token) {
t.Fatalf("finding leaked JSON bearer token: %#v", item)
}
}
}
func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_` + `token":"real-json-token"}`,
`{"client_` + `secret": "real ` + `secret value"}`,
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
`{"github` + `Token":"real-github-token"}`,
`{"vendorApi` + `Key":"real-vendor-key"}`,
`{"slackBot` + `Token":"xoxb-real-token"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add json config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
for _, forbidden := range []string{
"real-json-token",
"real secret value",
"real-tenant-camel-token",
"real-github-token",
"real-vendor-key",
"xoxb-real-token",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
}
}
}
if count != 6 {
t.Fatalf("JSON credential findings = %d, want 6: %#v", count, got)
}
}
func TestCollectAllowsBenignJSONTokenFields(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"tokenizer":"cl100k_base"}`,
`{"token_count": 42}`,
`{"page_token":"next"}`,
`{"next_page_token":"next"}`,
`{"file_token":"file-example"}`,
`{"doc_token":"doc-example"}`,
`{"node_token":"node-example"}`,
`{"wiki_token":"wikcn_public_doc_example"}`,
`{"folder_token":"folder-example"}`,
`{"obj_token":"obj-example"}`,
`{"spreadsheet_token":"sheet-example"}`,
`{"parent_node_token":"parent-example"}`,
`{"origin_node_token":"origin-example"}`,
`{"drive_route_token":"route-example"}`,
`{"token":"<wiki_token>"}`,
`{"token":"wiki_token"}`,
`{"token_url":"https://example.com/oauth/token"}`,
`{"token_endpoint":"https://example.com/oauth/token"}`,
`{"token_format":"Bearer"}`,
`{"secret_name":"public-example-secret"}`,
`{"base_token":"base-example"}`,
`{"app_token":"app-example"}`,
`{"sync_token":"sync-example"}`,
`{"parent_token":"parent-example"}`,
`{"target_token":"target-example"}`,
`{"parent_file_token":"parent-file-example"}`,
`{"refresh_token_expires_in": 7200}`,
`{"access_token_expires_in": 7200}`,
`{"token_expires_in": 7200}`,
`{"token_status":"active"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add benign json token fields")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
t.Fatalf("benign JSON token field should not be credential finding: %#v", got)
}
}
}
func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY: <" + stripeLike + ">",
"SECRET_TOKEN: <" + patLike + ">",
"CLIENT_SECRET: <real-client-secret-value>",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_token_expires_in":"` + patLike + `"}`,
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
`{"client_secret_status":"real-client-secret-value"}`,
`{"client_secret_name":"real-client-secret-value"}`,
`{"app_token":"` + patLike + `"}`,
`{"sync_token":"` + stripeLike + `"}`,
`{"target_token":"real-client-secret-value"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add credential-shaped benign fields")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
}
}
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_NAME: prod_key",
"CLIENT_SECRET_NAME: prod_secret",
"SECRET_STATUS: prod_secret",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY: " + accessKey,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add access key config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
}
}
if count != 3 {
t.Fatalf("access key credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"PRIVATE_KEY: " + privateKey,
"SSH_PRIVATE_KEY: " + privateKey,
"JWT_PRIVATE_KEY: " + privateKey,
"SIGNING_PRIVATE_KEY: " + privateKey,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add private key config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
if strings.Contains(item.Excerpt, privateKey) {
t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt)
}
}
if count != 4 {
t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got)
}
}
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: prod_key",
"CLIENT_SECRET_GOOGLE: prod_secret",
"TOKEN_GITHUB: github_token",
"APP_PASSWORD_PROD: prod_password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"tokens: 128",
"token_type: bearer",
"max_tokens: 2000",
"completion_tokens: 200",
"prompt_tokens: 100",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add benign token config")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
t.Fatalf("benign unquoted token field should not be credential finding: %#v", got)
}
}
}
func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
continue
}
count++
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
}
}
if count != 5 {
t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got)
}
}
func TestCollectDetectsPrivateKeyWhenOnlyEndIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n")
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\nnew-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "complete key")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectDetectsPrivateKeyWhenOnlyBeginIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), "legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "complete key")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectDetectsPrivateKeyWhenOnlyBodyIsAdded(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"new-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "add body")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
}
func TestCollectIgnoresUntouchedHistoricalPrivateKey(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
writeFile(t, filepath.Join(repo, "docs", "public.md"), "public docs update\n")
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "docs update")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
t.Fatalf("collector reported untouched historical private key: %#v", got)
}
}
}
func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
runGit(t, repo, "add", "docs/key.pem")
runGit(t, repo, "commit", "-m", "remove body")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
t.Fatalf("collector reported delete-only private key cleanup: %#v", got)
}
}
}
func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "collect_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add scanner fixtures")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
var foundOrdinaryTestLeak bool
for _, item := range got {
switch item.File {
case "internal/qualitygate/publiccontent/collect_test.go",
"internal/qualitygate/publiccontent/scan.go",
"internal/qualitygate/publiccontent/scan_test.go",
"internal/qualitygate/publiccontent/rules.go":
t.Fatalf("collector scanned known fixture or detector implementation file: %#v", got)
}
if item.File == "tests/e2e/new-public-workflow.test.sh" && item.Rule == "public_content_generic_credential" {
foundOrdinaryTestLeak = true
}
}
if !foundOrdinaryTestLeak {
t.Fatalf("collector should still scan ordinary test files for real leaks: %#v", got)
}
}
func TestScanChangedFileDocumentsFixtureExclusions(t *testing.T) {
excluded := []string{
"internal/qualitygate/publiccontent/collect_test.go",
"internal/qualitygate/publiccontent/rules.go",
"internal/qualitygate/publiccontent/scan.go",
"internal/qualitygate/publiccontent/scan_test.go",
}
for _, file := range excluded {
if scanChangedFile(file) {
t.Fatalf("scanChangedFile(%q) = true, want false for detector fixture/implementation path", file)
}
}
included := []string{
"internal/qualitygate/publiccontent/new_test.go",
"tests/e2e/new-public-workflow.test.sh",
"docs/public.md",
}
for _, file := range included {
if !scanChangedFile(file) {
t.Fatalf("scanChangedFile(%q) = false, want true", file)
}
}
}
func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "old.md"), "base\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add special paths")
got := collectFromPreviousCommit(t, repo)
requireFinding(t, got, "docs/has space.md", "public_content_generic_credential")
requireFinding(t, got, `weird"quote.md`, "public_content_generic_credential")
requireFinding(t, got, "docs/new name.md", "public_content_generic_credential")
}
func TestCollectScansBranchNameAsWarning(t *testing.T) {
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
if len(got) != 1 || got[0].Rule != "public_content_automation_branch" {
t.Fatalf("branch findings = %#v", got)
}
}
func TestCollectUsesExplicitBranchNameWhenDetached(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
runGit(t, repo, "checkout", "-b", "bot/public-doc-update")
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
runGit(t, repo, "add", "docs.md")
runGit(t, repo, "commit", "-m", "docs")
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
runGit(t, repo, "checkout", "--detach", head)
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
BranchName: "bot/public-doc-update",
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
requireFinding(t, got, "branch", "public_content_automation_branch")
}
func TestCollectUsesBranchEnvironmentWhenDetached(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
runGit(t, repo, "checkout", "-b", "bot/public-env-update")
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
runGit(t, repo, "add", "docs.md")
runGit(t, repo, "commit", "-m", "docs")
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
runGit(t, repo, "checkout", "--detach", head)
t.Setenv("GITHUB_HEAD_REF", "bot/public-env-update")
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
requireFinding(t, got, "branch", "public_content_automation_branch")
}
func TestCollectPreservesFindingAttributionForChangedLines(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\n")
runGit(t, repo, "add", "docs/auth.md")
runGit(t, repo, "commit", "-m", "base")
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\nAuthorization: Bearer abcdefghijklmnopqrstuvwxyz\n")
runGit(t, repo, "add", "docs/auth.md")
runGit(t, repo, "commit", "-m", "add auth docs")
got := collectFromPreviousCommit(t, repo)
for _, item := range got {
if item.Rule == "public_content_bearer_header" {
if item.File != "docs/auth.md" || item.Line != 2 || item.Source != "file" {
t.Fatalf("changed-line attribution = %#v", item)
}
return
}
}
t.Fatalf("missing bearer finding: %#v", got)
}
func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
base := []Finding{newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block")}
got := appendUniqueFindings(base,
newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block"),
newFinding("public_content_private_key_block", "docs/key.pem", 2, "file", "private key block"),
)
if len(got) != 2 {
t.Fatalf("appendUniqueFindings len = %d, want 2: %#v", len(got), got)
}
}
func newGitRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
return repo
}
func privateKeyBegin() string {
return privateKeyBeginPrefix + privateKeyMarker + "\n"
}
func privateKeyEnd() string {
return privateKeyEndPrefix + privateKeyMarker + "\n"
}
func collectFromPreviousCommit(t *testing.T, repo string) []Finding {
t.Helper()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{}`)
got, err := Collect(context.Background(), Options{
Repo: repo,
ChangedFrom: "HEAD~1",
MetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Collect() error = %v", err)
}
return got
}
func requireFinding(t *testing.T, got []Finding, file, rule string) {
t.Helper()
for _, item := range got {
if item.File == file && item.Rule == rule {
return
}
}
t.Fatalf("missing %s in %s findings: %#v", rule, file, got)
}
func TestCollectRequiresValidMetadataJSON(t *testing.T) {
repo := t.TempDir()
metadataPath := filepath.Join(repo, "pr-metadata.json")
writeFile(t, metadataPath, `{"title":`)
_, err := Collect(context.Background(), Options{Repo: repo, MetadataPath: metadataPath})
if err == nil || !strings.Contains(err.Error(), "public content metadata") {
t.Fatalf("Collect() error = %v, want metadata parse error", err)
}
}
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
if len(args) > 0 && args[0] == "commit" {
args = append([]string{"commit", "--no-verify"}, args[1:]...)
}
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
}
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
return out
}
func writeFile(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
func ScanComment(kind, body string) []Finding {
if kind == "" {
kind = "comment"
}
return scanText(kind, "comment", body, false)
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import "testing"
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
rules := findingRules(got)
if !rules["public_content_harness_metadata"] || !rules["public_content_ccm_harness_trailer"] {
t.Fatalf("comment audit findings = %#v", got)
}
for _, item := range got {
if item.File != "issue_comment" {
t.Fatalf("comment finding file = %q, want issue_comment", item.File)
}
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
func LoadMetadata(path string) (Metadata, error) {
if path == "" {
return Metadata{}, nil
}
data, err := vfs.ReadFile(path)
if err != nil {
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
}
if len(data) == 0 {
return Metadata{}, nil
}
var out Metadata
if err := json.Unmarshal(data, &out); err != nil {
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
}
return out, nil
}
func scanMetadata(m Metadata) []Finding {
text := ""
if m.Title != "" {
text += "title: " + m.Title + "\n"
}
if m.Body != "" {
text += "body:\n" + m.Body + "\n"
}
if text == "" {
return nil
}
out := scanText("pull_request_metadata", "metadata", text, false)
out = append(out, semanticCandidate("pull_request_metadata", "metadata", text, 1)...)
return out
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"path/filepath"
"testing"
)
func TestLoadMetadataReadsTitleAndBody(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
writeFile(t, path, `{"title":"public change","body":"pass`+`word = \"example-password\""}`)
got, err := LoadMetadata(path)
if err != nil {
t.Fatalf("LoadMetadata() error = %v", err)
}
if got.Title != "public change" || got.Body == "" {
t.Fatalf("metadata = %#v", got)
}
}

View File

@@ -0,0 +1,441 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/internal/qualitygate/report"
)
var (
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
semanticBearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+[^"'\s,}\]]+|["']Authorization["']\s*:\s*["']Bearer\s+[^"'\\\s,}\]]+)`)
changeIDTrailerRE = regexp.MustCompile(`(?i)^\s*Change-Id:\s*\S+`)
reviewedOnTrailerRE = regexp.MustCompile(`(?i)^\s*Reviewed-on:\s*\S+`)
ccmHarnessTrailerRE = regexp.MustCompile(`(?i)\bCCM-Harness:\s*\S+`)
privateIPv4RE = regexp.MustCompile(`\b(?:10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(?:1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3})\b`)
automationBranchRE = regexp.MustCompile(`(?i)(^|/)(bot|automation)[-/]`)
)
func actionForRule(rule string) report.Action {
switch rule {
case "public_content_generic_credential",
"public_content_private_key_block",
"public_content_jwt_like_token",
"public_content_bearer_header",
"public_content_credential_url",
"public_content_change_id_trailer",
"public_content_reviewed_on_trailer",
"public_content_provenance_marker",
"public_content_detector_fingerprint",
"public_content_harness_metadata",
"public_content_ccm_harness_trailer":
return report.ActionReject
case "public_content_private_ipv4",
"public_content_automation_branch":
return report.ActionWarning
default:
return report.ActionWarning
}
}
func isPlaceholderValue(value string) bool {
trimmed := strings.Trim(value, `"'`)
normalized := strings.ToLower(trimmed)
if normalized == "" ||
normalized == "=" ||
percentWrappedPlaceholder(normalized) ||
angleWrappedPlaceholder(normalized) ||
urlWithAnglePlaceholder(normalized) ||
isCredentialReferenceValue(trimmed) {
return true
}
return namedPlaceholderValue(normalized)
}
func namedPlaceholderValue(value string) bool {
switch value {
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
return true
}
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
}
func allXPlaceholder(value string) bool {
if len(value) < 4 {
return false
}
for _, r := range value {
if r != 'x' {
return false
}
}
return true
}
func urlWithAnglePlaceholder(value string) bool {
if !strings.Contains(value, "://") ||
!strings.Contains(value, "<") ||
!strings.Contains(value, ">") {
return false
}
return !urlRemainderLooksCredentialLike(removeAnglePlaceholders(value))
}
func removeAnglePlaceholders(value string) string {
var out strings.Builder
for len(value) > 0 {
start := strings.Index(value, "<")
if start < 0 {
out.WriteString(value)
break
}
out.WriteString(value[:start])
end := strings.Index(value[start+1:], ">")
if end < 0 {
out.WriteString(value[start:])
break
}
value = value[start+end+2:]
}
return out.String()
}
func urlRemainderLooksCredentialLike(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{
"secret",
"token",
"password",
"passwd",
"api_key",
"apikey",
"private_key",
"privatekey",
"client_secret",
"clientsecret",
} {
if strings.Contains(normalized, marker) {
return true
}
}
for _, part := range strings.FieldsFunc(normalized, func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
}) {
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
return true
}
}
return false
}
func longCredentialSegment(value string) bool {
if len(value) < 16 {
return false
}
var hasLetter, hasDigit bool
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
case r == '_' || r == '-':
default:
return false
}
}
return hasLetter || hasDigit
}
func isCredentialReferenceValue(value string) bool {
normalized := strings.ToLower(value)
switch {
case strings.HasPrefix(normalized, "${{"):
return githubExpressionReference(normalized)
case strings.HasPrefix(normalized, "$("):
return !commandSubstitutionLooksCredentialLike(normalized)
case strings.HasPrefix(normalized, "process.env."):
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "process.env."))
case strings.HasPrefix(normalized, "${"):
return credentialReferenceIdentifier(strings.TrimSuffix(strings.TrimPrefix(normalized, "${"), "}"))
case strings.HasPrefix(value, "$"):
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "$"))
default:
return false
}
}
func commandSubstitutionLooksCredentialLike(value string) bool {
if !strings.HasPrefix(value, "$(") || !strings.HasSuffix(value, ")") {
return false
}
inner := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
for _, part := range strings.FieldsFunc(inner, func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
}) {
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
return true
}
}
return false
}
func githubExpressionReference(value string) bool {
if !strings.HasPrefix(value, "${{") || !strings.HasSuffix(value, "}}") {
return false
}
expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
switch {
case strings.HasPrefix(expr, "secrets."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "secrets."))
case strings.HasPrefix(expr, "env."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "env."))
case strings.HasPrefix(expr, "vars."):
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "vars."))
case expr == "github.token":
return true
default:
return false
}
}
func dottedReferenceIdentifier(value string) bool {
if value == "" {
return false
}
for _, part := range strings.Split(value, ".") {
if !referenceIdentifier(part) {
return false
}
}
return true
}
func credentialReferenceIdentifier(value string) bool {
return referenceIdentifier(value) && !credentialShapedIdentifier(value)
}
func referenceIdentifier(value string) bool {
if value == "" {
return false
}
for i, r := range value {
switch {
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9' && i > 0:
case r == '_' && i > 0:
default:
return false
}
}
return true
}
func angleWrappedPlaceholder(value string) bool {
if len(value) < 3 || !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
return false
}
return anglePlaceholderIdentifier(strings.Trim(value, "<>"))
}
func percentWrappedPlaceholder(value string) bool {
if len(value) < 3 || !strings.HasPrefix(value, "%") || !strings.HasSuffix(value, "%") {
return false
}
inner := strings.Trim(value, "%")
return delimitedPlaceholderIdentifier(inner) && !credentialShapedIdentifier(inner)
}
func delimitedPlaceholderIdentifier(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
return true
}
func anglePlaceholderIdentifier(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
continue
}
return false
}
if credentialShapedIdentifier(value) {
return false
}
switch value {
case "token",
"id",
"userid",
"openid",
"key",
"secret",
"password",
"api-key",
"user-id",
"open-id",
"client-secret",
"access-token",
"refresh-token",
"auth-token",
"bearer-token",
"session-token",
"service-token":
return true
}
for _, suffix := range []string{"_token", "_id", "_key", "_secret", "_password"} {
if strings.HasSuffix(value, suffix) {
return true
}
}
for _, suffix := range []string{"-token", "-id", "-key", "-secret", "-password"} {
if strings.HasSuffix(value, suffix) {
return true
}
}
return false
}
func credentialShapedValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
return credentialShapedIdentifier(normalized)
}
func credentialShapedIdentifier(value string) bool {
switch {
case strings.HasPrefix(value, "sk_live_"),
strings.HasPrefix(value, "sk_test_"),
strings.HasPrefix(value, "ghp_"),
strings.HasPrefix(value, "gho_"),
strings.HasPrefix(value, "ghu_"),
strings.HasPrefix(value, "github_pat_"),
strings.HasPrefix(value, "xoxb_"),
strings.HasPrefix(value, "xoxp_"),
strings.HasPrefix(value, "xoxa_"):
return true
case strings.HasPrefix(value, "real-") &&
(strings.Contains(value, "secret") ||
strings.Contains(value, "token") ||
strings.Contains(value, "key") ||
strings.Contains(value, "password")):
return true
default:
return false
}
}
func resourceTokenPlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
switch normalized {
case "wiki_token",
"folder_token",
"obj_token",
"spreadsheet_token",
"file_token",
"doc_token",
"node_token",
"parent_node_token",
"origin_node_token",
"drive_route_token":
return true
default:
return minuteTokenFixturePlaceholder(normalized)
}
}
func minuteTokenFixturePlaceholder(value string) bool {
if value == "minute_no_meta" {
return true
}
suffix, ok := strings.CutPrefix(value, "minute_")
if !ok || suffix == "" {
return false
}
for _, r := range suffix {
if r < '0' || r > '9' {
return false
}
}
return true
}
func provenanceMarker(line string) bool {
normalized := strings.ToLower(line)
markers := []string{
"generat" + "ed by tool",
"creat" + "ed by tool",
"generat" + "ed by automation",
"creat" + "ed by automation",
"machine-" + "generated",
"generated with automated",
"generated with automation",
"🤖 generated",
}
for _, marker := range markers {
if strings.Contains(normalized, marker) {
return true
}
}
if strings.HasPrefix(normalized, "co-authored-by:") &&
(strings.Contains(normalized, "<bot@") ||
strings.Contains(normalized, " bot@") ||
strings.Contains(normalized, "[bot]") ||
strings.Contains(normalized, "automation") ||
strings.Contains(normalized, "automated-code-assistant")) {
return true
}
return false
}
// Detector fingerprint checks are intentionally scoped to public rule/config
// files. They do not try to hide this package's implementation; they prevent
// publishing reusable detector identifiers in external-facing rule bundles.
func isDetectorRuleFile(path string) bool {
normalized := filepath.ToSlash(path)
base := filepath.Base(normalized)
return base == ".gitleaks.toml" ||
strings.Contains(normalized, "public-rules/") ||
strings.Contains(normalized, "public_rules/")
}
func detectorFingerprint(line string) bool {
normalized := strings.ToLower(line)
fingerprints := []string{
strings.Join([]string{"public", "content", "leakage"}, "-"),
strings.Join([]string{"public", "content", "detector"}, "-"),
"publiccontent",
}
for _, fingerprint := range fingerprints {
if strings.Contains(normalized, fingerprint) {
return true
}
}
return false
}
func redactCredentialURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.User == nil {
return "<credential-url>"
}
u.User = url.UserPassword("<user>", "<redacted>")
return u.String()
}

View File

@@ -0,0 +1,797 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"fmt"
"path/filepath"
"sort"
"strings"
"unicode"
)
const (
privateKeyBeginPrefix = "-----" + "BEGIN "
privateKeyEndPrefix = "-----" + "END "
privateKeyMarker = "PRIVATE " + "KEY-----"
)
func ScanFile(path string, data []byte) []Finding {
return scanText(filepath.ToSlash(path), "file", string(data), isDetectorRuleFile(path))
}
func semanticCandidate(file, source, text string, line int) []Finding {
excerpt := redactedSemanticExcerpt(text)
if excerpt == "" {
return nil
}
return []Finding{newFinding("public_content_semantic_candidate", file, line, source, excerpt)}
}
func scanText(file, source, text string, detectorFile bool) []Finding {
var out []Finding
lines := strings.Split(text, "\n")
inPrivateKey := false
privateKeyLine := 0
for i, line := range lines {
lineNo := i + 1
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = true
privateKeyLine = lineNo
}
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
inPrivateKey = false
}
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
if !isCredentialAssignmentMatch(match[0]) {
continue
}
value := credentialAssignmentValue(match)
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, value) ||
isPlaceholderValue(value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
continue
}
if looksLikeEqualityComparison(value) {
continue
}
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if isSchemaDottedIdentifier(line, match) {
continue
}
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
}
for range bearerHeaderRE.FindAllString(line, -1) {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(match) {
continue
}
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
}
for _, match := range privateIPv4RE.FindAllString(line, -1) {
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
}
if source == "branch" && automationBranchRE.MatchString(line) {
out = append(out, newFinding("public_content_automation_branch", file, lineNo, source, "automation branch marker"))
}
switch {
case changeIDTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_change_id_trailer", file, lineNo, source, "Change-Id: <redacted>"))
case reviewedOnTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_reviewed_on_trailer", file, lineNo, source, "Reviewed-on: <redacted>"))
case ccmHarnessTrailerRE.MatchString(line):
out = append(out, newFinding("public_content_ccm_harness_trailer", file, lineNo, source, "CCM-Harness: <redacted>"))
}
if provenanceMarker(line) {
out = append(out, newFinding("public_content_provenance_marker", file, lineNo, source, "provenance marker"))
}
if strings.Contains(line, "/tmp/harness-agent") {
out = append(out, newFinding("public_content_harness_metadata", file, lineNo, source, "/tmp/harness-agent"))
}
if detectorFile && detectorFingerprint(line) {
out = append(out, newFinding("public_content_detector_fingerprint", file, lineNo, source, "public detector fingerprint"))
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
if out[i].Line != out[j].Line {
return out[i].Line < out[j].Line
}
return out[i].Rule < out[j].Rule
})
return out
}
func isCredentialAssignmentMatch(match string) bool {
name, value, ok := normalizedCredentialAssignment(match)
if !ok {
return false
}
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
return true
}
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
return isExplicitCredentialKey(name)
}
func normalizedCredentialAssignmentKey(match string) (string, bool) {
key, _, ok := normalizedCredentialAssignment(match)
return key, ok
}
func normalizedCredentialAssignment(match string) (string, string, bool) {
key, ok := credentialAssignmentKey(match)
if !ok {
return "", "", false
}
key = strings.TrimSpace(key)
if key == "" {
return "", "", false
}
submatches := credentialAssignmentRE.FindStringSubmatch(match)
return normalizedCredentialKey(strings.Trim(key, `"'`)), credentialAssignmentValue(submatches), true
}
func normalizedCredentialKey(key string) string {
key = strings.TrimSpace(key)
var out []rune
var prev rune
for i, r := range key {
if r == '-' {
r = '_'
}
if i > 0 && isCredentialKeyBoundary(prev, r) {
out = append(out, '_')
}
out = append(out, unicode.ToLower(r))
prev = r
}
key = string(out)
key = strings.ReplaceAll(key, "-", "_")
return key
}
func isCredentialKeyBoundary(prev, current rune) bool {
if prev == '_' || current == '_' {
return false
}
return (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(current)
}
func isBenignTokenField(key string) bool {
if isTokenMetricField(key) ||
isTokenMetadataField(key) ||
isResourceTokenField(key) ||
isPaginationOrSyncTokenField(key) {
return true
}
return false
}
func isTokenMetricField(key string) bool {
switch key {
case "tokenizer",
"token_count",
"tokens",
"max_tokens",
"completion_tokens",
"prompt_tokens":
return true
default:
return false
}
}
func isTokenMetadataField(key string) bool {
switch key {
case "access_token_expires_in",
"refresh_token_expires_in",
"token_expires_in",
"token_status",
"token_type",
"token_url",
"token_endpoint",
"token_format",
"secret_name":
return true
default:
return false
}
}
func isPaginationOrSyncTokenField(key string) bool {
switch key {
case "page_token",
"next_page_token",
"sync_token":
return true
default:
return false
}
}
func isResourceTokenField(key string) bool {
if !strings.HasSuffix(key, "_token") {
return false
}
prefix := strings.TrimSuffix(key, "_token")
switch prefix {
case "app",
"base",
"board",
"doc",
"drive_route",
"file",
"folder",
"host_node",
"minute",
"node",
"obj",
"origin_node",
"parent",
"parent_file",
"parent_node",
"share",
"spreadsheet",
"target",
"wiki":
return true
default:
return false
}
}
func isResourceTokenPlaceholderAssignment(key, value string) bool {
switch {
case key == "client_token" && idempotencyTokenPlaceholderValue(value):
return true
case key == "retry_without_token" && numericStringPlaceholderValue(value):
return true
case tokenLikePlaceholderKey(key):
return tokenLikePlaceholderValue(value)
default:
return false
}
}
func tokenLikePlaceholderKey(key string) bool {
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func tokenLikePlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
return resourceTokenPlaceholderValue(value) ||
isPlaceholderValue(value) ||
normalized == "token" ||
strings.Contains(normalized, "...") ||
strings.Contains(normalized, "xxx") ||
strings.Contains(normalized, "_or_") ||
strings.HasSuffix(normalized, "_token") ||
strings.HasPrefix(normalized, ".")
}
func idempotencyTokenPlaceholderValue(value string) bool {
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
}
func uuidStringPlaceholderValue(value string) bool {
normalized := strings.Trim(value, `"'`)
parts := strings.Split(normalized, "-")
if len(parts) != 5 {
return false
}
for i, part := range parts {
want := []int{8, 4, 4, 4, 12}[i]
if len(part) != want {
return false
}
for _, r := range part {
if (r >= '0' && r <= '9') ||
(r >= 'a' && r <= 'f') ||
(r >= 'A' && r <= 'F') {
continue
}
return false
}
}
return true
}
func numericStringPlaceholderValue(value string) bool {
normalized := strings.Trim(value, `"'`)
if normalized == "" {
return false
}
for _, r := range normalized {
if r < '0' || r > '9' {
return false
}
}
return true
}
func isBenignCodeCredentialExpression(file, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
return false
}
return codeReferenceExpression(normalized)
}
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".py":
return true
default:
return false
}
}
func quotedLiteral(value string) bool {
normalized := strings.TrimSpace(value)
return len(normalized) >= 2 &&
((strings.HasPrefix(normalized, `"`) && strings.HasSuffix(normalized, `"`)) ||
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
}
func codeReferenceExpression(value string) bool {
if value == "" {
return false
}
for _, marker := range []string{".", "(", ")", "[", "]", "{"} {
if strings.Contains(value, marker) {
return true
}
}
return codeIdentifier(value) && !credentialNameFragment(value)
}
func codeIdentifier(value string) bool {
for i, r := range value {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r == '_' && i > 0:
case r >= '0' && r <= '9' && i > 0:
default:
return false
}
}
return true
}
func credentialNameFragment(value string) bool {
normalized := strings.ToLower(value)
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func isSchemaDottedIdentifier(line, match string) bool {
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
}
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[":
return true
default:
return false
}
}
func isWebhookCredentialKey(key string) bool {
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
}
func webhookAssignmentValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || isPlaceholderValue(normalized) || isNonSecretLiteralValue(normalized) {
return false
}
return urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)) ||
credentialShapedIdentifier(strings.Trim(normalized, "$"))
}
func isExplicitCredentialKey(key string) bool {
compact := strings.ReplaceAll(key, "_", "")
switch compact {
case "token",
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"secret",
"secretkey",
"clientsecret",
"password",
"passwd":
return true
}
for _, phrase := range []string{
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"bottoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"clientsecret",
"secretkey",
} {
if strings.Contains(compact, phrase) {
return true
}
}
parts := credentialKeyParts(key)
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "key"},
{"access", "key"},
{"private", "key"},
{"api", "secret"},
{"client", "secret"},
{"secret", "key"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
for _, part := range parts {
switch part {
case "token", "secret", "password", "passwd":
return true
}
}
for _, suffix := range []string{
"token",
"accesstoken",
"refreshtoken",
"authtoken",
"bearertoken",
"sessiontoken",
"servicetoken",
"bottoken",
"apikey",
"accesskey",
"privatekey",
"apisecret",
"clientsecret",
"secret",
"secretkey",
"password",
"passwd",
} {
if strings.HasSuffix(compact, suffix) {
return true
}
}
for _, suffix := range []string{
"_access_token",
"_refresh_token",
"_auth_token",
"_bearer_token",
"_session_token",
"_service_token",
"_api_key",
"_access_key",
"_private_key",
"_api_secret",
"_client_secret",
"_secret",
"_secret_key",
"_password",
"_passwd",
} {
if strings.HasSuffix(key, suffix) {
return true
}
}
return false
}
func credentialKeyParts(key string) []string {
var parts []string
for _, part := range strings.Split(key, "_") {
if part != "" {
parts = append(parts, part)
}
}
return parts
}
func hasAdjacentCredentialParts(parts []string, first, second string) bool {
for i := 0; i+1 < len(parts); i++ {
if parts[i] == first && parts[i+1] == second {
return true
}
}
return false
}
func credentialAssignmentValue(match []string) string {
for _, value := range match[1:] {
if value != "" {
return value
}
}
return ""
}
func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=")
}
func isPlaceholderCredentialURL(raw string) bool {
userInfo, ok := credentialURLUserInfo(raw)
if !ok {
return false
}
_, password, ok := strings.Cut(userInfo, ":")
if !ok {
return false
}
return credentialURLPasswordPlaceholder(password)
}
func credentialURLPasswordPlaceholder(password string) bool {
normalized := strings.ToLower(password)
decoded := strings.ReplaceAll(normalized, "%3c", "<")
decoded = strings.ReplaceAll(decoded, "%3e", ">")
switch decoded {
case "placeholder", "redacted", "<redacted>", "xxxx":
return true
}
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
}
func credentialURLUserInfo(raw string) (string, bool) {
schemeIdx := strings.Index(raw, "://")
if schemeIdx < 0 {
return "", false
}
rest := raw[schemeIdx+len("://"):]
atIdx := strings.Index(rest, "@")
if atIdx < 0 {
return "", false
}
return rest[:atIdx], true
}
func newFinding(rule, file string, line int, source, excerpt string) Finding {
return Finding{
Rule: rule,
Action: actionForRule(rule),
File: file,
Line: line,
Source: source,
Excerpt: excerpt,
Message: messageForRule(rule),
Suggestion: suggestionForRule(rule),
}
}
func messageForRule(rule string) string {
switch rule {
case "public_content_generic_credential":
return "public contribution contains a generic credential assignment"
case "public_content_private_key_block":
return "public contribution contains a private key block"
case "public_content_jwt_like_token":
return "public contribution contains a JWT-like token"
case "public_content_bearer_header":
return "public contribution contains an Authorization bearer token"
case "public_content_credential_url":
return "public contribution contains credentials embedded in a URL"
case "public_content_private_ipv4":
return "public contribution contains a private-network IP address"
case "public_content_automation_branch":
return "public contribution uses an automation-shaped branch name"
case "public_content_change_id_trailer":
return "public contribution contains a Change-Id trailer"
case "public_content_reviewed_on_trailer":
return "public contribution contains a Reviewed-on trailer"
case "public_content_provenance_marker":
return "public contribution contains a prohibited provenance marker"
case "public_content_detector_fingerprint":
return "public rule/config content exposes public detector fingerprints"
case "public_content_harness_metadata":
return "public contribution contains visible harness pipeline metadata"
case "public_content_ccm_harness_trailer":
return "public contribution contains a CCM-Harness trailer"
case "public_content_semantic_candidate":
return "public contribution contains text for semantic public content review"
default:
return "public contribution contains content that should not be published"
}
}
func suggestionForRule(rule string) string {
switch actionForRule(rule) {
case "REJECT":
return "remove the value from the public contribution and replace it with a non-sensitive placeholder"
default:
return "remove private workflow metadata before publishing the public contribution"
}
}
func redactAssignment(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return "<credential-assignment>"
}
return fmt.Sprintf("%s= <redacted>", strings.TrimSpace(key))
}
func credentialAssignmentKey(match string) (string, bool) {
idx := -1
for _, sep := range []string{":", "="} {
if candidate := strings.Index(match, sep); candidate >= 0 && (idx < 0 || candidate < idx) {
idx = candidate
}
}
if idx < 0 {
return "", false
}
return match[:idx], true
}
func redactToken(_ string) string {
return "<jwt-like-token>"
}
func redactedSemanticExcerpt(text string) string {
normalized := strings.Join(strings.Fields(text), " ")
if normalized == "" {
return ""
}
signals := semanticSignals(normalized)
if len(signals) == 0 {
return ""
}
sanitized := truncateRunes(sanitizeSemanticExcerpt(text), 600)
return fmt.Sprintf("semantic signals: %s; excerpt: %q", strings.Join(signals, ","), sanitized)
}
func semanticSignals(normalized string) []string {
lower := strings.ToLower(normalized)
var signals []string
add := func(signal string) {
for _, existing := range signals {
if existing == signal {
return
}
}
signals = append(signals, signal)
}
hasPrivateScope := strings.Contains(lower, "private") || strings.Contains(lower, "internal-only")
hasRequestMetadata := strings.Contains(lower, "request header") || strings.Contains(lower, "request headers") || strings.Contains(lower, "authorization header") || strings.Contains(lower, "metadata header")
hasTrustBoundary := strings.Contains(lower, "spoof") || strings.Contains(lower, "trust") || strings.Contains(lower, "risk scoring") || strings.Contains(lower, "classification")
hasRoadmap := strings.Contains(lower, "roadmap") || strings.Contains(lower, "migration") || strings.Contains(lower, "rollout") || strings.Contains(lower, "cutover") || strings.Contains(lower, "unpublished")
hasTiming := strings.Contains(lower, "target date") || strings.Contains(lower, "friday") || strings.Contains(lower, "monday") || strings.Contains(lower, "tuesday") || strings.Contains(lower, "wednesday") || strings.Contains(lower, "thursday") || strings.Contains(lower, "customer-visible")
hasImplementation := strings.Contains(lower, "server-side") || strings.Contains(lower, "implementation")
if hasPrivateScope && hasRequestMetadata && hasTrustBoundary {
add("private_scope")
add("request_metadata")
add("trust_boundary_detail")
}
if hasRoadmap && (hasPrivateScope || hasTiming) {
add("roadmap_detail")
if hasPrivateScope {
add("private_scope")
}
if hasTiming {
add("roadmap_timing")
}
}
if hasPrivateScope && hasImplementation && hasTrustBoundary {
add("private_scope")
add("implementation_detail")
add("trust_boundary_detail")
}
return signals
}
func sanitizeSemanticExcerpt(text string) string {
text = redactPrivateKeyBlocks(text)
text = credentialAssignmentRE.ReplaceAllStringFunc(text, sanitizeCredentialAssignment)
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
return strings.Join(strings.Fields(text), " ")
}
func redactPrivateKeyBlocks(text string) string {
lines := strings.Split(text, "\n")
var out []string
inPrivateKey := false
for _, line := range lines {
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
out = append(out, "<private-key-block>")
inPrivateKey = true
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = false
}
continue
}
if inPrivateKey {
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
inPrivateKey = false
}
continue
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
func sanitizeCredentialAssignment(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return "<credential-assignment>"
}
return strings.TrimSpace(key) + "=<redacted>"
}
func sanitizeCredentialURL(raw string) string {
redacted := redactCredentialURL(raw)
redacted = strings.ReplaceAll(redacted, "%3Cuser%3E", "<user>")
redacted = strings.ReplaceAll(redacted, "%3Credacted%3E", "<redacted>")
return redacted
}
func truncateRunes(text string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(text)
if len(runes) <= limit {
return text
}
return string(runes[:limit]) + "..."
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import "github.com/larksuite/cli/internal/qualitygate/report"
type Options struct {
Repo string
ChangedFrom string
MetadataPath string
BranchName string
}
type Metadata struct {
Title string `json:"title"`
Body string `json:"body"`
Branch string `json:"branch"`
}
type Finding struct {
Rule string
Action report.Action
File string
Line int
Source string
Excerpt string
Message string
Suggestion string
}

View File

@@ -174,8 +174,9 @@ type materializedExample struct {
}
type placeholderContext struct {
FlagName string
FlagUsage string
FlagName string
FlagUsage string
FlagDefault string
}
func materializePlaceholderExample(raw string, cmd manifest.Command) (materializedExample, bool) {
@@ -247,6 +248,7 @@ func placeholderContextForFlag(name string, flag *manifest.Flag) placeholderCont
ctx := placeholderContext{FlagName: name}
if flag != nil {
ctx.FlagUsage = flag.Usage
ctx.FlagDefault = flag.DefValue
}
return ctx
}
@@ -309,11 +311,17 @@ func fakeValueForPlaceholder(raw string, ctx placeholderContext) (string, bool)
if name == "" {
return "", false
}
if value, ok := fakeNumericValueForPlaceholder(name, ctx); ok {
return value, true
}
if value, ok := fakeContextualURLValueForPlaceholder(name, ctx); ok {
return value, true
}
if value, ok := fakeValueFromPlaceholderName(name); ok {
return value, true
}
if isGenericPlaceholderName(name) {
return fakeValueFromUsageHint(ctx.FlagUsage)
return fakeValueFromContextHint(ctx)
}
return "", false
}
@@ -336,16 +344,26 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
return "file_test123", true
case hasPlaceholderToken(tokens, "file") && hasPlaceholderToken(tokens, "token"):
return "file_test123", true
case hasPlaceholderToken(tokens, "folder") && hasPlaceholderToken(tokens, "token"):
return "fld_test123", true
case hasPlaceholderToken(tokens, "image", "img"):
return "img_test123", true
case hasPlaceholderToken(tokens, "app"):
return "app_test123", true
case hasPlaceholderToken(tokens, "draft"):
return "draft_test123", true
case hasPlaceholderToken(tokens, "label"):
return "label_test123", true
case hasPlaceholderToken(tokens, "share"):
return "share_test123", true
case hasPlaceholderToken(tokens, "doc", "document"):
return "doc_test123", true
case hasPlaceholderToken(tokens, "sheet", "spreadsheet"):
return "shtcn_test123", true
case hasPlaceholderToken(tokens, "base"):
return "base_test123", true
case hasPlaceholderToken(tokens, "space"):
return "space_test123", true
case hasPlaceholderToken(tokens, "table"):
return "tbl_test123", true
case hasPlaceholderToken(tokens, "view"):
@@ -377,17 +395,98 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
}
}
func fakeValueFromUsageHint(usage string) (string, bool) {
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(usage))
func fakeValueFromContextHint(ctx placeholderContext) (string, bool) {
if value, ok := fakeNumericValueForPlaceholder("", ctx); ok {
return value, true
}
if value, ok := fakeContextualURLValueForPlaceholder("", ctx); ok {
return value, true
}
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(ctx.FlagUsage))
if len(match) != 2 || !knownTokenPrefix(match[1]) {
return "", false
}
return match[1] + "_test123", true
}
func fakeContextualURLValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
nameTokens := placeholderTokenSet(name)
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
flagTokens := placeholderTokenSet(flagName)
if !hasPlaceholderToken(nameTokens, "url", "link") && !hasPlaceholderToken(flagTokens, "url", "link") {
return "", false
}
usage := strings.ToLower(ctx.FlagUsage)
if strings.Contains(usage, "lark") || strings.Contains(usage, "feishu") || strings.Contains(usage, "document url") {
return "https://example.feishu.cn/docx/doc_test123", true
}
return "", false
}
func fakeNumericValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
nameTokens := placeholderTokenSet(name)
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
flagTokens := placeholderTokenSet(flagName)
usage := strings.ToLower(ctx.FlagUsage)
switch {
case placeholderTokenPair(nameTokens, "meeting", "id") || placeholderTokenPair(flagTokens, "meeting", "id"):
return "400000000001", true
case placeholderTokenPair(nameTokens, "meeting", "ids") || placeholderTokenPair(flagTokens, "meeting", "ids"):
return "400000000001", true
case placeholderTokenPair(nameTokens, "meeting", "no") || placeholderTokenPair(flagTokens, "meeting", "no"):
return "123456789", true
case placeholderTokenPair(nameTokens, "meeting", "number") || placeholderTokenPair(flagTokens, "meeting", "number"):
return "123456789", true
case hasPlaceholderToken(nameTokens, "timestamp") || hasPlaceholderToken(flagTokens, "timestamp") || strings.Contains(usage, "unix timestamp"):
return defaultPositiveInteger(ctx.FlagDefault, "1893456000"), true
case placeholderTokenPair(nameTokens, "page", "size") || placeholderTokenPair(flagTokens, "page", "size"):
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
case placeholderTokenPair(nameTokens, "page", "limit") || placeholderTokenPair(flagTokens, "page", "limit"):
return defaultPositiveInteger(ctx.FlagDefault, "10"), true
case numericPlaceholderName(nameTokens) || numericPlaceholderName(flagTokens) || numericUsageHint(usage):
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
default:
return "", false
}
}
func numericPlaceholderName(tokens map[string]bool) bool {
if len(tokens) == 0 || hasPlaceholderToken(tokens, "token", "format", "type", "status", "mode") {
return false
}
return hasPlaceholderToken(tokens,
"amount", "count", "depth", "height", "index", "length", "limit", "max",
"number", "revision", "size", "width",
)
}
func numericUsageHint(usage string) bool {
if usage == "" {
return false
}
return strings.Contains(usage, "positive integer") ||
strings.Contains(usage, "decimal integer") ||
strings.Contains(usage, "number of ") ||
strings.Contains(usage, "(number)")
}
func defaultPositiveInteger(raw, fallback string) string {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "-") || raw == "0" {
return fallback
}
for _, r := range raw {
if r < '0' || r > '9' {
return fallback
}
}
return raw
}
func knownTokenPrefix(prefix string) bool {
switch prefix {
case "app", "base", "doc", "file", "fld", "img", "item", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "shtcn", "task", "tbl", "token", "viw", "wiki":
case "app", "base", "doc", "draft", "file", "fld", "img", "item", "label", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "share", "shtcn", "space", "task", "tbl", "token", "viw", "wiki":
return true
default:
return false
@@ -431,6 +530,10 @@ func hasPlaceholderToken(tokens map[string]bool, wants ...string) bool {
return false
}
func placeholderTokenPair(tokens map[string]bool, first, second string) bool {
return tokens[first] && tokens[second]
}
func hasUnresolvedDryRunPlaceholder(value string) bool {
if skillscan.HasPlaceholder(value) {
return true
@@ -623,6 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
return nil, fmt.Errorf("not a lark-cli command")
}
argv = truncateShellTail(argv)
argv = forceDryRunJSONFormat(argv)
hasDryRunArg := false
dryRunEnabled := false
for _, arg := range argv[1:] {
@@ -642,6 +746,23 @@ func appendDryRunArg(raw string) ([]string, error) {
return append(argv[1:], "--dry-run"), nil
}
func forceDryRunJSONFormat(argv []string) []string {
for i := 1; i < len(argv); i++ {
arg := argv[i]
if arg == "--format" {
if i+1 < len(argv) && argv[i+1] == "pretty" {
argv[i+1] = "json"
}
return argv
}
if arg == "--format=pretty" {
argv[i] = "--format=json"
return argv
}
}
return argv
}
func truncateShellTail(argv []string) []string {
for i, arg := range argv {
if i == 0 {

View File

@@ -305,6 +305,161 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
}
}
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "vc +meeting-events",
Runnable: true,
Flags: []manifest.Flag{
{Name: "meeting-id", TakesValue: true, Usage: "meeting ID to query; must be a long positive integer, not a 9-digit meeting number"},
{Name: "page-size", TakesValue: true, Usage: "page size, 20-100 (default 50)", DefValue: "50"},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli vc +meeting-events --meeting-id <meeting_id> --page-size <page_size>",
SourceFile: "skills/lark-vc-agent/SKILL.md",
Line: 120,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("numeric placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--page-size", "50", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesNumericPlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/test","params":{"timestamp":"1893456000","count":"20"}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "api GET",
Runnable: true,
Flags: []manifest.Flag{
{Name: "params", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli api GET /open-apis/test --params '{"timestamp":"<timestamp>","count":"<count>"}'`,
SourceFile: "skills/lark-demo/SKILL.md",
Line: 20,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("JSON numeric placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"api", "GET", "/open-apis/test", "--params", `{"timestamp":"1893456000","count":"20"}`, "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesLarkDocumentURLPlaceholders(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/drive/v1/metas/batch_query"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "drive +inspect",
Runnable: true,
Flags: []manifest.Flag{
{Name: "url", TakesValue: true, Usage: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)"},
{Name: "format", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli drive +inspect --url '<url>' --format json",
SourceFile: "skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md",
Line: 15,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("Lark URL placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"drive", "+inspect", "--url", "https://example.feishu.cn/docx/doc_test123", "--format", "json", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesResourceIDPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/wiki/v2/spaces/space_test123/nodes"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "wiki +node-list",
Runnable: true,
Flags: []manifest.Flag{
{Name: "space-id", TakesValue: true, Usage: "wiki space ID"},
{Name: "page-token", TakesValue: true, Usage: "page token"},
{Name: "format", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: "lark-cli wiki +node-list --space-id <space_id> --page-token <PAGE_TOKEN> --format json",
SourceFile: "skills/lark-wiki/references/lark-wiki-node-list.md",
Line: 24,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("resource ID placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"wiki", "+node-list", "--space-id", "space_test123", "--page-token", "page_test123", "--format", "json", "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsMaterializesResourcePlaceholdersInsideJSONFlags(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"POST","url":"/open-apis/mail/v1/user_mailboxes/me/drafts/draft_test123/send"}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "mail user_mailbox.drafts send",
Runnable: true,
Flags: []manifest.Flag{
{Name: "params", TakesValue: true},
{Name: "data", TakesValue: true},
{Name: "dry-run"},
},
}}}
ex := skillscan.Example{
Raw: `lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'`,
SourceFile: "skills/lark-mail/references/lark-mail-send.md",
Line: 172,
HasPlaceholder: true,
}
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
if len(diags) != 0 {
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
}
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
t.Fatalf("JSON resource placeholder example should be executable after materialization: %#v", facts)
}
wantArgs := []string{"mail", "user_mailbox.drafts", "send", "--params", `{"user_mailbox_id":"me","draft_id":"draft_test123"}`, "--data", `{"send_time":"1893456000"}`, "--dry-run"}
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
}
}
func TestRunDryRunsSkipsUnknownFlagsBeforeDryRun(t *testing.T) {
m := manifest.Manifest{Commands: []manifest.Command{{
Path: "im +chat-messages-list",
@@ -600,6 +755,51 @@ func TestAppendDryRunArgDoesNotDuplicate(t *testing.T) {
}
}
func TestAppendDryRunArgForcesJSONFormat(t *testing.T) {
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format pretty")
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format", "json", "--dry-run"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
}
}
func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format=pretty --dry-run")
if err != nil {
t.Fatalf("appendDryRunArg() error = %v", err)
}
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format=json", "--dry-run"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
}
}
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
for _, raw := range []string{
"lark-cli mail +watch --format data --dry-run",
"lark-cli export +events --format=ndjson --dry-run",
"lark-cli docs +fetch --format table",
} {
got, err := appendDryRunArg(raw)
if err != nil {
t.Fatalf("appendDryRunArg(%q) error = %v", raw, err)
}
for _, arg := range got {
if arg == "--format=json" {
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote inline format: %#v", raw, got)
}
}
for i, arg := range got {
if arg == "--format" && i+1 < len(got) && got[i+1] == "json" {
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote split format: %#v", raw, got)
}
}
}
}
func TestAppendDryRunArgForcesDryRunWhenExplicitlyDisabled(t *testing.T) {
got, err := appendDryRunArg("lark-cli docs +fetch --dry-run=false --doc abc")
if err != nil {

View File

@@ -15,18 +15,20 @@ import (
manifestexamples "github.com/larksuite/cli/internal/qualitygate/examples"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/qualitygate/skillscan"
"github.com/larksuite/cli/internal/vfs"
)
type Options struct {
Repo string
CLIBin string
ChangedFrom string
FactsOut string
ManifestPath string
CommandIndexPath string
Repo string
CLIBin string
ChangedFrom string
FactsOut string
ManifestPath string
CommandIndexPath string
PublicContentMetadataPath string
}
func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, error) {
@@ -98,9 +100,60 @@ func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, e
if opts.ChangedFrom != "" {
diags = append(diags, errorDiags...)
}
publicContent, err := publiccontent.Collect(ctx, publiccontent.Options{
Repo: opts.Repo,
ChangedFrom: opts.ChangedFrom,
MetadataPath: opts.PublicContentMetadataPath,
})
if err != nil {
return nil, facts.Facts{}, err
}
diags = append(diags, publicContentDiagnostics(publicContent)...)
diags = filterPRDiagnostics(opts.Repo, opts.ChangedFrom, scope, m, diags)
return diags, facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files), nil
builtFacts := facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files)
return diags, facts.WithPublicContent(builtFacts, publicContentFacts(publicContent)), nil
}
func publicContentDiagnostics(items []publiccontent.Finding) []report.Diagnostic {
if len(items) == 0 {
return nil
}
out := make([]report.Diagnostic, 0, len(items))
for _, item := range items {
if item.Rule == "public_content_semantic_candidate" {
continue
}
out = append(out, report.Diagnostic{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}
func publicContentFacts(items []publiccontent.Finding) []facts.PublicContentFact {
if len(items) == 0 {
return nil
}
out := make([]facts.PublicContentFact, 0, len(items))
for _, item := range items {
out = append(out, facts.PublicContentFact{
Rule: item.Rule,
Action: item.Action,
File: item.File,
Line: item.Line,
Source: item.Source,
Excerpt: item.Excerpt,
Message: item.Message,
Suggestion: item.Suggestion,
})
}
return out
}
func readManifestInput(path, kind, flag string) (manifest.Manifest, error) {
@@ -167,6 +220,9 @@ func filterPRDiagnostics(repo, changedFrom string, scope qdiff.Scope, m manifest
}
func prDiagnosticRelevant(repo string, changedFiles map[string]bool, commandScope diagnosticCommandScope, m manifest.Manifest, diag report.Diagnostic) bool {
if strings.HasPrefix(diag.Rule, "public_content_") {
return true
}
file := normalizeDiagnosticFile(repo, diag.File)
if file != "" && changedFiles[file] {
return true

View File

@@ -189,6 +189,99 @@ description: Manage Drive comments with service command references.
}
}
func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
runGit(t, repo, "config", "user.email", "test@example.com")
runGit(t, repo, "config", "user.name", "Test User")
if err := vfs.WriteFile(filepath.Join(repo, "README.md"), []byte("# test\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "README.md")
runGit(t, repo, "commit", "-m", "base")
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
t.Fatal(err)
}
publicDoc := "api_" + "key = \"example-public-key\"\n" +
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc")
metadataPath := filepath.Join(repo, "pr-metadata.json")
if err := vfs.WriteFile(metadataPath, []byte(`{"title":"public docs","body":"Change`+`-Id: I0123456789abcdef0123456789abcdef01234567"}`), 0o644); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(repo, "command-manifest.json")
indexPath := filepath.Join(repo, "command-index.json")
m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{
Path: "docs +fetch",
CanonicalPath: "docs +fetch",
Domain: "docs",
Source: manifest.SourceShortcut,
}}}
if err := manifest.WriteFile(manifestPath, manifest.KindCommandManifest, m); err != nil {
t.Fatal(err)
}
idx := manifest.Manifest{SchemaVersion: 1, Commands: append([]manifest.Command{}, m.Commands...)}
idx.Commands = append(idx.Commands, manifest.Command{
Path: "drive files get",
CanonicalPath: "drive files get",
Domain: "drive",
Source: manifest.SourceService,
Generated: true,
Runnable: true,
})
if err := manifest.WriteFile(indexPath, manifest.KindCommandIndex, idx); err != nil {
t.Fatal(err)
}
diags, gotFacts, err := Run(context.Background(), Options{
Repo: repo,
CLIBin: "./lark-cli",
ChangedFrom: "HEAD~1",
ManifestPath: manifestPath,
CommandIndexPath: indexPath,
PublicContentMetadataPath: metadataPath,
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
actions := map[string]report.Action{}
for _, diag := range diags {
actions[diag.Rule] = diag.Action
}
if actions["public_content_generic_credential"] != report.ActionReject {
t.Fatalf("generic credential diagnostic action = %q, diagnostics=%#v", actions["public_content_generic_credential"], diags)
}
if actions["public_content_change_id_trailer"] != report.ActionReject {
t.Fatalf("change-id diagnostic action = %q, diagnostics=%#v", actions["public_content_change_id_trailer"], diags)
}
if actions["public_content_semantic_candidate"] != "" {
t.Fatalf("semantic candidates should not become deterministic diagnostics: %#v", diags)
}
factRules := map[string]bool{}
for _, item := range gotFacts.PublicContent {
factRules[item.Rule] = true
}
for _, want := range []string{
"public_content_generic_credential",
"public_content_change_id_trailer",
"public_content_semantic_candidate",
} {
if !factRules[want] {
t.Fatalf("missing public content fact %s: %#v", want, gotFacts.PublicContent)
}
}
if len(gotFacts.PublicContent) < 3 {
t.Fatalf("public content facts = %#v", gotFacts.PublicContent)
}
}
func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
repo := t.TempDir()
runGit(t, repo, "init")
@@ -506,7 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
out, err := cmd.CombinedOutput()
if err != nil {

View File

@@ -339,7 +339,7 @@ func jsonSchemaResponseFormat() map[string]any {
"properties": map[string]any{
"category": map[string]any{
"type": "string",
"enum": []string{"error_hint", "default_output", "naming", "skill_quality"},
"enum": []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
},
"severity": map[string]any{
"type": "string",

View File

@@ -10,9 +10,10 @@ import (
"strings"
"github.com/larksuite/cli/internal/qualitygate/facts"
"github.com/larksuite/cli/internal/qualitygate/report"
)
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs)\[(\d+)\]$`)
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$`)
func Decide(f facts.Facts, r Review, p Policy) Decision {
return DecideWithWaivers(f, r, p, Waivers{})
@@ -172,6 +173,16 @@ func evidenceFingerprint(f facts.Facts, ev string) string {
"has_default_limit:" + strconv.FormatBool(out.HasDefaultLimit),
"has_decision_field:" + strconv.FormatBool(out.HasDecisionField),
}, ":")
case "public_content":
item := f.PublicContent[idx]
return strings.Join([]string{
"public_content",
"rule:" + item.Rule,
"action:" + string(item.Action),
"file:" + item.File,
"line:" + strconv.Itoa(item.Line),
"source:" + item.Source,
}, ":")
default:
return "ref:" + ev
}
@@ -201,7 +212,7 @@ func validFinding(f Finding) bool {
func allowedCategory(category string) bool {
switch category {
case "error_hint", "default_output", "naming", "skill_quality":
case "error_hint", "default_output", "naming", "skill_quality", "public_content_leakage":
return true
default:
return false
@@ -247,6 +258,12 @@ func reproducibleEvidence(f facts.Facts, category, kind string, idx int) bool {
}
skill := f.Skills[idx]
return skill.ReferencesInvalidCommand
case "public_content_leakage":
if kind != "public_content" {
return false
}
item := f.PublicContent[idx]
return item.Action == report.ActionReject || item.Rule == "public_content_semantic_candidate"
default:
return false
}
@@ -277,6 +294,8 @@ func evidenceExists(f facts.Facts, kind string, idx int) bool {
return idx < len(f.Errors)
case "outputs":
return idx < len(f.Outputs)
case "public_content":
return idx < len(f.PublicContent)
default:
return false
}

View File

@@ -242,6 +242,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
Outputs: []facts.OutputFact{{Command: "im messages list", IsList: true, HasDefaultLimit: false, HasDecisionField: false}},
Commands: []facts.CommandFact{{Path: "docs fetch", NameConflictsExisting: true}},
Skills: []facts.SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 3, ReferencesInvalidCommand: true}},
PublicContent: []facts.PublicContentFact{{Rule: "public_content_generic_credential", Action: "REJECT", File: "docs/public.md", Line: 4, Source: "metadata"}},
}
for _, tc := range []struct {
category string
@@ -251,6 +252,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
{"default_output", "facts.outputs[0]"},
{"naming", "facts.commands[0]"},
{"skill_quality", "facts.skills[0]"},
{"public_content_leakage", "facts.public_content[0]"},
} {
t.Run(tc.category, func(t *testing.T) {
r := Review{Findings: []Finding{{
@@ -268,6 +270,59 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
}
}
func TestGatekeeperDoesNotPromotePublicContentWarningsToBlockers(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_" + "pri" + "vate_ipv4",
Action: "WARNING",
File: "docs/network.md",
Line: 1,
Source: "file",
}},
}
review := Review{Findings: []Finding{{
Category: "public_content_leakage",
Severity: "minor",
Evidence: []string{"facts.public_content[0]"},
Message: "pri" + "vate network address appears in public docs",
SuggestedAction: "confirm the public docs do not expose pri" + "vate deployment details",
}}}
got := Decide(f, review, DefaultPolicy())
if len(got.Blockers) != 0 || len(got.Warnings) != 1 {
t.Fatalf("public content warning should not become a blocker: %#v", got)
}
if got.Warnings[0].ReviewAction != ReviewActionObserve {
t.Fatalf("review action = %q, want %q", got.Warnings[0].ReviewAction, ReviewActionObserve)
}
}
func TestGatekeeperAllowsPublicContentSemanticCandidatesAsBlockers(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,
PublicContent: []facts.PublicContentFact{{
Rule: "public_content_semantic_candidate",
Action: "WARNING",
File: "docs/public.md",
Line: 1,
Source: "file",
}},
}
review := Review{Findings: []Finding{{
Category: "public_content_leakage",
Severity: "major",
Evidence: []string{"facts.public_content[0]"},
Message: "semantic review found pri" + "vate rollout detail",
SuggestedAction: "remove pri" + "vate rollout detail from public docs",
}}}
got := Decide(f, review, DefaultPolicy())
if len(got.Blockers) != 1 {
t.Fatalf("semantic candidate should remain blockable, got %#v", got)
}
}
func TestGatekeeperSkillQualityOnlyBlocksInvalidCommandReferences(t *testing.T) {
f := facts.Facts{
SchemaVersion: 1,

View File

@@ -24,7 +24,7 @@ func BuildPrompt(f facts.Facts) []Message {
"Use only the provided JSON view.",
"The changed_summary may summarize broad changed surfaces; review only listed facts, not omitted summarized items.",
"Use fact_ref values exactly when writing finding evidence.",
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
"facts.examples and facts.skill_quality entries are context only.",
"Report an error_hint finding for any facts.errors item where boundary is true, required_hint is true, and hint_action_count is 0.",
@@ -38,6 +38,9 @@ func BuildPrompt(f facts.Facts) []Message {
"For naming findings, use category \"naming\" and evidence containing that facts.commands fact_ref.",
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
"For skill_quality findings, use category \"skill_quality\" and evidence containing that facts.skills fact_ref.",
"Review public content leakage findings and semantic candidates without private dictionaries.",
"Do not reveal internal rule lists when explaining public content leakage.",
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
"Report each distinct issue as a separate finding.",
"The verdict value must be \"pass\" when findings is empty and \"warn\" when findings is non-empty; never use \"fail\".",
"Severity must be one of \"minor\", \"major\", or \"critical\"; never use \"error\", \"warning\", \"medium\", or \"high\".",

View File

@@ -23,7 +23,10 @@ func TestBuildPromptContainsSemanticReviewContract(t *testing.T) {
"A facts.outputs item with is_list true, has_default_limit false, and has_decision_field true must still produce a default_output finding.",
"Report a naming finding for any facts.commands item where name_conflicts_existing is true or flag_alias_conflict is true.",
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
"Review public content leakage findings and semantic candidates without private dictionaries.",
"Do not reveal internal rule lists when explaining public content leakage.",
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
"facts.examples and facts.skill_quality entries are context only.",
"Report each distinct issue as a separate finding.",

View File

@@ -78,11 +78,11 @@ func DefaultPolicy() Policy {
return Policy{
SchemaVersion: 1,
DefaultEnforcement: "observe",
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality"},
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
RolloutGroups: []RolloutGroup{{
ID: "all",
Enforcement: "blocking",
Categories: []string{"error_hint", "default_output", "naming", "skill_quality"},
Categories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
Owner: "test",
Reason: "default in-memory policy",
}},

View File

@@ -82,6 +82,15 @@ func factScope(f facts.Facts, kind string, idx int) (FactScope, bool) {
Source: item.Source,
CommandPath: item.Command,
}, true
case "public_content":
item := f.PublicContent[idx]
return FactScope{
FactKind: "public_content",
Changed: true,
Source: item.Source,
SourceFile: item.File,
Line: item.Line,
}, true
default:
return FactScope{}, false
}
@@ -195,7 +204,7 @@ func containsString(values []string, want string) bool {
func allowedFactKind(kind string) bool {
switch kind {
case "skill", "command", "error", "output":
case "skill", "command", "error", "output", "public_content":
return true
default:
return false

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