Compare commits

...

64 Commits

Author SHA1 Message Date
evandance
40a0a9de66 feat(enhancement): centralize HTTP transport policies (#2021) 2026-08-02 14:55:05 +08:00
liangshuo-1
a8ad44ba13 docs: remove broken Star History chart (#2141) 2026-08-01 11:42:24 +08:00
liangshuo-1
003d0f42f8 chore: release v1.0.81 (#2136) 2026-07-31 18:47:19 +08:00
wangweiming-01
7946e5c81d feat: support source file preview artifacts (#2085) 2026-07-31 17:52:31 +08:00
zhouyue-bytedance
5cf09ecfda docs(base): clarify form and file operation routing (#2110)
* docs(base): clarify form and file operation routing

* docs: clarify complete base role table rules

* docs: clarify base advanced permission status

* docs: clarify base form field lifecycle

* docs: guide base form question creation

* fix(base): address form dry-run review findings

* docs(base): add complete editable role example

* fix(base): validate form question create inputs
2026-07-31 15:23:03 +08:00
chenxingyang1019
41692b7041 feat(apps): add cache debug commands (+cache-get/-delete/-clear) (#1896)
* feat(apps): add cache debug commands (+cache-get/-delete/-clear)

Add three apps-domain cache debug shortcuts for inspecting/clearing an app's
runtime cache:
- +cache-get: read a business key's value + metadata (hit/miss)
- +cache-delete: delete a single key (idempotent, write)
- +cache-clear: clear all cache in an environment (high-risk-write, --yes)

value renders raw on --format json, deserialized on --format pretty;
value_size_bytes is computed CLI-side; --environment auto-selects the branch
when omitted. Includes unit tests (hit/miss/dry-run/confirmation) and the
lark-apps cache skill reference.

* fix(apps): normalize cache numeric output fields and tidy comments

Follow-up hardening for the cache debug commands (+cache-get/-delete/-clear):

- Normalize ttl_ms / deleted_key_count via a new cacheInt() helper so
  --format json emits a stable JSON number (or null) regardless of whether
  the server sends the value as a number or a string. Aligns with the
  repo convention that numeric wire fields may arrive as strings; previously
  these were passed through raw, leaving the output type at the server's mercy.
- Add unit tests locking the string-wire -> JSON number contract for both
  cache-get ttl_ms and cache-delete deleted_key_count.
- Tidy two comments: soften cacheBool's speculative "historical wire form"
  claim to a defensive-tolerance note, and drop implementation jargon from
  cache-delete's risk-level rationale.
2026-07-31 14:13:56 +08:00
dc-bytedance
b79827d60a fix: drop stale target version from root upgrade prompt (#2100) 2026-07-31 12:45:43 +08:00
zhaojiaxing-coding
0f35676a28 feat(drive): extend permission shortcuts for Miaoda (#2070)
* feat(drive): support Miaoda apps in permission shortcuts

Extend Drive permission shortcuts to accept Miaoda page URLs and the apps resource type while keeping each endpoint's accepted resource contract explicit.

Key features:

- Infer apps from /page/ URLs and accept explicit --type=apps in +apply-permission, +member-add, +member-list, and +permission-get-setting

- Decouple secure-label target parsing so expanding apply-permission does not widen secure-label support

- Align skill guidance and unit/dry-run coverage with the new resource type

* test(drive): cover apps permission target validation

Add focused coverage for Miaoda apps target handling across apply-permission and secure-label boundaries.

Exercise malformed page URLs, explicit apps bare tokens, typed validation errors, and command-level rejection so future resource-type changes cannot silently widen unsupported secure-label behavior.

* fix(drive): parse permission markers from URL paths

Keep drive +apply-permission resource inference aligned with URL component boundaries. Parse and validate URL inputs before extracting tokens so query strings and fragments cannot redirect permission requests to a different resource.

Key fixes:

- Match document and apps markers only against the parsed URL path

- Reject malformed URLs with a typed --token validation error

- Cover /page/ markers found only in query strings or fragments

* docs(skills): redact Miaoda page token example

Replace the concrete Miaoda page token with a representative pagcn placeholder. This keeps the token shape recognizable while avoiding exposure of a real resource identifier in the skill documentation.

* fix(drive): harden permission target resolution

Make Drive shortcut targets unambiguous before they reach read or write API paths. URL inputs now bind to a recognized root path and a single validated token segment, preventing encoded separators, dot segments, and type conflicts from silently changing the addressed resource.

Key fixes:

- Reject non-root URLs, dot/traversal tokens, and URL/type conflicts for secure-label and permission-apply writes

- Keep permission-setting URL parsing and pretty output reversible for every supported command-local resource kind

- Add unit and dry-run E2E regressions plus aligned permission-apply guidance
2026-07-31 12:16:22 +08:00
wangweiming-01
946964e093 fix(drive): use title for default download filename (#2089) 2026-07-31 12:12:11 +08:00
HanShaoshuai-k
cfe76ad56a ci: add protected public domain allowlists (#2111)
Co-authored-by: HanShaoshuai-k <268785735+HanShaoshuai-k@users.noreply.github.com>
2026-07-31 11:02:04 +08:00
calendar-assistant
fa9c30c690 docs(calendar): confirm scope before editing recurring events (#2119)
Promote the recurring-event rule to a pre-routing gate so it is read
before the specific operation flow, and require confirming the scope
(this event / all / this-and-following) when the user is ambiguous
instead of defaulting to this-event-only. Removes the redundant and
conflicting "edit existing event" row that hard-coded the single-
instance default.
2026-07-30 21:59:08 +08:00
zcc
ba95252019 feat(drive): add comment-operation shortcuts (#1898)
Add comment-domain shortcuts: +batch-query-comments, +resolve-comment,
+restore-comment, +add-reply, +list-replies, +update-reply, +delete-reply
and +react-reply, sharing one target resolver with per-endpoint file_type
sets.

Flatten the comment reference docs by dropping the comments-guide routing
layer and folding its cross-command knowledge into the command refs:
comment-card model, comment/reply/interaction counting and sorting rules
into lark-drive-list-comments.md; the --solved-status prerequisite into
lark-drive-restore-comment.md; the apps exception into
lark-drive-add-comment.md. Comment intents now route straight from the
drive SKILL.md Shortcuts table to each command ref.

Cover the new shortcuts with unit tests, dry-run e2e and live workflow
e2e behind LARK_DRIVE_MD_COMMENT_E2E=1, and register them in
tests/cli_e2e/drive/coverage.md.
2026-07-30 21:53:21 +08:00
zhouyue-bytedance
4a16139348 fix(base): resolve Base URL block types accurately (#2099)
* fix: resolve Base URL block types accurately

* fix: resolve Base block selection from Wiki URLs

* fix(base): guide resolved folder and docx blocks

* fix(base): avoid field fallback for untyped URL blocks

* docs(base): specify URL example fence language

* test(base): cover unmatched URL block resolution
2026-07-30 20:29:47 +08:00
BD-ZERO
6e5308af01 feat: add SXSD schema validation to Slides lint (#2103)
- add XSD-backed SXSD validation for tags, attributes, structure, scalar values, and namespaces
- preserve supported server-filled fields and readback namespace compatibility
- isolate SXSD failures by slide so valid slides continue through layout checks
- improve actionable lint diagnostics and suppress duplicate errors
- add regression coverage for schema validation and Slides readback cases

Validated with unit tests and real Slides create/readback round trips.
2026-07-30 20:04:53 +08:00
liangshuo-1
87be09ef5f fix(contact): stop bot match segments carrying tags or empty entries (#2115) 2026-07-30 18:08:38 +08:00
sang-neo03
a575a8ba60 feat(contact): add bot search shortcut (#2083) 2026-07-30 17:03:49 +08:00
calendar-assistant
1f565a290b docs(calendar): warn against container-default timezone in time conversion (#2104)
Agents dropping to the raw `calendar events create/patch` API must convert
wall-clock time to Unix timestamps themselves. In UTC containers this silently
yields an 8-hour offset. Require explicit ISO 8601 offsets on +create/+update
--start/--end, and warn that raw-API timestamp conversion must specify the
target timezone instead of relying on the container default.
2026-07-30 14:06:14 +08:00
yballul-bytedance
68a77eee5c feat: support visible_rule for form questions (#1891)
Form questions can now carry a visible_rule (display condition) so a question shows only when earlier questions match the rule. The rule shares the exact same structure as the view filter, so extract that structure into a single shared reference (lark-base-filter-condition.md) that both view-set-filter and visible_rule point to.

- create/update shortcuts: document visible_rule in --questions help and transcribe the questions body (including visible_rule) into dry-run output
- document that form question updates use full overwrite semantics and must preserve existing fields via read-modify-write
- skill refs: add visible_rule sections to form-questions create/update, note it is only needed when the user asks for a display condition, and clarify that the shared tuple filter protocol does not apply to data-query filters
- tests: pin flag help, verbatim visible_rule passthrough on create/update/list, and add dry-run E2E coverage

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-30 12:37:24 +08:00
liangshuo-1
29a97dbde8 chore: release v1.0.80 (#2101) 2026-07-29 21:37:15 +08:00
R0bynZhu
29a6a7b600 docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
* docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由

trace 里 +create 的三类高频错误(--yes、--name、--slides 塞文件路径)
共同点是调用前没读 lark-slides-create.md。原因不是文档缺内容,而是
SKILL.md 里 +create 的信息「够又不够」:给了半截参数描述,模型觉得
够用就直接拼命令,不再打开文档。

- 删掉「创建方式选择」整节(表格 + 两条 WARNING),下沉到 create.md,
  由生成流程 Step 3 和核心规则 2 指向那份文档
- Shortcuts 表 +create 行、核心规则 2 不再复述参数
- Quick Reference 顶部说明参数以文档和 --help 为准,「新建 PPT」行补上
  create.md
- PPTX 一行改写为 drive +import 导入路径;create.md 里写明本命令不读
  本地文件
- create.md 增加「--slides 不接受的形态」对照表,并合并开头零散的
  禁止/推荐/最稳/注意条目
- @ 占位符统一写成 <img src="@./path">,消除「--slides 支持 @ 路径」的歧义

* docs(slides): 去掉 create.md 里的「--slides 不接受的形态」对照表

* docs(slides): 模板一行的触发条件补上「已有 PPTX 要改」

* docs(slides): create.md 澄清「不读取本地文件」的歧义

原句「本命令只从零创建演示文稿,不读取本地文件」与本文档
「本地图片:@<path> 占位符」一节自相矛盾——@ 占位符恰恰会读
本地图片并自动上传。改为只否定「导入本地 PPT 文件的参数」,
不波及图片占位符能力。

* docs(slides): 两步创建的第二步补上 slide create 文档路由

生成流程 Step 3 和「执行前必做」的创建一行原来只指向
lark-slides-create.md,而两步创建的第二步用的是
xml_presentation.slide create,文档没被路由到,模型只能凭
记忆拼参数。
2026-07-29 20:50:24 +08:00
liangshuo-1
c167163d70 feat: propagate invocation metadata (#2097) 2026-07-29 19:39:53 +08:00
zhaojiaxing-coding
7988515e1c feat(drive): add +permission-get-setting shortcut (#1738)
* feat(drive): add +permission-get-setting shortcut

Add a Drive shortcut for reading public permission settings across supported documents, files, folders, and wiki nodes. Resolve URLs into typed resources, preserve permission_public output for machine consumers, and document the shortcut in the permission-governance workflow.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Query the Drive v2 public permission endpoint with typed validation and user or bot identity

- Support folder permission inspection without recursing into child resources

- Add unit, dry-run E2E, live workflow, output, and skill guidance coverage

* fix(drive): harden permission get setting contract

Harden +permission-get-setting after review findings so callers receive only the documented permission payload and folder support is verified against the live workflow. This prevents malformed responses from being presented as permission settings and keeps the command guidance aligned with the shortcut contract.

Key fixes:
- Reject responses without data.permission_public instead of projecting arbitrary payload fields
- Render complete permission settings in pretty output and mark --token required
- Exercise a created Drive folder in the live workflow and add the command reference
- Correct folder resolution guidance while retaining the shortcut's documented URL forms

* feat/drive-folder-permission-get
2026-07-29 17:57:24 +08:00
zhaojiaxing-coding
c7adff7a3b feat(drive): add +member-list shortcut (#1795)
* feat(drive): add +member-list shortcut

Add a Drive shortcut for listing collaborators on documents, files, folders, and wiki nodes. Resolve supported resource URLs into typed permission requests, preserve raw API data for machine consumers, and keep invalid flag combinations on typed validation paths.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Validate optional member fields and wiki-only permission type filters

- Provide pretty output, skill guidance, unit coverage, and dry-run/live E2E workflows

- Read dry-run assertions from the standard data.api success envelope

* feat/drive-member-list
2026-07-29 17:04:59 +08:00
ethan-zhx
59237f3104 Feat/detect line text overlap (#2069)
* fix: report ghost text canvas overflow

* fix(slides): detect text-line overlap in xml_text_overlap_lint
2026-07-29 16:20:59 +08:00
R0bynZhu
358cd06838 docs(slides): 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
* docs(slides): consolidate CWD-relative path rule into one global rule

State the "all local file path args must be CWD-relative (absolute
rejected)" rule once in SKILL.md 权威经验, and trim the per-command
repetitions in media-upload / create / screenshot / xml-presentations-get.
Also fix the stale xml-presentations-get param table: --output is optional
(relative), not required.

* feat: try common solution

* chore: 优化措辞

* feat: 优化措辞

* feat: 优化措辞

* docs(slides): 强调调用命令前必读对应命令文档

- 「调用命令前再读」改为「调用相关命令前必须读取相关的文档以了解命令的使用方式」,
  并把原「按需再读」列表合并进来,去掉可选语义
- 移除 lark-shared 的 CRITICAL 前置阅读要求
- Step 4 回读示例补全 `--presentation <xml_presentation_id>` 参数

* docs(slides): Shortcuts 表补充 +screenshot 并写明本地路径参数

- 新增 +screenshot 行:--slide-number 页号(从 1 开始,可重复,一次最多 10 页)、
  --output-dir 保存目录(CWD 内相对路径,默认 .lark-slides/screenshots)
- +xml-get 行补上 --presentation 和 --output(CWD 内相对路径),
  并说明省略 --output 时 XML 返回在 JSON 信封里

* revert(slides): 回退 references 下的文档改动,只保留 SKILL.md

把 lark-slides-create.md、lark-slides-media-upload.md、lark-slides-screenshot.md、
lark-slides-xml-presentations-get.md 还原为 main 的版本,本分支只改 SKILL.md。

* docs(slides): 恢复开始前必读 lark-shared 的 CRITICAL 要求

认证、权限和全局参数以 lark-shared 为准,这条前置阅读不该在本分支被删掉。

* chore: 移除output省略的说明
2026-07-29 10:55:05 +08:00
Yuxuan Zhao
b0b1ca4b5d test(e2e): wait for base role update visibility (#2087) 2026-07-28 21:45:00 +08:00
liangshuo-1
781d188a60 chore: release v1.0.79 (#2082) 2026-07-28 21:02:37 +08:00
calendar-assistant
2e0fb9a880 docs(calendar): refine attendee guidance for bots and user-search identity (#2086)
Consolidate the user-search identity note into SKILL.md, and clarify bot
handling across attendee flows: bots are virtual identities with no
free/busy semantics, no meeting-room seat, and no room preference, so
they must be excluded from +suggestion, +room-find, and the scheduling
free/busy check. Note in create/update that bots remain valid attendees.
2026-07-28 20:34:09 +08:00
ILUO
927b37cd63 docs(task): document create data passthrough (#2080) 2026-07-28 20:26:35 +08:00
zhangjun-bytedance
d2e22c5fca feat: 0728 fix url (#2079) 2026-07-28 19:05:47 +08:00
ethan-zhx
fdae560014 docs(slides): add formula inline element syntax to quick-ref (#2077)
* docs(slides): add formula inline element syntax to quick-ref

* docs(slides): add chart gradient syntax to quick-ref
2026-07-28 17:40:54 +08:00
zhengzhijiej-tech
1b173e1953 fix(sheets): recognize OFL0X local office tokens (#2063) 2026-07-28 15:09:42 +08:00
ethan-zhx
57db1b3a8d feat(slides):update xsd (#2067) 2026-07-28 14:43:15 +08:00
calendar-assistant
4c1c5f5287 docs(calendar): clarify identity selection by event ownership (#2071)
Reframe the identity section around event ownership: use `--as user`
for the logged-in user's own events and `--as bot` for events the bot
creates or participates in, with matching `+agenda` examples.
2026-07-28 14:05:21 +08:00
liangshuo-1
3d2c10cd0b fix(ci): validate static workflow identity (#2015) 2026-07-27 19:39:11 +08:00
liangshuo-1
03de81c5f3 chore: release v1.0.78 (#2061) 2026-07-27 19:17:53 +08:00
yballul-bytedance
7abcaa7f68 feat(drive): add title+body joint search guidance and Top N pagination rules (#2059)
* feat(drive): add title+body joint search guidance and pagination rules for Top N results

- Add new blockquote explaining combined title+body search: use a single
  --query with both keywords instead of splitting into two searches
- Add rule for Top N results: N is an output cap, not --page-size; scan
  up to 3 pages filtering by title and summary_highlighted, read body
  only for title-matched candidates, stop early at N confirmed results
- Add quick-reference table row for folder-scoped title+body search
- Update pagination strategy rule to cover the 3-page cap for joint
  search in addition to the existing 5-page limit for other scenarios

* feat(drive): clarify Top N search output limit

* feat(drive): clarify search filters share one call

---------

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-27 17:19:48 +08:00
zhangjun-bytedance
8fb2476985 0727 fix rich text (#2062) 2026-07-27 16:17:08 +08:00
zhanghuanxu
56c9a2afd8 fix: exempt ghost text from slides lint 2026-07-27 11:59:04 +08:00
zhanghuanxu
2029189809 fix(slides):text may over flow shape 2026-07-27 11:59:04 +08:00
zhanghuanxu
ee427979a8 fix(slides): preserve info lint severity 2026-07-27 11:59:04 +08:00
zhanghuanxu
545abcbbde fix: refine character width estimation for lark-slides text lint
Replace the uniform 0.55em half-width coefficient with per-character-type
coefficients, add font-family awareness (sans/serif), bold multiplier,
letter-spacing support, and fix padding-aware line wrapping.

- Split half-width chars into uppercase (0.57), lowercase (0.51 sans / 0.53
  serif), digits (0.58), and punctuation (0.50)
- Add classify_font_family() to apply slightly wider lowercase widths for
  serif fonts (Georgia, Source Han Serif/思源宋体, Times, etc.)
- Add 5% width multiplier for bold text; detect <strong>/<b>/<i>/<em> tags
  and span-level bold/italic attributes in addition to content attrs
- Fix estimate_text_line_count_for_text to subtract paddingLeft/paddingRight
  from available width before computing wrap lines
- Add resolve_letter_spacing and wire letterSpacing through estimate_text_width
- Extract fontFamily/bold/italic/letterSpacing into element dict during parse
2026-07-27 11:59:04 +08:00
zhanghuanxu
4a73e83f1e fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-27 11:59:04 +08:00
zhanghuanxu
7496420fa8 fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-27 11:59:04 +08:00
zhanghuanxu
43fabdf524 fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-27 11:59:04 +08:00
zhanghuanxu
8c46c74105 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-27 11:59:04 +08:00
zhanghuanxu
70777c86c3 fix(slides): restrict canvas overflow checks 2026-07-27 11:59:04 +08:00
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

Switch --slide-id to the string_slice flag type (cobra StringSlice),
which natively supports both comma-separated and repeated values, and
update the flag readers from StrArray to StrSlice. normalizeSlideIDs
already trims/dedupes/filters blanks, and
validateSlidesScreenshotSelectorLimit already caps the combined
selector count, so both continue to apply unchanged to CSV input.

Add tests covering --slide-id CSV parsing, whitespace/duplicate
normalization, and the >10 selector limit via CSV, mirroring the
existing --slide-number coverage.

Address review feedback:
- Fix "comma-separate" -> "comma-separated" wording in the --slide-id
  flag description (CodeRabbit).
- Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in the new screenshot
  tests, per the AGENTS.md testing convention, so local configuration
  state cannot leak into or be modified by the suite.
- Add a dry-run E2E test (tests/cli_e2e/slides) that pins --slide-id
  CSV parsing through the built CLI binary and asserts the emitted
  slide_ids request body, per the AGENTS.md dry-run E2E requirement
  for shortcut flag/param changes.
- Update the lark-slides skill reference to document that --slide-id
  and --slide-number both accept comma-separated values, not just
  repeated flags, so agents can discover the new syntax.
2026-07-24 18:32:36 +08:00
fangshuyu-768
dd7f741b62 docs(skills): clarify callout child rules (#2048) 2026-07-24 18:18:32 +08:00
kiraWangRuilong
e7d5ecdd01 feat: add risk-control protection (#1910)
1. Add baseline safe protection for Feishu/Lark API endpoints.
2. Add lark-cli config risk-control on|off|default command for workspace-level safety protection control.
2026-07-24 17:12:10 +08:00
zhanghuanxu
4807283368 fix(slides): declare screenshot scope 2026-07-24 15:25:11 +08:00
ILUO
d2bb36591f fix/task search pagination (#2041)
* fix: send task search page token in query

* test: assert task search dry-run pagination contract
2026-07-24 14:28:54 +08:00
yballul-bytedance
5a54bc07db fix(base): classify +form-submit as high-risk-write (#1969)
Form submission writes and submits data through a public share link, an
irreversible action that should require explicit confirmation. Reclassify
the shortcut from write to high-risk-write so the runner's --yes gate fires
before execution, matching +form-delete and other high-risk base commands.

Update the lark-base skill docs (--yes on all examples, param table, tips)
and add tests pinning the confirmation gate (unit) and dry-run structure (e2e).

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-24 11:11:37 +08:00
BD-ZERO
a528b3cb69 feat(slides): add layout density lint for sparse/empty containers (#2022)
feat(slides): add layout density lint for sparse/empty containers

Extend the XML layout lint into a single release gate for Slides XML:

- Add blank_slide, sparse_container_content, and sparse_slide_content
  detection, using visibility- and coverage-aware heuristics (alpha
  filtering, image-overlay/layout-panel exemptions, similar-short-card
  grouping) to avoid flagging intentional whitespace or background
  panels
- Broaden out-of-canvas detection from table/chart/text-only to every
  element kind, with rotation-aware bounding boxes and geometry
  extraction for icon/line/polyline
- Restructure output to schema v2.0: every issue carries rule
  (id/name/comparison/threshold), measurement, related_objects, and
  hint; summary gains status/release_ready/screenshot_review_required
- Change CLI exit-code semantics so only errors block (exit 1);
  warning-only output still exits 0 to let downstream screenshot review
  proceed
- Harden XML attribute parsing (single/double-quoted and spaced
  attributes, self-closing tags no longer bleeding content into the
  next element) and fix edge cases surfaced during review
  (image-overlay coverage ratio, invisible container/panel exemptions,
  bbox_overlap measurement consistency, background-only slide bypass,
  invisible short-card peers)
- Update SKILL.md, validation-checklist.md, and troubleshooting.md to
  match the new gate; add regression tests for the new rules and fixes
2026-07-24 10:47:15 +08:00
huarenmin13
f0176af330 docs(base): clarify complete and partial updates (#1993)
* docs(base): clarify complete and partial updates

Consolidate the update rule introduced in #1879 and make the command-contract boundary explicit. Full-update commands must use trusted current configuration for the first actual request, while delta commands should send the smallest legal payload.

* docs(base): clarify full-update state preservation

Address review feedback by requiring unchanged writable configuration to remain intact, except when the requested update makes a setting inapplicable.

* docs(base): strengthen update contract guidance
2026-07-24 00:01:35 +08:00
R0bynZhu
715aa8d960 feat(slides): fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
From EVAL-07-22-02-53 (42 convos), agents fell back to the full XSD for:
- shape type enum + presetHandlers (rounded corners)
- polyline (bounding-box positioning, required border, connector type)
- table merged cells (colspan / rowspan)

Add compact coverage for each, sized to real usage (shape/polyline type
lists trimmed to what actually appears in generations). Chart gaps deferred.
2026-07-23 22:18:17 +08:00
ILUO
ebc0c53ab5 fix/task id handling (#2023)
* fix: validate task GUID inputs

* fix: make task updates self-confirming

* fix: confirm task completion state

* docs: clarify task ID workflow

* test: cover task ID dry runs

* fix: address task ID review feedback
2026-07-23 20:48:38 +08:00
fangshuyu-768
1e682bd97c fix(slides): normalize presentation flag aliases (#2032) 2026-07-23 18:43:30 +08:00
fangshuyu-768
70424c486c docs(skill): clarify scope handling for query expansion (#2030) 2026-07-23 18:35:44 +08:00
liangshuo-1
b8f56dbc0b feat(apps): support absolute and relative upload paths (#2005) 2026-07-23 17:52:49 +08:00
chenxingyang1019
c74d9b63fb feat(apps): validate +file-list --page-size against server (0, 200] range (#2007)
paas_storage AppFileListForOpenAPI rejects page_size > 200 at the inner
checkMaxKeys guard with ErrInvalidRequest("maxKeys not in range (0, 200]").
Previously the CLI forwarded any --page-size straight to the API, so
--page-size 500 produced an opaque server error round-trip.

Add a client-side Validate check bounding --page-size to [1, 200] (aligned
with the existing validateAppsPageSize precedent in the observability
commands): out-of-range values now fail fast with a typed validation error
and never hit the network. The server tolerates page_size <= 0 by defaulting
to 20, but the CLI default is already 20 and an explicit < 1 is a user error,
so we reject it for a clearer message, consistent with other list commands.

Update the flag description and the lark-apps-file skill reference to
document the 1..200 range, and cover the boundaries in unit tests.
2026-07-23 15:55:08 +08:00
91-enjoy
67015eef8e feat: introducing official card icon (#1973)
Card header icon documentation contained invalid tokens (e.g., mail_colorful, approve_colorful) that do not render, and icon guidance lacked precise token enumeration, causing LLM to guess or fabricate icon tokens. This PR replaces
examples with valid tokens and adds a definitive colorful icon reference table.
2026-07-23 11:01:14 +08:00
303 changed files with 32439 additions and 1693 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,4 +1,7 @@
/go.mod @liangshuo-1
/go.sum @liangshuo-1
/internal/ @liangshuo-1
/shortcuts/common/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1

View File

@@ -25,19 +25,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -253,19 +250,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");

View File

@@ -2,6 +2,119 @@
All notable changes to this project will be documented in this file.
## [v1.0.81] - 2026-07-31
### Features
- support visible_rule for form questions (#1891)
- **contact**: add bot search shortcut (#2083)
- add SXSD schema validation to Slides lint (#2103)
- **drive**: add comment-operation shortcuts (#1898)
- **drive**: extend permission shortcuts for Miaoda (#2070)
- **apps**: add cache debug commands (+cache-get/-delete/-clear) (#1896)
- support source file preview artifacts (#2085)
### Bug Fixes
- **contact**: stop bot match segments carrying tags or empty entries (#2115)
- **base**: resolve Base URL block types accurately (#2099)
- **drive**: use title for default download filename (#2089)
- drop stale target version from root upgrade prompt (#2100)
### Documentation
- **calendar**: warn against container-default timezone in time conversion (#2104)
- **calendar**: confirm scope before editing recurring events (#2119)
- **base**: clarify form and file operation routing (#2110)
### Misc
- add protected public domain allowlists (#2111)
## [v1.0.80] - 2026-07-29
### Features
- **drive**: add +member-list shortcut (#1795)
- **drive**: add +permission-get-setting shortcut (#1738)
- propagate invocation metadata (#2097)
### Documentation
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
- **slides**: +create 的参数下沉到 create.md主 skill 只留路由 (#2096)
### Tests
- **e2e**: wait for base role update visibility (#2087)
### Misc
- Feat/detect line text overlap (#2069)
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
@@ -1638,6 +1751,11 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.81]: https://github.com/larksuite/cli/releases/tag/v1.0.81
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73

View File

@@ -285,12 +285,31 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date)
## Contributing
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).

View File

@@ -286,12 +286,31 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date)
## 贡献
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。

View File

@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +search-bot
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
### Skills
- lark-contact/references/lark-contact-search-bot.md
### Avoid when
- Looking for a person rather than a bot → use [[+search-user]]
- Running as a bot — this shortcut is user-only
### Tips
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
### Examples
**Find bots by keyword**
```bash
lark-cli contact +search-bot --query "会议助手" --as user
```
**Search inside one chat**
```bash
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
```
**Find bots you've chatted with**
```bash
lark-cli contact +search-bot --query "助手" --has-chatted --as user
```
**Search several bot keywords in one call**
```bash
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.

View File

@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -179,8 +179,8 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
}
// Step 1: Request app registration (begin)
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
// Registration is platform traffic, so it must use the provider-aware
// transport as well as the shared proxy configuration.
httpClient := transport.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -157,8 +157,8 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
}
}
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
// the real egress path (and are blocked when proxy plugin fails closed).
// Connectivity checks are platform traffic and must exercise the same
// provider-aware route as real platform requests.
httpClient := transport.NewHTTPClient(0)
mcpURL := ep.MCP + "/mcp"

View File

@@ -65,7 +65,17 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
// Deliberately no target version here: info.Latest comes from the on-disk
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
// failed refresh leaves the old value in place), so it can name a version
// that is no longer the one npm would install. The version actually
// installed is resolved live by the update subcommand, which prints
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
// that is where the user sees the real target. Keep going through the
// update subcommand rather than calling RunNpmInstall directly, otherwise
// that line disappears and the user approves a global install without ever
// being told what gets installed.
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
if !readYes(ios.In) {
return
}

View File

@@ -128,6 +128,17 @@ func TestOfferRootUpgrade(t *testing.T) {
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
// The prompt must not name a target version: info.Latest comes from
// the on-disk cache and can be stale, while the version actually
// installed is resolved live by the update subcommand.
if tc.wantPrompt {
if strings.Contains(errBuf.String(), tc.latest) {
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
}
if !strings.Contains(errBuf.String(), build.Version) {
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
}
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}

View File

@@ -12,9 +12,18 @@ import (
"net/http"
"testing"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/envvars"
internaltransport "github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/sidecar"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
type failingBody struct {
err error
@@ -263,3 +272,55 @@ func TestInterceptor_EmptyBody(t *testing.T) {
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
}
}
func TestLegacySidecarProviderStillHandlesForcedExternalRequests(t *testing.T) {
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
t.Setenv(envvars.CliProxyKey, "test-key")
previousProvider := exttransport.GetProvider()
exttransport.Register(&Provider{})
t.Cleanup(func() { exttransport.Register(previousProvider) })
seen := make(chan *http.Request, 2)
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
seen <- req.Clone(req.Context())
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
})
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: internaltransport.NewHTTPPolicyRouter(base, base)},
exttransport.RequestClassExternal,
)
withSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/protected", nil)
if err != nil {
t.Fatal(err)
}
withSentinel.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
resp, err := client.Do(withSentinel)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
withoutSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/public", nil)
if err != nil {
t.Fatal(err)
}
resp, err = client.Do(withoutSentinel)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
proxied := <-seen
if proxied.URL.Scheme != "http" || proxied.URL.Host != "127.0.0.1:16384" {
t.Fatalf("sentinel request URL = %s, want sidecar route", proxied.URL)
}
if got := proxied.Header.Get(sidecar.HeaderProxyTarget); got != "https://external.example" {
t.Fatalf("sentinel request proxy target = %q", got)
}
passthrough := <-seen
if got := passthrough.URL.String(); got != "https://external.example/public" {
t.Fatalf("non-sentinel request URL = %q, want unchanged", got)
}
}

View File

@@ -15,6 +15,27 @@ type Provider interface {
ResolveInterceptor(ctx context.Context) Interceptor
}
// RequestClass describes the trust boundary of an outbound HTTP request.
// Platform requests target endpoints owned by the CLI's endpoint resolver;
// external requests target user-provided, pre-signed, CDN, registry, or other
// non-platform URLs. Redirect targets are classified again from each hop's
// logical URL; rewriting a host in an interceptor does not add that host to
// the platform endpoint catalog.
type RequestClass string
const (
RequestClassPlatform RequestClass = "platform"
RequestClassExternal RequestClass = "external"
)
// ScopedProvider optionally limits a Provider to selected request classes.
// Providers that do not implement this interface retain the original
// behavior and apply to every request class.
type ScopedProvider interface {
Provider
SupportsRequestClass(RequestClass) bool
}
// Interceptor defines network-layer customization via a pre/post hook pair.
// The built-in transport chain always executes between PreRoundTrip and the
// returned post function, and cannot be skipped or overridden by the extension.

View File

@@ -17,6 +17,8 @@ import (
"github.com/larksuite/cli/internal/transport"
)
var _ transport.RoundTripperDecorator = (*SecurityPolicyTransport)(nil)
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
// and checks for security policy errors.
type SecurityPolicyTransport struct {
@@ -31,6 +33,16 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
return transport.Fallback()
}
func (t *SecurityPolicyTransport) BaseRoundTripper() http.RoundTripper {
return t.base()
}
func (t *SecurityPolicyTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
// RoundTrip implements http.RoundTripper.
func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base().RoundTrip(req)

View File

@@ -212,6 +212,9 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
resp, err := httpClient.Do(httpReq)
if err != nil {
cancel()
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
}
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}

View File

@@ -518,6 +518,29 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
}
}
func TestDoStream_PreservesTypedTransportError(t *testing.T) {
policyErr := errs.NewSecurityPolicyError(errs.SubtypeAccessDenied, "blocked redirect")
ac := &APIClient{
HTTP: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, policyErr
})},
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/files/file_token/download",
}, core.AsBot)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("DoStream() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if !errors.Is(err, policyErr) {
t.Fatal("DoStream() did not preserve the typed transport error")
}
}
// failingTokenResolver always returns TokenUnavailableError, exercising the
// auth/credential failure path through resolveAccessToken.
type failingTokenResolver struct{}

View File

@@ -16,10 +16,12 @@ import (
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/transport"
)
// Factory holds shared dependencies injected into every command.
@@ -31,7 +33,7 @@ type InvocationContext struct {
type Factory struct {
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
HttpClient func() (*http.Client, error) // policy-routed HTTP client for direct requests
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
IOStreams *IOStreams // stdin/stdout/stderr streams
@@ -48,6 +50,18 @@ type Factory struct {
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
}
// ExternalHTTPClient returns a clone of the existing Factory client whose
// requests are explicitly classified as external. The underlying client,
// redirect policy, timeout, proxy configuration, and legacy transport provider
// behavior are preserved.
func (f *Factory) ExternalHTTPClient() (*http.Client, error) {
client, err := f.HttpClient()
if err != nil {
return nil, err
}
return transport.ClientForRequestClass(client, exttransport.RequestClassExternal), nil
}
// ResolveFileIO resolves a FileIO instance using the current execution context.
// The provider controls whether the returned instance is fresh or cached.
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {

View File

@@ -5,16 +5,18 @@ package cmdutil
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
@@ -22,6 +24,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
@@ -33,7 +36,7 @@ import (
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -47,6 +50,19 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
ws := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(ws)
workspaceConfig := core.NewConfigSnapshot()
bootstrapHostSignalSource := sync.OnceValue(func() riskcontrol.Source {
return resolveSDKHostSignalSource(workspaceConfig)
})
// Install after workspace selection so the dependency bootstrap bridge uses
// the correct shared proxy configuration. NewDefault is also used by cmd.Build
// consumers, so this keeps their request routing identical to cmd.Execute.
transport.InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
return buildSDKPlatformTransportWithBase(
base,
bootstrapHostSignalSource(),
)
})
// Inject workspace-aware dir into keychain's log system.
// This breaks the core↔keychain import cycle by using a function variable.
@@ -56,7 +72,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
f.FileIOProvider = fileio.GetProvider()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
@@ -67,7 +83,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
// Phase 3: Runtime config contains resolved account data only.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -78,21 +94,52 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
return f
}
// safeRedirectPolicy prevents credential headers from being forwarded
// when a response redirects to a different host (e.g. Lark API 302 → CDN).
// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host
// redirects; other headers like X-Cli-* pass through.
// safeRedirectPolicy permits cross-origin redirects only for bodyless GET and
// HEAD requests. This allows API download redirects while preventing OAuth or
// other credential-bearing request bodies from being replayed to another
// origin. HTTPS requests can never be downgraded to HTTP.
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "too many redirects")
}
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
if len(via) == 0 {
return nil
}
original := via[0]
previous := via[len(via)-1]
if previous.URL != nil && req.URL != nil && strings.EqualFold(previous.URL.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
return errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"redirect from HTTPS to %s is not allowed",
req.URL.Scheme,
)
}
if !sameRedirectOrigin(previous.URL, req.URL) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
return errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"cross-origin redirect for HTTP method %s is not allowed",
req.Method,
)
}
if req.Body != nil || req.GetBody != nil {
return errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"cross-origin redirect with a request body is not allowed",
)
}
}
// net/http copies initial headers onto every redirect request. Continue
// stripping credentials for every hop outside the initial origin, even when
// two consecutive redirect targets share an origin.
if !sameRedirectOrigin(original.URL, req.URL) {
req.Header.Del("Authorization")
req.Header.Del("X-Lark-MCP-UAT")
req.Header.Del("X-Lark-MCP-TAT")
@@ -100,6 +147,29 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
return nil
}
func sameRedirectOrigin(left, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) &&
strings.EqualFold(left.Hostname(), right.Hostname()) &&
effectivePort(left) == effectivePort(right)
}
func effectivePort(candidate *url.URL) string {
if port := candidate.Port(); port != "" {
return port
}
switch strings.ToLower(candidate.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
// warnIfProxied is a test seam for the proxy-warning gate. Production wires it
// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is
// needed because the real function is guarded by an internal sync.Once, so
@@ -108,19 +178,19 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
var rt http.RoundTripper = transport.Shared()
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
rt = wrapWithExtension(rt)
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
shared := transport.Shared()
outbound := riskcontrol.NewTransport(shared, hostSignalSource)
platform := buildDirectHTTPTransport(outbound, true)
external := buildDirectHTTPTransport(outbound, false)
client := &http.Client{
Transport: rt,
Transport: transport.NewHTTPPolicyRouter(platform, external),
Timeout: 30 * time.Second,
CheckRedirect: safeRedirectPolicy,
}
@@ -128,7 +198,16 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
func buildDirectHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
var builtIn http.RoundTripper = &RetryTransport{Base: base}
builtIn = &SecurityHeaderTransport{Base: builtIn}
if platform {
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
}
return builtIn
}
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -142,8 +221,9 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
Transport: buildSDKTransport(hostSignalSource),
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -152,13 +232,41 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
return wrapWithExtension(sdkTransport)
func buildSDKTransport(hostSignalSource riskcontrol.Source) http.RoundTripper {
return buildSDKTransportWithBase(transport.Shared(), hostSignalSource)
}
func buildSDKPlatformTransportWithBase(
base http.RoundTripper,
hostSignalSource riskcontrol.Source,
) http.RoundTripper {
outbound := riskcontrol.NewTransport(base, hostSignalSource)
return buildSDKHTTPTransport(outbound, true)
}
func buildSDKTransportWithBase(
base http.RoundTripper,
hostSignalSource riskcontrol.Source,
) http.RoundTripper {
// Risk control is the innermost trusted boundary for both request classes.
// It therefore observes the final URL and strips extension-supplied reserved
// headers immediately before the network transport.
outbound := riskcontrol.NewTransport(base, hostSignalSource)
return transport.NewHTTPPolicyRouter(
buildSDKHTTPTransport(outbound, true),
buildSDKHTTPTransport(outbound, false),
)
}
func buildSDKHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
var builtIn http.RoundTripper = &RetryTransport{Base: base}
builtIn = &UserAgentTransport{Base: builtIn}
builtIn = &BuildHeaderTransport{Base: builtIn}
builtIn = &SecurityHeaderTransport{Base: builtIn}
if platform {
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
}
return builtIn
}
type credentialDeps struct {

View File

@@ -4,12 +4,24 @@
package cmdutil
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
func TestCachedHTTPClientFunc_ReturnsSameInstance(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c1, err := fn()
if err != nil {
@@ -28,18 +40,304 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
func TestCachedHTTPClientFunc_HasTimeout(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
}
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
func TestCachedHTTPClientFunc_HasRedirectPolicy(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
}
}
func TestFactoryExternalHTTPClientClonesExistingClient(t *testing.T) {
base := &http.Client{Timeout: 17, CheckRedirect: safeRedirectPolicy}
factory := &Factory{HttpClient: func() (*http.Client, error) { return base, nil }}
external, err := factory.ExternalHTTPClient()
if err != nil {
t.Fatal(err)
}
if external == base {
t.Fatal("ExternalHTTPClient returned the cached client instead of a clone")
}
if external.Timeout != base.Timeout || external.CheckRedirect == nil {
t.Fatal("ExternalHTTPClient did not preserve client policy")
}
if base.Transport != nil {
t.Fatal("ExternalHTTPClient mutated the cached client's transport")
}
}
type platformOnlyStubProvider struct {
*stubTransportProvider
}
func (*platformOnlyStubProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
return class == exttransport.RequestClassPlatform
}
func TestFactoryHTTPClientRoutesPoliciesByRequestClass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
interceptor := &headerCapturingInterceptor{}
exttransport.Register(&platformOnlyStubProvider{stubTransportProvider: &stubTransportProvider{interceptor: interceptor}})
t.Cleanup(func() { exttransport.Register(nil) })
received := make(chan http.Header, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
received <- req.Header.Clone()
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
client, err := cachedHttpClientFunc(factory, nil)()
if err != nil {
t.Fatal(err)
}
factory.HttpClient = func() (*http.Client, error) { return client, nil }
platformClient := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
externalClient, err := factory.ExternalHTTPClient()
if err != nil {
t.Fatal(err)
}
for _, client := range []*http.Client{platformClient, externalClient} {
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
platformHeaders := <-received
if got := platformHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
t.Fatalf("platform extension header = %q, want ext-trace-123", got)
}
if got := platformHeaders.Get(HeaderSource); got != SourceValue {
t.Fatalf("platform security header = %q, want %q", got, SourceValue)
}
externalHeaders := <-received
if got := externalHeaders.Get("X-Custom-Trace"); got != "" {
t.Fatalf("external request leaked extension header %q", got)
}
for header, values := range BaseSecurityHeaders() {
if len(values) == 0 {
continue
}
want := values[len(values)-1]
if got := externalHeaders.Get(header); got != want {
t.Fatalf("external security header %s = %q, want preserved value %q", header, got, want)
}
}
}
func TestFactoryExternalHTTPClientDoesNotParsePlatformErrorProtocol(t *testing.T) {
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
exttransport.Register(nil)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":21000,"msg":"application-defined external response","data":{"cli_hint":"external-defined"}}`)
}))
t.Cleanup(server.Close)
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
client, err := cachedHttpClientFunc(factory, nil)()
if err != nil {
t.Fatal(err)
}
factory.HttpClient = func() (*http.Client, error) { return client, nil }
platform := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
if _, err := platform.Get(server.URL); err == nil {
t.Fatal("platform request error = nil, want security policy classification")
} else {
var policyErr *errs.SecurityPolicyError
if !errors.As(err, &policyErr) {
t.Fatalf("platform request error type = %T, want *errs.SecurityPolicyError", err)
}
}
external, err := factory.ExternalHTTPClient()
if err != nil {
t.Fatal(err)
}
resp, err := external.Get(server.URL)
if err != nil {
t.Fatalf("external request parsed platform error protocol: %v", err)
}
resp.Body.Close()
}
func TestSafeRedirectPolicyAllowsBodylessCrossOriginGetAndStripsCredentials(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/file", nil)
if err != nil {
t.Fatal(err)
}
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
redirect.Header.Set(header, "secret")
}
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
t.Fatalf("safeRedirectPolicy() error = %v, want allowed GET redirect", err)
}
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
if got := redirect.Header.Get(header); got != "" {
t.Fatalf("redirect retained %s=%q", header, got)
}
}
}
func TestSafeRedirectPolicyRejectsHTTPSDowngrade(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "http://open.feishu.cn/next", nil)
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original})
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("safeRedirectPolicy() error = %v, want HTTPS downgrade rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func TestSafeRedirectPolicyRejectsCrossOriginMethod(t *testing.T) {
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodPost, "https://external.example/token", nil)
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original})
if err == nil || !strings.Contains(err.Error(), "HTTP method POST") {
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin method rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func TestSafeRedirectPolicyRejectsCrossOriginRequestBody(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://accounts.feishu.cn/token", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "https://external.example/token", strings.NewReader("client_secret=secret"))
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original})
if err == nil || !strings.Contains(err.Error(), "request body") {
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin body rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func TestSafeRedirectPolicyRejectsTooManyRedirects(t *testing.T) {
err := safeRedirectPolicy(&http.Request{}, make([]*http.Request, 10))
if err == nil || err.Error() != "too many redirects" {
t.Fatalf("safeRedirectPolicy() error = %v, want redirect limit rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
}
func TestSafeRedirectPolicyTreatsDefaultHTTPSPortAsSameOrigin(t *testing.T) {
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", strings.NewReader("secret"))
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn:443/token-next", strings.NewReader("secret"))
if err != nil {
t.Fatal(err)
}
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
t.Fatalf("safeRedirectPolicy() error = %v, want same-origin redirect", err)
}
}
func TestSafeRedirectPolicyKeepsCredentialsStrippedAcrossExternalHops(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
if err != nil {
t.Fatal(err)
}
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/first", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/second", nil)
if err != nil {
t.Fatal(err)
}
redirect.Header.Set("Authorization", "Bearer copied-from-initial-request")
if err := safeRedirectPolicy(redirect, []*http.Request{original, previous}); err != nil {
t.Fatalf("safeRedirectPolicy() error = %v, want same-CDN redirect", err)
}
if got := redirect.Header.Get("Authorization"); got != "" {
t.Fatalf("redirect retained Authorization=%q outside the initial origin", got)
}
}
func TestSafeRedirectPolicyRejectsDowngradeOnLaterHop(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "http://source.example/start", nil)
if err != nil {
t.Fatal(err)
}
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/secure", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "http://cdn.example.com/plain", nil)
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original, previous})
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("safeRedirectPolicy() error = %v, want later-hop HTTPS downgrade rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func requireRedirectProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed error", err)
}
if problem.Category != category || problem.Subtype != subtype {
t.Fatalf(
"error category/subtype = %s/%s, want %s/%s",
problem.Category,
problem.Subtype,
category,
subtype,
)
}
}

View File

@@ -8,6 +8,7 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -33,16 +34,18 @@ var proxyWarnGateCases = []struct {
{"non-terminal stderr stays silent", false, 0},
}
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// TestCachedHTTPClientFunc_ProxyWarnGate verifies the HTTP client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
func TestCachedHTTPClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
f.IOStreams.StderrIsTerminal = tc.terminal
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io/fs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// StatLocalFile returns metadata for a path in the process filesystem namespace.
// It is intended for advisory validation; callers must validate the opened file
// again before using its contents.
func StatLocalFile(path string) (fs.FileInfo, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Stat(localPath)
}
// OpenLocalFile opens a path in the process filesystem namespace.
// Absolute and relative paths are accepted. It is the shared replacement for
// direct os.Open/os.ReadFile use in commands that intentionally read local
// paths outside the workspace sandbox. Callers inspect the returned descriptor
// before reading so validation and use apply to the same opened file.
func OpenLocalFile(path string) (fs.File, error) {
localPath, err := validate.LocalInputPath(path)
if err != nil {
return nil, &fileio.PathValidationError{Err: err}
}
return vfs.Open(localPath)
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs"
)
func TestOpenLocalFile_AcceptsAbsoluteAndParentRelativePaths(t *testing.T) {
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
TestChdir(t, workDir)
for _, input := range []string{path, filepath.Join("..", "input.txt")} {
f, err := OpenLocalFile(input)
if err != nil {
t.Fatalf("OpenLocalFile(%q) error = %v", input, err)
}
got, readErr := io.ReadAll(f)
closeErr := f.Close()
if readErr != nil || closeErr != nil || string(got) != "content" {
t.Fatalf("OpenLocalFile(%q) content=%q read=%v close=%v", input, got, readErr, closeErr)
}
}
}
func TestOpenLocalFile_RejectsInvalidInput(t *testing.T) {
if _, err := OpenLocalFile("input\n.txt"); !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("OpenLocalFile() error = %v, want ErrPathValidation", err)
}
}
func TestStatLocalFile_ReturnsMetadata(t *testing.T) {
info, err := StatLocalFile(t.TempDir())
if err != nil {
t.Fatalf("StatLocalFile() error = %v", err)
}
if !info.IsDir() {
t.Fatalf("StatLocalFile() mode = %v, want directory", info.Mode())
}
}
func TestOpenLocalFile_DoesNotStatBeforeOpen(t *testing.T) {
path := filepath.Join(t.TempDir(), "input.txt")
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
previous := vfs.DefaultFS
counting := &countingLocalFileFS{FS: previous}
vfs.DefaultFS = counting
t.Cleanup(func() { vfs.DefaultFS = previous })
f, err := OpenLocalFile(path)
if err != nil {
t.Fatalf("OpenLocalFile() error = %v", err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if counting.openCalls != 1 || counting.statCalls != 0 {
t.Fatalf("OpenLocalFile() calls: Open=%d Stat=%d, want Open=1 Stat=0", counting.openCalls, counting.statCalls)
}
}
type countingLocalFileFS struct {
vfs.FS
openCalls int
statCalls int
}
func (f *countingLocalFileFS) Open(name string) (*os.File, error) {
f.openCalls++
return f.FS.Open(name)
}
func (f *countingLocalFileFS) Stat(name string) (fs.FileInfo, error) {
f.statCalls++
return f.FS.Stat(name)
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/riskcontrol"
)
type workspaceConfigSource interface {
MultiAppConfig() (*core.MultiAppConfig, error)
}
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
// boundary.
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
if config == nil {
return nil
}
workspace, configErr := config.MultiAppConfig()
// Default-on means an existing config with no explicit preference. Absent
// or unreadable config cannot authorize host-signal collection.
if configErr != nil || !workspace.RiskControlEnabled() {
return nil
}
return riskcontrol.NewHostSource()
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
)
type staticWorkspaceConfig struct {
config *core.MultiAppConfig
err error
}
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
return s.config, s.err
}
func TestResolveSDKHostSignalSource(t *testing.T) {
disabled := false
tests := []struct {
name string
config workspaceConfigSource
wantSource bool
}{
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
{name: "nil config value", config: staticWorkspaceConfig{}},
{name: "nil config source"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := resolveSDKHostSignalSource(test.config)
if (got != nil) != test.wantSource {
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
}
})
}
}

View File

@@ -26,6 +26,7 @@ const (
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
HeaderAgentName = "X-Agent-Name"
SourceValue = "lark-cli"
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v)
}
if v := envvars.AgentName(); v != "" {
h.Set(HeaderAgentName, v)
}
return h
}

View File

@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// HeaderAgentTrace injection (via BaseSecurityHeaders)
// Agent headers injected via BaseSecurityHeaders
// ---------------------------------------------------------------------------
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentName, "")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
const agentName = "sample-agent"
t.Setenv(envvars.CliAgentName, agentName)
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != agentName {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
}
}
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
h := BaseSecurityHeaders()
if v := h.Get(HeaderAgentName); v != "" {
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders()

View File

@@ -46,7 +46,7 @@ func TestTestFactory_ReplacesGlobals(t *testing.T) {
URL: "/test",
Body: "ok",
})
// Use the stub via Factory HttpClient
// Use the stub via Factory HttpClient.
httpClient, err := f.HttpClient()
if err != nil {
t.Fatalf("HttpClient() error: %v", err)

View File

@@ -4,14 +4,19 @@
package cmdutil
import (
"context"
"net/http"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/transport"
)
var (
_ transport.RoundTripperDecorator = (*RetryTransport)(nil)
_ transport.RoundTripperDecorator = (*UserAgentTransport)(nil)
_ transport.RoundTripperDecorator = (*BuildHeaderTransport)(nil)
_ transport.RoundTripperDecorator = (*SecurityHeaderTransport)(nil)
)
// RetryTransport is an http.RoundTripper that retries on 5xx responses
// and network errors. MaxRetries defaults to 0 (no retries).
type RetryTransport struct {
@@ -27,6 +32,16 @@ func (t *RetryTransport) base() http.RoundTripper {
return transport.Fallback()
}
func (t *RetryTransport) BaseRoundTripper() http.RoundTripper {
return t.base()
}
func (t *RetryTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
func (t *RetryTransport) delay() time.Duration {
if t.Delay > 0 {
return t.Delay
@@ -63,6 +78,19 @@ type UserAgentTransport struct {
Base http.RoundTripper
}
func (t *UserAgentTransport) BaseRoundTripper() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
}
func (t *UserAgentTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set(HeaderUserAgent, UserAgentValue())
@@ -73,14 +101,25 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
}
// BuildHeaderTransport is an http.RoundTripper that force-writes the
// X-Cli-Build header before every request. Used in the SDK transport chain,
// where SecurityHeaderTransport is not installed, to prevent extensions from
// tampering with the build classification. The direct HTTP chain is already
// covered by SecurityHeaderTransport iterating BaseSecurityHeaders.
// X-Cli-Build header before every request. It remains in the SDK transport
// chain as a narrow defense-in-depth layer alongside SecurityHeaderTransport.
type BuildHeaderTransport struct {
Base http.RoundTripper
}
func (t *BuildHeaderTransport) BaseRoundTripper() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
}
func (t *BuildHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set(HeaderBuild, DetectBuildKind())
@@ -103,6 +142,16 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
return transport.Fallback()
}
func (t *SecurityHeaderTransport) BaseRoundTripper() http.RoundTripper {
return t.base()
}
func (t *SecurityHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
// RoundTrip implements http.RoundTripper.
func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
@@ -120,67 +169,3 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
}
return t.base().RoundTrip(req)
}
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
// The built-in chain always executes unless the extension is an
// exttransport.AbortableInterceptor and its PreRoundTripE returns a non-nil
// error; it cannot otherwise be skipped or overridden.
//
// The original request context is restored after the pre hook to prevent
// extensions from tampering with cancellation, deadlines, or built-in values.
// Cloning the request isolates header/URL/etc. mutations from the caller's
// request object; req.Body is intentionally shared — extensions that consume
// it are responsible for rewinding (see Interceptor doc).
type extensionMiddleware struct {
Base http.RoundTripper
Ext exttransport.Interceptor
ExtName string // Provider.Name(), captured at wrap time for *AbortError.Extension
}
// RoundTrip invokes the interceptor pre hook, restores the original context,
// executes the built-in chain (unless aborted), then calls the post hook if
// non-nil. When the extension implements AbortableInterceptor and returns a
// non-nil error from PreRoundTripE, the built-in chain is skipped and an
// *exttransport.AbortError is returned; the post hook is still invoked with
// (nil, reason) so extensions can unwind resources.
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
origCtx := req.Context()
req = req.Clone(origCtx)
var (
post func(*http.Response, error)
abortEr error
)
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
post, abortEr = a.PreRoundTripE(req)
} else {
post = m.Ext.PreRoundTrip(req)
}
if abortEr != nil {
if post != nil {
post(nil, abortEr)
}
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortEr}
}
req = req.WithContext(origCtx) // restore original context
resp, err := m.Base.RoundTrip(req)
if post != nil {
post(resp, err)
}
return resp, err
}
// wrapWithExtension wraps transport with the registered extension middleware.
// If no extension is registered, returns transport unchanged.
func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
p := exttransport.GetProvider()
if p == nil {
return transport
}
tr := p.ResolveInterceptor(context.Background())
if tr == nil {
return transport
}
return &extensionMiddleware{Base: transport, Ext: tr, ExtName: p.Name()}
}

View File

@@ -14,7 +14,8 @@ import (
"time"
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
internaltransport "github.com/larksuite/cli/internal/transport"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -90,79 +91,107 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// buildSDKTransport policy behavior
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
func TestBuildSDKTransportAppliesSecurityHeadersToEveryRequestClass(t *testing.T) {
exttransport.Register(nil)
received := make(chan http.Header, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
received <- req.Header.Clone()
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
for _, class := range []exttransport.RequestClass{
exttransport.RequestClassPlatform,
exttransport.RequestClassExternal,
} {
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransport(nil)},
class,
)
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
headers := <-received
for header, values := range BaseSecurityHeaders() {
if len(values) == 0 {
continue
}
want := values[len(values)-1]
if got := headers.Get(header); got != want {
t.Fatalf("SDK %s header %s = %q, want %q", class, header, got, want)
}
}
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
previous := exttransport.GetProvider()
interceptor := &headerCapturingInterceptor{}
exttransport.Register(&platformOnlyStubProvider{
stubTransportProvider: &stubTransportProvider{interceptor: interceptor},
})
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransport(nil)},
exttransport.RequestClassPlatform,
)
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
resp.Body.Close()
if !interceptor.preCalled || !interceptor.postCalled {
t.Fatal("SDK platform request did not execute extension pre/post hooks")
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
if _, ok := buildSDKTransport(nil).(*internaltransport.HTTPPolicyRouter); !ok {
t.Fatalf(
"buildSDKTransport() type = %T, want *transport.HTTPPolicyRouter",
buildSDKTransport(nil),
)
}
}
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
func TestBuildSDKTransportSupportsPolicyLeafCloning(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
base := &http.Transport{}
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransportWithBase(base, nil)},
exttransport.RequestClassExternal,
)
source, ok := client.Transport.(interface {
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
})
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
t.Fatalf("SDK request-class transport type = %T, want clone capability", client.Transport)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
rebuilt, concrete, ok := source.CloneHTTPTransport()
if !ok || rebuilt == nil || concrete == nil {
t.Fatal("SDK policy graph could not clone its HTTP transport leaf")
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
if concrete == base {
t.Fatal("SDK policy graph reused the original HTTP transport")
}
}
@@ -222,7 +251,7 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &SecurityHeaderTransport{Base: base}
transport := wrapWithExtension(base)
transport := internaltransport.WrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
@@ -250,26 +279,132 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
}
}
// buildTamperingInterceptor tries to delete and spoof X-Cli-Build via
// PreRoundTrip. The SDK chain's BuildHeaderTransport must restore the real
// value before the request leaves the process.
// buildTamperingInterceptor tries to delete and spoof security headers via
// PreRoundTrip. The SDK built-in chain must restore the real values before the
// request leaves the process.
type buildTamperingInterceptor struct{}
func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Del(HeaderBuild)
req.Header.Set(HeaderBuild, "ext-tampered-build")
req.Header.Del(HeaderSource)
req.Header.Set(HeaderSource, "ext-tampered-source")
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
type bootstrapPolicyTamperingInterceptor struct{}
func (bootstrapPolicyTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(HeaderSource, "extension-value")
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
return nil
}
func TestNewDefaultInstallsSDKBootstrapSecurityPolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
oldTransport := http.DefaultClient.Transport
oldCheckRedirect := http.DefaultClient.CheckRedirect
t.Cleanup(func() {
http.DefaultClient.Transport = oldTransport
http.DefaultClient.CheckRedirect = oldCheckRedirect
})
previous := exttransport.GetProvider()
exttransport.Register(&platformOnlyStubProvider{
stubTransportProvider: &stubTransportProvider{
interceptor: bootstrapPolicyTamperingInterceptor{},
},
})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{
StatusCode: http.StatusNoContent,
Body: http.NoBody,
Request: req,
}, nil
})
http.DefaultClient.Transport = network
http.DefaultClient.CheckRedirect = nil
_ = NewDefault(nil, InvocationContext{})
req, err := http.NewRequest(
http.MethodPost,
"https://open.feishu.cn/callback/ws/endpoint",
strings.NewReader(`{"app_secret":"secret"}`),
)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if got := received.Get(HeaderSource); got != SourceValue {
t.Fatalf("%s = %q, want trusted value %q", HeaderSource, got, SourceValue)
}
if got := received.Get(riskcontrol.HeaderOSType); got != "" {
t.Fatalf("%s = %q, want extension value stripped", riskcontrol.HeaderOSType, got)
}
if got := received.Get(HeaderBuild); got != DetectBuildKind() {
t.Fatalf("%s = %q, want %q", HeaderBuild, got, DetectBuildKind())
}
if got := received.Get(HeaderUserAgent); got != UserAgentValue() {
t.Fatalf("%s = %q, want %q", HeaderUserAgent, got, UserAgentValue())
}
}
func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransportWithBase(network, nil)},
exttransport.RequestClassPlatform,
)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
// closes the gap where the SDK chain had no equivalent of
// SecurityHeaderTransport (see design doc §3.3.3).
// SDK chain restores both the build classification and the full security
// header set after an extension runs.
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
var receivedBuild string
var receivedBuild, receivedSource string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
receivedSource = r.Header.Get(HeaderSource)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
@@ -277,12 +412,13 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
// Replicate the SDK built-in chain inside buildSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}
base = &BuildHeaderTransport{Base: base}
transport := wrapWithExtension(base)
base = &SecurityHeaderTransport{Base: base}
transport := internaltransport.WrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
@@ -299,6 +435,9 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
if receivedBuild != want {
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
}
if receivedSource != SourceValue {
t.Fatalf("%s = %q, want %q", HeaderSource, receivedSource, SourceValue)
}
}
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
@@ -388,7 +527,7 @@ func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
return nil
})
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
mid := &internaltransport.ExtensionMiddleware{Base: capturer, Ext: tamperIC}
origCtx := context.WithValue(context.Background(), testKey, "original")
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
@@ -450,7 +589,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
@@ -491,7 +630,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
return nil, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
_, err := mid.RoundTrip(req)
@@ -510,7 +649,7 @@ func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
if err != nil {

View File

@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"io/fs"
"sync"
)
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
// invocation. All runtime consumers share the same load result so account and
// workspace policy resolution cannot observe different file revisions. Callers
// must treat the returned config as read-only.
type ConfigSnapshot struct {
load func() (*MultiAppConfig, error)
}
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}
}
return &ConfigSnapshot{load: sync.OnceValues(load)}
}
// MultiAppConfig returns the captured persistent config and load error.
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
if s == nil || s.load == nil {
return nil, fs.ErrNotExist
}
return s.load()
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"io/fs"
"testing"
)
func TestConfigSnapshotLoadsOnce(t *testing.T) {
calls := 0
want := &MultiAppConfig{}
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return want, nil
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if config != want {
t.Fatal("snapshot returned a different config instance")
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
config, err := (&ConfigSnapshot{}).MultiAppConfig()
if config != nil || !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return nil, want
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if config != nil || !errors.Is(err, want) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}

View File

@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {

View File

@@ -3,7 +3,10 @@
package core
import "strings"
import (
"net/url"
"strings"
)
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
@@ -63,3 +66,39 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
func ResolveOpenBaseURL(brand LarkBrand) string {
return ResolveEndpoints(brand).Open
}
var platformEndpointHosts = func() map[string]struct{} {
hosts := make(map[string]struct{})
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
endpoints := ResolveEndpoints(brand)
for _, rawURL := range []string{endpoints.Open, endpoints.Accounts, endpoints.MCP, endpoints.AppLink} {
parsed, err := url.Parse(rawURL)
if err == nil && parsed.Hostname() != "" {
hosts[strings.ToLower(parsed.Hostname())] = struct{}{}
}
}
}
return hosts
}()
// IsPlatformEndpointHost reports whether hostname exactly matches one of the
// endpoint hosts produced by ResolveEndpoints. It intentionally does not use a
// suffix match: lookalike external domains must never enter the platform
// transport extension.
func IsPlatformEndpointHost(hostname string) bool {
_, ok := platformEndpointHosts[strings.ToLower(hostname)]
return ok
}
// IsPlatformEndpointURL reports whether candidate uses a secure origin for a
// configured platform endpoint. Non-TLS and non-standard-port lookalikes are
// excluded even when their hostname matches.
func IsPlatformEndpointURL(candidate *url.URL) bool {
if candidate == nil || !strings.EqualFold(candidate.Scheme, "https") {
return false
}
if port := candidate.Port(); port != "" && port != "443" {
return false
}
return IsPlatformEndpointHost(candidate.Hostname())
}

View File

@@ -3,7 +3,11 @@
package core
import "testing"
import (
"net/url"
"reflect"
"testing"
)
func TestResolveEndpoints_Feishu(t *testing.T) {
ep := ResolveEndpoints(BrandFeishu)
@@ -91,3 +95,85 @@ func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
}
}
func TestIsPlatformEndpointHost_ExactMatchOnly(t *testing.T) {
for _, host := range []string{
"open.feishu.cn",
"accounts.feishu.cn",
"mcp.feishu.cn",
"applink.feishu.cn",
"open.larksuite.com",
"accounts.larksuite.com",
"mcp.larksuite.com",
"applink.larksuite.com",
} {
if !IsPlatformEndpointHost(host) {
t.Errorf("IsPlatformEndpointHost(%q) = false, want true", host)
}
}
for _, host := range []string{
"example.com",
"open.feishu.cn.example.com",
"notopen.feishu.cn",
"",
} {
if IsPlatformEndpointHost(host) {
t.Errorf("IsPlatformEndpointHost(%q) = true, want false", host)
}
}
}
func TestIsPlatformEndpointHost_CoversEveryResolvedEndpoint(t *testing.T) {
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
endpoints := reflect.ValueOf(ResolveEndpoints(brand))
for i := 0; i < endpoints.NumField(); i++ {
rawURL := endpoints.Field(i).String()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("ResolveEndpoints(%q) field %d URL %q: %v", brand, i, rawURL, err)
}
if !IsPlatformEndpointHost(parsed.Hostname()) {
t.Errorf("ResolveEndpoints(%q) field %d host %q is missing from the platform transport boundary", brand, i, parsed.Hostname())
}
}
}
}
func TestIsPlatformEndpointURL_RequiresSecureStandardOrigin(t *testing.T) {
if IsPlatformEndpointURL(nil) {
t.Error("IsPlatformEndpointURL(nil) = true, want false")
}
uppercaseScheme := &url.URL{Scheme: "HTTPS", Host: "open.feishu.cn", Path: "/path"}
if !IsPlatformEndpointURL(uppercaseScheme) {
t.Error("IsPlatformEndpointURL() rejected uppercase HTTPS scheme")
}
for _, rawURL := range []string{
"http://open.feishu.cn/path",
"https://open.feishu.cn:8443/path",
"https://open.feishu.cn.example.com/path",
} {
candidate, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
if IsPlatformEndpointURL(candidate) {
t.Errorf("IsPlatformEndpointURL(%q) = true, want false", rawURL)
}
}
for _, rawURL := range []string{
"https://open.feishu.cn/path",
"https://open.feishu.cn:443/path",
"https://OPEN.FEISHU.CN/path",
} {
candidate, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
if !IsPlatformEndpointURL(candidate) {
t.Errorf("IsPlatformEndpointURL(%q) = false, want true", rawURL)
}
}
}

View File

@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
const agentName = "sample-agent"
t.Setenv(CliAgentName, agentName)
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q", got, agentName)
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
const agentName = "sample-agent"
t.Setenv(CliAgentName, " "+agentName+" ")
if got := AgentName(); got != agentName {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
}
}

View File

@@ -38,6 +38,10 @@ type Stub struct {
// matches after the first hit. Each match appends to CapturedBodies.
Reusable bool
// Optional (optional): when true, Verify does not require this stub to be
// matched. Useful for negative assertions via OnMatch.
Optional bool
// CapturedHeaders records the request headers of the matched request.
// Populated after RoundTrip matches this stub.
CapturedHeaders http.Header
@@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) {
if s.matched {
continue
}
if s.Optional {
continue
}
// Reusable stubs never set s.matched; treat any captured hit as a match.
if s.Reusable && len(s.CapturedBodies) > 0 {
continue

View File

@@ -45,6 +45,18 @@ Adding a new row requires approval from the matching CODEOWNERS or quality gate
`legacy-commands.txt` only covers hand-authored legacy commands. Generated OpenAPI service commands are intentionally excluded from `command-manifest.json`; they are included in `command-index.json` only so command references can be checked against the real CLI surface.
## Public Domain Allowlists
`internal/qualitygate/config/allowlists/public-domains.txt` contains supported public hostnames approved for Go source. `fixture-domains.txt` contains test-only hostnames used by `*_test.go`, the repository-root `tests/` directory, or any `testdata/` directory; fixture entries do not apply to production Go files or `skills/`.
Keep one lowercase exact hostname per line, sorted alphabetically. Wildcards, suffix rules, duplicates, schemes, ports, and paths are rejected; approving `larkoffice.com` does not approve its subdomains.
RFC 2606 reserves the `.test`, `.example`, `.invalid`, and `.localhost` namespaces plus the exact names `example.com`, `example.net`, and `example.org`. These names are accepted without an allowlist entry and must not be listed.
Every public entry needs a current non-fixture Go use, evidence that it is a supported public endpoint, and CODEOWNER approval. Other test-only hostnames belong in the fixture list. Tenant-specific, private-control-plane, and internal API hostnames are not eligible.
`lint/domaincontract` validates both lists and scans complete Go files. In CI, unapproved-host findings are limited to values whose expressions intersect added lines; list validation and unused-entry checks remain repository-wide. See `lint/README.md` for scanner semantics.
## Semantic Blocker Policy
The semantic reviewer can propose findings, but the local gatekeeper recomputes whether each finding is reproducible from `facts.json`. A finding blocks only when all of these are true:

View File

@@ -0,0 +1,24 @@
# Exact test-only hostnames. Keep sorted.
abc.feishu.cn
attacker.example.com
bytedance.feishu.cn
cdn.feishu.cn
evil.example.com
example.feishu.cn
example.larkoffice.com
example.larksuite.com
feishu.cn
feishu.doubao.com
gateway.docker.internal
host.containers.internal
host.docker.internal
host.lima.internal
lf3-static.bytednsdoc.com
meetings.feishu.cn
meetings.larksuite.com
p3-lark-file.byteimg.com
passport.feishu.cn
sample.feishu.cn
x.feishu.cn
xxx.feishu.cn
xxx.larksuite.com

View File

@@ -0,0 +1,18 @@
# Exact public hostnames. Keep sorted.
accounts.feishu.cn
accounts.larksuite.com
applink.feishu.cn
applink.larksuite.com
ark.ap-southeast.bytepluses.com
github.com
larkoffice.com
lf-larkemail.bytetos.com
mcp.feishu.cn
mcp.larksuite.com
open.feishu.cn
open.larksuite.com
registry.npmjs.org
registry.npmmirror.com
sf16-sg.tiktokcdn.com
www.feishu.cn
www.larksuite.com

View File

@@ -180,8 +180,8 @@ func saveCachedMerged(data []byte, cm CacheMeta) error {
// localVersion is sent as data_version query param for server-side version comparison.
// Returns (data, reg, err). A nil reg means the version is unchanged (not modified).
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
// Route through the shared proxy-plugin-aware transport so remote API
// definition fetches honor proxy plugin mode instead of bypassing it.
// Remote metadata is platform traffic and must honor both the shared proxy
// configuration and the registered platform transport extension.
client := transport.NewHTTPClient(fetchTimeout)
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
if err != nil {

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deviceinfo collects the platform hardware product model and the
// platform values used by device-related risk-control headers.
package riskcontrol
import (
"runtime"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/http/httpguts"
)
// OSType is the server-side risk-control operating-system enum.
type OSType string
// OS type enum values for X-Agent-Os-Type.
const (
OSTypeUnknown = "0"
OSTypeWindows = "1"
OSTypeLinux = "2"
OSTypeMacOS = "3"
)
const (
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
TerminalTypePC = "1"
// Unknown is used when the hardware product model cannot be collected.
Unknown = "Unknown"
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
// Device models are short identifiers; a larger value is treated as
// malformed rather than truncated so the header never misrepresents it.
deviceModelMaxBytes = 256
)
// Snapshot contains the deliberately small risk-control signal set.
// ProductModel is omitted when the platform cannot provide a safe value.
type Snapshot struct {
OSType OSType
ProductModel string
}
// Source supplies one immutable process-level snapshot.
type Source interface {
Snapshot() Snapshot
}
// HostSource lazily reads host signals once, after outbound policy authorizes
// the first request. Failed probes are cached and are not retried per request.
type HostSource struct {
once sync.Once
value Snapshot
readModel func() string
}
// NewHostSource creates the production host signal source.
func NewHostSource() *HostSource {
return &HostSource{readModel: readDeviceModel}
}
// Snapshot returns the cached host signal snapshot.
func (s *HostSource) Snapshot() Snapshot {
if s == nil {
return Snapshot{}
}
s.once.Do(func() {
readModel := s.readModel
if readModel == nil {
readModel = readDeviceModel
}
s.value = Snapshot{
OSType: GetOSType(OSName()),
ProductModel: normalizeDeviceModel(readModel()),
}
})
return s.value
}
// normalizeModel removes non-printable characters and returns a model only
// when the remaining text is safe to use as an HTTP header value. Input that
// cannot produce a valid model is rejected so Get can fall back to Unknown.
func normalizeDeviceModel(model string) string {
if !utf8.ValidString(model) {
return ""
}
model = strings.Map(func(r rune) rune {
switch {
case r == '\r' || r == '\n' || r == '\x00':
return -1
case unicode.IsSpace(r):
return ' '
case unicode.IsPrint(r):
return r
default:
return -1
}
}, model)
model = strings.Join(strings.Fields(model), " ")
if model == "" || len(model) > deviceModelMaxBytes {
return ""
}
if !httpguts.ValidHeaderFieldValue(model) {
return ""
}
return model
}
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
func GetOSType(osName string) OSType {
switch osName {
case "Windows":
return OSTypeWindows
case "Linux":
return OSTypeLinux
case "MacOS":
return OSTypeMacOS
default:
return OSTypeUnknown
}
}
// OSName returns the platform name used by GetOSType.
func OSName() string {
switch runtime.GOOS {
case "darwin":
return "MacOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return runtime.GOOS
}
}

View File

@@ -0,0 +1,27 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/unix"
// readDeviceModel reads the current product key first and falls back to the
// legacy model key. Trying both keys is more robust than branching on a macOS
// version because virtualized or restricted environments may expose only one.
func readDeviceModel() string {
return readDarwinDeviceModel(unix.Sysctl)
}
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
for _, key := range [...]string{"hw.product", "hw.model"} {
model, err := readSysctl(key)
if err == nil {
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
}
return ""
}

View File

@@ -0,0 +1,48 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
t.Run("product available", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.product" {
return "Mac16,1", nil
}
return "", errors.New("unexpected fallback")
})
if got != "Mac16,1" {
t.Fatalf("model = %q, want %q", got, "Mac16,1")
}
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
t.Run("product unavailable", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.model" {
return "MacBookPro18,3", nil
}
return "", errors.New("not available")
})
if got != "MacBookPro18,3" {
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
}
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
}

View File

@@ -0,0 +1,17 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
// values vary widely and can expose the host or virtualization platform when
// the CLI runs in a container or sandbox.
func readDeviceModel() string {
return readLinuxDeviceModel()
}
func readLinuxDeviceModel() string {
return "linux"
}

View File

@@ -0,0 +1,20 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "testing"
func TestReadDeviceModelReturnsLinux(t *testing.T) {
if got := readDeviceModel(); got != "linux" {
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
}
}
func TestReadLinuxDeviceModel(t *testing.T) {
if got := readLinuxDeviceModel(); got != "linux" {
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
}
}

View File

@@ -0,0 +1,11 @@
//go:build !darwin && !windows && !linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns an empty model on unsupported platforms.
func readDeviceModel() string {
return ""
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"unicode"
)
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return " MacBookPro18,3\n"
}}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceCachesEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return ""
}}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
var calls atomic.Int32
s := &HostSource{readModel: func() string {
calls.Add(1)
return "ThinkPad X1 Carbon"
}}
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
snapshot := s.Snapshot()
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("read called %d times, want 1", got)
}
}
func TestNormalizeDeviceModel(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
{name: "rejects empty", model: " \t\r\n"},
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
{name: "normalizes tab", model: "model\tname", want: "model name"},
{name: "removes NUL", model: "model\x00name", want: "modelname"},
{name: "removes control character", model: "model\x1fname", want: "modelname"},
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDeviceModel(tt.model); got != tt.want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
for value := 0; value <= 0x7f; value++ {
if value >= 0x20 && value < 0x7f {
continue
}
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
model := "model" + string(rune(value)) + "name"
want := "modelname"
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
want = "model name"
}
if got := normalizeDeviceModel(model); got != want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
}
})
}
}
func TestGetOSType(t *testing.T) {
tests := []struct {
name string
want OSType
}{
{name: "Windows", want: OSTypeWindows},
{name: "Linux", want: OSTypeLinux},
{name: "MacOS", want: OSTypeMacOS},
{name: "unknown", want: OSTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetOSType(tt.name); got != tt.want {
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,44 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/windows/registry"
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
var systemInfoRegistryPaths = [...]string{
`HARDWARE\DESCRIPTION\System\BIOS`,
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
`SYSTEM\HardwareConfig\Current`,
}
// readDeviceModel returns the first product name found in the Windows registry.
func readDeviceModel() string {
return readWindowsDeviceModel(readWindowsRegistryModel)
}
func readWindowsRegistryModel(path string) (string, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
if err != nil {
return "", err
}
defer key.Close()
model, _, err := key.GetStringValue("SystemProductName")
return model, err
}
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
for _, path := range systemInfoRegistryPaths {
model, err := readRegistryModel(path)
if err != nil {
continue
}
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
return ""
}

View File

@@ -0,0 +1,78 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadWindowsDeviceModelFallback(t *testing.T) {
readError := errors.New("registry read failed")
tests := []struct {
name string
values map[string]string
errors map[string]error
want string
wantPaths []string
}{
{
name: "first path wins",
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
want: "Surface Laptop",
wantPaths: []string{systemInfoRegistryPaths[0]},
},
{
name: "read failure falls back",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
},
values: map[string]string{
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
},
want: "ThinkPad X1 Carbon",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "empty normalized value falls back",
values: map[string]string{
systemInfoRegistryPaths[0]: " \r\n\x00",
systemInfoRegistryPaths[1]: "Latitude 7450",
},
want: "Latitude 7450",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "all paths fail",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
systemInfoRegistryPaths[1]: readError,
systemInfoRegistryPaths[2]: readError,
},
wantPaths: systemInfoRegistryPaths[:],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var paths []string
got := readWindowsDeviceModel(func(path string) (string, error) {
paths = append(paths, path)
if err := tt.errors[path]; err != nil {
return "", err
}
return tt.values[path], nil
})
if got != tt.want {
t.Fatalf("model = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(paths, tt.wantPaths) {
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
}
})
}
}

View File

@@ -0,0 +1,162 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
var _ internaltransport.RoundTripperDecorator = (*Transport)(nil)
const (
HeaderProductModel = "X-Agent-Device-Type"
HeaderOSType = "X-Agent-Os-Type"
)
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
// Transport is the feature's final outbound boundary. It removes caller- or
// extension-supplied signal headers first and writes trusted values only after
// authorizing an official SDK origin and authentication state.
type Transport struct {
next http.RoundTripper
source Source
}
// NewTransport creates the final SDK outbound policy boundary. A nil source
// disables collection and injection while preserving restricted-header
// stripping for opt-out and extension-credential requests.
func NewTransport(next http.RoundTripper, source Source) *Transport {
if next == nil {
next = internaltransport.Fallback()
}
return &Transport{
next: next,
source: source,
}
}
// BaseRoundTripper exposes the network transport so policy routers can clone
// and rebuild the complete decorator graph without dropping risk control.
func (t *Transport) BaseRoundTripper() http.RoundTripper {
if t == nil || t.next == nil {
return internaltransport.Fallback()
}
return t.next
}
// WithBaseRoundTripper returns an equivalent risk-control boundary over base.
func (t *Transport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
if t == nil {
return NewTransport(base, nil)
}
cloned := *t
if base == nil {
base = internaltransport.Fallback()
}
cloned.next = base
return &cloned
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if req.Header == nil {
req.Header = make(http.Header)
}
stripRestrictedHeaders(req.Header)
if t.source != nil && t.routeAllowsSignals(req) {
snapshot := t.source.Snapshot()
if isSupportedOSType(snapshot.OSType) {
req.Header.Set(HeaderOSType, string(snapshot.OSType))
}
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
req.Header.Set(HeaderProductModel, model)
}
}
return t.next.RoundTrip(req)
}
func isSupportedOSType(value OSType) bool {
switch value {
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
return true
default:
return false
}
}
func stripRestrictedHeaders(header http.Header) {
for name := range header {
for _, restricted := range restrictedHeaders {
if strings.EqualFold(name, restricted) {
delete(header, name)
break
}
}
}
}
type origin struct {
scheme string
host string
port string
}
var officialFeishuOrigins = [...]origin{
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
}
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
if req == nil || req.URL == nil {
return false
}
return isOfficialFeishuOrigin(originOf(req.URL))
}
func originOf(value *url.URL) origin {
if value == nil {
return origin{}
}
scheme := strings.ToLower(value.Scheme)
port := value.Port()
if port == "" {
switch scheme {
case "https":
port = "443"
case "http":
port = "80"
}
}
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
}
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
endpoint, err := url.Parse(endpointURL)
if err != nil {
return origin{}
}
return originOf(endpoint)
}
func isOfficialFeishuOrigin(candidate origin) bool {
if candidate.scheme != "https" || candidate.port != "443" {
return false
}
for _, official := range officialFeishuOrigins {
if candidate == official {
return true
}
}
return false
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"strings"
"sync/atomic"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type countingSource struct {
calls atomic.Int32
}
func (s *countingSource) Snapshot() Snapshot {
s.calls.Add(1)
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
}
type staticSource Snapshot
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
tests := []struct {
name string
requestURL string
authorization string
wantSignals bool
}{
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := &countingSource{}
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", test.authorization)
req.Header.Set(HeaderOSType, "caller-value")
req.Header.Set(HeaderProductModel, "caller-value")
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
resp, err := NewTransport(base, source).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
gotSignals := received.Get(HeaderOSType) != ""
if gotSignals != test.wantSignals {
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
}
wantCalls := int32(0)
if test.wantSignals {
wantCalls = 1
}
if got := source.calls.Load(); got != wantCalls {
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
}
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
t.Fatalf("caller request OS header = %q, want unchanged", got)
}
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
t.Fatalf("caller request product-model header = %q, want unchanged", got)
}
if !test.wantSignals {
for name := range received {
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
t.Fatalf("restricted header leaked as %q", name)
}
}
}
})
}
}
func TestTransportValidatesSourceSnapshot(t *testing.T) {
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := NewTransport(base, staticSource{
OSType: OSType("unsupported"),
ProductModel: "unsafe\nvalue",
}).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
t.Fatalf("no signals collected: %v", received)
}
}

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: MIT
// Package transport owns how the CLI assembles its outbound HTTP transport: the
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
// shared base RoundTripper (Shared/Fallback and the HTTP client constructors), the LARK_CLI_NO_PROXY
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
//
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback

View File

@@ -0,0 +1,258 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"context"
"net/http"
"net/url"
"strings"
"sync"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/core"
)
type requestMatcher func(*http.Request) bool
type transportPolicyBuilder func(http.RoundTripper) http.RoundTripper
type sdkBootstrapRedirectContextKey struct{}
var (
// larkws pins this client during package initialization.
sdkBootstrapHTTPClient = http.DefaultClient
installDefaultClientMu sync.Mutex
)
// sdkBootstrapTransport applies the platform HTTP policy only to dependency
// bootstrap requests selected by match. Unmatched DefaultClient traffic is
// delegated directly to the previous transport.
type sdkBootstrapTransport struct {
base http.RoundTripper
match requestMatcher
buildPlatformPolicy transportPolicyBuilder
policyMu sync.RWMutex
}
func (t *sdkBootstrapTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if !t.isBootstrapRequest(req) {
return t.fallbackTransport().RoundTrip(req)
}
base := t.base
if base == nil {
// Resolve Shared lazily so bridge installation never initializes
// workspace-scoped proxy state ahead of workspace selection.
base = Shared()
}
buildPlatformPolicy := t.platformPolicyBuilder()
if buildPlatformPolicy == nil {
return nil, errs.NewInternalError(
errs.SubtypeUnknown,
"SDK bootstrap transport policy is not configured",
)
}
base = buildPlatformPolicy(base)
if base == nil {
return nil, errs.NewInternalError(
errs.SubtypeUnknown,
"SDK bootstrap transport policy returned a nil transport",
)
}
// Resolve extensions per hop so redirects retain platform policy.
extended := WrapWithExtensionForClass(base, exttransport.RequestClassPlatform)
guarded := &sameOriginRedirectTransport{base: extended}
return guarded.RoundTrip(req)
}
func (t *sdkBootstrapTransport) platformPolicyBuilder() transportPolicyBuilder {
t.policyMu.RLock()
defer t.policyMu.RUnlock()
return t.buildPlatformPolicy
}
func (t *sdkBootstrapTransport) setPlatformPolicyBuilder(build transportPolicyBuilder) {
t.policyMu.Lock()
t.buildPlatformPolicy = build
t.policyMu.Unlock()
}
func (t *sdkBootstrapTransport) isBootstrapRequest(req *http.Request) bool {
if req == nil {
return false
}
if _, redirected := req.Context().Value(sdkBootstrapRedirectContextKey{}).(struct{}); redirected {
return true
}
return t.match != nil && t.match(req)
}
func (t *sdkBootstrapTransport) fallbackTransport() http.RoundTripper {
if t.base != nil {
return t.base
}
// Preserve net/http's dynamic nil-Transport fallback.
return http.DefaultTransport
}
// sameOriginRedirectTransport rejects redirects before net/http can replay a
// bootstrap request to a different logical origin.
type sameOriginRedirectTransport struct {
base http.RoundTripper
}
func (t *sameOriginRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base.RoundTrip(req)
if err != nil || resp == nil || !isFollowedRedirect(resp.StatusCode) {
return resp, err
}
location := resp.Header.Get("Location")
if location == "" {
return resp, nil
}
target, parseErr := req.URL.Parse(location)
if parseErr != nil {
if resp.Body != nil {
_ = resp.Body.Close()
}
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"platform request returned an invalid redirect location: %v",
parseErr,
).WithCause(parseErr)
}
if sameOrigin(req.URL, target) {
return resp, nil
}
if resp.Body != nil {
_ = resp.Body.Close()
}
return nil, errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"platform bootstrap blocked cross-origin redirect from %q to %q",
originName(req.URL),
originName(target),
)
}
// sdkBootstrapRedirectPolicy preserves the prior hook and marks each redirect hop.
func sdkBootstrapRedirectPolicy(
match requestMatcher,
previous func(*http.Request, []*http.Request) error,
) func(*http.Request, []*http.Request) error {
return func(req *http.Request, via []*http.Request) error {
if previous != nil {
if err := previous(req, via); err != nil {
return err
}
} else if len(via) >= 10 {
// Retain net/http's default redirect limit.
return errs.NewNetworkError(
errs.SubtypeNetworkTransport,
"stopped after 10 redirects",
)
}
if req == nil || len(via) == 0 || match == nil || !match(via[0]) {
return nil
}
ctx := context.WithValue(req.Context(), sdkBootstrapRedirectContextKey{}, struct{}{})
*req = *req.WithContext(ctx)
return nil
}
}
func originName(candidate *url.URL) string {
if candidate == nil {
return ""
}
return strings.ToLower(candidate.Scheme) + "://" + candidate.Host
}
func sameOrigin(left, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) &&
strings.EqualFold(left.Hostname(), right.Hostname()) &&
originPort(left) == originPort(right)
}
func originPort(candidate *url.URL) string {
if port := candidate.Port(); port != "" {
return port
}
switch strings.ToLower(candidate.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
func isFollowedRedirect(status int) bool {
switch status {
case http.StatusMovedPermanently,
http.StatusFound,
http.StatusSeeOther,
http.StatusTemporaryRedirect,
http.StatusPermanentRedirect:
return true
default:
return false
}
}
// InstallSDKTransportBridge wraps larkws's captured HTTP bootstrap client. All
// requests through that client hit the bridge, but only matched bootstrap
// traffic uses platform policy. The SDK owns the subsequent WebSocket dial,
// which does not use this net/http transport.
func InstallSDKTransportBridge(buildPlatformPolicy func(http.RoundTripper) http.RoundTripper) {
installDefaultClientMu.Lock()
defer installDefaultClientMu.Unlock()
installSDKTransportBridge(
sdkBootstrapHTTPClient,
isSDKWebSocketBootstrapRequest,
buildPlatformPolicy,
)
}
func isSDKWebSocketBootstrapRequest(req *http.Request) bool {
return req != nil &&
req.Method == http.MethodPost &&
core.IsPlatformEndpointURL(req.URL) &&
req.URL.Path == larkws.GenEndpointUri
}
func installSDKTransportBridge(
client *http.Client,
match requestMatcher,
buildPlatformPolicy transportPolicyBuilder,
) {
if client == nil {
return
}
if existing, ok := client.Transport.(*sdkBootstrapTransport); ok {
existing.setPlatformPolicyBuilder(buildPlatformPolicy)
return
}
base := client.Transport
previousRedirect := client.CheckRedirect
client.Transport = &sdkBootstrapTransport{
base: base,
match: match,
buildPlatformPolicy: buildPlatformPolicy,
}
client.CheckRedirect = sdkBootstrapRedirectPolicy(match, previousRedirect)
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"context"
"net/http"
exttransport "github.com/larksuite/cli/extension/transport"
)
var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil)
type resolvedExtension struct {
provider exttransport.Provider
interceptor exttransport.Interceptor
}
func resolveExtension() *resolvedExtension {
p := exttransport.GetProvider()
if p == nil {
return nil
}
interceptor := p.ResolveInterceptor(context.Background())
if interceptor == nil {
return nil
}
return &resolvedExtension{provider: p, interceptor: interceptor}
}
func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.RequestClass, enforceScope bool) http.RoundTripper {
if base == nil {
base = Shared()
}
if e == nil {
return base
}
if enforceScope {
if scoped, ok := e.provider.(exttransport.ScopedProvider); ok && !scoped.SupportsRequestClass(class) {
return base
}
}
return &ExtensionMiddleware{Base: base, Ext: e.interceptor, ExtName: e.provider.Name()}
}
// ExtensionMiddleware wraps the built-in transport chain with extension
// pre/post hooks. The built-in chain always executes unless an
// exttransport.AbortableInterceptor rejects the request.
//
// The original request context is restored after the pre hook to prevent an
// extension from replacing cancellation, deadlines, or built-in values. The
// request is cloned so URL and header mutations do not alter the caller's
// request object. The body remains shared; interceptors that consume it must
// restore it before returning.
type ExtensionMiddleware struct {
Base http.RoundTripper
Ext exttransport.Interceptor
ExtName string
}
// BaseRoundTripper returns the wrapped built-in transport chain.
func (m *ExtensionMiddleware) BaseRoundTripper() http.RoundTripper {
if m.Base == nil {
return Shared()
}
return m.Base
}
// WithBaseRoundTripper clones the middleware over base.
func (m *ExtensionMiddleware) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *m
cloned.Base = base
return &cloned
}
// RoundTrip invokes the extension pre hook, the wrapped transport, and then
// the optional post hook. Abortable interceptors can stop the request before
// the wrapped transport is called.
func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
origCtx := req.Context()
req = req.Clone(origCtx)
var (
post func(*http.Response, error)
abortErr error
)
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
post, abortErr = a.PreRoundTripE(req)
} else {
post = m.Ext.PreRoundTrip(req)
}
if abortErr != nil {
if post != nil {
post(nil, abortErr)
}
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortErr}
}
req = req.WithContext(origCtx)
resp, err := m.BaseRoundTripper().RoundTrip(req)
if post != nil {
post(resp, err)
}
return resp, err
}
// WrapWithExtension wraps base with the currently registered transport
// extension. With no registered provider or no resolved interceptor, base is
// returned unchanged.
func WrapWithExtension(base http.RoundTripper) http.RoundTripper {
return resolveExtension().wrap(base, "", false)
}
// WrapWithExtensionForClass wraps base only when the registered provider
// supports class. Providers without the optional ScopedProvider interface keep
// their historical all-request behavior.
func WrapWithExtensionForClass(base http.RoundTripper, class exttransport.RequestClass) http.RoundTripper {
return resolveExtension().wrap(base, class, true)
}

