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
184 changed files with 3992 additions and 8418 deletions

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,83 +2,6 @@
All notable changes to this project will be documented in this file.
## [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
@@ -1685,9 +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.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

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

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

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

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

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

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

@@ -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"
@@ -23,32 +22,6 @@ 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()

7
package-lock.json generated
View File

@@ -1,16 +1,15 @@
{
"name": "@larksuite/cli",
"version": "1.0.78",
"version": "1.0.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.78",
"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.78",
"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(

View File

@@ -1,108 +0,0 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const STABLE_VERSION_PATTERN = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
function isStableVersion(value) {
return typeof value === "string" && STABLE_VERSION_PATTERN.test(value);
}
function releaseError(message, observed, hint) {
return { ok: false, error: { type: "release_preflight", message, observed, hint } };
}
function validateReleasePreflight(packageJson, packageLockJson, tag) {
const packageVersion = packageJson?.version;
const lockVersion = packageLockJson?.version;
const lockRootVersion = packageLockJson?.packages?.[""]?.version;
const observed = {
packageVersion: packageVersion ?? null,
lockVersion: lockVersion ?? null,
lockRootVersion: lockRootVersion ?? null,
tagVersion: null,
};
for (const [field, value] of [
["package.json.version", packageVersion],
["package-lock.json.version", lockVersion],
['package-lock.json.packages[""].version', lockRootVersion],
]) {
if (!isStableVersion(value)) {
return releaseError(
`${field} must be a stable release version in X.Y.Z form`,
observed,
"Use the same stable X.Y.Z version in all package fields; prerelease and build metadata are not allowed for production releases.",
);
}
}
if (packageVersion !== lockVersion || packageVersion !== lockRootVersion) {
return releaseError(
"Package version fields do not match",
observed,
"Synchronize package.json.version and both package-lock.json version fields.",
);
}
if (tag === undefined) {
return { ok: true, data: observed };
}
if (typeof tag !== "string" || !tag.startsWith("v") || !isStableVersion(tag.slice(1))) {
return releaseError(
"--tag must use the stable release form vX.Y.Z",
{ ...observed, tag },
`Use --tag v${packageVersion}; prerelease and build metadata are not allowed for production releases.`,
);
}
const tagVersion = tag.slice(1);
if (tagVersion !== packageVersion) {
return releaseError(
"Tag version does not match the package version",
{ ...observed, tagVersion, tag },
`Use --tag v${packageVersion}.`,
);
}
return { ok: true, data: { ...observed, tagVersion } };
}
function writeResult(result) {
(result.ok ? process.stdout : process.stderr).write(`${JSON.stringify(result)}\n`);
if (!result.ok) process.exitCode = 1;
}
function main() {
const args = process.argv.slice(2);
let tag;
if (args.length === 2 && args[0] === "--tag") {
tag = args[1];
} else if (args.length !== 0) {
writeResult(releaseError(
"Expected no arguments or --tag vX.Y.Z",
{ arguments: args },
"Run release:check without arguments or pass exactly one --tag value.",
));
return;
}
const repoRoot = path.resolve(__dirname, "..");
try {
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
const packageLockJson = JSON.parse(fs.readFileSync(path.join(repoRoot, "package-lock.json"), "utf8"));
writeResult(validateReleasePreflight(packageJson, packageLockJson, tag));
} catch (error) {
writeResult(releaseError(
"Could not read release package metadata",
{ reason: error.message },
"Ensure package.json and package-lock.json exist and contain valid JSON.",
));
}
}
module.exports = { validateReleasePreflight };
if (require.main === module) main();

View File

@@ -1,66 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const { describe, it } = require("node:test");
const { validateReleasePreflight } = require("./release-preflight");
function metadata(version = "1.2.3") {
return {
packageJson: { version },
packageLockJson: {
version,
packages: { "": { version } },
},
};
}
function assertRejected(result) {
assert.equal(result.ok, false);
assert.equal(result.error.type, "release_preflight");
assert.equal(typeof result.error.message, "string");
}
describe("validateReleasePreflight", () => {
it("accepts matching stable package, lock, and tag versions", () => {
const { packageJson, packageLockJson } = metadata();
assert.deepEqual(
validateReleasePreflight(packageJson, packageLockJson, "v1.2.3"),
{
ok: true,
data: {
packageVersion: "1.2.3",
lockVersion: "1.2.3",
lockRootVersion: "1.2.3",
tagVersion: "1.2.3",
},
},
);
});
it("rejects non-stable or inconsistent package metadata", () => {
const prerelease = metadata("1.2.3-beta.1");
const topLevelMismatch = metadata();
topLevelMismatch.packageLockJson.version = "1.2.4";
const rootMismatch = metadata();
rootMismatch.packageLockJson.packages[""].version = "1.2.4";
for (const { packageJson, packageLockJson } of [
prerelease,
topLevelMismatch,
rootMismatch,
]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson));
}
});
it("rejects an invalid or mismatched release tag", () => {
const { packageJson, packageLockJson } = metadata();
for (const tag of ["1.2.3", "v1.2.3-beta.1", "v1.2.4"]) {
assertRejected(validateReleasePreflight(packageJson, packageLockJson, tag));
}
});
});

