Compare commits

..

20 Commits

Author SHA1 Message Date
shanglei
6249392823 chore(output): remove now-unused PrintNdjson
Its callers were routed through the emitter, leaving PrintNdjson unreachable,
which the incremental deadcode CI gate rejects. WriteNDJSON remains for the
emitter path. No behavior change.
2026-07-23 21:05:41 +08:00
shanglei
dc9b6c1799 fix(mail): restore triage table/json rendering via output helpers
Real binary-vs-main comparison caught a regression: `mail +triage`'s default
view (table) rendered as a JSON array. The cause is the emitter routing —
EmitValue(rows, "table") on []map[string]interface{} goes through
emitValue→WriteTable directly, which (unlike WriteFormatted) does not
ExtractItems-normalize the shape, so it fell back to JSON. Restore
output.PrintTable / output.PrintJson for the table and json branches so the
rendered output matches the prior behavior. The filter-schema keeps OutJSON: it
must emit JSON regardless of the command's --format (verified by the
Format="data" test).
2026-07-23 20:29:18 +08:00
shanglei
4e221ed3be refactor(client): PaginateToOutput takes an options struct
PaginateToOutput had 11 positional parameters (writers, callbacks, pagination
knobs), which read poorly at the call sites and invited positional-argument
mistakes. Introduce PaginateOutputOptions and reduce the signature to
(ctx, opts). The two production call sites (cmd/api, cmd/service) now pass named
fields; the pagination tests use a small adapter so each case stays one
statement. No behavior change.
2026-07-23 20:07:49 +08:00
shanglei
ff7b7371fc revert(mail): keep mailbox and sender in watch logs and warnings
The prior change dropped the mailbox from the subscribe/filter progress lines
and the sender from the prompt-injection security warning. That logging content
is the command's own operational/security surface, not the output framework's
concern, so restore it. The unified emitter routing is kept.
2026-07-23 19:55:18 +08:00
shanglei
1ecaf831b1 fix(output): rendered-byte content scan, pretty rendering, block cap; wire remaining commands
Complete the content-safety and output-contract follow-ups across the emitter,
pagination, and the commands that emit directly.

- Content safety scans the exact rendered bytes for table/csv/ndjson/pretty/jq
  and per streamed page, so a match formed only in the rendered output (joined
  table cells, a jq concatenation) cannot slip past block mode. The JSON
  envelope keeps its data scan (JSON punctuation prevents whitespace-joined
  cross-field matches) and still embeds the alert.
- warn pagination writes each page as it arrives; block pagination buffers and
  commits atomically with a 64 MiB cap, failing closed past the limit.
- --format pretty without a dedicated renderer falls back to human-readable
  output rather than JSON; OutPartialFailure keeps the caller's table/csv/
  ndjson/pretty choice while json/jq keep the ok:false envelope.
- auth scopes --format pretty writes business data to stdout and propagates
  write errors through the emitter.
- event +subscribe exposes only json/ndjson (--json kept as an alias, conflict
  checked before run); event/mail-triage/mail-watch/record-markdown/schema
  output now run the unified content-safety scan; mail watch no longer logs
  addresses or sender info.
- calendar/minutes/vc pagination progress moved to stderr so csv/ndjson stdout
  stays parseable; non-list paginated responses keep the requested format.
- wrap the flag-parse error with %w in localfileio path handling.
2026-07-23 19:31:20 +08:00
shanglei
720c9275e5 fix(output): scan rendered bytes so block mode can't be bypassed by field joins
The content-safety scan ran over the structured data, but table joins cells
with whitespace and jq can concatenate fields, so a rule match can form only in
the rendered output — the scan never saw it and block mode still wrote it to
stdout (e.g. cells "ignore" + "previous instructions" render as
"ignore  previous instructions", matching instruction_override).

Scan the exact bytes that will be written instead:
- table, csv, ndjson, pretty, and streamed pages now render into a buffer,
  scan that buffer as full text, and copy to stdout only if the scan does not
  block (a warn-mode alert still goes to stderr).
- the jq path scans the rendered jq output before writing, catching
  expressions like '.data.a + " " + .data.b' that concatenate fields.
- the JSON envelope keeps scanning its data: JSON serialization keeps
  per-field punctuation between values, so a whitespace-joined cross-field
  match cannot form there, and this preserves the embedded content-safety
  alert without a second scan.
- removed the now-unused emit() helper.

Tests: TestEmitterBlockScansRenderedCrossFieldConcatenation covers table
cross-column values, table cross-column keys, a scanned csv render, and a jq
concatenation (all assert ContentSafetyError + empty stdout in block mode). The
api/service streaming content-safety tests now assert the scan sees the
rendered page bytes.
2026-07-23 18:04:13 +08:00
shanglei
037cd47941 fix(output): scan map keys, drop dead scan fn, validate PartialFailure format
Address the pr-review on PR #1998.

- P1 (security): the content-safety scanner walked map VALUES only, so a rule
  match hiding in a map KEY (which json/ndjson/table/csv all emit) slipped past
  block mode — a deterministic structured-output bypass. walk now scans each key
  before recursing into its value.
- P1 (CI): remove the now-unreachable exported ScanRenderedText. The scan path
  moved onto Emitter.scanForSafety, leaving ScanRenderedText dead, which failed
  the required `deadcode` check. ScanForSafety is still used and stays.
- P2: PartialFailure now validates opts.Format like Success/StreamPage and
  returns a typed internal error for an invalid enum instead of silently
  emitting JSON; added to the invalid-format test table.
- P2: the streaming pretty fallback warning said "showing JSON" but the output
  is NDJSON; corrected the message (and its test) to "showing NDJSON instead".

Tests: TestWalk_ScansMapKeys and TestProvider_ScanDetectsInjectionInMapKey
(Scan + ScanFullText) cover the map-key scan; the invalid-format table now
covers PartialFailure.
2026-07-23 17:25:06 +08:00
shanglei
d6afdafd2b fix(output): content-safety full-text capability, format hardening, pretty fallback
Second-round owner review follow-ups on PR #1998.

- P1: --format pretty on a command that has no pretty renderer no longer errors
  after the work already ran. For a write that path mutated remote state, then
  exited non-zero, so automation treated it as a failure and retried, creating
  duplicate resources. The emitter now writes a stderr warning and falls back
  to the JSON envelope (exit 0); StreamPage falls back to NDJSON with a single
  warning. Reads and writes behave identically.

- P1: make full-text content-safety scanning a DETECTABLE capability. Add
  extension/contentsafety.FullTextProvider; block mode requires it and returns
  scan-incomplete (blocked, empty stdout) for any provider that cannot
  guarantee a complete scan. A legacy provider that silently truncates can no
  longer let a match past the truncation point reach stdout.

- P1: structured output (json/table/csv/ndjson) is scanned in full under block
  mode. Per-string 128 KiB truncation and depth-cap stops now surface as
  scan-incomplete and block, instead of emitting data that was only partially
  scanned.

- P2: invalid Format enum values error instead of silently degrading.
  Format.Valid(), String() renders unknown(N) for out-of-range values, and
  Success / StreamPage / WriteFormatted / PaginatedFormatter.WritePage return a
  typed internal error rather than defaulting to JSON or writing nothing.

- P3: the content-safety scan-context factory is an injected dependency rather
  than a mutable package global, so parallel tests cannot interfere.
2026-07-23 16:45:22 +08:00
shanglei
4687e714a4 fix(output): close block-mode scan race; error on pretty without renderer
Address the owner review high-priority items on PR #1998:

- H1: a content-safety scan aborted by context cancellation returned (nil, nil),
  indistinguishable from a clean scan; when the result channel raced ctx.Done()
  the select could pick it and block mode would fail OPEN. scanner.walk now
  returns ctx.Err() so an aborted scan surfaces as an error (block fails closed
  via the existing path), and runContentSafety re-checks ctx.Err() after
  receiving a result as a second layer. Regression tests: a provider that
  returns (nil,nil) only after ctx.Done() is blocked in block mode; walk/scan
  cancellation surface errors.