View File

@@ -0,0 +1,924 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync/atomic"
"testing"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
)
type testProvider struct {
interceptor exttransport.Interceptor
resolveCalls *int
}
func (p testProvider) Name() string { return "test-provider" }
func (p testProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
if p.resolveCalls != nil {
*p.resolveCalls++
}
return p.interceptor
}
type scopedTestProvider struct {
testProvider
supported exttransport.RequestClass
}
func (p scopedTestProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
return class == p.supported
}
type testHeaderInterceptor struct {
calls int
}
func (i *testHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
i.calls++
req.Header.Set("X-Test-Platform", "routed")
return nil
}
type abortingTestInterceptor struct {
reason error
post func(*http.Response, error)
}
func (i *abortingTestInterceptor) PreRoundTrip(*http.Request) func(*http.Response, error) {
panic("PreRoundTrip called for abortable interceptor")
}
func (i *abortingTestInterceptor) PreRoundTripE(*http.Request) (func(*http.Response, error), error) {
return i.post, i.reason
}
func TestLegacyProviderKeepsAllRequestBehavior(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
interceptor := &testHeaderInterceptor{}
previousProvider := exttransport.GetProvider()
exttransport.Register(testProvider{interceptor: interceptor})
t.Cleanup(func() { exttransport.Register(previousProvider) })
received := make(chan string, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
received <- req.Header.Get("X-Test-Platform")
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
for _, client := range []*http.Client{
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
NewExternalHTTPClient(0),
} {
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
if got := <-received; got != "routed" {
t.Fatalf("platform request header = %q, want routed", got)
}
if got := <-received; got != "routed" {
t.Fatalf("external request header = %q, want routed for legacy provider", got)
}
if interceptor.calls != 2 {
t.Fatalf("extension calls = %d, want exactly 2", interceptor.calls)
}
}
func TestScopedProviderOnlyRunsForSupportedRequestClass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
interceptor := &testHeaderInterceptor{}
previousProvider := exttransport.GetProvider()
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
received := make(chan string, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
received <- req.Header.Get("X-Test-Platform")
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
clients := []*http.Client{
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
NewExternalHTTPClient(0),
}
for _, client := range clients {
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
if got := <-received; got != "routed" {
t.Fatalf("platform request header = %q, want routed", got)
}
if got := <-received; got != "" {
t.Fatalf("external request received scoped provider header %q", got)
}
if interceptor.calls != 1 {
t.Fatalf("extension calls = %d, want exactly 1", interceptor.calls)
}
}
func TestHTTPPolicyRouterResolvesProviderOnce(t *testing.T) {
resolveCalls := 0
previousProvider := exttransport.GetProvider()
exttransport.Register(testProvider{
interceptor: &testHeaderInterceptor{},
resolveCalls: &resolveCalls,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
})
_ = NewHTTPPolicyRouter(base, base)
if resolveCalls != 1 {
t.Fatalf("ResolveInterceptor() calls = %d, want 1 per router", resolveCalls)
}
}
func TestSDKBootstrapBridgeBlocksCrossOriginRedirectAfterSameOriginHop(t *testing.T) {
var externalCalls atomic.Int32
var relayBody string
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "external.example" {
externalCalls.Add(1)
return noContentResponse(req), nil
}
switch req.URL.Path {
case "/bootstrap":
return redirectResponse(req, http.StatusTemporaryRedirect, "/relay"), nil
case "/relay":
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
relayBody = string(body)
return redirectResponse(
req,
http.StatusPermanentRedirect,
"https://external.example/target",
), nil
default:
return noContentResponse(req), nil
}
})
client := &http.Client{Transport: base}
installSDKTransportBridge(client, func(req *http.Request) bool {
return req.URL != nil && req.URL.Path == "/bootstrap"
}, identityTransportPolicy)
const secret = "app_secret=secret"
req, err := http.NewRequest(
http.MethodPost,
"https://platform.example/bootstrap",
strings.NewReader(secret),
)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Do() error = %v, want cross-origin redirect rejection", err)
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryPolicy ||
problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if relayBody != secret {
t.Fatalf("same-origin relay body = %q, want %q", relayBody, secret)
}
if got := externalCalls.Load(); got != 0 {
t.Fatalf("cross-origin target calls = %d, want 0", got)
}
}
func TestSDKBootstrapRedirectGuardClassifiesInvalidLocation(t *testing.T) {
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
return redirectResponse(req, http.StatusFound, "%"), nil
})
client := &http.Client{Transport: &sameOriginRedirectTransport{base: base}}
resp, err := client.Get("https://platform.example/bootstrap")
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "invalid redirect location") {
t.Fatalf("Do() error = %v, want invalid redirect rejection", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Do() problem = %#v, %v; want internal/invalid_response", problem, ok)
}
}
type redirectPolicyInterceptor struct {
calls int
}
func (i *redirectPolicyInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
i.calls++
req.Header.Set("X-Extension-Hop", strconv.Itoa(i.calls))
req.Header.Set("X-Reserved", "extension")
return nil
}
func TestSDKBootstrapBridgeRetainsPoliciesAcrossSameOriginRedirect(t *testing.T) {
previousProvider := exttransport.GetProvider()
interceptor := &redirectPolicyInterceptor{}
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
var finalHeaders http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/bootstrap":
return redirectResponse(req, http.StatusTemporaryRedirect, "/next"), nil
case "/next":
finalHeaders = req.Header.Clone()
return noContentResponse(req), nil
default:
return noContentResponse(req), nil
}
})
builtInCalls := 0
client := &http.Client{Transport: base}
installSDKTransportBridge(
client,
func(req *http.Request) bool {
return req.URL != nil && req.URL.Path == "/bootstrap"
},
func(base http.RoundTripper) http.RoundTripper {
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
builtInCalls++
req = req.Clone(req.Context())
req.Header.Set("X-Builtin-Hop", strconv.Itoa(builtInCalls))
req.Header.Set("X-Reserved", "trusted")
return base.RoundTrip(req)
})
},
)
req, err := http.NewRequest(
http.MethodPost,
"https://platform.example/bootstrap",
strings.NewReader("body"),
)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if finalHeaders == nil {
t.Fatal("same-origin redirect target was not called")
}
if interceptor.calls != 2 {
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
}
if builtInCalls != 2 {
t.Fatalf("built-in policy calls = %d, want 2", builtInCalls)
}
if got := finalHeaders.Get("X-Extension-Hop"); got != "2" {
t.Fatalf("final X-Extension-Hop = %q, want 2", got)
}
if got := finalHeaders.Get("X-Builtin-Hop"); got != "2" {
t.Fatalf("final X-Builtin-Hop = %q, want 2", got)
}
if got := finalHeaders.Get("X-Reserved"); got != "trusted" {
t.Fatalf("final X-Reserved = %q, want trusted built-in value", got)
}
}
type redirectRewriteInterceptor struct {
target *url.URL
postLocation string
calls int
}
func (i *redirectRewriteInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
i.calls++
req.URL.Scheme = i.target.Scheme
req.URL.Host = i.target.Host
if i.postLocation == "" {
return nil
}
return func(resp *http.Response, err error) {
if err == nil && resp != nil && isFollowedRedirect(resp.StatusCode) {
resp.Header.Set("Location", i.postLocation)
}
}
}
func TestSDKBootstrapRedirectGuardUsesLogicalURLAfterExtensionRewrite(t *testing.T) {
sidecarURL, err := url.Parse("https://sidecar.example")
if err != nil {
t.Fatal(err)
}
sidecarCalls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host != sidecarURL.Host {
t.Fatalf("network host = %q, want extension target %q", req.URL.Host, sidecarURL.Host)
}
sidecarCalls++
switch req.URL.Path {
case "/bootstrap":
return redirectResponse(
req,
http.StatusTemporaryRedirect,
"https://platform.example/next",
), nil
case "/next":
return noContentResponse(req), nil
default:
return noContentResponse(req), nil
}
})
previousProvider := exttransport.GetProvider()
interceptor := &redirectRewriteInterceptor{target: sidecarURL}
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
client := &http.Client{Transport: base}
installSDKTransportBridge(client, func(req *http.Request) bool {
return req.URL != nil && req.URL.Path == "/bootstrap"
}, identityTransportPolicy)
req, err := http.NewRequest(
http.MethodPost,
"https://platform.example/bootstrap",
strings.NewReader("body"),
)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if sidecarCalls != 2 {
t.Fatalf("sidecar calls = %d, want 2", sidecarCalls)
}
if interceptor.calls != 2 {
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
}
}
func TestSDKBootstrapRedirectGuardChecksLocationAfterExtensionPostHook(t *testing.T) {
var externalCalls atomic.Int32
sidecarURL, err := url.Parse("https://sidecar.example")
if err != nil {
t.Fatal(err)
}
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "external.example" {
externalCalls.Add(1)
return noContentResponse(req), nil
}
return redirectResponse(
req,
http.StatusTemporaryRedirect,
"https://platform.example/next",
), nil
})
previousProvider := exttransport.GetProvider()
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: &redirectRewriteInterceptor{
target: sidecarURL,
postLocation: "https://external.example/target",
}},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
client := &http.Client{Transport: base}
installSDKTransportBridge(client, func(req *http.Request) bool {
return req.URL != nil && req.URL.Path == "/bootstrap"
}, identityTransportPolicy)
req, err := http.NewRequest(
http.MethodPost,
"https://platform.example/bootstrap",
strings.NewReader("secret"),
)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Do() error = %v, want post-hook Location rejection", err)
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryPolicy ||
problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if got := externalCalls.Load(); got != 0 {
t.Fatalf("post-hook redirect target calls = %d, want 0", got)
}
}
func TestSameOriginNormalizesDefaultPort(t *testing.T) {
left, err := url.Parse("https://platform.example/bootstrap")
if err != nil {
t.Fatal(err)
}
right, err := url.Parse("https://platform.example:443/next")
if err != nil {
t.Fatal(err)
}
if !sameOrigin(left, right) {
t.Fatal("sameOrigin() = false for equivalent default HTTPS ports")
}
}
func TestDefaultClientBridgeCoversWebSocketSDKBootstrap(t *testing.T) {
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "1")
previousProvider := exttransport.GetProvider()
interceptor := &testHeaderInterceptor{}
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
seenHeader := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
seenHeader <- req.Header.Get("X-Test-Platform")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"code":400,"msg":"stop after bootstrap"}`)
}))
t.Cleanup(server.Close)
installSDKTransportBridge(sdkBootstrapHTTPClient, func(req *http.Request) bool {
return req.URL != nil && req.URL.Host == strings.TrimPrefix(server.URL, "http://")
}, identityTransportPolicy)
client := larkws.NewClient(
"test-app",
"test-secret",
larkws.WithDomain(server.URL),
larkws.WithAutoReconnect(false),
)
if err := client.Start(context.Background()); err == nil {
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
}
if got := <-seenHeader; got != "routed" {
t.Fatalf("WebSocket bootstrap header = %q, want routed", got)
}
if interceptor.calls != 1 {
t.Fatalf("extension calls = %d, want exactly 1 bootstrap call", interceptor.calls)
}
}
func TestSDKTransportBridgeUsesPinnedClientAfterGlobalReplacement(t *testing.T) {
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
oldDefaultClient := http.DefaultClient
t.Cleanup(func() { http.DefaultClient = oldDefaultClient })
var pinnedCalls atomic.Int32
pinnedHeader := make(chan string, 1)
sdkBootstrapHTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
pinnedCalls.Add(1)
pinnedHeader <- req.Header.Get("X-Pinned-Bridge")
return &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"code":400,"msg":"stop"}`)),
Request: req,
}, nil
})
sdkBootstrapHTTPClient.CheckRedirect = nil
var replacementCalls atomic.Int32
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
replacementCalls.Add(1)
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: http.NoBody,
Request: req,
}, nil
})}
InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("X-Pinned-Bridge", "routed")
return base.RoundTrip(req)
})
})
client := larkws.NewClient(
"test-app",
"test-secret",
larkws.WithAutoReconnect(false),
)
if err := client.Start(context.Background()); err == nil {
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
}
if got := pinnedCalls.Load(); got != 1 {
t.Fatalf("SDK-pinned client calls = %d, want 1", got)
}
if got := <-pinnedHeader; got != "routed" {
t.Fatalf("SDK-pinned bridge header = %q, want routed", got)
}
if got := replacementCalls.Load(); got != 0 {
t.Fatalf("replacement DefaultClient calls = %d, want 0", got)
}
}
func TestSDKWebSocketBootstrapMatcherIsNarrow(t *testing.T) {
tests := []struct {
name string
method string
url string
want bool
}{
{
name: "platform bootstrap",
method: http.MethodPost,
url: "https://open.feishu.cn/callback/ws/endpoint",
want: true,
},
{
name: "other platform path",
method: http.MethodPost,
url: "https://open.feishu.cn/open-apis/test",
},
{
name: "wrong bootstrap method",
method: http.MethodGet,
url: "https://open.feishu.cn/callback/ws/endpoint",
},
{
name: "external lookalike",
method: http.MethodPost,
url: "https://external.example/callback/ws/endpoint",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest(tt.method, tt.url, nil)
if err != nil {
t.Fatal(err)
}
if got := isSDKWebSocketBootstrapRequest(req); got != tt.want {
t.Fatalf("isSDKWebSocketBootstrapRequest() = %v, want %v", got, tt.want)
}
})
}
}
func TestSDKTransportBridgeLeavesOtherPlatformPathsUntouched(t *testing.T) {
previousProvider := exttransport.GetProvider()
interceptor := &testHeaderInterceptor{}
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
baseCalls := 0
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
baseCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
})}
installSDKTransportBridge(client, isSDKWebSocketBootstrapRequest, nil)
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if baseCalls != 1 {
t.Fatalf("base calls = %d, want 1", baseCalls)
}
if interceptor.calls != 0 {
t.Fatalf("extension calls = %d, want 0 for unmatched DefaultClient traffic", interceptor.calls)
}
}
func TestSDKTransportBridgeNilBasePreservesDefaultTransportForUnmatchedRequest(t *testing.T) {
oldDefaultTransport := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = oldDefaultTransport })
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "1")
var firstCalls atomic.Int32
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
firstCalls.Add(1)
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
})
client := &http.Client{}
installSDKTransportBridge(client, func(*http.Request) bool { return false }, nil)
var currentCalls atomic.Int32
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
currentCalls.Add(1)
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
})
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:1/unmatched", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if got := firstCalls.Load(); got != 0 {
t.Fatalf("install-time DefaultTransport calls = %d, want 0", got)
}
if got := currentCalls.Load(); got != 1 {
t.Fatalf("request-time DefaultTransport calls = %d, want 1", got)
}
}
func TestSDKTransportBridgeUpdatesPlatformPolicy(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return noContentResponse(req), nil
})}
var firstCalls, secondCalls int
build := func(calls *int) transportPolicyBuilder {
return func(base http.RoundTripper) http.RoundTripper {
*calls++
return base
}
}
match := func(*http.Request) bool { return true }
installSDKTransportBridge(client, match, build(&firstCalls))
installSDKTransportBridge(client, match, build(&secondCalls))
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if firstCalls != 0 || secondCalls != 1 {
t.Fatalf("policy calls = (%d, %d), want (0, 1)", firstCalls, secondCalls)
}
}
func TestSDKBootstrapTransportFailsClosedWithoutPlatformPolicy(t *testing.T) {
var baseCalls atomic.Int32
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
baseCalls.Add(1)
return &http.Response{
StatusCode: http.StatusNoContent,
Body: http.NoBody,
Request: req,
}, nil
})}
installSDKTransportBridge(client, func(*http.Request) bool { return true }, nil)
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "policy is not configured") {
t.Fatalf("Do() error = %v, want missing policy rejection", err)
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryInternal ||
problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
}
if got := baseCalls.Load(); got != 0 {
t.Fatalf("base transport calls = %d, want 0", got)
}
}
func TestSDKBootstrapTransportFailsClosedForNilPlatformTransport(t *testing.T) {
var baseCalls atomic.Int32
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
baseCalls.Add(1)
return &http.Response{
StatusCode: http.StatusNoContent,
Body: http.NoBody,
Request: req,
}, nil
})}
installSDKTransportBridge(client, func(*http.Request) bool { return true }, func(http.RoundTripper) http.RoundTripper {
return nil
})
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "nil transport") {
t.Fatalf("Do() error = %v, want nil policy transport rejection", err)
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryInternal ||
problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
}
if got := baseCalls.Load(); got != 0 {
t.Fatalf("base transport calls = %d, want 0", got)
}
}
func TestSDKBootstrapRedirectPolicyRetainsDefaultLimit(t *testing.T) {
policy := sdkBootstrapRedirectPolicy(nil, nil)
via := make([]*http.Request, 10)
err := policy(&http.Request{}, via)
if err == nil {
t.Fatal("redirect policy error = nil after 10 redirects")
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryNetwork ||
problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("redirect problem = %#v, %v; want network/transport", problem, ok)
}
}
func TestExtensionMiddlewareUsesFallbackWhenBaseIsNil(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
previous := http.DefaultTransport
var calls atomic.Int32
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls.Add(1)
return &http.Response{
StatusCode: http.StatusNoContent,
Body: http.NoBody,
Request: req,
}, nil
})
t.Cleanup(func() { http.DefaultTransport = previous })
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := (&ExtensionMiddleware{Ext: &testHeaderInterceptor{}}).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if got := calls.Load(); got != 1 {
t.Fatalf("fallback transport calls = %d, want 1", got)
}
}
func TestExtensionMiddlewareAbortsBeforeBase(t *testing.T) {
reason := errors.New("blocked")
baseCalled := false
postCalled := false
interceptor := &abortingTestInterceptor{
reason: reason,
post: func(resp *http.Response, err error) {
postCalled = true
if resp != nil || err != reason {
t.Errorf("post arguments = (%v, %v), want (nil, reason)", resp, err)
}
},
}
middleware := &ExtensionMiddleware{
Base: roundTripFunc(func(*http.Request) (*http.Response, error) {
baseCalled = true
return nil, nil
}),
Ext: interceptor,
ExtName: "test-provider",
}
resp, err := middleware.RoundTrip(httptest.NewRequest(http.MethodGet, "https://example.com", nil))
if resp != nil {
t.Fatalf("response = %v, want nil", resp)
}
var abortErr *exttransport.AbortError
if !errors.As(err, &abortErr) {
t.Fatalf("error = %T, want *transport.AbortError", err)
}
if abortErr.Extension != "test-provider" || abortErr.Reason != reason {
t.Fatalf("abort error = %#v, want provider and reason", abortErr)
}
if baseCalled {
t.Fatal("base transport was called")
}
if !postCalled {
t.Fatal("post hook was not called")
}
}
func preserveHTTPClientState(t *testing.T, client *http.Client) {
t.Helper()
oldTransport := client.Transport
oldCheckRedirect := client.CheckRedirect
t.Cleanup(func() {
client.Transport = oldTransport
client.CheckRedirect = oldCheckRedirect
})
}
func identityTransportPolicy(base http.RoundTripper) http.RoundTripper {
return base
}
func redirectResponse(req *http.Request, status int, location string) *http.Response {
return &http.Response{
StatusCode: status,
Header: http.Header{"Location": []string{location}},
Body: http.NoBody,
Request: req,
}
}
func noContentResponse(req *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusNoContent,
Body: http.NoBody,
Request: req,
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

View File

@@ -0,0 +1,232 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"context"
"net/http"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/core"
)
type requestClassContextKey struct{}
type forcedRequestClassContextKey struct{}
// HTTPPolicyRouter selects an HTTP transport policy from request intent and
// the endpoint catalog. Explicit request intent takes precedence; otherwise
// known platform endpoints use the platform policy and all other URLs use the
// external policy.
type HTTPPolicyRouter struct {
platform http.RoundTripper
external http.RoundTripper
}
// RoundTripperDecorator describes a transport layer that can be rebuilt over
// a cloned base transport. Connection-policy helpers use this contract to
// preserve retry, response, and extension layers while safely customizing the
// innermost *http.Transport.
type RoundTripperDecorator interface {
BaseRoundTripper() http.RoundTripper
WithBaseRoundTripper(http.RoundTripper) http.RoundTripper
}
// NewHTTPPolicyRouter constructs a router over two policy chains. A nil chain
// falls back to the shared proxy-aware transport. The currently registered
// extension provider is resolved once and applied according to its optional
// ScopedProvider contract.
func NewHTTPPolicyRouter(platform, external http.RoundTripper) *HTTPPolicyRouter {
if platform == nil {
platform = Shared()
}
if external == nil {
external = Shared()
}
extension := resolveExtension()
return &HTTPPolicyRouter{
platform: extension.wrap(platform, exttransport.RequestClassPlatform, true),
external: extension.wrap(external, exttransport.RequestClassExternal, true),
}
}
// RoundTrip dispatches the request to its selected policy chain.
func (r *HTTPPolicyRouter) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil {
return nil, errs.NewInternalError(
errs.SubtypeUnknown,
"HTTP policy router received a nil request",
)
}
class, err := classifyRequest(req)
if err != nil {
return nil, err
}
if class == exttransport.RequestClassPlatform {
return r.platform.RoundTrip(req)
}
return r.external.RoundTrip(req)
}
func (r *HTTPPolicyRouter) transportForClass(class exttransport.RequestClass) (http.RoundTripper, bool) {
switch class {
case exttransport.RequestClassPlatform:
return r.platform, true
case exttransport.RequestClassExternal:
return r.external, true
default:
return nil, false
}
}
func classifyRequest(req *http.Request) (exttransport.RequestClass, error) {
if explicit, ok := req.Context().Value(requestClassContextKey{}).(exttransport.RequestClass); ok {
switch explicit {
case exttransport.RequestClassPlatform, exttransport.RequestClassExternal:
return explicit, nil
default:
return "", errs.NewInternalError(
errs.SubtypeUnknown,
"unsupported HTTP request class %q",
explicit,
)
}
}
if core.IsPlatformEndpointURL(req.URL) {
return exttransport.RequestClassPlatform, nil
}
return exttransport.RequestClassExternal, nil
}
// WithRequestClass returns a shallow copy of req with explicit routing intent.
func WithRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
if req == nil {
return nil
}
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
return req.WithContext(ctx)
}
func withForcedRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
if req == nil {
return nil
}
if _, forced := req.Context().Value(forcedRequestClassContextKey{}).(struct{}); forced {
return req
}
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
ctx = context.WithValue(ctx, forcedRequestClassContextKey{}, struct{}{})
return req.WithContext(ctx)
}
type requestClassTransport struct {
base http.RoundTripper
class exttransport.RequestClass
}
func (t *requestClassTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.base.RoundTrip(withForcedRequestClass(req, t.class))
}
// CloneHTTPTransport exposes a structural cloning capability without requiring
// higher-level safety helpers to import this package. The explicit request
// class selects the policy branch that must be rebuilt.
func (t *requestClassTransport) CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool) {
return CloneHTTPTransportForRequestClass(t.base, t.class)
}
// TransformHTTPTransport clones the selected policy branch and replaces its
// concrete transport in place. Keeping the replacement at the graph leaf is
// important for policies that must observe requests after outer decorators
// have run, such as proxy selection.
func (t *requestClassTransport) TransformHTTPTransport(transform func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool) {
return transformHTTPTransportForRequestClass(t.base, t.class, transform, 0)
}
// ClientForRequestClass clones client and forces all of its requests through a
// specific policy class. The original client is never mutated.
func ClientForRequestClass(client *http.Client, class exttransport.RequestClass) *http.Client {
if client == nil {
client = &http.Client{}
}
cloned := *client
base := client.Transport
if base == nil {
base = Shared()
}
cloned.Transport = &requestClassTransport{base: base, class: class}
return &cloned
}
// CloneHTTPTransportForRequestClass selects one policy branch, clones its
// innermost *http.Transport, and rebuilds every composable decorator around
// the clone. Callers can customize concrete before using rebuilt. The original
// transport graph is never mutated.
func CloneHTTPTransportForRequestClass(base http.RoundTripper, class exttransport.RequestClass) (rebuilt http.RoundTripper, concrete *http.Transport, ok bool) {
rebuilt, ok = transformHTTPTransportForRequestClass(base, class, func(cloned *http.Transport) (http.RoundTripper, bool) {
concrete = cloned
return cloned, true
}, 0)
if !ok {
return nil, nil, false
}
return rebuilt, concrete, true
}
func transformHTTPTransportForRequestClass(
base http.RoundTripper,
class exttransport.RequestClass,
transform func(*http.Transport) (http.RoundTripper, bool),
depth int,
) (http.RoundTripper, bool) {
if depth > 32 {
return nil, false
}
if base == nil || transform == nil {
if transform == nil {
return nil, false
}
base = Shared()
}
switch current := base.(type) {
case *http.Transport:
cloned := cloneHTTPTransport(current)
rebuilt, valid := transform(cloned)
return rebuilt, valid && rebuilt != nil
case *requestClassTransport:
return transformHTTPTransportForRequestClass(current.base, class, transform, depth+1)
case *HTTPPolicyRouter:
selected, valid := current.transportForClass(class)
if !valid {
return nil, false
}
return transformHTTPTransportForRequestClass(selected, class, transform, depth+1)
case RoundTripperDecorator:
inner := current.BaseRoundTripper()
if inner == nil || inner == base {
return nil, false
}
rebuiltInner, valid := transformHTTPTransportForRequestClass(inner, class, transform, depth+1)
if !valid {
return nil, false
}
rebuilt := current.WithBaseRoundTripper(rebuiltInner)
return rebuilt, rebuilt != nil
default:
return nil, false
}
}
func cloneHTTPTransport(source *http.Transport) *http.Transport {
cloned := source.Clone()
// Clone leaves an auto-configured h2 handler on source.
if cloned.TLSNextProto == nil {
if _, ok := source.TLSNextProto["h2"]; ok {
cloned.ForceAttemptHTTP2 = true
}
}
return cloned
}