View File

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

View File

@@ -3,48 +3,49 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${REPO_ROOT}"
VERSION=$(node -p "require('./package.json').version")
# Read version from package.json
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
if [ -z "$VERSION" ]; then
echo "Error: could not read version from package.json" >&2
exit 1
fi
TAG="v${VERSION}"
node "${SCRIPT_DIR}/release-preflight.js" --tag "${TAG}"
echo "Version: ${VERSION}"
echo "Tag: ${TAG}"
CURRENT_BRANCH=$(git branch --show-current)
if [ "${CURRENT_BRANCH}" != "main" ]; then
echo "Error: releases must be tagged from main; current branch is '${CURRENT_BRANCH}'." >&2
# Check if tag already exists locally
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag ${TAG} already exists locally, skipping."
exit 0
fi
# Check if tag already exists on remote
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "Tag ${TAG} already exists on remote, skipping."
exit 0
fi
# Ensure package.json changes are committed before tagging
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
exit 1
fi
if ! git diff --quiet HEAD -- package.json package-lock.json; then
echo "Error: package.json or package-lock.json has uncommitted changes. Please commit them before tagging." >&2
# Ensure current branch is pushed to remote before tagging
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
LOCAL_SHA=$(git rev-parse HEAD)
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
exit 1
fi
git fetch origin main
# Create and push tag
git tag "$TAG"
git push origin "$TAG"
HEAD_SHA=$(git rev-parse HEAD)
FETCHED_MAIN_SHA=$(git rev-parse "FETCH_HEAD^{commit}")
if [ "${HEAD_SHA}" != "${FETCHED_MAIN_SHA}" ]; then
echo "Error: HEAD must exactly match origin/main before tagging." >&2
exit 1
fi
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Error: local tag ${TAG} already exists." >&2
exit 1
fi
REMOTE_TAG=$(git ls-remote --tags origin "refs/tags/${TAG}")
if [ -n "${REMOTE_TAG}" ]; then
echo "Error: remote tag ${TAG} already exists." >&2
exit 1
fi
git tag "${TAG}" "${HEAD_SHA}"
git push origin "refs/tags/${TAG}"
echo "Successfully pushed tag ${TAG}"
echo "Successfully created and pushed tag ${TAG}"

View File

@@ -13,7 +13,7 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const createHint = "verify --app-type is html, frontend or full_stack and --name is non-empty; if this is a permission error, confirm your account can create apps"
const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create apps"
// AppsCreate creates a new app.
var AppsCreate = common.Shortcut{
@@ -23,7 +23,6 @@ var AppsCreate = common.Shortcut{
Risk: "write",
Tips: []string{
`Example: lark-cli apps +create --name "审批系统" --app-type full_stack`,
`Example: lark-cli apps +create --name "工具页" --app-type frontend --description "纯前端工具"`,
`Example: lark-cli apps +create --name "活动页" --app-type html --description "活动报名"`,
},
Scopes: []string{"spark:app:write"},
@@ -31,7 +30,7 @@ var AppsCreate = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "name", Desc: "app display name", Required: true},
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "frontend", "full_stack"}},
{Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}},
{Name: "description", Desc: "app description"},
{Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"},
},
@@ -60,7 +59,7 @@ var AppsCreate = common.Shortcut{
}
func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
// --app-type is constrained to the lowercase enum (html / frontend / full_stack) by the
// --app-type is constrained to the lowercase enum (html / full_stack) by the
// flag's Enum, so send it through verbatim. Legacy uppercase compatibility is
// a server concern and is intentionally not surfaced by the CLI.
agent := envvars.AgentName()