- H2: emitPretty no longer silently emits JSON when no pretty renderer is
  supplied — it returns a typed validation error (param --format). So
  --format pretty on a shortcut without a renderer now errors instead of
  returning JSON (intentional output-contract tightening; the legacy oracle
  golden's pretty_without_renderer case is updated to the error shape).

- Add a CLI dry-run E2E for the format contract (mixed-case pretty preview,
  unknown format rejected pre-request, error carries category/subtype/param).

- runner jq tests use cmdutil.TestFactory with an isolated config dir.
2026-07-23 15:27:53 +08:00
shanglei
cdc88bd38d docs(output): clarify FullText scan contract; guard OutFormat unknown format
Address Codex review low-priority items:
- extension/contentsafety ScanRequest.FullText now documents that providers
  must scan the full string with no per-string truncation, and the Provider
  interface comment requires implementations to honor it.
- RuntimeContext.OutFormat/OutFormatRaw check ParseFormat's ok and return a
  typed internal error for an unsupported format instead of silently degrading
  to JSON; drop the stale 'validated by ParseFormatStrict' comments (that gate
  only covers framework-injected formats, not self-declared ones).
2026-07-23 14:50:27 +08:00
shanglei
463e4170a4 fix(output): scan full pretty output; block mode fails closed
Address the owner review's blocking finding on PR #1998: the 128 KiB windowed
pretty scan could be bypassed — a match straddling a window boundary (via the
default instruction_override rule's unbounded \s+) matched the full text but
neither window, so block mode still wrote it to stdout.

- Scan the complete rendered text as one string (no windows): correct regex
  semantics, and the []any window round-trip through normalize disappears.
- Add a no-truncate full-text scan path (extcs.ScanRequest.FullText, additive)
  so content past the scanner's 128 KiB per-string cap is still scanned;
  latency stays bounded by the existing 100 ms scan timeout. The structured
  API-response scan path keeps its cap.
- Block mode now FAILS CLOSED when a scan cannot complete (timeout/error/panic):
  nothing is written and a typed ContentSafetyError is returned. warn/off keep
  failing open. This intentionally changes the §0.3 content-safety red line for
  block mode; the legacy oracle golden is updated accordingly.
- Report an unknown --format before the --jq conflict (with the --format param),
  and validate the framework --format on the --print-schema path too.

Regression tests: cross-window instruction_override payload is now blocked;
full-text scan catches matches beyond the per-string cap; regex boundary
semantics preserved; block-mode fail-closed on scan timeout/error.
2026-07-23 14:34:19 +08:00
shanglei
da5b752907 Merge remote-tracking branch 'origin/main' into refactor/output-emitter-followups 2026-07-22 19:22:43 +08:00
shanglei
35de0b9ee8 Merge remote-tracking branch 'origin/main' into refactor/output-emitter-followups 2026-07-22 17:44:13 +08:00
shanglei
8b30b075b6 Merge remote-tracking branch 'origin/main' into refactor/output-emitter-followups 2026-07-22 17:06:01 +08:00
shanglei
d668d754d5 fix(output): canonicalize shortcut format and align pretty scan window
Further review fixes:
- Normalize the framework-injected --format to its canonical lowercase and
  write it back to the runtime context and the cobra flag, so shortcuts that
  branch on the exact value (e.g. Format == "pretty") behave correctly for
  mixed-case input like --format Pretty, which previously slipped past those
  checks and produced empty output.
- Size the pretty rendered-text scan window to the content-safety scanner's
  native 128 KiB per-string capacity (was 64 KiB) so the windowing is no more
  restrictive than scanning the raw value; keep the 4 KiB overlap for matches
  crossing a window boundary.
2026-07-22 16:31:54 +08:00
shanglei
0a61b41f92 fix(output): close pretty-scan truncation gap and normalize dry-run format
Address code-review findings on the emitter follow-ups:

- Large pretty output is scanned in overlapping 64 KiB windows instead of one
  string, so content past the safety scanner's 128 KiB per-string cap is no
  longer skipped — this was a content-safety bypass reintroduced by the
  buffer-then-scan change.
- Feed the canonical Format.String() into the dry-run path (api/service/
  shortcut) so a mixed-case --format Pretty still renders the plain-text
  preview instead of falling through to the JSON envelope.
- The raw api/service unknown-format error lists only json/ndjson/table/csv,
  not the shortcut-only pretty, so it no longer suggests a value those commands
  reject.
2026-07-22 16:09:06 +08:00
shanglei
ed60f31912 refactor(output): unify api/service pagination into client.PaginateToOutput
apiPaginate and servicePaginate were near-identical; merge them into one shared
client.PaginateToOutput. The two call-site differences are injected: checkErr
(both pass APIClient.CheckResponse) and markErr (cmd/api passes errs.MarkRaw,
cmd/service passes nil). markErr wraps only the PaginateAll / StreamPages /
checkErr errors — never the WriteSuccessEnvelope return — preserving both
commands' exact stdout/stderr bytes and error semantics. Pure refactor.
2026-07-22 15:15:10 +08:00
shanglei
9f22d89112 refactor(output): always warn on stderr when jq may drop a content-safety alert
When --jq is applied, the jq expression can filter the _content_safety_alert
field out of stdout, hiding the warning. Whether a stderr fallback warning was
written used to depend on a caller-set EmitOptions.JQSafetyWarning flag, so the
raw api/service paths warned but shortcut commands did not — the safety alert
was silently lost. Remove the flag and always write the stderr warning when jq
is applied and an alert exists.
2026-07-22 14:37:11 +08:00
shanglei
e0e034c170 refactor(output): scan pretty-rendered output before writing to stdout
The pretty path scanned the structured `data` argument but rendered via an
opaque closure, so a renderer that printed content absent from `data` could
bypass content-safety in block mode. Render pretty output into a buffer, run
the safety scan on the actual rendered text, and only copy to stdout when it
passes — the bytes that reach stdout are now exactly what was scanned. Applies
to both Success's pretty path and StreamPage's pretty branch; json/table/csv/
ndjson are unchanged (they render from the scanned data directly).
2026-07-22 11:39:59 +08:00
shanglei
5393bd6395 refactor(output)!: strict typed Format — reject unknown format & illegal combos
An unknown --format now fails with a typed ValidationError instead of printing a
stderr warning and silently degrading to JSON, and unfulfillable combinations are
rejected at the flag boundary rather than dropped silently.

- Add FormatPretty to the Format enum and ParseFormatStrict, which returns a
  typed validation error (param --format) for any unrecognized format.
- EmitOptions.Format / StreamOptions.Format / Emitter.streamFormat are now the
  typed output.Format; the upstream .String() -> ParseFormat round-trip and the
  Emitter's internal unknown-format fallback (printLegacyDataJSON) are removed.
- Strict parsing runs at the api, service, and shortcut boundaries, before the
  dry-run branch so both dry-run and emit reject an unknown format. The strict
  contract applies only to the framework-injected --format; a shortcut that
  declares its own format flag (base +record-* markdown|json, mail +watch
  json|data) keeps its own enum, validated by validateEnumFlags.
- The raw api/service commands reject --format pretty on the emit path (no
  response pretty renderer) while preserving the dry-run plain-text preview;
  the check runs before confirmation and client init.
- ValidateJqFlags classifies the format via ParseFormat so --jq's JSON-only
  check is case-insensitive and single-sourced: --format JSON --jq no longer
  mis-rejects, while any non-JSON value (including a shortcut's markdown/data)
  still conflicts with --jq.

BREAKING CHANGE: an unknown --format value (e.g. a typo like `--format tabel`)
is now a typed validation error with a non-zero exit code instead of a stderr
warning plus JSON output. Scripts that relied on the unknown-format JSON
fallback must pass a valid format (json, ndjson, table, csv, or pretty).
2026-07-21 22:05:30 +08:00
399 changed files with 5920 additions and 41050 deletions

3
.github/CODEOWNERS vendored
View File

@@ -1,7 +1,4 @@
/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

@@ -9,40 +9,7 @@ permissions:
contents: read
jobs:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
- name: Validate tag and commit
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
node scripts/release-preflight.js --tag "$TAG"
git fetch origin main
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
exit 1
fi
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
exit 1
fi
build-release:
needs: preflight
goreleaser:
runs-on: ubuntu-22.04
permissions:
contents: write
@@ -59,79 +26,35 @@ jobs:
with:
python-version: '3.x'
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: '22.14.0'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Include release checksums
run: |
set -euo pipefail
test -s dist/checksums.txt
(cd dist && sha256sum --check checksums.txt)
cp dist/checksums.txt checksums.txt
- name: Collect release asset
run: |
set -euo pipefail
mkdir npm-publish-asset
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
- name: Upload release asset
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset/
if-no-files-found: error
overwrite: true
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-npm:
needs: build-release
needs: goreleaser
runs-on: ubuntu-22.04
environment: npm-production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22.14.0'
node-version: '20'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Install pinned npm
run: npm install --global npm@11.16.0
- name: Download release asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: npm-publish-asset-${{ github.run_id }}
path: npm-publish-asset
- name: Verify npm publish asset
- name: Download checksums from release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
(cd npm-publish-asset && sha256sum --check checksums.txt)
cp npm-publish-asset/checksums.txt checksums.txt
PACK_JSON="$(npm pack --ignore-scripts --json)"
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
test -s "$PACK_FILE"
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
rm "$PACK_FILE"
TAG="${GITHUB_REF_NAME}"
gh release download "${TAG}" --pattern checksums.txt --dir .
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish --access public

View File

@@ -25,16 +25,19 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
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.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}`);
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");
@@ -250,16 +253,19 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
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.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}`);
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,149 +2,6 @@
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
- add okr single create shortcut & skill text opti (#1941)
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
### Bug Fixes
- **base**: improve table shortcut behavior & guidance (#1803)
- issue#1935 & whiteboard shortcut reformat (#1980)
- remove legacy shortcut (#1997)
- **e2e**: inject shared credentials by identity (#1995)
### Documentation
- **skill**: describe html5 block xml usage (#1380)
- clarify fetch metadata and user cites (#1981)
- add topic move collector workflow (#1473)
- update lark doc HTML size limit (#2001)
- **base**: align record write schema guidance (#2000)
### Tests
- **e2e**: declare request identities explicitly (#2004)
### Misc
- harden npm release publishing (#1918)
## [v1.0.74] - 2026-07-21
### Features
@@ -1751,12 +1608,6 @@ 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
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

@@ -285,29 +285,6 @@ 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

View File

@@ -286,29 +286,6 @@ 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

View File

@@ -23,41 +23,6 @@ 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

@@ -5,8 +5,6 @@ package api
import (
"context"
"fmt"
"io"
"regexp"
"strings"
@@ -234,6 +232,15 @@ func apiRun(opts *APIOptions) error {
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
}
// Parse before the dry-run branch so both dry-run and emit reject unknown
// values. Raw API responses accept four formats; pretty remains available
// only for the dry-run request preview handled below.
format, ok := output.ParseFormat(opts.Format)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json, ndjson, table, or csv)", opts.Format).
WithParam("--format")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
}
@@ -250,9 +257,17 @@ func apiRun(opts *APIOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts, format), *fileMeta)
}
return apiDryRun(f, request, config, opts)
return apiDryRun(f, request, config, opts, format)
}
// pretty is a shortcut-only presentation format; the raw api command has no
// pretty renderer for responses, so reject it before client init rather than
// fall back. (Dry-run keeps its own plain-text pretty preview, handled above.)
if format == output.FormatPretty {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--format pretty is not supported here (use json, ndjson, table, or csv)").
WithParam("--format")
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
@@ -263,14 +278,20 @@ func apiRun(opts *APIOptions) error {
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
return client.PaginateToOutput(opts.Ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
CommandPath: opts.Cmd.CommandPath(),
Pagination: client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay},
CheckErr: ac.CheckResponse,
MarkErr: errs.MarkRaw,
})
}
resp, err := ac.DoAPI(opts.Ctx, request)
@@ -304,13 +325,13 @@ func apiRun(opts *APIOptions) error {
return nil
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions, format output.Format) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts, format))
}
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions, format output.Format) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
Format: format.String(),
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
@@ -318,75 +339,3 @@ func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOut
ErrOut: f.IOStreams.ErrOut,
}
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
return errs.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -66,6 +66,23 @@ func apiPaginateRequest() client.RawApiRequest {
}
}
// apiPaginate adapts the positional test calls to PaginateToOutput's options
// struct so each test case stays a single readable statement.
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pag client.PaginationOptions, checkErr func(interface{}, core.Identity) error, markErr func(error) error) error {
return client.PaginateToOutput(ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Pagination: pag,
CheckErr: checkErr,
MarkErr: markErr,
})
}
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
@@ -112,10 +129,10 @@ func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
}, ac.CheckResponse, errs.MarkRaw)
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
@@ -195,10 +212,10 @@ func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
}, ac.CheckResponse, errs.MarkRaw)
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
@@ -239,14 +256,14 @@ func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
if !errors.Is(err, sentinel) {
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
t.Fatalf("PaginateToOutput() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
t.Fatalf("PaginateToOutput() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
@@ -256,7 +273,7 @@ func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
}
}
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
func TestAPIPaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
@@ -271,22 +288,17 @@ func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
const want = "{\"name\":\"Test User\",\"user_id\":\"u123\"}\n"
if got := out.String(); got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
if errOut.Len() != 0 {
t.Fatalf("stderr bytes = %q, want empty", errOut.String())
}
}
@@ -314,10 +326,10 @@ func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
t.Fatal("PaginateToOutput() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
@@ -349,10 +361,10 @@ func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
if err == nil {
t.Fatal("apiPaginate() error = nil, want transport error")
t.Fatal("PaginateToOutput() error = nil, want transport error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
@@ -379,10 +391,10 @@ func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
t.Fatal("PaginateToOutput() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)

View File

@@ -118,6 +118,101 @@ func TestApiCmd_DryRunWithJq(t *testing.T) {
}
}
// An unknown --format is a typed validation error, not a silent JSON fallback —
// on both the emit path and (parsed before the dry-run branch) the dry-run path.
// No stub is registered because the command must fail before any API call.
func TestApiCmd_UnknownFormat_Rejected(t *testing.T) {
for _, extra := range [][]string{nil, {"--dry-run"}} {
name := "emit"
if len(extra) > 0 {
name = "dry-run"
}
t.Run(name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs(append([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "bogus"}, extra...))
err := cmd.Execute()
if err == nil {
t.Fatal("expected a validation error for unknown --format")
}
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Errorf("error = %v, want unknown-format message", err)
}
if strings.Contains(strings.ToLower(err.Error()), "pretty") {
t.Errorf("error = %v, raw api format choices must exclude pretty", err)
}
if stdout.String() != "" {
t.Errorf("unknown --format must not write stdout, got:\n%s", stdout.String())
}
})
}
}
func TestApiCmd_UnknownFormatPrecedesJqConflict(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{
"GET", "/open-apis/test", "--as", "bot",
"--format", "tabel", "--jq", ".",
})
err := cmd.Execute()
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Fatalf("error = %v, want unknown-format message", err)
}
if strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %v, unknown format should be reported before jq conflict", err)
}
if stdout.Len() != 0 {
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
}
}
// pretty is shortcut-only: the raw api command rejects it on the emit path
// (before client init) but keeps the dry-run plain-text preview.
func TestApiCmd_Pretty_RejectedOnEmit(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "pretty"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected a validation error for --format pretty on the emit path")
}
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "pretty") {
t.Errorf("error = %v, want pretty-not-supported message", err)
}
if stdout.String() != "" {
t.Errorf("rejected --format pretty must not write stdout, got:\n%s", stdout.String())
}
}
func TestApiCmd_MixedCasePretty_PreservedOnDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "Pretty", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run --format pretty must be accepted, got: %v", err)
}
if !strings.Contains(stdout.String(), "# dry-run: request not sent") {
t.Fatalf("dry-run --format pretty lost its plain-text preview, stdout:\n%s", stdout.String())
}
}
// Regression: --params null parses to a nil map; writing page_size onto it must
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
@@ -404,7 +499,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
}
}
func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
func TestApiCmd_PageAll_NonBatchAPI_HonorsNDJSON(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
})
@@ -427,24 +522,15 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should print fallback warning to stderr
if !strings.Contains(stderr.String(), "warning: this API does not return a list") {
t.Error("expected fallback warning in stderr")
if strings.Contains(stderr.String(), "falling back") {
t.Fatalf("stderr contains format fallback warning: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "falling back to json") {
t.Error("expected 'falling back to json' in stderr")
}
// Should output JSON result to stdout
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
t.Fatalf("invalid NDJSON object: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
t.Fatalf("unexpected fallback envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
if got["user_id"] != "u123" || got["name"] != "Test User" {
t.Fatalf("unexpected NDJSON object: %#v", got)
}
}
@@ -621,6 +707,10 @@ func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
}
func (p *apiContentSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
@@ -654,12 +744,12 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
data, ok := provider.data.(map[string]interface{})
data, ok := provider.data.(string)
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
t.Fatalf("scanned data type = %T, want rendered JSON string", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
if strings.Contains(data, `"code"`) || !strings.Contains(data, `"data"`) {
t.Fatalf("scanned JSON should be the success envelope without an API code, got %q", data)
}
var got map[string]interface{}
@@ -705,9 +795,11 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
// Streaming now scans the exact rendered page bytes (not the structured
// item) so a rule match formed only in the rendered output cannot slip past.
scanned, ok := provider.data.(string)
if !ok || !strings.Contains(scanned, `"id":"1"`) {
t.Fatalf("scanned data = %#v, want rendered ndjson page text", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
@@ -767,11 +859,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
if out != "" {
t.Fatalf("blocked complete stream was written before safety block: %s", out)
}
}
@@ -786,6 +875,18 @@ func requireProblem(t *testing.T, err error, category errs.Category, subtype err
}
}
func requireValidationParam(t *testing.T, err error, param string) {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, 0)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != param {
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
}
}
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
for _, tt := range []struct {
name string

View File

@@ -381,6 +381,34 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
}
}
func TestAuthScopesCmd_RejectsUnknownFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
runCalled := false
cmd := NewCmdAuthScopes(f, func(*ScopesOptions) error {
runCalled = true
return nil
})
cmd.SetArgs([]string{"--format", "tabel"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected invalid format error")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Category != errs.CategoryValidation || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--format" {
t.Fatalf("validation error = %#v; want validation/invalid_argument with --format", validationErr)
}
if runCalled {
t.Fatal("auth scopes runner was called for an invalid format")
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,

View File

@@ -6,6 +6,8 @@ package auth
import (
"context"
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
@@ -33,6 +35,13 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
} else {
opts.Format = strings.ToLower(strings.TrimSpace(opts.Format))
if opts.Format != "json" && opts.Format != "pretty" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json or pretty)", opts.Format).
WithParam("--format")
}
}
if runF != nil {
return runF(opts)
@@ -74,20 +83,36 @@ func authScopesRun(opts *ScopesOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError,
"failed to get app scope info: %v", err).WithCause(err)
}
data := map[string]interface{}{
"appId": config.AppID,
"brand": config.Brand,
"tokenType": "user",
"userScopes": appInfo.UserScopes,
"count": len(appInfo.UserScopes),
}
emitter := output.NewEmitter(output.EmitterConfig{
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
CommandPath: "lark-cli auth scopes",
})
if opts.Format == "pretty" {
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
for _, s := range appInfo.UserScopes {
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n", s)
}
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"appId": config.AppID,
"brand": config.Brand,
"tokenType": "user",
"userScopes": appInfo.UserScopes,
"count": len(appInfo.UserScopes),
return emitter.Value(data, output.StreamOptions{
Format: output.FormatPretty,
Pretty: func(w io.Writer, _ bool) error {
if _, err := fmt.Fprintf(w, "App ID: %s\n", config.AppID); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes)); err != nil {
return err
}
for _, scope := range appInfo.UserScopes {
if _, err := fmt.Fprintf(w, " • %s\n", scope); err != nil {
return err
}
}
return nil
},
})
}
return nil
return emitter.Value(data, output.StreamOptions{Format: output.FormatJSON})
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
@@ -43,6 +44,36 @@ func scopesTestFactory(t *testing.T) *ScopesOptions {
}
}
func TestAuthScopesRunPrettyWritesBusinessDataToStdout(t *testing.T) {
previous := getAppInfoFn
getAppInfoFn = func(context.Context, *cmdutil.Factory, string) (*appInfo, error) {
return &appInfo{UserScopes: []string{"im:message"}}, nil
}
t.Cleanup(func() { getAppInfoFn = previous })
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
})
err := authScopesRun(&ScopesOptions{
Factory: f,
Ctx: context.Background(),
Format: "pretty",
})
if err != nil {
t.Fatalf("authScopesRun() error = %v", err)
}
for _, want := range []string{"App ID: test-app", "Enabled scopes (1)", "im:message"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("stdout missing %q: %s", want, stdout.String())
}
if strings.Contains(stderr.String(), want) {
t.Fatalf("stderr contains business data %q: %s", want, stderr.String())
}
}
}
// TestAuthScopesRun_NetworkErrorPassedThrough pins that a typed NetworkError
// surfaced by the dependency is not re-classified as PermissionError —
// re-auth does not fix DNS / transport failures and blanket-wrapping them