View File

@@ -0,0 +1,351 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
)
type cloneTestDecorator struct {
base http.RoundTripper
}
func (d *cloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
return d.base.RoundTrip(req)
}
func (d *cloneTestDecorator) BaseRoundTripper() http.RoundTripper {
return d.base
}
func (d *cloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
return &cloneTestDecorator{base: base}
}
type headerCloneTestDecorator struct {
base http.RoundTripper
}
func (d *headerCloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("X-Decorator", "applied")
return d.base.RoundTrip(req)
}
func (d *headerCloneTestDecorator) BaseRoundTripper() http.RoundTripper {
return d.base
}
func (d *headerCloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
return &headerCloneTestDecorator{base: base}
}
func TestHTTPPolicyRouterClassifiesFromEndpointCatalog(t *testing.T) {
exttransport.Register(nil)
platformCalls := 0
externalCalls := 0
router := NewHTTPPolicyRouter(
roundTripFunc(func(req *http.Request) (*http.Response, error) {
platformCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
roundTripFunc(func(req *http.Request) (*http.Response, error) {
externalCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
)
for _, rawURL := range []string{
"https://open.feishu.cn/open-apis/test",
"https://example.com/file",
} {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
t.Fatal(err)
}
resp, err := router.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
if platformCalls != 1 || externalCalls != 1 {
t.Fatalf("platform calls = %d, external calls = %d; want 1 each", platformCalls, externalCalls)
}
}
func TestHTTPPolicyRouterExplicitClassOverridesCatalog(t *testing.T) {
exttransport.Register(nil)
platformCalls := 0
externalCalls := 0
router := NewHTTPPolicyRouter(
roundTripFunc(func(req *http.Request) (*http.Response, error) {
platformCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
roundTripFunc(func(req *http.Request) (*http.Response, error) {
externalCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
)
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req = WithRequestClass(req, exttransport.RequestClassExternal)
resp, err := router.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if platformCalls != 0 || externalCalls != 1 {
t.Fatalf("platform calls = %d, external calls = %d; want 0 and 1", platformCalls, externalCalls)
}
}
func TestClientForRequestClassOutermostIntentWins(t *testing.T) {
exttransport.Register(nil)
platformCalls := 0
externalCalls := 0
router := NewHTTPPolicyRouter(
roundTripFunc(func(req *http.Request) (*http.Response, error) {
platformCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
roundTripFunc(func(req *http.Request) (*http.Response, error) {
externalCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
)
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
resp, err := external.Get("https://open.feishu.cn/open-apis/test")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if platformCalls != 0 || externalCalls != 1 {
t.Fatalf("platform calls = %d, external calls = %d; want outer external intent to win", platformCalls, externalCalls)
}
}
func TestHTTPPolicyRouterRejectsInvalidExplicitClass(t *testing.T) {
exttransport.Register(nil)
router := NewHTTPPolicyRouter(nil, nil)
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatal(err)
}
req = WithRequestClass(req, exttransport.RequestClass("invalid"))
if _, err := router.RoundTrip(req); err == nil || !strings.Contains(err.Error(), "unsupported HTTP request class") {
t.Fatalf("RoundTrip() error = %v, want unsupported request class", err)
} else if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryInternal ||
problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
}
}
func TestHTTPPolicyRouterRejectsNilRequest(t *testing.T) {
router := NewHTTPPolicyRouter(nil, nil)
_, err := router.RoundTrip(nil)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
}
}
func TestHTTPPolicyRouterReclassifiesRedirectTargets(t *testing.T) {
interceptor := &testHeaderInterceptor{}
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(nil) })
receivedHeader := make(chan string, 1)
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
receivedHeader <- req.Header.Get("X-Test-Platform")
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(external.Close)
router := NewHTTPPolicyRouter(
roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": []string{external.URL}},
Body: http.NoBody,
Request: req,
}, nil
}),
http.DefaultTransport,
)
client := &http.Client{Transport: router}
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if got := <-receivedHeader; got != "" {
t.Fatalf("redirect target received platform-scoped header %q", got)
}
if interceptor.calls != 1 {
t.Fatalf("extension calls = %d, want only the initial platform request", interceptor.calls)
}
}
func TestCloneHTTPTransportForRequestClassRebuildsDecorators(t *testing.T) {
wantErr := errors.New("preserved proxy policy")
base := &http.Transport{
Proxy: func(*http.Request) (*url.URL, error) {
return nil, wantErr
},
}
decorated := &cloneTestDecorator{base: base}
router := NewHTTPPolicyRouter(decorated, decorated)
rebuilt, concrete, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
if !ok {
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
}
if concrete == base {
t.Fatal("CloneHTTPTransportForRequestClass() reused the original *http.Transport")
}
if _, ok := rebuilt.(*cloneTestDecorator); !ok {
t.Fatalf("rebuilt transport type = %T, want *cloneTestDecorator", rebuilt)
}
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
if err != nil {
t.Fatal(err)
}
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, wantErr) {
t.Fatalf("RoundTrip() error = %v, want %v", err, wantErr)
}
}
func TestCloneHTTPTransportForRequestClassPreservesAutomaticHTTP2(t *testing.T) {
previousProvider := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previousProvider) })
source := &http.Transport{
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
}
router := NewHTTPPolicyRouter(&http.Transport{}, source)
_, cloned, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
if !ok {
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
}
if !cloned.ForceAttemptHTTP2 {
t.Fatal("ForceAttemptHTTP2 = false, want true")
}
if cloned.TLSNextProto != nil {
t.Fatal("TLSNextProto is non-nil, want automatic HTTP/2")
}
}
func TestCloneHTTPTransportForRequestClassKeepsOutermostIntent(t *testing.T) {
platformErr := errors.New("platform transport")
externalErr := errors.New("external transport")
newBlocked := func(reason error) *http.Transport {
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
}
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
source, ok := external.Transport.(interface {
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
})
if !ok {
t.Fatalf("transport type %T has no clone capability", external.Transport)
}
rebuilt, _, ok := source.CloneHTTPTransport()
if !ok {
t.Fatal("CloneHTTPTransport() ok = false")
}
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, externalErr) {
t.Fatalf("RoundTrip() error = %v, want outer external transport error %v", err, externalErr)
}
}
func TestClientForRequestClassOverridesCallerIntent(t *testing.T) {
platformErr := errors.New("platform transport")
externalErr := errors.New("external transport")
newBlocked := func(reason error) *http.Transport {
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
}
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
if err != nil {
t.Fatal(err)
}
req = WithRequestClass(req, exttransport.RequestClassPlatform)
if _, err := client.Do(req); !errors.Is(err, externalErr) {
t.Fatalf("Do() error = %v, want forced external transport error %v", err, externalErr)
}
}
func TestTransformHTTPTransportReplacesLeafInsideDecorators(t *testing.T) {
exttransport.Register(nil)
decorated := &headerCloneTestDecorator{base: &http.Transport{}}
router := NewHTTPPolicyRouter(decorated, decorated)
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
source, ok := client.Transport.(interface {
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
})
if !ok {
t.Fatalf("transport type %T has no transform capability", client.Transport)
}
rebuilt, ok := source.TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool) {
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
if got := req.Header.Get("X-Decorator"); got != "applied" {
t.Fatalf("leaf received X-Decorator = %q, want applied", got)
}
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}), true
})
if !ok {
t.Fatal("TransformHTTPTransport() ok = false")
}
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := rebuilt.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}