View File

@@ -187,7 +187,7 @@ func TestAppsCreate_RequiresAppType(t *testing.T) {
}
// TestAppsCreate_RejectsInvalidAppType pins that --app-type is a strict
// lowercase enum (html / frontend / full_stack). Unknown values and legacy uppercase are
// lowercase enum (html / full_stack). Unknown values and legacy uppercase are
// both rejected by the flag's Enum — the CLI does not normalize case; legacy
// uppercase compatibility is a server-side concern, not surfaced by the client.
func TestAppsCreate_RejectsInvalidAppType(t *testing.T) {
@@ -363,18 +363,3 @@ func TestAppsCreate_AgentEnvVarNotSet(t *testing.T) {
t.Fatalf("source_agent should not be present when env var is unset: %v", sent)
}
}
// TestAppsCreate_AcceptsFrontend pins that --app-type frontend is a valid
// enum value and flows through to the request body as "frontend" verbatim.
func TestAppsCreate_AcceptsFrontend(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCreate,
[]string{"+create", "--name", "Demo", "--app-type", "frontend", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("frontend dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"app_type": "frontend"`) {
t.Fatalf("expected app_type frontend in body, got %s", got)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -86,10 +86,6 @@ var appTypePolicies = map[string]appTypePolicy{
// no startup env vars to pull, no steering skills to sync, and no app sync.
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
"html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
// frontend (vite-react, a buildable front-end app) is intentionally NOT
// listed here: it takes the zero-value policy (install deps, pull env, sync
// skills) like full_stack, since it needs a build step — it is not a static
// HTML site and must not skip those steps.
}
// policyForAppType returns the +init control strategy for appType. Unlisted
@@ -442,9 +438,6 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
// --skip-install is appended per the app_type's policy (see appTypePolicy):
// types whose policy sets skipInstall (e.g. modern_html) skip the dependency
// install; others run it as usual.
// appType is forwarded verbatim (including "frontend") — the CLI does not
// translate the app type; mapping the app type to a concrete tech stack is the
// downstream tool's responsibility.
func scaffoldInitArgs(appType, appID, sourcePath string) []string {
base := []string{"-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "init"}
at := appType

View File

@@ -35,7 +35,7 @@ var AppsList = common.Shortcut{
Flags: []common.Flag{
{Name: "keyword", Desc: "fuzzy match on app name"},
{Name: "ownership", Desc: "ownership filter: all (created by me + shared with me) | mine | shared", Enum: []string{"all", "mine", "shared"}},
{Name: "app-type", Desc: "app type filter (html, frontend or full_stack)", Enum: []string{"html", "frontend", "full_stack"}},
{Name: "app-type", Desc: "app type filter (html or full_stack)", Enum: []string{"html", "full_stack"}},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
},

View File

@@ -15,7 +15,7 @@ import (
// queryAppType fetches the app's type string from the server via
// GET /open-apis/spark/v1/apps/{identifier}. The identifier can be either
// an app_id or a meta_token — the server resolves both. The server returns
// uppercase app_type values ("HTML", "FRONTEND", "FULL_STACK", "MODERN_HTML");
// uppercase app_type values ("HTML", "FULL_STACK", "MODERN_HTML");
// this function normalizes to lowercase. Returns an error when the API
// is unavailable or the response is malformed — callers must not proceed
// with a fallback type to avoid creating the wrong project scaffold.

View File

@@ -2435,14 +2435,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
},
},
})
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"create_records":[{"Name":"Alice"},{"Name":"Bob"}]}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["Bob"]]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})

View File

