mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
9 Commits
sun/remove
...
features/F
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8581b1cd4c | ||
|
|
40a09c8957 | ||
|
|
806e8679f6 | ||
|
|
d69761e205 | ||
|
|
7346de30b1 | ||
|
|
cf93ee051c | ||
|
|
fe32a6e0a9 | ||
|
|
af9835c288 | ||
|
|
2e3073a532 |
49
.github/workflows/ci.yml
vendored
49
.github/workflows/ci.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -70,6 +71,7 @@ jobs:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -87,6 +89,23 @@ jobs:
|
||||
- name: Run errs/ lint guards (lintcheck)
|
||||
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
||||
|
||||
script-test:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Run script tests
|
||||
run: make script-test
|
||||
|
||||
deterministic-gate:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
@@ -109,8 +128,28 @@ jobs:
|
||||
env:
|
||||
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
||||
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||
- name: Write public content metadata
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
mkdir -p .tmp/quality-gate
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"title": os.environ.get("PR_TITLE", ""),
|
||||
"body": os.environ.get("PR_BODY", ""),
|
||||
"branch": os.environ.get("PR_BRANCH", ""),
|
||||
}, f)
|
||||
f.write("\n")
|
||||
PY
|
||||
- name: Run CLI deterministic gate
|
||||
run: make quality-gate
|
||||
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
|
||||
- name: Upload quality gate facts
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
@@ -220,7 +259,7 @@ jobs:
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint, deterministic-gate]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
@@ -241,7 +280,7 @@ jobs:
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, deterministic-gate]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -333,7 +372,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -345,6 +384,7 @@ jobs:
|
||||
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -361,6 +401,7 @@ jobs:
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
"${{ needs.unit-test.result }}" \
|
||||
"${{ needs.lint.result }}" \
|
||||
"${{ needs.script-test.result }}" \
|
||||
"${{ needs.deterministic-gate.result }}" \
|
||||
"${{ needs.coverage.result }}" \
|
||||
"${{ needs.deadcode.result }}" \
|
||||
|
||||
28
.github/workflows/comment-audit.yml
vendored
Normal file
28
.github/workflows/comment-audit.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Comment Audit
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
pull_request_review:
|
||||
types: [submitted, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
public-content-comment-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Post-publication comment audit
|
||||
run: |
|
||||
mkdir -p .tmp/comment-audit
|
||||
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
|
||||
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"
|
||||
77
.github/workflows/semantic-review.yml
vendored
77
.github/workflows/semantic-review.yml
vendored
@@ -88,31 +88,44 @@ jobs:
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.state === "open" &&
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
if (candidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (candidatePRs.length === 1) {
|
||||
prNumber = candidatePRs[0].number;
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "open",
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
if (candidatePRs.length !== 1) {
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
prNumber = candidatePRs[0].number;
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
@@ -121,6 +134,11 @@ jobs:
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
@@ -299,31 +317,44 @@ jobs:
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.state === "open" &&
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
if (candidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (candidatePRs.length === 1) {
|
||||
prNumber = candidatePRs[0].number;
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "open",
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
if (candidatePRs.length !== 1) {
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
prNumber = candidatePRs[0].number;
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
@@ -332,6 +363,16 @@ jobs:
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (!pr.head.repo) {
|
||||
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
@@ -389,6 +430,10 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
if (pull.state !== "open") {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
|
||||
return;
|
||||
}
|
||||
if (pull.head.sha !== headSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR head changed");
|
||||
return;
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -51,3 +51,4 @@ app.log
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
/automations/
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.58] - 2026-06-25
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
|
||||
- **base**: Add Base URL and title resolve shortcuts (#1338)
|
||||
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
|
||||
- **doc**: Support `create` title option (#1536)
|
||||
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
|
||||
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
|
||||
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
|
||||
- **task**: Add task event consumer (#1510)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Prefix docs resource shortcuts (#1564)
|
||||
- **binding**: Skip unix mode audit on Windows (#1525)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Sync approval skill for meta API commands (#1499)
|
||||
- **doc**: Restore lark-doc style requirements (#1579)
|
||||
- **im**: Document `chat.nickname` get/update/delete (#1378)
|
||||
- **im**: Clarify audio message opus requirement (#1271)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Add public content safeguards and reduce false positives
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
@@ -1236,6 +1265,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
|
||||
5
Makefile
5
Makefile
@@ -12,6 +12,7 @@ QUALITY_GATE_DIR ?= .tmp/quality-gate
|
||||
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
|
||||
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
|
||||
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
|
||||
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
@@ -69,7 +70,8 @@ integration-test: build
|
||||
test: vet fmt-check script-test unit-test examples-build integration-test
|
||||
|
||||
quality-gate: build
|
||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
|
||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
|
||||
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
|
||||
LARKSUITE_CLI_REMOTE_META=off \
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
|
||||
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
|
||||
@@ -89,6 +91,7 @@ quality-gate: build
|
||||
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
|
||||
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
|
||||
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
|
||||
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
|
||||
--facts-out $(QUALITY_GATE_FACTS_OUT)
|
||||
|
||||
install: build
|
||||
|
||||
174
events/vc/bot_events.go
Normal file
174
events/vc/bot_events.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCBotEventOutput is the raw-preserving shape for bot-observed VC events.
|
||||
type VCBotEventOutput struct {
|
||||
Type string `json:"type" desc:"Event type; one of the supported vc.bot.* keys"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
CallID string `json:"call_id,omitempty" desc:"Bot invitation call ID; pass through to vc agent join when present"`
|
||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number when present in the bot event payload"`
|
||||
ActivityEventType string `json:"activity_event_type,omitempty" desc:"Meeting activity event subtype when present"`
|
||||
ChatEmojiTypes []string `json:"chat_emoji_types,omitempty" desc:"Feishu post emotion emoji_type values extracted from vc.bot.meeting_event_v1 payloads"`
|
||||
RawEvent json.RawMessage `json:"raw_event,omitempty" desc:"Original VC bot event payload; authoritative for fields not normalized by lark-cli"`
|
||||
}
|
||||
|
||||
func processVCBotMeetingInvited(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
return processVCBotEvent(raw, false)
|
||||
}
|
||||
|
||||
func processVCBotMeetingEvent(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
return processVCBotEvent(raw, true)
|
||||
}
|
||||
|
||||
func processVCBotMeetingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
return processVCBotEvent(raw, false)
|
||||
}
|
||||
|
||||
func processVCBotEvent(raw *event.RawEvent, includeEmojiTypes bool) (json.RawMessage, error) {
|
||||
var payload any
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw.Payload))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
out := &VCBotEventOutput{
|
||||
Type: firstString(payload, "event_type"),
|
||||
EventID: firstString(payload, "event_id"),
|
||||
Timestamp: firstString(payload, "create_time"),
|
||||
CallID: firstString(payload, "call_id"),
|
||||
MeetingNo: firstString(payload, "meeting_no"),
|
||||
ActivityEventType: firstString(payload, "activity_event_type"),
|
||||
RawEvent: append(json.RawMessage(nil), raw.Payload...),
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
if includeEmojiTypes {
|
||||
out.ChatEmojiTypes = botEmojiTypes(payload)
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func firstString(value any, key string) string {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
if raw, ok := v[key]; ok {
|
||||
if s := jsonString(raw); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
for _, child := range orderedChildren(v) {
|
||||
if s := firstString(child, key); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range v {
|
||||
if s := firstString(child, key); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func orderedChildren(v map[string]any) []any {
|
||||
priority := []string{"header", "event", "meeting", "meeting_info", "activity", "message", "reaction_type"}
|
||||
out := make([]any, 0, len(v))
|
||||
used := make(map[string]bool, len(priority))
|
||||
for _, key := range priority {
|
||||
if child, ok := v[key]; ok {
|
||||
out = append(out, child)
|
||||
used[key] = true
|
||||
}
|
||||
}
|
||||
rest := make([]string, 0, len(v))
|
||||
for key := range v {
|
||||
if !used[key] {
|
||||
rest = append(rest, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(rest)
|
||||
for _, key := range rest {
|
||||
out = append(out, v[key])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func botEmojiTypes(value any) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
collectEmojiTypes(value, seen, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func collectEmojiTypes(value any, seen map[string]bool, out *[]string) {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range []string{"emoji_type", "chat_emoji_type"} {
|
||||
if s := jsonString(v[key]); s != "" && !seen[s] {
|
||||
seen[s] = true
|
||||
*out = append(*out, s)
|
||||
}
|
||||
}
|
||||
if raw, ok := v["chat_emoji_types"]; ok {
|
||||
for _, s := range jsonStringSlice(raw) {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
*out = append(*out, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, child := range v {
|
||||
collectEmojiTypes(child, seen, out)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range v {
|
||||
collectEmojiTypes(child, seen, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jsonString(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
case json.Number:
|
||||
return v.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func jsonStringSlice(value any) []string {
|
||||
switch v := value.(type) {
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s := jsonString(item); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return append([]string(nil), v...)
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{v}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
206
events/vc/bot_events_test.go
Normal file
206
events/vc/bot_events_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestVCKeys_BotEventsRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, eventType := range []string{
|
||||
eventTypeBotMeetingInvited,
|
||||
eventTypeBotMeetingEvent,
|
||||
eventTypeBotMeetingEnded,
|
||||
} {
|
||||
t.Run(eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("bot event must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("bot event must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("bot event Process must not be nil")
|
||||
}
|
||||
if def.PreConsume != nil {
|
||||
t.Fatal("bot event must not reuse user-side VC PreConsume subscription")
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
|
||||
t.Errorf("AuthTypes = %v, want [bot]", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventType}) {
|
||||
t.Errorf("RequiredConsoleEvents = %v, want [%s]", def.RequiredConsoleEvents, eventType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCBotEvents_StableFieldsAndRawEvent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
payload string
|
||||
want VCBotEventOutput
|
||||
wantEmojis []string
|
||||
}{
|
||||
{
|
||||
name: "invited",
|
||||
eventType: eventTypeBotMeetingInvited,
|
||||
process: processVCBotMeetingInvited,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_invited",
|
||||
"event_type": "vc.bot.meeting_invited_v1",
|
||||
"create_time": "1776409469273"
|
||||
},
|
||||
"event": {
|
||||
"call_id": "call_123",
|
||||
"meeting": {"meeting_no": "123456789"}
|
||||
}
|
||||
}`,
|
||||
want: VCBotEventOutput{
|
||||
Type: eventTypeBotMeetingInvited,
|
||||
EventID: "ev_invited",
|
||||
Timestamp: "1776409469273",
|
||||
CallID: "call_123",
|
||||
MeetingNo: "123456789",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "meeting event",
|
||||
eventType: eventTypeBotMeetingEvent,
|
||||
process: processVCBotMeetingEvent,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_activity",
|
||||
"event_type": "vc.bot.meeting_event_v1",
|
||||
"create_time": "1776409469274"
|
||||
},
|
||||
"event": {
|
||||
"meeting_no": "987654321",
|
||||
"activity_event_type": "chat_message",
|
||||
"chat_messages": [
|
||||
{"message_type": 3, "reaction_type": {"emoji_type": "JIAYI"}},
|
||||
{"message_type": 3, "chat_emoji_types": ["OK", "JIAYI"]}
|
||||
]
|
||||
}
|
||||
}`,
|
||||
want: VCBotEventOutput{
|
||||
Type: eventTypeBotMeetingEvent,
|
||||
EventID: "ev_activity",
|
||||
Timestamp: "1776409469274",
|
||||
MeetingNo: "987654321",
|
||||
ActivityEventType: "chat_message",
|
||||
},
|
||||
wantEmojis: []string{"JIAYI", "OK"},
|
||||
},
|
||||
{
|
||||
name: "ended",
|
||||
eventType: eventTypeBotMeetingEnded,
|
||||
process: processVCBotMeetingEnded,
|
||||
payload: `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_ended",
|
||||
"event_type": "vc.bot.meeting_ended_v1",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"meeting_no": "246801357"
|
||||
}
|
||||
}`,
|
||||
want: VCBotEventOutput{
|
||||
Type: eventTypeBotMeetingEnded,
|
||||
EventID: "ev_ended",
|
||||
Timestamp: "1776409469275",
|
||||
MeetingNo: "246801357",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out := runBotEventProcess(t, tc.eventType, tc.process, tc.payload)
|
||||
if out.Type != tc.want.Type || out.EventID != tc.want.EventID || out.Timestamp != tc.want.Timestamp {
|
||||
t.Errorf("type/event_id/timestamp = %q/%q/%q", out.Type, out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.CallID != tc.want.CallID {
|
||||
t.Errorf("CallID = %q, want %q", out.CallID, tc.want.CallID)
|
||||
}
|
||||
if out.MeetingNo != tc.want.MeetingNo {
|
||||
t.Errorf("MeetingNo = %q, want %q", out.MeetingNo, tc.want.MeetingNo)
|
||||
}
|
||||
if out.ActivityEventType != tc.want.ActivityEventType {
|
||||
t.Errorf("ActivityEventType = %q, want %q", out.ActivityEventType, tc.want.ActivityEventType)
|
||||
}
|
||||
if !reflect.DeepEqual(out.ChatEmojiTypes, tc.wantEmojis) {
|
||||
t.Errorf("ChatEmojiTypes = %v, want %v", out.ChatEmojiTypes, tc.wantEmojis)
|
||||
}
|
||||
if len(out.RawEvent) == 0 {
|
||||
t.Fatal("RawEvent must be preserved")
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(out.RawEvent, &raw); err != nil {
|
||||
t.Fatalf("RawEvent is not valid JSON: %v", err)
|
||||
}
|
||||
if raw["schema"] != "2.0" {
|
||||
t.Errorf("RawEvent schema = %v, want 2.0", raw["schema"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCBotMeetingEvent_MalformedPassthrough(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: eventTypeBotMeetingEvent,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processVCBotMeetingEvent(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("process error: %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Fatalf("malformed payload passthrough = %s, want raw payload", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func runBotEventProcess(t *testing.T, eventType string, process event.ProcessFunc, payload string) VCBotEventOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "raw_" + eventType,
|
||||
EventType: eventType,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("process %s: %v", eventType, err)
|
||||
}
|
||||
var out VCBotEventOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\n%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -16,6 +16,9 @@ const (
|
||||
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
||||
eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1"
|
||||
eventTypeRecordingEnded = "vc.recording.recording_ended_v1"
|
||||
eventTypeBotMeetingInvited = "vc.bot.meeting_invited_v1"
|
||||
eventTypeBotMeetingEvent = "vc.bot.meeting_event_v1"
|
||||
eventTypeBotMeetingEnded = "vc.bot.meeting_ended_v1"
|
||||
|
||||
pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription"
|
||||
pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription"
|
||||
@@ -110,5 +113,41 @@ func Keys() []event.KeyDefinition {
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeRecordingEnded},
|
||||
},
|
||||
{
|
||||
Key: eventTypeBotMeetingInvited,
|
||||
DisplayName: "Bot meeting invited",
|
||||
Description: "Triggered when the bot is invited to a meeting; bot-observed event that does not create a user-side VC subscription",
|
||||
EventType: eventTypeBotMeetingInvited,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCBotEventOutput{})},
|
||||
},
|
||||
Process: processVCBotMeetingInvited,
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeBotMeetingInvited},
|
||||
},
|
||||
{
|
||||
Key: eventTypeBotMeetingEvent,
|
||||
DisplayName: "Bot meeting event",
|
||||
Description: "Triggered when the bot observes activity in a meeting; keeps the raw bot payload and extracts stable activity fields",
|
||||
EventType: eventTypeBotMeetingEvent,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCBotEventOutput{})},
|
||||
},
|
||||
Process: processVCBotMeetingEvent,
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeBotMeetingEvent},
|
||||
},
|
||||
{
|
||||
Key: eventTypeBotMeetingEnded,
|
||||
DisplayName: "Bot meeting ended",
|
||||
Description: "Triggered when a meeting observed by the bot has ended; distinct from user participant or open meeting resource events",
|
||||
EventType: eventTypeBotMeetingEnded,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCBotEventOutput{})},
|
||||
},
|
||||
Process: processVCBotMeetingEnded,
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeBotMeetingEnded},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
92
internal/qualitygate/cmd/comment-audit/main.go
Normal file
92
internal/qualitygate/cmd/comment-audit/main.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type eventPayload struct {
|
||||
Comment *struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"comment"`
|
||||
Review *struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"review"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
|
||||
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
|
||||
flag.Parse()
|
||||
|
||||
if *eventPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
body, err := commentBody(*eventPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
diags := diagnostics(publiccontent.ScanComment(*kind, body))
|
||||
if len(diags) > 0 {
|
||||
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
|
||||
}
|
||||
report.Print(os.Stderr, diags)
|
||||
os.Exit(report.ExitCode(diags))
|
||||
}
|
||||
|
||||
func auditFailureSummary(count int) string {
|
||||
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
|
||||
}
|
||||
|
||||
func commentBody(path string) (string, error) {
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
WithParam("--event").
|
||||
WithCause(err)
|
||||
}
|
||||
data, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload eventPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case payload.Comment != nil:
|
||||
return payload.Comment.Body, nil
|
||||
case payload.Review != nil:
|
||||
return payload.Review.Body, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func diagnostics(items []publiccontent.Finding) []report.Diagnostic {
|
||||
out := make([]report.Diagnostic, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, report.Diagnostic{
|
||||
Rule: item.Rule,
|
||||
Action: item.Action,
|
||||
File: item.File,
|
||||
Line: item.Line,
|
||||
Message: item.Message,
|
||||
Suggestion: item.Suggestion,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
70
internal/qualitygate/cmd/comment-audit/main_test.go
Normal file
70
internal/qualitygate/cmd/comment-audit/main_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"clean comment"}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
got, err := commentBody("event.json")
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got != "clean comment" {
|
||||
t.Fatalf("comment body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "event.json")
|
||||
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := commentBody(path)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if err == nil || !ok {
|
||||
t.Fatalf("commentBody(%q) error = %v, want unsafe path validation error", path, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("commentBody(%q) problem = %#v, want invalid argument validation", path, problem)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--event" {
|
||||
t.Fatalf("commentBody(%q) error = %v, want --event validation param", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditFailureSummaryStatesPostPublicationAudit(t *testing.T) {
|
||||
got := auditFailureSummary(2)
|
||||
want := "post-publication audit found public content findings: 2"
|
||||
if got != want {
|
||||
t.Fatalf("auditFailureSummary() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(path, data string) error {
|
||||
return os.WriteFile(path, []byte(data), 0o644)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/qualitygate/rules"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -41,6 +42,7 @@ func runCheck(args []string) int {
|
||||
fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path")
|
||||
fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON")
|
||||
fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON")
|
||||
fs.StringVar(&opts.PublicContentMetadataPath, "public-content-metadata", "", "PR title/body metadata JSON for public content checks")
|
||||
fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates")
|
||||
fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -48,6 +50,15 @@ func runCheck(args []string) int {
|
||||
return 2
|
||||
}
|
||||
|
||||
if opts.PublicContentMetadataPath != "" {
|
||||
safePath, err := validate.SafeInputPath(opts.PublicContentMetadataPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "quality-gate check: --public-content-metadata: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
opts.PublicContentMetadataPath = safePath
|
||||
}
|
||||
|
||||
if opts.ManifestPath == "" || opts.CommandIndexPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required")
|
||||
return 2
|
||||
|
||||
@@ -37,6 +37,37 @@ func TestCheckRequiresManifestInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAcceptsPublicContentMetadataFlag(t *testing.T) {
|
||||
code, stderr := runCheckCaptureStderr(t, []string{
|
||||
"--repo", t.TempDir(),
|
||||
"--cli-bin", "./lark-cli",
|
||||
"--public-content-metadata", ".tmp/quality-gate/pr.json",
|
||||
})
|
||||
if code != 2 {
|
||||
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "flag provided but not defined") {
|
||||
t.Fatalf("public content metadata flag was not registered: %s", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "--manifest and --command-index are required") {
|
||||
t.Fatalf("stderr = %s", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRejectsUnsafePublicContentMetadataPath(t *testing.T) {
|
||||
code, stderr := runCheckCaptureStderr(t, []string{
|
||||
"--repo", t.TempDir(),
|
||||
"--cli-bin", "./lark-cli",
|
||||
"--public-content-metadata", filepath.Join(t.TempDir(), "pr.json"),
|
||||
})
|
||||
if code != 2 {
|
||||
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "--public-content-metadata") || !strings.Contains(stderr, "--file") {
|
||||
t.Fatalf("stderr = %s, want unsafe public content metadata path error", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
manifestPath := filepath.Join(dir, "command-manifest.json")
|
||||
|
||||
@@ -56,6 +56,14 @@ func run(args []string) int {
|
||||
_ = semantic.WriteMarkdown(markdownOut, decision)
|
||||
return 0
|
||||
}
|
||||
if reviewPath == "" && !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
decision := finalizeDecision(block, waiverDiags, semantic.Decision{})
|
||||
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
return decisionExitCode(decision)
|
||||
}
|
||||
review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||
@@ -72,6 +80,15 @@ func run(args []string) int {
|
||||
return 0
|
||||
}
|
||||
decision := semantic.DecideWithWaivers(f, review, policy, waivers)
|
||||
decision = finalizeDecision(block, waiverDiags, decision)
|
||||
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
return decisionExitCode(decision)
|
||||
}
|
||||
|
||||
func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision semantic.Decision) semantic.Decision {
|
||||
decision.BlockMode = block
|
||||
if !block && len(decision.Blockers) > 0 {
|
||||
for i := range decision.Blockers {
|
||||
@@ -81,15 +98,21 @@ func run(args []string) int {
|
||||
decision.Blockers = nil
|
||||
}
|
||||
decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...)
|
||||
return decision
|
||||
}
|
||||
|
||||
func writeSemanticOutputs(decisionOut, markdownOut string, decision semantic.Decision) error {
|
||||
if err := semantic.WriteDecision(decisionOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err)
|
||||
return 2
|
||||
return fmt.Errorf("write decision: %w", err)
|
||||
}
|
||||
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err)
|
||||
return 2
|
||||
return fmt.Errorf("write markdown: %w", err)
|
||||
}
|
||||
if block && len(decision.Blockers) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func decisionExitCode(decision semantic.Decision) int {
|
||||
if decision.BlockMode && len(decision.Blockers) > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
@@ -211,7 +212,19 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
|
||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||
}`, "")
|
||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
Skills: []facts.SkillFact{{
|
||||
SourceFile: "skills/lark-wiki/SKILL.md",
|
||||
Line: 30,
|
||||
Changed: true,
|
||||
ReferencesInvalidCommand: true,
|
||||
}},
|
||||
}
|
||||
if !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
t.Fatal("test setup must contain reviewable facts")
|
||||
}
|
||||
if err := f.WriteFile(factsPath); err != nil {
|
||||
t.Fatalf("write facts: %v", err)
|
||||
}
|
||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||
@@ -228,6 +241,71 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortCircuitsEmptySemanticInputWithoutReviewer(t *testing.T) {
|
||||
t.Setenv("ARK_API_KEY", "")
|
||||
t.Setenv("ARK_BASE_URL", "")
|
||||
t.Setenv("ARK_MODEL", "")
|
||||
|
||||
repo := t.TempDir()
|
||||
writeSemanticConfig(t, repo, `{
|
||||
"schema_version": 1,
|
||||
"default_enforcement": "observe",
|
||||
"block_categories": ["skill_quality"]
|
||||
}`, `{
|
||||
"allowed": ["semantic-review-v1"],
|
||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||
}`, "")
|
||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
Commands: []facts.CommandFact{{
|
||||
Path: "service command 1",
|
||||
Domain: "service",
|
||||
Changed: true,
|
||||
Source: "service",
|
||||
}},
|
||||
Outputs: []facts.OutputFact{{
|
||||
Command: "service command 1",
|
||||
Domain: "service",
|
||||
Changed: true,
|
||||
Source: "service",
|
||||
IsList: true,
|
||||
HasDefaultLimit: true,
|
||||
HasDecisionField: true,
|
||||
}},
|
||||
}
|
||||
if semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
t.Fatal("test setup must not contain reviewable facts")
|
||||
}
|
||||
if err := f.WriteFile(factsPath); err != nil {
|
||||
t.Fatalf("write facts: %v", err)
|
||||
}
|
||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||
markdownPath := filepath.Join(t.TempDir(), "semantic.md")
|
||||
code := run([]string{"--repo", repo, "--facts", factsPath, "--decision-out", decisionPath, "--markdown-out", markdownPath, "--block"})
|
||||
if code != 0 {
|
||||
t.Fatalf("run() = %d, want clean pass", code)
|
||||
}
|
||||
decision := readDecision(t, decisionPath)
|
||||
if decision.Skipped || decision.Degraded || decision.InfrastructureFailure || !decision.BlockMode {
|
||||
t.Fatalf("expected non-degraded pass decision: %#v", decision)
|
||||
}
|
||||
if len(decision.SystemWarnings) != 0 || len(decision.Warnings) != 0 || len(decision.Blockers) != 0 {
|
||||
t.Fatalf("empty semantic view should not produce findings: %#v", decision)
|
||||
}
|
||||
data, err := os.ReadFile(markdownPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read markdown: %v", err)
|
||||
}
|
||||
markdown := string(data)
|
||||
if !strings.Contains(markdown, "No semantic blockers.") {
|
||||
t.Fatalf("markdown missing pass summary: %s", markdown)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(markdown), "skipped") || strings.Contains(strings.ToLower(markdown), "degraded") {
|
||||
t.Fatalf("markdown should not report semantic review as skipped/degraded: %s", markdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) {
|
||||
t.Setenv("ARK_API_KEY", "test-key")
|
||||
t.Setenv("ARK_BASE_URL", "")
|
||||
@@ -243,7 +321,19 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi
|
||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||
}`, "")
|
||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
Skills: []facts.SkillFact{{
|
||||
SourceFile: "skills/lark-wiki/SKILL.md",
|
||||
Line: 30,
|
||||
Changed: true,
|
||||
ReferencesInvalidCommand: true,
|
||||
}},
|
||||
}
|
||||
if !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
t.Fatal("test setup must contain reviewable facts")
|
||||
}
|
||||
if err := f.WriteFile(factsPath); err != nil {
|
||||
t.Fatalf("write facts: %v", err)
|
||||
}
|
||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"error_hint",
|
||||
"default_output",
|
||||
"naming",
|
||||
"skill_quality"
|
||||
"skill_quality",
|
||||
"public_content_leakage"
|
||||
],
|
||||
"rollout_groups": [
|
||||
{
|
||||
@@ -16,7 +17,8 @@
|
||||
},
|
||||
"categories": [
|
||||
"error_hint",
|
||||
"skill_quality"
|
||||
"skill_quality",
|
||||
"public_content_leakage"
|
||||
],
|
||||
"owner": "cli-owner",
|
||||
"reason": "first semantic blocking rollout only affects changed facts"
|
||||
|
||||
@@ -13,14 +13,15 @@ import (
|
||||
)
|
||||
|
||||
type Facts struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Commands []CommandFact `json:"commands,omitempty"`
|
||||
Skills []SkillFact `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorFact `json:"errors,omitempty"`
|
||||
Outputs []OutputFact `json:"outputs,omitempty"`
|
||||
Examples []CommandExample `json:"examples,omitempty"`
|
||||
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Commands []CommandFact `json:"commands,omitempty"`
|
||||
Skills []SkillFact `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorFact `json:"errors,omitempty"`
|
||||
Outputs []OutputFact `json:"outputs,omitempty"`
|
||||
Examples []CommandExample `json:"examples,omitempty"`
|
||||
PublicContent []PublicContentFact `json:"public_content,omitempty"`
|
||||
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
type CommandFact struct {
|
||||
@@ -109,6 +110,17 @@ type OutputFact struct {
|
||||
HasDecisionField bool `json:"has_decision_field,omitempty"`
|
||||
}
|
||||
|
||||
type PublicContentFact struct {
|
||||
Rule string `json:"rule"`
|
||||
Action report.Action `json:"action"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Excerpt string `json:"excerpt,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Suggestion string `json:"suggestion,omitempty"`
|
||||
}
|
||||
|
||||
type DryRunRequest struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
@@ -206,6 +218,11 @@ func BuildWithCommandLookup(m manifest.Manifest, commandLookup manifest.Manifest
|
||||
}
|
||||
}
|
||||
|
||||
func WithPublicContent(f Facts, publicContent []PublicContentFact) Facts {
|
||||
f.PublicContent = publicContent
|
||||
return f
|
||||
}
|
||||
|
||||
type commandScope struct {
|
||||
Domain string
|
||||
Source string
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
|
||||
Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}},
|
||||
Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}},
|
||||
Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}},
|
||||
PublicContent: []PublicContentFact{{Rule: "public_content_generic_credential", Action: report.ActionReject, File: "docs/public.md", Line: 4, Excerpt: "api_key = <redacted>"}},
|
||||
}
|
||||
data, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
@@ -43,7 +44,10 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal facts: %v", err)
|
||||
}
|
||||
if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict {
|
||||
if !got.Errors[0].RequiredHint ||
|
||||
got.Outputs[0].Fields[0] != "message_id" ||
|
||||
!got.Skills[0].ScopeConflict ||
|
||||
got.PublicContent[0].Rule != "public_content_generic_credential" {
|
||||
t.Fatalf("facts lost gatekeeper fields: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
343
internal/qualitygate/publiccontent/collect.go
Normal file
343
internal/qualitygate/publiccontent/collect.go
Normal file
@@ -0,0 +1,343 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Collect(ctx context.Context, opts Options) ([]Finding, error) {
|
||||
metadata, err := LoadMetadata(opts.MetadataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Finding
|
||||
changedFiles, base, err := changedFiles(ctx, opts.Repo, opts.ChangedFrom)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patches := map[string][]changedChunk{}
|
||||
if base != "" {
|
||||
patches, err = changedPatches(ctx, opts.Repo, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, file := range changedFiles {
|
||||
if !scanChangedFile(file) {
|
||||
continue
|
||||
}
|
||||
for _, chunk := range patches[file] {
|
||||
findings := scanText(file, "file", chunk.Text, isDetectorRuleFile(file))
|
||||
for i := range findings {
|
||||
findings[i].Line += chunk.StartLine - 1
|
||||
}
|
||||
out = append(out, findings...)
|
||||
out = append(out, semanticCandidate(file, "file", chunk.Text, chunk.StartLine)...)
|
||||
}
|
||||
privateKeyFindings, err := scanTouchedPrivateKeyBlocks(ctx, opts.Repo, file, patches[file])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = appendUniqueFindings(out, privateKeyFindings...)
|
||||
}
|
||||
if base != "" {
|
||||
commitFindings, err := scanCommitMessages(ctx, opts.Repo, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, commitFindings...)
|
||||
}
|
||||
branchName := opts.BranchName
|
||||
if branchName == "" {
|
||||
branchName = metadata.Branch
|
||||
}
|
||||
if branchName == "" {
|
||||
branchName = branchFromEnv()
|
||||
}
|
||||
if branchName == "" {
|
||||
branchName = currentBranch(ctx, opts.Repo)
|
||||
}
|
||||
if branchName != "" {
|
||||
out = append(out, scanText("branch", "branch", branchName, false)...)
|
||||
}
|
||||
out = append(out, scanMetadata(metadata)...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
if out[i].Line != out[j].Line {
|
||||
return out[i].Line < out[j].Line
|
||||
}
|
||||
return out[i].Rule < out[j].Rule
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func currentBranch(ctx context.Context, repo string) string {
|
||||
data, err := gitOutput(ctx, repo, "branch", "--show-current")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func branchFromEnv() string {
|
||||
for _, key := range []string{"PR_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"} {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func changedFiles(ctx context.Context, repo, changedFrom string) ([]string, string, error) {
|
||||
if changedFrom == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
baseBytes, err := gitOutput(ctx, repo, "merge-base", changedFrom, "HEAD")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
base := strings.TrimSpace(string(baseBytes))
|
||||
files, err := diffFileNames(ctx, repo, base)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, base, nil
|
||||
}
|
||||
|
||||
func diffFileNames(ctx context.Context, repo, base string) ([]string, error) {
|
||||
data, err := gitOutput(ctx, repo, "diff", "--name-only", "-z", "--diff-filter=ACMR", base+"..HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files []string
|
||||
for _, file := range bytes.Split(data, []byte{0}) {
|
||||
if len(file) == 0 {
|
||||
continue
|
||||
}
|
||||
files = append(files, filepath.ToSlash(string(file)))
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
var detectorFixtureExclusions = map[string]bool{
|
||||
"internal/qualitygate/publiccontent/collect_test.go": true,
|
||||
"internal/qualitygate/publiccontent/rules.go": true,
|
||||
"internal/qualitygate/publiccontent/scan.go": true,
|
||||
"internal/qualitygate/publiccontent/scan_test.go": true,
|
||||
}
|
||||
|
||||
func scanChangedFile(file string) bool {
|
||||
normalized := strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), "./")
|
||||
return !detectorFixtureExclusions[normalized]
|
||||
}
|
||||
|
||||
type changedChunk struct {
|
||||
StartLine int
|
||||
Text string
|
||||
}
|
||||
|
||||
func (c changedChunk) endLine() int {
|
||||
lines := strings.Count(strings.TrimRight(c.Text, "\n"), "\n") + 1
|
||||
if lines < 1 {
|
||||
lines = 1
|
||||
}
|
||||
return c.StartLine + lines - 1
|
||||
}
|
||||
|
||||
func changedPatches(ctx context.Context, repo, base string) (map[string][]changedChunk, error) {
|
||||
files, err := diffFileNames(ctx, repo, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := gitOutput(ctx, repo, "diff", "--no-ext-diff", "--unified=0", "--diff-filter=ACMR", base+"..HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]changedChunk{}
|
||||
var file string
|
||||
var chunk *changedChunk
|
||||
nextLine := 0
|
||||
nextFile := 0
|
||||
flush := func() {
|
||||
if file == "" || chunk == nil || chunk.Text == "" {
|
||||
chunk = nil
|
||||
return
|
||||
}
|
||||
out[file] = append(out[file], *chunk)
|
||||
chunk = nil
|
||||
}
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(raw, "diff --git "):
|
||||
flush()
|
||||
file = ""
|
||||
if nextFile < len(files) {
|
||||
file = files[nextFile]
|
||||
nextFile++
|
||||
}
|
||||
case strings.HasPrefix(raw, "@@ "):
|
||||
flush()
|
||||
start, ok := parseNewHunkStart(raw)
|
||||
if !ok {
|
||||
nextLine = 0
|
||||
continue
|
||||
}
|
||||
nextLine = start
|
||||
chunk = &changedChunk{StartLine: start}
|
||||
case strings.HasPrefix(raw, "+") && !strings.HasPrefix(raw, "+++"):
|
||||
if chunk == nil {
|
||||
chunk = &changedChunk{StartLine: max(nextLine, 1)}
|
||||
}
|
||||
chunk.Text += strings.TrimPrefix(raw, "+") + "\n"
|
||||
nextLine++
|
||||
case strings.HasPrefix(raw, "-"):
|
||||
continue
|
||||
default:
|
||||
if chunk != nil && strings.HasPrefix(raw, `\ No newline at end of file`) {
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseNewHunkStart(header string) (int, bool) {
|
||||
parts := strings.Split(header, " ")
|
||||
for _, part := range parts {
|
||||
if !strings.HasPrefix(part, "+") {
|
||||
continue
|
||||
}
|
||||
raw := strings.TrimPrefix(part, "+")
|
||||
if before, _, ok := strings.Cut(raw, ","); ok {
|
||||
raw = before
|
||||
}
|
||||
start, err := strconv.Atoi(raw)
|
||||
return start, err == nil && start > 0
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func scanCommitMessages(ctx context.Context, repo, base string) ([]Finding, error) {
|
||||
data, err := gitOutput(ctx, repo, "log", "--format=%H%x00%B%x00", base+"..HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := bytes.Split(data, []byte{0})
|
||||
var out []Finding
|
||||
for i := 0; i+1 < len(parts); i += 2 {
|
||||
sha := strings.TrimSpace(string(parts[i]))
|
||||
body := string(parts[i+1])
|
||||
if sha == "" || body == "" {
|
||||
continue
|
||||
}
|
||||
short := sha
|
||||
if len(short) > 12 {
|
||||
short = short[:12]
|
||||
}
|
||||
out = append(out, scanText("commit:"+short, "commit", body, false)...)
|
||||
out = append(out, semanticCandidate("commit:"+short, "commit", body, 1)...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type lineRange struct {
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
func scanTouchedPrivateKeyBlocks(ctx context.Context, repo, file string, chunks []changedChunk) ([]Finding, error) {
|
||||
if len(chunks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := gitOutput(ctx, repo, "show", "HEAD:"+file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var added []lineRange
|
||||
for _, chunk := range chunks {
|
||||
added = append(added, lineRange{Start: chunk.StartLine, End: chunk.endLine()})
|
||||
}
|
||||
var out []Finding
|
||||
for _, block := range privateKeyBlocks(string(data)) {
|
||||
if !rangesIntersectAny(block, added) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_private_key_block", file, block.Start, "file", "private key block"))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func privateKeyBlocks(text string) []lineRange {
|
||||
lines := strings.Split(text, "\n")
|
||||
var out []lineRange
|
||||
inPrivateKey := false
|
||||
start := 0
|
||||
for i, line := range lines {
|
||||
lineNo := i + 1
|
||||
if !inPrivateKey && strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = true
|
||||
start = lineNo
|
||||
}
|
||||
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
out = append(out, lineRange{Start: start, End: lineNo})
|
||||
inPrivateKey = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rangesIntersectAny(block lineRange, ranges []lineRange) bool {
|
||||
for _, r := range ranges {
|
||||
if block.Start <= r.End && r.Start <= block.End {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendUniqueFindings(items []Finding, additions ...Finding) []Finding {
|
||||
for _, addition := range additions {
|
||||
duplicate := false
|
||||
for _, item := range items {
|
||||
if item.Rule == addition.Rule &&
|
||||
item.File == addition.File &&
|
||||
item.Line == addition.Line &&
|
||||
item.Source == addition.Source {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !duplicate {
|
||||
items = append(items, addition)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func gitOutput(ctx context.Context, repo string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = repo
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.Bytes())
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
885
internal/qualitygate/publiccontent/collect_test.go
Normal file
885
internal/qualitygate/publiccontent/collect_test.go
Normal file
@@ -0,0 +1,885 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "baseline.md"), `BASE_`+`TOKEN="baseline-only"
|
||||
`)
|
||||
runGit(t, repo, "add", "baseline.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
|
||||
|
||||
api_`+`key = "example-public-key"
|
||||
`)
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{"title":"publish public docs","body":"Reviewed`+`-on: https://review.example.test/c/project/+/123"}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
|
||||
rules := findingRules(got)
|
||||
for _, want := range []string{
|
||||
"public_content_generic_credential",
|
||||
"public_content_change_id_trailer",
|
||||
"public_content_reviewed_on_trailer",
|
||||
} {
|
||||
if !rules[want] {
|
||||
t.Fatalf("missing rule %s in findings %#v", want, got)
|
||||
}
|
||||
}
|
||||
for _, item := range got {
|
||||
if item.File == "baseline.md" {
|
||||
t.Fatalf("collector scanned unchanged baseline file: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\n")
|
||||
runGit(t, repo, "add", "docs/workflow.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\nnew public line\n")
|
||||
runGit(t, repo, "add", "docs/workflow.md")
|
||||
runGit(t, repo, "commit", "-m", "add public line")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" && item.File == "docs/workflow.md" {
|
||||
t.Fatalf("collector scanned unchanged legacy line in changed file: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
raw := "private launch plan for alpha-service rollout on Friday with SERVICE_" + "TOKEN=real-" + "secret-value"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add semantic candidate")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, item := range got {
|
||||
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !strings.Contains(item.Excerpt, "alpha-service rollout on Friday") {
|
||||
t.Fatalf("semantic candidate should include sanitized review text, got %#v", item)
|
||||
}
|
||||
if strings.Contains(item.Excerpt, "real-"+"secret-value") {
|
||||
t.Fatalf("semantic candidate leaked credential value: %#v", item)
|
||||
}
|
||||
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
|
||||
t.Fatalf("semantic candidate should redact credentials in review text, got %#v", item)
|
||||
}
|
||||
if !strings.Contains(item.Excerpt, "semantic signals") || !strings.Contains(item.Excerpt, "roadmap_timing") {
|
||||
t.Fatalf("semantic candidate excerpt should preserve semantic signals, got %#v", item)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing semantic candidate in findings %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectSemanticCandidatesDoNotLeakWhitespaceCredentialTail(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
raw := "private launch plan for internal rollout on Friday with SERVICE_" + "TOKEN=\"real " + "secret value\""
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add semantic candidate")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(item.Excerpt, "secret value") || strings.Contains(item.Excerpt, "real "+"secret value") {
|
||||
t.Fatalf("semantic candidate leaked credential tail: %#v", item)
|
||||
}
|
||||
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
|
||||
t.Fatalf("semantic candidate should redact full credential assignment, got %#v", item)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("missing semantic candidate in findings %#v", got)
|
||||
}
|
||||
|
||||
func TestCollectJSONBearerHeadersDoNotLeakIntoSemanticCandidates(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
token := "abcdefghijklmnopqrstuvwxyz"
|
||||
raw := "private launch plan for internal rollout on Friday with " +
|
||||
`{"headers":{"Authorization":"Bearer ` + token + `"}}`
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add json bearer")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/public.md", "public_content_bearer_header")
|
||||
for _, item := range got {
|
||||
if item.File != "docs/public.md" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(item.Excerpt, token) {
|
||||
t.Fatalf("finding leaked JSON bearer token: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"access_` + `token":"real-json-token"}`,
|
||||
`{"client_` + `secret": "real ` + `secret value"}`,
|
||||
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
|
||||
`{"github` + `Token":"real-github-token"}`,
|
||||
`{"vendorApi` + `Key":"real-vendor-key"}`,
|
||||
`{"slackBot` + `Token":"xoxb-real-token"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add json config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-json-token",
|
||||
"real secret value",
|
||||
"real-tenant-camel-token",
|
||||
"real-github-token",
|
||||
"real-vendor-key",
|
||||
"xoxb-real-token",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 6 {
|
||||
t.Fatalf("JSON credential findings = %d, want 6: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignJSONTokenFields(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"tokenizer":"cl100k_base"}`,
|
||||
`{"token_count": 42}`,
|
||||
`{"page_token":"next"}`,
|
||||
`{"next_page_token":"next"}`,
|
||||
`{"file_token":"file-example"}`,
|
||||
`{"doc_token":"doc-example"}`,
|
||||
`{"node_token":"node-example"}`,
|
||||
`{"wiki_token":"wikcn_public_doc_example"}`,
|
||||
`{"folder_token":"folder-example"}`,
|
||||
`{"obj_token":"obj-example"}`,
|
||||
`{"spreadsheet_token":"sheet-example"}`,
|
||||
`{"parent_node_token":"parent-example"}`,
|
||||
`{"origin_node_token":"origin-example"}`,
|
||||
`{"drive_route_token":"route-example"}`,
|
||||
`{"token":"<wiki_token>"}`,
|
||||
`{"token":"wiki_token"}`,
|
||||
`{"token_url":"https://example.com/oauth/token"}`,
|
||||
`{"token_endpoint":"https://example.com/oauth/token"}`,
|
||||
`{"token_format":"Bearer"}`,
|
||||
`{"secret_name":"public-example-secret"}`,
|
||||
`{"base_token":"base-example"}`,
|
||||
`{"app_token":"app-example"}`,
|
||||
`{"sync_token":"sync-example"}`,
|
||||
`{"parent_token":"parent-example"}`,
|
||||
`{"target_token":"target-example"}`,
|
||||
`{"parent_file_token":"parent-file-example"}`,
|
||||
`{"refresh_token_expires_in": 7200}`,
|
||||
`{"access_token_expires_in": 7200}`,
|
||||
`{"token_expires_in": 7200}`,
|
||||
`{"token_status":"active"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add benign json token fields")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("benign JSON token field should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY: <" + stripeLike + ">",
|
||||
"SECRET_TOKEN: <" + patLike + ">",
|
||||
"CLIENT_SECRET: <real-client-secret-value>",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"access_token_expires_in":"` + patLike + `"}`,
|
||||
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
|
||||
`{"client_secret_status":"real-client-secret-value"}`,
|
||||
`{"client_secret_name":"real-client-secret-value"}`,
|
||||
`{"app_token":"` + patLike + `"}`,
|
||||
`{"sync_token":"` + stripeLike + `"}`,
|
||||
`{"target_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add credential-shaped benign fields")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_NAME: prod_key",
|
||||
"CLIENT_SECRET_NAME: prod_secret",
|
||||
"SECRET_STATUS: prod_secret",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY: " + accessKey,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add access key config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
|
||||
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("access key credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t"
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"PRIVATE_KEY: " + privateKey,
|
||||
"SSH_PRIVATE_KEY: " + privateKey,
|
||||
"JWT_PRIVATE_KEY: " + privateKey,
|
||||
"SIGNING_PRIVATE_KEY: " + privateKey,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add private key config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, privateKey) {
|
||||
t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_OPENAI: prod_key",
|
||||
"CLIENT_SECRET_GOOGLE: prod_secret",
|
||||
"TOKEN_GITHUB: github_token",
|
||||
"APP_PASSWORD_PROD: prod_password",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"tokens: 128",
|
||||
"token_type: bearer",
|
||||
"max_tokens: 2000",
|
||||
"completion_tokens: 200",
|
||||
"prompt_tokens: 100",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add benign token config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("benign unquoted token field should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 5 {
|
||||
t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyWhenOnlyEndIsAdded(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n")
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\nnew-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "complete key")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyWhenOnlyBeginIsAdded(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), "legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "complete key")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyWhenOnlyBodyIsAdded(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"new-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "add body")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||
}
|
||||
|
||||
func TestCollectIgnoresUntouchedHistoricalPrivateKey(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "public docs update\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "docs update")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
|
||||
t.Fatalf("collector reported untouched historical private key: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "remove body")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
|
||||
t.Fatalf("collector reported delete-only private key cleanup: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "collect_test.go"), "SECRET_TOKEN=fixture\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add scanner fixtures")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
var foundOrdinaryTestLeak bool
|
||||
for _, item := range got {
|
||||
switch item.File {
|
||||
case "internal/qualitygate/publiccontent/collect_test.go",
|
||||
"internal/qualitygate/publiccontent/scan.go",
|
||||
"internal/qualitygate/publiccontent/scan_test.go",
|
||||
"internal/qualitygate/publiccontent/rules.go":
|
||||
t.Fatalf("collector scanned known fixture or detector implementation file: %#v", got)
|
||||
}
|
||||
if item.File == "tests/e2e/new-public-workflow.test.sh" && item.Rule == "public_content_generic_credential" {
|
||||
foundOrdinaryTestLeak = true
|
||||
}
|
||||
}
|
||||
if !foundOrdinaryTestLeak {
|
||||
t.Fatalf("collector should still scan ordinary test files for real leaks: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanChangedFileDocumentsFixtureExclusions(t *testing.T) {
|
||||
excluded := []string{
|
||||
"internal/qualitygate/publiccontent/collect_test.go",
|
||||
"internal/qualitygate/publiccontent/rules.go",
|
||||
"internal/qualitygate/publiccontent/scan.go",
|
||||
"internal/qualitygate/publiccontent/scan_test.go",
|
||||
}
|
||||
for _, file := range excluded {
|
||||
if scanChangedFile(file) {
|
||||
t.Fatalf("scanChangedFile(%q) = true, want false for detector fixture/implementation path", file)
|
||||
}
|
||||
}
|
||||
|
||||
included := []string{
|
||||
"internal/qualitygate/publiccontent/new_test.go",
|
||||
"tests/e2e/new-public-workflow.test.sh",
|
||||
"docs/public.md",
|
||||
}
|
||||
for _, file := range included {
|
||||
if !scanChangedFile(file) {
|
||||
t.Fatalf("scanChangedFile(%q) = false, want true", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "old.md"), "base\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
|
||||
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add special paths")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/has space.md", "public_content_generic_credential")
|
||||
requireFinding(t, got, `weird"quote.md`, "public_content_generic_credential")
|
||||
requireFinding(t, got, "docs/new name.md", "public_content_generic_credential")
|
||||
}
|
||||
|
||||
func TestCollectScansBranchNameAsWarning(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Rule != "public_content_automation_branch" {
|
||||
t.Fatalf("branch findings = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUsesExplicitBranchNameWhenDetached(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
runGit(t, repo, "checkout", "-b", "bot/public-doc-update")
|
||||
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
|
||||
runGit(t, repo, "add", "docs.md")
|
||||
runGit(t, repo, "commit", "-m", "docs")
|
||||
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
|
||||
runGit(t, repo, "checkout", "--detach", head)
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
MetadataPath: metadataPath,
|
||||
BranchName: "bot/public-doc-update",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
requireFinding(t, got, "branch", "public_content_automation_branch")
|
||||
}
|
||||
|
||||
func TestCollectUsesBranchEnvironmentWhenDetached(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
runGit(t, repo, "checkout", "-b", "bot/public-env-update")
|
||||
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
|
||||
runGit(t, repo, "add", "docs.md")
|
||||
runGit(t, repo, "commit", "-m", "docs")
|
||||
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
|
||||
runGit(t, repo, "checkout", "--detach", head)
|
||||
t.Setenv("GITHUB_HEAD_REF", "bot/public-env-update")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
requireFinding(t, got, "branch", "public_content_automation_branch")
|
||||
}
|
||||
|
||||
func TestCollectPreservesFindingAttributionForChangedLines(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\n")
|
||||
runGit(t, repo, "add", "docs/auth.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\nAuthorization: Bearer abcdefghijklmnopqrstuvwxyz\n")
|
||||
runGit(t, repo, "add", "docs/auth.md")
|
||||
runGit(t, repo, "commit", "-m", "add auth docs")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_bearer_header" {
|
||||
if item.File != "docs/auth.md" || item.Line != 2 || item.Source != "file" {
|
||||
t.Fatalf("changed-line attribution = %#v", item)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing bearer finding: %#v", got)
|
||||
}
|
||||
|
||||
func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
|
||||
base := []Finding{newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block")}
|
||||
got := appendUniqueFindings(base,
|
||||
newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block"),
|
||||
newFinding("public_content_private_key_block", "docs/key.pem", 2, "file", "private key block"),
|
||||
)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("appendUniqueFindings len = %d, want 2: %#v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
func newGitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
return repo
|
||||
}
|
||||
|
||||
func privateKeyBegin() string {
|
||||
return privateKeyBeginPrefix + privateKeyMarker + "\n"
|
||||
}
|
||||
|
||||
func privateKeyEnd() string {
|
||||
return privateKeyEndPrefix + privateKeyMarker + "\n"
|
||||
}
|
||||
|
||||
func collectFromPreviousCommit(t *testing.T, repo string) []Finding {
|
||||
t.Helper()
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func requireFinding(t *testing.T, got []Finding, file, rule string) {
|
||||
t.Helper()
|
||||
for _, item := range got {
|
||||
if item.File == file && item.Rule == rule {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing %s in %s findings: %#v", rule, file, got)
|
||||
}
|
||||
|
||||
func TestCollectRequiresValidMetadataJSON(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{"title":`)
|
||||
|
||||
_, err := Collect(context.Background(), Options{Repo: repo, MetadataPath: metadataPath})
|
||||
if err == nil || !strings.Contains(err.Error(), "public content metadata") {
|
||||
t.Fatalf("Collect() error = %v, want metadata parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
if len(args) > 0 && args[0] == "commit" {
|
||||
args = append([]string{"commit", "--no-verify"}, args[1:]...)
|
||||
}
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, data string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
11
internal/qualitygate/publiccontent/comment_audit.go
Normal file
11
internal/qualitygate/publiccontent/comment_audit.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
func ScanComment(kind, body string) []Finding {
|
||||
if kind == "" {
|
||||
kind = "comment"
|
||||
}
|
||||
return scanText(kind, "comment", body, false)
|
||||
}
|
||||
19
internal/qualitygate/publiccontent/comment_audit_test.go
Normal file
19
internal/qualitygate/publiccontent/comment_audit_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
|
||||
rules := findingRules(got)
|
||||
if !rules["public_content_harness_metadata"] || !rules["public_content_ccm_harness_trailer"] {
|
||||
t.Fatalf("comment audit findings = %#v", got)
|
||||
}
|
||||
for _, item := range got {
|
||||
if item.File != "issue_comment" {
|
||||
t.Fatalf("comment finding file = %q, want issue_comment", item.File)
|
||||
}
|
||||
}
|
||||
}
|
||||
45
internal/qualitygate/publiccontent/metadata.go
Normal file
45
internal/qualitygate/publiccontent/metadata.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func LoadMetadata(path string) (Metadata, error) {
|
||||
if path == "" {
|
||||
return Metadata{}, nil
|
||||
}
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return Metadata{}, nil
|
||||
}
|
||||
var out Metadata
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanMetadata(m Metadata) []Finding {
|
||||
text := ""
|
||||
if m.Title != "" {
|
||||
text += "title: " + m.Title + "\n"
|
||||
}
|
||||
if m.Body != "" {
|
||||
text += "body:\n" + m.Body + "\n"
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
out := scanText("pull_request_metadata", "metadata", text, false)
|
||||
out = append(out, semanticCandidate("pull_request_metadata", "metadata", text, 1)...)
|
||||
return out
|
||||
}
|
||||
22
internal/qualitygate/publiccontent/metadata_test.go
Normal file
22
internal/qualitygate/publiccontent/metadata_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadMetadataReadsTitleAndBody(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "metadata.json")
|
||||
writeFile(t, path, `{"title":"public change","body":"pass`+`word = \"example-password\""}`)
|
||||
|
||||
got, err := LoadMetadata(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMetadata() error = %v", err)
|
||||
}
|
||||
if got.Title != "public change" || got.Body == "" {
|
||||
t.Fatalf("metadata = %#v", got)
|
||||
}
|
||||
}
|
||||
441
internal/qualitygate/publiccontent/rules.go
Normal file
441
internal/qualitygate/publiccontent/rules.go
Normal file
@@ -0,0 +1,441 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
)
|
||||
|
||||
var (
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
|
||||
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
|
||||
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
|
||||
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
|
||||
semanticBearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+[^"'\s,}\]]+|["']Authorization["']\s*:\s*["']Bearer\s+[^"'\\\s,}\]]+)`)
|
||||
changeIDTrailerRE = regexp.MustCompile(`(?i)^\s*Change-Id:\s*\S+`)
|
||||
reviewedOnTrailerRE = regexp.MustCompile(`(?i)^\s*Reviewed-on:\s*\S+`)
|
||||
ccmHarnessTrailerRE = regexp.MustCompile(`(?i)\bCCM-Harness:\s*\S+`)
|
||||
privateIPv4RE = regexp.MustCompile(`\b(?:10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(?:1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3})\b`)
|
||||
automationBranchRE = regexp.MustCompile(`(?i)(^|/)(bot|automation)[-/]`)
|
||||
)
|
||||
|
||||
func actionForRule(rule string) report.Action {
|
||||
switch rule {
|
||||
case "public_content_generic_credential",
|
||||
"public_content_private_key_block",
|
||||
"public_content_jwt_like_token",
|
||||
"public_content_bearer_header",
|
||||
"public_content_credential_url",
|
||||
"public_content_change_id_trailer",
|
||||
"public_content_reviewed_on_trailer",
|
||||
"public_content_provenance_marker",
|
||||
"public_content_detector_fingerprint",
|
||||
"public_content_harness_metadata",
|
||||
"public_content_ccm_harness_trailer":
|
||||
return report.ActionReject
|
||||
case "public_content_private_ipv4",
|
||||
"public_content_automation_branch":
|
||||
return report.ActionWarning
|
||||
default:
|
||||
return report.ActionWarning
|
||||
}
|
||||
}
|
||||
|
||||
func isPlaceholderValue(value string) bool {
|
||||
trimmed := strings.Trim(value, `"'`)
|
||||
normalized := strings.ToLower(trimmed)
|
||||
if normalized == "" ||
|
||||
normalized == "=" ||
|
||||
percentWrappedPlaceholder(normalized) ||
|
||||
angleWrappedPlaceholder(normalized) ||
|
||||
urlWithAnglePlaceholder(normalized) ||
|
||||
isCredentialReferenceValue(trimmed) {
|
||||
return true
|
||||
}
|
||||
return namedPlaceholderValue(normalized)
|
||||
}
|
||||
|
||||
func namedPlaceholderValue(value string) bool {
|
||||
switch value {
|
||||
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
|
||||
return true
|
||||
}
|
||||
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
|
||||
}
|
||||
|
||||
func allXPlaceholder(value string) bool {
|
||||
if len(value) < 4 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r != 'x' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func urlWithAnglePlaceholder(value string) bool {
|
||||
if !strings.Contains(value, "://") ||
|
||||
!strings.Contains(value, "<") ||
|
||||
!strings.Contains(value, ">") {
|
||||
return false
|
||||
}
|
||||
return !urlRemainderLooksCredentialLike(removeAnglePlaceholders(value))
|
||||
}
|
||||
|
||||
func removeAnglePlaceholders(value string) string {
|
||||
var out strings.Builder
|
||||
for len(value) > 0 {
|
||||
start := strings.Index(value, "<")
|
||||
if start < 0 {
|
||||
out.WriteString(value)
|
||||
break
|
||||
}
|
||||
out.WriteString(value[:start])
|
||||
end := strings.Index(value[start+1:], ">")
|
||||
if end < 0 {
|
||||
out.WriteString(value[start:])
|
||||
break
|
||||
}
|
||||
value = value[start+end+2:]
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func urlRemainderLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
for _, marker := range []string{
|
||||
"secret",
|
||||
"token",
|
||||
"password",
|
||||
"passwd",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"private_key",
|
||||
"privatekey",
|
||||
"client_secret",
|
||||
"clientsecret",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, part := range strings.FieldsFunc(normalized, func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
|
||||
}) {
|
||||
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func longCredentialSegment(value string) bool {
|
||||
if len(value) < 16 {
|
||||
return false
|
||||
}
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
hasLetter = true
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
case r == '_' || r == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter || hasDigit
|
||||
}
|
||||
|
||||
func isCredentialReferenceValue(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
switch {
|
||||
case strings.HasPrefix(normalized, "${{"):
|
||||
return githubExpressionReference(normalized)
|
||||
case strings.HasPrefix(normalized, "$("):
|
||||
return !commandSubstitutionLooksCredentialLike(normalized)
|
||||
case strings.HasPrefix(normalized, "process.env."):
|
||||
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "process.env."))
|
||||
case strings.HasPrefix(normalized, "${"):
|
||||
return credentialReferenceIdentifier(strings.TrimSuffix(strings.TrimPrefix(normalized, "${"), "}"))
|
||||
case strings.HasPrefix(value, "$"):
|
||||
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "$"))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func commandSubstitutionLooksCredentialLike(value string) bool {
|
||||
if !strings.HasPrefix(value, "$(") || !strings.HasSuffix(value, ")") {
|
||||
return false
|
||||
}
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
|
||||
for _, part := range strings.FieldsFunc(inner, func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
|
||||
}) {
|
||||
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func githubExpressionReference(value string) bool {
|
||||
if !strings.HasPrefix(value, "${{") || !strings.HasSuffix(value, "}}") {
|
||||
return false
|
||||
}
|
||||
expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
switch {
|
||||
case strings.HasPrefix(expr, "secrets."):
|
||||
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "secrets."))
|
||||
case strings.HasPrefix(expr, "env."):
|
||||
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "env."))
|
||||
case strings.HasPrefix(expr, "vars."):
|
||||
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "vars."))
|
||||
case expr == "github.token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func dottedReferenceIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, part := range strings.Split(value, ".") {
|
||||
if !referenceIdentifier(part) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialReferenceIdentifier(value string) bool {
|
||||
return referenceIdentifier(value) && !credentialShapedIdentifier(value)
|
||||
}
|
||||
|
||||
func referenceIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9' && i > 0:
|
||||
case r == '_' && i > 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func angleWrappedPlaceholder(value string) bool {
|
||||
if len(value) < 3 || !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
|
||||
return false
|
||||
}
|
||||
return anglePlaceholderIdentifier(strings.Trim(value, "<>"))
|
||||
}
|
||||
|
||||
func percentWrappedPlaceholder(value string) bool {
|
||||
if len(value) < 3 || !strings.HasPrefix(value, "%") || !strings.HasSuffix(value, "%") {
|
||||
return false
|
||||
}
|
||||
inner := strings.Trim(value, "%")
|
||||
return delimitedPlaceholderIdentifier(inner) && !credentialShapedIdentifier(inner)
|
||||
}
|
||||
|
||||
func delimitedPlaceholderIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func anglePlaceholderIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
if credentialShapedIdentifier(value) {
|
||||
return false
|
||||
}
|
||||
switch value {
|
||||
case "token",
|
||||
"id",
|
||||
"userid",
|
||||
"openid",
|
||||
"key",
|
||||
"secret",
|
||||
"password",
|
||||
"api-key",
|
||||
"user-id",
|
||||
"open-id",
|
||||
"client-secret",
|
||||
"access-token",
|
||||
"refresh-token",
|
||||
"auth-token",
|
||||
"bearer-token",
|
||||
"session-token",
|
||||
"service-token":
|
||||
return true
|
||||
}
|
||||
for _, suffix := range []string{"_token", "_id", "_key", "_secret", "_password"} {
|
||||
if strings.HasSuffix(value, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{"-token", "-id", "-key", "-secret", "-password"} {
|
||||
if strings.HasSuffix(value, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func credentialShapedValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
return credentialShapedIdentifier(normalized)
|
||||
}
|
||||
|
||||
func credentialShapedIdentifier(value string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(value, "sk_live_"),
|
||||
strings.HasPrefix(value, "sk_test_"),
|
||||
strings.HasPrefix(value, "ghp_"),
|
||||
strings.HasPrefix(value, "gho_"),
|
||||
strings.HasPrefix(value, "ghu_"),
|
||||
strings.HasPrefix(value, "github_pat_"),
|
||||
strings.HasPrefix(value, "xoxb_"),
|
||||
strings.HasPrefix(value, "xoxp_"),
|
||||
strings.HasPrefix(value, "xoxa_"):
|
||||
return true
|
||||
case strings.HasPrefix(value, "real-") &&
|
||||
(strings.Contains(value, "secret") ||
|
||||
strings.Contains(value, "token") ||
|
||||
strings.Contains(value, "key") ||
|
||||
strings.Contains(value, "password")):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resourceTokenPlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
switch normalized {
|
||||
case "wiki_token",
|
||||
"folder_token",
|
||||
"obj_token",
|
||||
"spreadsheet_token",
|
||||
"file_token",
|
||||
"doc_token",
|
||||
"node_token",
|
||||
"parent_node_token",
|
||||
"origin_node_token",
|
||||
"drive_route_token":
|
||||
return true
|
||||
default:
|
||||
return minuteTokenFixturePlaceholder(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func minuteTokenFixturePlaceholder(value string) bool {
|
||||
if value == "minute_no_meta" {
|
||||
return true
|
||||
}
|
||||
suffix, ok := strings.CutPrefix(value, "minute_")
|
||||
if !ok || suffix == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range suffix {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func provenanceMarker(line string) bool {
|
||||
normalized := strings.ToLower(line)
|
||||
markers := []string{
|
||||
"generat" + "ed by tool",
|
||||
"creat" + "ed by tool",
|
||||
"generat" + "ed by automation",
|
||||
"creat" + "ed by automation",
|
||||
"machine-" + "generated",
|
||||
"generated with automated",
|
||||
"generated with automation",
|
||||
"🤖 generated",
|
||||
}
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(normalized, "co-authored-by:") &&
|
||||
(strings.Contains(normalized, "<bot@") ||
|
||||
strings.Contains(normalized, " bot@") ||
|
||||
strings.Contains(normalized, "[bot]") ||
|
||||
strings.Contains(normalized, "automation") ||
|
||||
strings.Contains(normalized, "automated-code-assistant")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Detector fingerprint checks are intentionally scoped to public rule/config
|
||||
// files. They do not try to hide this package's implementation; they prevent
|
||||
// publishing reusable detector identifiers in external-facing rule bundles.
|
||||
func isDetectorRuleFile(path string) bool {
|
||||
normalized := filepath.ToSlash(path)
|
||||
base := filepath.Base(normalized)
|
||||
return base == ".gitleaks.toml" ||
|
||||
strings.Contains(normalized, "public-rules/") ||
|
||||
strings.Contains(normalized, "public_rules/")
|
||||
}
|
||||
|
||||
func detectorFingerprint(line string) bool {
|
||||
normalized := strings.ToLower(line)
|
||||
fingerprints := []string{
|
||||
strings.Join([]string{"public", "content", "leakage"}, "-"),
|
||||
strings.Join([]string{"public", "content", "detector"}, "-"),
|
||||
"publiccontent",
|
||||
}
|
||||
for _, fingerprint := range fingerprints {
|
||||
if strings.Contains(normalized, fingerprint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redactCredentialURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.User == nil {
|
||||
return "<credential-url>"
|
||||
}
|
||||
u.User = url.UserPassword("<user>", "<redacted>")
|
||||
return u.String()
|
||||
}
|
||||
797
internal/qualitygate/publiccontent/scan.go
Normal file
797
internal/qualitygate/publiccontent/scan.go
Normal file
@@ -0,0 +1,797 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
privateKeyBeginPrefix = "-----" + "BEGIN "
|
||||
privateKeyEndPrefix = "-----" + "END "
|
||||
privateKeyMarker = "PRIVATE " + "KEY-----"
|
||||
)
|
||||
|
||||
func ScanFile(path string, data []byte) []Finding {
|
||||
return scanText(filepath.ToSlash(path), "file", string(data), isDetectorRuleFile(path))
|
||||
}
|
||||
|
||||
func semanticCandidate(file, source, text string, line int) []Finding {
|
||||
excerpt := redactedSemanticExcerpt(text)
|
||||
if excerpt == "" {
|
||||
return nil
|
||||
}
|
||||
return []Finding{newFinding("public_content_semantic_candidate", file, line, source, excerpt)}
|
||||
}
|
||||
|
||||
func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
var out []Finding
|
||||
lines := strings.Split(text, "\n")
|
||||
inPrivateKey := false
|
||||
privateKeyLine := 0
|
||||
for i, line := range lines {
|
||||
lineNo := i + 1
|
||||
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = true
|
||||
privateKeyLine = lineNo
|
||||
}
|
||||
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
|
||||
inPrivateKey = false
|
||||
}
|
||||
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
|
||||
if !isCredentialAssignmentMatch(match[0]) {
|
||||
continue
|
||||
}
|
||||
value := credentialAssignmentValue(match)
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
continue
|
||||
}
|
||||
if looksLikeEqualityComparison(value) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
||||
}
|
||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||
if isSchemaDottedIdentifier(line, match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
|
||||
}
|
||||
for range bearerHeaderRE.FindAllString(line, -1) {
|
||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||
}
|
||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||
if isPlaceholderCredentialURL(match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
||||
}
|
||||
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
||||
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
||||
}
|
||||
if source == "branch" && automationBranchRE.MatchString(line) {
|
||||
out = append(out, newFinding("public_content_automation_branch", file, lineNo, source, "automation branch marker"))
|
||||
}
|
||||
switch {
|
||||
case changeIDTrailerRE.MatchString(line):
|
||||
out = append(out, newFinding("public_content_change_id_trailer", file, lineNo, source, "Change-Id: <redacted>"))
|
||||
case reviewedOnTrailerRE.MatchString(line):
|
||||
out = append(out, newFinding("public_content_reviewed_on_trailer", file, lineNo, source, "Reviewed-on: <redacted>"))
|
||||
case ccmHarnessTrailerRE.MatchString(line):
|
||||
out = append(out, newFinding("public_content_ccm_harness_trailer", file, lineNo, source, "CCM-Harness: <redacted>"))
|
||||
}
|
||||
if provenanceMarker(line) {
|
||||
out = append(out, newFinding("public_content_provenance_marker", file, lineNo, source, "provenance marker"))
|
||||
}
|
||||
if strings.Contains(line, "/tmp/harness-agent") {
|
||||
out = append(out, newFinding("public_content_harness_metadata", file, lineNo, source, "/tmp/harness-agent"))
|
||||
}
|
||||
if detectorFile && detectorFingerprint(line) {
|
||||
out = append(out, newFinding("public_content_detector_fingerprint", file, lineNo, source, "public detector fingerprint"))
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
if out[i].Line != out[j].Line {
|
||||
return out[i].Line < out[j].Line
|
||||
}
|
||||
return out[i].Rule < out[j].Rule
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func isCredentialAssignmentMatch(match string) bool {
|
||||
name, value, ok := normalizedCredentialAssignment(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
|
||||
return true
|
||||
}
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignmentKey(match string) (string, bool) {
|
||||
key, _, ok := normalizedCredentialAssignment(match)
|
||||
return key, ok
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignment(match string) (string, string, bool) {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", "", false
|
||||
}
|
||||
submatches := credentialAssignmentRE.FindStringSubmatch(match)
|
||||
return normalizedCredentialKey(strings.Trim(key, `"'`)), credentialAssignmentValue(submatches), true
|
||||
}
|
||||
|
||||
func normalizedCredentialKey(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
var out []rune
|
||||
var prev rune
|
||||
for i, r := range key {
|
||||
if r == '-' {
|
||||
r = '_'
|
||||
}
|
||||
if i > 0 && isCredentialKeyBoundary(prev, r) {
|
||||
out = append(out, '_')
|
||||
}
|
||||
out = append(out, unicode.ToLower(r))
|
||||
prev = r
|
||||
}
|
||||
key = string(out)
|
||||
key = strings.ReplaceAll(key, "-", "_")
|
||||
return key
|
||||
}
|
||||
|
||||
func isCredentialKeyBoundary(prev, current rune) bool {
|
||||
if prev == '_' || current == '_' {
|
||||
return false
|
||||
}
|
||||
return (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(current)
|
||||
}
|
||||
|
||||
func isBenignTokenField(key string) bool {
|
||||
if isTokenMetricField(key) ||
|
||||
isTokenMetadataField(key) ||
|
||||
isResourceTokenField(key) ||
|
||||
isPaginationOrSyncTokenField(key) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTokenMetricField(key string) bool {
|
||||
switch key {
|
||||
case "tokenizer",
|
||||
"token_count",
|
||||
"tokens",
|
||||
"max_tokens",
|
||||
"completion_tokens",
|
||||
"prompt_tokens":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isTokenMetadataField(key string) bool {
|
||||
switch key {
|
||||
case "access_token_expires_in",
|
||||
"refresh_token_expires_in",
|
||||
"token_expires_in",
|
||||
"token_status",
|
||||
"token_type",
|
||||
"token_url",
|
||||
"token_endpoint",
|
||||
"token_format",
|
||||
"secret_name":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPaginationOrSyncTokenField(key string) bool {
|
||||
switch key {
|
||||
case "page_token",
|
||||
"next_page_token",
|
||||
"sync_token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isResourceTokenField(key string) bool {
|
||||
if !strings.HasSuffix(key, "_token") {
|
||||
return false
|
||||
}
|
||||
prefix := strings.TrimSuffix(key, "_token")
|
||||
switch prefix {
|
||||
case "app",
|
||||
"base",
|
||||
"board",
|
||||
"doc",
|
||||
"drive_route",
|
||||
"file",
|
||||
"folder",
|
||||
"host_node",
|
||||
"minute",
|
||||
"node",
|
||||
"obj",
|
||||
"origin_node",
|
||||
"parent",
|
||||
"parent_file",
|
||||
"parent_node",
|
||||
"share",
|
||||
"spreadsheet",
|
||||
"target",
|
||||
"wiki":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isResourceTokenPlaceholderAssignment(key, value string) bool {
|
||||
switch {
|
||||
case key == "client_token" && idempotencyTokenPlaceholderValue(value):
|
||||
return true
|
||||
case key == "retry_without_token" && numericStringPlaceholderValue(value):
|
||||
return true
|
||||
case tokenLikePlaceholderKey(key):
|
||||
return tokenLikePlaceholderValue(value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func tokenLikePlaceholderKey(key string) bool {
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func tokenLikePlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
return resourceTokenPlaceholderValue(value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
normalized == "token" ||
|
||||
strings.Contains(normalized, "...") ||
|
||||
strings.Contains(normalized, "xxx") ||
|
||||
strings.Contains(normalized, "_or_") ||
|
||||
strings.HasSuffix(normalized, "_token") ||
|
||||
strings.HasPrefix(normalized, ".")
|
||||
}
|
||||
|
||||
func idempotencyTokenPlaceholderValue(value string) bool {
|
||||
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
|
||||
}
|
||||
|
||||
func uuidStringPlaceholderValue(value string) bool {
|
||||
normalized := strings.Trim(value, `"'`)
|
||||
parts := strings.Split(normalized, "-")
|
||||
if len(parts) != 5 {
|
||||
return false
|
||||
}
|
||||
for i, part := range parts {
|
||||
want := []int{8, 4, 4, 4, 12}[i]
|
||||
if len(part) != want {
|
||||
return false
|
||||
}
|
||||
for _, r := range part {
|
||||
if (r >= '0' && r <= '9') ||
|
||||
(r >= 'a' && r <= 'f') ||
|
||||
(r >= 'A' && r <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func numericStringPlaceholderValue(value string) bool {
|
||||
normalized := strings.Trim(value, `"'`)
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range normalized {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".py":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func quotedLiteral(value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
return len(normalized) >= 2 &&
|
||||
((strings.HasPrefix(normalized, `"`) && strings.HasSuffix(normalized, `"`)) ||
|
||||
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
|
||||
}
|
||||
|
||||
func codeReferenceExpression(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{".", "(", ")", "[", "]", "{"} {
|
||||
if strings.Contains(value, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return codeIdentifier(value) && !credentialNameFragment(value)
|
||||
}
|
||||
|
||||
func codeIdentifier(value string) bool {
|
||||
for i, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r == '_' && i > 0:
|
||||
case r >= '0' && r <= '9' && i > 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialNameFragment(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSchemaDottedIdentifier(line, match string) bool {
|
||||
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
|
||||
}
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isWebhookCredentialKey(key string) bool {
|
||||
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
|
||||
}
|
||||
|
||||
func webhookAssignmentValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || isPlaceholderValue(normalized) || isNonSecretLiteralValue(normalized) {
|
||||
return false
|
||||
}
|
||||
return urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)) ||
|
||||
credentialShapedIdentifier(strings.Trim(normalized, "$"))
|
||||
}
|
||||
|
||||
func isExplicitCredentialKey(key string) bool {
|
||||
compact := strings.ReplaceAll(key, "_", "")
|
||||
switch compact {
|
||||
case "token",
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"authtoken",
|
||||
"bearertoken",
|
||||
"sessiontoken",
|
||||
"servicetoken",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"privatekey",
|
||||
"apisecret",
|
||||
"secret",
|
||||
"secretkey",
|
||||
"clientsecret",
|
||||
"password",
|
||||
"passwd":
|
||||
return true
|
||||
}
|
||||
for _, phrase := range []string{
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"authtoken",
|
||||
"bearertoken",
|
||||
"sessiontoken",
|
||||
"servicetoken",
|
||||
"bottoken",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"privatekey",
|
||||
"apisecret",
|
||||
"clientsecret",
|
||||
"secretkey",
|
||||
} {
|
||||
if strings.Contains(compact, phrase) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
parts := credentialKeyParts(key)
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "key"},
|
||||
{"access", "key"},
|
||||
{"private", "key"},
|
||||
{"api", "secret"},
|
||||
{"client", "secret"},
|
||||
{"secret", "key"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, part := range parts {
|
||||
switch part {
|
||||
case "token", "secret", "password", "passwd":
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{
|
||||
"token",
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"authtoken",
|
||||
"bearertoken",
|
||||
"sessiontoken",
|
||||
"servicetoken",
|
||||
"bottoken",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"privatekey",
|
||||
"apisecret",
|
||||
"clientsecret",
|
||||
"secret",
|
||||
"secretkey",
|
||||
"password",
|
||||
"passwd",
|
||||
} {
|
||||
if strings.HasSuffix(compact, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{
|
||||
"_access_token",
|
||||
"_refresh_token",
|
||||
"_auth_token",
|
||||
"_bearer_token",
|
||||
"_session_token",
|
||||
"_service_token",
|
||||
"_api_key",
|
||||
"_access_key",
|
||||
"_private_key",
|
||||
"_api_secret",
|
||||
"_client_secret",
|
||||
"_secret",
|
||||
"_secret_key",
|
||||
"_password",
|
||||
"_passwd",
|
||||
} {
|
||||
if strings.HasSuffix(key, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func credentialKeyParts(key string) []string {
|
||||
var parts []string
|
||||
for _, part := range strings.Split(key, "_") {
|
||||
if part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func hasAdjacentCredentialParts(parts []string, first, second string) bool {
|
||||
for i := 0; i+1 < len(parts); i++ {
|
||||
if parts[i] == first && parts[i+1] == second {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func credentialAssignmentValue(match []string) string {
|
||||
for _, value := range match[1:] {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeEqualityComparison(value string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||
}
|
||||
|
||||
func isPlaceholderCredentialURL(raw string) bool {
|
||||
userInfo, ok := credentialURLUserInfo(raw)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, password, ok := strings.Cut(userInfo, ":")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return credentialURLPasswordPlaceholder(password)
|
||||
}
|
||||
|
||||
func credentialURLPasswordPlaceholder(password string) bool {
|
||||
normalized := strings.ToLower(password)
|
||||
decoded := strings.ReplaceAll(normalized, "%3c", "<")
|
||||
decoded = strings.ReplaceAll(decoded, "%3e", ">")
|
||||
switch decoded {
|
||||
case "placeholder", "redacted", "<redacted>", "xxxx":
|
||||
return true
|
||||
}
|
||||
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
||||
}
|
||||
|
||||
func credentialURLUserInfo(raw string) (string, bool) {
|
||||
schemeIdx := strings.Index(raw, "://")
|
||||
if schemeIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := raw[schemeIdx+len("://"):]
|
||||
atIdx := strings.Index(rest, "@")
|
||||
if atIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return rest[:atIdx], true
|
||||
}
|
||||
|
||||
func newFinding(rule, file string, line int, source, excerpt string) Finding {
|
||||
return Finding{
|
||||
Rule: rule,
|
||||
Action: actionForRule(rule),
|
||||
File: file,
|
||||
Line: line,
|
||||
Source: source,
|
||||
Excerpt: excerpt,
|
||||
Message: messageForRule(rule),
|
||||
Suggestion: suggestionForRule(rule),
|
||||
}
|
||||
}
|
||||
|
||||
func messageForRule(rule string) string {
|
||||
switch rule {
|
||||
case "public_content_generic_credential":
|
||||
return "public contribution contains a generic credential assignment"
|
||||
case "public_content_private_key_block":
|
||||
return "public contribution contains a private key block"
|
||||
case "public_content_jwt_like_token":
|
||||
return "public contribution contains a JWT-like token"
|
||||
case "public_content_bearer_header":
|
||||
return "public contribution contains an Authorization bearer token"
|
||||
case "public_content_credential_url":
|
||||
return "public contribution contains credentials embedded in a URL"
|
||||
case "public_content_private_ipv4":
|
||||
return "public contribution contains a private-network IP address"
|
||||
case "public_content_automation_branch":
|
||||
return "public contribution uses an automation-shaped branch name"
|
||||
case "public_content_change_id_trailer":
|
||||
return "public contribution contains a Change-Id trailer"
|
||||
case "public_content_reviewed_on_trailer":
|
||||
return "public contribution contains a Reviewed-on trailer"
|
||||
case "public_content_provenance_marker":
|
||||
return "public contribution contains a prohibited provenance marker"
|
||||
case "public_content_detector_fingerprint":
|
||||
return "public rule/config content exposes public detector fingerprints"
|
||||
case "public_content_harness_metadata":
|
||||
return "public contribution contains visible harness pipeline metadata"
|
||||
case "public_content_ccm_harness_trailer":
|
||||
return "public contribution contains a CCM-Harness trailer"
|
||||
case "public_content_semantic_candidate":
|
||||
return "public contribution contains text for semantic public content review"
|
||||
default:
|
||||
return "public contribution contains content that should not be published"
|
||||
}
|
||||
}
|
||||
|
||||
func suggestionForRule(rule string) string {
|
||||
switch actionForRule(rule) {
|
||||
case "REJECT":
|
||||
return "remove the value from the public contribution and replace it with a non-sensitive placeholder"
|
||||
default:
|
||||
return "remove private workflow metadata before publishing the public contribution"
|
||||
}
|
||||
}
|
||||
|
||||
func redactAssignment(match string) string {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "<credential-assignment>"
|
||||
}
|
||||
return fmt.Sprintf("%s= <redacted>", strings.TrimSpace(key))
|
||||
}
|
||||
|
||||
func credentialAssignmentKey(match string) (string, bool) {
|
||||
idx := -1
|
||||
for _, sep := range []string{":", "="} {
|
||||
if candidate := strings.Index(match, sep); candidate >= 0 && (idx < 0 || candidate < idx) {
|
||||
idx = candidate
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return match[:idx], true
|
||||
}
|
||||
|
||||
func redactToken(_ string) string {
|
||||
return "<jwt-like-token>"
|
||||
}
|
||||
|
||||
func redactedSemanticExcerpt(text string) string {
|
||||
normalized := strings.Join(strings.Fields(text), " ")
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
signals := semanticSignals(normalized)
|
||||
if len(signals) == 0 {
|
||||
return ""
|
||||
}
|
||||
sanitized := truncateRunes(sanitizeSemanticExcerpt(text), 600)
|
||||
return fmt.Sprintf("semantic signals: %s; excerpt: %q", strings.Join(signals, ","), sanitized)
|
||||
}
|
||||
|
||||
func semanticSignals(normalized string) []string {
|
||||
lower := strings.ToLower(normalized)
|
||||
var signals []string
|
||||
add := func(signal string) {
|
||||
for _, existing := range signals {
|
||||
if existing == signal {
|
||||
return
|
||||
}
|
||||
}
|
||||
signals = append(signals, signal)
|
||||
}
|
||||
|
||||
hasPrivateScope := strings.Contains(lower, "private") || strings.Contains(lower, "internal-only")
|
||||
hasRequestMetadata := strings.Contains(lower, "request header") || strings.Contains(lower, "request headers") || strings.Contains(lower, "authorization header") || strings.Contains(lower, "metadata header")
|
||||
hasTrustBoundary := strings.Contains(lower, "spoof") || strings.Contains(lower, "trust") || strings.Contains(lower, "risk scoring") || strings.Contains(lower, "classification")
|
||||
hasRoadmap := strings.Contains(lower, "roadmap") || strings.Contains(lower, "migration") || strings.Contains(lower, "rollout") || strings.Contains(lower, "cutover") || strings.Contains(lower, "unpublished")
|
||||
hasTiming := strings.Contains(lower, "target date") || strings.Contains(lower, "friday") || strings.Contains(lower, "monday") || strings.Contains(lower, "tuesday") || strings.Contains(lower, "wednesday") || strings.Contains(lower, "thursday") || strings.Contains(lower, "customer-visible")
|
||||
hasImplementation := strings.Contains(lower, "server-side") || strings.Contains(lower, "implementation")
|
||||
|
||||
if hasPrivateScope && hasRequestMetadata && hasTrustBoundary {
|
||||
add("private_scope")
|
||||
add("request_metadata")
|
||||
add("trust_boundary_detail")
|
||||
}
|
||||
if hasRoadmap && (hasPrivateScope || hasTiming) {
|
||||
add("roadmap_detail")
|
||||
if hasPrivateScope {
|
||||
add("private_scope")
|
||||
}
|
||||
if hasTiming {
|
||||
add("roadmap_timing")
|
||||
}
|
||||
}
|
||||
if hasPrivateScope && hasImplementation && hasTrustBoundary {
|
||||
add("private_scope")
|
||||
add("implementation_detail")
|
||||
add("trust_boundary_detail")
|
||||
}
|
||||
|
||||
return signals
|
||||
}
|
||||
|
||||
func sanitizeSemanticExcerpt(text string) string {
|
||||
text = redactPrivateKeyBlocks(text)
|
||||
text = credentialAssignmentRE.ReplaceAllStringFunc(text, sanitizeCredentialAssignment)
|
||||
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
|
||||
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
|
||||
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
|
||||
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
|
||||
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
func redactPrivateKeyBlocks(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
var out []string
|
||||
inPrivateKey := false
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
out = append(out, "<private-key-block>")
|
||||
inPrivateKey = true
|
||||
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inPrivateKey {
|
||||
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func sanitizeCredentialAssignment(match string) string {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "<credential-assignment>"
|
||||
}
|
||||
return strings.TrimSpace(key) + "=<redacted>"
|
||||
}
|
||||
|
||||
func sanitizeCredentialURL(raw string) string {
|
||||
redacted := redactCredentialURL(raw)
|
||||
redacted = strings.ReplaceAll(redacted, "%3Cuser%3E", "<user>")
|
||||
redacted = strings.ReplaceAll(redacted, "%3Credacted%3E", "<redacted>")
|
||||
return redacted
|
||||
}
|
||||
|
||||
func truncateRunes(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= limit {
|
||||
return text
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
1056
internal/qualitygate/publiccontent/scan_test.go
Normal file
1056
internal/qualitygate/publiccontent/scan_test.go
Normal file
File diff suppressed because it is too large
Load Diff
30
internal/qualitygate/publiccontent/types.go
Normal file
30
internal/qualitygate/publiccontent/types.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import "github.com/larksuite/cli/internal/qualitygate/report"
|
||||
|
||||
type Options struct {
|
||||
Repo string
|
||||
ChangedFrom string
|
||||
MetadataPath string
|
||||
BranchName string
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
|
||||
type Finding struct {
|
||||
Rule string
|
||||
Action report.Action
|
||||
File string
|
||||
Line int
|
||||
Source string
|
||||
Excerpt string
|
||||
Message string
|
||||
Suggestion string
|
||||
}
|
||||
@@ -174,8 +174,9 @@ type materializedExample struct {
|
||||
}
|
||||
|
||||
type placeholderContext struct {
|
||||
FlagName string
|
||||
FlagUsage string
|
||||
FlagName string
|
||||
FlagUsage string
|
||||
FlagDefault string
|
||||
}
|
||||
|
||||
func materializePlaceholderExample(raw string, cmd manifest.Command) (materializedExample, bool) {
|
||||
@@ -247,6 +248,7 @@ func placeholderContextForFlag(name string, flag *manifest.Flag) placeholderCont
|
||||
ctx := placeholderContext{FlagName: name}
|
||||
if flag != nil {
|
||||
ctx.FlagUsage = flag.Usage
|
||||
ctx.FlagDefault = flag.DefValue
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -309,11 +311,17 @@ func fakeValueForPlaceholder(raw string, ctx placeholderContext) (string, bool)
|
||||
if name == "" {
|
||||
return "", false
|
||||
}
|
||||
if value, ok := fakeNumericValueForPlaceholder(name, ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
if value, ok := fakeContextualURLValueForPlaceholder(name, ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
if value, ok := fakeValueFromPlaceholderName(name); ok {
|
||||
return value, true
|
||||
}
|
||||
if isGenericPlaceholderName(name) {
|
||||
return fakeValueFromUsageHint(ctx.FlagUsage)
|
||||
return fakeValueFromContextHint(ctx)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -336,16 +344,26 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
|
||||
return "file_test123", true
|
||||
case hasPlaceholderToken(tokens, "file") && hasPlaceholderToken(tokens, "token"):
|
||||
return "file_test123", true
|
||||
case hasPlaceholderToken(tokens, "folder") && hasPlaceholderToken(tokens, "token"):
|
||||
return "fld_test123", true
|
||||
case hasPlaceholderToken(tokens, "image", "img"):
|
||||
return "img_test123", true
|
||||
case hasPlaceholderToken(tokens, "app"):
|
||||
return "app_test123", true
|
||||
case hasPlaceholderToken(tokens, "draft"):
|
||||
return "draft_test123", true
|
||||
case hasPlaceholderToken(tokens, "label"):
|
||||
return "label_test123", true
|
||||
case hasPlaceholderToken(tokens, "share"):
|
||||
return "share_test123", true
|
||||
case hasPlaceholderToken(tokens, "doc", "document"):
|
||||
return "doc_test123", true
|
||||
case hasPlaceholderToken(tokens, "sheet", "spreadsheet"):
|
||||
return "shtcn_test123", true
|
||||
case hasPlaceholderToken(tokens, "base"):
|
||||
return "base_test123", true
|
||||
case hasPlaceholderToken(tokens, "space"):
|
||||
return "space_test123", true
|
||||
case hasPlaceholderToken(tokens, "table"):
|
||||
return "tbl_test123", true
|
||||
case hasPlaceholderToken(tokens, "view"):
|
||||
@@ -377,17 +395,98 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func fakeValueFromUsageHint(usage string) (string, bool) {
|
||||
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(usage))
|
||||
func fakeValueFromContextHint(ctx placeholderContext) (string, bool) {
|
||||
if value, ok := fakeNumericValueForPlaceholder("", ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
if value, ok := fakeContextualURLValueForPlaceholder("", ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(ctx.FlagUsage))
|
||||
if len(match) != 2 || !knownTokenPrefix(match[1]) {
|
||||
return "", false
|
||||
}
|
||||
return match[1] + "_test123", true
|
||||
}
|
||||
|
||||
func fakeContextualURLValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
|
||||
nameTokens := placeholderTokenSet(name)
|
||||
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
|
||||
flagTokens := placeholderTokenSet(flagName)
|
||||
if !hasPlaceholderToken(nameTokens, "url", "link") && !hasPlaceholderToken(flagTokens, "url", "link") {
|
||||
return "", false
|
||||
}
|
||||
usage := strings.ToLower(ctx.FlagUsage)
|
||||
if strings.Contains(usage, "lark") || strings.Contains(usage, "feishu") || strings.Contains(usage, "document url") {
|
||||
return "https://example.feishu.cn/docx/doc_test123", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func fakeNumericValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
|
||||
nameTokens := placeholderTokenSet(name)
|
||||
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
|
||||
flagTokens := placeholderTokenSet(flagName)
|
||||
usage := strings.ToLower(ctx.FlagUsage)
|
||||
|
||||
switch {
|
||||
case placeholderTokenPair(nameTokens, "meeting", "id") || placeholderTokenPair(flagTokens, "meeting", "id"):
|
||||
return "400000000001", true
|
||||
case placeholderTokenPair(nameTokens, "meeting", "ids") || placeholderTokenPair(flagTokens, "meeting", "ids"):
|
||||
return "400000000001", true
|
||||
case placeholderTokenPair(nameTokens, "meeting", "no") || placeholderTokenPair(flagTokens, "meeting", "no"):
|
||||
return "123456789", true
|
||||
case placeholderTokenPair(nameTokens, "meeting", "number") || placeholderTokenPair(flagTokens, "meeting", "number"):
|
||||
return "123456789", true
|
||||
case hasPlaceholderToken(nameTokens, "timestamp") || hasPlaceholderToken(flagTokens, "timestamp") || strings.Contains(usage, "unix timestamp"):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "1893456000"), true
|
||||
case placeholderTokenPair(nameTokens, "page", "size") || placeholderTokenPair(flagTokens, "page", "size"):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
|
||||
case placeholderTokenPair(nameTokens, "page", "limit") || placeholderTokenPair(flagTokens, "page", "limit"):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "10"), true
|
||||
case numericPlaceholderName(nameTokens) || numericPlaceholderName(flagTokens) || numericUsageHint(usage):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func numericPlaceholderName(tokens map[string]bool) bool {
|
||||
if len(tokens) == 0 || hasPlaceholderToken(tokens, "token", "format", "type", "status", "mode") {
|
||||
return false
|
||||
}
|
||||
return hasPlaceholderToken(tokens,
|
||||
"amount", "count", "depth", "height", "index", "length", "limit", "max",
|
||||
"number", "revision", "size", "width",
|
||||
)
|
||||
}
|
||||
|
||||
func numericUsageHint(usage string) bool {
|
||||
if usage == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(usage, "positive integer") ||
|
||||
strings.Contains(usage, "decimal integer") ||
|
||||
strings.Contains(usage, "number of ") ||
|
||||
strings.Contains(usage, "(number)")
|
||||
}
|
||||
|
||||
func defaultPositiveInteger(raw, fallback string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(raw, "-") || raw == "0" {
|
||||
return fallback
|
||||
}
|
||||
for _, r := range raw {
|
||||
if r < '0' || r > '9' {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func knownTokenPrefix(prefix string) bool {
|
||||
switch prefix {
|
||||
case "app", "base", "doc", "file", "fld", "img", "item", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "shtcn", "task", "tbl", "token", "viw", "wiki":
|
||||
case "app", "base", "doc", "draft", "file", "fld", "img", "item", "label", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "share", "shtcn", "space", "task", "tbl", "token", "viw", "wiki":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -431,6 +530,10 @@ func hasPlaceholderToken(tokens map[string]bool, wants ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func placeholderTokenPair(tokens map[string]bool, first, second string) bool {
|
||||
return tokens[first] && tokens[second]
|
||||
}
|
||||
|
||||
func hasUnresolvedDryRunPlaceholder(value string) bool {
|
||||
if skillscan.HasPlaceholder(value) {
|
||||
return true
|
||||
@@ -623,6 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
|
||||
return nil, fmt.Errorf("not a lark-cli command")
|
||||
}
|
||||
argv = truncateShellTail(argv)
|
||||
argv = forceDryRunJSONFormat(argv)
|
||||
hasDryRunArg := false
|
||||
dryRunEnabled := false
|
||||
for _, arg := range argv[1:] {
|
||||
@@ -642,6 +746,23 @@ func appendDryRunArg(raw string) ([]string, error) {
|
||||
return append(argv[1:], "--dry-run"), nil
|
||||
}
|
||||
|
||||
func forceDryRunJSONFormat(argv []string) []string {
|
||||
for i := 1; i < len(argv); i++ {
|
||||
arg := argv[i]
|
||||
if arg == "--format" {
|
||||
if i+1 < len(argv) && argv[i+1] == "pretty" {
|
||||
argv[i+1] = "json"
|
||||
}
|
||||
return argv
|
||||
}
|
||||
if arg == "--format=pretty" {
|
||||
argv[i] = "--format=json"
|
||||
return argv
|
||||
}
|
||||
}
|
||||
return argv
|
||||
}
|
||||
|
||||
func truncateShellTail(argv []string) []string {
|
||||
for i, arg := range argv {
|
||||
if i == 0 {
|
||||
|
||||
@@ -305,6 +305,161 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "vc +meeting-events",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "meeting-id", TakesValue: true, Usage: "meeting ID to query; must be a long positive integer, not a 9-digit meeting number"},
|
||||
{Name: "page-size", TakesValue: true, Usage: "page size, 20-100 (default 50)", DefValue: "50"},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: "lark-cli vc +meeting-events --meeting-id <meeting_id> --page-size <page_size>",
|
||||
SourceFile: "skills/lark-vc-agent/SKILL.md",
|
||||
Line: 120,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("numeric placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--page-size", "50", "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesNumericPlaceholdersInsideJSONFlags(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/test","params":{"timestamp":"1893456000","count":"20"}}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "api GET",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "params", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: `lark-cli api GET /open-apis/test --params '{"timestamp":"<timestamp>","count":"<count>"}'`,
|
||||
SourceFile: "skills/lark-demo/SKILL.md",
|
||||
Line: 20,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("JSON numeric placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"api", "GET", "/open-apis/test", "--params", `{"timestamp":"1893456000","count":"20"}`, "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesLarkDocumentURLPlaceholders(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/drive/v1/metas/batch_query"}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "drive +inspect",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "url", TakesValue: true, Usage: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)"},
|
||||
{Name: "format", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: "lark-cli drive +inspect --url '<url>' --format json",
|
||||
SourceFile: "skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md",
|
||||
Line: 15,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("Lark URL placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"drive", "+inspect", "--url", "https://example.feishu.cn/docx/doc_test123", "--format", "json", "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesResourceIDPlaceholderFlagValues(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/wiki/v2/spaces/space_test123/nodes"}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "wiki +node-list",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "space-id", TakesValue: true, Usage: "wiki space ID"},
|
||||
{Name: "page-token", TakesValue: true, Usage: "page token"},
|
||||
{Name: "format", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: "lark-cli wiki +node-list --space-id <space_id> --page-token <PAGE_TOKEN> --format json",
|
||||
SourceFile: "skills/lark-wiki/references/lark-wiki-node-list.md",
|
||||
Line: 24,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("resource ID placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"wiki", "+node-list", "--space-id", "space_test123", "--page-token", "page_test123", "--format", "json", "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesResourcePlaceholdersInsideJSONFlags(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"POST","url":"/open-apis/mail/v1/user_mailboxes/me/drafts/draft_test123/send"}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "mail user_mailbox.drafts send",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "params", TakesValue: true},
|
||||
{Name: "data", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: `lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'`,
|
||||
SourceFile: "skills/lark-mail/references/lark-mail-send.md",
|
||||
Line: 172,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("JSON resource placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"mail", "user_mailbox.drafts", "send", "--params", `{"user_mailbox_id":"me","draft_id":"draft_test123"}`, "--data", `{"send_time":"1893456000"}`, "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsSkipsUnknownFlagsBeforeDryRun(t *testing.T) {
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "im +chat-messages-list",
|
||||
@@ -600,6 +755,51 @@ func TestAppendDryRunArgDoesNotDuplicate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgForcesJSONFormat(t *testing.T) {
|
||||
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format pretty")
|
||||
if err != nil {
|
||||
t.Fatalf("appendDryRunArg() error = %v", err)
|
||||
}
|
||||
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format", "json", "--dry-run"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
|
||||
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format=pretty --dry-run")
|
||||
if err != nil {
|
||||
t.Fatalf("appendDryRunArg() error = %v", err)
|
||||
}
|
||||
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format=json", "--dry-run"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"lark-cli mail +watch --format data --dry-run",
|
||||
"lark-cli export +events --format=ndjson --dry-run",
|
||||
"lark-cli docs +fetch --format table",
|
||||
} {
|
||||
got, err := appendDryRunArg(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("appendDryRunArg(%q) error = %v", raw, err)
|
||||
}
|
||||
for _, arg := range got {
|
||||
if arg == "--format=json" {
|
||||
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote inline format: %#v", raw, got)
|
||||
}
|
||||
}
|
||||
for i, arg := range got {
|
||||
if arg == "--format" && i+1 < len(got) && got[i+1] == "json" {
|
||||
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote split format: %#v", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgForcesDryRunWhenExplicitlyDisabled(t *testing.T) {
|
||||
got, err := appendDryRunArg("lark-cli docs +fetch --dry-run=false --doc abc")
|
||||
if err != nil {
|
||||
|
||||
@@ -15,18 +15,20 @@ import (
|
||||
manifestexamples "github.com/larksuite/cli/internal/qualitygate/examples"
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/qualitygate/skillscan"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Repo string
|
||||
CLIBin string
|
||||
ChangedFrom string
|
||||
FactsOut string
|
||||
ManifestPath string
|
||||
CommandIndexPath string
|
||||
Repo string
|
||||
CLIBin string
|
||||
ChangedFrom string
|
||||
FactsOut string
|
||||
ManifestPath string
|
||||
CommandIndexPath string
|
||||
PublicContentMetadataPath string
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, error) {
|
||||
@@ -98,9 +100,60 @@ func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, e
|
||||
if opts.ChangedFrom != "" {
|
||||
diags = append(diags, errorDiags...)
|
||||
}
|
||||
publicContent, err := publiccontent.Collect(ctx, publiccontent.Options{
|
||||
Repo: opts.Repo,
|
||||
ChangedFrom: opts.ChangedFrom,
|
||||
MetadataPath: opts.PublicContentMetadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, facts.Facts{}, err
|
||||
}
|
||||
diags = append(diags, publicContentDiagnostics(publicContent)...)
|
||||
diags = filterPRDiagnostics(opts.Repo, opts.ChangedFrom, scope, m, diags)
|
||||
|
||||
return diags, facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files), nil
|
||||
builtFacts := facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files)
|
||||
return diags, facts.WithPublicContent(builtFacts, publicContentFacts(publicContent)), nil
|
||||
}
|
||||
|
||||
func publicContentDiagnostics(items []publiccontent.Finding) []report.Diagnostic {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]report.Diagnostic, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Rule == "public_content_semantic_candidate" {
|
||||
continue
|
||||
}
|
||||
out = append(out, report.Diagnostic{
|
||||
Rule: item.Rule,
|
||||
Action: item.Action,
|
||||
File: item.File,
|
||||
Line: item.Line,
|
||||
Message: item.Message,
|
||||
Suggestion: item.Suggestion,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func publicContentFacts(items []publiccontent.Finding) []facts.PublicContentFact {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]facts.PublicContentFact, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, facts.PublicContentFact{
|
||||
Rule: item.Rule,
|
||||
Action: item.Action,
|
||||
File: item.File,
|
||||
Line: item.Line,
|
||||
Source: item.Source,
|
||||
Excerpt: item.Excerpt,
|
||||
Message: item.Message,
|
||||
Suggestion: item.Suggestion,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readManifestInput(path, kind, flag string) (manifest.Manifest, error) {
|
||||
@@ -167,6 +220,9 @@ func filterPRDiagnostics(repo, changedFrom string, scope qdiff.Scope, m manifest
|
||||
}
|
||||
|
||||
func prDiagnosticRelevant(repo string, changedFiles map[string]bool, commandScope diagnosticCommandScope, m manifest.Manifest, diag report.Diagnostic) bool {
|
||||
if strings.HasPrefix(diag.Rule, "public_content_") {
|
||||
return true
|
||||
}
|
||||
file := normalizeDiagnosticFile(repo, diag.File)
|
||||
if file != "" && changedFiles[file] {
|
||||
return true
|
||||
|
||||
@@ -189,6 +189,99 @@ description: Manage Drive comments with service command references.
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
if err := vfs.WriteFile(filepath.Join(repo, "README.md"), []byte("# test\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicDoc := "api_" + "key = \"example-public-key\"\n" +
|
||||
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
|
||||
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
if err := vfs.WriteFile(metadataPath, []byte(`{"title":"public docs","body":"Change`+`-Id: I0123456789abcdef0123456789abcdef01234567"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(repo, "command-manifest.json")
|
||||
indexPath := filepath.Join(repo, "command-index.json")
|
||||
m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{
|
||||
Path: "docs +fetch",
|
||||
CanonicalPath: "docs +fetch",
|
||||
Domain: "docs",
|
||||
Source: manifest.SourceShortcut,
|
||||
}}}
|
||||
if err := manifest.WriteFile(manifestPath, manifest.KindCommandManifest, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := manifest.Manifest{SchemaVersion: 1, Commands: append([]manifest.Command{}, m.Commands...)}
|
||||
idx.Commands = append(idx.Commands, manifest.Command{
|
||||
Path: "drive files get",
|
||||
CanonicalPath: "drive files get",
|
||||
Domain: "drive",
|
||||
Source: manifest.SourceService,
|
||||
Generated: true,
|
||||
Runnable: true,
|
||||
})
|
||||
if err := manifest.WriteFile(indexPath, manifest.KindCommandIndex, idx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
diags, gotFacts, err := Run(context.Background(), Options{
|
||||
Repo: repo,
|
||||
CLIBin: "./lark-cli",
|
||||
ChangedFrom: "HEAD~1",
|
||||
ManifestPath: manifestPath,
|
||||
CommandIndexPath: indexPath,
|
||||
PublicContentMetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
actions := map[string]report.Action{}
|
||||
for _, diag := range diags {
|
||||
actions[diag.Rule] = diag.Action
|
||||
}
|
||||
if actions["public_content_generic_credential"] != report.ActionReject {
|
||||
t.Fatalf("generic credential diagnostic action = %q, diagnostics=%#v", actions["public_content_generic_credential"], diags)
|
||||
}
|
||||
if actions["public_content_change_id_trailer"] != report.ActionReject {
|
||||
t.Fatalf("change-id diagnostic action = %q, diagnostics=%#v", actions["public_content_change_id_trailer"], diags)
|
||||
}
|
||||
if actions["public_content_semantic_candidate"] != "" {
|
||||
t.Fatalf("semantic candidates should not become deterministic diagnostics: %#v", diags)
|
||||
}
|
||||
factRules := map[string]bool{}
|
||||
for _, item := range gotFacts.PublicContent {
|
||||
factRules[item.Rule] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"public_content_generic_credential",
|
||||
"public_content_change_id_trailer",
|
||||
"public_content_semantic_candidate",
|
||||
} {
|
||||
if !factRules[want] {
|
||||
t.Fatalf("missing public content fact %s: %#v", want, gotFacts.PublicContent)
|
||||
}
|
||||
}
|
||||
if len(gotFacts.PublicContent) < 3 {
|
||||
t.Fatalf("public content facts = %#v", gotFacts.PublicContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
@@ -506,7 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -339,7 +339,7 @@ func jsonSchemaResponseFormat() map[string]any {
|
||||
"properties": map[string]any{
|
||||
"category": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
"enum": []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
|
||||
},
|
||||
"severity": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
)
|
||||
|
||||
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs)\[(\d+)\]$`)
|
||||
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$`)
|
||||
|
||||
func Decide(f facts.Facts, r Review, p Policy) Decision {
|
||||
return DecideWithWaivers(f, r, p, Waivers{})
|
||||
@@ -172,6 +173,16 @@ func evidenceFingerprint(f facts.Facts, ev string) string {
|
||||
"has_default_limit:" + strconv.FormatBool(out.HasDefaultLimit),
|
||||
"has_decision_field:" + strconv.FormatBool(out.HasDecisionField),
|
||||
}, ":")
|
||||
case "public_content":
|
||||
item := f.PublicContent[idx]
|
||||
return strings.Join([]string{
|
||||
"public_content",
|
||||
"rule:" + item.Rule,
|
||||
"action:" + string(item.Action),
|
||||
"file:" + item.File,
|
||||
"line:" + strconv.Itoa(item.Line),
|
||||
"source:" + item.Source,
|
||||
}, ":")
|
||||
default:
|
||||
return "ref:" + ev
|
||||
}
|
||||
@@ -201,7 +212,7 @@ func validFinding(f Finding) bool {
|
||||
|
||||
func allowedCategory(category string) bool {
|
||||
switch category {
|
||||
case "error_hint", "default_output", "naming", "skill_quality":
|
||||
case "error_hint", "default_output", "naming", "skill_quality", "public_content_leakage":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -247,6 +258,12 @@ func reproducibleEvidence(f facts.Facts, category, kind string, idx int) bool {
|
||||
}
|
||||
skill := f.Skills[idx]
|
||||
return skill.ReferencesInvalidCommand
|
||||
case "public_content_leakage":
|
||||
if kind != "public_content" {
|
||||
return false
|
||||
}
|
||||
item := f.PublicContent[idx]
|
||||
return item.Action == report.ActionReject || item.Rule == "public_content_semantic_candidate"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -277,6 +294,8 @@ func evidenceExists(f facts.Facts, kind string, idx int) bool {
|
||||
return idx < len(f.Errors)
|
||||
case "outputs":
|
||||
return idx < len(f.Outputs)
|
||||
case "public_content":
|
||||
return idx < len(f.PublicContent)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
|
||||
Outputs: []facts.OutputFact{{Command: "im messages list", IsList: true, HasDefaultLimit: false, HasDecisionField: false}},
|
||||
Commands: []facts.CommandFact{{Path: "docs fetch", NameConflictsExisting: true}},
|
||||
Skills: []facts.SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 3, ReferencesInvalidCommand: true}},
|
||||
PublicContent: []facts.PublicContentFact{{Rule: "public_content_generic_credential", Action: "REJECT", File: "docs/public.md", Line: 4, Source: "metadata"}},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
category string
|
||||
@@ -251,6 +252,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
|
||||
{"default_output", "facts.outputs[0]"},
|
||||
{"naming", "facts.commands[0]"},
|
||||
{"skill_quality", "facts.skills[0]"},
|
||||
{"public_content_leakage", "facts.public_content[0]"},
|
||||
} {
|
||||
t.Run(tc.category, func(t *testing.T) {
|
||||
r := Review{Findings: []Finding{{
|
||||
@@ -268,6 +270,59 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperDoesNotPromotePublicContentWarningsToBlockers(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_" + "pri" + "vate_ipv4",
|
||||
Action: "WARNING",
|
||||
File: "docs/network.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
}},
|
||||
}
|
||||
review := Review{Findings: []Finding{{
|
||||
Category: "public_content_leakage",
|
||||
Severity: "minor",
|
||||
Evidence: []string{"facts.public_content[0]"},
|
||||
Message: "pri" + "vate network address appears in public docs",
|
||||
SuggestedAction: "confirm the public docs do not expose pri" + "vate deployment details",
|
||||
}}}
|
||||
|
||||
got := Decide(f, review, DefaultPolicy())
|
||||
if len(got.Blockers) != 0 || len(got.Warnings) != 1 {
|
||||
t.Fatalf("public content warning should not become a blocker: %#v", got)
|
||||
}
|
||||
if got.Warnings[0].ReviewAction != ReviewActionObserve {
|
||||
t.Fatalf("review action = %q, want %q", got.Warnings[0].ReviewAction, ReviewActionObserve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperAllowsPublicContentSemanticCandidatesAsBlockers(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_semantic_candidate",
|
||||
Action: "WARNING",
|
||||
File: "docs/public.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
}},
|
||||
}
|
||||
review := Review{Findings: []Finding{{
|
||||
Category: "public_content_leakage",
|
||||
Severity: "major",
|
||||
Evidence: []string{"facts.public_content[0]"},
|
||||
Message: "semantic review found pri" + "vate rollout detail",
|
||||
SuggestedAction: "remove pri" + "vate rollout detail from public docs",
|
||||
}}}
|
||||
|
||||
got := Decide(f, review, DefaultPolicy())
|
||||
if len(got.Blockers) != 1 {
|
||||
t.Fatalf("semantic candidate should remain blockable, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperSkillQualityOnlyBlocksInvalidCommandReferences(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
|
||||
@@ -24,7 +24,7 @@ func BuildPrompt(f facts.Facts) []Message {
|
||||
"Use only the provided JSON view.",
|
||||
"The changed_summary may summarize broad changed surfaces; review only listed facts, not omitted summarized items.",
|
||||
"Use fact_ref values exactly when writing finding evidence.",
|
||||
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
|
||||
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
|
||||
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
|
||||
"facts.examples and facts.skill_quality entries are context only.",
|
||||
"Report an error_hint finding for any facts.errors item where boundary is true, required_hint is true, and hint_action_count is 0.",
|
||||
@@ -38,6 +38,9 @@ func BuildPrompt(f facts.Facts) []Message {
|
||||
"For naming findings, use category \"naming\" and evidence containing that facts.commands fact_ref.",
|
||||
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
|
||||
"For skill_quality findings, use category \"skill_quality\" and evidence containing that facts.skills fact_ref.",
|
||||
"Review public content leakage findings and semantic candidates without private dictionaries.",
|
||||
"Do not reveal internal rule lists when explaining public content leakage.",
|
||||
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
|
||||
"Report each distinct issue as a separate finding.",
|
||||
"The verdict value must be \"pass\" when findings is empty and \"warn\" when findings is non-empty; never use \"fail\".",
|
||||
"Severity must be one of \"minor\", \"major\", or \"critical\"; never use \"error\", \"warning\", \"medium\", or \"high\".",
|
||||
|
||||
@@ -23,7 +23,10 @@ func TestBuildPromptContainsSemanticReviewContract(t *testing.T) {
|
||||
"A facts.outputs item with is_list true, has_default_limit false, and has_decision_field true must still produce a default_output finding.",
|
||||
"Report a naming finding for any facts.commands item where name_conflicts_existing is true or flag_alias_conflict is true.",
|
||||
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
|
||||
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
|
||||
"Review public content leakage findings and semantic candidates without private dictionaries.",
|
||||
"Do not reveal internal rule lists when explaining public content leakage.",
|
||||
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
|
||||
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
|
||||
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
|
||||
"facts.examples and facts.skill_quality entries are context only.",
|
||||
"Report each distinct issue as a separate finding.",
|
||||
|
||||
@@ -78,11 +78,11 @@ func DefaultPolicy() Policy {
|
||||
return Policy{
|
||||
SchemaVersion: 1,
|
||||
DefaultEnforcement: "observe",
|
||||
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
|
||||
RolloutGroups: []RolloutGroup{{
|
||||
ID: "all",
|
||||
Enforcement: "blocking",
|
||||
Categories: []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
Categories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
|
||||
Owner: "test",
|
||||
Reason: "default in-memory policy",
|
||||
}},
|
||||
|
||||
@@ -82,6 +82,15 @@ func factScope(f facts.Facts, kind string, idx int) (FactScope, bool) {
|
||||
Source: item.Source,
|
||||
CommandPath: item.Command,
|
||||
}, true
|
||||
case "public_content":
|
||||
item := f.PublicContent[idx]
|
||||
return FactScope{
|
||||
FactKind: "public_content",
|
||||
Changed: true,
|
||||
Source: item.Source,
|
||||
SourceFile: item.File,
|
||||
Line: item.Line,
|
||||
}, true
|
||||
default:
|
||||
return FactScope{}, false
|
||||
}
|
||||
@@ -195,7 +204,7 @@ func containsString(values []string, want string) bool {
|
||||
|
||||
func allowedFactKind(kind string) bool {
|
||||
switch kind {
|
||||
case "skill", "command", "error", "output":
|
||||
case "skill", "command", "error", "output", "public_content":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -81,6 +81,30 @@ func TestGatekeeperSkillQualityUsesSkillEvidence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperUsesPublicContentEvidence(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_generic_credential",
|
||||
Action: "REJECT",
|
||||
File: "docs/public.md",
|
||||
Line: 12,
|
||||
Source: "metadata",
|
||||
}},
|
||||
}
|
||||
review := Review{Findings: []Finding{{
|
||||
Category: "public_content_leakage",
|
||||
Severity: "critical",
|
||||
Evidence: []string{"facts.public_content[0]"},
|
||||
Message: "public content finding needs review",
|
||||
SuggestedAction: "remove the sensitive public content",
|
||||
}}}
|
||||
got := Decide(f, review, DefaultPolicy())
|
||||
if len(got.Blockers) != 1 || got.Blockers[0].RolloutGroups[0] != "all" {
|
||||
t.Fatalf("expected public content blocker, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperAppliesSharedWaiverID(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
|
||||
@@ -13,27 +13,29 @@ import (
|
||||
)
|
||||
|
||||
type InputView struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ChangedSummary ChangedSummary `json:"changed_summary"`
|
||||
RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
|
||||
Commands []CommandInput `json:"commands,omitempty"`
|
||||
Skills []SkillInput `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorInput `json:"errors,omitempty"`
|
||||
Outputs []OutputInput `json:"outputs,omitempty"`
|
||||
Examples []ExampleInput `json:"examples,omitempty"`
|
||||
Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ChangedSummary ChangedSummary `json:"changed_summary"`
|
||||
RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
|
||||
Commands []CommandInput `json:"commands,omitempty"`
|
||||
Skills []SkillInput `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorInput `json:"errors,omitempty"`
|
||||
Outputs []OutputInput `json:"outputs,omitempty"`
|
||||
Examples []ExampleInput `json:"examples,omitempty"`
|
||||
PublicContentLeakage []PublicContentInput `json:"public_content_leakage,omitempty"`
|
||||
Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
type ChangedSummary struct {
|
||||
Commands int `json:"commands,omitempty"`
|
||||
Skills int `json:"skills,omitempty"`
|
||||
SkillQuality int `json:"skill_quality,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
Outputs int `json:"outputs,omitempty"`
|
||||
Examples int `json:"examples,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
Commands int `json:"commands,omitempty"`
|
||||
Skills int `json:"skills,omitempty"`
|
||||
SkillQuality int `json:"skill_quality,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
Outputs int `json:"outputs,omitempty"`
|
||||
Examples int `json:"examples,omitempty"`
|
||||
PublicContent int `json:"public_content,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
}
|
||||
|
||||
type RuleSummaryItem struct {
|
||||
@@ -86,6 +88,22 @@ type ExampleInput struct {
|
||||
facts.CommandExample
|
||||
}
|
||||
|
||||
type PublicContentInput struct {
|
||||
FactRef string `json:"fact_ref"`
|
||||
facts.PublicContentFact
|
||||
}
|
||||
|
||||
func (v InputView) HasReviewableFacts() bool {
|
||||
return len(v.Commands) > 0 ||
|
||||
len(v.Skills) > 0 ||
|
||||
len(v.SkillQuality) > 0 ||
|
||||
len(v.Errors) > 0 ||
|
||||
len(v.Outputs) > 0 ||
|
||||
len(v.Examples) > 0 ||
|
||||
len(v.PublicContentLeakage) > 0 ||
|
||||
len(v.Diagnostics) > 0
|
||||
}
|
||||
|
||||
func BuildInputView(f facts.Facts) InputView {
|
||||
selected := newInputSelection(f)
|
||||
selected.addChangedReviewCandidates()
|
||||
@@ -104,16 +122,17 @@ func BuildInputView(f facts.Facts) InputView {
|
||||
}
|
||||
|
||||
return InputView{
|
||||
SchemaVersion: f.SchemaVersion,
|
||||
ChangedSummary: changedSummary(f),
|
||||
RuleSummary: ruleSummary(f.Diagnostics),
|
||||
Commands: selected.commandInputs(),
|
||||
Skills: selected.skillInputs(),
|
||||
SkillQuality: selected.skillQualityInputs(),
|
||||
Errors: selected.errorInputs(),
|
||||
Outputs: selected.outputInputs(),
|
||||
Examples: selected.exampleInputs(),
|
||||
Diagnostics: viewDiagnostics,
|
||||
SchemaVersion: f.SchemaVersion,
|
||||
ChangedSummary: changedSummary(f),
|
||||
RuleSummary: ruleSummary(f.Diagnostics),
|
||||
Commands: selected.commandInputs(),
|
||||
Skills: selected.skillInputs(),
|
||||
SkillQuality: selected.skillQualityInputs(),
|
||||
Errors: selected.errorInputs(),
|
||||
Outputs: selected.outputInputs(),
|
||||
Examples: selected.exampleInputs(),
|
||||
PublicContentLeakage: selected.publicContentInputs(),
|
||||
Diagnostics: viewDiagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +157,11 @@ func (s *inputSelection) addChangedReviewCandidates() {
|
||||
s.outputs[i] = true
|
||||
}
|
||||
}
|
||||
for i, item := range s.f.PublicContent {
|
||||
if publicContentReviewCandidate(item) {
|
||||
s.publicContent[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func commandReviewCandidate(cmd facts.CommandFact) bool {
|
||||
@@ -157,25 +181,31 @@ func outputReviewCandidate(_ facts.OutputFact) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func publicContentReviewCandidate(item facts.PublicContentFact) bool {
|
||||
return item.Rule == "public_content_semantic_candidate"
|
||||
}
|
||||
|
||||
type inputSelection struct {
|
||||
f facts.Facts
|
||||
commands []bool
|
||||
skills []bool
|
||||
skillQuality []bool
|
||||
errors []bool
|
||||
outputs []bool
|
||||
examples []bool
|
||||
f facts.Facts
|
||||
commands []bool
|
||||
skills []bool
|
||||
skillQuality []bool
|
||||
errors []bool
|
||||
outputs []bool
|
||||
examples []bool
|
||||
publicContent []bool
|
||||
}
|
||||
|
||||
func newInputSelection(f facts.Facts) *inputSelection {
|
||||
return &inputSelection{
|
||||
f: f,
|
||||
commands: make([]bool, len(f.Commands)),
|
||||
skills: make([]bool, len(f.Skills)),
|
||||
skillQuality: make([]bool, len(f.SkillQuality)),
|
||||
errors: make([]bool, len(f.Errors)),
|
||||
outputs: make([]bool, len(f.Outputs)),
|
||||
examples: make([]bool, len(f.Examples)),
|
||||
f: f,
|
||||
commands: make([]bool, len(f.Commands)),
|
||||
skills: make([]bool, len(f.Skills)),
|
||||
skillQuality: make([]bool, len(f.SkillQuality)),
|
||||
errors: make([]bool, len(f.Errors)),
|
||||
outputs: make([]bool, len(f.Outputs)),
|
||||
examples: make([]bool, len(f.Examples)),
|
||||
publicContent: make([]bool, len(f.PublicContent)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +224,8 @@ func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSele
|
||||
s.addDiagnosticExamples(out, diag)
|
||||
case diag.Rule == "no_bare_helper_error":
|
||||
s.addDiagnosticErrors(out, diag)
|
||||
case strings.HasPrefix(diag.Rule, "public_content_"):
|
||||
s.addDiagnosticPublicContent(out, diag)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -256,6 +288,15 @@ func (s *inputSelection) addDiagnosticExamples(out *inputSelection, diag facts.D
|
||||
}
|
||||
}
|
||||
|
||||
func (s *inputSelection) addDiagnosticPublicContent(out *inputSelection, diag facts.DiagnosticFact) {
|
||||
for i, item := range s.f.PublicContent {
|
||||
if diagnosticLocationMatches(diag.File, diag.Line, item.File, item.Line) ||
|
||||
diag.Rule == item.Rule {
|
||||
out.publicContent[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func includeDiagnosticInView(diag facts.DiagnosticFact, selected, context *inputSelection) bool {
|
||||
if diag.Action == report.ActionReject {
|
||||
return true
|
||||
@@ -270,6 +311,7 @@ func (s *inputSelection) merge(other *inputSelection) {
|
||||
mergeSelections(s.errors, other.errors)
|
||||
mergeSelections(s.outputs, other.outputs)
|
||||
mergeSelections(s.examples, other.examples)
|
||||
mergeSelections(s.publicContent, other.publicContent)
|
||||
}
|
||||
|
||||
func (s *inputSelection) intersects(other *inputSelection) bool {
|
||||
@@ -278,7 +320,8 @@ func (s *inputSelection) intersects(other *inputSelection) bool {
|
||||
selectionsIntersect(s.skillQuality, other.skillQuality) ||
|
||||
selectionsIntersect(s.errors, other.errors) ||
|
||||
selectionsIntersect(s.outputs, other.outputs) ||
|
||||
selectionsIntersect(s.examples, other.examples)
|
||||
selectionsIntersect(s.examples, other.examples) ||
|
||||
selectionsIntersect(s.publicContent, other.publicContent)
|
||||
}
|
||||
|
||||
func (s *inputSelection) commandInputs() []CommandInput {
|
||||
@@ -351,6 +394,16 @@ func (s *inputSelection) exampleInputs() []ExampleInput {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *inputSelection) publicContentInputs() []PublicContentInput {
|
||||
out := make([]PublicContentInput, 0, countSelected(s.publicContent))
|
||||
for i, ok := range s.publicContent {
|
||||
if ok {
|
||||
out = append(out, PublicContentInput{FactRef: factRef("public_content", i), PublicContentFact: s.f.PublicContent[i]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func changedSummary(f facts.Facts) ChangedSummary {
|
||||
domains := map[string]bool{}
|
||||
sources := map[string]bool{}
|
||||
@@ -402,6 +455,10 @@ func changedSummary(f facts.Facts) ChangedSummary {
|
||||
addNonEmpty(domains, example.Domain)
|
||||
addNonEmpty(sources, example.Source)
|
||||
}
|
||||
for _, item := range f.PublicContent {
|
||||
out.PublicContent++
|
||||
addNonEmpty(sources, item.Source)
|
||||
}
|
||||
out.Domains = sortedViewSetKeys(domains)
|
||||
out.Sources = sortedViewSetKeys(sources)
|
||||
return out
|
||||
@@ -434,7 +491,8 @@ func semanticDiagnosticRule(rule string) bool {
|
||||
strings.HasPrefix(rule, "default_output") ||
|
||||
strings.HasPrefix(rule, "skill_") ||
|
||||
strings.HasPrefix(rule, "example_dry_run") ||
|
||||
rule == "no_bare_helper_error"
|
||||
rule == "no_bare_helper_error" ||
|
||||
strings.HasPrefix(rule, "public_content_")
|
||||
}
|
||||
|
||||
func diagnosticCommandMatches(diag facts.DiagnosticFact, values ...string) bool {
|
||||
|
||||
@@ -77,6 +77,122 @@ func TestInputViewKeepsChangedReviewCandidatesWithOriginalRefs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewIncludesPublicContentLeakage(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_generic_credential",
|
||||
Action: report.ActionReject,
|
||||
File: "docs/public.md",
|
||||
Line: 4,
|
||||
Excerpt: "api_key = <redacted>",
|
||||
Message: "generic credential assignment",
|
||||
}},
|
||||
Diagnostics: []facts.DiagnosticFact{{
|
||||
Rule: "public_content_generic_credential",
|
||||
Action: report.ActionReject,
|
||||
File: "docs/public.md",
|
||||
Line: 4,
|
||||
Message: "generic credential assignment",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 1 {
|
||||
t.Fatalf("public content leakage len = %d, want 1", len(view.PublicContentLeakage))
|
||||
}
|
||||
if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
|
||||
t.Fatalf("public content fact ref = %q", got)
|
||||
}
|
||||
if len(view.Diagnostics) != 1 {
|
||||
t.Fatalf("diagnostics len = %d, want 1", len(view.Diagnostics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewIncludesPublicContentSemanticCandidatesWithoutDiagnostics(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_semantic_candidate",
|
||||
Action: report.ActionWarning,
|
||||
File: "docs/public.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
Excerpt: "public prose that needs semantic review",
|
||||
Message: "public contribution contains text for semantic public content review",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 1 {
|
||||
t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
|
||||
}
|
||||
if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
|
||||
t.Fatalf("semantic candidate fact ref = %q", got)
|
||||
}
|
||||
if len(view.Diagnostics) != 0 {
|
||||
t.Fatalf("semantic candidate should not require diagnostics, got %#v", view.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptIncludesSanitizedPublicContentExcerpt(t *testing.T) {
|
||||
scopeText := "pri" + "vate rollout"
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_semantic_candidate",
|
||||
Action: report.ActionWarning,
|
||||
File: "docs/public.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
Excerpt: `semantic signals: pri` + `vate_scope,roadmap_detail; excerpt: "` + scopeText + ` token=<redacted>"`,
|
||||
Message: "public contribution contains text for semantic public content review",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 1 {
|
||||
t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
|
||||
}
|
||||
if got := view.PublicContentLeakage[0].Excerpt; !strings.Contains(got, scopeText) || !strings.Contains(got, "token=<redacted>") {
|
||||
t.Fatalf("semantic candidate excerpt missing from view: %q", got)
|
||||
}
|
||||
|
||||
messages := BuildPrompt(f)
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("messages len = %d, want 2", len(messages))
|
||||
}
|
||||
if !strings.Contains(messages[1].Content, scopeText) || !strings.Contains(messages[1].Content, "redacted") {
|
||||
t.Fatalf("prompt missing sanitized public content excerpt: %s", messages[1].Content)
|
||||
}
|
||||
if strings.Contains(messages[1].Content, "real-"+"secret-value") {
|
||||
t.Fatalf("prompt leaked raw sensitive value %q", messages[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewExcludesPublicContentWarningsWithoutSemanticCandidate(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_" + "pri" + "vate_ipv4",
|
||||
Action: report.ActionWarning,
|
||||
File: "docs/network.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
Excerpt: "192.168." + "0.10",
|
||||
Message: "public contribution contains a pri" + "vate-network IP address",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 0 {
|
||||
t.Fatalf("warning-only public content should not enter semantic view: %#v", view.PublicContentLeakage)
|
||||
}
|
||||
if len(view.Diagnostics) != 0 {
|
||||
t.Fatalf("warning-only public content should not add diagnostics: %#v", view.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewSummarizesBroadChangedCommandSurface(t *testing.T) {
|
||||
f := broadChangedFacts(434, 44)
|
||||
|
||||
|
||||
@@ -138,6 +138,10 @@ func parseWaiver(parts []string, lineNo int) (Waiver, error) {
|
||||
if item.SourceFile == "" || item.Line == 0 {
|
||||
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires source_file and line", waiverPath, lineNo, item.FactKind)
|
||||
}
|
||||
case "public_content":
|
||||
if item.SourceFile == "" || item.Line == 0 || item.CommandPath != "" {
|
||||
return Waiver{}, fmt.Errorf("%s:%d: public_content waiver requires source_file and line only", waiverPath, lineNo)
|
||||
}
|
||||
case "command", "output":
|
||||
if item.CommandPath == "" {
|
||||
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires command_path", waiverPath, lineNo, item.FactKind)
|
||||
|
||||
@@ -21,24 +21,27 @@ func TestLoadWaivers(t *testing.T) {
|
||||
|
||||
writeSemanticFile(t, repo, "waivers.txt", "# waiver_id\tcategory\tfact_kind\tsource_file\tline\tcommand_path\towner\treason\tadded_at\texpires_at\n"+
|
||||
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/SKILL.md\t30\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
|
||||
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n")
|
||||
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
|
||||
"public-doc-202606\tpublic_content_leakage\tpublic_content\tdocs/public.md\t4\t\tsecurity-owner\treviewed false positive\t2026-06-08\t2026-07-15\n")
|
||||
w, diags, err = LoadWaivers(repo, now)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWaivers() error = %v", err)
|
||||
}
|
||||
if len(diags) != 0 || len(w.Items) != 2 {
|
||||
if len(diags) != 0 || len(w.Items) != 3 {
|
||||
t.Fatalf("LoadWaivers() = %#v %#v", w, diags)
|
||||
}
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"bad columns": "one\ttoo-few\n",
|
||||
"bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
|
||||
"bad columns": "one\ttoo-few\n",
|
||||
"bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"public content missing line": "id1\tpublic_content_leakage\tpublic_content\tdocs/public.md\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"public content command selector": "id1\tpublic_content_leakage\tpublic_content\t\t\tcmd/foo\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
writeSemanticFile(t, repo, "waivers.txt", body)
|
||||
|
||||
@@ -5609,6 +5609,21 @@
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.nickname:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.nickname:write",
|
||||
"final_score": "79.5982",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.user_setting:write",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.user_setting:read",
|
||||
"final_score": "88.0587",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.57",
|
||||
"version": "1.0.58",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -45,6 +45,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
|
||||
repo: context.repo.repo,
|
||||
pull_number: target.pr,
|
||||
});
|
||||
if (pr.state !== "open") {
|
||||
core.notice(`PR quality summary skipped: PR is no longer open before ${phase}`);
|
||||
return false;
|
||||
}
|
||||
if (pr.head.sha !== target.headSha) {
|
||||
core.notice(`PR quality summary skipped: PR head changed before ${phase}`);
|
||||
return false;
|
||||
|
||||
@@ -152,6 +152,25 @@ describe("ci-quality-summary-publish", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not publish a summary when the PR closes before comment creation", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
await publish({
|
||||
github: fakeGithub(calls, {
|
||||
jobs: [{ name: "unit-test", conclusion: "failure", html_url: "https://github.example/jobs/1" }],
|
||||
pullResponses: [
|
||||
currentPullResponse(),
|
||||
currentPullResponse({ state: "closed" }),
|
||||
],
|
||||
}),
|
||||
context: workflowRunContext({ conclusion: "failure" }),
|
||||
core: silentCore(calls),
|
||||
});
|
||||
|
||||
assert.equal(calls.comments.length, 0);
|
||||
assert.match(calls.notices.join("\n"), /PR is no longer open/);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not delete an existing summary when the PR base changes before cleanup", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
await publish({
|
||||
@@ -338,6 +357,7 @@ function fakeGithub(calls, options = {}) {
|
||||
function currentPullResponse(overrides = {}) {
|
||||
return {
|
||||
data: {
|
||||
state: overrides.state || "open",
|
||||
head: { sha: overrides.headSha || process.env.CI_QUALITY_SUMMARY_HEAD_SHA },
|
||||
base: {
|
||||
sha: overrides.baseSha || process.env.CI_QUALITY_SUMMARY_BASE_SHA,
|
||||
|
||||
@@ -5,26 +5,42 @@
|
||||
set -euo pipefail
|
||||
|
||||
workflow=".github/workflows/ci.yml"
|
||||
job_section() {
|
||||
local job="$1"
|
||||
awk -v job="$job" '
|
||||
$0 == " " job ":" { in_job = 1; print; next }
|
||||
in_job && /^ [A-Za-z0-9_-]+:/ { exit }
|
||||
in_job { print }
|
||||
' "$workflow"
|
||||
}
|
||||
workflow_permissions="$(awk '
|
||||
/^permissions:/ { in_permissions = 1; print; next }
|
||||
in_permissions && /^[^[:space:]]/ { exit }
|
||||
in_permissions { print }
|
||||
' "$workflow")"
|
||||
fast_gate_section="$(job_section fast-gate)"
|
||||
unit_test_section="$(job_section unit-test)"
|
||||
lint_section="$(awk '
|
||||
/^ lint:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
/^ deterministic-gate:/ { exit }
|
||||
/^ script-test:/ { exit }
|
||||
' "$workflow")"
|
||||
script_test_section="$(job_section script-test)"
|
||||
deterministic_section="$(awk '
|
||||
/^ deterministic-gate:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
/^ coverage:/ { exit }
|
||||
' "$workflow")"
|
||||
coverage_job_section="$(job_section coverage)"
|
||||
deadcode_section="$(job_section deadcode)"
|
||||
dry_run_section="$(job_section e2e-dry-run)"
|
||||
section="$(awk '
|
||||
/^ e2e-live:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
/^ security:/ { exit }
|
||||
' "$workflow")"
|
||||
security_section="$(job_section security)"
|
||||
license_header_section="$(job_section license-header)"
|
||||
results_section="$(awk '
|
||||
/^ results:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
@@ -98,13 +114,94 @@ if ! grep -Fq "make quality-gate" <<<"$deterministic_section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "Write public content metadata" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should write PR title/body metadata before quality-gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "types: [opened, synchronize, reopened, edited]" "$workflow"; then
|
||||
echo "CI pull_request trigger should include edited so PR title/body changes are rescanned"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "script-test:" <<<"$script_test_section"; then
|
||||
echo "CI should run make script-test so workflow and publisher contract tests are not local-only"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "make script-test" <<<"$script_test_section"; then
|
||||
echo "script-test job should invoke make script-test"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "actions/setup-node" <<<"$script_test_section"; then
|
||||
echo "script-test job should install Node for JavaScript workflow tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq '${{ secrets.' <<<"$script_test_section"; then
|
||||
echo "script-test must not reference secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "metadata-gate:" "$workflow"; then
|
||||
echo "metadata-gate should not run alongside deterministic-gate because both would upload the same facts artifact"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "github.event.action != 'edited'" <<<"$fast_gate_section"; then
|
||||
echo "fast-gate must run on pull_request edited events so title/body edits cannot replace failed CI with a light success"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for full_job in \
|
||||
"$unit_test_section" \
|
||||
"$lint_section" \
|
||||
"$script_test_section" \
|
||||
"$deterministic_section" \
|
||||
"$coverage_job_section" \
|
||||
"$dry_run_section" \
|
||||
"$security_section"; do
|
||||
if grep -Fq "github.event.action != 'edited'" <<<"$full_job"; then
|
||||
echo "full CI jobs must run on pull_request edited events; do not skip title/body-only edits"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for pull_request_job in "$deadcode_section" "$license_header_section"; do
|
||||
if grep -Fq "github.event.action != 'edited'" <<<"$pull_request_job"; then
|
||||
echo "pull_request-only CI jobs must run on edited events"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if grep -Fq '${{ secrets.' <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate must not reference secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "PUBLIC_CONTENT_METADATA=" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should pass public content metadata into make quality-gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "PR_BRANCH:" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should pass the pull request branch into public content metadata"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: quality-gate-facts-\${{ github.event.pull_request.base.sha }}-\${{ github.event.pull_request.head.sha }}" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should upload base/head-bound quality-gate-facts for semantic review"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "needs: [unit-test, lint, deterministic-gate]" "$workflow"; then
|
||||
echo "E2E jobs should wait for deterministic-gate"
|
||||
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate]" "$workflow"; then
|
||||
echo "E2E jobs should wait for script-test and deterministic-gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "script-test" <<<"$results_section"; then
|
||||
echo "results job should include script-test"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -210,6 +307,11 @@ if ! grep -Fq "go run ./internal/qualitygate/cmd/manifest-export" <<<"$make_outp
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq -- "--public-content-metadata .tmp/quality-gate/public-content-metadata.json" <<<"$make_output"; then
|
||||
echo "quality-gate check should consume public content metadata"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq -- "--manifest .tmp/quality-gate/command-manifest.json" <<<"$make_output" ||
|
||||
! grep -Fq -- "--command-index .tmp/quality-gate/command-index.json" <<<"$make_output"; then
|
||||
echo "quality-gate check should consume both exported command snapshots"
|
||||
|
||||
@@ -175,7 +175,7 @@ function inlineCode(value) {
|
||||
}
|
||||
|
||||
function parseEvidenceRef(ref) {
|
||||
const match = /^facts\.(commands|skills|errors|outputs)\[(\d+)\]$/.exec(String(ref || ""));
|
||||
const match = /^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$/.exec(String(ref || ""));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -230,6 +230,20 @@ function evidenceLocation(facts, ref) {
|
||||
return { kind: parsed.kind, command: item.path, label: item.path };
|
||||
}
|
||||
return null;
|
||||
case "public_content":
|
||||
if (item.file && Number.isInteger(item.line) && item.line > 0) {
|
||||
const label = `${item.file}:${item.line}`;
|
||||
if (item.file === "branch" || item.file === "pull_request_metadata" || String(item.file).startsWith("commit:")) {
|
||||
return { kind: parsed.kind, label };
|
||||
}
|
||||
return {
|
||||
kind: parsed.kind,
|
||||
path: item.file,
|
||||
line: item.line,
|
||||
label,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -845,6 +859,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
|
||||
repo: context.repo.repo,
|
||||
pull_number: target.pr,
|
||||
});
|
||||
if (pr.state !== "open") {
|
||||
core.notice(`semantic review skipped: PR is no longer open before ${phase}`);
|
||||
return false;
|
||||
}
|
||||
if (pr.head.sha !== target.headSha) {
|
||||
core.notice(`semantic review skipped: PR head changed before ${phase}`);
|
||||
return false;
|
||||
|
||||
@@ -202,6 +202,100 @@ describe("semantic-review-publish", () => {
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.errors[0]"] }, facts, changedLineIndex), null);
|
||||
});
|
||||
|
||||
it("maps public content evidence to changed files but not virtual metadata", () => {
|
||||
const restrictedScope = "pri" + "vate";
|
||||
const facts = {
|
||||
public_content: [
|
||||
{
|
||||
rule: "public_content_semantic_candidate",
|
||||
action: "WARNING",
|
||||
file: "docs/public-roadmap.md",
|
||||
line: 4,
|
||||
source: "file",
|
||||
},
|
||||
{
|
||||
rule: "public_content_semantic_candidate",
|
||||
action: "WARNING",
|
||||
file: "pull_request_metadata",
|
||||
line: 1,
|
||||
source: "metadata",
|
||||
},
|
||||
{
|
||||
rule: "public_content_automation_branch",
|
||||
action: "WARNING",
|
||||
file: "branch",
|
||||
line: 1,
|
||||
source: "branch",
|
||||
},
|
||||
{
|
||||
rule: "public_content_change_id_trailer",
|
||||
action: "REJECT",
|
||||
file: "commit:1234abc",
|
||||
line: 3,
|
||||
source: "commit",
|
||||
},
|
||||
],
|
||||
};
|
||||
const changedLineIndex = buildChangedLineIndex([{
|
||||
filename: "docs/public-roadmap.md",
|
||||
patch: [
|
||||
"@@ -3,2 +3,3 @@",
|
||||
" context",
|
||||
"+Specific " + restrictedScope + " roadmap detail",
|
||||
].join("\n"),
|
||||
}]);
|
||||
|
||||
assert.deepEqual(
|
||||
selectInlineTarget({ evidence: ["facts.public_content[0]"] }, facts, changedLineIndex),
|
||||
{ path: "docs/public-roadmap.md", line: 4 },
|
||||
);
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[1]"] }, facts, changedLineIndex), null);
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[2]"] }, facts, changedLineIndex), null);
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[3]"] }, facts, changedLineIndex), null);
|
||||
|
||||
const markdown = buildSummaryMarkdown({
|
||||
block_mode: true,
|
||||
blockers: [{
|
||||
category: "public_content_leakage",
|
||||
severity: "major",
|
||||
review_action: "must_fix",
|
||||
evidence: ["facts.public_content[1]"],
|
||||
fingerprint: "public-content-metadata",
|
||||
message: "PR metadata contains " + restrictedScope + " rollout detail",
|
||||
suggested_action: "Move " + restrictedScope + " detail to an internal channel.",
|
||||
}],
|
||||
warnings: [],
|
||||
}, facts);
|
||||
assert.match(markdown, /pull_request_metadata:1/);
|
||||
|
||||
const virtualMarkdown = buildSummaryMarkdown({
|
||||
block_mode: true,
|
||||
blockers: [
|
||||
{
|
||||
category: "public_content_leakage",
|
||||
severity: "major",
|
||||
review_action: "must_fix",
|
||||
evidence: ["facts.public_content[2]"],
|
||||
fingerprint: "public-content-branch",
|
||||
message: "Branch name looks automation-owned.",
|
||||
suggested_action: "Use a maintainer-owned public branch name.",
|
||||
},
|
||||
{
|
||||
category: "public_content_leakage",
|
||||
severity: "major",
|
||||
review_action: "must_fix",
|
||||
evidence: ["facts.public_content[3]"],
|
||||
fingerprint: "public-content-commit",
|
||||
message: "Commit trailer contains " + restrictedScope + " review metadata.",
|
||||
suggested_action: "Remove " + restrictedScope + " review metadata from commits.",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
}, facts);
|
||||
assert.match(virtualMarkdown, /branch:1/);
|
||||
assert.match(virtualMarkdown, /commit:1234abc:3/);
|
||||
});
|
||||
|
||||
it("builds finding markers from stable fingerprints and evidence identity", () => {
|
||||
const factsA = {
|
||||
skills: [{
|
||||
@@ -615,6 +709,35 @@ describe("semantic-review-publish", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips publishing when the PR closes after verification", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
fs.writeFileSync("decision.json", JSON.stringify({
|
||||
block_mode: true,
|
||||
blockers: [],
|
||||
warnings: [],
|
||||
}), "utf8");
|
||||
|
||||
await publish({
|
||||
github: fakeGithub(calls, {
|
||||
currentPullRequest: {
|
||||
state: "closed",
|
||||
head: { sha: "0123456789abcdef0123456789abcdef01234567" },
|
||||
base: {
|
||||
sha: "fedcba9876543210fedcba9876543210fedcba98",
|
||||
repo: { id: 123 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
context: workflowRunContext(),
|
||||
core: silentCore(calls),
|
||||
});
|
||||
|
||||
assert.equal(calls.checks.length, 0);
|
||||
assert.equal(calls.comments.length, 0);
|
||||
assert.match(calls.notices[0], /PR is no longer open before publishing/);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects publishing when the PR base repo changed after verification", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
fs.writeFileSync("decision.json", JSON.stringify({
|
||||
@@ -2223,8 +2346,8 @@ function fakeGithub(calls, options = {}) {
|
||||
},
|
||||
},
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: Array.isArray(options.currentPullRequests)
|
||||
get: async () => {
|
||||
const pull = Array.isArray(options.currentPullRequests)
|
||||
? options.currentPullRequests[Math.min(pullGetCount++, options.currentPullRequests.length - 1)]
|
||||
: options.currentPullRequest || {
|
||||
head: { sha: process.env.SEMANTIC_REVIEW_HEAD_SHA },
|
||||
@@ -2232,8 +2355,9 @@ function fakeGithub(calls, options = {}) {
|
||||
sha: process.env.SEMANTIC_REVIEW_BASE_SHA,
|
||||
repo: { id: 123 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { data: { state: "open", ...pull } };
|
||||
},
|
||||
listFiles() {},
|
||||
listReviewComments() {},
|
||||
createReviewComment: async (args) => {
|
||||
|
||||
@@ -229,6 +229,36 @@ function requireSafePath(value, path) {
|
||||
return file;
|
||||
}
|
||||
|
||||
function requirePublicContentFile(value, path) {
|
||||
const file = requireString(value, path);
|
||||
if (file === "branch" || file === "pull_request_metadata" || /^commit:[0-9a-f]{7,40}$/.test(file)) {
|
||||
return file;
|
||||
}
|
||||
if (file.startsWith("commit:")) {
|
||||
throw new Error(`facts JSON ${path} must be a valid public content location`);
|
||||
}
|
||||
requireSafePath(file, path);
|
||||
if (
|
||||
file === "" ||
|
||||
file === "." ||
|
||||
file.startsWith("./") ||
|
||||
file.includes("\\") ||
|
||||
file.includes("\0") ||
|
||||
file.split("/").includes(".git") ||
|
||||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)
|
||||
) {
|
||||
throw new Error(`facts JSON ${path} must be a repository-relative path`);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function requirePositiveLine(value, path) {
|
||||
requireLine(value, path);
|
||||
if (value === 0) {
|
||||
throw new Error(`facts JSON ${path} must be a positive line number`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireStringArray(value, path, { optional = false } = {}) {
|
||||
if (value === undefined || value === null) {
|
||||
if (optional) {
|
||||
@@ -421,6 +451,20 @@ function verifyFactsJSON(data) {
|
||||
for (const [i, value] of requireArray(facts, "examples").entries()) {
|
||||
verifyCommandExample(value, `examples[${i}]`);
|
||||
}
|
||||
for (const [i, value] of requireArray(facts, "public_content").entries()) {
|
||||
const item = requireObject(value, `public_content[${i}]`);
|
||||
requireString(item.rule, `public_content[${i}].rule`);
|
||||
const action = requireString(item.action, `public_content[${i}].action`);
|
||||
if (!VALID_ACTIONS.has(action)) {
|
||||
throw new Error(`facts JSON public_content[${i}].action is invalid`);
|
||||
}
|
||||
requirePublicContentFile(item.file, `public_content[${i}].file`);
|
||||
requirePositiveLine(item.line, `public_content[${i}].line`);
|
||||
requireString(item.source, `public_content[${i}].source`, { optional: true });
|
||||
requireString(item.excerpt, `public_content[${i}].excerpt`, { optional: true });
|
||||
requireString(item.message, `public_content[${i}].message`, { optional: true });
|
||||
requireString(item.suggestion, `public_content[${i}].suggestion`, { optional: true });
|
||||
}
|
||||
for (const [i, value] of requireArray(facts, "diagnostics").entries()) {
|
||||
const item = requireObject(value, `diagnostics[${i}]`);
|
||||
requireString(item.rule, `diagnostics[${i}].rule`);
|
||||
|
||||
@@ -67,7 +67,43 @@ describe("verifyZipEntries", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
|
||||
const zipPath = path.join(dir, "facts.zip");
|
||||
const outPath = path.join(dir, "facts.json");
|
||||
const facts = Buffer.from('{"schema_version":1}\n');
|
||||
const restrictedScope = "pri" + "vate";
|
||||
const facts = Buffer.from(JSON.stringify({
|
||||
schema_version: 1,
|
||||
public_content: [
|
||||
{
|
||||
rule: "public_content_semantic_candidate",
|
||||
action: "WARNING",
|
||||
file: "pull_request_metadata",
|
||||
line: 1,
|
||||
source: "metadata",
|
||||
excerpt: "public release notes mention an internal rollout plan",
|
||||
message: "public contribution may contain sensitive implementation detail",
|
||||
suggestion: "move internal detail to " + restrictedScope + " discussion",
|
||||
},
|
||||
{
|
||||
rule: "public_content_change_id_trailer",
|
||||
action: "REJECT",
|
||||
file: "commit:1234abc",
|
||||
line: 3,
|
||||
source: "commit",
|
||||
},
|
||||
{
|
||||
rule: "public_content_automation_branch",
|
||||
action: "WARNING",
|
||||
file: "branch",
|
||||
line: 1,
|
||||
source: "branch",
|
||||
},
|
||||
{
|
||||
rule: "public_content_" + "pri" + "vate_ipv4",
|
||||
action: "WARNING",
|
||||
file: "docs/public-network.md",
|
||||
line: 7,
|
||||
source: "file",
|
||||
},
|
||||
],
|
||||
}) + "\n");
|
||||
const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]);
|
||||
fs.writeFileSync(zipPath, zip);
|
||||
|
||||
@@ -103,6 +139,19 @@ describe("verifyZipEntries", () => {
|
||||
["bad-error-path", Buffer.from('{"schema_version":1,"errors":[{"file":"../x.go","line":1,"boundary":true,"uses_structured_error":false,"has_hint":false,"hint_action_count":0,"required_hint":true,"retryable":false}]}'), /errors\[0\]\.file/],
|
||||
["bad-example-dry-run", Buffer.from('{"schema_version":1,"examples":[{"raw":"lark-cli docs +fetch","source_file":"skills/lark-doc/SKILL.md","line":3,"executable":true,"dry_run":{"method":"GET","url":"/open-apis/docx","query":{"page_size":["20",1]}}}]}'), /examples\[0\]\.dry_run\.query\.page_size\[1\]/],
|
||||
["bad-output-field", Buffer.from(JSON.stringify({ schema_version: 1, outputs: [{ command: "drive files list", fields: ["ok", "x".repeat(9000)] }] })), /outputs\[0\]\.fields\[1\]/],
|
||||
["non-array-public-content", Buffer.from('{"schema_version":1,"public_content":{}}'), /public_content must be an array/],
|
||||
["bad-public-content-item", Buffer.from('{"schema_version":1,"public_content":["not-object"]}'), /public_content\[0\]/],
|
||||
["bad-public-content-action", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"BLOCK","file":"pull_request_metadata","line":1}]}'), /public_content\[0\]\.action/],
|
||||
["bad-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"../x","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["dot-slash-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"./foo","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["empty-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["dot-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["url-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"https://example.invalid/x","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["dotgit-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".git/config","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["windows-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"C:\\\\tmp\\\\x","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["bad-public-content-commit-ref", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_change_id_trailer","action":"REJECT","file":"commit:notasha","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["bad-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":"1"}]}'), /public_content\[0\]\.line/],
|
||||
["zero-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":0}]}'), /public_content\[0\]\.line/],
|
||||
["bad-diagnostic-action", Buffer.from('{"schema_version":1,"diagnostics":[{"rule":"r","action":"BLOCK","file":"x.go","line":1,"message":"m"}]}'), /diagnostics.*action/],
|
||||
["long-message", Buffer.from(JSON.stringify({ schema_version: 1, diagnostics: [{ rule: "r", action: "REJECT", file: "x.go", line: 1, message: "x".repeat(9000) }] })), /too long/],
|
||||
]) {
|
||||
|
||||
@@ -184,6 +184,10 @@ require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase
|
||||
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
|
||||
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
|
||||
require_in_step "$summary_verify_step" 'state: "all"' "PR quality summary fallback must inspect closed PRs before failing"
|
||||
require_in_step "$summary_verify_step" 'candidate.state === "open"' "PR quality summary fallback must still prefer open PRs"
|
||||
require_in_step "$summary_verify_step" 'workflow_run target PR is no longer open' "PR quality summary must skip stale workflow_run events after PR closure"
|
||||
require_in_step "$summary_verify_step" 'pr.state !== "open"' "PR quality summary must skip direct workflow_run PR bindings after PR closure"
|
||||
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
|
||||
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
|
||||
|
||||
@@ -212,7 +216,12 @@ require_in_step "$verify_step" 'runPRs.length > 1' "semantic-review must fail cl
|
||||
require_in_step "$verify_step" 'listPullRequestsAssociatedWithCommit' "semantic-review must resolve fork workflow_run PRs when pull_requests is empty"
|
||||
require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fallback must resolve PRs by the workflow_run PR head SHA"
|
||||
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
|
||||
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
require_in_step "$verify_step" 'openCandidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
require_in_step "$verify_step" 'state: "all"' "semantic-review fallback must inspect closed PRs before failing"
|
||||
require_in_step "$verify_step" 'candidate.state === "open"' "semantic-review fallback must still prefer open PRs"
|
||||
require_in_step "$verify_step" 'workflow_run target PR is no longer open' "semantic-review must skip stale workflow_run events after PR closure"
|
||||
require_in_step "$verify_step" 'pr.state !== "open"' "semantic-review must skip direct workflow_run PR bindings after PR closure"
|
||||
require_in_step "$verify_step" '!pr.head.repo' "semantic-review must skip unavailable PR head repositories before reading owner/repo"
|
||||
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
|
||||
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
|
||||
@@ -260,6 +269,7 @@ require_in_step "$semantic_step" 'args+=(--waivers-file' "same-repo PR head waiv
|
||||
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_BASE_SHA' "pre-checkout failure publisher must receive verified base SHA"
|
||||
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_RUN_ID' "pre-checkout failure publisher must receive verified run id"
|
||||
require_in_step "$precheckout_step" 'github.rest.pulls.get' "pre-checkout failure publisher must recheck PR target before writing"
|
||||
require_in_step "$precheckout_step" 'pull.state !== "open"' "pre-checkout failure publisher must skip closed PRs before writing"
|
||||
require_in_step "$precheckout_step" 'pull.head.sha !== headSha' "pre-checkout failure publisher must skip stale PR heads"
|
||||
require_in_step "$precheckout_step" 'pull.base.sha !== baseSha' "pre-checkout failure publisher must skip stale PR bases"
|
||||
|
||||
|
||||
@@ -203,6 +203,13 @@ func TestValidateCreateV2Contract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCreateV2AllowsTitleWithoutContent(t *testing.T) {
|
||||
rt := docValidateRuntime(t, map[string]string{"title": "Only Title"}, nil, nil)
|
||||
if err := validateCreateV2(context.Background(), rt); err != nil {
|
||||
t.Fatalf("validateCreateV2() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFetchV2Contract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -282,7 +282,6 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "项目计划",
|
||||
"--markdown", "## 目标",
|
||||
"--as", "user",
|
||||
})
|
||||
@@ -292,8 +291,7 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"docs +create is v2-only",
|
||||
"the old v1 interface has been shut down",
|
||||
"legacy v1 flag(s) --title, --markdown are no longer supported",
|
||||
"--title -> put the title in --content",
|
||||
"legacy v1 flag(s) --markdown are no longer supported",
|
||||
"--markdown -> use --content with --doc-format markdown",
|
||||
"lark-cli skills read lark-doc references/lark-doc-create.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||
func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
|
||||
@@ -25,8 +28,12 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateDocsV2Only(runtime, "+create", docsCreateLegacyFlags()); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("content") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
|
||||
title := strings.TrimSpace(runtime.Str("title"))
|
||||
if runtime.Changed("title") && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
|
||||
}
|
||||
if runtime.Str("content") == "" && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
}
|
||||
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
|
||||
@@ -66,7 +73,7 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
"content": runtime.Str("content"),
|
||||
"content": buildCreateContent(runtime),
|
||||
}
|
||||
if v := runtime.Str("parent-token"); v != "" {
|
||||
body["parent_token"] = v
|
||||
@@ -78,6 +85,26 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return body
|
||||
}
|
||||
|
||||
func buildCreateContent(runtime *common.RuntimeContext) string {
|
||||
content := runtime.Str("content")
|
||||
title := strings.TrimSpace(runtime.Str("title"))
|
||||
if title == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
|
||||
if content == "" {
|
||||
return titleTag
|
||||
}
|
||||
return titleTag + "\n" + content
|
||||
}
|
||||
|
||||
func escapeDocTitleText(title string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(title))
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// augmentDocsCreatePermission grants full_access to the current CLI user when
|
||||
// the document was created with bot identity.
|
||||
func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) {
|
||||
|
||||
@@ -43,6 +43,23 @@ func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBodyPrependsTitleToContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newCreateBodyTestRuntime(context.Background())
|
||||
if err := runtime.Cmd.Flags().Set("title", "A & B <C>"); err != nil {
|
||||
t.Fatalf("set title: %v", err)
|
||||
}
|
||||
if err := runtime.Cmd.Flags().Set("content", "## Body"); err != nil {
|
||||
t.Fatalf("set content: %v", err)
|
||||
}
|
||||
|
||||
body := buildCreateBody(runtime)
|
||||
if got, want := body["content"], "<title>A & B <C></title>\n## Body"; got != want {
|
||||
t.Fatalf("content = %#v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -845,6 +862,7 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s
|
||||
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+create"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("title", "", "")
|
||||
cmd.Flags().String("content", "<title>hello</title>", "")
|
||||
cmd.Flags().String("parent-token", "", "")
|
||||
cmd.Flags().String("parent-position", "", "")
|
||||
|
||||
@@ -25,7 +25,6 @@ func docsAPIVersionCompatFlag() common.Flag {
|
||||
|
||||
func docsCreateLegacyFlags() []docsLegacyFlag {
|
||||
return []docsLegacyFlag{
|
||||
{Name: "title", Replacement: "put the title in --content, for example <title>Title</title>"},
|
||||
{Name: "markdown", Replacement: "use --content with --doc-format markdown"},
|
||||
{Name: "folder-token", Replacement: "use --parent-token"},
|
||||
{Name: "wiki-node", Replacement: "use --parent-token"},
|
||||
|
||||
686
shortcuts/drive/drive_member_add.go
Normal file
686
shortcuts/drive/drive_member_add.go
Normal file
@@ -0,0 +1,686 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// driveMemberAddIDTypes covers every user-facing --member-type value accepted
|
||||
// by the shortcut. Some values are normalized before hitting the API.
|
||||
var driveMemberAddIDTypes = []string{
|
||||
"email", "openid", "unionid", "openchat", "opendepartmentid",
|
||||
"groupid", "appid", "wikispaceid",
|
||||
}
|
||||
|
||||
var driveMemberAddPerms = []string{"view", "edit", "full_access"}
|
||||
var driveMemberAddPermTypes = []string{"container", "single_page"}
|
||||
var driveMemberAddWikiSpaceMemberKinds = []string{"wiki_space_member", "wiki_space_viewer", "wiki_space_editor"}
|
||||
|
||||
// driveMemberAddPrefixToType maps ID prefixes to their expected member_type
|
||||
// for conflict validation when --member-type is provided explicitly.
|
||||
var driveMemberAddPrefixToType = map[string]string{
|
||||
"ou_": "openid",
|
||||
"on_": "unionid",
|
||||
"oc_": "openchat",
|
||||
"od_": "opendepartmentid",
|
||||
}
|
||||
|
||||
var driveMemberAddURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes"}
|
||||
|
||||
const driveMemberAddBatchLimit = 10
|
||||
|
||||
// DriveMemberAdd adds a collaborator/member permission to a Drive resource.
|
||||
var DriveMemberAdd = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-add",
|
||||
Description: "Add a collaborator/member permission to a Drive document, file, folder, or wiki node",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"docs:permission.member:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target token or document URL; type is auto-inferred from URL path when omitted", Required: true},
|
||||
{Name: "type", Desc: "target resource type; required when --token is a bare token"},
|
||||
{Name: "member-id", Desc: "collaborator ID; comma-separated for batch (max 10). Interpretation is decided by --member-type", Required: true},
|
||||
{Name: "member-type", Desc: "ID type for --member-id; supported: email|openid|unionid|openchat|opendepartmentid|groupid|appid|wikispaceid", Required: true},
|
||||
{Name: "member-kind", Desc: "request body type when --member-type=wikispaceid; one of wiki_space_member|wiki_space_viewer|wiki_space_editor"},
|
||||
{Name: "perm", Desc: "permission role to grant; defaults to view"},
|
||||
{Name: "perm-type", Desc: "wiki permission scope; defaults to container; rejected for non-wiki types"},
|
||||
{Name: "need-notification", Type: "bool", Desc: "send an in-app notification after the grant (user identity only)"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Resource type is auto-inferred from URL paths; pass --type when --token is a bare token.",
|
||||
"Supported --member-type values: email, openid, unionid, openchat, opendepartmentid, groupid, appid, wikispaceid.",
|
||||
"When --member-type=wikispaceid, pass --member-kind wiki_space_member, wiki_space_viewer, or wiki_space_editor.",
|
||||
"--member-type is required; if the ID prefix conflicts with --member-type (e.g. ou_xxx with email), the command rejects it.",
|
||||
"--perm defaults to view (safest); use --dry-run first when granting edit or full_access.",
|
||||
"For wiki nodes, --perm-type defaults to container (current page and sub-pages), except --member-type=wikispaceid where --member-kind provides the wiki-space role.",
|
||||
"Department collaborator (--member-type=opendepartmentid) requires --as user; bot identity is not supported for department authorization.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveMemberAddSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveMemberAddSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveMemberAddDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveMemberAddSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(spec.MemberIDs) == 1 {
|
||||
return executeDriveMemberAddSingle(runtime, spec)
|
||||
}
|
||||
return executeDriveMemberAddBatch(runtime, spec)
|
||||
},
|
||||
}
|
||||
|
||||
// driveMemberAddSpec is the normalized request model shared by Validate,
|
||||
// DryRun, Execute, and output shaping so they all observe the same defaults.
|
||||
type driveMemberAddSpec struct {
|
||||
Token string
|
||||
ResourceType string
|
||||
MemberIDs []string
|
||||
MemberType string
|
||||
// MemberKind is the explicit --member-kind value for member_type=wikispaceid.
|
||||
MemberKind string
|
||||
Perm string
|
||||
PermType string
|
||||
NeedNotification bool
|
||||
NotificationSet bool
|
||||
}
|
||||
|
||||
// DryRunParams builds the preview query string while preserving the semantic
|
||||
// difference between an omitted notification flag and an explicit false.
|
||||
func (spec driveMemberAddSpec) DryRunParams() map[string]interface{} {
|
||||
params := map[string]interface{}{"type": spec.ResourceType}
|
||||
if spec.NotificationSet {
|
||||
params["need_notification"] = spec.NeedNotification
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// APIQueryParams builds the query params for permission.members.create.
|
||||
func (spec driveMemberAddSpec) APIQueryParams() map[string]interface{} {
|
||||
params := map[string]interface{}{"type": spec.ResourceType}
|
||||
if spec.NotificationSet {
|
||||
params["need_notification"] = strconv.FormatBool(spec.NeedNotification)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// buildMemberBody builds a single member object for the request body.
|
||||
func buildMemberBody(memberID, memberType, wikiSpaceMemberKind, perm, permType string) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"member_id": memberID,
|
||||
"member_type": memberType,
|
||||
"perm": perm,
|
||||
}
|
||||
if bodyType := driveMemberAddBodyType(memberType, wikiSpaceMemberKind); bodyType != "" {
|
||||
body["type"] = bodyType
|
||||
}
|
||||
if permType != "" {
|
||||
body["perm_type"] = permType
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// readDriveMemberAddSpec parses runtime flags into a normalized request model,
|
||||
// applying inference, defaults, and cross-field validation in one place.
|
||||
func readDriveMemberAddSpec(runtime *common.RuntimeContext) (driveMemberAddSpec, error) {
|
||||
token, resourceType, err := resolveDriveMemberAddTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveMemberAddSpec{}, err
|
||||
}
|
||||
|
||||
// Parse member-id: comma-separated for batch.
|
||||
rawMemberID := strings.TrimSpace(runtime.Str("member-id"))
|
||||
if rawMemberID == "" {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
|
||||
}
|
||||
memberIDs := splitAndTrimMembers(rawMemberID)
|
||||
if len(memberIDs) == 0 {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and must contain at least one non-blank ID").WithParam("--member-id")
|
||||
}
|
||||
if len(memberIDs) > driveMemberAddBatchLimit {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id accepts at most %d IDs, got %d", driveMemberAddBatchLimit, len(memberIDs)).WithParam("--member-id")
|
||||
}
|
||||
if duplicate, first, second, ok := firstDuplicateDriveMemberID(memberIDs); ok {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--member-id contains duplicate collaborator ID %q at positions %d and %d; remove duplicates before retrying",
|
||||
duplicate, first+1, second+1,
|
||||
).WithParam("--member-id")
|
||||
}
|
||||
|
||||
memberType, err := resolveDriveMemberAddMemberType(memberIDs, runtime.Str("member-type"))
|
||||
if err != nil {
|
||||
return driveMemberAddSpec{}, err
|
||||
}
|
||||
memberKind, err := resolveDriveMemberAddMemberKind(memberType, runtime.Str("member-kind"))
|
||||
if err != nil {
|
||||
return driveMemberAddSpec{}, err
|
||||
}
|
||||
|
||||
// perm: default to view.
|
||||
perm, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm"), driveMemberAddPerms, "--perm")
|
||||
if err != nil {
|
||||
return driveMemberAddSpec{}, err
|
||||
}
|
||||
if perm == "" {
|
||||
perm = "view"
|
||||
}
|
||||
|
||||
// perm-type: only meaningful for wiki; default container except for wiki-space collaborators.
|
||||
permType, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm-type"), driveMemberAddPermTypes, "--perm-type")
|
||||
if err != nil {
|
||||
return driveMemberAddSpec{}, err
|
||||
}
|
||||
if resourceType == "wiki" && memberType == "wikispaceid" {
|
||||
if runtime.Changed("perm-type") {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--perm-type is not supported when --member-type=wikispaceid; use --member-kind wiki_space_member|wiki_space_viewer|wiki_space_editor to set the wiki-space role",
|
||||
).WithParam("--perm-type")
|
||||
}
|
||||
permType = ""
|
||||
} else if resourceType == "wiki" && permType == "" {
|
||||
permType = driveMemberAddDefaultPermType(resourceType)
|
||||
} else if resourceType != "wiki" && runtime.Changed("perm-type") {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
|
||||
} else if resourceType != "wiki" {
|
||||
permType = ""
|
||||
}
|
||||
|
||||
spec := driveMemberAddSpec{
|
||||
Token: token,
|
||||
ResourceType: resourceType,
|
||||
MemberIDs: memberIDs,
|
||||
MemberType: memberType,
|
||||
MemberKind: memberKind,
|
||||
Perm: perm,
|
||||
PermType: permType,
|
||||
NeedNotification: runtime.Bool("need-notification"),
|
||||
NotificationSet: runtime.Changed("need-notification"),
|
||||
}
|
||||
if runtime.As().IsBot() && spec.NotificationSet {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--need-notification is only valid with --as user; omit it when using --as bot").WithParam("--need-notification")
|
||||
}
|
||||
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type=opendepartmentid requires --as user; bot identity does not support adding department collaborators").WithParam("--member-type")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// resolveDriveMemberAddTarget extracts (token, type) from a user-supplied
|
||||
// --token value that may be either a bare token or a full resource URL, plus an
|
||||
// optional explicit --type. Explicit --type wins over URL inference.
|
||||
func resolveDriveMemberAddTarget(raw, explicitType string) (token, resourceType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
explicitType = strings.ToLower(strings.TrimSpace(explicitType))
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
parsed, parseErr := url.Parse(raw)
|
||||
if parseErr != nil || parsed.Hostname() == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
|
||||
}
|
||||
urlToken, urlType, ok := parseDriveMemberAddResourceURLPath(parsed.Path)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported URL path %q: expected one of %s followed by a token",
|
||||
parsed.Path, strings.Join(driveMemberAddSupportedURLPaths(), ", "),
|
||||
).WithParam("--token")
|
||||
}
|
||||
token = urlToken
|
||||
if explicitType == "" {
|
||||
resourceType = urlType
|
||||
}
|
||||
} else {
|
||||
token = raw
|
||||
}
|
||||
|
||||
if explicitType != "" {
|
||||
if !isSupportedDriveMemberAddResourceType(explicitType) {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--type must be one of: %s", strings.Join(driveMemberAddResourceTypes, ", ")).WithParam("--type")
|
||||
}
|
||||
resourceType = explicitType
|
||||
}
|
||||
|
||||
if resourceType == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(driveMemberAddResourceTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
return token, resourceType, nil
|
||||
}
|
||||
|
||||
func driveMemberAddSupportedURLPaths() []string {
|
||||
paths := make([]string, 0, len(driveMemberAddURLPathToType))
|
||||
for _, mapping := range driveMemberAddURLPathToType {
|
||||
paths = append(paths, mapping.Prefix)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func parseDriveMemberAddResourceURLPath(path string) (token, resourceType string, ok bool) {
|
||||
for _, mapping := range driveMemberAddURLPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return token, mapping.Type, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func isSupportedDriveMemberAddResourceType(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDriveMemberAddMemberType(memberIDs []string, explicit string) (string, error) {
|
||||
var err error
|
||||
explicit, err = normalizeDriveMemberAddEnumValue(explicit, driveMemberAddIDTypes, "--member-type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if explicit == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type is required; accepted values: %s", strings.Join(driveMemberAddIDTypes, ", ")).WithParam("--member-type")
|
||||
}
|
||||
for i, memberID := range memberIDs {
|
||||
if expected := inferMemberTypeFromID(memberID); expected != "" && expected != explicit {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"member-id[%d] %q prefix implies --member-type %s, but --member-type %s was provided; fix the ID or use the matching member type",
|
||||
i+1, memberID, expected, explicit,
|
||||
).WithParam("--member-id")
|
||||
}
|
||||
}
|
||||
return normalizeDriveMemberAddMemberType(explicit), nil
|
||||
}
|
||||
|
||||
func resolveDriveMemberAddMemberKind(memberType, raw string) (string, error) {
|
||||
memberKind, err := normalizeDriveMemberAddEnumValue(raw, driveMemberAddWikiSpaceMemberKinds, "--member-kind")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if memberType == "wikispaceid" {
|
||||
if memberKind == "" {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--member-kind is required when --member-type=wikispaceid; allowed: %s",
|
||||
strings.Join(driveMemberAddWikiSpaceMemberKinds, ", "),
|
||||
).WithParam("--member-kind")
|
||||
}
|
||||
return memberKind, nil
|
||||
}
|
||||
if memberKind != "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-kind only applies when --member-type=wikispaceid").WithParam("--member-kind")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func normalizeDriveMemberAddMemberType(memberType string) string {
|
||||
return strings.ToLower(strings.TrimSpace(memberType))
|
||||
}
|
||||
|
||||
func normalizeDriveMemberAddEnumValue(raw string, allowed []string, flagName string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(value, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for %s, allowed: %s",
|
||||
value,
|
||||
flagName,
|
||||
strings.Join(allowed, ", "),
|
||||
).WithParam(flagName)
|
||||
}
|
||||
|
||||
// splitAndTrimMembers splits a comma-separated member-id string and trims whitespace.
|
||||
func splitAndTrimMembers(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
var result []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstDuplicateDriveMemberID(memberIDs []string) (duplicate string, first, second int, ok bool) {
|
||||
seen := make(map[string]int, len(memberIDs))
|
||||
for i, memberID := range memberIDs {
|
||||
if prev, exists := seen[memberID]; exists {
|
||||
return memberID, prev, i, true
|
||||
}
|
||||
seen[memberID] = i
|
||||
}
|
||||
return "", 0, 0, false
|
||||
}
|
||||
|
||||
// inferMemberTypeFromID returns the expected member_type for a member-id
|
||||
// based on its prefix, or "" if no prefix matches (e.g. groupid).
|
||||
func inferMemberTypeFromID(memberID string) string {
|
||||
memberID = strings.TrimSpace(memberID)
|
||||
if memberID == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(memberID, "@") {
|
||||
return "email"
|
||||
}
|
||||
for prefix, mtype := range driveMemberAddPrefixToType {
|
||||
if strings.HasPrefix(memberID, prefix) {
|
||||
return mtype
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// driveMemberAddDefaultPermType returns the default perm_type for a given
|
||||
// resource type. For wiki nodes, container is the default for regular
|
||||
// collaborators. Wiki-space collaborators omit perm_type because their role is
|
||||
// carried by the body type field.
|
||||
func driveMemberAddDefaultPermType(resourceType string) string {
|
||||
switch resourceType {
|
||||
case "wiki":
|
||||
return "container"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// inferDriveMemberKind derives the request-body collaborator kind from
|
||||
// member-type for all supported member-type values.
|
||||
func inferDriveMemberKind(memberType string) string {
|
||||
switch memberType {
|
||||
case "email", "openid", "unionid", "userid":
|
||||
return "user"
|
||||
case "openchat":
|
||||
return "chat"
|
||||
case "opendepartmentid":
|
||||
return "department"
|
||||
case "groupid":
|
||||
return "group"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func driveMemberAddBodyType(memberType, wikiSpaceMemberKind string) string {
|
||||
if memberType == "wikispaceid" {
|
||||
return wikiSpaceMemberKind
|
||||
}
|
||||
return inferDriveMemberKind(memberType)
|
||||
}
|
||||
|
||||
// buildDriveMemberAddDryRun renders the exact request preview for --dry-run.
|
||||
func buildDriveMemberAddDryRun(spec driveMemberAddSpec) *common.DryRunAPI {
|
||||
if len(spec.MemberIDs) == 1 {
|
||||
body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Add Drive collaborator/member permission").
|
||||
POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token))).
|
||||
Params(spec.DryRunParams()).
|
||||
Body(body)
|
||||
}
|
||||
|
||||
members := buildDriveMemberAddMemberBodies(spec)
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Batch add Drive collaborator/member permissions").
|
||||
POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token))).
|
||||
Params(spec.DryRunParams()).
|
||||
Body(map[string]interface{}{"members": members})
|
||||
}
|
||||
|
||||
// executeDriveMemberAddSingle calls the single-member create API.
|
||||
func executeDriveMemberAddSingle(runtime *common.RuntimeContext, spec driveMemberAddSpec) error {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Adding Drive member %s (type=%s, perm=%s) to %s %s...\n",
|
||||
common.MaskToken(spec.MemberIDs[0]), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token))
|
||||
|
||||
body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token)),
|
||||
spec.APIQueryParams(),
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := driveMemberAddOutput(spec, spec.MemberIDs[0], common.GetMap(data, "member"))
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Added Drive member %s\n", common.MaskToken(common.GetString(out, "member_id")))
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDriveMemberAddBatch calls the batch_create API. A successful HTTP/API
|
||||
// response is treated as complete only when the server returns every requested
|
||||
// member_id, regardless of response array order.
|
||||
func executeDriveMemberAddBatch(runtime *common.RuntimeContext, spec driveMemberAddSpec) error {
|
||||
members := buildDriveMemberAddMemberBodies(spec)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Adding %d Drive members (type=%s, perm=%s) to %s %s...\n",
|
||||
len(spec.MemberIDs), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token))
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token)),
|
||||
spec.APIQueryParams(),
|
||||
map[string]interface{}{"members": members},
|
||||
)
|
||||
if err != nil {
|
||||
return wrapDriveMemberAddBatchAPIError(err)
|
||||
}
|
||||
|
||||
result := buildDriveMemberAddBatchResult(spec, data)
|
||||
if common.GetBool(result, "partial") {
|
||||
return runtime.OutPartialFailure(result, nil)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Added %d Drive member(s)\n", result["succeeded_count"])
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
driveMemberAddInvalidParameterCode = 1063001
|
||||
driveMemberAddInvalidOperationCode = 1063003
|
||||
)
|
||||
|
||||
func wrapDriveMemberAddBatchAPIError(err error) error {
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return err
|
||||
}
|
||||
|
||||
wrapped := *apiErr
|
||||
switch apiErr.Code {
|
||||
case driveMemberAddInvalidOperationCode:
|
||||
wrapped.Message = "Drive batch member add failed: one or more requested members may already be collaborators on this resource"
|
||||
wrapped.Hint = "For batch add, remove members that already have access (especially a bot/app being added again), then retry only the missing collaborators."
|
||||
case driveMemberAddInvalidParameterCode:
|
||||
wrapped.Message = "Drive batch member add failed: one or more requested members may be invalid for this resource or identity"
|
||||
wrapped.Hint = "Check whether each --member-id exists, belongs to the same tenant, and is visible to the current identity; remove invalid members and retry only the valid collaborators."
|
||||
default:
|
||||
return err
|
||||
}
|
||||
wrapped.Cause = err
|
||||
return &wrapped
|
||||
}
|
||||
|
||||
func buildDriveMemberAddMemberBodies(spec driveMemberAddSpec) []map[string]interface{} {
|
||||
members := make([]map[string]interface{}, len(spec.MemberIDs))
|
||||
for i, mid := range spec.MemberIDs {
|
||||
members[i] = buildMemberBody(mid, spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
func buildDriveMemberAddBatchResult(spec driveMemberAddSpec, data map[string]interface{}) map[string]interface{} {
|
||||
rawMembers, _ := data["members"].([]interface{})
|
||||
|
||||
// Build set of requested IDs for O(1) lookup.
|
||||
requestedSet := make(map[string]bool, len(spec.MemberIDs))
|
||||
for _, id := range spec.MemberIDs {
|
||||
requestedSet[id] = true
|
||||
}
|
||||
|
||||
// First pass: build returned map and results array.
|
||||
// Matching is done by member_id, not by array index, so the server may
|
||||
// return members in any order without causing false partial_failure.
|
||||
results := make([]map[string]interface{}, 0, len(rawMembers))
|
||||
succeededIDs := make(map[string]bool, len(rawMembers))
|
||||
var mismatched []map[string]interface{}
|
||||
|
||||
for _, raw := range rawMembers {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rawMemberID := common.GetString(m, "member_id")
|
||||
|
||||
out := driveMemberAddOutputWithOptions(spec, "", m, false)
|
||||
results = append(results, out)
|
||||
|
||||
if rawMemberID != "" {
|
||||
if requestedSet[rawMemberID] {
|
||||
succeededIDs[rawMemberID] = true
|
||||
} else {
|
||||
mismatched = append(mismatched, map[string]interface{}{
|
||||
"returned": rawMemberID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: find requested IDs missing from the response.
|
||||
missing := make([]string, 0)
|
||||
for _, memberID := range spec.MemberIDs {
|
||||
if !succeededIDs[memberID] {
|
||||
missing = append(missing, memberID)
|
||||
}
|
||||
}
|
||||
|
||||
partial := len(results) != len(spec.MemberIDs) || len(missing) > 0 || len(mismatched) > 0
|
||||
result := map[string]interface{}{
|
||||
"resource_token": spec.Token,
|
||||
"resource_type": spec.ResourceType,
|
||||
"requested_count": len(spec.MemberIDs),
|
||||
"succeeded_count": len(succeededIDs),
|
||||
"partial": partial,
|
||||
"members": results,
|
||||
"missing_member_ids": missing,
|
||||
}
|
||||
if len(mismatched) > 0 {
|
||||
result["mismatched_member_ids"] = mismatched
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// driveMemberAddOutput flattens the server response into a stable envelope and
|
||||
// backfills fields from spec when the server omits them.
|
||||
func driveMemberAddOutput(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}) map[string]interface{} {
|
||||
return driveMemberAddOutputWithOptions(spec, fallbackMemberID, raw, true)
|
||||
}
|
||||
|
||||
func driveMemberAddOutputWithOptions(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}, allowDefaultMemberID bool) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"resource_token": spec.Token,
|
||||
"resource_type": spec.ResourceType,
|
||||
}
|
||||
if raw != nil {
|
||||
for _, key := range []string{"member_id", "member_type", "perm", "type"} {
|
||||
if v, ok := raw[key]; ok {
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
if spec.ResourceType == "wiki" {
|
||||
if v, ok := raw["perm_type"]; ok {
|
||||
out["perm_type"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if common.GetString(out, "member_id") == "" {
|
||||
if fallbackMemberID == "" && allowDefaultMemberID && len(spec.MemberIDs) > 0 {
|
||||
fallbackMemberID = spec.MemberIDs[0]
|
||||
}
|
||||
if fallbackMemberID != "" {
|
||||
out["member_id"] = fallbackMemberID
|
||||
}
|
||||
}
|
||||
if common.GetString(out, "member_type") == "" {
|
||||
out["member_type"] = spec.MemberType
|
||||
}
|
||||
if common.GetString(out, "perm") == "" {
|
||||
out["perm"] = spec.Perm
|
||||
}
|
||||
if spec.PermType != "" && common.GetString(out, "perm_type") == "" {
|
||||
out["perm_type"] = spec.PermType
|
||||
}
|
||||
if bodyType := driveMemberAddBodyType(spec.MemberType, spec.MemberKind); bodyType != "" && common.GetString(out, "type") == "" {
|
||||
out["type"] = bodyType
|
||||
}
|
||||
if t := common.GetString(out, "type"); t != "" {
|
||||
out["member_kind"] = t
|
||||
}
|
||||
delete(out, "type")
|
||||
return out
|
||||
}
|
||||
1507
shortcuts/drive/drive_member_add_test.go
Normal file
1507
shortcuts/drive/drive_member_add_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveSync,
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -33,6 +33,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+sync",
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -246,8 +246,9 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
shortcutHelp: "Create a Lark document",
|
||||
visibleFlag: "--content",
|
||||
skillCommand: "lark-cli skills read lark-doc references/lark-doc-create.md",
|
||||
hiddenFlags: []string{"title", "markdown", "folder-token", "wiki-node", "wiki-space"},
|
||||
hiddenFlags: []string{"markdown", "folder-token", "wiki-node", "wiki-space"},
|
||||
contentHelp: []string{
|
||||
"--title",
|
||||
"AI agents MUST read",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
"before writing any --content payload",
|
||||
@@ -257,7 +258,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"use --help for the latest command flags",
|
||||
},
|
||||
unwanted: []string{"--markdown", "--title", "--folder-token", "--wiki-node", "--wiki-space"},
|
||||
unwanted: []string{"--markdown", "--folder-token", "--wiki-node", "--wiki-space"},
|
||||
},
|
||||
{
|
||||
name: "fetch",
|
||||
|
||||
40
skill-template/domains/approval.md
Normal file
40
skill-template/domains/approval.md
Normal file
@@ -0,0 +1,40 @@
|
||||
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
|
||||
|
||||
## 选哪个命令
|
||||
|
||||
| 想做什么 | 命令 |
|
||||
|---|---|
|
||||
| 搜可发起定义 | `approvals search` |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
|
||||
| 发起原生审批实例 | `instances create` |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
||||
| 看表单/进度/当前节点 | `instances get` |
|
||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
||||
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
|
||||
| 催办 | `tasks remind` |
|
||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
||||
|
||||
处理链:
|
||||
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
lark-cli approval approvals get --params '{"approval_code":"<code>"}' --as user
|
||||
lark-cli approval instances create --data '{"approval_code":"<code>","form":"[...]"}' --yes --as user
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
|
||||
```
|
||||
|
||||
## 发起原生审批
|
||||
|
||||
发起审批属于高风险写操作,按下表处理:
|
||||
|
||||
| 规则 | 处理 |
|
||||
|---|---|
|
||||
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
|
||||
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` |
|
||||
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
|
||||
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
|
||||
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` |
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-approval
|
||||
version: 1.1.0
|
||||
description: "飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例。审批待办不是飞书任务(任务类待办走 lark-task);不负责创建审批定义和发起新审批。"
|
||||
version: 1.2.0
|
||||
description: "飞书审批:查询和处理审批待办/已办/实例,搜索可发起审批定义、查看定义详情并发起原生审批实例。当用户要处理审批任务、查看审批实例、搜索或发起审批时使用。审批待办不是飞书任务;非审批类待办走 lark-task。不负责创建审批定义;三方审批定义不走原生提单。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -16,6 +16,9 @@ metadata:
|
||||
|
||||
| 想做什么 | 命令 |
|
||||
|---|---|
|
||||
| 搜可发起定义 | `approvals search` |
|
||||
| 看审批定义详情/提单前确认表单与流程 | `approvals get` |
|
||||
| 发起原生审批实例 | `instances create` |
|
||||
| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)|
|
||||
| 看表单/进度/当前节点 | `instances get` |
|
||||
| 同意/拒绝 | `tasks approve` / `tasks reject` |
|
||||
@@ -23,13 +26,31 @@ metadata:
|
||||
| 催办 | `tasks remind` |
|
||||
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
|
||||
|
||||
处理链:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作。
|
||||
处理链:
|
||||
|
||||
- 发起审批:`approvals search` -> `approvals get` -> `instances.create`
|
||||
- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作
|
||||
|
||||
```bash
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}' --as user
|
||||
lark-cli approval approvals get --params '{"approval_code":"<code>"}' --as user
|
||||
lark-cli approval instances create --data '{"approval_code":"<code>","form":"[...]"}' --yes --as user
|
||||
lark-cli approval tasks query --params '{"topic":"1"}' --as user
|
||||
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
|
||||
```
|
||||
|
||||
## 发起原生审批
|
||||
|
||||
发起审批属于高风险写操作,按下表处理:
|
||||
|
||||
| 规则 | 处理 |
|
||||
|---|---|
|
||||
| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` |
|
||||
| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` |
|
||||
| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 |
|
||||
| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) |
|
||||
| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` |
|
||||
|
||||
## 不在本 skill 范围
|
||||
|
||||
创建审批定义/发起新审批(走飞书客户端或审批管理后台);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
||||
创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)
|
||||
|
||||
196
skills/lark-approval/references/lark-approval-initiate.md
Normal file
196
skills/lark-approval/references/lark-approval-initiate.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# 审批提单工作流
|
||||
|
||||
## 执行摘要
|
||||
|
||||
- **原生审批提单必须固定走 `approvals.search` -> `approvals.get` -> `instances.create`。** 不要跳过 `get` 直接拼请求。
|
||||
- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances.create`,应优先使用 `create_link`。
|
||||
- **所有人员类参数默认使用 `open_id`。** 若用户给的是姓名、邮箱或其他身份,先用 [`../../lark-contact/SKILL.md`](../../lark-contact/SKILL.md) 解析。
|
||||
- **先读控件参数 reference 和值来源 reference,再看 `schema`。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create`。
|
||||
- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id`、`type`、选项值范围和明细子控件结构;真正的 `instances.create.data.form` 中,请求字段与节点字段以 `schema` / `meta` 为准,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。
|
||||
- **节点参数只从 `node_list` 和 `schema` / `meta` 里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,要先与当前 `schema` 字段名和 ID 口径对齐,不要混用姓名或其他身份标识。
|
||||
- **看到 `need_approver=true` 就说明该节点需要发起人补充审批人。** 如果 `approver_chosen_multi=false`,该节点只允许一个 `open_id`。
|
||||
- **创建实例前先确认。** `approval instances create` 是写操作,真正执行时显式传 `--yes`。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- “帮我提交一个请假审批”
|
||||
- “帮我发起报销审批”
|
||||
- “我想提一个出差审批”
|
||||
- “先搜可发起的审批,再帮我提单”
|
||||
|
||||
## 严禁行为
|
||||
|
||||
- **严禁在未先查看 `schema` 的情况下猜测 `--data` 结构。**
|
||||
- **严禁在未先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)、[`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 且未先查看 `schema` 的情况下直接提单。**
|
||||
- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances.create`。
|
||||
- **严禁把姓名直接写进 `node_approver_list`、`node_cc_list` 或表单人员控件。** 必须先转成 `open_id`。
|
||||
- **严禁对三方定义调用 `instances.create`。**
|
||||
- **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。
|
||||
- **严禁把 `approvals.get.form` 当成可直接提交的原样模板。**
|
||||
- **严禁在未得到用户确认前直接执行真实提单。**
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 搜索可发起审批定义
|
||||
|
||||
先用 `schema` 看参数,再搜索定义:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.approvals.search
|
||||
lark-cli approval approvals search --data '{"keyword":"请假"}'
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
- 若结果为空,告诉用户当前关键词下没有可发起定义。
|
||||
- 若命中多个定义,必须把候选项列给用户选择,不要自行猜测。
|
||||
- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances.create`。
|
||||
- 只有 `is_external=false` 的原生定义才继续下一步。
|
||||
|
||||
### 2. 获取审批定义详情
|
||||
|
||||
拿到 `approval_code` 后,读取定义详情:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.approvals.get
|
||||
lark-cli approval approvals get \
|
||||
--params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}'
|
||||
```
|
||||
|
||||
重点关注返回:
|
||||
|
||||
- `approval_name`: 当前发起的是哪个审批定义。
|
||||
- `form`: 表单定义快照,用于识别控件 `id`、`type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。
|
||||
- `node_list`: 流程节点信息,是后续 `node_approver_list` / `node_cc_list` 的唯一可靠来源。
|
||||
|
||||
### 3. 组装 `form`
|
||||
|
||||
`instances.create.data.form` 是一个 JSON 数组字符串。组装原则:
|
||||
|
||||
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按 `schema` / `meta` 与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
|
||||
- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前 `schema` 要求;不要假设定义快照里出现的其他字段都能直接照搬。
|
||||
- 如果用户提供的是人员信息,优先转换成 `open_id` 后再写入对应控件。
|
||||
- 单选/多选控件提交的是选项 `value`,该值可从 `approvals.get.form` 的选项定义中取得。
|
||||
- `contact`、`department`、`fieldList`、`dateInterval`、`amount`、`telephone`、`document` 等控件的 `value` 结构各不相同,必须按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 单独组装,不要套用文本控件的写法。
|
||||
- 值本身从哪里拿,优先按 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 处理;不要把“知道结构”误当成“已经拿到可提交值”。
|
||||
- 若 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 标明某控件不支持通过创建实例 API 提交,则不要硬猜绕过;应明确告诉用户该定义当前无法仅通过 API 提单。
|
||||
- 若遇到当前 skill 未明确覆盖的复杂控件,不要硬猜;先依据 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 判断支持性与传值结构,再向用户确认。
|
||||
|
||||
## API 不支持的控件
|
||||
|
||||
根据 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md),创建审批实例 API 不支持的控件至少包括:
|
||||
|
||||
- `text`
|
||||
- `mutableGroup`
|
||||
- `account`
|
||||
- `serialNumber`
|
||||
- `tripGroup`
|
||||
- `apaascorehrOnboardingGroup`
|
||||
- `apaascorehrRegularateGroup`
|
||||
- `remedyGroupV2`
|
||||
- `apaascorehrJobAdjustGroup`
|
||||
- `apaascorehrOffboardingGroup`
|
||||
|
||||
如果目标审批定义包含上述控件,不要继续硬拼 `form`;应直接告诉用户该定义不能仅通过当前 API 完整提单。
|
||||
|
||||
## 高频控件速查
|
||||
|
||||
优先按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 组装,下面只保留最常用、最容易出错的格式:
|
||||
|
||||
- `input` / `textarea`: `value` 是字符串
|
||||
- `date`: `value` 是 RFC3339 时间字符串
|
||||
- `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval`
|
||||
- `radio` / `radioV2`: `value` 是单个选项值,取自定义详情里的 option.value;关联外部选项时传 `options.id`
|
||||
- `checkbox` / `checkboxV2`: `value` 是选项值数组
|
||||
- `number`: `value` 是数字
|
||||
- `amount`: `value` 是数字,还要带 `currency`
|
||||
- `formula`: `value` 必须与定义中的公式结果匹配,否则会报错
|
||||
- `contact`: 只推荐写 `open_ids`,由人员信息先转换成 `open_id`
|
||||
- `connect`: `value` 是关联审批实例 `instance_code` 数组,当前默认要求用户直接提供 `instance_code`
|
||||
- `document`: `value` 是对象,至少含 `token` 和 `type=docx`
|
||||
- `attachmentV2` / `image` / `imageV2`: `value` 是 file code 数组,当前默认要求用户直接提供
|
||||
- `fieldList`: `value` 是二维数组,子项继续按各自控件类型组装
|
||||
- `department`: `value` 是对象数组,元素字段名为 `open_id`,其值填写部门的 `open_department_id`
|
||||
- `telephone`: `value` 是对象,包含 `countryCode` 和 `nationalNumber`
|
||||
- `address`: `value` 是对象数组,至少包含地理库 `id`,可选 `detailAddress`;当前默认要求用户直接提供该 `id`
|
||||
|
||||
## 特殊控件组
|
||||
|
||||
[`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 还明确给出了若干特殊控件组的提单格式,至少包括:
|
||||
|
||||
- `leaveGroupV2`
|
||||
- `workGroup`
|
||||
- `outGroup`
|
||||
- `shiftGroup`
|
||||
|
||||
这类控件组不是简单文本控件,通常内部还嵌套 `radioV2`、`date`、`fieldList`、`image`、`contact` 等子控件。遇到这些控件组时:
|
||||
|
||||
- 先从 `approvals.get.form` 找到控件组及其子控件 ID
|
||||
- 再严格按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 的示例组装 `value`
|
||||
- 不要把控件组整体当成普通字符串或扁平对象提交
|
||||
|
||||
### 4. 组装节点参数
|
||||
|
||||
从 `node_list` 推导节点参数:
|
||||
|
||||
- 若某节点 `need_approver=true`,则必须在 `node_approver_list` 中补该节点的审批人。
|
||||
- `key` 优先取 `custom_node_id`;若不存在,再用 `node_id`。
|
||||
- `value` 是审批人 `open_id` 列表。
|
||||
- 若 `approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`。
|
||||
- `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。
|
||||
|
||||
### 5. 创建审批实例
|
||||
|
||||
先看 `schema`,确认最终结构后再执行:
|
||||
|
||||
```bash
|
||||
lark-cli schema approval.instances.create
|
||||
|
||||
lark-cli approval instances create \
|
||||
--data '{
|
||||
"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85",
|
||||
"form":"[{\"id\":\"widget1\",\"type\":\"input\",\"value\":\"请假半天\"}]",
|
||||
"node_approver_list":[
|
||||
{
|
||||
"key":"manager_node_id",
|
||||
"value":["ou_xxx"]
|
||||
}
|
||||
]
|
||||
}' \
|
||||
--yes
|
||||
```
|
||||
|
||||
执行规则:
|
||||
|
||||
- 执行前先向用户确认:目标审批定义、核心表单值、节点审批人/抄送人。
|
||||
- 若需要幂等,可补 `uuid`。
|
||||
- 成功后回报 `instance_code` 与 `instance_link`。
|
||||
|
||||
## 组装时优先依据的资料
|
||||
|
||||
优先级固定如下:
|
||||
|
||||
1. `lark-cli schema approval.instances.create` 与对应 `meta`:决定创建请求体有哪些字段、节点参数怎么传。
|
||||
2. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md):决定每种控件的 `value` 结构与支持范围。
|
||||
3. [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md):决定每类值应该从哪里拿,以及当前哪些值必须由用户直接提供。
|
||||
4. `approvals.get.form`:提供当前审批定义里实际有哪些控件、控件 `id`、控件 `type`、选项值范围、明细子控件结构。
|
||||
5. `approvals.get.node_list`:提供节点 key 与是否需要补充审批人/抄送人的线索。
|
||||
|
||||
不要反过来把 `approvals.get.form` 当成第一优先级,更不要把它当成可直接提交的 JSON 模板。
|
||||
|
||||
## 最小判断表
|
||||
|
||||
| 你手上有什么 | 下一步 |
|
||||
|---|---|
|
||||
| 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` |
|
||||
| 已经拿到 `approval_code` | 直接 `approvals.get` |
|
||||
| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances.create` |
|
||||
| `is_external=true` | 返回 `create_link`,不要调 `instances.create` |
|
||||
|
||||
## 返回结果
|
||||
|
||||
完成创建后,至少向用户返回:
|
||||
|
||||
- `approval_name`
|
||||
- `instance_code`
|
||||
- `instance_link`
|
||||
@@ -0,0 +1,606 @@
|
||||
# 审批实例表单控件参数
|
||||
|
||||
> 说明:本文尽量保留上游参数文档的原始结构与示例,用于回答“控件 `value` 长什么样”。
|
||||
> 当前 `lark-cli` 的推荐取值口径以 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 为准;如果两份文档在“值从哪里拿”上存在差异,以后者为准。
|
||||
|
||||
在调用创建审批实例接口时需要使用表单控件参数,你可以通过本文了解审批实例内各表单控件的参数说明。
|
||||
|
||||
## 准备工作
|
||||
|
||||
审批实例的表单控件参数依据审批定义表单来配置,例如,审批定义的表单设计包括了 **单行文本** 和 **日期区间** 控件,则审批实例的表单控件参数就需要为 **单行文本** 和 **日期区间** 控件进行赋值。因此,在操作审批实例表单的控件参数前,应先通过审批定义详情确认表单控件结构。
|
||||
|
||||
## 审批实例 API 不支持的控件
|
||||
|
||||
创建审批实例 API 未完全支持所有的审批表单控件,不支持的控件如下表所示。如果你必须使用 API 不支持的控件,则不能仅通过当前 API 完成提单。
|
||||
|
||||
**控件/控件组** | **Type** |
|
||||
| ---------- | --------------------------- |
|
||||
| 说明 | text |
|
||||
| 引用多维表格 | mutableGroup |
|
||||
| 收款账户 | account |
|
||||
| 流水号 | serialNumber |
|
||||
| 出差控件组 | tripGroup |
|
||||
| 录用控件组 | apaascorehrOnboardingGroup |
|
||||
| 转正控件组 | apaascorehrRegularateGroup |
|
||||
| 补卡控件组 | remedyGroupV2 |
|
||||
| 调岗控件组 | apaascorehrJobAdjustGroup |
|
||||
| 离职控件组 | apaascorehrOffboardingGroup
|
||||
|
||||
## 通用参数
|
||||
|
||||
审批实例的表单控件均包含的参数如下表所示。
|
||||
|
||||
参数 | 类型 | 是否必填 | 描述
|
||||
---|---|---|---
|
||||
id | string | 是 | 控件的 ID,需要与审批定义中的控件 ID 保持一致。
|
||||
type | string | 是 | 控件类型。各控件类型取值参见下文 **不同控件的参数** 章节。
|
||||
value | 不同控件的类型不同 | 是 | 控件的取值。不同控件 value 数据类型也不同,例如单行文本控件的 value 为字符串、联系人的 value 为数组。详情参见下文 **不同控件的参数** 章节。
|
||||
|
||||
## 不同控件的参数
|
||||
|
||||
本章节提供不同控件的 type 参数值、JSON 示例以及非通用参数说明。
|
||||
|
||||
### 单行文本
|
||||
|
||||
控件 type 为 input,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "input",
|
||||
"value": "data" // string 类型
|
||||
}
|
||||
```
|
||||
|
||||
### 多行文本
|
||||
|
||||
控件 type 为 textarea,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "textarea",
|
||||
"value": "data" // string 类型
|
||||
}
|
||||
```
|
||||
|
||||
### 日期
|
||||
|
||||
控件 type 为 date,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "date",
|
||||
"value": "2019-10-01T08:12:01+08:00" // 需满足 RFC3339 格式的 string 类型
|
||||
}
|
||||
```
|
||||
|
||||
### 日期区间
|
||||
|
||||
控件 type 为 dateInterval,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "dateInterval",
|
||||
"value": {
|
||||
"start":"2019-10-01T08:12:01+08:00",
|
||||
"end":"2019-10-02T08:12:01+08:00",
|
||||
"interval": 1.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
value 参数为 object 类型,包含参数说明:
|
||||
|
||||
参数 | 类型 | 是否必填 | 描述
|
||||
---|---|---|---
|
||||
start | string | 是 | 开始时间,需满足 RFC3339 格式。
|
||||
end | string | 是 | 结束时间,需满足 RFC3339 格式。
|
||||
interval | float | 是 | 时长(天)。
|
||||
|
||||
### 单选
|
||||
|
||||
控件 type 为 radio/radioV2,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "radioV2",
|
||||
"value": "k2b8mkx0-h71x5gl1234-1" // string 类型
|
||||
}
|
||||
```
|
||||
|
||||
其中, value 表示选项值,取值范围需要参考相应审批定义中 **单选** 控件 option 的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取单选控件 option 的 value 取值。如果控件关联了外部选项,则 value 需要传入外部选项的 `options.id`。
|
||||
|
||||
### 多选
|
||||
|
||||
控件 type 为 checkbox/checkboxV2,JSON数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id":"widget1",
|
||||
"type":"checkboxV2",
|
||||
"value": ["k2b8mkx0-h71x5gl4321-1"] // string 类型的数组
|
||||
}
|
||||
```
|
||||
其中, value 表示选项值,取值范围需要参考相应审批定义中 **多选** 控件 option 的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取多选控件 option 的 value 取值。如果控件关联了外部选项,则 value 需要传入外部选项的 `options.id`。
|
||||
|
||||
### 数字
|
||||
|
||||
控件 type 为 number,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "number",
|
||||
"value": 1234.5678 // float 类型
|
||||
}
|
||||
```
|
||||
|
||||
### 金额
|
||||
|
||||
控件 type 为 amount,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "amount",
|
||||
"value": 1234.5678, // float 类型
|
||||
"currency":"USD"
|
||||
}
|
||||
```
|
||||
|
||||
其中,currency 表示货币种类,取值范围需要参考相应审批定义中 **金额** 控件的 value 参数。你可以通过审批定义详情返回的 `form` 参数,获取金额控件可设置的货币种类。
|
||||
|
||||
### 计算公式
|
||||
|
||||
控件 type 为 formula,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "formula",
|
||||
"value": 1234.5678 // 该值由审批定义内配置的公式计算出取值,若不匹配则返回报错。
|
||||
}
|
||||
```
|
||||
|
||||
### 联系人
|
||||
|
||||
控件 type 为 contact,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id":"widget1",
|
||||
"type":"contact",
|
||||
"value": ["f8ca557e"], // string 类型的数组
|
||||
"open_ids": ["ou_12345"] // string 类型的数组
|
||||
}
|
||||
```
|
||||
其中,value 包含的是用户 `user_id`;open_ids 包含的是用户 `open_id`。
|
||||
|
||||
### 关联审批
|
||||
|
||||
控件 type 为 connect,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id":"widget1",
|
||||
"type":"connect",
|
||||
"value": ["19EAC829-F1CB-527F-BE2A-1330422E60C0"] // string 类型的数组
|
||||
}
|
||||
```
|
||||
其中,value 包含的是被关联的审批实例 Code,你可以通过审批实例详情能力根据实例 Code 获取实例详情。
|
||||
|
||||
### 文档控件
|
||||
|
||||
控件 type 为 document,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "document",
|
||||
"value": {
|
||||
"token":"TLLKdcpDro9ijQxA33ycNMabcef",
|
||||
"type":"docx",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
value 参数为 object 类型,包含参数说明:
|
||||
|
||||
参数 | 类型 | 是否必填 | 描述
|
||||
---|---|---|---
|
||||
token | string | 是 | 文档的 document_id。
|
||||
type | string | 是 | 文档类型,支持 `docx`。
|
||||
|
||||
### 附件
|
||||
|
||||
控件 type 为 attachmentV2,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id":"widget1",
|
||||
"type":"attachmentV2",
|
||||
"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] // string 类型的数组
|
||||
}
|
||||
```
|
||||
其中,value 包含的是上传文件后返回的文件 code。
|
||||
|
||||
### 图片
|
||||
|
||||
控件 type 为 image/imageV2,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id":"widget1",
|
||||
"type":"image",
|
||||
"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"] // string 类型的数组
|
||||
}
|
||||
```
|
||||
|
||||
其中,value 包含的是上传文件后返回的文件 code。
|
||||
|
||||
### 明细/表格
|
||||
|
||||
控件 type 为 fieldList,JSON 格式示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "fieldList",
|
||||
"value": [
|
||||
[
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "checkbox",
|
||||
"value": ["jxpsebqp-0"]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
其中 value 是二维数组,根据审批定义内 **明细/表格** 控件所包含的控件,依次设置控件 JSON 值。
|
||||
|
||||
### 部门
|
||||
|
||||
控件 type 为 department,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id":"widget1",
|
||||
"type":"department",
|
||||
"value":[
|
||||
{
|
||||
"open_id": "od-xxx"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
其中 value 为对象数组,通过 open_id 设置部门的 open_department_id。
|
||||
|
||||
### 电话
|
||||
|
||||
控件 type 为 telephone,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id":"widget1",
|
||||
"type":"telephone",
|
||||
"value": {
|
||||
"countryCode":"+86",
|
||||
"nationalNumber":"13122222222"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
value 参数为 object 类型,包含参数说明:
|
||||
|
||||
参数 | 类型 | 是否必填 | 描述
|
||||
---|---|---|---
|
||||
countryCode | string | 是 | 区号。
|
||||
nationalNumber | string | 是 | 电话号。
|
||||
|
||||
### 地址
|
||||
控件 type 为 address,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "address",
|
||||
"value": [{
|
||||
"id": "290557",
|
||||
"detailAddress": "详细的地址"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
value 参数为 []object 类型,参数说明如下:
|
||||
|
||||
参数 | 类型 | 是否必填 | 描述
|
||||
---|---|---|---
|
||||
value | []object | 是 | 非出差控件组场景地址控件仅支持单个地址,传入多个时默认只取第一个
|
||||
└ id | string | 是 | 区域ID, 可通过审批的地理库接口获取
|
||||
└ detailAddress | string | 否 | 详细的地址,若表单配置中未开启填写详细地址,则会忽略该参数,即使传入也不会生效
|
||||
|
||||
### 换班控件组
|
||||
|
||||
控件 type 为 shiftGroup,JSON 数据示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "widget1",
|
||||
"type": "shiftGroup",
|
||||
"value": {
|
||||
"shiftTime": "2019-10-01T08:12:01+08:00",
|
||||
"returnTime": "2019-10-02T08:12:01+08:00",
|
||||
"reason": "ask for leave"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
value 参数为 object 类型,包含参数说明:
|
||||
|
||||
参数 | 类型 | 是否必填 | 描述
|
||||
---|---|---|---
|
||||
shiftTime | string | 是 | 换班时间,需满足 RFC3339 格式。
|
||||
returnTime | string | 是 | 对调日期,需满足 RFC3339 格式。
|
||||
reason | string | 是 | 换班原因。
|
||||
|
||||
### 请假控件组
|
||||
|
||||
**请假控件组请求示例**
|
||||
```json
|
||||
{
|
||||
"id": "widgetLeaveGroupV2",
|
||||
"type": "leaveGroupV2",
|
||||
"value": [
|
||||
{
|
||||
"id": "widgetLeaveGroupType",
|
||||
"type": "radioV2",
|
||||
"value": "7488925543484620819"
|
||||
},
|
||||
{
|
||||
"id": "widgetLeaveGroupStartTime",
|
||||
"type": "date",
|
||||
"value": "2025-08-25T11:30:00+08:00"
|
||||
},
|
||||
{
|
||||
"id": "widgetLeaveGroupEndTime",
|
||||
"type": "date",
|
||||
"value": "2025-08-26T11:35:00+08:00"
|
||||
},
|
||||
{
|
||||
"id": "widgetLeaveGroupReason",
|
||||
"type": "textarea",
|
||||
"value": "123123"
|
||||
},
|
||||
{
|
||||
"id": "widgetLeaveCertification",
|
||||
"type": "image",
|
||||
"value": [
|
||||
"B69F8E26-0EAA-4A92-9B80-DA613CD36136"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id":"widgetLeaveCertification",
|
||||
"type":"image",
|
||||
"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]
|
||||
},
|
||||
{
|
||||
"id": "widgetLeaveGroupFeedingArrivingLate",
|
||||
"type": "radioV2",
|
||||
"value": "30"
|
||||
},
|
||||
{
|
||||
"id": "widgetLeaveGroupFeedingOffLeaveEarly",
|
||||
"type": "radioV2",
|
||||
"value": "30"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**请假控件组包含参数说明:**
|
||||
|
||||
id | 类型 | JSON示例 | 描述
|
||||
---|---|---|---
|
||||
id | string | 是 | 控件组ID,固定为widgetLeaveGroupV2
|
||||
type | string | 是 | 控件组类型,固定为leaveGroupV2
|
||||
value | object[] | 是 | 控件组的值,值为多个子控件值的列表
|
||||
|
||||
value中包含的子控件值说明:
|
||||
|
||||
id | 类型 | JSON示例 | 描述
|
||||
---|---|---|---
|
||||
widgetLeaveGroupType | radioV2 | ```<br>{<br>"id": "widgetLeaveGroupType",<br>"type": "radioV2",<br>"value": "7488925543484620819"<br>}<br>``` | 假期类型,具体格式可参考单选控件,选项由假勤接口获取,提单时必须包含该控件
|
||||
widgetLeaveGroupStartTime | date | ```<br>{<br>"id": "widgetLeaveGroupStartTime",<br>"type": "date",<br>"value": "2019-10-01T08:12:01+08:00", // 需满足 RFC3339 格式的 string 类型<br>} <br>``` | 请假开始时间,具体格式可参考日期控件,会根据假期类型自动取整,其中半天假小于12点则认为是上午,小时假则以半小时为粒度向前取整, 提单时必须包含该控件
|
||||
widgetLeaveGroupEndTime | date | ```<br>{<br>"id": "widgetLeaveGroupEndTime",<br>"type": "date",<br>"value": "2019-10-01T08:12:01+08:00", // 需满足 RFC3339 格式的 string 类型<br>}<br>``` | 请假结束时间,具体格式可参考日期控件,会根据假期类型自动取整,其中半天假小于12点则认为是上午,小时假则以半小时为粒度向后取整
|
||||
widgetLeaveGroupReason | textarea | ```<br>{<br>"id": "widgetLeaveGroupReason",<br>"type": "textarea",<br>"value": "123123"<br>}<br>``` | 请假事由,具体格式可参考多行文本控件,哺乳假无需填写,其他情况则根据控件组配置中该控件是否可见以及必填判断
|
||||
widgetLeaveCertification | image | ```<br>{<br>"id":"widgetLeaveCertification",<br>"type":"image",<br>"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]<br>}<br>``` | 请假证明,具体格式可参考图片控件,如果所选假期类型配置要求补充证明则必须传递该值,缺失会报错
|
||||
widgetLeaveGroupFeedingArrivingLate | radioV2 | ```<br>{ <br>"id": "widgetLeaveGroupFeedingArrivingLate",<br>"type": "radioV2",<br>"value": "30"<br>}<br>``` | 上班晚到的分钟数,具体格式可参考单选控件,仅哺乳假需要填写,取值范围是0-120分钟,粒度是15分钟,选项从审批定义中该控件的option中获取
|
||||
widgetLeaveGroupFeedingOffLeaveEarly | radioV2 | ```<br>{ <br>"id": "widgetLeaveGroupFeedingOffLeaveEarly",<br>"type": "radioV2",<br>"value": "30"<br>} <br>``` | 下班早走的分钟数,具体格式可参考单选控件,仅哺乳假需要填写,取值范围是0-120分钟,粒度是15分钟,选项即是分钟对应的字符串
|
||||
|
||||
**特殊的参数校验报错信息**
|
||||
message | 说明 |
|
||||
| -------------------------------------------------- | ---------------------------- |
|
||||
| leave type id parse error | 请假类型不是int64 |
|
||||
| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 |
|
||||
| start time format is not RFC3339 | 开始时间日期格式非*RFC3339格式* |
|
||||
| end time format is not RFC3339 | 结束时间日期格式非*RFC3339格式* |
|
||||
| start time is after end time | 开始时间晚于结束时间 |
|
||||
| user not in gray | 申请用户不在假勤灰度内 |
|
||||
| leave type not found | 请假类型不存在 |
|
||||
| reason is required | 请假原因未填写 |
|
||||
| leave quote should be bigger than 0 | 请假时长需要大于0 |
|
||||
| leave is conflict | 所选时间内已有请假记录,请选择其他时间 |
|
||||
| balance is not enough | 当前假期类型下假期余额不足 |
|
||||
| certification is required | 需要上传请假证明 |
|
||||
| arriving late is required | 哺乳假需要填写上班晚到时长 |
|
||||
| arriving late value is not in the optional items | 晚到时间不在可选范围内 |
|
||||
| leaving early is required | 哺乳假需要填写下班提前时长 |
|
||||
| leaving early value is not in the optional items | 下班提前时间不在可选范围内 |
|
||||
| feeding rest daily is 0 | 哺乳假每日休息时长为0,请重新选择 |
|
||||
| the operation is prohibited by the workforce rules | 当前账户已在假勤侧封账,无法提交
|
||||
|
||||
### 加班控件组
|
||||
|
||||
**加班控件组请求示例**
|
||||
```json
|
||||
{
|
||||
"id": "widgetWorkGroup",
|
||||
"type": "workGroup",
|
||||
"value":[
|
||||
{
|
||||
"id":"widgetWorkGroupOvertimeWorkers",
|
||||
"type":"contact",
|
||||
"value": ["f8ca557e"],
|
||||
"open_ids": ["ou_12345"]
|
||||
},
|
||||
{
|
||||
"id": "widgetWorkGroupType",
|
||||
"type": "radioV2",
|
||||
"value": "7259635026038505475"
|
||||
},
|
||||
{
|
||||
"id":"widgetWorkGroupTimeRangeFieldList",
|
||||
"type":"fieldList",
|
||||
"value":[
|
||||
[
|
||||
{
|
||||
"id":"widgetWorkGroupStartTime",
|
||||
"type":"date",
|
||||
"value":"2019-10-01T08:12:01+08:00"
|
||||
},
|
||||
{
|
||||
"id":"widgetWorkGroupEndTime",
|
||||
"type":"date",
|
||||
"value":"2019-10-01T08:12:01+08:00"
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "widgetWorkGroupReason",
|
||||
"type": "textarea",
|
||||
"value": "111"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**加班控件组参数说明:**
|
||||
|
||||
参数 | 类型 | 是否必填 | 描述
|
||||
---|---|---|---
|
||||
id | string | 是 | 控件组ID,固定为widgetWorkGroup
|
||||
type | string | 是 | 控件组类型,固定为workGroup
|
||||
value | object[] | 是 | 控件组的值,值为多个子控件值的列表
|
||||
|
||||
value中包含的子控件值说明:
|
||||
|
||||
id | 类型 | JSON示例 | 描述
|
||||
---|---|---|---
|
||||
widgetWorkGroupOvertimeWorkers | contact | ```<br>{<br>"id":"widgetWorkGroupOvertimeWorkers",<br>"type":"contact",<br>"value": ["f8ca557e"], <br>"open_ids": ["ou_12345"]<br>}<br>``` | 加班人员列表,具体格式可参考联系人控件,如果定义中配置「允许代多人提交」则该字段必填,如果是提交人给自己提交需填写提交人的ID
|
||||
widgetWorkGroupType | radioV2 | ```<br>{<br>"id": "widgetWorkGroupType",<br>"type": "radioV2",<br>"value": "7259635026038505475" // 对应的类型选项ID<br>}<br>``` | 加班类型,具体格式可参考单选控件,如果定义中关闭「关联加班规则」则需要填写该字段
|
||||
widgetWorkGroupTimeRangeFieldList | fieldList | ```<br>{<br>"id":"widgetWorkGroupTimeRangeFieldList",<br>"type":"fieldList",<br>"value":[<br>[<br>{<br>"id":"widgetWorkGroupStartTime",<br>"type":"date",<br>"value":"2019-10-01T08:12:01+08:00"<br>},<br>{<br>"id":"widgetWorkGroupEndTime",<br>"type":"date",<br>"value":"2019-10-01T08:12:01+08:00"<br>}<br>]<br>]<br>}<br>``` | 加班时段,具体格式可参考明细控件,如果定义中打开「允许提交多个加班时段」则可以传多个,最多支持30个,否则只会取第一个,单次加班时长不可超过两天
|
||||
widgetWorkGroupReason | textarea | ```<br>{<br>"id": "widgetWorkGroupReason",<br>"type": "textarea",<br>"value": "111"<br>}<br>``` | 加班事由,如果定义中配置了「加班事由」必填,则必须填写该字段
|
||||
|
||||
**特殊的参数校验报错信息**
|
||||
message | 说明 |
|
||||
| ---------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| the time range list has more than 30 items | 加班时段数量超过30 |
|
||||
| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 |
|
||||
| overtime type is required | 未关联加班规则时,加班类型必填 |
|
||||
| work time range is required | 至少需要一个加班时段 |
|
||||
| start time is after end time | 开始时间晚于结束时间 |
|
||||
| start time or end time of range is required | 加班时间段的开始时间和结束时间必填 |
|
||||
| overtime duration is over 2 days | 单次加班时长不可超过两天 |
|
||||
| overtime date time zone not support | 加班时段的日期时区信息无法识别 |
|
||||
| {date} can not apply overtime | 所选时间不可申请加班 |
|
||||
| {date} already apply overtime | 所选时间已经有加班记录 |
|
||||
| {date} no need approval | 所选日期加班无需申请 |
|
||||
| apply reason is required | 定义中设置了加班事由为必填,不可为空 |
|
||||
| {users} user follow different overtime rules, cannot be submitted in the same form | 所选加班人不在同一个考勤组内,无法同时提交加班 |
|
||||
| invalid overtime work application | 没有有效的加班申请,请重新选择加班日期 |
|
||||
| the overtime duration cannot be 0 | 加班时长不能是0 |
|
||||
| the number of apply workers cannot exceed 50 | 单次申请加班人数量不可大于50 |
|
||||
| apply worker is required | 必须有加班人,配置置可代多人提交时必须指定加班人 |
|
||||
| resigned worker can not apply | 离职人员不可申请加班 |
|
||||
| overtime duration is over limit | 加班时长超过限制
|
||||
|
||||
### 外出控件组
|
||||
|
||||
**外出控件组请求体示例**
|
||||
```json
|
||||
{
|
||||
"id": "widgetOutGroup",
|
||||
"type": "outGroup",
|
||||
"value":[
|
||||
{
|
||||
"id": "widgetOutGroupType",
|
||||
"type": "radioV2",
|
||||
"value": "me15yqrf-gmjgbml2vhp-0"
|
||||
},
|
||||
{
|
||||
"id": "widgetOutGroupStartTime",
|
||||
"type": "date",
|
||||
"value":"2019-10-01T08:12:01+08:00"
|
||||
},
|
||||
{
|
||||
"id": "widgetOutGroupEndTime",
|
||||
"type": "date",
|
||||
"value":"2019-10-01T08:12:01+08:00"
|
||||
},
|
||||
{
|
||||
"id": "widgetOutGroupReason",
|
||||
"type": "textarea",
|
||||
"value":"123213"
|
||||
},
|
||||
{
|
||||
"id":"widgetOutGroupImage",
|
||||
"type":"image",
|
||||
"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**外出控件参数说明**
|
||||
|
||||
参数 | 类型 | 是否必填 | 描述
|
||||
---|---|---|---
|
||||
id | string | 是 | 控件组ID,固定为widgetOutGroup
|
||||
type | string | 是 | 控件组Type,固定为outGroup
|
||||
value | object[] | 是 | 控件组的值,值为多个子控件值的列表
|
||||
|
||||
value中包含的子控件值说明:
|
||||
|
||||
id | 类型 | JSON示例 | 描述
|
||||
---|---|---|---
|
||||
widgetOutGroupType | radioV2 | ```<br>{<br>"id": "widgetOutGroupType",<br>"type": "radioV2",<br>"value": "me15yqrf-gmjgbml2vhp-0" <br>}<br>``` | 外出类型,具体格式可参考单选控件,如果配置了「外出类型」则必填,外出时长单位会选取所选外出类型关联的单位,如果没有配置「外出类型」,则该字段无需填写,计算外出时长时会选取「外出时长」配置的单位
|
||||
widgetOutGroupStartTime | date | ```<br>{<br>"id": "widgetOutGroupStartTime",<br>"type": "date",<br>"value":"2019-10-01T08:12:01+08:00"<br>}<br>``` | 外出开始时间,具体格式可参考日期控件,如果外出时长单位是半天假,则小于12点则认为是上午,否则认为是下午;如果单位是小时,则会按半小时的粒度向前取整
|
||||
widgetOutGroupEndTime | date | ```<br>{<br>"id": "widgetOutGroupEndTime",<br>"type": "date",<br>"value":"2019-10-01T08:12:01+08:00"<br>}<br>``` | 外出结束时间,具体格式可参考日期控件,如果外出时长单位是半天假,则小于12点则认为是上午,否则认为是下午;如果单位是小时,则会按半小时的粒度向后取整
|
||||
widgetOutGroupReason | textarea | ```<br>{<br>"id": "widgetOutGroupReason",<br>"type": "textarea",<br>"value":"123213"<br>}<br>``` | 外出事由,具体格式可参考多行文本控件,如果定义中「外出事由」必填,则必须填写该控件,如果定义配置无需填写,则无需填写该控件
|
||||
widgetOutGroupImage | image | ```<br>{<br>"id":"widgetOutGroupImage",<br>"type":"image",<br>"value": ["D93653C3-2609-4EE0-8041-61DC1D84F0B5"]<br>} <br>``` | 外出证明,具体格式可参考图片控件,如果定义中「外出拍照」必填,则必须填写该控件,如果定义配置无需填写,则无需填写该控件
|
||||
|
||||
**特殊的参数校验报错信息**
|
||||
|
||||
message | 说明 |
|
||||
| ----------------------------------------------------- | ---------------------------- |
|
||||
| group value is invalid | 当前控件组的值无效,请校验是否为空或者校验类型是否为数组 |
|
||||
| start time format is not RFC3339 | 开始时间日期格式非*RFC3339格式* |
|
||||
| end time format is not RFC3339 | 结束时间日期格式非*RFC3339格式* |
|
||||
| start time and end time must be in the same time zone | 开始时间与结束时间必须是同一时区 |
|
||||
| out type is required | 如果定义中设定了「外出类型」,则外出类型必填 |
|
||||
| out start time is required | 外出开始时间必填 |
|
||||
| out end time is required | 外出结束时间必填 |
|
||||
| out duration must be greater than 0 | 外出间隔不能为0,请检查起止时间并重新选择 |
|
||||
| out reason is empty | 如果定义中勾选「外出事由」同时设定必填,则该字段必填 |
|
||||
| photo is required | 如果定义中勾选「外出拍照」同时设定必填,则该字段必填 |
|
||||
| out time is conflict | 外出时间有冲突,请确认是否已在该时段申请外出
|
||||
@@ -0,0 +1,108 @@
|
||||
# 审批提单值来源
|
||||
|
||||
## 目的
|
||||
|
||||
本文用于回答一个固定问题:在调用 `approval instances create` 发起原生审批实例时,**每个要填写的值从哪里拿**。
|
||||
|
||||
阅读顺序固定如下:
|
||||
|
||||
1. `lark-cli schema approval.instances.create`
|
||||
2. `approval approvals get` 返回的 `form` / `node_list`
|
||||
3. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)
|
||||
4. 本文
|
||||
|
||||
## 总原则
|
||||
|
||||
- `schema` / `meta` 决定请求字段名、字段层级、节点参数结构。
|
||||
- `approvals.get.form` 决定控件 `id`、`type`、选项值范围、子控件结构。
|
||||
- `approvals.get.node_list` 决定节点 key、是否必须补审批人、是否允许多人。
|
||||
- [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 决定各控件 `value` 的最终结构。
|
||||
- 除非本文明确允许,否则不要猜值来源,不要把展示文案直接当成可提交值。
|
||||
|
||||
## 默认来源
|
||||
|
||||
- 审批定义、`approval_code`、`is_external`、`create_link` 等基础信息,默认从 `approval approvals search` 获取。
|
||||
- 控件 `id`、`type`、选项值、子控件结构,默认从 `approval approvals get.form` 获取。
|
||||
- 节点 key、`need_approver`、`approver_chosen_multi` 等节点信息,默认从 `approval approvals get.node_list` 获取。
|
||||
- 本文只补充 **这些默认来源之外** 的取值规则,以及当前必须由用户直接提供的值。
|
||||
|
||||
## 控件值来源规则
|
||||
|
||||
### 联系人 `contact`
|
||||
|
||||
- 只推荐写 `open_ids`。
|
||||
- 不再推荐双写 `value(user_id)` + `open_ids`,避免复杂度继续上升。
|
||||
- 如果用户给的是姓名、邮箱或账号,先用 `lark-contact` 解析成 `open_id`。
|
||||
|
||||
### 部门 `department`
|
||||
|
||||
- 最优先:用户直接提供 `open_department_id`。
|
||||
- 若用户说“我的部门”或“张三的部门”,先用 `lark-contact` 查询对应人员信息,再取其所属部门里的 `open_department_id`。
|
||||
- 如果查到该人员只有一个部门,可直接使用。
|
||||
- 如果查到多个部门,不自动猜,必须让用户明确选一个,或直接输入 `open_department_id`。
|
||||
- 如果仍无法确定,则明确告知当前不支持自动决定部门值。
|
||||
|
||||
### 附件 `attachmentV2`
|
||||
|
||||
- 当前 `lark-approval` 不负责上传文件。
|
||||
- 用户必须直接提供 file code。
|
||||
- 如果用户无法提供 file code,应明确告知当前无法仅通过 `lark-approval` 完成该控件提单。
|
||||
|
||||
### 图片 `image` / `imageV2`
|
||||
|
||||
- 当前 `lark-approval` 不负责上传图片。
|
||||
- 用户必须直接提供 file code。
|
||||
- 如果用户无法提供 file code,应明确告知当前无法仅通过 `lark-approval` 完成该控件提单。
|
||||
|
||||
### 文档 `document`
|
||||
|
||||
- 用户可直接提供 `token` / `document_id`。
|
||||
- 如果用户给的是飞书文档链接,应先尝试从链接中提取 token。
|
||||
- 若链接提取失败,再要求用户手动输入 token。
|
||||
|
||||
### 关联审批 `connect`
|
||||
|
||||
- 用户直接提供目标审批实例的 `instance_code`。
|
||||
- 当前不默认做“搜索关联实例再反查 code”的自动流程。
|
||||
|
||||
### 地址 `address`
|
||||
|
||||
- 用户直接提供地理库 `id`。
|
||||
- 若用户无法提供该 `id`,当前不支持自动取值。
|
||||
|
||||
## 特殊控件组
|
||||
|
||||
以下控件组的结构仍按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 组装:
|
||||
|
||||
- `leaveGroupV2`
|
||||
- `workGroup`
|
||||
- `outGroup`
|
||||
- `shiftGroup`
|
||||
|
||||
补充规则:
|
||||
|
||||
- 控件组自身和子控件的 `id` / `type` 从 `approval approvals get.form` 中识别。
|
||||
- 组内单选/多选或业务枚举值,优先从 `approval approvals get.form` 返回的选项结构中取。
|
||||
- 不要把控件组整体当成普通字符串或扁平对象提交。
|
||||
|
||||
## 不支持自动准备的值
|
||||
|
||||
以下值当前不建议由 `lark-approval` 自动准备:
|
||||
|
||||
- 文件上传后的 file code
|
||||
- 图片上传后的 file code
|
||||
- 地址控件的地理库 `id`
|
||||
- 无法唯一确定的部门 `open_department_id`
|
||||
|
||||
遇到这类值时,应明确告诉用户需要提供什么,而不是继续猜测。
|
||||
|
||||
## 最小决策表
|
||||
|
||||
| 场景 | 处理 |
|
||||
|---|---|
|
||||
| 用户说“找张三当审批人” | 用 `lark-contact` 解析张三,取 `open_id` |
|
||||
| 用户说“我的部门” | 先查当前用户部门;若多个部门,让用户选 |
|
||||
| 用户给了文档链接 | 先尝试提取 token |
|
||||
| 用户要填图片/附件 | 要求直接提供 file code |
|
||||
| 用户要填关联审批 | 要求直接提供 `instance_code` |
|
||||
| 用户要填地址 | 要求直接提供地理库 `id` |
|
||||
@@ -26,8 +26,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch --api-version v2`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
4. **需要使用 callout、grid、table、whiteboard 等富 block 时** → 参考 [`lark-doc-style.md`](references/style/lark-doc-style.md) 的元素能力说明。该文件不是固定模板或强制排版规范;除非用户明确要求美化、重排版或特定风格,不要为了“达标”主动套用固定结构。
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)(元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
@@ -16,16 +15,10 @@
|
||||
|
||||
```bash
|
||||
# 创建 XML 文档(默认格式,推荐)
|
||||
lark-cli docs +create --api-version v2 --content '<title>项目计划</title><h1>目标</h1><ul><li>目标 1</li><li>目标 2</li></ul>'
|
||||
lark-cli docs +create --api-version v2 --content '<title>项目计划</title><h1>目标</h1><p>记录本周重点。</p>'
|
||||
|
||||
# 创建到指定文件夹(XML)
|
||||
lark-cli docs +create --api-version v2 --parent-token fldcnXXXX --content '<title>标题</title><p>首段内容</p>'
|
||||
|
||||
# 创建到个人知识库(XML)
|
||||
lark-cli docs +create --api-version v2 --parent-position my_library --content '<title>标题</title><p>内容</p>'
|
||||
|
||||
# 仅当用户明确要求时才使用 Markdown;文档标题必须是开头唯一的一级标题,正文从二级标题开始
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项目计划\n\n## 目标\n\n- 目标 1\n- 目标 2'
|
||||
# 仅当用户明确要求导入 Markdown 时才使用;文档标题用 --title,正文标题按内容自然组织
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --title "项目计划" --content $'## 目标\n\n- 明确重点\n- 记录待办'
|
||||
```
|
||||
|
||||
## 返回值
|
||||
@@ -36,9 +29,9 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"document": {
|
||||
"document_id": "doxcnXXXXXXXXXXXXXXXXXXX",
|
||||
"document_id": "docx_token",
|
||||
"revision_id": 1,
|
||||
"url": "https://xxx.feishu.cn/docx/doxcnXXXXXXXXXXXXXXXXXXX",
|
||||
"url": "https://xxx.feishu.cn/docx/docx_token",
|
||||
"new_blocks": [
|
||||
{ "block_id": "blkcnXXXX", "block_type": "whiteboard", "block_token": "boardXXXX" }
|
||||
]
|
||||
@@ -66,15 +59,15 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
|
||||
| 参数 | 必填 | 说明 |
|
||||
| ------------------- | -- |---------------------------------------------|
|
||||
| `--api-version` | 是 | 固定传 `v2` |
|
||||
| `--content` | 是 | 文档内容(XML 或 Markdown 格式) |
|
||||
| `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `<title>...</title>`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 |
|
||||
| `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
| `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) |
|
||||
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- 文档标题从内容中自动提取:XML 使用 `<title>`;Markdown 使用文档开头唯一的一级标题(`# 标题`),正文从 `##` 开始。不要在内容开头重复写标题,也不要在 Markdown 正文中使用多个一级标题。
|
||||
- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容。
|
||||
- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容
|
||||
- **表达形式**:由用户目标和内容决定。需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要默认套用固定开头、固定富 block 比例或固定图表
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -99,7 +99,7 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
|
||||
|------|------|------|
|
||||
| `--api-version` | 是 | 固定传 `v2` |
|
||||
| `--doc` | 是 | 文档 URL 或 token(支持 `/docx/` 和 `/wiki/`) |
|
||||
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `text` \| `im-markdown`(仅用于获取内容后在 `lark-im` 场景下使用) |
|
||||
| `--doc-format` | 否 | `xml`(默认)\| `markdown` \| `im-markdown`(仅用于获取内容后在 `lark-im` 场景下使用) |
|
||||
| `--detail` | 否 | `simple`(默认)\| `with-ids` \| `full` |
|
||||
| `--revision-id` | 否 | 文档版本号,`-1` = 最新(默认) |
|
||||
| `--scope` | 否 | `outline` \| `range` \| `keyword` \| `section`(省略 = 读整篇) |
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
`docs +fetch --api-version v2` / `docs +create --api-version v2` / `docs +update --api-version v2` 使用 `--doc-format markdown` 时适用;fetch 的 `--doc-format im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用,不作为 create/update 写入格式。
|
||||
|
||||
## 创建文档标题
|
||||
|
||||
使用 `docs +create --doc-format markdown` 创建文档时,文档标题必须写成内容开头唯一的一级标题:`# 标题`。正文标题从 `##` 开始,不要使用多个一级标题;否则标题可能无法被提取并显示为 `Untitled`。
|
||||
|
||||
## 转义规则
|
||||
|
||||
> **⚠️ 当文本中包含以下字符且不想触发 Markdown 语法时**,需用 `\` 前缀转义。转义分为**无条件转义**(行内任意位置生效)和**位置敏感转义**(仅特定位置才需要)两类。
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
| `<title>` | 文档标题(每篇唯一)| `align` |
|
||||
| `<checkbox>` | 待办项| `done="true"\|"false"` |
|
||||
|
||||
## 创建文档标题
|
||||
|
||||
使用 `docs +create` 创建 XML 文档时,文档标题必须写成 `<title>标题</title>`,且每篇文档只写一个 `<title>`。
|
||||
|
||||
## 容器标签
|
||||
|标签|说明|关键属性|
|
||||
|-|-|-|
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
|
||||
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索
|
||||
- `lark-doc-xml.md` 的完整路径(Agent 须先读取);仅在需要使用富 block 或用户要求美化时提供 `lark-doc-style.md`
|
||||
- `lark-doc-xml.md` 和 `lark-doc-style.md` 的完整路径(Agent 须先读取)
|
||||
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
|
||||
|
||||
### 步骤三:整合审查与画板识别(串行)
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
## Agent 子任务要求
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。只有在需要使用富 block 或用户要求美化时,才提供 `lark-doc-style.md` 路径。
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
## Agent 子任务要求
|
||||
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。只有在需要使用富 block 或用户要求美化时,才提供 `lark-doc-style.md` 路径。
|
||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
|
||||
|
||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
||||
|
||||
|
||||
@@ -144,6 +144,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+task_result`](references/lark-drive-task-result.md) | 查询 import/export/move/delete 等异步任务结果。 |
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
|
||||
| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 |
|
||||
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
|
||||
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |
|
||||
|
||||
|
||||
66
skills/lark-drive/references/lark-drive-member-add.md
Normal file
66
skills/lark-drive/references/lark-drive-member-add.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# drive +member-add(添加协作者/授权成员权限)
|
||||
|
||||
> 这是高风险写操作。真实执行会修改文档权限,需要显式加 `--yes`
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
|
||||
# 批量添加(同一 member-type 和 perm,最多 10 人)
|
||||
lark-cli drive +member-add \
|
||||
--token "<bare_token_or_url>" \
|
||||
--type bitable \
|
||||
--member-id "ou_a,ou_b" \
|
||||
--member-type openid \
|
||||
--perm view \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|----|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `--token` | 是 | 裸 token 或完整 URL。路径支持 `/drive/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`;URL 输入可从路径推断 `--type`,裸 token 不做前缀推断 |
|
||||
| `--type` | 必填 | 目标资源类型:`docx` / `doc` / `sheet` / `bitable` / `file` / `folder` / `wiki` / `mindnote` / `slides` / `minutes`。传 URL 时可省略;裸 token 必须显式传;若同时传 URL 和 `--type`,显式 `--type` 覆盖 URL 推断 |
|
||||
| `--member-id` | 是 | 协作者 ID;逗号分隔可批量添加,最多 10 个 |
|
||||
| `--member-type` | 是 | member-id 的类型;支持 `email` / `openid` / `unionid` / `openchat` / `opendepartmentid` / `groupid` / `appid` / `wikispaceid`。在实际使用里,给当前应用授权仍优先推荐 bot `open_id` + `openid`。 |
|
||||
| `--member-kind` | 条件必填 | 仅当 `--member-type=wikispaceid` 时填写,映射到请求 body 的 `type` 字段。取值:`wiki_space_member` / `wiki_space_viewer` / `wiki_space_editor`。其他 member-type 禁止传此参数。 |
|
||||
| `--perm` | 否 | 授权角色:`view`(默认)/ `edit` / `full_access` |
|
||||
| `--perm-type` | 否 | 只作用 wiki 节点权限范围:`container`(默认,当前页面+子页面)/ `single_page`(仅当前页面) |
|
||||
| `--need-notification` | 否 | 是否通知对方。仅 `--as user` 可用;未传时不会写入 query,`--need-notification=false` 表示显式不通知 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不实际授权 |
|
||||
| `--yes` | 真实执行时是 | 确认高风险写操作 |
|
||||
|
||||
## 输出
|
||||
|
||||
批量成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"resource_token": "doc_token_or_url",
|
||||
"resource_type": "docx",
|
||||
"requested_count": 2,
|
||||
"succeeded_count": 2,
|
||||
"partial": false,
|
||||
"members": [
|
||||
{"resource_token": "doc_token_or_url", "resource_type": "docx", "member_id": "ou_a", "member_type": "openid", "member_kind": "user", "perm": "view"},
|
||||
{"resource_token": "doc_token_or_url", "resource_type": "docx", "member_id": "ou_b", "member_type": "openid", "member_kind": "user", "perm": "view"}
|
||||
],
|
||||
"missing_member_ids": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
批量部分失败时,`partial` 为 `true`,CLI 以非零退出码返回 `error.type=partial_failure`。检查 `error.detail` 中的 `requested_count`、`succeeded_count`、`members`、`missing_member_ids` 和可选的 `mismatched_member_ids`。响应顺序不影响匹配结果。
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **身份支持**:`--as user` 和 `--as bot` 均可使用。
|
||||
- **部门协作者**:`--member-type=opendepartmentid` 必须配合 `--as user`;bot 身份不支持添加部门协作者。
|
||||
- **通知**:`--need-notification` 仅 `--as user` 时有效;`--as bot` 时传此参数会被拒绝。
|
||||
- **批量约束**:批量请求共享同一 `--member-type`、`--perm` 和 `--perm-type`;混合用户/群组/部门的场景需拆分为多次调用。
|
||||
- **Wiki 空间 ID**:`--member-type=wikispaceid` 时必须同时传 `--member-kind`,否则 API 会缺少必填的 body `type` 字段。`wiki_space_member` 对应知识库成员角色;若知识库已将成员拆分为可阅读/可编辑成员组,改用 `wiki_space_viewer` 或 `wiki_space_editor`。
|
||||
- **ID 解析**:优先用 `open_id` + `--member-type openid`;仅在无法解析 `open_id` 时使用 `email`。群组优先用 `openchat`,部门用 `opendepartmentid`。
|
||||
@@ -149,6 +149,6 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
|
||||
|------------|------------------------------------------------------------------------------|---|
|
||||
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) |
|
||||
| Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes |
|
||||
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 2 VC EventKeys (`vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
|
||||
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | VC user events plus bot-observed EventKeys (`vc.bot.meeting_invited_v1`, `vc.bot.meeting_event_v1`, `vc.bot.meeting_ended_v1`) + raw_event/stable field reference + post emotion guidance |
|
||||
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) |
|
||||
| Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=<token>`) + payload field reference (whiteboard_id / operator_ids triple-id) |
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
|
||||
|
||||
## Key catalog (2)
|
||||
## Key catalog
|
||||
|
||||
| EventKey | Purpose |
|
||||
|---|---|
|
||||
| `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended |
|
||||
| `vc.note.generated_v1` | A note has been generated (meeting, recording, upload, etc.) |
|
||||
| `vc.bot.meeting_invited_v1` | The bot is invited to a meeting |
|
||||
| `vc.bot.meeting_event_v1` | The bot observes meeting activity |
|
||||
| `vc.bot.meeting_ended_v1` | A meeting observed by the bot has ended |
|
||||
|
||||
Both keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. Both require `--as user`.
|
||||
The user VC keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. They require `--as user`.
|
||||
|
||||
The `vc.bot.*` keys are bot-observed events. They require `--as bot`, keep the original payload in `raw_event`, and do not call the user-side VC meeting subscription / unsubscription APIs.
|
||||
|
||||
## Scopes & auth
|
||||
|
||||
@@ -17,6 +22,9 @@ Both keys use a **Custom schema** (flat output) and carry a **PreConsume hook**
|
||||
|---|---|---|
|
||||
| `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user |
|
||||
| `vc.note.generated_v1` | `vc:note:read` | user |
|
||||
| `vc.bot.meeting_invited_v1` | App event subscription in the Developer Console | bot |
|
||||
| `vc.bot.meeting_event_v1` | App event subscription in the Developer Console | bot |
|
||||
| `vc.bot.meeting_ended_v1` | App event subscription in the Developer Console | bot |
|
||||
|
||||
---
|
||||
|
||||
@@ -92,3 +100,60 @@ lark-cli event consume vc.note.generated_v1 --as user \
|
||||
lark-cli event consume vc.note.generated_v1 --as user \
|
||||
--jq 'select(.note_source.source_type == "meeting") | {note_id, meeting_id: .note_source.source_entity_id}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bot-observed VC events
|
||||
|
||||
Use bot identity for all `vc.bot.*` keys:
|
||||
|
||||
```bash
|
||||
lark-cli event consume vc.bot.meeting_invited_v1 --as bot
|
||||
lark-cli event consume vc.bot.meeting_event_v1 --as bot
|
||||
lark-cli event consume vc.bot.meeting_ended_v1 --as bot
|
||||
```
|
||||
|
||||
These keys model what the bot observes. Do not treat them as aliases for:
|
||||
|
||||
| Bot event | Not the same as |
|
||||
|---|---|
|
||||
| `vc.bot.meeting_invited_v1` | Meeting start events, participant join events, or IM meeting cards |
|
||||
| `vc.bot.meeting_event_v1` | User-side `vc +meeting-events` open meeting activity queries |
|
||||
| `vc.bot.meeting_ended_v1` | `vc.meeting.participant_meeting_ended_v1` or open meeting resource ended events |
|
||||
|
||||
### Output fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `type` | string | Event type; one of the supported `vc.bot.*` keys |
|
||||
| `event_id` | string | Globally unique event ID; safe for deduplication |
|
||||
| `timestamp` | string (timestamp_ms) | Event delivery time from `header.create_time` when present |
|
||||
| `call_id` | string | Invitation call ID; pass through to VC agent join when present |
|
||||
| `meeting_no` | string | Meeting number when present in the payload |
|
||||
| `activity_event_type` | string | Meeting activity subtype when present |
|
||||
| `chat_emoji_types` | string[] | Feishu post emotion `emoji_type` values extracted from `vc.bot.meeting_event_v1` payloads |
|
||||
| `raw_event` | object | Original bot event payload; authoritative for fields not normalized by `lark-cli` |
|
||||
|
||||
Malformed or evolving payloads are not over-normalized. If a payload cannot be parsed, `event consume` passes the raw payload through; if a field is not recognized, read `raw_event`.
|
||||
|
||||
### Post emotion forwarding
|
||||
|
||||
`lark-cli event consume` does not send IM messages automatically. When `vc.bot.meeting_event_v1` exposes `chat_emoji_types`, an agent can explicitly forward the emotion by sending a Feishu `post` message whose content uses `tag: "emotion"` and `emoji_type` from `chat_emoji_types`.
|
||||
|
||||
```bash
|
||||
lark-cli im +messages-send \
|
||||
--chat-id <chat_id> \
|
||||
--msg-type post \
|
||||
--content '{
|
||||
"zh_cn": {
|
||||
"title": "Meeting reaction",
|
||||
"content": [[
|
||||
{"tag": "text", "text": "Reaction: "},
|
||||
{"tag": "emotion", "emoji_type": "JIAYI"}
|
||||
]]
|
||||
}
|
||||
}' \
|
||||
--as bot
|
||||
```
|
||||
|
||||
Use the exact key from `.chat_emoji_types[]` as `emoji_type`; do not convert it to a system emoji or synthesize another value.
|
||||
|
||||
@@ -149,6 +149,12 @@ lark-cli im <resource> <method> [flags] # 调用 API
|
||||
- `batch_query` — 批量查询当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
|
||||
- `batch_update` — 批量更新当前用户在群内的个人偏好设置 (e.g. `is_muted` mutes normal messages, `is_mute_at_all` mutes @all messages); up to 10 chats per request. Identity: `user` only (`user_access_token`); the caller must be in each target chat.
|
||||
|
||||
### chat.nickname
|
||||
|
||||
- `get` — 获取自己的群昵称。Get your own nickname in the chat (self-only). Identity: `user` only (`user_access_token`); returns an empty string when no nickname is set.
|
||||
- `update` — 设置自己的群昵称。Set or update your own nickname in the chat (self-only). Identity: `user` only (`user_access_token`); `nickname` must be a non-empty string (max 300 bytes). Use DELETE to clear it.
|
||||
- `delete` — 清空自己的群昵称。Clear your own nickname in the chat (self-only). Identity: `user` only (`user_access_token`).
|
||||
|
||||
### chat.managers
|
||||
|
||||
- `add_managers` — 指定群管理员。Identity: supports `user` and `bot`; only the group owner can add managers; max 10 managers per chat (20 for super-large chats), and at most 5 bots per request.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token.
|
||||
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
|
||||
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance.
|
||||
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
|
||||
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
|
||||
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
|
||||
|
||||
@@ -17,7 +18,7 @@
|
||||
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create | `--parent-token`; `--doc-format markdown`; `--content` | helper asserts returned doc id from `data.document.document_id` |
|
||||
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
|
||||
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown` | |
|
||||
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
|
||||
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
@@ -106,3 +107,31 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
|
||||
// Fake creds are enough — dry-run short-circuits before any real API call.
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+create",
|
||||
"--title", "Dry Run & Title",
|
||||
"--doc-format", "markdown",
|
||||
"--content", "## Body",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "/open-apis/docs_ai/v1/documents", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "markdown", gjson.Get(out, "api.0.body.format").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "<title>Dry Run & Title</title>\n## Body", gjson.Get(out, "api.0.body.content").String(), "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
499
tests/cli_e2e/drive/drive_member_add_dryrun_test.go
Normal file
499
tests/cli_e2e/drive/drive_member_add_dryrun_test.go
Normal file
@@ -0,0 +1,499 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_MemberAddDryRun locks in the request shape the shortcut emits
|
||||
// under --dry-run: the real CLI binary is invoked end-to-end (so flag parsing,
|
||||
// validation, and dry-run rendering all execute), and the printed request is
|
||||
// inspected to confirm
|
||||
// - HTTP method, URL template, and token path segment,
|
||||
// - type query parameter (auto-inferred from a URL input, explicit for a
|
||||
// bare token input),
|
||||
// - explicit --type overriding URL inference,
|
||||
// - member_type / member kind / perm / perm_type body fields,
|
||||
// - single-member vs batch endpoint selection.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before any
|
||||
// network call.
|
||||
func TestDrive_MemberAddDryRun(t *testing.T) {
|
||||
// Isolate from any local CLI state: the subprocess inherits the parent
|
||||
// test environment, and without an explicit config dir it could read a
|
||||
// developer's real credentials/profile instead of the fake ones below.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantResourceType string
|
||||
wantNeedNotification string
|
||||
wantMemberID string
|
||||
wantMemberType string
|
||||
wantPerm string
|
||||
wantMemberKind string
|
||||
wantPermType string
|
||||
wantBatch bool
|
||||
}{
|
||||
{
|
||||
name: "URL input auto-infers docx type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--perm", "view",
|
||||
"--need-notification=false",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E001/members",
|
||||
wantResourceType: "docx",
|
||||
wantNeedNotification: "false",
|
||||
wantMemberID: "ou_e2e_user",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "URL input auto-infers mindnote type from mindnotes path",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "https://example.feishu.cn/mindnotes/mndE2E011?from=share",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/mndE2E011/members",
|
||||
wantResourceType: "mindnote",
|
||||
wantMemberID: "ou_e2e_user",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "bare token with explicit wiki type defaults perm_type container",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "wikcnE2E002",
|
||||
"--type", "wiki",
|
||||
"--member-id", "ou_e2e_admin",
|
||||
"--member-type", "openid",
|
||||
"--perm", "full_access",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/wikcnE2E002/members",
|
||||
wantResourceType: "wiki",
|
||||
wantMemberID: "ou_e2e_admin",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "full_access",
|
||||
wantMemberKind: "user",
|
||||
wantPermType: "container",
|
||||
},
|
||||
{
|
||||
// Explicit --type must override URL inference: the /docx/ marker
|
||||
// would infer type=docx, but the caller asked to grant permission
|
||||
// against a wiki node. The URL token itself is still used as the
|
||||
// path token.
|
||||
name: "explicit --type overrides URL inference",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E009",
|
||||
"--type", "wiki",
|
||||
"--member-id", "ou_e2e_override",
|
||||
"--member-type", "openid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E009/members",
|
||||
wantResourceType: "wiki",
|
||||
wantMemberID: "ou_e2e_override",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
wantPermType: "container",
|
||||
},
|
||||
{
|
||||
name: "bare token with explicit folder type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "fldE2E010",
|
||||
"--type", "folder",
|
||||
"--member-id", "ou_e2e_folder",
|
||||
"--member-type", "openid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/fldE2E010/members",
|
||||
wantResourceType: "folder",
|
||||
wantMemberID: "ou_e2e_folder",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "email member-type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "shtcnE2E003",
|
||||
"--type", "sheet",
|
||||
"--member-id", "user@example.com",
|
||||
"--member-type", "email",
|
||||
"--perm", "edit",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/shtcnE2E003/members",
|
||||
wantResourceType: "sheet",
|
||||
wantMemberID: "user@example.com",
|
||||
wantMemberType: "email",
|
||||
wantPerm: "edit",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "unionid member-type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E006",
|
||||
"--type", "docx",
|
||||
"--member-id", "on_e2e_union",
|
||||
"--member-type", "unionid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E006/members",
|
||||
wantResourceType: "docx",
|
||||
wantMemberID: "on_e2e_union",
|
||||
wantMemberType: "unionid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "explicit-only groupid member-type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E007",
|
||||
"--type", "docx",
|
||||
"--member-id", "group_e2e",
|
||||
"--member-type", "groupid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E007/members",
|
||||
wantResourceType: "docx",
|
||||
wantMemberID: "group_e2e",
|
||||
wantMemberType: "groupid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "group",
|
||||
},
|
||||
{
|
||||
name: "batch members use batch_create endpoint",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "bascnE2E004",
|
||||
"--type", "bitable",
|
||||
"--member-id", "ou_a,ou_b",
|
||||
"--member-type", "openid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/bascnE2E004/members/batch_create",
|
||||
wantResourceType: "bitable",
|
||||
wantMemberID: "ou_a",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
wantBatch: true,
|
||||
},
|
||||
{
|
||||
name: "explicit groupid member-type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E005",
|
||||
"--type", "docx",
|
||||
"--member-id", "grp_abc",
|
||||
"--member-type", "groupid",
|
||||
"--perm", "edit",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E005/members",
|
||||
wantResourceType: "docx",
|
||||
wantMemberID: "grp_abc",
|
||||
wantMemberType: "groupid",
|
||||
wantPerm: "edit",
|
||||
wantMemberKind: "group",
|
||||
},
|
||||
{
|
||||
name: "appid member-type is passed through",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E008",
|
||||
"--type", "docx",
|
||||
"--member-id", "cli_app_123",
|
||||
"--member-type", "appid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E008/members",
|
||||
wantResourceType: "docx",
|
||||
wantMemberID: "cli_app_123",
|
||||
wantMemberType: "appid",
|
||||
wantPerm: "view",
|
||||
},
|
||||
{
|
||||
name: "wikispaceid member-type requires wiki-space body type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E012",
|
||||
"--type", "docx",
|
||||
"--member-id", "spc_e2e_wiki",
|
||||
"--member-type", "wikispaceid",
|
||||
"--member-kind", "wiki_space_editor",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E012/members",
|
||||
wantResourceType: "docx",
|
||||
wantMemberID: "spc_e2e_wiki",
|
||||
wantMemberType: "wikispaceid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "wiki_space_editor",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantResourceType {
|
||||
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantResourceType, out)
|
||||
}
|
||||
notification := gjson.Get(out, "api.0.params.need_notification")
|
||||
if tt.wantNeedNotification == "" {
|
||||
if notification.Exists() {
|
||||
t.Fatalf("need_notification should be omitted\nstdout:\n%s", out)
|
||||
}
|
||||
} else if got := notification.String(); got != tt.wantNeedNotification {
|
||||
t.Fatalf("need_notification = %q, want %q\nstdout:\n%s", got, tt.wantNeedNotification, out)
|
||||
}
|
||||
bodyPath := "api.0.body"
|
||||
if tt.wantBatch {
|
||||
bodyPath = "api.0.body.members.0"
|
||||
if count := len(gjson.Get(out, "api.0.body.members").Array()); count != 2 {
|
||||
t.Fatalf("body.members count = %d, want 2\nstdout:\n%s", count, out)
|
||||
}
|
||||
}
|
||||
if got := gjson.Get(out, bodyPath+".member_id").String(); got != tt.wantMemberID {
|
||||
t.Fatalf("body.member_id = %q, want %q\nstdout:\n%s", got, tt.wantMemberID, out)
|
||||
}
|
||||
if got := gjson.Get(out, bodyPath+".member_type").String(); got != tt.wantMemberType {
|
||||
t.Fatalf("body.member_type = %q, want %q\nstdout:\n%s", got, tt.wantMemberType, out)
|
||||
}
|
||||
if got := gjson.Get(out, bodyPath+".perm").String(); got != tt.wantPerm {
|
||||
t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out)
|
||||
}
|
||||
if got := gjson.Get(out, bodyPath+".type").String(); got != tt.wantMemberKind {
|
||||
t.Fatalf("body.type = %q, want %q\nstdout:\n%s", got, tt.wantMemberKind, out)
|
||||
}
|
||||
permType := gjson.Get(out, bodyPath+".perm_type")
|
||||
if tt.wantPermType == "" {
|
||||
if permType.Exists() {
|
||||
t.Fatalf("perm_type should be omitted\nstdout:\n%s", out)
|
||||
}
|
||||
} else if got := permType.String(); got != tt.wantPermType {
|
||||
t.Fatalf("body.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_MemberAddDryRunRejectsInvalidInputs(t *testing.T) {
|
||||
// Isolate from any local CLI state: the subprocess inherits the parent
|
||||
// test environment, and without an explicit config dir it could read a
|
||||
// developer's real credentials/profile instead of the fake ones below.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
defaultAs string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "any host accepted",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "https://google.com/docx/doxcnE2E001",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsupported URL path",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "https://example.feishu.cn/calendar/calE2E001",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "unsupported URL path",
|
||||
},
|
||||
{
|
||||
name: "bare token requires explicit type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "unknownE2E001",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "--type is required when --token is a bare token",
|
||||
},
|
||||
{
|
||||
name: "duplicate member IDs are rejected",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "ou_a,ou_b,ou_a",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "duplicate collaborator ID",
|
||||
},
|
||||
{
|
||||
name: "invalid explicit type is rejected",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "mincnE2E001",
|
||||
"--type", "invalidtype",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "--type must be one of: docx, doc, sheet, bitable, file, folder, wiki, mindnote, slides, minutes",
|
||||
},
|
||||
{
|
||||
name: "member-id prefix conflicts with explicit member-type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "ou_e2e_user,oc_e2e_chat",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "implies --member-type openchat",
|
||||
},
|
||||
{
|
||||
name: "explicit member-type conflicts with member-id prefix",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "oc_e2e_chat",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "implies --member-type openchat",
|
||||
},
|
||||
{
|
||||
name: "department collaborator requires user identity",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "od_e2e_dept",
|
||||
"--member-type", "opendepartmentid",
|
||||
"--dry-run",
|
||||
},
|
||||
defaultAs: "bot",
|
||||
wantErr: "--member-type=opendepartmentid requires --as user",
|
||||
},
|
||||
{
|
||||
name: "wikispaceid requires member-kind",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "spc_e2e_wiki",
|
||||
"--member-type", "wikispaceid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "--member-kind is required when --member-type=wikispaceid",
|
||||
},
|
||||
{
|
||||
name: "member-kind only applies to wikispaceid",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--member-kind", "wiki_space_member",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "--member-kind only applies when --member-type=wikispaceid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
defaultAs := tt.defaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = "user"
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.wantErr == "" {
|
||||
result.AssertExitCode(t, 0)
|
||||
return
|
||||
}
|
||||
|
||||
result.AssertExitCode(t, 2)
|
||||
output := result.Stdout + result.Stderr
|
||||
require.Contains(t, output, tt.wantErr, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user