View File

@@ -8,6 +8,8 @@ import (
"os"
"sync"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
)
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
@@ -55,21 +57,29 @@ func Fallback() *http.Transport {
return noProxyTransport()
}
// NewHTTPClient returns an *http.Client whose Transport is the shared,
// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{}
// for outbound requests: a bare client falls back to http.DefaultTransport and
// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or
// fail-closed), creating an audit blind spot.
// NewHTTPClient returns a policy-routed client over the shared proxy-aware
// transport. Known platform endpoints use the platform request class; all
// other URLs use the external request class. Existing unscoped transport
// providers continue to apply to both classes.
//
// A zero timeout means no client-level timeout (callers relying on context
// deadlines pass 0).
func NewHTTPClient(timeout time.Duration) *http.Client {
base := Shared()
return &http.Client{
Transport: Shared(),
Transport: NewHTTPPolicyRouter(base, base),
Timeout: timeout,
}
}
// NewExternalHTTPClient returns a client for user-provided, pre-signed, CDN,
// package-registry, and other non-platform URLs. It forces the external policy
// while preserving the shared proxy configuration and the historical behavior
// of unscoped transport providers. A zero timeout means no client-level timeout.
func NewExternalHTTPClient(timeout time.Duration) *http.Client {
return ClientForRequestClass(NewHTTPClient(timeout), exttransport.RequestClassExternal)
}
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily
// built the first time LARK_CLI_NO_PROXY is observed set.
var noProxyTransport = sync.OnceValue(func() *http.Transport {

View File

@@ -88,23 +88,24 @@ func TestShared_NoProxyOverridesSystemProxy(t *testing.T) {
}
}
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
// transport (instead of a bare client that bypasses proxy plugin mode).
func TestNewHTTPClient(t *testing.T) {
// TestHTTPClientConstructors verifies both the policy-routed client and its
// forced-external view retain explicit transports and configured timeouts.
func TestHTTPClientConstructors(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
c := NewHTTPClient(7 * time.Second)
if c.Transport == nil {
t.Fatal("NewHTTPClient transport is nil; want shared transport")
}
if c.Transport != Shared() {
t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport)
}
if c.Timeout != 7*time.Second {
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
for name, client := range map[string]*http.Client{
"routed": NewHTTPClient(7 * time.Second),
"external": NewExternalHTTPClient(7 * time.Second),
} {
if client.Transport == nil {
t.Fatalf("%s client transport is nil", name)
}
if client.Timeout != 7*time.Second {
t.Errorf("%s client timeout = %v, want 7s", name, client.Timeout)
}
}
}
@@ -153,4 +154,32 @@ func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) {
if err == nil {
t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp)
}
for name, test := range map[string]struct {
client *http.Client
url string
}{
"platform": {
client: NewHTTPClient(time.Second),
url: "https://open.feishu.cn/open-apis/test",
},
"external": {
client: NewHTTPClient(time.Second),
url: "https://external.example/test",
},
"forced external": {
client: NewExternalHTTPClient(time.Second),
url: "https://external.example/test",
},
} {
t.Run(name, func(t *testing.T) {
resp, err := test.client.Get(test.url)
if err == nil {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
t.Fatalf("policy-routed client succeeded with malformed proxy config")
}
})
}
}

View File

@@ -62,10 +62,7 @@ func httpClient() *http.Client {
if DefaultClient != nil {
return DefaultClient
}
return &http.Client{
Timeout: fetchTimeout,
Transport: transport.Shared(),
}
return transport.NewExternalHTTPClient(fetchTimeout)
}
// updateState is persisted to disk for caching.

View File