View File

@@ -31,7 +31,6 @@ 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

@@ -1,80 +0,0 @@
// 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

@@ -1,130 +0,0 @@
// 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

@@ -65,17 +65,7 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
if info == nil {
return
}
// 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)
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
if !readYes(ios.In) {
return
}

View File

@@ -128,17 +128,6 @@ 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

@@ -6,7 +6,6 @@ package service
import (
"context"
"fmt"
"io"
"sort"
"strings"
@@ -380,6 +379,15 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.PageAll && opts.Output != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --page-all are mutually exclusive").WithParam("--output")
}
// Parse before the dry-run branch so both dry-run and emit reject unknown
// values. Raw service responses accept four formats; pretty remains available
// only for the dry-run request preview handled below.
format, ok := output.ParseFormat(opts.Format)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json, ndjson, table, or csv)", opts.Format).
WithParam("--format")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
}
@@ -403,9 +411,18 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts, format), *fileMeta)
}
return serviceDryRun(f, request, config, opts)
return serviceDryRun(f, request, config, opts, format)
}
// pretty is a shortcut-only presentation format; the raw service command has
// no pretty renderer for responses, so reject it before the confirmation and
// client init rather than fall back. (Dry-run keeps its own plain-text pretty
// preview, handled above.)
if format == output.FormatPretty {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--format pretty is not supported here (use json, ndjson, table, or csv)").
WithParam("--format")
}
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
@@ -420,10 +437,6 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
// Scope-insufficient (99991679) and all other Lark API codes route through
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
@@ -431,8 +444,18 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := ac.CheckResponse
if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
return client.PaginateToOutput(opts.Ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
CommandPath: opts.Cmd.CommandPath(),
Pagination: client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay},
CheckErr: checkErr,
MarkErr: nil,
})
}
resp, err := ac.DoAPI(opts.Ctx, request)
@@ -667,13 +690,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return request, nil, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions, format output.Format) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts, format))
}
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions, format output.Format) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
Format: format.String(),
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
@@ -681,75 +704,3 @@ func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions)
ErrOut: f.IOStreams.ErrOut,
}
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
return apiErr
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -66,6 +66,23 @@ func servicePaginateRequest() client.RawApiRequest {
}
}
// servicePaginate adapts the positional test calls to PaginateToOutput's options
// struct so each test case stays a single readable statement.
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pag client.PaginationOptions, checkErr func(interface{}, core.Identity) error, markErr func(error) error) error {
return client.PaginateToOutput(ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Pagination: pag,
CheckErr: checkErr,
MarkErr: markErr,
})
}
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
@@ -112,10 +129,10 @@ func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
}, ac.CheckResponse, nil)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
@@ -195,10 +212,10 @@ func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
}, ac.CheckResponse, nil)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
@@ -239,14 +256,14 @@ func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse, nil)
if !errors.Is(err, sentinel) {
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
t.Fatalf("PaginateToOutput() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
t.Fatalf("PaginateToOutput() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
@@ -256,7 +273,7 @@ func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
}
}
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
func TestServicePaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
@@ -272,22 +289,17 @@ func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T)
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
const want = "{\"name\":\"Test User\",\"user_id\":\"u123\"}\n"
if got := out.String(); got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
if errOut.Len() != 0 {
t.Fatalf("stderr bytes = %q, want empty", errOut.String())
}
}
@@ -316,10 +328,10 @@ func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
t.Fatal("PaginateToOutput() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
@@ -352,10 +364,10 @@ func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
if err == nil {
t.Fatal("servicePaginate() error = nil, want transport error")
t.Fatal("PaginateToOutput() error = nil, want transport error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
@@ -383,10 +395,10 @@ func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
t.Fatal("PaginateToOutput() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")

View File

@@ -257,6 +257,21 @@ func TestServiceMethod_DryRunWithJq(t *testing.T) {
}
}
func TestServiceMethod_DryRunMixedCasePrettyUsesPlainTextPreview(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--dry-run", "--format", "Pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run --format Pretty must be accepted, got: %v", err)
}
if !strings.Contains(stdout.String(), "# dry-run: request not sent") {
t.Fatalf("dry-run --format Pretty lost its plain-text preview, stdout:\n%s", stdout.String())
}
}
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
tests := []struct {
name string
@@ -525,6 +540,10 @@ func (p *serviceContentSafetyProvider) Scan(_ context.Context, req extcs.ScanReq
return &extcs.Alert{Provider: "service-test", MatchedRules: []string{"pagination"}}, nil
}
func (p *serviceContentSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
@@ -561,12 +580,12 @@ func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
data, ok := provider.data.(map[string]interface{})
data, ok := provider.data.(string)
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
t.Fatalf("scanned data type = %T, want rendered JSON string", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
if strings.Contains(data, `"code"`) || !strings.Contains(data, `"data"`) {
t.Fatalf("scanned JSON should be the success envelope without an API code, got %q", data)
}
var got map[string]interface{}
@@ -615,9 +634,11 @@ func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
// Streaming now scans the exact rendered page bytes (not the structured
// item) so a rule match formed only in the rendered output cannot slip past.
scanned, ok := provider.data.(string)
if !ok || !strings.Contains(scanned, `"id":"1"`) {
t.Fatalf("scanned data = %#v, want rendered ndjson page text", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from service-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
@@ -680,11 +701,8 @@ func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
if out != "" {
t.Fatalf("blocked complete stream was written before safety block: %s", out)
}
}
@@ -795,26 +813,81 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T)
}
}
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
func TestServiceMethod_UnknownFormat_Rejected(t *testing.T) {
// No stub is registered: the unknown --format must be rejected before any
// API call is made.
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
// An unknown --format is a typed validation error, not a silent JSON fallback.
err := cmd.Execute()
if err == nil {
t.Fatal("expected a validation error for unknown --format")
}
if !strings.Contains(stderr.String(), "warning: unknown format") {
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Errorf("error = %v, want unknown-format message", err)
}
if strings.Contains(strings.ToLower(err.Error()), "pretty") {
t.Errorf("error = %v, raw service format choices must exclude pretty", err)
}
if stdout.String() != "" {
t.Errorf("unknown --format must not write stdout, got:\n%s", stdout.String())
}
// The old degrade-to-JSON warning must be gone, not merely accompanied by an error.
if strings.Contains(stderr.String(), "falling back to json") {
t.Errorf("unknown --format must not emit the legacy fallback warning, got stderr:\n%s", stderr.String())
}
}
func TestServiceMethod_UnknownFormatPrecedesJqConflict(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{},
})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "tabel", "--jq", "."})
err := cmd.Execute()
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Fatalf("error = %v, want unknown-format message", err)
}
if strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %v, unknown format should be reported before jq conflict", err)
}
if stdout.Len() != 0 {
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
}
}
func TestServiceMethod_PrettyRejectedOnEmit(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{},
})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "pretty"})
err := cmd.Execute()
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "pretty") {
t.Fatalf("error = %v, want pretty-not-supported message", err)
}
if stdout.Len() != 0 {
t.Fatalf("rejected --format pretty wrote stdout:\n%s", stdout.String())
}
}
@@ -1028,6 +1101,18 @@ func requireProblem(t *testing.T, err error, category errs.Category, subtype err
}
}
func requireValidationParam(t *testing.T, err error, param string) {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, 0)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != param {
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
}
}
// ── file upload ──
func imImageMethod() meta.Method {

View File

@@ -9,17 +9,30 @@ import (
)
// Provider scans parsed response data for content-safety issues.
// Implementations must be safe for concurrent use.
// Implementations must be safe for concurrent use. Scan may be a best-effort
// scan with bounded string length or nesting depth.
type Provider interface {
Name() string
Scan(ctx context.Context, req ScanRequest) (*Alert, error)
}
// FullTextProvider is a Provider that guarantees a complete scan of Data with
// NO per-string or depth truncation. Block mode requires this capability so a
// match anywhere in the output cannot slip past a truncation boundary.
type FullTextProvider interface {
Provider
ScanFullText(ctx context.Context, req ScanRequest) (*Alert, error)
}
// ScanRequest carries the data to scan.
type ScanRequest struct {
Path string // normalized command path (e.g. "im.messages_search")
Data any // parsed response data (generic JSON shape)
ErrOut io.Writer // stderr for provider-level notices (e.g. lazy-config creation)
// FullText marks Data as one complete rendered-output string. It remains a
// compatibility hint for Provider.Scan; block mode calls
// FullTextProvider.ScanFullText to enforce complete scanning.
FullText bool
}
// Alert holds the result of a content-safety scan that detected issues.

View File

@@ -29,6 +29,14 @@ func (s *stubProvider) Scan(_ context.Context, _ ScanRequest) (*Alert, error) {
return &Alert{Provider: "stub", MatchedRules: []string{"test"}}, nil
}
type fullTextStubProvider struct {
stubProvider
}
func (s *fullTextStubProvider) ScanFullText(ctx context.Context, req ScanRequest) (*Alert, error) {
return s.Scan(ctx, req)
}
func TestProviderInterface(t *testing.T) {
var p Provider = &stubProvider{}
if p.Name() != "stub" {
@@ -43,6 +51,17 @@ func TestProviderInterface(t *testing.T) {
}
}
func TestFullTextProviderInterface(t *testing.T) {
var p FullTextProvider = &fullTextStubProvider{}
alert, err := p.ScanFullText(context.Background(), ScanRequest{Path: "test", Data: "full", ErrOut: io.Discard})
if err != nil {
t.Fatalf("ScanFullText() error = %v", err)
}
if alert.Provider != "stub" {
t.Errorf("alert.Provider = %q, want %q", alert.Provider, "stub")
}
}
func TestRegistryLastWriteWins(t *testing.T) {
mu.Lock()
old := provider

View File

@@ -711,3 +711,19 @@ func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
t.Errorf("ExitCodeOf = %d, want %d (internal)", output.ExitCodeOf(err), output.ExitInternal)
}
}
func TestPaginateToOutputRejectsUnsupportedInternalFormat(t *testing.T) {
for _, format := range []output.Format{output.FormatPretty, output.Format(99)} {
err := PaginateToOutput(context.Background(), PaginateOutputOptions{
Request: RawApiRequest{},
Format: format,
Out: io.Discard,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture",
})
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("format %q error = %T, want *errs.InternalError", format, err)
}
}
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"io"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// PaginateOutputOptions bundles the inputs for PaginateToOutput. Grouping the
// writers, callbacks, and pagination knobs into one struct keeps the call sites
// readable and avoids positional-argument mistakes across the many parameters.
type PaginateOutputOptions struct {
Client *APIClient
Request RawApiRequest
Format output.Format
JqExpr string
Out io.Writer
ErrOut io.Writer
CommandPath string
Pagination PaginationOptions
CheckErr func(interface{}, core.Identity) error
MarkErr func(error) error
}
// PaginateToOutput fetches all requested pages and emits them in the selected format.
func PaginateToOutput(ctx context.Context, opts PaginateOutputOptions) error {
ac := opts.Client
request := opts.Request
format := opts.Format
jqExpr := opts.JqExpr
out := opts.Out
errOut := opts.ErrOut
commandPath := opts.CommandPath
pagOpts := opts.Pagination
checkErr := opts.CheckErr
markErr := opts.MarkErr
if !format.Valid() || format == output.FormatPretty {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unsupported pagination output format %q", format)
}
if markErr == nil {
markErr = func(err error) error { return err }
}
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
emitValue := func(data interface{}, valueFormat output.Format) error {
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
})
return emitter.Value(data, output.StreamOptions{Format: valueFormat})
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return markErr(err)
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
if emitErr := emitValue(result, output.FormatJSON); emitErr != nil {
return markErr(emitErr)
}
return markErr(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
return emitter.StreamPage(items, output.StreamOptions{Format: format})
}, pagOpts)
if err != nil && errs.IsContentSafety(err) {
return markErr(err)
}
if finishErr := emitter.FinishStream(); finishErr != nil {
return markErr(finishErr)
}
if err != nil {
return markErr(err)
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
return markErr(apiErr)
}
if !hasItems {
return emitter.Value(output.SuccessEnvelopeData(result), output.StreamOptions{Format: format})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return markErr(err)
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
if emitErr := emitValue(result, output.FormatJSON); emitErr != nil {
return markErr(emitErr)
}
return markErr(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -139,7 +139,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
Identity: string(identity),
NoticeProvider: output.GetNotice,
})
return emitter.Success(result, output.EmitOptions{Format: opts.Format.String()})
return emitter.Success(result, output.EmitOptions{Format: opts.Format})
}
// Non-JSON (binary) responses.

View File

@@ -14,16 +14,12 @@ import (
// with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The hint is deliberately NOT a pre-built retry
// command: argv cannot faithfully reproduce the original invocation (pipeline
// producers, stdin bytes, redirections, inline env and the executable's real
// path are all gone), POSIX quoting does not survive PowerShell/cmd.exe, and
// echoing argv values can copy credentials or free-form payloads (--sql,
// --json) into the error envelope and every log that captures it. Per the
// lark-shared approval protocol, the caller that obtained the user's consent
// appends --yes to its own saved argv array and re-executes.
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
func RequireConfirmation(action string) error {
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action)
return err.WithHint("add --yes to confirm")
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action).
WithHint("add --yes to confirm")
}

View File

@@ -35,11 +35,8 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
}
// The hint is the plain add-yes contract and nothing more: no pre-built
// retry command may ride behind it (argv cannot faithfully reproduce the
// invocation and may carry sensitive payloads — see RequireConfirmation).
if cre.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want exactly 'add --yes to confirm'", cre.Hint)
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
}
if cre.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
@@ -64,8 +61,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
// No fix_command field leaks into the envelope: the typed protocol stays
// action-only.
// No fix_command field leaks into the envelope: the protocol avoids
// shell-quoting hazards by delegating retry to agent-side logic.
if _, has := back["fix_command"]; has {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}

View File

@@ -22,7 +22,6 @@ 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
@@ -34,7 +33,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 and workspace policy
// Phase 4: LarkClient derived from Credential
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -55,10 +54,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
f.HttpClient = cachedHttpClientFunc(f)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
@@ -69,7 +67,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Runtime config contains resolved account data only.
// Phase 3: Config derived from Credential via an explicit conversion boundary.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -80,9 +78,8 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
return f
}
@@ -111,16 +108,13 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
@@ -134,7 +128,7 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
})
}
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -148,15 +142,8 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: sdkTransport,
Transport: buildSDKTransport(),
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -165,8 +152,9 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
})
}
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
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}