@@ -26,7 +26,7 @@ var BaseFormSubmit = common.Shortcut{
Service: "base",
Command: "+form-submit",
Description: "Submit a form (fill and submit form data)",
Risk: "high-risk-write",
Risk: "write",
Scopes: []string{"base:form:update", "docs:document.media:upload"},
AuthTypes: authTypes(),
HasFormat: true,
@@ -39,7 +39,6 @@ var BaseFormSubmit = common.Shortcut{
`Example (no attachments): --share-token shrXXXX --json '{"fields":{"Service Rating":5,"Review":"Good service"}}'`,
`Example (with attachments): --share-token shrXXXX --base-token basXXX --json '{"fields":{"Service Rating":5},"attachments":{"Attachment":["./report.pdf"]}}'`,
`Cell values in "fields" follow lark-base-cell-value.md conventions; "attachments" maps field names to local file path arrays — the CLI uploads them in parallel and merges them into the submission.`,
baseHighRiskYesTip,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFormSubmit(runtime)

View File

@@ -801,8 +801,7 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record batch create json",
shortcut: BaseRecordBatchCreate,
wantHelp: []string{
"create_records contains one field map per record",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
},
},
{
@@ -851,8 +850,8 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
`{"Parent Link":[{"id":"rec_xxx"}]}`,
"do not look for parent_record_id or a separate child-record API",
"CellValue happy path: text/phone/url",
"select (multiple=false) -> \"Todo\"",
"select (multiple=true) -> [\"Tag A\",\"Tag B\"]",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
"datetime -> \"2026-03-24 10:00:00\"",
"checkbox -> true/false",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -866,11 +865,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch create",
shortcut: BaseRecordBatchCreate,
wantTips: []string{
"Happy path field: create_records",
"create_records is an array of independent record field maps",
`{"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`,
"Happy path fields: fields is the column order",
"rows is an array of row arrays",
"may use null for empty cells",
"use +field-list to confirm real writable fields",
"Batch create supports max 200 records per call",
"Batch create supports max 200 rows per call",
"do not immediately +record-list the same table",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
@@ -2056,8 +2055,8 @@ func TestBaseFormSubmitShortcut(t *testing.T) {
if s.Service != "base" {
t.Fatalf("Service=%q want base", s.Service)
}
if s.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", s.Risk)
if s.Risk != "write" {
t.Fatalf("Risk=%q want write", s.Risk)
}
if !s.HasFormat {
t.Fatal("HasFormat should be true")
@@ -2357,7 +2356,6 @@ func TestExecuteFormSubmit(t *testing.T) {
"+form-submit",
"--share-token", "shr_exec1",
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2426,7 +2424,6 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_exec6",
"--base-token", "bas_exec6",
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
@@ -2475,7 +2472,6 @@ func TestExecuteFormSubmit(t *testing.T) {
"--share-token", "shr_dedup",
"--base-token", "bas_dedup",
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2487,33 +2483,6 @@ func TestExecuteFormSubmit(t *testing.T) {
})
}
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
// without --yes the runner's confirmation gate must fire before Execute runs,
// returning a typed confirmation_required error and touching no API.
func TestFormSubmitRequiresConfirmation(t *testing.T) {
if BaseFormSubmit.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
}
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_confirm",
"--json", `{"fields":{"Rating":5}}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected confirmation_required error without --yes")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
}
}
func TestUploadAttachmentsParallel(t *testing.T) {
t.Run("single file upload via execute path", func(t *testing.T) {
tmpDir := t.TempDir()
@@ -2550,7 +2519,6 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_para1",
"--base-token", "bas_para1",
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
"--yes",
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
@@ -2585,7 +2553,6 @@ func TestUploadAttachmentsParallel(t *testing.T) {
"--share-token", "shr_err",
"--base-token", "bas_err",
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
"--yes",
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {

View File

@@ -27,7 +27,7 @@ var BaseFieldSearchOptions = common.Shortcut{
},
Tips: []string{
`Example: lark-cli base +field-search-options --base-token <base_token> --table-id <table_id> --field-id "Status" --keyword "Do"`,
"Use only for select fields, whether multiple is false or true.",
"Use only for fields with options, such as select or multi-select fields.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateLimitPageSizeAlias(runtime); err != nil {

View File

@@ -19,13 +19,12 @@ var BaseRecordBatchCreate = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: `batch create JSON object; create_records contains one field map per record, e.g. {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}`, Required: true},
{Name: "json", Desc: `batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`, Required: true},
},
Tips: append([]string{
"Happy path field: create_records is an array of independent record field maps.",
`Example: {"create_records":[{"Name":"Task A","Status":"Todo"},{"Name":"Task B","Score":20}]}.`,
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch create supports max 200 records per call.",
"Batch create supports max 200 rows per call.",
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),

View File

@@ -6,10 +6,9 @@ package base
import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -31,7 +30,7 @@ func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interf
func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[string]interface{}, renderer func(map[string]interface{}) (string, error)) error {
if runtime.JqExpr != "" {
if !runtime.Changed("format") {
runtime.Out(data, nil)
runtime.OutJSON(data, nil)
return nil
}
return baseValidationErrorf("--jq and --format markdown are mutually exclusive")
@@ -39,32 +38,13 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
rendered, err := renderer(data)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
runtime.Out(data, nil)
runtime.OutJSON(data, nil)
return nil
}
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
if scanResult.Blocked {
return baseContentSafetyBlockError(scanResult)
}
if scanResult.Alert != nil {
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
}
fmt.Fprint(runtime.IO().Out, rendered)
return nil
}
func baseContentSafetyBlockError(scanResult output.ScanResult) error {
message := "content safety violation detected"
var rules []string
if scanResult.Alert != nil {
rules = scanResult.Alert.MatchedRules
}
if len(rules) > 0 {
message = fmt.Sprintf("content safety violation detected (rules: %s)", strings.Join(rules, ", "))
}
return errs.NewContentSafetyError(errs.SubtypeUnknown, "%s", message).
WithRules(rules...).
WithCause(scanResult.BlockErr)
return runtime.EmitRenderedValue(data, func(w io.Writer) error {
_, writeErr := io.WriteString(w, rendered)
return writeErr
})
}
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {

View File

@@ -31,6 +31,10 @@ func (p *recordMarkdownCSTestProvider) Scan(_ context.Context, _ extcs.ScanReque
return p.alert, nil
}
func (p *recordMarkdownCSTestProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeContext {
parentCmd := &cobra.Command{Use: "lark-cli"}
baseCmd := &cobra.Command{Use: "base"}
@@ -41,6 +45,7 @@ func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeC
return &common.RuntimeContext{
Config: &core.CliConfig{Brand: core.BrandFeishu},
Cmd: cmd,
Format: "markdown",
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}},
}
}

View File

@@ -19,7 +19,7 @@ const maxBatchGetSelectFieldCount = 100
const maxRecordSearchSelectFieldCount = 50
var recordCellValueHappyPathTips = []string{
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select (multiple=false) -> "Todo"; select (multiple=true) -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}], [{"id":"oc_xxx"}], [{"id":"rec_xxx"}]; location uses {"lng":116.397428,"lat":39.90923}; null clears a cell when allowed.`,
"Do not guess user/chat/linked-record IDs or location coordinates; resolve them first with the relevant contact/im/record lookup flow.",
"Use lark-base-cell-value.md for complex CellValue shapes and special field types; do not invent values for fields not covered by the happy path.",

View File

@@ -250,8 +250,6 @@ var CalendarAgenda = common.Shortcut{
}
}
collapseDescription(e)
filtered = append(filtered, e)
}
}

View File

@@ -20,6 +20,7 @@ import (
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
eventData := map[string]interface{}{
"summary": runtime.Str("summary"),
"description": runtime.Str("description"),
"start_time": map[string]string{"timestamp": startTs},
"end_time": map[string]string{"timestamp": endTs},
"attendee_ability": "can_modify_event",
@@ -32,9 +33,6 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
if rrule := runtime.Str("rrule"); rrule != "" {
eventData["recurrence"] = rrule
}
if description := descriptionToSend(runtime); description != "" {
eventData["description_rich"] = description
}
return eventData
}
@@ -120,7 +118,7 @@ var CalendarCreate = common.Shortcut{
{Name: "summary", Desc: "event title"},
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "description", Desc: "event description"},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -233,9 +231,6 @@ var CalendarCreate = common.Shortcut{
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
return err
}
eventData := buildEventData(runtime, startTs, endTs)

View File

@@ -81,7 +81,6 @@ type calendarEvent struct {
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
DescriptionRich string `json:"description_rich,omitempty"`
StartTime *calendarEventTime `json:"start_time,omitempty"`
EndTime *calendarEventTime `json:"end_time,omitempty"`
VChat *calendarEventVChat `json:"vchat,omitempty"`
@@ -170,7 +169,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
collapseDescription(out)
return out, nil
}

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