@@ -4,6 +4,7 @@
package update
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -12,6 +13,8 @@ import (
"path/filepath"
"testing"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
)
// roundTripFunc adapts a function to http.RoundTripper.
@@ -19,6 +22,30 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
type updateExternalProvider struct {
interceptor exttransport.Interceptor
}
func (p updateExternalProvider) Name() string { return "update-external-test" }
func (p updateExternalProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
return p.interceptor
}
func (updateExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
return class == exttransport.RequestClassExternal
}
type updateExternalInterceptor struct {
calls int
}
func (i *updateExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
i.calls++
req.Header.Set("X-External-Route", "1")
return nil
}
// clearSkipEnv unsets all env vars that shouldSkip checks,
// preventing the host environment (e.g. CI=true) from polluting test results.
func clearSkipEnv(t *testing.T) {
@@ -242,6 +269,46 @@ func TestRefreshCache(t *testing.T) {
RefreshCache("1.0.0")
}
func TestHTTPClientUsesExternalRequestClass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARK_CLI_NO_PROXY", "")
previousClient := DefaultClient
DefaultClient = nil
t.Cleanup(func() { DefaultClient = previousClient })
previousProvider := exttransport.GetProvider()
interceptor := &updateExternalInterceptor{}
exttransport.Register(updateExternalProvider{interceptor: interceptor})
t.Cleanup(func() { exttransport.Register(previousProvider) })
previousTransport := http.DefaultTransport
var receivedHeader string
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
receivedHeader = req.Header.Get("X-External-Route")
return &http.Response{
StatusCode: http.StatusNoContent,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})
t.Cleanup(func() { http.DefaultTransport = previousTransport })
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/npm/latest", nil)
if err != nil {
t.Fatal(err)
}
resp, err := httpClient().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if interceptor.calls != 1 || receivedHeader != "1" {
t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1")
}
}
func TestPendingAtomicAccess(t *testing.T) {
// Initially nil
if got := GetPending(); got != nil {

View File

@@ -17,6 +17,13 @@ func SafeInputPath(path string) (string, error) {
return localfileio.SafeInputPath(path)
}
// LocalInputPath validates a local input path without restricting it to the
// current working directory. It delegates to localfileio.LocalInputPath so
// command validation and shared local-file readers use one policy.
func LocalInputPath(path string) (string, error) {
return localfileio.LocalInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {

View File

@@ -211,6 +211,18 @@ func TestSafeLocalFlagPath(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalPathsAndRejectsUnsafeCharacters(t *testing.T) {
for _, path := range []string{"/tmp/report.pdf", "../report.pdf"} {
got, err := LocalInputPath(path)
if err != nil || got != path {
t.Fatalf("LocalInputPath(%q) = %q, %v; want unchanged path", path, got, err)
}
}
if _, err := LocalInputPath("report\n.pdf"); err == nil {
t.Fatal("LocalInputPath() unexpectedly accepted a control character")
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")

View File

@@ -5,11 +5,15 @@ package validate
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync"
"github.com/larksuite/cli/errs"
)
const (
@@ -34,6 +38,9 @@ func isRestrictedDownloadIP(ip net.IP) bool {
return true
}
if v4 := ip.To4(); v4 != nil {
if v4[0] == 0 { // RFC 1122 "this network"
return true
}
if v4[0] == 10 || v4[0] == 127 {
return true
}
@@ -52,6 +59,9 @@ func isRestrictedDownloadIP(ip net.IP) bool {
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) { // RFC2544 benchmarking
return true
}
if v4[0] >= 240 {
return true
}
return false
}
if ip.IsPrivate() {
@@ -76,32 +86,42 @@ func ValidateDownloadSourceURL(ctx context.Context, rawURL string) error {
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("only http/https URLs are supported")
}
host := strings.TrimSpace(strings.ToLower(u.Hostname()))
_, err = resolveDownloadHost(ctx, u.Hostname(), net.DefaultResolver.LookupIP)
return err
}
type downloadLookupIPFunc func(context.Context, string, string) ([]net.IP, error)
func resolveDownloadHost(ctx context.Context, rawHost string, lookupIP downloadLookupIPFunc) ([]net.IP, error) {
host := strings.TrimSpace(strings.ToLower(rawHost))
if host == "" {
return fmt.Errorf("URL host is required")
return nil, fmt.Errorf("URL host is required")
}
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return fmt.Errorf("local/internal host is not allowed")
return nil, fmt.Errorf("local/internal host is not allowed")
}
if ip := net.ParseIP(host); ip != nil {
if isRestrictedDownloadIP(ip) {
return fmt.Errorf("local/internal host is not allowed")
return nil, fmt.Errorf("local/internal host is not allowed")
}
return nil
return []net.IP{ip}, nil
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if lookupIP == nil {
lookupIP = net.DefaultResolver.LookupIP
}
ips, err := lookupIP(ctx, "ip", host)
if err != nil {
return fmt.Errorf("failed to resolve host")
return nil, fmt.Errorf("failed to resolve host")
}
if len(ips) == 0 {
return fmt.Errorf("failed to resolve host")
return nil, fmt.Errorf("failed to resolve host")
}
for _, ip := range ips {
if isRestrictedDownloadIP(ip) {
return fmt.Errorf("local/internal host is not allowed")
return nil, fmt.Errorf("local/internal host is not allowed")
}
}
return nil
return ips, nil
}
// NewDownloadHTTPClient clones base client and enforces download-safe redirect
@@ -115,7 +135,10 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
}
cloned := *base
cloned.Transport = cloneDownloadTransport(base.Transport)
cloned.Transport = &downloadSchemeTransport{
base: cloneDownloadTransport(base.Transport),
allowHTTP: opts.AllowHTTP,
}
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= opts.MaxRedirects {
return fmt.Errorf("too many redirects")
@@ -138,18 +161,310 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
return &cloned
}
func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
var cloned *http.Transport
if src, ok := base.(*http.Transport); ok && src != nil {
cloned = src.Clone()
} else {
if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil {
cloned = def.Clone()
} else {
cloned = &http.Transport{}
}
type downloadSchemeTransport struct {
base http.RoundTripper
allowHTTP bool
}
func (t *downloadSchemeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil || req.URL == nil {
return nil, errs.NewInternalError(
errs.SubtypeUnknown,
"download transport received a nil request",
)
}
switch {
case strings.EqualFold(req.URL.Scheme, "https"):
case t.allowHTTP && strings.EqualFold(req.URL.Scheme, "http"):
default:
return nil, errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"only https URLs are supported",
)
}
return t.base.RoundTrip(req)
}
type selectedDownloadProxyKey struct{}
type proxyAwareDownloadTransport struct {
selectProxy func(*http.Request) (*url.URL, error)
direct http.RoundTripper
proxied *http.Transport
lookupIP downloadLookupIPFunc
mu sync.Mutex
proxiedByTLSServer map[string]*http.Transport
}
func (t *proxyAwareDownloadTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil || req.URL == nil {
return nil, fmt.Errorf("download transport received a nil request")
}
proxyURL, err := t.selectProxy(req)
if err != nil {
return nil, err
}
if proxyURL == nil {
return t.direct.RoundTrip(req)
}
targetIPs, err := resolveDownloadHost(req.Context(), req.URL.Hostname(), t.lookupIP)
if err != nil {
return nil, errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"blocked download target: %v",
err,
).WithCause(err)
}
if strings.EqualFold(req.URL.Scheme, "http") && net.ParseIP(req.URL.Hostname()) == nil {
// HTTP proxies cannot pin the target IP separately from the Host header.
return nil, errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"plain HTTP hostname downloads through a proxy are not allowed",
).WithHint("use HTTPS or a literal public IP")
}
selected := *proxyURL
proxied := t.proxied
if strings.EqualFold(req.URL.Scheme, "https") {
proxied = t.proxiedTransportForTLSServer(req.URL.Hostname())
}
var lastErr error
for index, targetIP := range targetIPs {
proxiedReq, pinErr := pinDownloadRequestTargetToIP(req, targetIP)
if pinErr != nil {
return nil, pinErr
}
ctx := context.WithValue(proxiedReq.Context(), selectedDownloadProxyKey{}, &selected)
proxiedReq = proxiedReq.WithContext(ctx)
resp, roundTripErr := proxied.RoundTrip(proxiedReq)
if roundTripErr == nil {
if resp != nil {
// Hide the internal pinned URL from redirect handling.
resp.Request = req
}
return resp, nil
}
lastErr = roundTripErr
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if req.Context().Err() != nil {
break
}
if index+1 < len(targetIPs) && !canRetryDownloadTarget(req) {
break
}
}
return nil, lastErr
}
func (t *proxyAwareDownloadTransport) CloseIdleConnections() {
if closer, ok := t.direct.(interface{ CloseIdleConnections() }); ok {
closer.CloseIdleConnections()
}
t.proxied.CloseIdleConnections()
t.mu.Lock()
defer t.mu.Unlock()
for _, transport := range t.proxiedByTLSServer {
transport.CloseIdleConnections()
}
}
func (t *proxyAwareDownloadTransport) proxiedTransportForTLSServer(serverName string) *http.Transport {
if configured := t.proxied.TLSClientConfig; configured != nil && configured.ServerName != "" {
serverName = configured.ServerName
}
t.mu.Lock()
defer t.mu.Unlock()
if transport := t.proxiedByTLSServer[serverName]; transport != nil {
return transport
}
transport := t.proxied.Clone()
targetTLSConfig := cloneDownloadTLSConfig(transport.TLSClientConfig)
targetTLSConfig.ServerName = serverName
transport.TLSClientConfig = targetTLSConfig
configureHTTPSProxyTLSDialer(transport, t.proxied)
if t.proxiedByTLSServer == nil {
t.proxiedByTLSServer = make(map[string]*http.Transport)
}
t.proxiedByTLSServer[serverName] = transport
return transport
}
type blockedDownloadTransport struct {
err error
}
func (t *blockedDownloadTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, t.err
}
func cloneDownloadTransport(base http.RoundTripper) http.RoundTripper {
if base == nil {
base = http.DefaultTransport
}
if source, ok := base.(interface {
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
}); ok {
rebuilt, transformed := source.TransformHTTPTransport(newDownloadTransportLeaf)
if transformed && rebuilt != nil {
return rebuilt
}
}
if source, ok := base.(*http.Transport); ok && source != nil {
rebuilt, transformed := newDownloadTransportLeaf(source)
if transformed && rebuilt != nil {
return rebuilt
}
}
return &blockedDownloadTransport{err: errs.NewInternalError(
errs.SubtypeUnknown,
"cannot safely clone download transport %T",
base,
)}
}
func newDownloadTransportLeaf(source *http.Transport) (http.RoundTripper, bool) {
return newDownloadTransportLeafWithResolver(source, net.DefaultResolver.LookupIP)
}
func newDownloadTransportLeafWithResolver(source *http.Transport, lookupIP downloadLookupIPFunc) (http.RoundTripper, bool) {
if source == nil {
return nil, false
}
selectProxy := source.Proxy
direct := cloneDownloadHTTPTransport(source)
direct.Proxy = nil
configureDirectDownloadTransport(direct)
if selectProxy == nil {
return direct, true
}
// The proxied branch validates the requested URL before construction and
// on every redirect. Its TCP peer is the selected proxy, so applying the
// direct-origin IP guard there would incorrectly reject trusted loopback or
// private-network proxies. Freeze the selected proxy in request context so
// a stateful selector cannot switch the second lookup to direct egress.
proxied := cloneDownloadHTTPTransport(source)
proxied.Proxy = func(req *http.Request) (*url.URL, error) {
selected, ok := req.Context().Value(selectedDownloadProxyKey{}).(*url.URL)
if !ok || selected == nil {
return nil, fmt.Errorf("download proxy selection is missing")
}
cloned := *selected
return &cloned, nil
}
return &proxyAwareDownloadTransport{
selectProxy: selectProxy,
direct: direct,
proxied: proxied,
lookupIP: lookupIP,
proxiedByTLSServer: make(map[string]*http.Transport),
}, true
}
func cloneDownloadHTTPTransport(source *http.Transport) *http.Transport {
cloned := source.Clone()
if cloned.TLSNextProto == nil {
if _, ok := source.TLSNextProto["h2"]; ok {
cloned.ForceAttemptHTTP2 = true
}
}
return cloned
}
func pinDownloadRequestTargetToIP(req *http.Request, targetIP net.IP) (*http.Request, error) {
if req == nil || req.URL == nil {
return nil, fmt.Errorf("download request URL is missing")
}
if targetIP == nil || isRestrictedDownloadIP(targetIP) {
return nil, fmt.Errorf("blocked download target: local/internal host is not allowed")
}
originalHost := req.URL.Host
pinnedHost := targetIP.String()
if port := req.URL.Port(); port != "" {
pinnedHost = net.JoinHostPort(pinnedHost, port)
} else if strings.Contains(pinnedHost, ":") {
pinnedHost = "[" + pinnedHost + "]"
}
pinned := req.Clone(req.Context())
pinnedURL := *req.URL
pinnedURL.Host = pinnedHost
pinned.URL = &pinnedURL
pinned.Host = originalHost
return pinned, nil
}
func canRetryDownloadTarget(req *http.Request) bool {
if req == nil || req.Body != nil {
return false
}
return req.Method == http.MethodGet || req.Method == http.MethodHead
}
func cloneDownloadTLSConfig(config *tls.Config) *tls.Config {
if config == nil {
return &tls.Config{MinVersion: tls.VersionTLS12}
}
return config.Clone()
}
func configureHTTPSProxyTLSDialer(transport, source *http.Transport) {
if transport.DialTLSContext != nil || transport.DialTLS != nil {
return
}
proxyTLSConfig := cloneDownloadTLSConfig(source.TLSClientConfig)
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
rawConn, err := dialDownloadProxy(ctx, source, network, addr)
if err != nil {
return nil, err
}
config := proxyTLSConfig.Clone()
serverName, _, splitErr := net.SplitHostPort(addr)
if splitErr != nil {
rawConn.Close()
return nil, fmt.Errorf("invalid HTTPS proxy address: %w", splitErr)
}
config.ServerName = serverName
tlsConn := tls.Client(rawConn, config)
handshakeCtx := ctx
cancel := func() {}
if source.TLSHandshakeTimeout > 0 {
handshakeCtx, cancel = context.WithTimeout(ctx, source.TLSHandshakeTimeout)
}
defer cancel()
if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
rawConn.Close()
return nil, err
}
return tlsConn, nil
}
}
func dialDownloadProxy(ctx context.Context, source *http.Transport, network, addr string) (net.Conn, error) {
if source.DialContext != nil {
return source.DialContext(ctx, network, addr)
}
if source.Dial != nil {
return source.Dial(network, addr)
}
var dialer net.Dialer
return dialer.DialContext(ctx, network, addr)
}
func configureDirectDownloadTransport(cloned *http.Transport) {
origDial := cloned.DialContext
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialConn(ctx, origDial, network, addr)
@@ -158,7 +473,7 @@ func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
return nil, downloadTargetPolicyError(err)
}
return conn, nil
}
@@ -172,13 +487,26 @@ func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, downloadTargetPolicyError(err)
}
return conn, nil
}
}
if cloned.DialTLS != nil {
origDialTLS := cloned.DialTLS
cloned.DialTLS = func(network, addr string) (net.Conn, error) {
conn, err := origDialTLS(network, addr)
if err != nil {
return nil, err
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, downloadTargetPolicyError(err)
}
return conn, nil
}
}
return cloned
}
// DialContextFunc is the signature for DialContext / DialTLSContext.
@@ -194,7 +522,7 @@ func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
return nil, downloadTargetPolicyError(err)
}
return conn, nil
}
@@ -208,6 +536,14 @@ func dialConn(ctx context.Context, dialFn func(context.Context, string, string)
return d.DialContext(ctx, network, addr)
}
func downloadTargetPolicyError(err error) error {
return errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"blocked download target: %v",
err,
).WithCause(err)
}
func validateConnRemoteIP(conn net.Conn) error {
if conn == nil {
return fmt.Errorf("nil connection")

View File

@@ -0,0 +1,529 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/errs"
)
func TestProxiedHTTPSDownloadPinsValidatedTargetIP(t *testing.T) {
const (
targetHost = "rebind.example"
targetIP = "203.0.113.10"
)
proxyCalled := make(chan struct{}, 1)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
proxyCalled <- struct{}{}
if req.Method != http.MethodConnect {
t.Errorf("proxy request method = %q, want CONNECT", req.Method)
}
if got := req.Host; got != targetIP+":443" {
t.Errorf("proxy CONNECT target = %q, want validated IP %q", got, targetIP+":443")
}
w.WriteHeader(http.StatusBadGateway)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
return []net.IP{net.ParseIP(targetIP)}, nil
}
transport, ok := newDownloadTransportLeafWithResolver(
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
lookupIP,
)
if !ok {
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
}
req, err := http.NewRequest(http.MethodGet, "https://"+targetHost+"/file", nil)
if err != nil {
t.Fatal(err)
}
pinned, err := pinDownloadRequestTargetToIP(req, net.ParseIP(targetIP))
if err != nil {
t.Fatal(err)
}
if pinned.Host != targetHost {
t.Fatalf("pinned request Host = %q, want %q", pinned.Host, targetHost)
}
if _, err := transport.RoundTrip(req); err == nil {
t.Fatal("RoundTrip() error = nil, want proxy rejection after CONNECT")
}
select {
case <-proxyCalled:
default:
t.Fatal("proxy was not called")
}
}
func TestRestrictedDownloadIPBlocksReservedIPv4(t *testing.T) {
for _, rawIP := range []string{"0.1.2.3", "240.0.0.1"} {
if !isRestrictedDownloadIP(net.ParseIP(rawIP)) {
t.Fatalf("%s was classified as safe", rawIP)
}
}
if isRestrictedDownloadIP(net.ParseIP("1.1.1.1")) {
t.Fatal("1.1.1.1 was classified as restricted")
}
}
func TestCloneDownloadTLSConfigSetsMinimumVersion(t *testing.T) {
if got := cloneDownloadTLSConfig(nil).MinVersion; got != tls.VersionTLS12 {
t.Fatalf("MinVersion = %d, want TLS 1.2", got)
}
configured := &tls.Config{MinVersion: tls.VersionTLS13}
if got := cloneDownloadTLSConfig(configured).MinVersion; got != tls.VersionTLS13 {
t.Fatalf("cloned MinVersion = %d, want TLS 1.3", got)
}
}
func TestCloneDownloadHTTPTransportPreservesHTTP2Policy(t *testing.T) {
tests := []struct {
name string
source *http.Transport
wantForce bool
wantH2Handler bool
wantProtocolMap bool
}{
{
name: "automatic",
source: &http.Transport{
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
},
wantForce: true,
},
{
name: "custom TLS without opt-in",
source: &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
},
},
{
name: "custom dial without opt-in",
source: &http.Transport{
DialContext: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("unused")
},
},
},
{
name: "explicit opt-in",
source: &http.Transport{ForceAttemptHTTP2: true},
wantForce: true,
},
{
name: "explicit h2 handler",
source: &http.Transport{
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{
"h2": func(string, *tls.Conn) http.RoundTripper { return nil },
},
},
wantH2Handler: true,
wantProtocolMap: true,
},
{
name: "explicit opt-out",
source: &http.Transport{
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
},
wantProtocolMap: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cloned := cloneDownloadHTTPTransport(test.source)
if cloned.ForceAttemptHTTP2 != test.wantForce {
t.Fatalf("ForceAttemptHTTP2 = %v, want %v", cloned.ForceAttemptHTTP2, test.wantForce)
}
_, hasH2Handler := cloned.TLSNextProto["h2"]
if hasH2Handler != test.wantH2Handler {
t.Fatalf("h2 handler = %v, want %v", hasH2Handler, test.wantH2Handler)
}
if hasProtocolMap := cloned.TLSNextProto != nil; hasProtocolMap != test.wantProtocolMap {
t.Fatalf("TLSNextProto is non-nil = %v, want %v", hasProtocolMap, test.wantProtocolMap)
}
})
}
}
func TestCloneDownloadTransportPreservesAutomaticHTTP2(t *testing.T) {
source := &http.Transport{
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
}
rebuilt := cloneDownloadTransport(source)
proxyAware, ok := rebuilt.(*proxyAwareDownloadTransport)
if !ok {
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", rebuilt)
}
direct, ok := proxyAware.direct.(*http.Transport)
if !ok {
t.Fatalf("direct transport type = %T, want *http.Transport", proxyAware.direct)
}
for name, transport := range map[string]*http.Transport{
"direct": direct,
"proxied": proxyAware.proxied,
} {
if !transport.ForceAttemptHTTP2 {
t.Fatalf("%s ForceAttemptHTTP2 = false, want true", name)
}
}
}
func TestProxiedDownloadRejectsRestrictedResolvedTarget(t *testing.T) {
var proxyCalled atomic.Bool
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
proxyCalled.Store(true)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
return []net.IP{net.ParseIP("127.0.0.1")}, nil
}
transport, ok := newDownloadTransportLeafWithResolver(
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
lookupIP,
)
if !ok {
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
}
req, err := http.NewRequest(http.MethodGet, "http://rebind.example/file", nil)
if err != nil {
t.Fatal(err)
}
_, err = transport.RoundTrip(req)
if err == nil {
t.Fatal("RoundTrip() error = nil, want restricted target rejection")
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryPolicy ||
problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if proxyCalled.Load() {
t.Fatal("proxy was called for a restricted resolved target")
}
}
func TestProxiedPlainHTTPHostnameRejectsLocalProxy(t *testing.T) {
var proxyCalled atomic.Bool
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
proxyCalled.Store(true)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
lookupIP := func(ctx context.Context, _, host string) ([]net.IP, error) {
if host == "public.example" {
return []net.IP{net.ParseIP("203.0.113.10")}, nil
}
return net.DefaultResolver.LookupIP(ctx, "ip", host)
}
transport, ok := newDownloadTransportLeafWithResolver(
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
lookupIP,
)
if !ok {
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
}
req, err := http.NewRequest(http.MethodGet, "http://public.example/file", nil)
if err != nil {
t.Fatal(err)
}
_, err = transport.RoundTrip(req)
if err == nil {
t.Fatal("RoundTrip() error = nil, want plain HTTP hostname rejection")
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryPolicy ||
problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
} else if problem.Hint != "use HTTPS or a literal public IP" {
t.Fatalf("RoundTrip() hint = %q, want recovery guidance", problem.Hint)
}
if proxyCalled.Load() {
t.Fatal("proxy was called for a plain HTTP hostname target")
}
}
func TestProxiedHTTPSDownloadTriesEveryValidatedTargetIP(t *testing.T) {
connectTargets := make(chan string, 2)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
connectTargets <- req.Host
w.WriteHeader(http.StatusBadGateway)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
transport, ok := newDownloadTransportLeafWithResolver(
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
func(context.Context, string, string) ([]net.IP, error) {
return []net.IP{
net.ParseIP("203.0.113.10"),
net.ParseIP("203.0.113.11"),
}, nil
},
)
if !ok {
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
}
req, err := http.NewRequest(http.MethodGet, "https://multi.example/file", nil)
if err != nil {
t.Fatal(err)
}
if _, err := transport.RoundTrip(req); err == nil {
t.Fatal("RoundTrip() error = nil, want proxy rejection")
}
for _, want := range []string{"203.0.113.10:443", "203.0.113.11:443"} {
select {
case got := <-connectTargets:
if got != want {
t.Fatalf("proxy CONNECT target = %q, want %q", got, want)
}
default:
t.Fatalf("proxy did not receive CONNECT target %q", want)
}
}
}
func TestCanRetryDownloadTargetOnlyAllowsBodylessReads(t *testing.T) {
for _, test := range []struct {
method string
body string
want bool
}{
{method: http.MethodGet, want: true},
{method: http.MethodHead, want: true},
{method: http.MethodPost},
{method: http.MethodGet, body: "body"},
} {
req, err := http.NewRequest(test.method, "https://download.example/file", strings.NewReader(test.body))
if err != nil {
t.Fatal(err)
}
if test.body == "" {
req.Body = nil
}
if got := canRetryDownloadTarget(req); got != test.want {
t.Fatalf("canRetryDownloadTarget(%s, body=%q) = %v, want %v", test.method, test.body, got, test.want)
}
}
}
func TestProxiedHTTPSTargetPreservesOriginalTLSServerName(t *testing.T) {
transport, ok := newDownloadTransportLeafWithResolver(
&http.Transport{Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"})},
func(context.Context, string, string) ([]net.IP, error) {
return []net.IP{net.ParseIP("203.0.113.10")}, nil
},
)
if !ok {
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
}
proxyAware, ok := transport.(*proxyAwareDownloadTransport)
if !ok {
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", transport)
}
pinned := proxyAware.proxiedTransportForTLSServer("download.example")
if pinned.TLSClientConfig == nil {
t.Fatal("TLSClientConfig = nil")
}
if pinned.TLSClientConfig.ServerName != "download.example" {
t.Fatalf("TLS ServerName = %q, want download.example", pinned.TLSClientConfig.ServerName)
}
if proxyAware.proxied.TLSClientConfig != nil && proxyAware.proxied.TLSClientConfig.ServerName != "" {
t.Fatalf("base proxy TLS ServerName = %q, want unchanged", proxyAware.proxied.TLSClientConfig.ServerName)
}
}
func TestHTTPSProxyTLSDialerUsesLegacyDial(t *testing.T) {
wantErr := errors.New("legacy dial used")
source := &http.Transport{
Dial: func(string, string) (net.Conn, error) {
return nil, wantErr
},
}
target := source.Clone()
configureHTTPSProxyTLSDialer(target, source)
if target.DialTLSContext == nil {
t.Fatal("DialTLSContext = nil")
}
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); !errors.Is(err, wantErr) {
t.Fatalf("DialTLSContext() error = %v, want %v", err, wantErr)
}
}
func TestDirectDownloadLegacyDialTLSClosesRestrictedConnection(t *testing.T) {
clientConn, serverConn := net.Pipe()
t.Cleanup(func() { serverConn.Close() })
conn := &trackedDownloadConn{
Conn: clientConn,
remoteAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443},
}
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
DialTLS: func(string, string) (net.Conn, error) {
return conn, nil
},
})
if !ok {
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
}
transport, ok := rebuilt.(*http.Transport)
if !ok {
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
}
_, err := transport.DialTLS("tcp", "public.example:443")
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
t.Fatalf("DialTLS() error = %v, want restricted target rejection", err)
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryPolicy ||
problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("DialTLS() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if !conn.closed {
t.Fatal("restricted connection was not closed")
}
}
func TestDirectDownloadLegacyDialTLSPreservesDialError(t *testing.T) {
wantErr := errors.New("dial failed")
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
DialTLS: func(string, string) (net.Conn, error) {
return nil, wantErr
},
})
if !ok {
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
}
transport, ok := rebuilt.(*http.Transport)
if !ok {
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
}
if _, err := transport.DialTLS("tcp", "public.example:443"); !errors.Is(err, wantErr) {
t.Fatalf("DialTLS() error = %v, want %v", err, wantErr)
}
}
func TestHTTPSProxyTLSDialerRetainsHandshakeTimeout(t *testing.T) {
clientConn, serverConn := net.Pipe()
t.Cleanup(func() {
clientConn.Close()
serverConn.Close()
})
source := &http.Transport{
DialContext: func(context.Context, string, string) (net.Conn, error) {
return clientConn, nil
},
TLSHandshakeTimeout: 50 * time.Millisecond,
}
target := source.Clone()
configureHTTPSProxyTLSDialer(target, source)
started := time.Now()
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); err == nil {
t.Fatal("DialTLSContext() error = nil, want TLS handshake timeout")
}
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("TLS handshake timeout took %s, want under 1s", elapsed)
}
}
func TestHTTPSProxyTLSDialerUsesProxyServerName(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
t.Cleanup(server.Close)
clientConn, serverConn := net.Pipe()
t.Cleanup(func() {
clientConn.Close()
serverConn.Close()
})
proxySNI := make(chan string, 1)
serverTLSConfig := server.TLS.Clone()
serverTLSConfig.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
proxySNI <- info.ServerName
return nil, nil
}
serverErr := make(chan error, 1)
go func() {
serverErr <- tls.Server(serverConn, serverTLSConfig).Handshake()
}()
roots := x509.NewCertPool()
roots.AddCert(server.Certificate())
source := &http.Transport{
DialContext: func(context.Context, string, string) (net.Conn, error) {
return clientConn, nil
},
TLSClientConfig: &tls.Config{
RootCAs: roots,
ServerName: "target.example.com",
},
}
target := source.Clone()
configureHTTPSProxyTLSDialer(target, source)
conn, err := target.DialTLSContext(context.Background(), "tcp", "example.com:443")
if err != nil {
t.Fatal(err)
}
conn.Close()
if err := <-serverErr; err != nil {
t.Fatal(err)
}
if got := <-proxySNI; got != "example.com" {
t.Fatalf("proxy TLS ServerName = %q, want example.com", got)
}
}
type trackedDownloadConn struct {
net.Conn
remoteAddr net.Addr
closed bool
}
func (c *trackedDownloadConn) RemoteAddr() net.Addr {
return c.remoteAddr
}
func (c *trackedDownloadConn) Close() error {
c.closed = true
return c.Conn.Close()
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate_test
import (
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
internaltransport "github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/validate"
)
type opaqueRoundTripper struct {
called bool
}
func (t *opaqueRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
t.called = true
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}
type downloadTestProvider struct {
interceptor exttransport.Interceptor
}
func (p downloadTestProvider) Name() string { return "download-test" }
func (p downloadTestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
return p.interceptor
}
type downloadHeaderInterceptor struct{}
func (downloadHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set("X-Use-Proxy", "1")
return nil
}
func TestNewDownloadHTTPClientPreservesPolicyRouterBaseTransport(t *testing.T) {
wantErr := errors.New("proxy policy blocked request")
base := &http.Transport{
Proxy: func(*http.Request) (*url.URL, error) {
return nil, wantErr
},
}
router := internaltransport.NewHTTPPolicyRouter(base, base)
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: router},
exttransport.RequestClassExternal,
)
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := download.Transport.RoundTrip(req)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if !errors.Is(err, wantErr) {
t.Fatalf("RoundTrip() error = %v, want preserved proxy error %v", err, wantErr)
}
}
func TestNewDownloadHTTPClientRejectsInitialHTTPBeforeTransport(t *testing.T) {
base := &opaqueRoundTripper{}
download := validate.NewDownloadHTTPClient(
&http.Client{Transport: base},
validate.DownloadHTTPClientOptions{},
)
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
if err != nil {
t.Fatal(err)
}
_, err = download.Transport.RoundTrip(req)
if err == nil {
t.Fatal("RoundTrip() error = nil, want initial HTTP rejection")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if base.called {
t.Fatal("base transport was called for a disallowed initial HTTP request")
}
}
func TestNewDownloadHTTPClientAllowsSelectedLoopbackProxy(t *testing.T) {
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Host != "203.0.113.10" {
t.Errorf("proxy request target = %q, want 203.0.113.10", req.URL.Host)
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
base := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
router := internaltransport.NewHTTPPolicyRouter(base, base)
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: router},
exttransport.RequestClassExternal,
)
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := download.Do(req)
if err != nil {
t.Fatalf("download through selected loopback proxy: %v", err)
}
resp.Body.Close()
}
func TestNewDownloadHTTPClientSelectsProxyAfterOuterDecorators(t *testing.T) {
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if got := req.Header.Get("X-Use-Proxy"); got != "1" {
t.Errorf("proxy received X-Use-Proxy = %q, want 1", got)
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
wantErr := errors.New("proxy selector ran before decorators")
base := &http.Transport{Proxy: func(req *http.Request) (*url.URL, error) {
if req.Header.Get("X-Use-Proxy") != "1" {
return nil, wantErr
}
return proxyURL, nil
}}
previousProvider := exttransport.GetProvider()
exttransport.Register(downloadTestProvider{interceptor: downloadHeaderInterceptor{}})
t.Cleanup(func() { exttransport.Register(previousProvider) })
router := internaltransport.NewHTTPPolicyRouter(base, base)
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: router},
exttransport.RequestClassExternal,
)
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := download.Do(req)
if err != nil {
t.Fatalf("download through decorator-selected proxy: %v", err)
}
resp.Body.Close()
}
func TestNewDownloadHTTPClientGuardsLegacyDialTLS(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
base := &http.Transport{DialTLS: func(_, _ string) (net.Conn, error) {
return tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // local TLS server verifies the connection guard.
}}
download := validate.NewDownloadHTTPClient(&http.Client{Transport: base}, validate.DownloadHTTPClientOptions{AllowHTTP: true})
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
if err != nil {
t.Fatal(err)
}
_, err = download.Transport.RoundTrip(req)
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
t.Fatalf("RoundTrip() error = %v, want legacy DialTLS IP guard", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
}
var policyErr *errs.SecurityPolicyError
if !errors.As(err, &policyErr) || policyErr.Cause == nil {
t.Fatalf("RoundTrip() error = %T, want policy error with cause", err)
}
}
func TestNewDownloadHTTPClientFailsClosedForOpaqueTransport(t *testing.T) {
opaque := &opaqueRoundTripper{}
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: opaque},
exttransport.RequestClassExternal,
)
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
if err != nil {
t.Fatal(err)
}
_, err = download.Transport.RoundTrip(req)
if err == nil || !strings.Contains(err.Error(), "cannot safely clone download transport") {
t.Fatalf("RoundTrip() error = %v, want fail-closed clone error", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
}
if opaque.called {
t.Fatal("opaque transport was called after safe cloning failed")
}
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"path/filepath"
"strings"
"unicode"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
@@ -22,6 +23,32 @@ func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}
// LocalInputPath validates an input path in the process local filesystem
// namespace. It intentionally does not impose cwd containment or canonicalize
// the path: absolute paths, parent-relative paths, and symlink traversal retain
// their normal OS semantics. Character validation remains mandatory because
// paths are user-controlled and may appear in errors or progress output.
func LocalInputPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("local input path must not be empty")
}
if strings.IndexFunc(path, unicode.IsControl) >= 0 {
return "", fmt.Errorf("local input path must not contain control characters")
}
if err := charcheck.RejectControlChars(path, "local input path"); err != nil {
return "", err
}
if err := validateLocalInputPlatform(path); err != nil {
return "", err
}
return path, nil
}
func isWindowsNonLocalNamespace(path string) bool {
normalized := strings.ReplaceAll(path, "/", `\`)
return strings.HasPrefix(normalized, `\\`) || strings.HasPrefix(normalized, `\??\`)
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation.
func SafeLocalFlagPath(flagName, value string) (string, error) {
@@ -29,7 +56,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
return value, nil
}
if _, err := SafeInputPath(value); err != nil {
return "", fmt.Errorf("%s: %v", flagName, err)
return "", fmt.Errorf("%s: %w", flagName, err)
}
return value, nil
}

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build !windows
package localfileio
func validateLocalInputPlatform(string) error { return nil }

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import (
"fmt"
"path/filepath"
"strings"
)
func validateLocalInputPlatform(path string) error {
if isWindowsNonLocalNamespace(path) {
return fmt.Errorf("local input path must not use a Windows network or device namespace")
}
cleaned := filepath.Clean(path)
volume := filepath.VolumeName(cleaned)
remainder := strings.TrimLeft(cleaned[len(volume):], `\/`)
for _, component := range strings.FieldsFunc(remainder, func(r rune) bool {
return r == '\\' || r == '/'
}) {
if component == "." || component == ".." {
continue
}
if !filepath.IsLocal(component) {
return fmt.Errorf("local input path contains a reserved Windows path component %q", component)
}
}
return nil
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package localfileio
import "testing"
func TestLocalInputPath_RejectsWindowsNetworkDeviceAndReservedPaths(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
`C:\Users\agent\NUL.txt`,
`CON`,
} {
t.Run(input, func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}

View File

@@ -4,6 +4,7 @@
package localfileio
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -71,6 +72,72 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
}
}
func TestLocalInputPath_AllowsLocalNamespaceWithoutRewriting(t *testing.T) {
for _, input := range []string{
"/tmp/report.pdf",
"../outside/report.pdf",
"./report.pdf",
"nested/../report.pdf",
`C:\Users\agent\report.pdf`,
"报告.pdf",
} {
t.Run(input, func(t *testing.T) {
got, err := LocalInputPath(input)
if err != nil {
t.Fatalf("LocalInputPath(%q) error = %v", input, err)
}
if got != input {
t.Fatalf("LocalInputPath(%q) = %q, want path preserved verbatim", input, got)
}
})
}
}
func TestWindowsNonLocalNamespace(t *testing.T) {
for _, input := range []string{
`\\server\share\report.pdf`,
`//server/share/report.pdf`,
`\\.\pipe\upload`,
`\\?\C:\Users\agent\report.pdf`,
`\\?\UNC\server\share\report.pdf`,
`\??\C:\Users\agent\report.pdf`,
} {
if !isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = false, want true", input)
}
}
for _, input := range []string{
`C:\Users\agent\report.pdf`,
`C:/Users/agent/report.pdf`,
`..\outside\report.pdf`,
`.\report.pdf`,
} {
if isWindowsNonLocalNamespace(input) {
t.Errorf("isWindowsNonLocalNamespace(%q) = true, want false", input)
}
}
}
func TestLocalInputPath_RejectsEmptyControlAndDangerousUnicode(t *testing.T) {
for _, input := range []string{
"",
" ",
"file\x00.txt",
"file\tname.txt",
"file\nname.txt",
"file\rname.txt",
"file\u202Ename.txt",
"file\u200Bname.txt",
} {
t.Run(fmt.Sprintf("%q", input), func(t *testing.T) {
if _, err := LocalInputPath(input); err == nil {
t.Fatalf("LocalInputPath(%q) unexpectedly succeeded", input)
}
})
}
}
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
// GIVEN: a clean temp directory as CWD
dir := t.TempDir()