View File

@@ -6,15 +6,10 @@ package cmdutil
import (
"io"
"testing"
"github.com/larksuite/cli/internal/core"
)
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}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c1, err := fn()
if err != nil {
@@ -34,10 +29,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
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}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -45,10 +37,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
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}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -8,7 +8,6 @@ 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"
)
@@ -37,15 +36,13 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
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}})
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -76,7 +73,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, nil)(); err != nil {
if _, err := cachedLarkClientFunc(f)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -1,36 +0,0 @@
// 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

@@ -1,96 +0,0 @@
// 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

@@ -77,11 +77,6 @@ func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, er
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
// All paths go through the caller's fileIO provider and its relative-to-cwd
// policy — no absolute-path side door: a trust root defined by the process
// environment (TMPDIR) is not a security boundary, and reading outside the
// provider would break sidecar/custom-FileIO ownership. Out-of-tree content
// reaches flags via stdin ("-").
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
if fileIO == nil {
return nil, fmt.Errorf("file input is not available in this context")

View File

@@ -1,28 +0,0 @@
// 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

@@ -1,45 +0,0 @@
// 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,7 +26,6 @@ const (
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace"
HeaderAgentName = "X-Agent-Name"
SourceValue = "lark-cli"
@@ -56,9 +55,6 @@ 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,34 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
}
// ---------------------------------------------------------------------------
// Agent headers injected via BaseSecurityHeaders
// HeaderAgentTrace injection (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

@@ -15,7 +15,6 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -92,13 +91,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
// wrapSDKTransport chain composition
// buildSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -111,23 +110,18 @@ func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestWrapSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
func TestBuildSDKTransport_WithExtension(t *testing.T) {
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(previous) })
t.Cleanup(func() { exttransport.Register(nil) })
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
transport := buildSDKTransport()
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -144,23 +138,17 @@ func TestWrapSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
transport := buildSDKTransport()
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -173,13 +161,9 @@ func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
if _, ok := ua.Base.(*RetryTransport); !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
// ---------------------------------------------------------------------------
@@ -277,40 +261,6 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
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
}
func TestWrapSDKTransport_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")
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(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
@@ -327,7 +277,7 @@ 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 wrapSDKTransport.
// Replicate the SDK chain layering used by buildSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}

View File

@@ -60,18 +60,11 @@ 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

@@ -1,37 +0,0 @@
// 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

@@ -1,58 +0,0 @@
// 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,9 +60,7 @@ 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{},
@@ -86,9 +84,6 @@ 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

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

View File

@@ -38,10 +38,6 @@ 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
@@ -141,9 +137,6 @@ 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

@@ -4,6 +4,7 @@
package output
import (
"context"
"errors"
"fmt"
"io"
@@ -15,17 +16,19 @@ import (
// ScanResult holds the output of ScanForSafety.
type ScanResult struct {
Alert *extcs.Alert
Blocked bool
BlockErr error
Alert *extcs.Alert
Blocked bool
BlockErr error
scanFailed bool
}
// ScanForSafety runs content-safety scanning on the given data.
// cmdPath is the raw cobra CommandPath().
// When MODE=off, no provider registered, or the command is not allowlisted,
// returns a zero ScanResult.
// ScanForSafety scans structured response data.
func ScanForSafety(cmdPath string, data any, errOut io.Writer) ScanResult {
alert, csErr := runContentSafety(cmdPath, data, errOut)
return scanForSafetyMode(cmdPath, data, errOut, false, modeFromEnv(errOut), defaultContentSafetyContext)
}
func scanForSafetyMode(cmdPath string, data any, errOut io.Writer, fullText bool, m mode, newScanContext scanContextFactory) ScanResult {
alert, csErr := runContentSafety(cmdPath, data, errOut, fullText, m, newScanContext)
if errors.Is(csErr, errBlocked) {
return ScanResult{
Alert: alert,
@@ -33,10 +36,18 @@ func ScanForSafety(cmdPath string, data any, errOut io.Writer) ScanResult {
BlockErr: wrapBlockError(alert),
}
}
if errors.Is(csErr, errScanIncomplete) {
return ScanResult{
Blocked: true,
BlockErr: wrapScanIncompleteError(csErr),
}
}
if errors.Is(csErr, errScanFailed) {
return ScanResult{scanFailed: true}
}
return ScanResult{Alert: alert}
}
// wrapBlockError creates a typed error for content-safety block.
func wrapBlockError(alert *extcs.Alert) error {
var matchedRules []string
if alert != nil {
@@ -48,8 +59,16 @@ func wrapBlockError(alert *extcs.Alert) error {
WithCause(errBlocked)
}
// WriteAlertWarning writes a human-readable content-safety warning to w.
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
func wrapScanIncompleteError(cause error) error {
message := "content-safety scan did not complete; blocked (block mode)"
if errors.Is(cause, context.DeadlineExceeded) {
message = "content-safety scan did not complete in time; blocked (block mode)"
}
return errs.NewContentSafetyError(errs.SubtypeContentSafety, "%s", message).
WithCause(cause)
}
// WriteAlertWarning writes a content-safety warning.
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
if alert == nil {
return nil

View File

@@ -6,6 +6,7 @@ package output
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
@@ -24,11 +25,15 @@ const (
modeBlock
)
// scanTimeout caps the content-safety scan so it cannot dominate CLI latency.
// 100 ms is generous for a regex walk of a typical API response (KB-scale JSON);
// larger responses hit maxDepth/maxStringBytes well before this fires.
// scanTimeout also bounds untruncated rendered-text scans.
const scanTimeout = 100 * time.Millisecond
type scanContextFactory func() (context.Context, context.CancelFunc)
func defaultContentSafetyContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), scanTimeout)
}
// modeFromEnv reads LARKSUITE_CLI_CONTENT_SAFETY_MODE.
func modeFromEnv(errOut io.Writer) mode {
raw := strings.TrimSpace(os.Getenv(envvars.CliContentSafetyMode))
@@ -66,11 +71,13 @@ func normalizeCommandPath(cobraPath string) string {
return strings.Join(segs, ".")
}
var errBlocked = fmt.Errorf("content safety blocked")
var (
errBlocked = errors.New("content safety blocked")
errScanFailed = errors.New("content safety scan failed")
errScanIncomplete = errors.New("content safety scan incomplete")
)
// runContentSafety orchestrates the scan: mode check -> provider -> scan with timeout + panic recovery.
func runContentSafety(cobraPath string, data any, errOut io.Writer) (*extcs.Alert, error) {
m := modeFromEnv(errOut)
func runContentSafety(cobraPath string, data any, errOut io.Writer, fullText bool, m mode, newScanContext scanContextFactory) (*extcs.Alert, error) {
if m == modeOff {
return nil, nil
}
@@ -85,17 +92,28 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer) (*extcs.Aler
return nil, nil
}
scan := p.Scan
if m == modeBlock {
fullTextProvider, ok := p.(extcs.FullTextProvider)
if !ok {
return nil, fmt.Errorf("%w: provider %q does not support complete scans",
errScanIncomplete, p.Name())
}
scan = fullTextProvider.ScanFullText
}
type result struct {
alert *extcs.Alert
err error
}
ch := make(chan result, 1)
ctx, cancel := context.WithTimeout(context.Background(), scanTimeout)
if newScanContext == nil {
newScanContext = defaultContentSafetyContext
}
ctx, cancel := newScanContext()
defer cancel()
// Give the goroutine its own writer so it cannot race on errOut after timeout.
// On success, we copy any provider notices to the real errOut.
// On timeout, the buffer is owned by the goroutine until it finishes; no shared access.
// A timed-out provider may outlive this call, so it cannot share errOut.
scanErrBuf := &bytes.Buffer{}
go func() {
defer func() {
@@ -103,7 +121,12 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer) (*extcs.Aler
ch <- result{nil, fmt.Errorf("content safety panic: %v", r)}
}
}()
a, e := p.Scan(ctx, extcs.ScanRequest{Path: cmdPath, Data: data, ErrOut: scanErrBuf})
a, e := scan(ctx, extcs.ScanRequest{
Path: cmdPath,
Data: data,
ErrOut: scanErrBuf,
FullText: fullText,
})
ch <- result{a, e}
}()
@@ -113,13 +136,22 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer) (*extcs.Aler
if scanErrBuf.Len() > 0 {
_, _ = io.Copy(errOut, scanErrBuf)
}
if ctx.Err() != nil && m == modeBlock {
return nil, fmt.Errorf("%w: %w", errScanIncomplete, ctx.Err())
}
case <-ctx.Done():
return nil, nil // timeout, fail-open; scanErrBuf stays with the goroutine
if m == modeBlock {
return nil, fmt.Errorf("%w: %w", errScanIncomplete, ctx.Err())
}
return nil, fmt.Errorf("%w: %w", errScanFailed, ctx.Err())
}
if res.err != nil {
fmt.Fprintf(errOut, "warning: content safety scan error: %v\n", res.err)
return nil, nil // fail-open
if m == modeBlock {
return nil, fmt.Errorf("%w: %w", errScanIncomplete, res.err)
}
return nil, fmt.Errorf("%w: %w", errScanFailed, res.err)
}
if res.alert == nil {
return nil, nil

View File

@@ -8,6 +8,7 @@ import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
@@ -22,11 +23,70 @@ type mockProvider struct {
err error
}
type resultFirstCanceledContext struct {
selectDone chan struct{}
providerDone chan struct{}
selectWaiting chan struct{}
doneCallCounter atomic.Int32
}
func newResultFirstCanceledContext() *resultFirstCanceledContext {
providerDone := make(chan struct{})
close(providerDone)
return &resultFirstCanceledContext{
selectDone: make(chan struct{}),
providerDone: providerDone,
selectWaiting: make(chan struct{}),
}
}
func (c *resultFirstCanceledContext) Deadline() (time.Time, bool) {
return time.Time{}, false
}
func (c *resultFirstCanceledContext) Done() <-chan struct{} {
if c.doneCallCounter.Add(1) == 1 {
close(c.selectWaiting)
return c.selectDone
}
return c.providerDone
}
func (c *resultFirstCanceledContext) Err() error {
return context.DeadlineExceeded
}
func (c *resultFirstCanceledContext) Value(any) any {
return nil
}
type abortedCleanProvider struct {
selectWaiting <-chan struct{}
}
func (p *abortedCleanProvider) Name() string {
return "aborted-clean"
}
func (p *abortedCleanProvider) Scan(ctx context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
<-p.selectWaiting
<-ctx.Done()
return nil, nil
}
func (p *abortedCleanProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
func (m *mockProvider) Name() string { return m.name }
func (m *mockProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
return m.alert, m.err
}
func (m *mockProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return m.Scan(ctx, req)
}
func TestScanForSafety_ModeOff(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
var buf bytes.Buffer
@@ -102,36 +162,131 @@ func TestScanForSafety_NoProvider(t *testing.T) {
}
}
func TestScanForSafety_ScanError_FailOpen(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
mp := &mockProvider{name: "mock", err: errors.New("scan broke")}
extcs.Register(mp)
defer extcs.Register(nil)
func TestScanForSafety_ScanError_ModeBehavior(t *testing.T) {
for _, tt := range []struct {
name string
mode string
wantBlocked bool
wantWarning bool
}{
{name: "block fails closed", mode: "block", wantBlocked: true, wantWarning: true},
{name: "warn fails open", mode: "warn", wantWarning: true},
{name: "off skips scan", mode: "off"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
mp := &mockProvider{name: "mock", err: errors.New("scan broke")}
extcs.Register(mp)
t.Cleanup(func() { extcs.Register(nil) })
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked {
t.Error("scan error should fail-open, not block")
}
if !strings.Contains(buf.String(), "scan error") {
t.Errorf("expected warning on stderr, got: %s", buf.String())
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked != tt.wantBlocked {
t.Fatalf("Blocked = %v, want %v", result.Blocked, tt.wantBlocked)
}
if tt.wantBlocked {
var safetyErr *errs.ContentSafetyError
if !errors.As(result.BlockErr, &safetyErr) {
t.Fatalf("BlockErr = %T, want *errs.ContentSafetyError", result.BlockErr)
}
if !strings.Contains(safetyErr.Message, "scan did not complete") {
t.Fatalf("BlockErr message = %q, want scan-incomplete message", safetyErr.Message)
}
if !errors.Is(result.BlockErr, errScanIncomplete) {
t.Fatal("BlockErr should preserve errScanIncomplete cause")
}
}
if got := strings.Contains(buf.String(), "scan error"); got != tt.wantWarning {
t.Fatalf("scan warning present = %v, want %v; stderr=%q", got, tt.wantWarning, buf.String())
}
})
}
}
func TestScanForSafety_SlowProvider_Timeout_FailOpen(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
func TestScanForSafety_SlowProvider_TimeoutModeBehavior(t *testing.T) {
for _, tt := range []struct {
name string
mode string
wantBlocked bool
}{
{name: "block fails closed", mode: "block", wantBlocked: true},
{name: "warn fails open", mode: "warn"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
extcs.Register(&slowProvider{})
t.Cleanup(func() { extcs.Register(nil) })
slow := &slowProvider{}
extcs.Register(slow)
defer extcs.Register(nil)
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked {
t.Error("slow provider should fail-open on timeout, not block")
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked != tt.wantBlocked {
t.Fatalf("Blocked = %v, want %v", result.Blocked, tt.wantBlocked)
}
if result.Alert != nil {
t.Error("slow provider should return nil alert on timeout")
}
if tt.wantBlocked {
var safetyErr *errs.ContentSafetyError
if !errors.As(result.BlockErr, &safetyErr) {
t.Fatalf("BlockErr = %T, want *errs.ContentSafetyError", result.BlockErr)
}
if !strings.Contains(safetyErr.Message, "did not complete in time") {
t.Fatalf("BlockErr message = %q, want timeout message", safetyErr.Message)
}
}
})
}
if result.Alert != nil {
t.Error("slow provider should return nil alert on timeout")
}
func TestEmitterAbortedCleanLookingScanModeBehavior(t *testing.T) {
tests := []struct {
name string
mode string
wantBlocked bool
}{
{name: "block fails closed", mode: "block", wantBlocked: true},
{name: "warn fails open", mode: "warn"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
scanCtx := newResultFirstCanceledContext()
extcs.Register(&abortedCleanProvider{selectWaiting: scanCtx.selectWaiting})
t.Cleanup(func() { extcs.Register(nil) })
stdout := &bytes.Buffer{}
emitter := NewEmitter(EmitterConfig{
Out: stdout,
ErrOut: &bytes.Buffer{},
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
})
emitter.scanCtx = func() (context.Context, context.CancelFunc) {
return scanCtx, func() {}
}
err := emitter.Success(map[string]any{"id": "1"}, EmitOptions{Format: FormatJSON})
if tt.wantBlocked {
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
}
if !strings.Contains(safetyErr.Message, "scan did not complete") {
t.Fatalf("Emitter.Success() error = %v, want scan-incomplete message", err)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
return
}
if err != nil {
t.Fatalf("Emitter.Success() error = %v, want nil", err)
}
if stdout.Len() == 0 {
t.Fatal("Emitter.Success() stdout is empty, want emitted output")
}
})
}
}
@@ -148,6 +303,10 @@ func (s *slowProvider) Scan(ctx context.Context, _ extcs.ScanRequest) (*extcs.Al
}
}
func (s *slowProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return s.Scan(ctx, req)
}
func TestWriteAlertWarning(t *testing.T) {
alert := &extcs.Alert{Provider: "regex", MatchedRules: []string{"r1", "r2"}}
var buf bytes.Buffer

View File

@@ -6,11 +6,11 @@ package output
import (
"bytes"
"encoding/json"
"fmt"
"io"
"maps"
"sort"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
)
// NoticeProvider supplies the notice attached to a structured envelope.
@@ -25,31 +25,30 @@ type PrettyRenderer func(w io.Writer, colorEnabled bool) error
// EmitterConfig contains command-scoped dependencies. A command constructs one
// Emitter and reuses it for its success result or streamed pages.
type EmitterConfig struct {
Out io.Writer
ErrOut io.Writer
CommandPath string
Identity string
ColorEnabled bool
NoticeProvider NoticeProvider
Out io.Writer
ErrOut io.Writer
CommandPath string
Identity string
ColorEnabled bool
NoticeProvider NoticeProvider
MaxBufferedStreamBytes int
}
// EmitOptions describes one result's wire representation.
//
// The format contract is explicit: JSON (including the empty default) uses an
// The format contract is explicit: FormatJSON (the zero value) uses an
// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
// precedence over Format and filters the JSON Envelope. Raw affects only JSON
// envelope encoding and jq's complex-value encoding.
//
// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
// envelope encoding and jq's complex-value encoding. Format is a canonical
// typed value — boundaries reject unknown formats via ParseFormatStrict, so the
// Emitter never sees one and never falls back.
type EmitOptions struct {
Raw bool
Meta *Meta
Format string
JQ string
DryRun bool
Pretty PrettyRenderer
JQSafetyWarning bool
Raw bool
Meta *Meta
Format Format
JQ string
DryRun bool
Pretty PrettyRenderer
}
// StreamOptions describes one streamed page's wire representation. Streaming
@@ -59,7 +58,7 @@ type EmitOptions struct {
// the aggregated result, which the caller's pagination layer owns before it
// streams pages.
type StreamOptions struct {
Format string
Format Format
Pretty PrettyRenderer
}
@@ -72,17 +71,33 @@ type Emitter struct {
identity string
colorEnabled bool
noticeProvider NoticeProvider
scanCtx scanContextFactory
streamFormat string
streamFormat Format
streamFormatSet bool
streamPrettySet bool
streamHasPretty bool
streamFormatter *PaginatedFormatter
streamMode mode
streamModeSet bool
streamBuffer bytes.Buffer
maxStreamBytes int
streamFinished bool
streamFinishErr error
}
const defaultMaxBufferedStreamBytes = 64 << 20
// NewEmitter constructs a command-scoped output emitter.
func NewEmitter(config EmitterConfig) *Emitter {
errOut := config.ErrOut
if errOut == nil {
errOut = io.Discard
}
maxStreamBytes := config.MaxBufferedStreamBytes
if maxStreamBytes <= 0 {
maxStreamBytes = defaultMaxBufferedStreamBytes
}
return &Emitter{
out: config.Out,
errOut: errOut,
@@ -90,6 +105,8 @@ func NewEmitter(config EmitterConfig) *Emitter {
identity: config.Identity,
colorEnabled: config.ColorEnabled,
noticeProvider: config.NoticeProvider,
scanCtx: defaultContentSafetyContext,
maxStreamBytes: maxStreamBytes,
}
}
@@ -97,6 +114,10 @@ func NewEmitter(config EmitterConfig) *Emitter {
// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
// ndjson render the business value directly.
func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
@@ -106,26 +127,49 @@ func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
}
switch opts.Format {
case "", "json":
case FormatJSON:
return e.emitEnvelope(data, true, opts)
case "pretty":
case FormatPretty:
return e.emitPretty(data, opts)
default:
return e.emitFormatted(data, opts.Format)
}
}
// PartialFailure emits a multi-status result whose envelope honestly reports
// ok:false. It is the typed counterpart to Success for batch operations where
// some items failed but the per-item outcomes are the primary stdout output.
// Like the legacy OutPartialFailure it produces only the JSON/jq envelope; the
// caller owns the non-zero exit signal, keeping the Emitter free of exit
// semantics.
func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
// Value scans and emits one naked business value. It is intended for
// long-running streams and custom-format shortcuts whose public contract does
// not use the standard success envelope.
func (e *Emitter) Value(data interface{}, opts StreamOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
return e.emitEnvelope(data, false, opts)
if opts.Format == FormatPretty && opts.Pretty != nil {
return e.emitPrettyRenderer(data, opts.Pretty)
}
return e.emitValue(data, opts.Format)
}
// PartialFailure emits a multi-status result whose envelope honestly reports
// ok:false. It is the typed counterpart to Success for batch operations where
// some items failed but the per-item outcomes are the primary stdout output.
// JSON and jq retain the failure envelope. Other formats emit the selected
// naked representation while the caller supplies the non-zero exit signal.
func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
if opts.JQ != "" || opts.Format == FormatJSON {
return e.emitEnvelope(data, false, opts)
}
return e.Value(data, StreamOptions{Format: opts.Format, Pretty: opts.Pretty})
}
// StreamPage scans and emits one page while retaining table/csv columns from
@@ -136,54 +180,80 @@ func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
// jq from the type makes "jq requires aggregated output" a compile-time fact
// instead of a runtime rejection.
func (e *Emitter) StreamPage(data interface{}, opts StreamOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
if e.streamFinished {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream output is already finished")
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if opts.Format == "pretty" {
if opts.Pretty == nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"pretty output requires a renderer")
}
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
}
format, known := ParseFormat(opts.Format)
if !known && e.streamFormatter == nil && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
if e.streamFormatter == nil {
if !e.streamFormatSet {
e.streamFormat = opts.Format
e.streamFormatter = NewPaginatedFormatter(nil, format)
e.streamFormatSet = true
} else if opts.Format != e.streamFormat {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream output format changed from %q to %q", e.streamFormat, opts.Format)
}
return e.emit(func(w io.Writer) error {
e.streamFormatter.W = w
return e.streamFormatter.WritePage(data)
})
if opts.Format == FormatPretty {
hasPretty := opts.Pretty != nil
if !e.streamPrettySet {
e.streamHasPretty = hasPretty
e.streamPrettySet = true
} else if hasPretty != e.streamHasPretty {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream pretty renderer availability changed between pages")
}
if opts.Pretty != nil {
var buf bytes.Buffer
if err := opts.Pretty(&buf, e.colorEnabled); err != nil {
return wrapOutputError("render", err)
}
return e.emitStreamBuffer(data, &buf)
}
// Commands without a curated pretty renderer use the generic table
// representation. This keeps --format pretty truthful without requiring
// every shortcut to duplicate a renderer.
opts.Format = FormatTable
}
if e.streamFormatter == nil {
e.streamFormatter = NewPaginatedFormatter(nil, opts.Format)
}
// Render this page, then scan the exact bytes before writing: a rule match
// can form in the rendered page (joined table cells, adjacent objects) even
// when no single value matches.
var buf bytes.Buffer
e.streamFormatter.W = &buf
if err := e.streamFormatter.WritePage(data); err != nil {
return wrapOutputError("render", err)
}
return e.emitStreamBuffer(data, &buf)
}
// FinishStream commits output buffered by StreamPage in block mode. Warn mode
// remains incremental: each page is scanned and written by StreamPage. Callers
// must invoke FinishStream after the final page, including when pagination ends
// with an API error and partial block-mode output should remain visible.
func (e *Emitter) FinishStream() error {
if e.streamFinished {
return e.streamFinishErr
}
e.streamFinished = true
if !e.streamModeSet || e.streamMode != modeBlock || e.streamBuffer.Len() == 0 {
return nil
}
e.streamFinishErr = e.emitScannedBufferMode(&e.streamBuffer, e.streamMode)
return e.streamFinishErr
}
func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
m := modeFromEnv(e.errOut)
env := Envelope{
OK: ok,
Identity: e.identity,
@@ -192,15 +262,14 @@ func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) erro
Meta: opts.Meta,
Notice: e.notice(),
}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
if opts.JQ != "" {
if scanResult.Alert != nil && opts.JQSafetyWarning {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
sourceScan := e.scanForSafetyMode(data, false, m)
if sourceScan.Blocked {
return sourceScan.BlockErr
}
if sourceScan.Alert != nil {
env.ContentSafetyAlert = sourceScan.Alert
}
// Buffer the jq output manually so jq's own typed error (a validation
// error for a bad expression, an api error for a runtime failure) is
@@ -216,106 +285,220 @@ func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) erro
if jqErr != nil {
return jqErr
}
var renderedScan ScanResult
if !sourceScan.scanFailed {
renderedScan = e.scanRenderedBufferMode(&buf, m)
}
if renderedScan.Blocked {
return renderedScan.BlockErr
}
alert := mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert)
if alert != nil {
if err := WriteAlertWarning(e.errOut, alert); err != nil {
return wrapOutputError("write", err)
}
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
return e.emit(func(w io.Writer) error {
if opts.Raw {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(env)
}
return WriteJSON(w, env)
})
}
func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if opts.Pretty != nil {
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
// Scan both representations. The structured scan detects content changed by
// JSON escaping, while the rendered scan detects matches formed across
// serialized fields.
sourceScan := e.scanForSafetyMode(data, false, m)
if sourceScan.Blocked {
return sourceScan.BlockErr
}
// RuntimeContext.outFormat falls back through Out/OutRaw when no pretty
// renderer is supplied. Keep that second scan visible in the leaf contract
// until production callers are migrated and the legacy behavior is removed.
return e.emitEnvelope(data, true, opts)
}
func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
format, known := ParseFormat(rawFormat)
if !known && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
}
if format == FormatJSON {
return e.printLegacyDataJSON(data)
}
return e.emit(func(w io.Writer) error {
return WriteFormatted(w, data, format)
})
}
type emitterDataMap map[string]interface{}
// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
// data from this Emitter instead of PrintJson's global PendingNotice hook.
func (e *Emitter) printLegacyDataJSON(data interface{}) error {
// Normalise structs / named maps to plain generic types first, exactly as
// FormatValue does, so a struct or named-map payload still matches the map
// case below and keeps its injected _notice on the unknown-format fallback.
data = toGeneric(data)
if m, ok := data.(map[string]interface{}); ok {
if _, isEnvelope := m["ok"]; isEnvelope {
if notice := e.notice(); notice != nil {
m = maps.Clone(m)
m["_notice"] = notice
}
}
// The named map retains identical JSON bytes while preventing PrintJson
// from consulting its legacy global notice hook a second time.
return e.emit(func(w io.Writer) error {
return WriteJSON(w, emitterDataMap(m))
})
}
return e.emit(func(w io.Writer) error {
return WriteJSON(w, data)
})
}
func (e *Emitter) emit(render func(io.Writer) error) error {
var buf bytes.Buffer
if err := render(&buf); err != nil {
if err := renderEnvelope(&buf, env, opts.Raw); err != nil {
return wrapOutputError("render", err)
}
var renderedScan ScanResult
if !sourceScan.scanFailed {
renderedScan = e.scanRenderedBufferMode(&buf, m)
}
if renderedScan.Blocked {
return renderedScan.BlockErr
}
if alert := mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert); alert != nil {
env.ContentSafetyAlert = alert
buf.Reset()
if err := renderEnvelope(&buf, env, opts.Raw); err != nil {
return wrapOutputError("render", err)
}
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
if opts.Pretty != nil {
return e.emitPrettyRenderer(data, opts.Pretty)
}
return e.emitFormatted(data, FormatPretty)
}
func (e *Emitter) emitPrettyRenderer(data interface{}, renderer PrettyRenderer) error {
// Buffer pretty output so the safety scan sees the exact text that will be
// written to stdout, including anything captured by the opaque renderer.
var buf bytes.Buffer
if err := renderer(&buf, e.colorEnabled); err != nil {
return wrapOutputError("render", err)
}
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
}
// emitFormatted renders naked business data for ndjson, table, csv, and the
// generic pretty representation. Success routes FormatJSON to the envelope and
// curated pretty output to its renderer.
func (e *Emitter) emitFormatted(data interface{}, format Format) error {
var buf bytes.Buffer
if err := WriteFormatted(&buf, data, format); err != nil {
return wrapOutputError("render", err)
}
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
}
func (e *Emitter) emitValue(data interface{}, format Format) error {
var buf bytes.Buffer
var err error
switch format {
case FormatJSON:
err = WriteJSON(&buf, data)
case FormatNDJSON:
err = WriteNDJSON(&buf, data)
case FormatTable:
err = WriteTable(&buf, data)
case FormatCSV:
err = WriteCSV(&buf, data)
case FormatPretty:
err = WriteFormatted(&buf, data, format)
default:
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(format))
}
if err != nil {
return wrapOutputError("render", err)
}
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
}
func (e *Emitter) emitScannedBufferMode(buf *bytes.Buffer, m mode) error {
scanResult := e.scanRenderedBufferMode(buf, m)
return e.emitBufferAfterScan(buf, scanResult)
}
func (e *Emitter) emitSourceAndRenderedBufferMode(data interface{}, buf *bytes.Buffer, m mode) error {
scanResult := e.scanSourceAndRenderedBufferMode(data, buf, m)
return e.emitBufferAfterScan(buf, scanResult)
}
func (e *Emitter) emitBufferAfterScan(buf *bytes.Buffer, scanResult ScanResult) error {
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if _, err := io.Copy(e.out, buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
func (e *Emitter) emitStreamBuffer(data interface{}, buf *bytes.Buffer) error {
if !e.streamModeSet {
e.streamMode = modeFromEnv(e.errOut)
e.streamModeSet = true
}
switch e.streamMode {
case modeWarn:
return e.emitSourceAndRenderedBufferMode(data, buf, e.streamMode)
case modeBlock:
sourceScan := e.scanForSafetyMode(data, false, e.streamMode)
if sourceScan.Blocked {
return sourceScan.BlockErr
}
if buf.Len() > e.maxStreamBytes-e.streamBuffer.Len() {
return errs.NewContentSafetyError(errs.SubtypeContentSafety,
"content-safety scan input exceeds the %d-byte stream limit; blocked",
e.maxStreamBytes).
WithHint("reduce --page-limit or request fewer records")
}
_, _ = e.streamBuffer.Write(buf.Bytes())
return nil
}
if _, err := io.Copy(e.out, buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
func (e *Emitter) scanSourceAndRenderedBufferMode(data interface{}, buf *bytes.Buffer, m mode) ScanResult {
sourceScan := e.scanForSafetyMode(data, false, m)
if sourceScan.Blocked || sourceScan.scanFailed {
return sourceScan
}
renderedScan := e.scanRenderedBufferMode(buf, m)
if renderedScan.Blocked {
return renderedScan
}
renderedScan.Alert = mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert)
return renderedScan
}
func (e *Emitter) scanRenderedBufferMode(buf *bytes.Buffer, m mode) ScanResult {
return e.scanForSafetyMode(buf.String(), true, m)
}
func renderEnvelope(w io.Writer, env Envelope, raw bool) error {
if raw {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(env)
}
return WriteJSON(w, env)
}
func mergeSafetyAlerts(first, second *extcs.Alert) *extcs.Alert {
if first == nil {
return second
}
if second == nil {
return first
}
rules := make(map[string]struct{}, len(first.MatchedRules)+len(second.MatchedRules))
for _, rule := range first.MatchedRules {
rules[rule] = struct{}{}
}
for _, rule := range second.MatchedRules {
rules[rule] = struct{}{}
}
mergedRules := make([]string, 0, len(rules))
for rule := range rules {
mergedRules = append(mergedRules, rule)
}
sort.Strings(mergedRules)
provider := first.Provider
if provider == "" {
provider = second.Provider
}
return &extcs.Alert{Provider: provider, MatchedRules: mergedRules}
}
func (e *Emitter) scanForSafetyMode(data interface{}, fullText bool, m mode) ScanResult {
return scanForSafetyMode(e.commandPath, data, e.errOut, fullText, m, e.scanCtx)
}
func wrapOutputError(op string, err error) error {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s command output", op).WithCause(err)
}

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,10 @@ func (p *emitterSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs
return p.alert, p.err
}
func (p *emitterSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
const (
runtimeContextLegacyGoldenPath = "testdata/runtime_context_legacy.golden.json"
writeSuccessEnvelopeLegacyGoldenPath = "testdata/write_success_envelope_legacy.golden.json"
@@ -60,6 +64,7 @@ type runtimeContextOracleCase struct {
format string
useFormat bool
pretty bool
keepError bool
notice map[string]interface{}
safetyMode string
safetyAlert *extcs.Alert
@@ -188,15 +193,6 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
useFormat: true,
pretty: true,
},
{
name: "pretty_without_renderer",
data: func() interface{} {
return map[string]interface{}{"name": "Alice"}
},
ok: true,
format: "pretty",
useFormat: true,
},
{
name: "ndjson",
data: func() interface{} {
@@ -236,7 +232,7 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
useFormat: true,
},
{
name: "jq_safety_alert_without_stderr_warning",
name: "jq_safety_alert_writes_stderr_warning",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
@@ -249,7 +245,7 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
},
},
{
name: "scanner_error_fails_open",
name: "scanner_error_warn_mode_fails_open",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
@@ -257,6 +253,16 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
safetyMode: "warn",
safetyErr: errors.New("scanner unavailable"),
},
// Block mode intentionally fails closed when scanning errors.
{
name: "scanner_error_block_mode_fails_closed",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: false,
safetyMode: "block",
safetyErr: errors.New("scanner unavailable"),
},
{
name: "scanner_block",
data: func() interface{} {
@@ -269,16 +275,6 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "unknown_format_data_envelope_notice",
data: func() interface{} {
return map[string]interface{}{"ok": true, "value": "fixture"}
},
ok: true,
format: "yaml",
useFormat: true,
notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
},
}
golden := loadRuntimeContextLegacyGolden(t)
@@ -309,7 +305,11 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
format: tc.format,
useFormat: tc.useFormat,
pretty: tc.pretty,
keepError: tc.keepError,
}
// tc.format is the string a shortcut's --format flag would carry; the
// boundary parses it to a canonical Format before the Emitter sees it.
format, _ := output.ParseFormat(tc.format)
current := runEmitterWithRuntimeContextContract(tc.data(), output.EmitterConfig{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
@@ -317,10 +317,10 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
}, tc.ok, output.EmitOptions{
Raw: tc.raw,
Meta: tc.meta,
Format: tc.format,
Format: format,
JQ: tc.jq,
Pretty: emitterPrettyRenderer(tc.pretty),
})
}, tc.keepError)
assertEmitterGolden(t, want, current)
@@ -388,10 +388,15 @@ type runtimeOracleOptions struct {
format string
useFormat bool
pretty bool
keepError bool
}
func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
t.Helper()
if opts.keepError {
return runRuntimeContextShortcutOracle(t, data, opts)
}
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
parent := &cobra.Command{Use: "lark-cli"}
@@ -431,6 +436,42 @@ func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleO
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runRuntimeContextShortcutOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
root := &cobra.Command{Use: "lark-cli", SilenceErrors: true, SilenceUsage: true}
fixture := &cobra.Command{Use: "fixture"}
root.AddCommand(fixture)
shortcut := common.Shortcut{
Service: "fixture",
Command: "+emit",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, runtime *common.RuntimeContext) error {
pretty := func(w io.Writer) {
fmt.Fprintln(w, "pretty:fixture")
}
if !opts.pretty {
pretty = nil
}
if opts.raw {
runtime.OutFormatRaw(data, opts.meta, pretty)
} else {
runtime.OutFormat(data, opts.meta, pretty)
}
return nil
},
}
shortcut.Mount(fixture, factory)
root.SetArgs([]string{"fixture", "+emit", "--as", "bot", "--format", opts.format})
err := root.Execute()
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
@@ -446,7 +487,13 @@ func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, o
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterWithRuntimeContextContract(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
func runEmitterWithRuntimeContextContract(
data interface{},
config output.EmitterConfig,
ok bool,
opts output.EmitOptions,
keepError bool,
) emitterCapture {
capture := runEmitterSuccess(data, config, ok, opts)
if capture.err != nil {
var safetyErr *errs.ContentSafetyError
@@ -457,6 +504,9 @@ func runEmitterWithRuntimeContextContract(data interface{}, config output.Emitte
capture.stderr += fmt.Sprintf("error: %v\n", capture.err)
return capture
}
if keepError {
return capture
}
capture.err = nil
}
if !ok {
@@ -546,11 +596,10 @@ func TestEmitterMatchesWriteSuccessEnvelopeLegacyOracle(t *testing.T) {
Identity: "bot",
NoticeProvider: func() map[string]interface{} { return notice },
}, true, output.EmitOptions{
Format: "",
Raw: false,
JQ: tc.jq,
DryRun: tc.dryRun,
JQSafetyWarning: true,
Format: output.FormatJSON,
Raw: false,
JQ: tc.jq,
DryRun: tc.dryRun,
})
assertEmitterGolden(t, want, current)
@@ -605,18 +654,9 @@ func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
{name: "table", format: output.FormatTable},
{name: "csv", format: output.FormatCSV},
{
name: "warn",
format: output.FormatNDJSON,
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "block",
name: "table warn",
format: output.FormatTable,
safetyMode: "block",
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
@@ -640,7 +680,7 @@ func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
t.Cleanup(func() { extcs.Register(nil) })
legacy := runPaginationOracle(pages, tc.format)
current := runEmitterStreamPages(pages, tc.format.String())
current := runEmitterStreamPages(pages, tc.format)
assertEmitterBytes(t, legacy, current)
assertEquivalentError(t, legacy.err, current.err)
@@ -667,7 +707,7 @@ func runPaginationOracle(pages []interface{}, format output.Format) emitterCaptu
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
func runEmitterStreamPages(pages []interface{}, format output.Format) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
@@ -682,6 +722,9 @@ func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
break
}
}
if emitErr == nil {
emitErr = emitter.FinishStream()
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
@@ -706,7 +749,7 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
return map[string]interface{}{"source": "captured"}
},
})
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"}); err != nil {
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: output.FormatJSON}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
@@ -714,7 +757,7 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "pretty",
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: output.FormatPretty,
Pretty: func(w io.Writer, colorEnabled bool) error {
colorSeen = colorEnabled
_, err := fmt.Fprintln(w, "pretty")
@@ -726,14 +769,6 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
if !colorSeen {
t.Fatal("PrettyRenderer did not receive captured ColorEnabled value")
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"ok": true, "id": "1"}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success(unknown format) error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
}
}
type failingEmitterWriter struct {
@@ -751,7 +786,7 @@ func TestEmitterPropagatesOutputError(t *testing.T) {
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Raw: true, Format: "json",
Raw: true, Format: output.FormatJSON,
JQ: ".data",
})
if !errors.Is(err, sentinel) {

View File

@@ -41,10 +41,9 @@ func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
Identity: opts.Identity,
NoticeProvider: GetNotice,
}).Success(data, EmitOptions{
Format: "",
Raw: false,
JQ: opts.JqExpr,
DryRun: opts.DryRun,
JQSafetyWarning: true,
Format: FormatJSON,
Raw: false,
JQ: opts.JqExpr,
DryRun: opts.DryRun,
})
}

View File

@@ -9,6 +9,8 @@ import (
"fmt"
"io"
"sort"
"github.com/larksuite/cli/errs"
)
// Known array field names for pagination.
@@ -114,8 +116,22 @@ func FormatValue(w io.Writer, data interface{}, format Format) {
// WriteFormatted formats a single response and returns marshal or write errors.
func WriteFormatted(w io.Writer, data interface{}, format Format) error {
if !format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(format))
}
data = toGeneric(data)
switch format {
case FormatJSON:
return WriteJSON(w, data)
case FormatPretty:
switch data.(type) {
case map[string]interface{}, []interface{}:
return WriteTable(w, data)
default:
_, err := fmt.Fprintln(w, cellStr(data))
return err
}
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
@@ -137,9 +153,9 @@ func WriteFormatted(w io.Writer, data interface{}, format Format) error {
}
return WriteCSV(w, data)
default: // FormatJSON
return WriteJSON(w, data)
}
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(format))
}
// PaginatedFormatter holds state across paginated calls to ensure
@@ -166,6 +182,10 @@ func (pf *PaginatedFormatter) FormatPage(data interface{}) {
// WritePage formats one page of items and returns marshal or write errors.
func (pf *PaginatedFormatter) WritePage(data interface{}) error {
if !pf.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(pf.Format))
}
switch pf.Format {
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
@@ -194,7 +214,8 @@ func (pf *PaginatedFormatter) WritePage(data interface{}) error {
return writeCSVRows(w, rows, cols, isFirst)
})
}
return nil
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(pf.Format))
}
// formatStructuredPage handles column-locking logic shared by table and csv.