View File

@@ -19,7 +19,7 @@ lint/
├── lintapi/ # shared types every domain returns
│ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning
└── errscontract/ # first domain: typed-error contract guards
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
├── runner.go
├── typecheck.go
├── violation.go # local type aliases to lintapi
@@ -30,16 +30,19 @@ lint/
├── rule_subtype_classifier.go
├── rule_typed_error_completeness.go
└── *_test.go
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
── scan_test.go
└── domaincontract/ # resolver ownership + approved public hostname policy
├── scan.go # ScanRepoWithOptions(root, opts) ← public entry
── unapproved.go # Go AST/type-aware hostname extraction
├── policy.go # exact public/fixture allowlist validation
├── diff.go # added-line attribution
└── *_test.go
```
## Endpoint domain contract (`domaincontract`)
`domaincontract` is a syntax-level regression guard for the resolver-owned
Open, Accounts, MCP, and AppLink hosts used by the Go CLI. In production `.go`
files it rejects:
`domaincontract` contains two complementary Go source guards.
The resolver-ownership guard rejects:
- string literals containing a resolver-owned host FQDN
(`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`), and
@@ -59,17 +62,54 @@ parse-level guard). The forbidden-host list is bound to the resolver source by
`TestForbiddenHostsMatchResolver`, so adding a resolver domain without updating
the guard fails the lint module's tests.
This is not a general outbound-URL or data-flow analyzer. It does not inspect
non-Go assets, hosts assembled from string fragments, SDK constructor option
flow, or previously unknown Feishu/Lark hosts. The literal rule and code review
remain the backstop for those cases.
The approved-domain guard parses every Git-tracked Go file in full. In CI,
unapproved-host findings are limited to values whose expressions intersect an
added line; policy validation and unused-entry checks remain repository-wide.
It rejects an exact hostname unless it is present in one of:
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
- `internal/qualitygate/config/allowlists/public-domains.txt`, for production
and test code; or
- `internal/qualitygate/config/allowlists/fixture-domains.txt`, only for
`*_test.go`, the repository-root `tests/`, and any `testdata/` (never
`skills/`).
RFC 2606 example/test names are accepted independently of those lists. This
includes the reserved `.test`, `.example`, `.invalid`, and `.localhost`
namespaces and the exact names `example.com`, `example.net`, and `example.org`;
they are safe placeholders rather than supported public endpoints.
High-confidence evidence is deliberately limited to static string expressions
assigned to `host`, `hostname`, or `domain` semantic names (including common
case/plural forms and collections), plus static strings whose entire value is
an absolute `http`, `https`, `ws`, or `wss` URL. It supports Go literals,
escapes, compile-time concatenation, constant references, grouped declarations,
multi-value assignments, and multiline expressions. Bare domain-shaped strings
without hostname semantics are not blocked.
Sequence values are scanned individually. For a hostname-semantic map, a key or
value is evidence only when it is the sole hostname-shaped side of that entry;
ambiguous string-to-string entries are not guessed. Struct fields use Go type
information so known non-network `Host` / `Domain` fields do not become hostname
evidence merely because an enum or command category contains a dot.
Allowlist matching is lowercase and exact: there are no wildcard, suffix, DNS,
or public-suffix exceptions. Entries must be sorted and unique, use ASCII
hostnames, and have a current in-scope use. See
`internal/qualitygate/config/README.md` for admission and approval rules.
This is not a general outbound-URL or cross-language data-flow analyzer. It does
not inspect non-Go assets or dynamically constructed values.
To add or change a resolver-owned Feishu/Lark endpoint, edit the resolver rather
than hardcoding the host elsewhere.
## Running
```bash
# from the repo root (one level above lint/)
# PR-scoped scan from the repo root (one level above lint/)
go run -C lint . --changed-from <base-revision> ..
# Full inventory (also reports historical unapproved hostnames)
go run -C lint . ..
```
@@ -100,10 +140,14 @@ Exit codes follow `lint/main.go`:
import "github.com/larksuite/cli/lint/lintapi"
// ScanRepo walks root and returns every violation produced by this
// domain's checks. Domains MUST return []lintapi.Violation so the
// top-level dispatcher can aggregate uniformly.
func ScanRepo(root string) ([]lintapi.Violation, error) { ... }
type ScanOptions struct {
ChangedFrom string
}
// ScanRepoWithOptions walks root and returns every violation produced
// by this domain's checks. Domains MUST return []lintapi.Violation so
// the top-level dispatcher can aggregate uniformly.
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) { ... }
```
3. Per-rule files are named `rule_<name>.go` with sibling
@@ -114,8 +158,12 @@ Exit codes follow `lint/main.go`:
```go
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepo},
{name: "<domain>", fn: <domain>.ScanRepo}, // ← add here
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
{name: "<domain>", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
return <domain>.ScanRepoWithOptions(root, <domain>.ScanOptions{
ChangedFrom: opts.ChangedFrom,
})
}},
}
```

171
lint/domaincontract/diff.go Normal file
View File

@@ -0,0 +1,171 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
)
type addedLineRange struct {
Start int
End int
}
type changedGoPath struct {
Old string
New string
}
var unifiedHunkRE = regexp.MustCompile(`^@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,([0-9]+))? @@`)
func changedGoLineRanges(root, from string) (map[string][]addedLineRange, error) {
if from == "" {
return nil, nil
}
names, err := gitCommandOutput(
root,
"diff",
"--name-status",
"-z",
"--find-renames",
"--diff-filter=ACMR",
from+"...HEAD",
"--",
)
if err != nil {
return nil, fmt.Errorf("list changed Go files: %w", err)
}
paths, err := parseChangedGoPaths(names)
if err != nil {
return nil, fmt.Errorf("parse changed Go files: %w", err)
}
out := map[string][]addedLineRange{}
for _, path := range paths {
args := []string{
"diff",
"--unified=0",
"--no-color",
"--no-ext-diff",
"--find-renames",
"--diff-filter=ACMR",
from + "...HEAD",
"--",
}
if path.Old != path.New {
args = append(args, path.Old)
}
args = append(args, path.New)
patch, err := gitCommandOutput(root, args...)
if err != nil {
return nil, fmt.Errorf("read diff for %s: %w", path.New, err)
}
ranges, err := parseAddedLineRanges(patch)
if err != nil {
return nil, fmt.Errorf("parse diff for %s: %w", path.New, err)
}
out[path.New] = ranges
}
return out, nil
}
func parseChangedGoPaths(raw []byte) ([]changedGoPath, error) {
fields := bytes.Split(raw, []byte{0})
var out []changedGoPath
for i := 0; i < len(fields); {
status := string(fields[i])
i++
if status == "" {
break
}
if i >= len(fields) || len(fields[i]) == 0 {
return nil, fmt.Errorf("truncated name-status record")
}
oldPath := filepath.ToSlash(string(fields[i]))
i++
newPath := oldPath
if status[0] == 'R' || status[0] == 'C' {
if i >= len(fields) || len(fields[i]) == 0 {
return nil, fmt.Errorf("truncated rename/copy record for %q", oldPath)
}
newPath = filepath.ToSlash(string(fields[i]))
i++
if status[0] == 'C' {
// A copy introduces every destination line. Diff only the new
// path so Git presents it as an added file rather than a
// metadata-only copy with no added-line ranges.
oldPath = newPath
}
}
if !strings.HasSuffix(newPath, ".go") {
continue
}
out = append(out, changedGoPath{Old: oldPath, New: newPath})
}
return out, nil
}
func parseAddedLineRanges(patch []byte) ([]addedLineRange, error) {
var out []addedLineRange
for _, raw := range bytes.Split(patch, []byte{'\n'}) {
line := string(raw)
if !strings.HasPrefix(line, "@@") {
continue
}
match := unifiedHunkRE.FindStringSubmatch(line)
if match == nil {
return nil, fmt.Errorf("unsupported unified hunk header %q", line)
}
start, err := strconv.Atoi(match[1])
if err != nil {
return nil, fmt.Errorf("parse added start line in %q: %w", line, err)
}
count := 1
if match[2] != "" {
count, err = strconv.Atoi(match[2])
if err != nil {
return nil, fmt.Errorf("parse added line count in %q: %w", line, err)
}
}
if count == 0 {
continue
}
out = append(out, addedLineRange{Start: start, End: start + count - 1})
}
return out, nil
}
func firstAddedLineInSpan(ranges []addedLineRange, start, end int) (int, bool) {
for _, r := range ranges {
if start <= r.End && end >= r.Start {
if start > r.Start {
return start, true
}
return r.Start, true
}
}
return 0, false
}
func gitCommandOutput(root string, args ...string) ([]byte, error) {
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.Output()
if err == nil {
return out, nil
}
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := strings.TrimSpace(string(exitErr.Stderr))
if stderr != "" {
return nil, fmt.Errorf("%w: %s", err, stderr)
}
}
return nil, err
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import "testing"
func TestParseChangedGoPaths(t *testing.T) {
raw := []byte("M\x00changed.go\x00R100\x00old.go\x00renamed.go\x00C100\x00source.go\x00copied.go\x00A\x00README.md\x00")
got, err := parseChangedGoPaths(raw)
if err != nil {
t.Fatal(err)
}
want := []changedGoPath{
{Old: "changed.go", New: "changed.go"},
{Old: "old.go", New: "renamed.go"},
{Old: "copied.go", New: "copied.go"},
}
if len(got) != len(want) {
t.Fatalf("paths = %#v, want %#v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("paths = %#v, want %#v", got, want)
}
}
}
func TestParseChangedGoPathsRejectsTruncatedRename(t *testing.T) {
if _, err := parseChangedGoPaths([]byte("R100\x00old.go\x00")); err == nil {
t.Fatal("expected truncated rename error")
}
}
func TestParseAddedLineRanges(t *testing.T) {
patch := []byte(`diff --git a/x.go b/x.go
index 1111111..2222222 100644
--- a/x.go
+++ b/x.go
@@ -2,0 +3,2 @@
+first
+second
@@ -10 +12 @@
-old
+new
@@ -20 +21,0 @@
-deleted
`)
got, err := parseAddedLineRanges(patch)
if err != nil {
t.Fatal(err)
}
want := []addedLineRange{{Start: 3, End: 4}, {Start: 12, End: 12}}
if len(got) != len(want) {
t.Fatalf("ranges = %#v, want %#v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("ranges = %#v, want %#v", got, want)
}
}
}
func TestParseAddedLineRangesRejectsUnknownHunk(t *testing.T) {
if _, err := parseAddedLineRanges([]byte("@@@ unsupported @@@\n")); err == nil {
t.Fatal("expected unsupported hunk error")
}
}
func TestFirstAddedLineInSpan(t *testing.T) {
ranges := []addedLineRange{{Start: 5, End: 7}, {Start: 10, End: 10}}
tests := []struct {
start, end int
line int
ok bool
}{
{start: 1, end: 4, ok: false},
{start: 4, end: 6, line: 5, ok: true},
{start: 6, end: 9, line: 6, ok: true},
{start: 8, end: 12, line: 10, ok: true},
}
for _, tc := range tests {
line, ok := firstAddedLineInSpan(ranges, tc.start, tc.end)
if line != tc.line || ok != tc.ok {
t.Errorf(
"firstAddedLineInSpan(%d, %d) = (%d, %v), want (%d, %v)",
tc.start,
tc.end,
line,
ok,
tc.line,
tc.ok,
)
}
}
}

View File

@@ -0,0 +1,126 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
publicDomainsPath = "internal/qualitygate/config/allowlists/public-domains.txt"
fixtureDomainsPath = "internal/qualitygate/config/allowlists/fixture-domains.txt"
)
type domainPolicyEntry struct {
Host string
File string
Line int
}
type domainPolicy struct {
Public map[string]domainPolicyEntry
Fixtures map[string]domainPolicyEntry
}
// isReservedExampleHostname recognizes only names reserved by RFC 2606 for
// examples, testing, invalid-name examples, and localhost use. These names are
// safe source placeholders and are policy exceptions, not supported public
// endpoints.
func isReservedExampleHostname(host string) bool {
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
switch host {
case "example.com", "example.net", "example.org":
return true
}
labels := strings.Split(host, ".")
switch labels[len(labels)-1] {
case "test", "example", "invalid", "localhost":
return true
default:
return false
}
}
func loadDomainPolicy(root string) (domainPolicy, error) {
public, err := loadDomainList(root, publicDomainsPath)
if err != nil {
return domainPolicy{}, err
}
fixtures, err := loadDomainList(root, fixtureDomainsPath)
if err != nil {
return domainPolicy{}, err
}
for host, entry := range fixtures {
if publicEntry, ok := public[host]; ok {
return domainPolicy{}, fmt.Errorf(
"%s:%d: hostname %q is already listed at %s:%d",
entry.File, entry.Line, host, publicEntry.File, publicEntry.Line,
)
}
}
return domainPolicy{Public: public, Fixtures: fixtures}, nil
}
func loadDomainList(root, rel string) (map[string]domainPolicyEntry, error) {
path := filepath.Join(root, filepath.FromSlash(rel))
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open domain allowlist %s: %w", rel, err)
}
defer file.Close()
entries := map[string]domainPolicyEntry{}
var previous string
scanner := bufio.NewScanner(file)
for line := 1; scanner.Scan(); line++ {
host := strings.TrimSpace(scanner.Text())
if host == "" || strings.HasPrefix(host, "#") {
continue
}
if host != strings.ToLower(host) {
return nil, fmt.Errorf("%s:%d: hostname must be lowercase: %q", rel, line, host)
}
if err := validatePolicyHostname(host); err != nil {
return nil, fmt.Errorf("%s:%d: %w", rel, line, err)
}
if previous != "" && host <= previous {
return nil, fmt.Errorf("%s:%d: hostnames must be unique and sorted: %q", rel, line, host)
}
entries[host] = domainPolicyEntry{Host: host, File: rel, Line: line}
previous = host
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read domain allowlist %s: %w", rel, err)
}
if len(entries) == 0 {
return nil, fmt.Errorf("%s: domain list must not be empty", rel)
}
return entries, nil
}
func validatePolicyHostname(host string) error {
if len(host) > 253 || !strings.Contains(host, ".") || strings.HasSuffix(host, ".") {
return fmt.Errorf("invalid exact hostname %q", host)
}
labels := strings.Split(host, ".")
for _, label := range labels {
if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
return fmt.Errorf("invalid exact hostname %q", host)
}
for _, r := range label {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
continue
}
return fmt.Errorf("invalid exact hostname %q", host)
}
}
if !strings.ContainsAny(labels[len(labels)-1], "abcdefghijklmnopqrstuvwxyz") {
return fmt.Errorf("invalid exact hostname %q", host)
}
return nil
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"strings"
"testing"
)
func TestLoadDomainPolicy(t *testing.T) {
root := t.TempDir()
writeFile(t, root, publicDomainsPath, "# public\napi.example.com\nwww.example.com\n")
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
policy, err := loadDomainPolicy(root)
if err != nil {
t.Fatal(err)
}
if len(policy.Public) != 2 || len(policy.Fixtures) != 1 {
t.Fatalf("unexpected policy sizes: public=%d fixtures=%d", len(policy.Public), len(policy.Fixtures))
}
if policy.Public["api.example.com"].Line != 2 {
t.Fatalf("api.example.com line = %d, want 2", policy.Public["api.example.com"].Line)
}
}
func TestLoadDomainPolicyRejectsInvalidLists(t *testing.T) {
tests := []struct {
name string
public string
fixtures string
want string
}{
{
name: "uppercase",
public: "API.example.com\n",
fixtures: "fixture.example.com\n",
want: "must be lowercase",
},
{
name: "unsorted",
public: "www.example.com\napi.example.com\n",
fixtures: "fixture.example.com\n",
want: "unique and sorted",
},
{
name: "duplicate",
public: "api.example.com\napi.example.com\n",
fixtures: "fixture.example.com\n",
want: "unique and sorted",
},
{
name: "wildcard",
public: "*.example.com\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "scheme",
public: "https://example.com\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "path",
public: "api.example.com/v1\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "port",
public: "api.example.com:443\n",
fixtures: "fixture.example.com\n",
want: "invalid exact hostname",
},
{
name: "cross-list duplicate",
public: "api.example.com\n",
fixtures: "api.example.com\n",
want: "already listed",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
writeFile(t, root, publicDomainsPath, tc.public)
writeFile(t, root, fixtureDomainsPath, tc.fixtures)
_, err := loadDomainPolicy(root)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("loadDomainPolicy() error = %v, want substring %q", err, tc.want)
}
})
}
}
func TestReservedExampleHostname(t *testing.T) {
for _, host := range []string{
"example.com",
"example.net",
"example.org",
"example.test",
"docs.example",
"missing.invalid",
"service.localhost",
} {
if !isReservedExampleHostname(host) {
t.Errorf("%q should be a reserved example hostname", host)
}
}
for _, host := range []string{
"attacker.example.com",
"example.dev",
"private.corp.internal",
} {
if isReservedExampleHostname(host) {
t.Errorf("%q must still require policy approval", host)
}
}
}

View File

@@ -1,8 +1,8 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package domaincontract guards the Go CLI against direct reuse of the current
// resolver-owned host FQDNs outside core.ResolveEndpoints.
// Package domaincontract guards resolver ownership and rejects newly introduced
// static Go hostnames that are not covered by the repository domain policy.
package domaincontract
import (
@@ -11,6 +11,7 @@ import (
"go/token"
"io/fs"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -75,10 +76,40 @@ func skipDir(name string) bool {
return false
}
// ScanRepo walks production .go files under root and flags string literals
// containing a forbidden resolver host outside the allowlist. Comments and
// _test.go files are not scanned.
// ScanRepo runs the resolver-owned endpoint guard and a full repository domain
// inventory. CI should use ScanRepoWithOptions with a changed-from revision so
// historical unapproved domains are not attributed to an unrelated change.
func ScanRepo(root string) ([]lintapi.Violation, error) {
return ScanRepoWithOptions(root, ScanOptions{})
}
type ScanOptions struct {
ChangedFrom string
}
func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) {
out, err := scanHardcodedEndpoints(root)
if err != nil {
return nil, err
}
domainViolations, err := scanUnapprovedDomains(root, opts)
if err != nil {
return nil, err
}
out = append(out, domainViolations...)
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 scanHardcodedEndpoints(root string) ([]lintapi.Violation, error) {
var out []lintapi.Violation
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {

View File

@@ -0,0 +1,911 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"fmt"
"go/ast"
"go/constant"
"go/parser"
"go/token"
"go/types"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"unicode"
"github.com/larksuite/cli/lint/lintapi"
"golang.org/x/tools/go/packages"
)
const (
unapprovedDomainRule = "unapproved-domain"
unusedDomainRule = "domain-allowlist-unused"
incompleteDomainRule = "domain-scan-incomplete"
)
type typedGoFile struct {
File *ast.File
Fset *token.FileSet
Info *types.Info
}
type domainEvidence struct {
Host string
Kind string
Expr ast.Expr
}
type evidenceKey struct {
Host string
Start, End token.Pos
}
type fileDomainScan struct {
File *ast.File
Fset *token.FileSet
Info *types.Info
Evidence []domainEvidence
TypeInfoRequired []ast.Expr
seen map[evidenceKey]bool
parents map[ast.Node]ast.Node
}
type collectionCompositeKind uint8
const (
notCollectionComposite collectionCompositeKind = iota
sequenceComposite
mapComposite
)
type hostnameFieldID struct {
Type string
Field string
}
var nonNetworkHostnameFields = map[hostnameFieldID]bool{
{Type: "github.com/larksuite/cli/events/im.CardActionTriggerOutput", Field: "Host"}: true,
{Type: "github.com/larksuite/cli/internal/cmdmeta.Meta", Field: "Domain"}: true,
}
func scanUnapprovedDomains(root string, opts ScanOptions) ([]lintapi.Violation, error) {
root, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve repository root: %w", err)
}
publicPath := filepath.Join(root, filepath.FromSlash(publicDomainsPath))
if _, err := os.Stat(publicPath); err != nil {
if os.IsNotExist(err) {
if _, goModErr := os.Stat(filepath.Join(root, "go.mod")); os.IsNotExist(goModErr) {
return nil, nil
}
}
return nil, fmt.Errorf("domain policy unavailable: %w", err)
}
policy, err := loadDomainPolicy(root)
if err != nil {
return nil, err
}
added, err := changedGoLineRanges(root, opts.ChangedFrom)
if err != nil {
return nil, err
}
typed, typeLoadErr := loadTypedGoFiles(root)
goFiles, err := trackedGoFiles(root)
if err != nil {
return nil, err
}
observedPublic := map[string]bool{}
observedFixtures := map[string]bool{}
inventoryComplete := typeLoadErr == nil
var out []lintapi.Violation
parseFailureReported := false
typeInfoGapReported := false
for _, rel := range goFiles {
path := filepath.Join(root, filepath.FromSlash(rel))
parsedFset := token.NewFileSet()
parsedFile, parseErr := parser.ParseFile(parsedFset, path, nil, 0)
if parseErr != nil {
inventoryComplete = false
if opts.ChangedFrom == "" {
out = append(out, incompleteDomainViolation(rel, parseErr))
parseFailureReported = true
} else if _, changed := added[rel]; changed {
out = append(out, incompleteDomainViolation(rel, parseErr))
parseFailureReported = true
}
continue
}
tf, ok := typed[filepath.Clean(path)]
if !ok {
tf = typedGoFile{File: parsedFile, Fset: parsedFset}
}
scan := newFileDomainScan(tf)
scan.collectSemanticEvidence()
scan.collectAbsoluteURLEvidence()
if len(scan.TypeInfoRequired) > 0 {
// Inventory completeness is a property of the whole HEAD. Whether
// this PR owns an incomplete-scan diagnostic is decided separately
// by the added-line intersection below.
inventoryComplete = false
}
for _, expr := range scan.TypeInfoRequired {
start := tf.Fset.Position(expr.Pos()).Line
end := tf.Fset.Position(expr.End()).Line
line := start
if opts.ChangedFrom != "" {
var intersects bool
line, intersects = firstAddedLineInSpan(added[rel], start, end)
if !intersects {
continue
}
}
typeInfoGapReported = true
out = append(out, incompleteDomainViolationAt(
rel,
line,
fmt.Errorf("Go type information unavailable for hostname-oriented field evidence"),
))
break
}
fixture := isDomainFixturePath(rel)
// The detector's own policy literals and contract corpus may be
// scanned, but they cannot justify keeping an allowlist entry.
policyOwner := strings.HasPrefix(rel, "lint/domaincontract/")
for _, evidence := range scan.Evidence {
if isReservedExampleHostname(evidence.Host) {
continue
}
if _, ok := policy.Public[evidence.Host]; ok {
if !fixture && !policyOwner {
observedPublic[evidence.Host] = true
}
continue
}
if _, ok := policy.Fixtures[evidence.Host]; ok && fixture {
if !policyOwner {
observedFixtures[evidence.Host] = true
}
continue
}
start := tf.Fset.Position(evidence.Expr.Pos()).Line
end := tf.Fset.Position(evidence.Expr.End()).Line
line := start
if opts.ChangedFrom != "" {
var intersects bool
line, intersects = firstAddedLineInSpan(added[rel], start, end)
if !intersects {
continue
}
}
suggestion := "remove the hostname or replace it with an approved public endpoint; " +
"public allowlist additions require evidence and CODEOWNER approval"
if _, fixtureOnly := policy.Fixtures[evidence.Host]; fixtureOnly && !fixture {
suggestion = "remove the fixture-only hostname or move this use into an approved fixture scope; " +
"fixture entries are not approved for production Go code or skills"
}
out = append(out, lintapi.Violation{
Rule: unapprovedDomainRule,
Action: lintapi.ActionReject,
File: rel,
Line: line,
Message: fmt.Sprintf(
"unapproved hostname %q found in %s",
evidence.Host,
evidence.Kind,
),
Suggestion: suggestion,
})
}
}
// A syntax error is also surfaced by go/packages. Prefer the file-specific
// parse diagnostic when one was already reported; otherwise make a
// repository-wide type-loading failure explicit instead of silently
// continuing without the type information required by field evidence.
if typeLoadErr != nil && !parseFailureReported && !typeInfoGapReported {
out = append(out, incompleteDomainViolation("go.mod", typeLoadErr))
}
if inventoryComplete {
for host, entry := range policy.Public {
if !observedPublic[host] {
out = append(out, unusedDomainViolation(entry))
}
}
for host, entry := range policy.Fixtures {
if !observedFixtures[host] {
out = append(out, unusedDomainViolation(entry))
}
}
}
return out, nil
}
func trackedGoFiles(root string) ([]string, error) {
out, err := gitCommandOutput(root, "ls-files", "-z", "--", "*.go")
if err != nil {
return nil, fmt.Errorf("list tracked Go files: %w", err)
}
var files []string
for _, raw := range strings.Split(string(out), "\x00") {
if raw == "" {
continue
}
rel := filepath.ToSlash(raw)
if strings.HasPrefix(rel, "vendor/") || strings.HasPrefix(rel, "node_modules/") {
continue
}
files = append(files, rel)
}
return files, nil
}
func loadTypedGoFiles(root string) (map[string]typedGoFile, error) {
moduleDirs, err := trackedGoModuleDirs(root)
if err != nil {
return nil, err
}
out := map[string]typedGoFile{}
var firstLoadErr error
var loadErrCount int
for _, moduleDir := range moduleDirs {
moduleRoot := root
if moduleDir != "." {
moduleRoot = filepath.Join(root, filepath.FromSlash(moduleDir))
}
files, err := loadTypedGoModule(moduleRoot)
for path, file := range files {
out[path] = file
}
if err != nil {
loadErrCount++
if firstLoadErr == nil {
firstLoadErr = err
}
}
}
if loadErrCount == 1 {
return out, firstLoadErr
}
if loadErrCount > 1 {
return out, fmt.Errorf("%w (and %d more module errors)", firstLoadErr, loadErrCount-1)
}
return out, nil
}
func trackedGoModuleDirs(root string) ([]string, error) {
raw, err := gitCommandOutput(root, "ls-files", "-z")
if err != nil {
return nil, fmt.Errorf("list tracked Go modules: %w", err)
}
var dirs []string
for _, path := range strings.Split(string(raw), "\x00") {
path = filepath.ToSlash(path)
if path != "go.mod" && !strings.HasSuffix(path, "/go.mod") {
continue
}
dir := filepath.ToSlash(filepath.Dir(path))
dirs = append(dirs, dir)
}
return dirs, nil
}
func loadTypedGoModule(moduleRoot string) (map[string]typedGoFile, error) {
fset := token.NewFileSet()
cfg := &packages.Config{
Mode: packages.NeedName |
packages.NeedFiles |
packages.NeedCompiledGoFiles |
packages.NeedImports |
packages.NeedDeps |
packages.NeedTypes |
packages.NeedSyntax |
packages.NeedTypesInfo,
Dir: moduleRoot,
Fset: fset,
Tests: true,
}
pkgs, err := packages.Load(cfg, "./...")
if err != nil {
return nil, fmt.Errorf("load Go type information: %w", err)
}
out := map[string]typedGoFile{}
var firstPackageErr string
var packageErrCount int
packages.Visit(pkgs, nil, func(pkg *packages.Package) {
if pkg == nil {
return
}
for _, pkgErr := range pkg.Errors {
packageErrCount++
if firstPackageErr == "" {
firstPackageErr = pkgErr.Error()
}
}
if pkg.TypesInfo == nil || pkg.Fset == nil {
return
}
for i, file := range pkg.Syntax {
if i >= len(pkg.CompiledGoFiles) {
break
}
path := filepath.Clean(pkg.CompiledGoFiles[i])
if _, exists := out[path]; exists {
continue
}
out[path] = typedGoFile{File: file, Fset: pkg.Fset, Info: pkg.TypesInfo}
}
})
if packageErrCount == 1 {
return out, fmt.Errorf("load Go type information: %s", firstPackageErr)
}
if packageErrCount > 1 {
return out, fmt.Errorf(
"load Go type information: %s (and %d more package errors)",
firstPackageErr,
packageErrCount-1,
)
}
return out, nil
}
func newFileDomainScan(file typedGoFile) *fileDomainScan {
return &fileDomainScan{
File: file.File,
Fset: file.Fset,
Info: file.Info,
seen: map[evidenceKey]bool{},
parents: astParentMap(file.File),
}
}
func (s *fileDomainScan) collectSemanticEvidence() {
ast.Inspect(s.File, func(node ast.Node) bool {
switch n := node.(type) {
case *ast.AssignStmt:
if len(n.Lhs) != len(n.Rhs) {
return true
}
for i, lhs := range n.Lhs {
if s.Info == nil &&
potentialHostnameSelectorTarget(lhs) &&
s.hasStaticBareHostnameValue(n.Rhs[i]) {
s.requireTypeInfo(n.Rhs[i])
}
if index, ok := stripParens(lhs).(*ast.IndexExpr); ok {
switch {
case s.isHostnameTarget(index.X):
s.addMapPair(index.Index, n.Rhs[i])
case s.isHostnameMapKey(index.Index):
s.addHostValue(n.Rhs[i], "host assignment")
}
continue
}
if s.isHostnameTarget(lhs) {
s.addHostValue(n.Rhs[i], "host assignment")
}
}
case *ast.ValueSpec:
if len(n.Names) != len(n.Values) {
return true
}
for i, name := range n.Names {
if isHostnameSemanticName(name.Name) {
s.addHostValue(n.Values[i], "host assignment")
}
}
case *ast.KeyValueExpr:
if s.Info == nil && s.keyValueNeedsTypeInfo(n) {
s.requireTypeInfo(n.Value)
}
if s.isHostnameKeyValue(n) {
s.addHostValue(n.Value, "host assignment")
}
}
return true
})
}
func (s *fileDomainScan) requireTypeInfo(expr ast.Expr) {
for _, existing := range s.TypeInfoRequired {
if existing.Pos() == expr.Pos() && existing.End() == expr.End() {
return
}
}
s.TypeInfoRequired = append(s.TypeInfoRequired, expr)
}
func (s *fileDomainScan) hasStaticBareHostnameValue(expr ast.Expr) bool {
value, ok := staticStringValue(expr, s.Info, nil)
if !ok {
return false
}
host, ok := semanticHostname(value)
return ok && !isReservedExampleHostname(host)
}
func (s *fileDomainScan) keyValueNeedsTypeInfo(pair *ast.KeyValueExpr) bool {
composite, ok := s.parents[pair].(*ast.CompositeLit)
if !ok {
return false
}
if _, explicitMap := composite.Type.(*ast.MapType); explicitMap {
return false
}
key, ok := pair.Key.(*ast.Ident)
return ok && isHostnameSemanticName(key.Name) && s.hasStaticBareHostnameValue(pair.Value)
}
func potentialHostnameSelectorTarget(expr ast.Expr) bool {
switch n := stripParens(expr).(type) {
case *ast.SelectorExpr:
return isHostnameSemanticName(n.Sel.Name)
case *ast.StarExpr:
return potentialHostnameSelectorTarget(n.X)
case *ast.IndexExpr:
return potentialHostnameSelectorTarget(n.X)
default:
return false
}
}
func (s *fileDomainScan) collectAbsoluteURLEvidence() {
ast.Inspect(s.File, func(node ast.Node) bool {
expr, ok := node.(ast.Expr)
if !ok {
return true
}
if ident, ok := expr.(*ast.Ident); ok && s.Info != nil && s.Info.Defs[ident] != nil {
// A declaration name may carry the constant value in types.Info,
// but it is not a second source expression.
return true
}
value, ok := staticStringValue(expr, s.Info, nil)
if !ok {
return true
}
if s.hasStaticStringContainer(expr) {
return true
}
host, ok := absoluteURLHostname(value)
if ok {
s.addEvidence(host, "absolute URL", expr)
}
return true
})
}
func (s *fileDomainScan) hasStaticStringContainer(expr ast.Expr) bool {
parent, ok := s.parents[expr].(ast.Expr)
if !ok {
return false
}
switch parent.(type) {
case *ast.BinaryExpr, *ast.ParenExpr:
_, ok := staticStringValue(parent, s.Info, nil)
return ok
default:
return false
}
}
func (s *fileDomainScan) addHostValue(expr ast.Expr, kind string) {
expr = stripParens(expr)
if composite, ok := expr.(*ast.CompositeLit); ok {
switch s.collectionCompositeKind(composite) {
case sequenceComposite:
for _, element := range composite.Elts {
if valueExpr, ok := element.(ast.Expr); ok {
s.addHostValue(valueExpr, "host collection")
}
}
case mapComposite:
for _, element := range composite.Elts {
pair, ok := element.(*ast.KeyValueExpr)
if !ok {
continue
}
keyExpr, ok := pair.Key.(ast.Expr)
if !ok {
continue
}
s.addMapPair(keyExpr, pair.Value)
}
default:
if s.Info == nil {
s.requireTypeInfoForUnclassifiedCollection(composite)
}
return
}
return
}
if evidence, ok := s.hostnameEvidence(expr, kind); ok {
s.addEvidence(evidence.Host, evidence.Kind, evidence.Expr)
}
}
func (s *fileDomainScan) requireTypeInfoForUnclassifiedCollection(composite *ast.CompositeLit) {
for _, element := range composite.Elts {
if pair, ok := element.(*ast.KeyValueExpr); ok {
keyExpr, ok := pair.Key.(ast.Expr)
if !ok {
continue
}
keyIsHost := s.hasStaticBareHostnameValue(keyExpr)
valueIsHost := s.hasStaticBareHostnameValue(pair.Value)
if keyIsHost == valueIsHost {
continue
}
if keyIsHost {
s.requireTypeInfo(keyExpr)
} else {
s.requireTypeInfo(pair.Value)
}
continue
}
valueExpr, ok := element.(ast.Expr)
if ok && s.hasStaticBareHostnameValue(valueExpr) {
s.requireTypeInfo(valueExpr)
}
}
}
// addMapPair reports a map side only when it is the sole hostname-shaped
// static value. A semantic map name does not establish whether a string map
// is hostname->metadata or alias->hostname, so reporting both sides would turn
// filenames such as client.pem into blocking hostname evidence.
func (s *fileDomainScan) addMapPair(key, value ast.Expr) {
keyEvidence, keyOK := s.hostnameEvidence(key, "host collection")
valueEvidence, valueOK := s.hostnameEvidence(value, "host collection")
if keyOK == valueOK {
return
}
if keyOK {
s.addEvidence(keyEvidence.Host, keyEvidence.Kind, keyEvidence.Expr)
return
}
s.addEvidence(valueEvidence.Host, valueEvidence.Kind, valueEvidence.Expr)
}
func (s *fileDomainScan) hostnameEvidence(expr ast.Expr, kind string) (domainEvidence, bool) {
expr = stripParens(expr)
value, ok := staticStringValue(expr, s.Info, nil)
if !ok {
return domainEvidence{}, false
}
if host, ok := absoluteURLHostname(value); ok {
return domainEvidence{Host: host, Kind: "absolute URL", Expr: expr}, true
}
if host, ok := semanticHostname(value); ok {
return domainEvidence{Host: host, Kind: kind, Expr: expr}, true
}
return domainEvidence{}, false
}
func (s *fileDomainScan) collectionCompositeKind(expr *ast.CompositeLit) collectionCompositeKind {
if s.Info != nil {
if tv, ok := s.Info.Types[expr]; ok && tv.Type != nil {
switch tv.Type.Underlying().(type) {
case *types.Array, *types.Slice:
return sequenceComposite
case *types.Map:
return mapComposite
}
}
}
switch expr.Type.(type) {
case *ast.ArrayType:
return sequenceComposite
case *ast.MapType:
return mapComposite
default:
return notCollectionComposite
}
}
func (s *fileDomainScan) addEvidence(host, kind string, expr ast.Expr) {
key := evidenceKey{Host: host, Start: expr.Pos(), End: expr.End()}
if s.seen[key] {
return
}
s.seen[key] = true
s.Evidence = append(s.Evidence, domainEvidence{Host: host, Kind: kind, Expr: expr})
}
func staticStringValue(expr ast.Expr, info *types.Info, seen map[*ast.Object]bool) (string, bool) {
if info != nil {
if tv, ok := info.Types[expr]; ok && tv.Value != nil && tv.Value.Kind() == constant.String {
return constant.StringVal(tv.Value), true
}
}
switch n := expr.(type) {
case *ast.BasicLit:
if n.Kind != token.STRING {
return "", false
}
value, err := strconv.Unquote(n.Value)
return value, err == nil
case *ast.ParenExpr:
return staticStringValue(n.X, info, seen)
case *ast.BinaryExpr:
if n.Op != token.ADD {
return "", false
}
left, ok := staticStringValue(n.X, info, seen)
if !ok {
return "", false
}
right, ok := staticStringValue(n.Y, info, seen)
if !ok {
return "", false
}
return left + right, true
case *ast.Ident:
if info != nil {
if obj := info.ObjectOf(n); obj != nil {
if c, ok := obj.(*types.Const); ok {
if c.Val().Kind() == constant.String {
return constant.StringVal(c.Val()), true
}
}
}
}
if n.Obj == nil || n.Obj.Kind != ast.Con {
return "", false
}
if seen == nil {
seen = map[*ast.Object]bool{}
}
if seen[n.Obj] {
return "", false
}
seen[n.Obj] = true
defer delete(seen, n.Obj)
spec, ok := n.Obj.Decl.(*ast.ValueSpec)
if !ok {
return "", false
}
for i, name := range spec.Names {
if name.Name == n.Name && i < len(spec.Values) {
return staticStringValue(spec.Values[i], info, seen)
}
}
}
return "", false
}
func absoluteURLHostname(value string) (string, bool) {
value = strings.TrimSpace(value)
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" {
return "", false
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https", "ws", "wss":
default:
return "", false
}
return normalizeCandidateHostname(parsed.Hostname())
}
func semanticHostname(value string) (string, bool) {
value = strings.TrimSpace(value)
if value == "" || strings.ContainsAny(value, `/\?#@`) || strings.ContainsAny(value, " \t\r\n") {
return "", false
}
parsed, err := url.Parse("//" + value)
if err != nil || parsed.Host == "" || parsed.Path != "" {
return "", false
}
return normalizeCandidateHostname(parsed.Hostname())
}
func normalizeCandidateHostname(host string) (string, bool) {
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
if host == "" || !strings.Contains(host, ".") || net.ParseIP(host) != nil {
return "", false
}
labels := strings.Split(host, ".")
for _, label := range labels {
if label == "" || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return "", false
}
for _, r := range label {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
continue
}
return "", false
}
}
return host, true
}
func (s *fileDomainScan) isHostnameTarget(expr ast.Expr) bool {
switch n := stripParens(expr).(type) {
case *ast.Ident:
return isHostnameSemanticName(n.Name)
case *ast.SelectorExpr:
return s.isHostnameSelector(n)
case *ast.StarExpr:
return s.isHostnameTarget(n.X)
default:
return false
}
}
func (s *fileDomainScan) isHostnameKeyValue(pair *ast.KeyValueExpr) bool {
composite, ok := s.parents[pair].(*ast.CompositeLit)
if !ok {
return false
}
switch s.collectionCompositeKind(composite) {
case mapComposite:
key, ok := pair.Key.(ast.Expr)
return ok && s.isHostnameMapKey(key)
case notCollectionComposite:
ident, ok := pair.Key.(*ast.Ident)
return ok && s.isHostnameStructField(composite, ident.Name)
default:
return false
}
}
func (s *fileDomainScan) isHostnameMapKey(expr ast.Expr) bool {
value, ok := staticStringValue(expr, s.Info, nil)
return ok && isHostnameSemanticName(value)
}
func (s *fileDomainScan) isHostnameSelector(selector *ast.SelectorExpr) bool {
if s.Info == nil || !isHostnameSemanticName(selector.Sel.Name) {
return false
}
selection := s.Info.Selections[selector]
if selection == nil || selection.Kind() != types.FieldVal {
return false
}
return !nonNetworkHostnameFields[hostnameFieldID{
Type: namedTypeID(selection.Recv()),
Field: selector.Sel.Name,
}]
}
func (s *fileDomainScan) isHostnameStructField(composite *ast.CompositeLit, field string) bool {
if s.Info == nil || !isHostnameSemanticName(field) {
return false
}
typeID := namedTypeID(s.Info.TypeOf(composite))
if typeID == "" {
return false
}
return !nonNetworkHostnameFields[hostnameFieldID{Type: typeID, Field: field}]
}
func namedTypeID(typ types.Type) string {
for {
switch t := typ.(type) {
case *types.Pointer:
typ = t.Elem()
case *types.Named:
obj := t.Obj()
if obj == nil || obj.Pkg() == nil {
return ""
}
return obj.Pkg().Path() + "." + obj.Name()
default:
return ""
}
}
}
func isHostnameSemanticName(name string) bool {
lower := strings.ToLower(name)
switch lower {
case "host", "hosts", "hostname", "hostnames", "domain", "domains":
return true
}
for _, marker := range []string{
"HostBy", "HostsBy", "HostnameBy", "HostnamesBy", "DomainBy", "DomainsBy",
} {
if i := strings.Index(name, marker); i >= 0 {
end := i + len(marker)
if end < len(name) && unicode.IsUpper(rune(name[end])) {
return true
}
}
}
for _, prefix := range []string{
"hostBy", "hostsBy", "hostnameBy", "hostnamesBy", "domainBy", "domainsBy",
} {
if strings.HasPrefix(name, prefix) &&
len(name) > len(prefix) &&
unicode.IsUpper(rune(name[len(prefix)])) {
return true
}
}
if i := strings.LastIndexAny(name, "_-"); i >= 0 {
return isHostnameSemanticName(name[i+1:])
}
for _, suffix := range []string{"Hostnames", "Hostname", "Domains", "Domain", "Hosts", "Host"} {
if strings.HasSuffix(name, suffix) && len(name) > len(suffix) {
return true
}
}
return false
}
func stripParens(expr ast.Expr) ast.Expr {
for {
paren, ok := expr.(*ast.ParenExpr)
if !ok {
return expr
}
expr = paren.X
}
}
func astParentMap(root ast.Node) map[ast.Node]ast.Node {
parents := map[ast.Node]ast.Node{}
var stack []ast.Node
ast.Inspect(root, func(node ast.Node) bool {
if node == nil {
stack = stack[:len(stack)-1]
return false
}
if len(stack) > 0 {
parents[node] = stack[len(stack)-1]
}
stack = append(stack, node)
return true
})
return parents
}
func isDomainFixturePath(rel string) bool {
rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, "skills/") {
return false
}
if strings.HasSuffix(rel, "_test.go") || strings.HasPrefix(rel, "tests/") {
return true
}
for _, part := range strings.Split(rel, "/") {
if part == "testdata" {
return true
}
}
return false
}
func unusedDomainViolation(entry domainPolicyEntry) lintapi.Violation {
return lintapi.Violation{
Rule: unusedDomainRule,
Action: lintapi.ActionReject,
File: entry.File,
Line: entry.Line,
Message: fmt.Sprintf("domain allowlist entry %q has no in-scope Go reference", entry.Host),
Suggestion: "remove the unused entry; allowlist entries must be justified by a current in-scope reference",
}
}
func incompleteDomainViolation(file string, err error) lintapi.Violation {
return incompleteDomainViolationAt(file, 1, err)
}
func incompleteDomainViolationAt(file string, line int, err error) lintapi.Violation {
return lintapi.Violation{
Rule: incompleteDomainRule,
Action: lintapi.ActionReject,
File: file,
Line: line,
Message: "domain scan incomplete: " + err.Error(),
Suggestion: "fix the Go parse or type-loading error so hostname analysis can complete",
}
}

View File

@@ -0,0 +1,462 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/lint/lintapi"
)
func gitTestCommand(t *testing.T, root string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
func setupDomainDiffRepo(t *testing.T, target string) (root, base string) {
t.Helper()
root = t.TempDir()
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n")
writeFile(t, root, publicDomainsPath, "# public\npublic.example.com\n")
writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n")
writeFile(t, root, "policy_refs.go", "package sample\n\nvar APIHost = \"public.example.com\"\n")
writeFile(t, root, "policy_refs_test.go", "package sample\n\nvar FixtureHost = \"fixture.example.com\"\n")
writeFile(t, root, "target.go", target)
gitTestCommand(t, root, "init", "-q")
gitTestCommand(t, root, "config", "user.name", "Domain Contract Test")
gitTestCommand(t, root, "config", "user.email", "domain-contract@example.com")
gitTestCommand(t, root, "add", ".")
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", "base")
return root, gitTestCommand(t, root, "rev-parse", "HEAD")
}
func commitDomainDiff(t *testing.T, root, message string) {
t.Helper()
gitTestCommand(t, root, "add", "-A")
gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", message)
}
func violationsForRule(vs []lintapi.Violation, rule string) []lintapi.Violation {
var out []lintapi.Violation
for _, v := range vs {
if v.Rule == rule {
out = append(out, v)
}
}
return out
}
func scanDomainDiff(t *testing.T, root, base string) []lintapi.Violation {
t.Helper()
vs, err := ScanRepoWithOptions(root, ScanOptions{ChangedFrom: base})
if err != nil {
t.Fatal(err)
}
return vs
}
func TestUnapprovedDomainDiffContract(t *testing.T) {
t.Run("new PR 1975 case", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar APIHost = \"internal-api-drive-stream.larkoffice.com\"\n")
commitDomainDiff(t, root, "add internal host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "internal-api-drive-stream.larkoffice.com") {
t.Fatalf("violations = %+v, want PR 1975 hostname", got)
}
})
t.Run("hostname field in nested Go module", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "nested/go.mod", "module example.com/nested\n\ngo 1.23.0\n")
writeFile(t, root, "nested/target.go",
"package nested\n\ntype Config struct{ Host string }\n\n"+
"var config = Config{Host: \"private.corp.internal\"}\n")
commitDomainDiff(t, root, "add nested module hostname")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, unapprovedDomainRule)
if len(got) != 1 || filepath.ToSlash(got[0].File) != "nested/target.go" ||
!strings.Contains(got[0].Message, "private.corp.internal") {
t.Fatalf("violations = %+v, want nested-module hostname rejection", got)
}
if incomplete := violationsForRule(all, incompleteDomainRule); len(incomplete) != 0 {
t.Fatalf("nested module must have complete type information: %+v", incomplete)
}
})
t.Run("changed excluded field reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type Config struct{ Host string }\n\n"+
"var config = Config{Host: \"private.corp.internal\"}\n")
commitDomainDiff(t, root, "add excluded hostname field")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
t.Fatalf("violations = %+v, want changed field scan-incomplete at line 7", got)
}
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
t.Fatalf("untyped field must not produce an unverified hostname finding: %+v", unapproved)
}
})
t.Run("changed excluded selector reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type Config struct{ Host string }\n\n"+
"func configure(config *Config) { config.Host = \"private.corp.internal\" }\n")
commitDomainDiff(t, root, "add excluded hostname selector")
got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 {
t.Fatalf("violations = %+v, want changed selector scan-incomplete at line 7", got)
}
})
t.Run("changed excluded named slice reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type HostList []string\n\n"+
"var AllowedHosts = HostList{\n\t\"attacker.zip\",\n}\n")
commitDomainDiff(t, root, "add excluded hostname slice")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
t.Fatalf("violations = %+v, want named-slice scan-incomplete at line 8", got)
}
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
t.Fatalf("untyped named slice must not produce an unverified hostname finding: %+v", unapproved)
}
})
t.Run("changed excluded named map reports incomplete scan", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type HostSet map[string]struct{}\n\n"+
"var AllowedHosts = HostSet{\n\t\"attacker.zip\": {},\n}\n")
commitDomainDiff(t, root, "add excluded hostname map")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 {
t.Fatalf("violations = %+v, want named-map scan-incomplete at line 8", got)
}
if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 {
t.Fatalf("untyped named map must not produce an unverified hostname finding: %+v", unapproved)
}
})
t.Run("changed excluded unrelated code stays allowed", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\nvar unrelated = 2\n")
commitDomainDiff(t, root, "add excluded unrelated code")
if got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule); len(got) != 0 {
t.Fatalf("unrelated excluded code must not require hostname type information: %+v", got)
}
})
t.Run("new element in existing collection", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n}\n")
writeFile(t, root, "target.go",
"package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n\t\"attacker.zip\",\n}\n")
commitDomainDiff(t, root, "add collection host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
t.Fatalf("violations = %+v, want attacker.zip", got)
}
if got[0].Line != 5 {
t.Fatalf("violation line = %d, want 5", got[0].Line)
}
})
t.Run("multiline expression changed segment", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"example.com\"\n")
writeFile(t, root, "target.go",
"package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"internal\"\n")
commitDomainDiff(t, root, "change concatenated host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
t.Fatalf("violations = %+v, want private.corp.internal", got)
}
if got[0].Line != 4 {
t.Fatalf("violation line = %d, want changed line 4", got[0].Line)
}
})
t.Run("unrelated change beside historical hostname", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
writeFile(t, root, "target.go",
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\nvar unrelated = 1\n")
commitDomainDiff(t, root, "add unrelated value")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected historical-domain violation: %+v", got)
}
})
t.Run("historical hostname expression changed", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
writeFile(t, root, "target.go",
"package sample\n\nvar HistoricalHost = \"replacement.private.internal\"\n")
commitDomainDiff(t, root, "change historical host")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "replacement.private.internal") {
t.Fatalf("violations = %+v, want replacement.private.internal", got)
}
})
t.Run("new assignment references existing constant", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nconst existingConst = \"private.corp.internal\"\n")
writeFile(t, root, "target.go",
"package sample\n\nconst existingConst = \"private.corp.internal\"\nvar APIHost = existingConst\n")
commitDomainDiff(t, root, "use existing hostname constant")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") {
t.Fatalf("violations = %+v, want private.corp.internal", got)
}
if got[0].Line != 4 {
t.Fatalf("violation line = %d, want 4", got[0].Line)
}
})
t.Run("allowlisted hostname", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"public.example.com\"\n")
commitDomainDiff(t, root, "add public host")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected public-domain violation: %+v", got)
}
})
t.Run("reserved example URL", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nfunc fakeValue() string { return \"https://example.test/resource\" }\n")
commitDomainDiff(t, root, "add safe example URL")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected reserved-example violation: %+v", got)
}
})
t.Run("historical type gap suppresses unused policy diagnostics", func(t *testing.T) {
root, _ := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\nplatform.example.com\npublic.example.com\n")
writeFile(t, root, "excluded.go",
"//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+
"type Config struct{ Host string }\n\n"+
"var config = Config{Host: \"platform.example.com\"}\n")
commitDomainDiff(t, root, "add historical platform hostname")
base := gitTestCommand(t, root, "rev-parse", "HEAD")
writeFile(t, root, "target.go", "package sample\n\nvar unrelated = 2\n")
commitDomainDiff(t, root, "change unrelated code")
all := scanDomainDiff(t, root, base)
if got := violationsForRule(all, incompleteDomainRule); len(got) != 0 {
t.Fatalf("historical type gap must not be attributed to this change: %+v", got)
}
if got := violationsForRule(all, unusedDomainRule); len(got) != 0 {
t.Fatalf("incomplete inventory must not produce unused-policy diagnostics: %+v", got)
}
})
t.Run("allowlist does not approve subdomains", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"evil.public.example.com\"\n")
commitDomainDiff(t, root, "add unapproved public subdomain")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.public.example.com") {
t.Fatalf("violations = %+v, want evil.public.example.com", got)
}
})
t.Run("multi assignment pairs names and values", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\nopen.larksuite.com\npublic.example.com\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar APIHost, BackupHost = \"open.larksuite.com\", \"attacker.zip\"\n")
commitDomainDiff(t, root, "add multiple hosts")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") {
t.Fatalf("violations = %+v, want only attacker.zip", got)
}
})
t.Run("IDN hostname is rejected", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar BackupHost = \"例子.公司.cn\"\n")
commitDomainDiff(t, root, "add IDN hostname")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "例子.公司.cn") {
t.Fatalf("violations = %+v, want IDN hostname", got)
}
})
t.Run("fixture limited to test files", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go",
"package sample\n\nvar unrelated = 1\nvar ProductionHost = \"fixture.example.com\"\n")
commitDomainDiff(t, root, "use fixture in production")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
t.Fatalf("violations = %+v, want production fixture rejection", got)
}
if !strings.Contains(got[0].Suggestion, "fixture-only hostname") ||
strings.Contains(got[0].Suggestion, "public allowlist") {
t.Fatalf("suggestion = %q, want fixture-scope guidance", got[0].Suggestion)
}
})
t.Run("fixture accepted in test file", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "new_target_test.go",
"package sample\n\nvar BackupHost = \"fixture.example.com\"\n")
commitDomainDiff(t, root, "use fixture in test")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected fixture-domain violation: %+v", got)
}
})
t.Run("fixture allowlist does not approve subdomains", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "new_target_test.go",
"package sample\n\nvar BackupHost = \"evil.fixture.example.com\"\n")
commitDomainDiff(t, root, "use unapproved fixture subdomain")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "evil.fixture.example.com") {
t.Fatalf("violations = %+v, want exact fixture match", got)
}
})
t.Run("fixture rejected in skills", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "skills/example/example_test.go",
"package example\n\nvar BackupHost = \"fixture.example.com\"\n")
commitDomainDiff(t, root, "use fixture in skill")
got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") {
t.Fatalf("violations = %+v, want skill fixture rejection", got)
}
})
t.Run("pure rename", func(t *testing.T) {
root, base := setupDomainDiffRepo(t,
"package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n")
gitTestCommand(t, root, "mv", "target.go", "renamed.go")
commitDomainDiff(t, root, "rename file")
if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 {
t.Fatalf("unexpected rename violation: %+v", got)
}
})
}
func TestUnapprovedDomainPolicyAndFailurePaths(t *testing.T) {
t.Run("unused policy entry", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\npublic.example.com\nunused.example.com\n")
commitDomainDiff(t, root, "add unused policy")
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "unused.example.com") {
t.Fatalf("violations = %+v, want unused.example.com", got)
}
})
t.Run("public entry used only by fixture", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, publicDomainsPath,
"# public\npublic.example.com\ntest-only.example.com\n")
writeFile(t, root, "public_only_test.go",
"package sample\n\nvar BackupHost = \"test-only.example.com\"\n")
commitDomainDiff(t, root, "add test-only public policy")
got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule)
if len(got) != 1 || !strings.Contains(got[0].Message, "test-only.example.com") {
t.Fatalf("violations = %+v, want test-only.example.com", got)
}
})
t.Run("changed Go parse failure", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "target.go", "package sample\n\nfunc broken(\n")
commitDomainDiff(t, root, "break source")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "target.go" {
t.Fatalf("violations = %+v, want target.go scan-incomplete", got)
}
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
t.Fatalf("parse failure must not produce unreliable unused-policy diagnostics: %+v", unused)
}
})
t.Run("repository type loading failure", func(t *testing.T) {
root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n")
writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n\n"+
"require example.com/missing v0.0.0\n\nreplace example.com/missing => ./missing\n")
writeFile(t, root, "target.go",
"package sample\n\nimport _ \"example.com/missing\"\n\n"+
"type Config struct{ Host string }\nvar config = Config{Host: \"malicious.corp.internal\"}\n")
commitDomainDiff(t, root, "break type loading")
all := scanDomainDiff(t, root, base)
got := violationsForRule(all, incompleteDomainRule)
if len(got) != 1 || filepath.Base(got[0].File) != "go.mod" {
t.Fatalf("violations = %+v, want go.mod scan-incomplete", got)
}
if !strings.Contains(got[0].Message, "load Go type information") {
t.Fatalf("message = %q, want type-loading failure", got[0].Message)
}
if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 {
t.Fatalf("type-loading failure must not produce unreliable unused-policy diagnostics: %+v", unused)
}
})
}

View File

@@ -0,0 +1,380 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package domaincontract
import (
"go/ast"
"go/parser"
"go/token"
"go/types"
"sort"
"testing"
)
func scanDomainEvidence(t *testing.T, source string) []domainEvidence {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
if err != nil {
t.Fatalf("parse fixture: %v\n%s", err, source)
}
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset})
scan.collectSemanticEvidence()
scan.collectAbsoluteURLEvidence()
sort.Slice(scan.Evidence, func(i, j int) bool {
if scan.Evidence[i].Host != scan.Evidence[j].Host {
return scan.Evidence[i].Host < scan.Evidence[j].Host
}
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
})
return scan.Evidence
}
func scanTypedDomainEvidence(t *testing.T, source string) []domainEvidence {
t.Helper()
return scanTypedDomainEvidenceInPackage(t, "fixture", source)
}
func scanTypedDomainEvidenceInPackage(t *testing.T, packagePath, source string) []domainEvidence {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "fixture.go", source, 0)
if err != nil {
t.Fatalf("parse fixture: %v\n%s", err, source)
}
info := &types.Info{
Types: map[ast.Expr]types.TypeAndValue{},
Defs: map[*ast.Ident]types.Object{},
Uses: map[*ast.Ident]types.Object{},
Selections: map[*ast.SelectorExpr]*types.Selection{},
}
if _, err := (&types.Config{}).Check(packagePath, fset, []*ast.File{file}, info); err != nil {
t.Fatalf("type-check fixture: %v\n%s", err, source)
}
scan := newFileDomainScan(typedGoFile{File: file, Fset: fset, Info: info})
scan.collectSemanticEvidence()
scan.collectAbsoluteURLEvidence()
sort.Slice(scan.Evidence, func(i, j int) bool {
if scan.Evidence[i].Host != scan.Evidence[j].Host {
return scan.Evidence[i].Host < scan.Evidence[j].Host
}
return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos()
})
return scan.Evidence
}
func evidenceHosts(evidence []domainEvidence) []string {
hosts := make([]string, 0, len(evidence))
for _, item := range evidence {
hosts = append(hosts, item.Host)
}
return hosts
}
func TestTypedAbsoluteURLDeclarationProducesOneFinding(t *testing.T) {
evidence := scanTypedDomainEvidence(t,
"package p\nconst DomainContractE2EURL = \"https://private.corp.internal/v1\"\n")
if got := evidenceHosts(evidence); len(got) != 1 || got[0] != "private.corp.internal" {
t.Fatalf("hosts = %v, want [private.corp.internal]", got)
}
}
func TestGoDomainEvidenceTruePositives(t *testing.T) {
tests := []struct {
name string
source string
want []string
}{
{
name: "PR 1975 Feishu assignment",
source: "package p\nfunc f() { host := \"internal-api-drive-stream.feishu.cn\"; _ = host }\n",
want: []string{"internal-api-drive-stream.feishu.cn"},
},
{
name: "PR 1975 Lark assignment",
source: "package p\nfunc f() { var host string; host = \"internal-api-drive-stream.larksuite.com\"; _ = host }\n",
want: []string{"internal-api-drive-stream.larksuite.com"},
},
{
name: "uppercase snake target",
source: "package p\nfunc f() { API_HOST := \"private.corp.internal\"; _ = API_HOST }\n",
want: []string{"private.corp.internal"},
},
{
name: "typed declaration",
source: "package p\nconst APIHost string = \"attacker.zip\"\n",
want: []string{"attacker.zip"},
},
{
name: "grouped const declaration",
source: "package p\nconst (\n APIHost string = \"attacker.zip\"\n)\n",
want: []string{"attacker.zip"},
},
{
name: "grouped var declaration",
source: "package p\nvar (\n APIHost string = \"attacker.zip\"\n)\n",
want: []string{"attacker.zip"},
},
{
name: "multi assignment",
source: "package p\nfunc f() {\n" +
" APIHost, BackupHost := \"public.example.com\", \"attacker.zip\"\n" +
" _, _ = APIHost, BackupHost\n}\n",
want: []string{"attacker.zip", "public.example.com"},
},
{
name: "map semantic key",
source: "package p\nvar c = map[string]string{\"host\": \"private.corp.internal\"}\n",
want: []string{"private.corp.internal"},
},
{
name: "map semantic key assignment",
source: "package p\nfunc f() { c := map[string]string{}; c[\"host\"] = \"private.corp.internal\" }\n",
want: []string{"private.corp.internal"},
},
{
name: "host collection values",
source: "package p\nvar ALLOWED_HOSTS = []string{\"private.corp.internal\", \"attacker.zip\"}\n",
want: []string{"attacker.zip", "private.corp.internal"},
},
{
name: "host collection map keys",
source: "package p\nvar allowedHosts = map[string]struct{}{\"attacker.zip\": {}}\n",
want: []string{"attacker.zip"},
},
{
name: "host collection bool map keys",
source: "package p\nvar AllowedHosts = map[string]bool{\"api.example.com\": true}\n",
want: []string{"api.example.com"},
},
{
name: "host collection map values",
source: "package p\nvar HostsByRegion = map[string]string{\"sg\": \"api.example.com\"}\n",
want: []string{"api.example.com"},
},
{
name: "host collection map value assignment",
source: "package p\nfunc f() {\n" +
" HostsByRegion := map[string]string{}\n" +
" HostsByRegion[\"sg\"] = \"api.example.com\"\n" +
"}\n",
want: []string{"api.example.com"},
},
{
name: "static concatenation",
source: "package p\nvar APIHost = \"attacker.\" + \"zip\"\n",
want: []string{"attacker.zip"},
},
{
name: "multiline assignment",
source: "package p\nfunc f() {\n APIHost :=\n \"attacker.zip\"\n _ = APIHost\n}\n",
want: []string{"attacker.zip"},
},
{
name: "escaped hostname",
source: "package p\nvar APIHost = \"private\\u002ecorp\\u002einternal\"\n",
want: []string{"private.corp.internal"},
},
{
name: "hex escaped hostname",
source: "package p\nvar APIHost = \"private\\x2ecorp\\x2einternal\"\n",
want: []string{"private.corp.internal"},
},
{
name: "octal escaped hostname",
source: "package p\nvar APIHost = \"private\\056corp\\056internal\"\n",
want: []string{"private.corp.internal"},
},
{
name: "raw hostname",
source: "package p\nvar APIHost = `private.corp.internal`\n",
want: []string{"private.corp.internal"},
},
{
name: "same-file constant reference",
source: "package p\nconst existingConst = \"private.corp.internal\"\n" +
"func f() { APIHost := existingConst; _ = APIHost }\n",
want: []string{"private.corp.internal"},
},
{
name: "absolute URL",
source: "package p\nvar message = \"https://private.corp.internal/v1\"\n",
want: []string{"private.corp.internal"},
},
{
name: "websocket URL with port",
source: "package p\nvar endpoint = \"wss://private.corp.internal:443/v1\"\n",
want: []string{"private.corp.internal"},
},
{
name: "URL userinfo query and fragment",
source: "package p\nvar endpoint = \" https://user:pass@private.corp.internal:8443/v1?q=1#result \"\n",
want: []string{"private.corp.internal"},
},
{
name: "IDN hostname",
source: "package p\nvar APIHost = \"例子.公司.cn\"\n",
want: []string{"例子.公司.cn"},
},
{
name: "case port and trailing dot normalization",
source: "package p\nvar APIHost = \"EXAMPLE.COM.:443\"\n",
want: []string{"example.com"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := evidenceHosts(scanDomainEvidence(t, tc.source))
if len(got) != len(tc.want) {
t.Fatalf("hosts = %v, want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("hosts = %v, want %v", got, tc.want)
}
}
})
}
}
func TestGoDomainEvidenceTrueNegatives(t *testing.T) {
source := `package p
import _ "github.com/larksuite/oapi-sdk-go/v3"
var file = "archive.zip"
var event = "card.action.trigger"
var schema = "im.messages.list"
var configFile = "service.prod.json"
var version = "v1.2.3"
var email = "name@example.com"
var lowConfidence = "attacker.zip"
var downloadURL = "archive.zip/file"
var prose = "See https://private.corp.internal/v1 for details"
// https://private.corp.internal/v1
var ghost = "private.corp.internal"
var hostnameParser = "private.corp.internal"
var domainError = "private.corp.internal"
var APIHost = "localhost"
var BackupHost = "127.0.0.1"
var hosts = struct{ File string }{File: "archive.zip"}
var AllowedHosts = map[string]string{"api.example.com": "client.pem"}
func dynamicValue() string { return "private.corp.internal" }
var DynamicHost = dynamicValue()
func setAmbiguousHostMetadata() {
AllowedHosts["api.example.com"] = "client.pem"
}
`
if got := scanDomainEvidence(t, source); len(got) != 0 {
t.Fatalf("unexpected evidence: %+v", got)
}
}
func TestTypedStructFieldHostnameSemantics(t *testing.T) {
t.Run("network fields", func(t *testing.T) {
source := `package source
type Config struct { Host string }
type FeishuSource struct { Domain string }
var config = Config{Host: "api.example.com"}
var source = FeishuSource{Domain: "events.example.com"}
`
got := evidenceHosts(scanTypedDomainEvidenceInPackage(
t,
"github.com/larksuite/cli/internal/event/source",
source,
))
want := []string{"api.example.com", "events.example.com"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("hosts = %v, want %v", got, want)
}
})
t.Run("command metadata domain", func(t *testing.T) {
source := `package cmdmeta
type Meta struct { Domain string }
var meta = Meta{Domain: "im.messages"}
func update(meta *Meta) { meta.Domain = "docs.pages" }
`
if got := scanTypedDomainEvidenceInPackage(
t,
"github.com/larksuite/cli/internal/cmdmeta",
source,
); len(got) != 0 {
t.Fatalf("unexpected command metadata evidence: %+v", got)
}
})
t.Run("card action host", func(t *testing.T) {
source := `package im
type CardActionTriggerOutput struct { Host string }
var output = CardActionTriggerOutput{Host: "card.action"}
func update(output *CardActionTriggerOutput) { output.Host = "im.message" }
`
if got := scanTypedDomainEvidenceInPackage(
t,
"github.com/larksuite/cli/events/im",
source,
); len(got) != 0 {
t.Fatalf("unexpected card host evidence: %+v", got)
}
})
t.Run("unknown field ownership is conservative", func(t *testing.T) {
source := "package p\ntype Config struct { Host string }\nvar c = Config{Host: \"api.example.com\"}\n"
if got := scanDomainEvidence(t, source); len(got) != 0 {
t.Fatalf("unexpected untyped field evidence: %+v", got)
}
})
}
func TestHostnameSemanticNames(t *testing.T) {
for _, name := range []string{
"host", "HOST", "hosts", "hostname", "domains",
"api_host", "API_HOST", "ALLOWED_HOSTS",
"apiHost", "APIHost", "backupHostname",
"HostsByRegion", "APIHostsByRegion", "hostsByRegion",
} {
if !isHostnameSemanticName(name) {
t.Errorf("%q should be hostname-semantic", name)
}
}
for _, name := range []string{
"ghost", "hostnameParser", "domainError", "hostValue", "downloadURL", "endpoint", "origin",
"HostBypass", "APIHostBypass",
} {
if isHostnameSemanticName(name) {
t.Errorf("%q must not be hostname-semantic", name)
}
}
}
func TestDomainFixturePaths(t *testing.T) {
for _, path := range []string{
"internal/x/x_test.go",
"tests/cli_e2e/x.go",
"internal/x/testdata/sample.go",
} {
if !isDomainFixturePath(path) {
t.Errorf("%q should be fixture scope", path)
}
}
for _, path := range []string{
"internal/x/test_helper.go",
"examples/demo.go",
"skills/example/testdata/sample.go",
"skills/example/example_test.go",
} {
if isDomainFixturePath(path) {
t.Errorf("%q must not be fixture scope", path)
}
}
}