View File

@@ -6,8 +6,11 @@ package output
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestFormatValue_JSON(t *testing.T) {
@@ -98,6 +101,18 @@ func TestFormatValue_CSV(t *testing.T) {
}
}
func TestWriteFormatted_InvalidFormatReturnsInternalErrorWithoutOutput(t *testing.T) {
var buf bytes.Buffer
err := WriteFormatted(&buf, map[string]interface{}{"id": "1"}, Format(99))
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("WriteFormatted() problem = %#v, %v; want internal/unknown", problem, ok)
}
if buf.Len() != 0 {
t.Fatalf("WriteFormatted() wrote %d bytes, want 0", buf.Len())
}
}
func TestPaginatedFormatter_JSON(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatJSON)
@@ -170,6 +185,22 @@ func TestPaginatedFormatter_CSV(t *testing.T) {
}
}
func TestPaginatedFormatterWritePage_InvalidFormatReturnsInternalErrorWithoutOutput(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, Format(99))
err := pf.WritePage([]interface{}{map[string]interface{}{"id": "1"}})
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("WritePage() error = %T, want *errs.InternalError", err)
}
if internalErr.Category != errs.CategoryInternal || internalErr.Subtype != errs.SubtypeUnknown {
t.Fatalf("WritePage() problem = %s/%s, want internal/unknown", internalErr.Category, internalErr.Subtype)
}
if buf.Len() != 0 {
t.Fatalf("WritePage() wrote %d bytes, want 0", buf.Len())
}
}
func TestPaginatedFormatter_ColumnConsistency(t *testing.T) {
// Page 1 has {a, b}, page 2 has {a, b, c} — c should be ignored in CSV
var buf bytes.Buffer

View File

@@ -3,7 +3,12 @@
package output
import "strings"
import (
"fmt"
"strings"
"github.com/larksuite/cli/errs"
)
// Format represents an output format type.
type Format int
@@ -13,11 +18,22 @@ const (
FormatNDJSON
FormatTable
FormatCSV
FormatPretty
)
// Valid reports whether f is one of the defined output formats.
func (f Format) Valid() bool {
return f >= FormatJSON && f <= FormatPretty
}
// ParseFormat parses a format string into a Format value.
// The second return value is false if the format string was not recognized,
// in which case FormatJSON is returned as default.
//
// Prefer ParseFormatStrict at flag boundaries so an unknown --format fails
// loudly instead of degrading to JSON. ParseFormat's lenient fallback is kept
// for internal callers that only need a best-effort classification (e.g.
// ValidateJqFlags, which folds any non-JSON — known or not — into one branch).
func ParseFormat(s string) (Format, bool) {
switch strings.ToLower(s) {
case "json", "":
@@ -28,21 +44,41 @@ func ParseFormat(s string) (Format, bool) {
return FormatTable, true
case "csv":
return FormatCSV, true
case "pretty":
return FormatPretty, true
default:
return FormatJSON, false
}
}
// ParseFormatStrict parses a --format value into a typed Format, returning a
// typed ValidationError for any unrecognized value instead of silently falling
// back to JSON. Flag boundaries use this so an unknown format is a typed
// failure the caller cannot accidentally serve as JSON, and so the Emitter
// downstream only ever receives a canonical Format.
func ParseFormatStrict(s string) (Format, error) {
if f, ok := ParseFormat(s); ok {
return f, nil
}
return FormatJSON, errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json, ndjson, table, csv, or pretty)", s).
WithParam("--format")
}
// String returns the string representation of a Format.
func (f Format) String() string {
switch f {
case FormatJSON:
return "json"
case FormatNDJSON:
return "ndjson"
case FormatTable:
return "table"
case FormatCSV:
return "csv"
case FormatPretty:
return "pretty"
default:
return "json"
return fmt.Sprintf("unknown(%d)", int(f))
}
}

View File

@@ -3,7 +3,11 @@
package output
import "testing"
import (
"testing"
"github.com/larksuite/cli/errs"
)
func TestParseFormat(t *testing.T) {
tests := []struct {
@@ -23,6 +27,9 @@ func TestParseFormat(t *testing.T) {
{"csv", FormatCSV, true},
{"CSV", FormatCSV, true},
{"Csv", FormatCSV, true},
{"pretty", FormatPretty, true},
{"PRETTY", FormatPretty, true},
{"Pretty", FormatPretty, true},
{"", FormatJSON, true},
// Legacy/unknown values fall back to JSON with ok=false
{"data", FormatJSON, false},
@@ -55,7 +62,8 @@ func TestFormatString(t *testing.T) {
{FormatNDJSON, "ndjson"},
{FormatTable, "table"},
{FormatCSV, "csv"},
{Format(99), "json"}, // unknown falls back
{FormatPretty, "pretty"},
{Format(99), "unknown(99)"},
}
for _, tt := range tests {
@@ -67,3 +75,59 @@ func TestFormatString(t *testing.T) {
})
}
}
func TestFormatValid(t *testing.T) {
for _, format := range []Format{FormatJSON, FormatNDJSON, FormatTable, FormatCSV, FormatPretty} {
if !format.Valid() {
t.Errorf("Format(%d).Valid() = false, want true", format)
}
}
if Format(99).Valid() {
t.Error("Format(99).Valid() = true, want false")
}
}
func TestParseFormatStrict(t *testing.T) {
valid := []struct {
input string
want Format
}{
{"", FormatJSON},
{"json", FormatJSON},
{"JSON", FormatJSON},
{"ndjson", FormatNDJSON},
{"table", FormatTable},
{"csv", FormatCSV},
{"pretty", FormatPretty},
{"Pretty", FormatPretty},
}
for _, tt := range valid {
t.Run("valid/"+tt.input, func(t *testing.T) {
got, err := ParseFormatStrict(tt.input)
if err != nil {
t.Fatalf("ParseFormatStrict(%q) error = %v, want nil", tt.input, err)
}
if got != tt.want {
t.Errorf("ParseFormatStrict(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
// Unknown values are a typed validation error on --format, never a silent
// fallback to JSON.
for _, input := range []string{"yaml", "xml", "data", "raw", "tabel"} {
t.Run("unknown/"+input, func(t *testing.T) {
got, err := ParseFormatStrict(input)
if err == nil {
t.Fatalf("ParseFormatStrict(%q) error = nil, want validation error", input)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation {
t.Fatalf("ParseFormatStrict(%q) problem = %#v, %v; want validation category", input, problem, ok)
}
if got != FormatJSON {
t.Errorf("ParseFormatStrict(%q) format = %v, want FormatJSON sentinel", input, got)
}
})
}
}

View File

@@ -70,7 +70,14 @@ func ValidateJqFlags(jqExpr, outputFlag, format string) error {
if outputFlag != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --output are mutually exclusive")
}
if format != "" && format != "json" {
// Classify via ParseFormat so the JSON check is case-insensitive and shares
// the single canonical format definition. Only a recognized JSON format is
// compatible with --jq; every other value conflicts and is rejected: known
// non-JSON framework formats ("csv", "pretty", ...) and values ParseFormat
// does not recognize as JSON (a shortcut's own "markdown"/"data" enum, or an
// unknown format that ParseFormatStrict rejects downstream). The !ok guard
// keeps those unrecognized values out of the JSON-compatible branch.
if f, ok := ParseFormat(format); !ok || f != FormatJSON {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --format %s are mutually exclusive", format)
}
return ValidateJqExpression(jqExpr)

View File

@@ -160,8 +160,13 @@ func TestValidateJqFlags(t *testing.T) {
{name: "empty jq is noop", jqExpr: "", outputFlag: "file.json", format: "csv", wantErr: ""},
{name: "jq only", jqExpr: ".data", outputFlag: "", format: "", wantErr: ""},
{name: "jq with json format", jqExpr: ".data", outputFlag: "", format: "json", wantErr: ""},
// Format classification is case-insensitive via ParseFormat: an
// upper/mixed-case JSON must not be mistaken for a conflicting format.
{name: "jq with uppercase JSON format", jqExpr: ".data", outputFlag: "", format: "JSON", wantErr: ""},
{name: "jq with mixed-case Json format", jqExpr: ".data", outputFlag: "", format: "Json", wantErr: ""},
{name: "jq and output conflict", jqExpr: ".data", outputFlag: "out.json", format: "", wantErr: "--jq and --output are mutually exclusive"},
{name: "jq and csv conflict", jqExpr: ".data", outputFlag: "", format: "csv", wantErr: "--jq and --format csv are mutually exclusive"},
{name: "jq and pretty conflict", jqExpr: ".data", outputFlag: "", format: "pretty", wantErr: "--jq and --format pretty are mutually exclusive"},
{name: "jq and ndjson conflict", jqExpr: ".data", outputFlag: "", format: "ndjson", wantErr: "--jq and --format ndjson are mutually exclusive"},
{name: "invalid expression", jqExpr: "invalid[", outputFlag: "", format: "", wantErr: "invalid jq expression"},
}

View File

@@ -81,21 +81,6 @@ func injectNotice(data interface{}) {
m["_notice"] = notice
}
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
func PrintNdjson(w io.Writer, data interface{}) {
if arr, ok := data.([]interface{}); ok {
for _, item := range arr {
if err := WriteNDJSON(w, item); isOutputMarshalError(err) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
return
}
if err := WriteNDJSON(w, data); isOutputMarshalError(err) {
legacyStderrf("ndjson marshal error: %v\n", err)
}
}
// WriteNDJSON writes data as NDJSON and returns marshal or write errors.
func WriteNDJSON(w io.Writer, data interface{}) error {
emit := func(item interface{}) error {

View File

@@ -5,7 +5,7 @@
"stderr": ""
},
"format_raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"<p>a&b</p>\"\n }\n}\n",
"stderr": ""
},
"jq_invalid_expression": {
@@ -22,9 +22,9 @@
"exit_code": 2
}
},
"jq_safety_alert_without_stderr_warning": {
"jq_safety_alert_writes_stderr_warning": {
"stdout": "1\n",
"stderr": ""
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
},
"jq_scalar": {
"stdout": "Alice\n",
@@ -62,16 +62,12 @@
"stdout": "pretty:fixture\n",
"stderr": ""
},
"pretty_without_renderer": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"name\": \"Alice\"\n }\n}\n",
"stderr": ""
},
"raw_jq_complex": {
"stdout": "{\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n}\n",
"stdout": "{\n \"html\": \"<p>a&b</p>\"\n}\n",
"stderr": ""
},
"raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"<p>a&b</p>\"\n }\n}\n",
"stderr": ""
},
"scanner_block": {
@@ -91,17 +87,27 @@
"exit_code": 6
}
},
"scanner_error_fails_open": {
"scanner_error_block_mode_fails_closed": {
"stdout": "",
"stderr": "warning: content safety scan error: scanner unavailable\n",
"error": {
"go_type": "*errs.ContentSafetyError",
"json": {
"type": "policy",
"subtype": "content_safety",
"message": "content-safety scan did not complete; blocked (block mode)"
},
"message": "content-safety scan did not complete; blocked (block mode)",
"exit_code": 6
}
},
"scanner_error_warn_mode_fails_open": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
"stderr": "warning: content safety scan error: scanner unavailable\n"
},
"table_with_safety_warning": {
"stdout": "id name \n── ─────\n1 Alice\n",
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
},
"unknown_format_data_envelope_notice": {
"stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
"stderr": "warning: unknown format \"yaml\", falling back to json\n"
}
}
}

View File

@@ -45,18 +45,6 @@ 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

@@ -1,24 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# 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

@@ -1,142 +0,0 @@
// 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

@@ -1,27 +0,0 @@
//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

@@ -1,48 +0,0 @@
//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

@@ -1,17 +0,0 @@
//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

@@ -1,20 +0,0 @@
//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

@@ -1,11 +0,0 @@
//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

@@ -1,143 +0,0 @@
// 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

@@ -1,44 +0,0 @@
//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

@@ -1,78 +0,0 @@
//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

@@ -1,138 +0,0 @@
// 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"
)
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,
}
}
// 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

@@ -1,124 +0,0 @@
// 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

@@ -24,6 +24,15 @@ type regexProvider struct {
func (p *regexProvider) Name() string { return "regex" }
func (p *regexProvider) Scan(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.scan(ctx, req, false)
}
func (p *regexProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
req.FullText = true
return p.scan(ctx, req, true)
}
func (p *regexProvider) scan(ctx context.Context, req extcs.ScanRequest, fullText bool) (*extcs.Alert, error) {
cfg, err := p.loadOrCreate(req.ErrOut)
if err != nil {
return nil, err
@@ -37,9 +46,11 @@ func (p *regexProvider) Scan(ctx context.Context, req extcs.ScanRequest) (*extcs
}
data := normalize(req.Data)
s := &scanner{rules: cfg.Rules}
s := &scanner{rules: cfg.Rules, fullText: fullText}
hits := make(map[string]struct{})
s.walk(ctx, data, hits, 0)
if err := s.walk(ctx, data, hits, 0); err != nil {
return nil, err
}
if len(hits) == 0 {
return nil, nil

View File

@@ -4,15 +4,22 @@
package contentsafety
import (
"bytes"
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/output"
)
var _ extcs.FullTextProvider = (*regexProvider)(nil)
func writeTestConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
@@ -70,6 +77,28 @@ func TestProvider_ScanCleanData(t *testing.T) {
}
}
func TestProvider_ScanCanceledContextReturnsError(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "r1", "pattern": "(?i)inject"}]
}`)
p := &regexProvider{configDir: dir}
ctx, cancel := context.WithCancel(context.Background())
cancel()
alert, err := p.Scan(ctx, extcs.ScanRequest{
Path: "im.messages_search",
Data: map[string]any{"text": "Hello, clean data"},
ErrOut: io.Discard,
})
if alert != nil {
t.Fatalf("Scan() alert = %v, want nil", alert)
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("Scan() error = %v, want context.Canceled", err)
}
}
func TestProvider_ScanNotInAllowlist(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["im"],
@@ -141,6 +170,169 @@ func TestProvider_ScanNestedData(t *testing.T) {
}
}
func TestProvider_FullTextBypassesPerStringCap(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "tail", "pattern": "TAIL_MARKER"}]
}`)
p := &regexProvider{configDir: dir}
text := strings.Repeat("x", maxStringBytes+1) + "TAIL_MARKER"
alert, err := p.Scan(context.Background(), extcs.ScanRequest{
Path: "test",
Data: text,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("Scan() structured-data error = %v", err)
}
if alert != nil {
t.Fatalf("structured-data scan should retain the per-string cap, got %v", alert)
}
alert, err = p.ScanFullText(context.Background(), extcs.ScanRequest{
Path: "test",
Data: text,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("ScanFullText() error = %v", err)
}
if alert == nil || len(alert.MatchedRules) != 1 || alert.MatchedRules[0] != "tail" {
t.Fatalf("full-text scan alert = %v, want tail match", alert)
}
}
func TestEmitterStructuredBlockFullTextWritesZeroBytesAndWarnEmits(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [
{"id": "prefix", "pattern": "PREFIX_MARKER"},
{"id": "tail", "pattern": "TAIL_MARKER"}
]
}`)
p := &regexProvider{configDir: dir}
extcs.Register(p)
t.Cleanup(func() { extcs.Register(nil) })
data := map[string]any{
"text": "PREFIX_MARKER" + strings.Repeat("x", maxStringBytes+1) + "TAIL_MARKER",
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
blockStdout := &bytes.Buffer{}
blockEmitter := output.NewEmitter(output.EmitterConfig{
Out: blockStdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := blockEmitter.Success(data, output.EmitOptions{Format: output.FormatJSON})
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("block Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
}
foundTail := false
for _, ruleID := range safetyErr.Rules {
if ruleID == "tail" {
foundTail = true
break
}
}
if !foundTail {
t.Fatalf("block matched rules = %v, want tail match beyond per-string cap", safetyErr.Rules)
}
if blockStdout.Len() != 0 {
t.Fatalf("block stdout bytes = %d, want 0", blockStdout.Len())
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
warnStdout := &bytes.Buffer{}
warnStderr := &bytes.Buffer{}
warnEmitter := output.NewEmitter(output.EmitterConfig{
Out: warnStdout,
ErrOut: warnStderr,
CommandPath: "lark-cli fixture +emit",
})
if err := warnEmitter.Success(data, output.EmitOptions{Format: output.FormatJSON}); err != nil {
t.Fatalf("warn Emitter.Success() error = %v", err)
}
if warnStdout.Len() == 0 {
t.Fatal("warn stdout bytes = 0, want emitted structured payload")
}
if !strings.Contains(warnStdout.String(), `"_content_safety_alert"`) ||
!strings.Contains(warnStdout.String(), `"prefix"`) {
t.Fatalf("warn stdout = %q, want embedded prefix content-safety warning", warnStdout.String())
}
if warnStderr.Len() != 0 {
t.Fatalf("warn stderr = %q, want empty for JSON envelope warning", warnStderr.String())
}
}
func TestEmitterStructuredBlockDepthIncompleteWritesZeroBytes(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "deep", "pattern": "DEEP_MARKER"}]
}`)
p := &regexProvider{configDir: dir}
extcs.Register(p)
t.Cleanup(func() { extcs.Register(nil) })
var data any = "DEEP_MARKER"
for i := 0; i < maxDepth+5; i++ {
data = map[string]any{"nested": data}
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(data, output.EmitOptions{Format: output.FormatJSON})
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
}
if !strings.Contains(safetyErr.Message, "scan did not complete") {
t.Fatalf("Emitter.Success() error = %v, want scan-incomplete message", err)
}
if stdout.Len() != 0 {
t.Fatalf("block stdout bytes = %d, want 0", stdout.Len())
}
}
func TestProvider_ScanDetectsInjectionInMapKey(t *testing.T) {
// A rule match hiding in a map key (which JSON/NDJSON/table/CSV all emit)
// must be detected, not just matches in values.
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "override", "pattern": "(?i)ignore previous instructions"}]
}`)
p := &regexProvider{configDir: dir}
data := map[string]any{"ignore previous instructions": "ok"}
for _, tc := range []struct {
name string
scan func() (*extcs.Alert, error)
}{
{"Scan", func() (*extcs.Alert, error) {
return p.Scan(context.Background(), extcs.ScanRequest{Path: "test", Data: data, ErrOut: io.Discard})
}},
{"ScanFullText", func() (*extcs.Alert, error) {
return p.ScanFullText(context.Background(), extcs.ScanRequest{Path: "test", Data: data, ErrOut: io.Discard})
}},
} {
t.Run(tc.name, func(t *testing.T) {
alert, err := tc.scan()
if err != nil {
t.Fatalf("%s() error = %v", tc.name, err)
}
if alert == nil || len(alert.MatchedRules) != 1 || alert.MatchedRules[0] != "override" {
t.Fatalf("%s() alert = %v, want override match on the map key", tc.name, alert)
}
})
}
}
func TestProvider_EmptyRulesNoAlert(t *testing.T) {
dir := writeTestConfig(t, `{"allowlist":["all"],"rules":[]}`)
p := &regexProvider{configDir: dir}

View File

@@ -5,6 +5,8 @@ package contentsafety
import (
"context"
"errors"
"fmt"
"regexp"
)
@@ -13,38 +15,52 @@ const (
maxDepth = 64
)
var errScanIncomplete = errors.New("content safety scan incomplete")
type rule struct {
ID string
Pattern *regexp.Regexp
}
type scanner struct {
rules []rule
rules []rule
fullText bool
}
func (s *scanner) walk(ctx context.Context, v any, hits map[string]struct{}, depth int) {
if depth > maxDepth {
return
func (s *scanner) walk(ctx context.Context, v any, hits map[string]struct{}, depth int) error {
if err := ctx.Err(); err != nil {
return err
}
if ctx.Err() != nil {
return
if depth > maxDepth {
if s.fullText {
return fmt.Errorf("%w: maximum depth %d exceeded", errScanIncomplete, maxDepth)
}
return nil
}
switch t := v.(type) {
case string:
s.scanString(t, hits)
case map[string]any:
for _, child := range t {
s.walk(ctx, child, hits, depth+1)
for k, child := range t {
// Scan the key too: JSON/NDJSON/table/CSV all emit map keys, so a
// rule match hiding in a key must not slip past block mode.
s.scanString(k, hits)
if err := s.walk(ctx, child, hits, depth+1); err != nil {
return err
}
}
case []any:
for _, child := range t {
s.walk(ctx, child, hits, depth+1)
if err := s.walk(ctx, child, hits, depth+1); err != nil {
return err
}
}
}
return ctx.Err()
}
func (s *scanner) scanString(text string, hits map[string]struct{}) {
if len(text) > maxStringBytes {
if !s.fullText && len(text) > maxStringBytes {
text = text[:maxStringBytes]
}
for _, r := range s.rules {

View File

@@ -5,6 +5,7 @@ package contentsafety
import (
"context"
"errors"
"regexp"
"testing"
)
@@ -45,6 +46,23 @@ func TestScanString_Truncate(t *testing.T) {
}
}
func TestScanString_FullTextDoesNotTruncate(t *testing.T) {
s := &scanner{
rules: []rule{testRule("tail", `TAIL_MARKER`)},
fullText: true,
}
big := make([]byte, maxStringBytes+100)
for i := range big {
big[i] = 'x'
}
copy(big[maxStringBytes+10:], "TAIL_MARKER")
hits := make(map[string]struct{})
s.scanString(string(big), hits)
if _, ok := hits["tail"]; !ok {
t.Error("full-text scan should match marker beyond maxStringBytes")
}
}
func TestScanString_SkipsDuplicate(t *testing.T) {
s := &scanner{rules: []rule{testRule("r1", `match`)}}
hits := map[string]struct{}{"r1": {}}
@@ -62,16 +80,34 @@ func TestWalk_NestedMap(t *testing.T) {
},
}
hits := make(map[string]struct{})
s.walk(context.Background(), data, hits, 0)
if err := s.walk(context.Background(), data, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
if _, ok := hits["found"]; !ok {
t.Error("expected to find 'inject' in nested map")
}
}
func TestWalk_ScansMapKeys(t *testing.T) {
// JSON/NDJSON/table/CSV all emit map keys, so a rule match hiding in a key
// must be scanned too — not only the value.
s := &scanner{rules: []rule{testRule("found", `(?i)inject`)}}
data := map[string]any{"please inject this": "harmless value"}
hits := make(map[string]struct{})
if err := s.walk(context.Background(), data, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
if _, ok := hits["found"]; !ok {
t.Error("expected to match a rule hiding in a map key")
}
}
func TestWalk_Array(t *testing.T) {
s := &scanner{rules: []rule{testRule("found", `(?i)inject`)}}
hits := make(map[string]struct{})
s.walk(context.Background(), []any{"normal", "try to inject"}, hits, 0)
if err := s.walk(context.Background(), []any{"normal", "try to inject"}, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
if _, ok := hits["found"]; !ok {
t.Error("expected to find 'inject' in array")
}
@@ -84,18 +120,42 @@ func TestWalk_MaxDepth(t *testing.T) {
data = map[string]any{"n": data}
}
hits := make(map[string]struct{})
s.walk(context.Background(), data, hits, 0)
if err := s.walk(context.Background(), data, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
if _, ok := hits["deep"]; ok {
t.Error("should not reach string beyond maxDepth")
}
}
func TestWalk_FullTextMaxDepthReturnsIncomplete(t *testing.T) {
s := &scanner{
rules: []rule{testRule("deep", `secret`)},
fullText: true,
}
var data any = "secret"
for i := 0; i < maxDepth+5; i++ {
data = map[string]any{"n": data}
}
hits := make(map[string]struct{})
err := s.walk(context.Background(), data, hits, 0)
if !errors.Is(err, errScanIncomplete) {
t.Fatalf("walk() error = %v, want errScanIncomplete", err)
}
if _, ok := hits["deep"]; ok {
t.Error("full-text walk should report incomplete before matching data beyond maxDepth")
}
}
func TestWalk_ContextCancel(t *testing.T) {
s := &scanner{rules: []rule{testRule("found", `target`)}}
ctx, cancel := context.WithCancel(context.Background())
cancel()
hits := make(map[string]struct{})
s.walk(ctx, map[string]any{"key": "target"}, hits, 0)
err := s.walk(ctx, map[string]any{"key": "target"}, hits, 0)
if !errors.Is(err, context.Canceled) {
t.Fatalf("walk() error = %v, want context.Canceled", err)
}
if _, ok := hits["found"]; ok {
t.Error("should not match after context cancel")
}

View File

@@ -17,13 +17,6 @@ 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,18 +211,6 @@ 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

@@ -7,7 +7,6 @@ import (
"fmt"
"path/filepath"
"strings"
"unicode"
"github.com/larksuite/cli/internal/charcheck"
"github.com/larksuite/cli/internal/vfs"
@@ -19,39 +18,10 @@ func SafeOutputPath(path string) (string, error) {
}
// SafeInputPath validates an upload/read source path for --file flags.
// Deliberately strict (relative-to-cwd only): several callers — drive sync,
// upload flags, the CI quality gates — treat "absolute paths rejected" as a
// load-bearing invariant. Out-of-tree content reaches flags via stdin ("-").
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) {

View File

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

View File

@@ -1,33 +0,0 @@
// 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

@@ -1,27 +0,0 @@
// 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,7 +4,6 @@
package localfileio
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -72,72 +71,6 @@ 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()
@@ -242,7 +175,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
}
}
func TestSafeUploadPath_RejectsTempFileAbsolutePath(t *testing.T) {
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")
if err != nil {
@@ -252,11 +185,10 @@ func TestSafeUploadPath_RejectsTempFileAbsolutePath(t *testing.T) {
f.Close()
t.Cleanup(func() { os.Remove(tmpPath) })
// WHEN: SafeInputPath validates the absolute temp path
// WHEN: SafeUploadPath validates the absolute temp path
_, err = SafeInputPath(tmpPath)
// THEN: the strict validator rejects it — uploads / drive sync rely on
// relative-only; out-of-tree content reaches flags via stdin ("-")
// THEN: absolute paths are rejected even in temp dir
if err == nil {
t.Fatal("expected error for absolute temp path, got nil")
}

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 # ScanRepoWithOptions(root, opts) ← public entry
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
├── runner.go
├── typecheck.go
├── violation.go # local type aliases to lintapi
@@ -30,19 +30,16 @@ lint/
├── rule_subtype_classifier.go
├── rule_typed_error_completeness.go
└── *_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
└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts
├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry
── scan_test.go
```
## Endpoint domain contract (`domaincontract`)
`domaincontract` contains two complementary Go source guards.
The resolver-ownership guard rejects:
`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:
- string literals containing a resolver-owned host FQDN
(`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`), and
@@ -62,54 +59,17 @@ 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.
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:
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.
- `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.
To add or change an outbound endpoint, edit the resolver — never hardcode a host.
## Running
```bash
# 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)
# from the repo root (one level above lint/)
go run -C lint . ..
```
@@ -140,14 +100,10 @@ Exit codes follow `lint/main.go`:
import "github.com/larksuite/cli/lint/lintapi"
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) { ... }
// 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) { ... }
```
3. Per-rule files are named `rule_<name>.go` with sibling
@@ -158,12 +114,8 @@ Exit codes follow `lint/main.go`:
```go
var scanners = []scanner{
{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,
})
}},
{name: "errscontract", fn: errscontract.ScanRepo},
{name: "<domain>", fn: <domain>.ScanRepo}, // ← add here
}
```

View File

@@ -1,171 +0,0 @@
// 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

@@ -1,96 +0,0 @@
// 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

@@ -1,126 +0,0 @@
// 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

@@ -1,120 +0,0 @@
// 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 resolver ownership and rejects newly introduced
// static Go hostnames that are not covered by the repository domain policy.
// Package domaincontract guards the Go CLI against direct reuse of the current
// resolver-owned host FQDNs outside core.ResolveEndpoints.
package domaincontract
import (
@@ -11,7 +11,6 @@ import (
"go/token"
"io/fs"
"path/filepath"
"sort"
"strconv"
"strings"
@@ -76,40 +75,10 @@ func skipDir(name string) bool {
return false
}
// 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.
// 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.
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

@@ -1,911 +0,0 @@
// 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

@@ -1,462 +0,0 @@
// 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

@@ -1,380 +0,0 @@
// 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 and approved-domain contracts.
// resolver-owned endpoint contract.
//
// 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,10 +43,8 @@ type scanner struct {
var scanners = []scanner{
{name: "errscontract", fn: errscontract.ScanRepoWithOptions},
{name: "domaincontract", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) {
return domaincontract.ScanRepoWithOptions(root, domaincontract.ScanOptions{
ChangedFrom: opts.ChangedFrom,
})
{name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) {
return domaincontract.ScanRepo(root)
}},
}
@@ -59,7 +57,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 source-contract checks")
flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental boundary-error checks")
flag.BoolVar(&printLegacyCommandErrorCandidates, "print-legacy-command-error-candidates", false, "print existing command boundary bare errors as allowlist candidates")
flag.Parse()

7
package-lock.json generated
View File

@@ -1,16 +1,15 @@
{
"name": "@larksuite/cli",
"version": "1.0.81",
"version": "1.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.81",
"version": "1.0.11",
"cpu": [
"x64",
"arm64",
"riscv64"
"arm64"
],
"hasInstallScript": true,
"license": "MIT",

View File

@@ -1,13 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.81",
"version": "1.0.74",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"
},
"scripts": {
"postinstall": "node scripts/install.js",
"release:check": "node scripts/release-preflight.js"
"postinstall": "node scripts/install.js"
},
"os": [
"darwin",

View File

@@ -265,7 +265,10 @@ function getExpectedChecksum(archiveName, checksumsDir) {
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
throw new Error(`[SECURITY] checksums.txt not found at ${checksumsPath}`);
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
}
const content = fs.readFileSync(checksumsPath, "utf8");
@@ -283,14 +286,7 @@ function getExpectedChecksum(archiveName, checksumsDir) {
}
function verifyChecksum(archivePath, expectedHash) {
if (typeof expectedHash !== "string" || expectedHash.length === 0) {
throw new Error("[SECURITY] Expected checksum is missing or invalid");
}
if (!/^[0-9a-f]{64}$/i.test(expectedHash)) {
throw new Error(
"[SECURITY] Expected checksum must be a 64-character hexadecimal SHA-256 digest"
);
}
if (expectedHash === null) return;
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.

View File

@@ -52,12 +52,11 @@ describe("getExpectedChecksum", () => {
);
});
it("throws [SECURITY]-prefixed Error when checksums.txt does not exist", () => {
it("returns null when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
assert.throws(
() => getExpectedChecksum("anything.tar.gz", dir),
{ message: /^\[SECURITY\] checksums\.txt not found/ }
);
// No checksums.txt in dir
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
});
it("skips malformed lines and still finds valid entry", () => {
@@ -107,7 +106,7 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
it("accepts a valid uppercase 64-character hex hash", () => {
it("matches case-insensitively", () => {
const content = "case test";
const filePath = makeTmpFile(content);
const hash = sha256(content).toUpperCase();
@@ -115,40 +114,6 @@ describe("verifyChecksum", () => {
verifyChecksum(filePath, hash);
});
for (const [name, expectedHash] of [
["null", null],
["empty", ""],
["non-string", 123],
]) {
it(`throws [SECURITY]-prefixed Error for ${name} expected hash`, () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, expectedHash),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
assert.match(err.message, /Expected checksum is missing or invalid/);
return true;
}
);
});
}
it("throws [SECURITY] format Error for an incorrectly sized hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "abc123"),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY] format Error for a non-hex hash", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "g".repeat(64)),
{ message: /^\[SECURITY\] Expected checksum must be a 64-character hexadecimal SHA-256 digest$/ }
);
});
it("throws [SECURITY]-prefixed Error on mismatch", () => {
const filePath = makeTmpFile("real content");
assert.throws(

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