View File

@@ -3,7 +3,7 @@
// Command lintcheck runs repository source-contract guards that golangci-lint
// cannot express directly. It currently covers typed-error contracts and the
// resolver-owned endpoint contract.
// resolver-owned endpoint and approved-domain contracts.
//
// lintcheck lives in its own Go module under lint/ so its build-time
// dependency on golang.org/x/tools/go/packages does not leak into the
@@ -43,8 +43,10 @@ type scanner struct {
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
return domaincontract.ScanRepo(root)
{name: "domaincontract", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
return domaincontract.ScanRepoWithOptions(root, domaincontract.ScanOptions{
ChangedFrom: opts.ChangedFrom,
})
}},
}
@@ -57,7 +59,7 @@ func main() {
"Runs every registered lint domain against repo-root (default: current directory).\n")
flag.PrintDefaults()
}
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental boundary-error checks")
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental source-contract checks")
flag.BoolVar(&printLegacyCommandErrorCandidates, "print-legacy-command-error-candidates", false, "print existing command boundary bare errors as allowlist candidates")
flag.Parse()

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.81",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.81",
"cpu": [
"x64",
"arm64",

View File

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

View File

@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
exit 1
fi
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
if grep -Fq 'run.name !== "CI"' "$workflow"; then
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
exit 1
fi
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheClear clears all cache entries for the app in the given environment.
//
// POST /apps/{app_id}/cache/clearbody {env}。清空当前应用指定环境下全部缓存,用于无法定位
// 具体 key 的快速恢复;影响面大,定 high-risk-write框架自动注入 --yes 确认)。
var AppsCacheClear = common.Shortcut{
Service: appsService,
Command: "+cache-clear",
Description: "Clear all cache entries for the app in the given environment",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +cache-clear --app-id <app_id> --environment dev --yes",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appCacheClearPath(appID)).
Desc("Clear all cache entries for the app in the given environment").
Body(dbEnvParams(rctx, map[string]interface{}{}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", appCacheClearPath(appID), nil, dbEnvParams(rctx, map[string]interface{}{}))
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := map[string]interface{}{
"environment": resolvedEnv(data, rctx),
"deleted_key_count": cacheInt(data["deleted_key_count"]),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheClearPretty(w, out)
})
return nil
},
}
// renderCacheClearPretty 打 "✓ cache cleared: N entries (env)"。
func renderCacheClearPretty(w io.Writer, out map[string]interface{}) {
n := int64(0)
if f, ok := numericAsFloat(out["deleted_key_count"]); ok {
n = int64(f)
}
fmt.Fprintf(w, "✓ cache cleared: %d entries (%s)\n", n, common.GetString(out, "environment"))
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheDelete deletes a single business cache key (idempotent).
//
// DELETE /apps/{app_id}/cache?env=&key=。缓存是派生数据、删单 key 影响面小且可重建,
// 故定 write非 high-risk-write、不需 --yes。目标不存在按幂等成功处理deleted_key_count=0
var AppsCacheDelete = common.Shortcut{
Service: appsService,
Command: "+cache-delete",
Description: "Delete a single business cache key (idempotent)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +cache-delete --app-id <app_id> --environment dev --key <key>",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "key", Desc: "business cache key", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
DELETE(appCachePath(appID)).
Desc("Delete a Miaoda app runtime cache key").
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
key := rctx.Str("key")
data, err := rctx.CallAPITyped("DELETE", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := map[string]interface{}{
"key": key,
"environment": resolvedEnv(data, rctx),
"deleted_key_count": cacheInt(data["deleted_key_count"]),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheDeletePretty(w, out)
})
return nil
},
}
// renderCacheDeletePretty 命中打 "✓ cache deleted",幂等未命中打 "✓ cache already absent"(措辞区分,都成功)。
func renderCacheDeletePretty(w io.Writer, out map[string]interface{}) {
key := common.GetString(out, "key")
if n, ok := numericAsFloat(out["deleted_key_count"]); ok && n > 0 {
fmt.Fprintf(w, "✓ cache deleted: %s\n", key)
return
}
fmt.Fprintf(w, "✓ cache already absent: %s\n", key)
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheGet reads a single business cache key's value + metadata.
//
// GET /apps/{app_id}/cache?env=&key=。value 在 wire 上是 JSON 字符串透传:--format json
// 原样输出该字符串(不反序列化),--format pretty 反序列化后缩进展开。value_size_bytes 由 CLI
// 按 value 字节长度算出端点不返回未命中exists=false时不带 valuettl_ms/value_size_bytes 为 null。
var AppsCacheGet = common.Shortcut{
Service: appsService,
Command: "+cache-get",
Description: "Get a business cache key's value and metadata",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +cache-get --app-id <app_id> --key spotbonus:2026:winners:list:v1",
"Example: lark-cli apps +cache-get --app-id <app_id> --environment online --key <key>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "key", Desc: "business cache key", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appCachePath(appID)).
Desc("Get a Miaoda app runtime cache key").
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
key := rctx.Str("key")
data, err := rctx.CallAPITyped("GET", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := projectCacheGet(data, key, rctx)
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheGetPretty(w, out)
})
return nil
},
}
// projectCacheGet 组装 cache-get 输出key 回显、environment 取 resolved env、exists 直读;
// 命中时带 ttl_ms + value原始串+ value_size_bytesCLI 算),未命中时 ttl_ms/value_size_bytes 为 null、无 value。
func projectCacheGet(data map[string]interface{}, key string, rctx *common.RuntimeContext) map[string]interface{} {
exists := cacheBool(data["exists"])
out := map[string]interface{}{
"key": key,
"environment": resolvedEnv(data, rctx),
"exists": exists,
}
if exists {
val := common.GetString(data, "value")
out["ttl_ms"] = cacheInt(data["ttl_ms"])
out["value_size_bytes"] = len([]byte(val))
out["value"] = val
} else {
out["ttl_ms"] = nil
out["value_size_bytes"] = nil
}
return out
}
// renderCacheGetPretty 打元信息块key/environment/exists命中再加 ttl/value_size命中时末尾展开 value。
func renderCacheGetPretty(w io.Writer, out map[string]interface{}) {
exists, _ := out["exists"].(bool)
pairs := [][2]string{
{"key", common.GetString(out, "key")},
{"environment", common.GetString(out, "environment")},
{"exists", fmt.Sprintf("%v", exists)},
}
if exists {
pairs = append(pairs,
[2]string{"ttl", formatCacheTTL(out["ttl_ms"])},
[2]string{"value_size", humanBytes(out["value_size_bytes"])},
)
}
renderKeyValuePairs(w, pairs)
if exists {
fmt.Fprintln(w, "value:")
printCacheValuePretty(w, common.GetString(out, "value"))
}
}

View File

@@ -0,0 +1,357 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
const (
cacheURL = "/open-apis/spark/v1/apps/app_x/cache"
cacheClearURL = "/open-apis/spark/v1/apps/app_x/cache/clear"
)
// cacheValueStr 是服务端在 wire 上透传的原始 JSON 字符串value 不反序列化)。
const cacheValueStr = `[{"name":"Alice","award":"Gold"},{"name":"Bob","award":"Silver"}]`
// ── cache-get ──
// TestAppsCacheGet_HitJSON命中时 json 默认——value 原样透传(不反序列化),
// value_size_bytes 由 CLI 按 value 字节长度算出environment 取服务端 resolved env。
func TestAppsCacheGet_HitJSON(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["key"] != "k:1" || d["environment"] != "online" || d["exists"] != true {
t.Fatalf("get hit data=%v", d)
}
if v, _ := d["value"].(string); v != cacheValueStr {
t.Fatalf("value must be raw passthrough string, got %v", d["value"])
}
if sz, _ := numericAsFloat(d["value_size_bytes"]); int(sz) != len(cacheValueStr) {
t.Fatalf("value_size_bytes = %v, want %d", d["value_size_bytes"], len(cacheValueStr))
}
// ttl_ms 必须是 JSON number透传服务端数字不得变成字符串JSON 解析后为 float64。
if _, ok := d["ttl_ms"].(float64); !ok {
t.Fatalf("ttl_ms must be a JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
}
}
// TestAppsCacheGet_HitPrettypretty 把 value 反序列化后展开(含缩进后的字段),并打元信息标签。
func TestAppsCacheGet_HitPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"key", "environment", "exists", "value", "Alice"} {
if !strings.Contains(got, want) {
t.Errorf("pretty missing %q:\n%s", want, got)
}
}
}
// TestAppsCacheGet_Miss未命中——exists=false无 valuettl_ms / value_size_bytes 为 null。
func TestAppsCacheGet_Miss(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": false,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["exists"] != false {
t.Fatalf("miss exists=%v", d["exists"])
}
if _, ok := d["value"]; ok {
t.Fatalf("miss must not carry value: %v", d)
}
if d["ttl_ms"] != nil || d["value_size_bytes"] != nil {
t.Fatalf("miss ttl_ms/value_size_bytes must be null: %v", d)
}
}
// TestAppsCacheGet_ExistsAsString服务端把 exists 返成字符串 "true" 时仍按命中处理
// cacheBool 容错,防 exists 以字符串形态出现被误判成未命中、hit→miss 翻转)。
func TestAppsCacheGet_ExistsAsString(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": "true", "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["exists"] != true {
t.Fatalf("exists string \"true\" 应按命中解析, got exists=%v", d["exists"])
}
if v, _ := d["value"].(string); v != cacheValueStr {
t.Fatalf("命中应带 value, got %v", d["value"])
}
}
// TestAppsCacheGet_PrettyNonJSONFallbackpretty 下 value 不是合法 JSON 时降级原样输出
// safeParseJSON 解析失败→原样打印,不报错、不吞值)。补齐 HitPretty 只覆盖了"能反序列化"路径的缺口。
func TestAppsCacheGet_PrettyNonJSONFallback(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": "hello-plain-not-json",
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "hello-plain-not-json") {
t.Fatalf("非 JSON value 应原样输出(降级), got:\n%s", stdout.String())
}
}
// TestAppsCacheGet_TTLAsStringNormalized服务端把 ttl_ms 返成字符串 "272000" 时,
// 输出的 ttl_ms 必须归一成 JSON numbercacheInt不得随 wire 形态漂移成字符串。
func TestAppsCacheGet_TTLAsStringNormalized(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": "272000", "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
f, ok := d["ttl_ms"].(float64)
if !ok {
t.Fatalf("ttl_ms string wire 应归一成 JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
}
if int(f) != 272000 {
t.Fatalf("ttl_ms = %v, want 272000", f)
}
}
// TestAppsCacheDelete_CountAsStringNormalized服务端把 deleted_key_count 返成字符串 "1" 时,
// 输出必须归一成 JSON numbercacheInt
func TestAppsCacheDelete_CountAsStringNormalized(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": "1"}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if _, ok := d["deleted_key_count"].(float64); !ok {
t.Fatalf("deleted_key_count string wire 应归一成 JSON number, got %T (%v)", d["deleted_key_count"], d["deleted_key_count"])
}
}
// TestAppsCacheGet_DryRunOmitsEnv不传 --environment 时 dry-run query 不带 env服务端自动选但带 key。
func TestAppsCacheGet_DryRunOmitsEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "GET" || a.URL != cacheURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if _, ok := a.Params["env"]; ok {
t.Fatalf("no --environment → env must be omitted, params=%v", a.Params)
}
if a.Params["key"] != "k:1" {
t.Fatalf("key must be in query, params=%v", a.Params)
}
}
// TestAppsCacheGet_DryRunWithEnv显式 --environment dev → query 带 env=dev。
func TestAppsCacheGet_DryRunWithEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Params["env"] != "dev" {
t.Fatalf("env must be dev, params=%v", a.Params)
}
}
// TestAppsCacheGet_RequiresKey缺 --key → 校验错。
func TestAppsCacheGet_RequiresKey(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected required --key error")
}
}
// ── cache-delete ──
// TestAppsCacheDelete_Hit删中命中的 key → deleted_key_count=1pretty 打 "✓ cache deleted"。
func TestAppsCacheDelete_Hit(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 1}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "✓ cache deleted") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheDelete_AbsentJSON目标不存在 → 幂等成功deleted_key_count=0pretty 措辞区分。
func TestAppsCacheDelete_AbsentJSON(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if sz, _ := numericAsFloat(d["deleted_key_count"]); int(sz) != 0 || d["key"] != "k:1" || d["environment"] != "dev" {
t.Fatalf("absent data=%v", d)
}
}
// TestAppsCacheDelete_AbsentPretty不存在 pretty 打 "✓ cache already absent"。
func TestAppsCacheDelete_AbsentPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "already absent") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheDelete_DryRunDELETE 方法、/cache 路由query 带 key + env。
func TestAppsCacheDelete_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "DELETE" || a.URL != cacheURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Params["key"] != "k:1" || a.Params["env"] != "dev" {
t.Fatalf("params=%v", a.Params)
}
}
// ── cache-clear ──
// TestAppsCacheClear_Success清空成功 → deleted_key_count=128pretty 打 "✓ cache cleared: 128 entries (dev)"。
func TestAppsCacheClear_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: cacheClearURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 128}},
})
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "✓ cache cleared: 128 entries (dev)") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheClear_RequiresConfirmationhigh-risk-write 无 --yes → 被确认门拦截。
func TestAppsCacheClear_RequiresConfirmation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected confirmation gate without --yes")
}
}
// TestAppsCacheClear_DryRunBodyWithEnvdry-run POST /cache/clearbody 带 env=dev。
func TestAppsCacheClear_DryRunBodyWithEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "POST" || a.URL != cacheClearURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Body["env"] != "dev" {
t.Fatalf("body must carry env=dev, body=%v", a.Body)
}
}
// TestAppsCacheClear_DryRunBodyOmitsEnv不传 --environment → body 不带 env服务端自动选
func TestAppsCacheClear_DryRunBodyOmitsEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if _, ok := a.Body["env"]; ok {
t.Fatalf("no --environment → body env must be omitted, body=%v", a.Body)
}
}
// firstDryRunAPI 解析 dry-run 输出的第一个 api[] 项method/url/params/body
// 复用本包规范的 dryRunAPIEnvelopeapi 现嵌在 data.api 下,见 dryrun_test.go
func firstDryRunAPI(t *testing.T, s string) dryRunAPICall {
t.Helper()
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(s), &env); err != nil || len(env.API) == 0 {
t.Fatalf("bad dry-run json: %v\n%s", err, s)
}
return env.API[0]
}

View File

@@ -12,10 +12,23 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// maxFileListPageSize 是 file_list 分页上限,与后端 paas_storage checkMaxKeys 的 (0, 200] 契约对齐:
// page_size > 200 服务端直接返回 ErrInvalidRequest("maxKeys not in range (0, 200]")。CLI 前置校验避免无谓往返。
// 注:服务端对 page_size<=0 会兜底为默认值,但 CLI 默认已是 20、显式传 <1 属误用,故与其它 list 命令一致地按 [1, 200] 校验。
const maxFileListPageSize = 200
// validateFileListPageSize 前置校验 --page-size ∈ [1, maxFileListPageSize],与后端 checkMaxKeys 的 (0, 200] 契约对齐。
func validateFileListPageSize(n int) error {
if n < 1 || n > maxFileListPageSize {
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxFileListPageSize)
}
return nil
}
// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。
//
// GET /apps/{app_id}/storage/file_list。过滤器--name / --path / --type / --size-gt /
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size/--page-token。
// --size-lt / --uploaded-since / --uploaded-until精确或区间分页 --page-size(1..200)/--page-token。
// file 域不分 dev/online无 --env。
//
// pretty 渲染 5 列file_name / path / size / type / uploaded_at空结果打 "No files found."。
@@ -41,13 +54,17 @@ var AppsFileList = common.Shortcut{
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1..200)"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
// page_size 前置校验:对齐后端 checkMaxKeys 的 (0, 200] 契约,避免 >200 触发服务端 ErrInvalidRequest。
if err := validateFileListPageSize(rctx.Int("page-size")); err != nil {
return err
}
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC回写到 flag 供 buildFileListParams 透传。
for _, f := range []string{"uploaded-since", "uploaded-until"} {
if strings.TrimSpace(rctx.Str(f)) == "" {

View File

@@ -82,6 +82,34 @@ func TestAppsFileList_RequiresAppID(t *testing.T) {
}
}
// TestAppsFileList_PageSizeOutOfRange 验证 --page-size 超出 (0, 200] 契约时前置报 --page-size 校验错误,不发请求。
func TestAppsFileList_PageSizeOutOfRange(t *testing.T) {
for _, ps := range []string{"0", "201", "500"} {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--as", "user"}, factory, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("page-size=%s: err = %T %v, want *errs.ValidationError", ps, err, err)
}
if ve.Param != "--page-size" {
t.Fatalf("page-size=%s: Param = %q, want --page-size", ps, ve.Param)
}
}
}
// TestAppsFileList_PageSizeBoundaryOK 验证边界值 1 与 200 通过校验dry-run 不报错并把 page_size 下发)。
func TestAppsFileList_PageSizeBoundaryOK(t *testing.T) {
for _, ps := range []string{"1", "200"} {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileList,
[]string{"+file-list", "--app-id", "app_x", "--page-size", ps, "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("page-size=%s: dry-run err=%v", ps, err)
}
}
}
// 过滤器 + 分页全部进 querysize-gt/lt 走 intuploaded_since/until 原样)。
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)

View File

@@ -14,7 +14,6 @@ import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,21 +46,7 @@ var AppsFileUpload = common.Shortcut{
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
f := strings.TrimSpace(rctx.Str("file"))
if f == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
}
st, err := rctx.FileIO().Stat(f)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
}
if st.IsDir() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
}
if st.Size() > fileUploadMaxBytes {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
}
return nil
return rctx.ValidateLocalFileFlag("file", fileUploadMaxBytes)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
@@ -76,9 +61,9 @@ var AppsFileUpload = common.Shortcut{
return err
}
localPath := strings.TrimSpace(rctx.Str("file"))
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
content, err := rctx.ReadLocalFileFlag("file", fileUploadMaxBytes)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
return err
}
fileName := filepath.Base(localPath)
contentType := mimeByExt(fileName)

View File

@@ -12,6 +12,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -58,22 +59,17 @@ func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
}
}
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_uploadbody.file_name 取文件 basename。
// TestAppsFileUpload_DryRunPreUpload verifies that dry-run validates the local
// file and previews the pre-upload request without reading or uploading it.
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
// Validate 会 Stat --file在 DryRun 之前),故 dry-run 也需要真实存在的文件。
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
absolutePath := filepath.Join(t.TempDir(), "logo.png")
if err := os.WriteFile(absolutePath, []byte("not-read-by-dry-run"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
[]string{"+file-upload", "--app-id", "app_x", "--file", absolutePath, "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env dryRunAPIEnvelope
@@ -87,6 +83,18 @@ func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
}
}
func TestAppsFileUpload_DryRunRejectsMissingFile(t *testing.T) {
missingAbsolutePath := filepath.Join(t.TempDir(), "does-not-exist", "logo.png")
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", missingAbsolutePath, "--dry-run", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want invalid_argument for --file", err, err)
}
}
// 三步直传pre-upload → 客户端 PUT 字节 → callback。
func TestAppsFileUpload_EndToEnd(t *testing.T) {
var putBody []byte
@@ -149,6 +157,142 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) {
}
}
// TestAppsFileUpload_AcceptsAbsolutePath verifies that file-upload can read an
// absolute path outside the current working directory.
func TestAppsFileUpload_AcceptsAbsolutePath(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-abs"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Keep the process cwd unchanged so the temporary file is outside it.
dir := t.TempDir()
absFile := filepath.Join(dir, "report.pdf")
if !filepath.IsAbs(absFile) {
t.Fatalf("test setup: %q is not absolute", absFile)
}
if err := os.WriteFile(absFile, []byte("PDFBYTES"), 0o600); err != nil {
t.Fatal(err)
}
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-abs"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/1858537546760999.pdf", "size_bytes": 8,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", absFile, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with absolute path err=%v", err)
}
if string(putBody) != "PDFBYTES" {
t.Fatalf("PUT body = %q, want file bytes", putBody)
}
}
func TestAppsFileUpload_AcceptsParentRelativePathOutsideCWD(t *testing.T) {
var putBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
putBody, _ = io.ReadAll(r.Body)
w.Header().Set("ETag", `"etag-parent"`)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
root := t.TempDir()
workDir := filepath.Join(root, "work")
if err := os.Mkdir(workDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "report.pdf"), []byte("PARENT"), 0o600); err != nil {
t.Fatal(err)
}
oldWD, _ := os.Getwd()
if err := os.Chdir(workDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(oldWD) })
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-parent"}},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"file_name": "report.pdf", "path": "/parent.pdf", "size_bytes": 6,
}},
})
if err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", filepath.Join("..", "report.pdf"), "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute with parent-relative path err=%v", err)
}
if string(putBody) != "PARENT" {
t.Fatalf("PUT body = %q, want PARENT", putBody)
}
}
func TestAppsFileUpload_RejectsFileAboveLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "too-large.bin")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(fileUploadMaxBytes + 1); err != nil {
_ = f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err = runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", path, "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "limit") {
t.Fatalf("error = %v, want size limit context", validationErr)
}
}
func TestAppsFileUpload_RejectsDeviceWithoutReadingIt(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("/dev/zero is unavailable on Windows")
}
if _, err := os.Stat("/dev/zero"); err != nil {
t.Skipf("/dev/zero unavailable: %v", err)
}
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsFileUpload,
[]string{"+file-upload", "--app-id", "app_x", "--file", "/dev/zero", "--as", "user"}, factory, stdout)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) || validationErr.Param != "--file" {
t.Fatalf("error = %T %v, want --file ValidationError", err, err)
}
if !strings.Contains(validationErr.Error(), "regular file") {
t.Fatalf("error = %v, want non-regular-file context", validationErr)
}
}
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
func TestSanitizeUploadFileName_Cases(t *testing.T) {
cases := []struct{ in, want string }{

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// 应用运行时缓存Cache调试命令共享件路由 + 环境 flag + 渲染。
//
// 三条命令都走 spark OpenAPI `/apps/{app_id}/cache[/clear]`按运行环境env→dbBranch隔离
// 环境 flag 用 cacheEnvFlag()(只 --environment不带 db 家族的旧名 --envenv 值经 dbEnv 读、
// 经 dbEnvParams 注入——get/delete 放 queryclear 放 body省略即服务端自动选分支
// appCachePath 返回缓存单 key 读/删 URLcacheGET 读、DELETE 删,靠方法区分)。
func appCachePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID))
}
// appCacheClearPath 返回清空指定环境缓存 URLcache/clear。
func appCacheClearPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/cache/clear", apiBasePath, validate.EncodePathSegment(appID))
}
// cacheEnvFlag 返回缓存命令的运行环境 flag。cache 是全新命令、从无旧名 --env
// 故只注册干净的 --environment不带 db 家族那套隐藏 --env + 拒收逻辑)。
// 省略即服务端按应用多环境状态自动选分支多环境→dev非多环境→online
func cacheEnvFlag() common.Flag {
return common.Flag{
Name: "environment",
Enum: []string{"dev", "online"},
Desc: "target runtime environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online",
}
}
// cacheBool 防御性解析布尔:真 bool 直接用;若服务端把 exists 返成字符串 "true"/"false" 也归一成 bool
// 其它类型按 false。避免 exists 万一以字符串形态出现时被误判成未命中hit→miss 翻转)。
func cacheBool(v interface{}) bool {
switch x := v.(type) {
case bool:
return x
case string:
return strings.EqualFold(strings.TrimSpace(x), "true")
}
return false
}
// cacheInt 把服务端下发的数值字段归一成 int64无法解析→nil。本仓惯例数值可能以字符串下发
// (见 numericAsFloat 的 string 分支),若直接透传,--format json 的字段类型会随服务端 wire 形态漂移
// number ↔ string。归一后输出类型恒定为数字或 null消费方无需自己容忍字符串。
func cacheInt(raw interface{}) interface{} {
if f, ok := numericAsFloat(raw); ok {
return int64(f)
}
return nil
}
// resolvedEnv 取服务端回吐的 resolved env缺失时兜底成请求侧 --environment可能为空
// 省略 --environment 时服务端自动选分支,靠服务端回吐才知道实际命中 dev / online。
func resolvedEnv(data map[string]interface{}, rctx *common.RuntimeContext) string {
if env := common.GetString(data, "env"); env != "" {
return env
}
return dbEnv(rctx)
}
// formatCacheTTL 把剩余 TTL毫秒格式化成 4m32s 这样的时长串;非数字返回 "—"。
func formatCacheTTL(ms interface{}) string {
f, ok := numericAsFloat(ms)
if !ok {
return "—"
}
return (time.Duration(int64(f)) * time.Millisecond).String()
}
// printCacheValuePretty 把 value 反序列化后缩进展开pretty 口径);非 JSON 则原样打印。
// 与「json 原样字符串、pretty 才反序列化」的设计一致。
func printCacheValuePretty(w io.Writer, raw string) {
v := safeParseJSON(raw)
if s, ok := v.(string); ok {
fmt.Fprintln(w, s)
return
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
fmt.Fprintln(w, raw)
return
}
w.Write(b)
fmt.Fprintln(w)
}

View File

@@ -14,6 +14,7 @@ import (
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -74,11 +75,9 @@ func normalizeTimestamp(raw string) (string, error) {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s)
}
// newFileTransferClient 直传 / 直下对象存储 presigned URL 用(绕开 Lark 网关,无需 auth、无超时以容纳大文件
//
//nolint:forbidigo // presigned object-storage transfer bypasses the Lark gateway — raw http.Client is required (no Lark auth, no gateway routing); not a Lark API call, so RuntimeContext.DoAPI does not apply.
//nolint:forbidigo // Presigned transfers use the external HTTP policy.
func newFileTransferClient() *http.Client {
return &http.Client{Transport: http.DefaultTransport}
return transport.NewExternalHTTPClient(0)
}
// URL helpers for the file (storage) CLI commands.

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"net/http"
"testing"
exttransport "github.com/larksuite/cli/extension/transport"
)
type appsExternalProvider struct {
interceptor exttransport.Interceptor
}
func (p appsExternalProvider) Name() string { return "apps-external-test" }
func (p appsExternalProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
return p.interceptor
}
func (appsExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
return class == exttransport.RequestClassExternal
}
type appsExternalInterceptor struct {
calls int
}
func (i *appsExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
i.calls++
req.Header.Set("X-External-Route", "1")
return nil
}
type appsRoundTripFunc func(*http.Request) (*http.Response, error)
func (f appsRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestFileTransferClientUsesExternalRequestClass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARK_CLI_NO_PROXY", "")
previousProvider := exttransport.GetProvider()
interceptor := &appsExternalInterceptor{}
exttransport.Register(appsExternalProvider{interceptor: interceptor})
t.Cleanup(func() { exttransport.Register(previousProvider) })
previousTransport := http.DefaultTransport
var receivedHeader string
http.DefaultTransport = appsRoundTripFunc(func(req *http.Request) (*http.Response, error) {
receivedHeader = req.Header.Get("X-External-Route")
return &http.Response{
StatusCode: http.StatusNoContent,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})
t.Cleanup(func() { http.DefaultTransport = previousTransport })
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/presigned/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := newFileTransferClient().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if interceptor.calls != 1 || receivedHeader != "1" {
t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1")
}
}

View File

@@ -64,6 +64,9 @@ func Shortcuts() []common.Shortcut {
AppsFileUpload,
AppsFileDelete,
AppsFileQuotaGet,
AppsCacheGet,
AppsCacheDelete,
AppsCacheClear,
AppsGitCredentialInit,
AppsGitCredentialList,
AppsGitCredentialRemove,

View File

@@ -20,13 +20,14 @@ import (
// - 3 git-credential
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 cacheget/delete/clear
// - 3 plugininstall/uninstall/list
// - 6 automationlist/get/create/update/enable/disable
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 79
func TestAppsShortcuts_Returns79(t *testing.T) {
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 82
func TestAppsShortcuts_Returns82(t *testing.T) {
got := Shortcuts()
if len(got) != 79 {
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
if len(got) != 82 {
t.Fatalf("Shortcuts() returned %d entries, want 82", len(got))
}
}

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