Compare commits

...

3 Commits

Author SHA1 Message Date
caichengjie.viper
c313f8bd1a fix(slides): local XML precheck, 99991400 backoff
Lands three of the four agreed fixes from the 2026-06-08 slides write-path
telemetry analysis (the commercial quota-code registration is deferred to a
separate change):

1. Local XML well-formedness precheck (shortcuts/slides/)
   - checkXMLWellFormed: pure syntax validation via stdlib encoding/xml
     (same parser family as the backend, false-positive risk ~0);
     explicitly rejects <?xml ?> declarations; deliberately allows
     multiple top-level elements (legal in block_insert fragments)
   - wired into +create --slides (at Validate, so a bad slide no longer
     leaves a half-built deck) and +replace-slide --parts
     replacement/insertion; errors carry line numbers + escaping
     guidance, rejected locally with zero API calls

2. 99991400 rate-limit backoff (retryOnRateLimit)
   - the code was registered Retryable:true but no slides loop actually
     retried, so one frequency-window hit aborted the whole batch
   - up to 2 retries with 1s/2s backoff, announced on stderr,
     context-cancellable; wired into the +create slide POST loop and
     uploadSlidesMedia (+media-upload and the placeholder upload loop)
   - upload switched to UploadDriveMediaAllTyped (retry match requires
     typed errors; aligns with the slides typed migration)

3. lark-slides skill tag-whitelist ban (skills/lark-slides/)
   - quick-ref: never write tags outside the whitelist, name the six
     confirmed-rejected tags (audio/video/timeline/animation/trigger/
     header), substitution table, escaping rules
   - removed <?xml ?> declarations from all examples (contradicted
     backend behavior and the new precheck)

Tested with unit + httpmock integration tests, plus live verification
against the real feishu.cn API: all precheck negatives rejected locally,
no false positives on real create/replace, and 18 concurrent uploads hit
3 real 99991400 responses which all retried and succeeded (18/18).

CCM-Harness: set-lark-cli-dev-env,spec
2026-06-23 19:58:01 +08:00
jiangguozhou
824aa9edf8 docs: add lark-drive permission governance workflow (#1292)
Change-Id: Ib62bd439669fec3e9d5589d1fbe266d3aef964a8
2026-06-22 14:20:02 +08:00
zhanghuanxu
9d4ae94394 feat(slides):slide screenshot 2026-06-22 13:20:39 +08:00
23 changed files with 2671 additions and 26 deletions

View File

@@ -223,6 +223,12 @@ func (ctx *RuntimeContext) Float64(name string) float64 {
return v
}
// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting).
func (ctx *RuntimeContext) IntArray(name string) []int {
v, _ := ctx.Cmd.Flags().GetIntSlice(name)
return v
}
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
func (ctx *RuntimeContext) StrArray(name string) []string {
v, _ := ctx.Cmd.Flags().GetStringArray(name)
@@ -1176,6 +1182,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
var d float64
fmt.Sscanf(fl.Default, "%g", &d)
cmd.Flags().Float64(fl.Name, d, desc)
case "int_array":
cmd.Flags().IntSlice(fl.Name, nil, desc)
case "string_array":
cmd.Flags().StringArray(fl.Name, nil, desc)
case "string_slice":

View File

@@ -4,9 +4,12 @@
package common
import (
"context"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -56,3 +59,29 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
t.Fatalf("expected no error for empty args, got: %v", err)
}
}
func TestShortcutFlagIntArray(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
var got []int
shortcut := Shortcut{
Service: "slides",
Command: "+screenshot",
Description: "capture screenshots",
Flags: []Flag{
{Name: "slide-number", Type: "int_array"},
},
Execute: func(ctx context.Context, runtime *RuntimeContext) error {
got = runtime.IntArray("slide-number")
return nil
},
}
shortcut.Mount(parent, f)
parent.SetArgs([]string{"+screenshot", "--as", "user", "--slide-number", "1", "--slide-number", "2,3"})
if err := parent.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) {
t.Fatalf("slide-number = %#v, want %#v", got, want)
}
}

View File

@@ -18,7 +18,7 @@ const (
// Flag describes a CLI flag for a shortcut.
type Flag struct {
Name string // flag name (e.g. "calendar-id")
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime

View File

@@ -4,15 +4,74 @@
package slides
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/url"
"regexp"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
// slidesRateLimitMaxRetries is the number of automatic retries (beyond the
// initial request) when the API answers 99991400 "request trigger frequency
// limit". The slides batch paths (+create slide loop, placeholder image
// uploads) fire consecutive POSTs and are the dominant 99991400 producers
// in telemetry; a short backoff absorbs a transient burst without masking a
// genuinely saturated tenant.
slidesRateLimitMaxRetries = 2
)
// slidesRateLimitBaseDelay is the initial backoff delay; subsequent retries
// double it (1s, 2s). Mirrors the wiki +node-create lock-contention pattern
// but with a larger base because a frequency window takes longer to clear than
// a sub-second lock race. var (not const) only so tests can shrink it.
var slidesRateLimitBaseDelay = 1 * time.Second
// isRateLimitedErr reports whether err is a typed retryable rate-limit error
// (e.g. 99991400), as classified by errclass.BuildAPIError.
func isRateLimitedErr(err error) bool {
p, ok := errs.ProblemOf(err)
return ok && p.Subtype == errs.SubtypeRateLimit && p.Retryable
}
// retryOnRateLimit runs fn, retrying with exponential backoff (1s, 2s) when it
// returns a retryable rate-limit error. Any other outcome — success or a
// different error — is returned immediately. Progress is announced on errOut
// so a user watching a batch upload understands the pause.
func retryOnRateLimit(ctx context.Context, errOut io.Writer, fn func() error) error {
var lastErr error
for attempt := 0; attempt <= slidesRateLimitMaxRetries; attempt++ {
if attempt > 0 {
delay := slidesRateLimitBaseDelay << uint(attempt-1)
// Report the actual code from the error: the retry predicate matches
// any retryable SubtypeRateLimit, not just 99991400.
code := 0
if p, ok := errs.ProblemOf(lastErr); ok {
code = p.Code
}
fmt.Fprintf(errOut, "Rate limited by the API (%d), retrying (attempt %d/%d) in %v...\n",
code, attempt, slidesRateLimitMaxRetries, delay)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
lastErr = fn()
if lastErr == nil || !isRateLimitedErr(lastErr) {
return lastErr
}
}
return lastErr
}
// presentationRef holds a parsed --presentation input.
//
// Slides shortcuts accept three input shapes:
@@ -125,8 +184,30 @@ func resolvePresentationID(runtime *common.RuntimeContext, ref presentationRef)
// around `=`); without it we'd silently leave such placeholders unrewritten.
var imgSrcPlaceholderRegex = regexp.MustCompile(`(?s)<img\b[^>]*?\bsrc\s*=\s*(["'])@([^"']+)(["'])`)
// xmlEntityUnescaper reverses the five XML built-in entities in attribute
// values captured from raw slide XML. strings.Replacer scans left-to-right in
// a single pass, so "&amp;lt;" correctly yields "&lt;" (the leading "&amp;"
// is consumed first), matching XML unescape semantics.
var xmlEntityUnescaper = strings.NewReplacer(
"&lt;", "<",
"&gt;", ">",
"&quot;", `"`,
"&apos;", "'",
"&amp;", "&",
)
// placeholderFilePath converts a raw <img src="@..."> capture into the local
// filesystem path it refers to. The capture comes from well-formed XML where
// a literal & must be written &amp; (the precheck enforces this), so the
// entities are decoded before the path touches Stat/upload. Filesystem paths
// containing & are therefore written as e.g. src="@./Q1&amp;Q2.png".
func placeholderFilePath(raw string) string {
return xmlEntityUnescaper.Replace(strings.TrimSpace(raw))
}
// extractImagePlaceholderPaths returns the de-duplicated list of local paths
// referenced via <img src="@path"> in the given slide XML strings.
// referenced via <img src="@path"> in the given slide XML strings, with XML
// built-in entities decoded (see placeholderFilePath).
//
// Order is preserved (first occurrence wins) so dry-run / progress messages are
// stable across runs.
@@ -141,7 +222,7 @@ func extractImagePlaceholderPaths(slideXMLs []string) []string {
// so we filter it here. Treat as malformed XML and skip.
continue
}
path := strings.TrimSpace(m[2])
path := placeholderFilePath(m[2])
if path == "" || seen[path] {
continue
}
@@ -280,6 +361,48 @@ func ensureShapeHasContent(xmlFragment string) string {
return xmlFragment[:m[1]] + "<content/>" + afterOpen
}
// checkXMLWellFormed verifies that fragment parses as well-formed XML, using
// the same parser family as the backend (Go encoding/xml). Syntax only —
// element names and attributes are NOT checked against the SML schema, so
// anything passing here can still be rejected server-side for semantic
// reasons; conversely nothing rejected here could ever have succeeded, which
// keeps the false-positive risk at zero.
//
// The backend reports these failures as an opaque 3350001/4001000
// "invalid param" with no position info; catching them locally turns the
// dominant real-world causes (bare & in text, unclosed tags, attribute
// quoting) into actionable messages with a line number.
//
// An <?xml ?> declaration is rejected explicitly: the rendering backend does
// not accept processing instructions on slide fragments (rejects with
// "?xml not provide the implement"). encoding/xml surfaces it as a regular
// ProcInst token, so it needs its own check.
//
// Multiple top-level elements are deliberately allowed — insertion fragments
// may legitimately carry sibling elements.
func checkXMLWellFormed(fragment string) error {
dec := xml.NewDecoder(strings.NewReader(fragment))
for {
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
var syn *xml.SyntaxError
if errors.As(err, &syn) {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"XML not well-formed at line %d: %s (escape literal & as &amp; and < as &lt; in text)",
syn.Line, syn.Msg)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML not well-formed: %v", err)
}
if pi, ok := tok.(xml.ProcInst); ok && strings.EqualFold(pi.Target, "xml") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"XML must not contain an <?xml ?> declaration (the slides backend rejects it); remove it and start at the root element")
}
}
}
// replaceImagePlaceholders rewrites <img src="@path"> occurrences in the input
// XML by looking up each path in tokens. Paths missing from the map are left
// untouched (callers should ensure the map is complete).
@@ -294,7 +417,10 @@ func replaceImagePlaceholders(slideXML string, tokens map[string]string) string
// Mismatched quotes — see extractImagePlaceholderPaths.
return match
}
token, ok := tokens[strings.TrimSpace(path)]
// tokens is keyed by the decoded filesystem path (see
// extractImagePlaceholderPaths), while oldQuoted below must use the
// raw capture so the literal XML text is what gets replaced.
token, ok := tokens[placeholderFilePath(path)]
if !ok {
return match
}

View File

@@ -4,9 +4,15 @@
package slides
import (
"bytes"
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
)
func TestParsePresentationRef(t *testing.T) {
@@ -216,6 +222,15 @@ func TestExtractImagePlaceholderPaths(t *testing.T) {
in: []string{`<img src = "@./spaced.png" />`},
want: []string{"./spaced.png"},
},
{
// Regression: the well-formedness precheck forces a literal & in a
// filename to be written &amp; in the XML; the captured path must
// be entity-decoded before it reaches Stat/upload so the file is
// actually found on disk.
name: "decodes XML entities in path",
in: []string{`<img src="@./Q1&amp;Q2.png"/>`},
want: []string{"./Q1&Q2.png"},
},
}
for _, tt := range tests {
@@ -233,8 +248,9 @@ func TestReplaceImagePlaceholders(t *testing.T) {
t.Parallel()
tokens := map[string]string{
"./pic.png": "tok_abc",
"./b.png": "tok_b",
"./pic.png": "tok_abc",
"./b.png": "tok_b",
"./Q1&Q2.png": "tok_amp", // keyed by decoded filesystem path
}
tests := []struct {
@@ -280,6 +296,13 @@ func TestReplaceImagePlaceholders(t *testing.T) {
in: `<img src = "@./pic.png" topLeftX="10"/>`,
want: `<img src = "tok_abc" topLeftX="10"/>`,
},
{
// Regression: tokens are keyed by the decoded filesystem path, but
// the literal XML text (with &amp;) is what must be rewritten.
name: "decodes XML entities when looking up token",
in: `<img src="@./Q1&amp;Q2.png" topLeftX="10"/>`,
want: `<img src="tok_amp" topLeftX="10"/>`,
},
}
for _, tt := range tests {
@@ -413,3 +436,152 @@ func TestEnsureXMLRootID(t *testing.T) {
})
}
}
func TestCheckXMLWellFormed(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
wantErr string
}{
{name: "simple element", in: `<shape type="rect"><content/></shape>`},
{name: "nested with attributes", in: `<slide><shape type="text"><content><p>hi</p></content></shape></slide>`},
// Insertion fragments may carry sibling top-level elements; the decoder
// must not enforce a single document element.
{name: "multiple top-level elements", in: `<p>a</p><p>b</p>`},
{name: "escaped entities", in: `<p>A &amp; B &lt;tag&gt; &quot;q&quot;</p>`},
{name: "CDATA with raw ampersand", in: `<p><![CDATA[a & b < c]]></p>`},
{name: "comment", in: `<!-- note --><shape/>`},
{name: "img placeholder attr", in: `<img src="@./local.png" width="100"/>`},
{name: "unicode text", in: `<p>项目汇报 🎯</p>`},
// Top CLI-path failure cause in engine logs: bare & in text.
{name: "bare ampersand", in: `<p>Q & A</p>`, wantErr: "line 1"},
{name: "bare ampersand multiline", in: "<slide>\n<p>R&D</p>\n</slide>", wantErr: "line 2"},
{name: "unclosed tag", in: `<shape><content></shape>`, wantErr: "not well-formed"},
{name: "unquoted attribute", in: `<shape type=rect/>`, wantErr: "not well-formed"},
{name: "stray closing tag", in: `<p>hi</p></div>`, wantErr: "not well-formed"},
{name: "undefined entity", in: `<p>a&nbsp;b</p>`, wantErr: "not well-formed"},
// nodeserver rejects processing instructions ("?xml not provide the
// implement"); reject the declaration locally regardless of position.
{name: "xml declaration", in: `<?xml version="1.0"?><shape/>`, wantErr: "declaration"},
{name: "xml declaration with encoding", in: `<?xml version="1.0" encoding="UTF-8"?><slide/>`, wantErr: "declaration"},
{name: "uppercase xml declaration", in: `<?XML version="1.0"?><shape/>`, wantErr: "declaration"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := checkXMLWellFormed(tt.in)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
return
}
if err == nil {
t.Fatalf("want error containing %q, got nil", tt.wantErr)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("want SubtypeInvalidArgument, got %v", ve.Subtype)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("want error containing %q, got %q", tt.wantErr, err.Error())
}
})
}
}
// TestRetryOnRateLimit verifies the 99991400 backoff helper: retryable
// rate-limit errors are retried with backoff, anything else returns
// immediately, and exhaustion surfaces the last rate-limit error.
//
// Not parallel: shrinks the package-level slidesRateLimitBaseDelay.
func TestRetryOnRateLimit(t *testing.T) {
restore := slidesRateLimitBaseDelay
slidesRateLimitBaseDelay = time.Millisecond
t.Cleanup(func() { slidesRateLimitBaseDelay = restore })
rateLimitErr := func() error {
return errs.NewAPIError(errs.SubtypeRateLimit, "request trigger frequency limit").WithRetryable()
}
t.Run("success without retry", func(t *testing.T) {
var errOut bytes.Buffer
calls := 0
err := retryOnRateLimit(context.Background(), &errOut, func() error {
calls++
return nil
})
if err != nil || calls != 1 {
t.Fatalf("err=%v calls=%d, want nil/1", err, calls)
}
if errOut.Len() != 0 {
t.Fatalf("no retry message expected, got: %s", errOut.String())
}
})
t.Run("succeeds after transient rate limit", func(t *testing.T) {
var errOut bytes.Buffer
calls := 0
err := retryOnRateLimit(context.Background(), &errOut, func() error {
calls++
if calls <= 2 {
return rateLimitErr()
}
return nil
})
if err != nil || calls != 3 {
t.Fatalf("err=%v calls=%d, want nil/3", err, calls)
}
if !strings.Contains(errOut.String(), "retrying") {
t.Fatalf("expected retry announcement, got: %s", errOut.String())
}
})
t.Run("exhaustion returns last rate-limit error", func(t *testing.T) {
var errOut bytes.Buffer
calls := 0
err := retryOnRateLimit(context.Background(), &errOut, func() error {
calls++
return rateLimitErr()
})
if err == nil || !isRateLimitedErr(err) {
t.Fatalf("want rate-limit error after exhaustion, got: %v", err)
}
if calls != slidesRateLimitMaxRetries+1 {
t.Fatalf("calls=%d, want %d", calls, slidesRateLimitMaxRetries+1)
}
})
t.Run("non-rate-limit error returns immediately", func(t *testing.T) {
var errOut bytes.Buffer
calls := 0
boom := errs.NewAPIError(errs.SubtypeNotFound, "not found")
err := retryOnRateLimit(context.Background(), &errOut, func() error {
calls++
return boom
})
if !errors.Is(err, boom) || calls != 1 {
t.Fatalf("err=%v calls=%d, want boom/1", err, calls)
}
})
t.Run("cancelled context aborts the backoff wait", func(t *testing.T) {
var errOut bytes.Buffer
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := retryOnRateLimit(ctx, &errOut, func() error {
return rateLimitErr()
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("want context.Canceled, got: %v", err)
}
})
}

View File

@@ -11,5 +11,6 @@ func Shortcuts() []common.Shortcut {
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesScreenshot,
}
}

View File

@@ -50,6 +50,15 @@ var SlidesCreate = common.Shortcut{
if len(slides) > maxSlidesPerCreate {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate).WithParam("--slides")
}
// Well-formedness precheck before any API call: a syntax error in
// slide N would otherwise create the presentation and then fail on
// the slide POST with an opaque backend "invalid param", leaving a
// partially-built deck behind.
for i, slideXML := range slides {
if err := checkXMLWellFormed(slideXML); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slides[%d]: %v", i, err).WithParam("--slides").WithCause(err)
}
}
// Validate placeholder paths up front so we don't create a presentation
// only to fail mid-way on a missing local file.
for _, path := range extractImagePlaceholderPaths(slides) {
@@ -183,14 +192,22 @@ var SlidesCreate = common.Shortcut{
var slideIDs []string
for i, slideXML := range slides {
slideData, err := runtime.CallAPITyped(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
},
)
var slideData map[string]interface{}
// Consecutive slide POSTs are the main 99991400 producer in
// telemetry; absorb the per-second frequency window with a
// short backoff instead of aborting the whole batch.
err := retryOnRateLimit(ctx, runtime.IO().ErrOut, func() error {
var callErr error
slideData, callErr = runtime.CallAPITyped(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
},
)
return callErr
})
if err != nil {
return appendSlidesProgressHint(err, fmt.Sprintf("adding slide %d/%d failed; presentation %s was created, %d slide(s) added before failure", i+1, len(slides), presentationID, i))
}

View File

@@ -10,6 +10,7 @@ import (
"os"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
@@ -392,7 +393,10 @@ func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
},
})
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<bad-xml>"]`
// The second slide is well-formed XML (so it passes the local precheck)
// but uses an element the backend rejects — partial failure must come from
// the API layer, not validation.
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<slide><audio src=\"x\"/></slide>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Partial",
@@ -918,3 +922,93 @@ func TestSlidesCreateWithPlaceholdersDryRun(t *testing.T) {
t.Fatalf("dry-run header should describe upload count, got: %s", out)
}
}
// TestSlidesCreateRejectsMalformedSlideXML verifies the well-formedness
// precheck fires before any API call — no presentation should be created when
// a slide fragment has a syntax error, so no httpmock stubs are registered.
func TestSlidesCreateRejectsMalformedSlideXML(t *testing.T) {
t.Parallel()
tests := []struct {
name string
slides string
wantErr string
}{
{"bare ampersand", `["<slide><p>Q & A</p></slide>"]`, "--slides[0]"},
{"unclosed tag", `["<slide><p>ok</p></slide>","<slide><shape></slide>"]`, "--slides[1]"},
{"xml declaration", `["<?xml version=\"1.0\"?><slide/>"]`, "declaration"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "precheck",
"--slides", tt.slides,
"--as", "user",
})
if err == nil {
t.Fatalf("expected validation error for %s, got nil", tt.name)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
if !strings.Contains(err.Error(), "well-formed") && !strings.Contains(err.Error(), "declaration") {
t.Fatalf("error should explain the XML problem, got %q", err.Error())
}
})
}
}
// TestSlidesCreateRetriesSlideRateLimit verifies the +create slide loop
// retries a 99991400 "request trigger frequency limit" slide POST with
// backoff instead of aborting the batch (one-shot stubs: first slide POST
// answers 99991400, the second answers success — both must be consumed).
//
// Not parallel: shrinks the package-level slidesRateLimitBaseDelay.
func TestSlidesCreateRetriesSlideRateLimit(t *testing.T) {
restore := slidesRateLimitBaseDelay
slidesRateLimitBaseDelay = time.Millisecond
t.Cleanup(func() { slidesRateLimitBaseDelay = restore })
f, stdout, stderr, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"xml_presentation_id": "pres_rl", "revision_id": 1},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_rl/slide",
Status: 400,
Body: map[string]interface{}{"code": 99991400, "msg": "request trigger frequency limit"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_rl/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "s1", "revision_id": 2}},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "RL test",
"--slides", `["<slide><data/></slide>"]`,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error (rate limit should have been retried): %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["slides_added"] != float64(1) {
t.Fatalf("slides_added = %v, want 1", data["slides_added"])
}
if !strings.Contains(stderr.String(), "retrying") {
t.Fatalf("expected retry announcement on stderr, got: %s", stderr.String())
}
}

View File

@@ -128,13 +128,26 @@ func uploadSlidesMedia(runtime *common.RuntimeContext, filePath, fileName string
fileName, common.FormatSize(fileSize))
}
parent := presentationID
return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: slidesMediaParentType,
ParentNode: &parent,
var fileToken string
// upload_all is rate-limited per second; consecutive placeholder uploads
// from +create (and rapid repeated +media-upload calls) can hit 99991400.
// A failed rate-limited attempt creates nothing server-side, and each
// attempt re-opens the file from FilePath, so the retry is safe.
// The Typed variant is required here: retryOnRateLimit matches on the
// typed subtype, and slides error wrapping (appendSlidesProgressHint)
// already expects typed errs.* envelopes.
err := retryOnRateLimit(runtime.Ctx(), runtime.IO().ErrOut, func() error {
var callErr error
fileToken, callErr = common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: slidesMediaParentType,
ParentNode: &parent,
})
return callErr
})
return fileToken, err
}
// appendSlidesUploadDryRun renders the upload_all step for a single file.

View File

@@ -12,6 +12,7 @@ import (
"os"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
@@ -367,3 +368,49 @@ func readAll(t *testing.T, r interface {
}
return buf.Bytes()
}
// TestSlidesMediaUploadRetriesRateLimit verifies uploadSlidesMedia retries a
// 99991400 "request trigger frequency limit" upload_all response with backoff
// (one-shot stubs: the rate-limited response is consumed first, then the
// success response) and still returns the file_token.
//
// Not parallel: uses os.Chdir and shrinks slidesRateLimitBaseDelay.
func TestSlidesMediaUploadRetriesRateLimit(t *testing.T) {
restore := slidesRateLimitBaseDelay
slidesRateLimitBaseDelay = time.Millisecond
t.Cleanup(func() { slidesRateLimitBaseDelay = restore })
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("rl.png", []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Status: 400,
Body: map[string]interface{}{"code": 99991400, "msg": "request trigger frequency limit"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "tok_rl"}},
})
err := runSlidesShortcut(t, f, stdout, SlidesMediaUpload, []string{
"+media-upload",
"--file", "rl.png",
"--presentation", "pres_rl_upload",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error (rate limit should have been retried): %v", err)
}
data := decodeShortcutData(t, stdout)
if data["file_token"] != "tok_rl" {
t.Fatalf("file_token = %v, want tok_rl", data["file_token"])
}
}

View File

@@ -34,6 +34,9 @@ const maxReplaceParts = 200
// it triggers 3350001.
// 4. On 3350001 errors it enriches the hint with context-specific guidance
// so AI agents can self-correct.
// 5. It rejects non-well-formed replacement/insertion XML before any API
// call, with a line number and escaping hint — the backend reports these
// only as an opaque 3350001/4001000 "invalid param".
//
// `str_replace` is intentionally NOT exposed: product direction is that
// slide edits go through structural (block-level) operations only. The backend
@@ -278,6 +281,8 @@ func enrichSlidesReplaceError(err error) error {
// - size is within [1, 200]
// - action is one of the exposed actions (block_replace / block_insert)
// - per-action required fields are present
// - replacement / insertion fragments are well-formed XML (syntax only;
// see checkXMLWellFormed)
func validateReplaceParts(parts []replacePart) error {
if len(parts) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts must contain at least 1 item").WithParam("--parts")
@@ -294,10 +299,16 @@ func validateReplaceParts(parts []replacePart) error {
if p.Replacement == nil || strings.TrimSpace(*p.Replacement) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_replace) requires non-empty replacement", i).WithParam("--parts")
}
if err := checkXMLWellFormed(*p.Replacement); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].replacement: %v", i, err).WithParam("--parts").WithCause(err)
}
case "block_insert":
if p.Insertion == nil || strings.TrimSpace(*p.Insertion) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d] (block_insert) requires non-empty insertion", i).WithParam("--parts")
}
if err := checkXMLWellFormed(*p.Insertion); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parts[%d].insertion: %v", i, err).WithParam("--parts").WithCause(err)
}
case "str_replace":
// Backend still accepts str_replace, but product decision is to
// force structural edits through the CLI. Block it up-front so

View File

@@ -731,3 +731,41 @@ func TestReplaceSlideValidationParam(t *testing.T) {
})
}
}
// TestReplaceSlideRejectsMalformedFragmentXML verifies the well-formedness
// precheck on replacement / insertion fragments fires at validation time,
// before wiki resolution or the replace POST.
func TestReplaceSlideRejectsMalformedFragmentXML(t *testing.T) {
t.Parallel()
tests := []struct {
name string
parts string
wantErr string
}{
{"replacement bare ampersand", `[{"action":"block_replace","block_id":"bUn","replacement":"<shape><content><p>R & D</p></content></shape>"}]`, "--parts[0].replacement"},
{"replacement unclosed tag", `[{"action":"block_replace","block_id":"bUn","replacement":"<shape><content></shape>"}]`, "--parts[0].replacement"},
{"insertion xml declaration", `[{"action":"block_insert","insertion":"<?xml version=\"1.0\"?><shape/>"}]`, "declaration"},
{"second part malformed", `[{"action":"block_insert","insertion":"<p>ok</p>"},{"action":"block_insert","insertion":"<p>Q & A</p>"}]`, "--parts[1].insertion"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesReplaceSlide, []string{
"+replace-slide",
"--presentation", "pres_abc",
"--slide-id", "s",
"--parts", tt.parts,
"--as", "user",
})
if err == nil {
t.Fatalf("expected validation error for %s, got nil", tt.name)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
})
}
}

View File

@@ -0,0 +1,537 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const defaultSlidesScreenshotDir = ".lark-slides/screenshots"
var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
// SlidesScreenshot fetches server-rendered slide screenshots and writes them to
// local files. The raw API returns Base64 image payloads; this shortcut keeps
// those payloads out of stdout so agents only see small file metadata.
var SlidesScreenshot = common.Shortcut{
Service: "slides",
Command: "+screenshot",
Description: "Save slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{"slides:presentation:screenshot"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"},
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"},
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
{Name: "output-name", Desc: "file name stem for --content render output"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
renderMode := runtime.Changed("content")
if renderMode {
if strings.TrimSpace(runtime.Str("content")) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
}
} else {
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
if ref.Kind == "wiki" {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return err
}
}
if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil {
return err
}
if !hasSlideScreenshotSelector(runtime) {
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
}
}
if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if runtime.Changed("content") {
return dryRunRenderScreenshot(runtime)
}
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required")
}
presentationID := ref.Token
dry := common.NewDryRunAPI()
if ref.Kind == "wiki" {
presentationID = "<resolved_slides_token>"
dry.Desc("2-step orchestration: resolve wiki → fetch slide screenshot(s)").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to slides presentation").
Params(map[string]interface{}{"token": ref.Token})
} else {
dry.Desc(fmt.Sprintf("Fetch %d slide screenshot(s) and save files under %s", len(slideIDs)+len(slideNumbers), runtime.Str("output-dir")))
}
body := map[string]interface{}{}
if len(slideIDs) > 0 {
body["slide_ids"] = slideIDs
}
if len(slideNumbers) > 0 {
body["slide_numbers"] = slideNumbers
}
dry.POST(fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
validate.EncodePathSegment(presentationID),
)).
Body(body)
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local files during execution")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("content") {
return executeRenderScreenshot(runtime)
}
ref, err := parsePresentationRef(runtime.Str("presentation"))
if err != nil {
return err
}
presentationID, err := resolvePresentationID(runtime, ref)
if err != nil {
return err
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
}
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
}
outputDir := runtime.Str("output-dir")
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
if err != nil {
return err
}
url := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
validate.EncodePathSegment(presentationID),
)
query := larkcore.QueryParams{}
body := map[string]interface{}{}
if len(slideIDs) > 0 {
body["slide_ids"] = slideIDs
}
if len(slideNumbers) > 0 {
body["slide_numbers"] = slideNumbers
}
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", url, query, body)
if err != nil {
return enrichSlidesScreenshotSelectorError(err, slideNumbers)
}
saved, err := saveSlideScreenshots(runtime, data, safeOutputDir, presentationID)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"xml_presentation_id": presentationID,
"output_dir": outputDir,
"screenshots": saved,
}, nil)
return nil
},
}
func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
content := runtime.Str("content")
if strings.TrimSpace(content) == "" {
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return common.NewDryRunAPI().Set("error", "--presentation cannot be used with --content")
}
dry := common.NewDryRunAPI().Desc("Render slide XML content to a screenshot file")
dry.POST("/open-apis/slides_ai/v1/slide_image/render").
Body(map[string]interface{}{
"content": fmt.Sprintf("<xml omitted; length=%d>", len(content)),
})
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local file during execution")
}
func executeRenderScreenshot(runtime *common.RuntimeContext) error {
content := runtime.Str("content")
if strings.TrimSpace(content) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
}
outputDir := runtime.Str("output-dir")
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
if err != nil {
return err
}
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", "/open-apis/slides_ai/v1/slide_image/render", larkcore.QueryParams{}, map[string]interface{}{
"content": content,
})
if err != nil {
return err
}
saved, err := saveRenderedSlideScreenshot(runtime, data, safeOutputDir, runtime.Str("output-name"))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{
"output_dir": outputDir,
"screenshots": saved,
}, nil)
return nil
}
func normalizeSlideIDs(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, v := range values {
s := strings.TrimSpace(v)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
func normalizeSlideNumbers(values []int) ([]int, error) {
out := make([]int, 0, len(values))
seen := map[int]struct{}{}
for _, n := range values {
if n < 1 {
return nil, slidesScreenshotFlagErrorf("--slide-number must be a positive integer")
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
return out, nil
}
func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool {
return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0
}
func slidesScreenshotFlagErrorf(format string, args ...interface{}) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
func validateScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
if _, err := runtime.ResolveSavePath(filepath.Join(outputDir, "probe.png")); err != nil {
return "", slidesScreenshotFlagErrorf("--output-dir invalid: %v", err)
}
return outputDir, nil
}
func ensureScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
return validateScreenshotOutputDir(runtime, outputDir)
}
func saveSlideScreenshots(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, presentationID string) ([]map[string]interface{}, error) {
items := common.GetSlice(data, "slide_images")
if len(items) == 0 {
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned no slide_images")
}
saved := make([]map[string]interface{}, 0, len(items))
for i, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]", i)
}
item, err := saveSlideScreenshotImage(runtime, m, outputDir, slideScreenshotListFileBase(presentationID, m, i), "")
if err != nil {
if isSlidesScreenshotPassthroughError(err) {
return nil, err
}
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]: %v", i, err)
}
saved = append(saved, item)
}
return saved, nil
}
func saveRenderedSlideScreenshot(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, outputName string) ([]map[string]interface{}, error) {
item := common.GetMap(data, "slide_image")
if item == nil {
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned no slide_image")
}
saved, err := saveSlideScreenshotImage(runtime, item, outputDir, outputName, "rendered-slide")
if err != nil {
if isSlidesScreenshotPassthroughError(err) {
return nil, err
}
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned invalid slide_image: %v", err)
}
return []map[string]interface{}{saved}, nil
}
func saveSlideScreenshotImage(runtime *common.RuntimeContext, item map[string]interface{}, outputDir string, outputName string, fallbackName string) (map[string]interface{}, error) {
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
ext, label, err := slideScreenshotFormat(item)
if err != nil {
return nil, slidesScreenshotImageDataError(slideID, "%s", err)
}
encoded := strings.TrimSpace(common.GetString(item, "data"))
if encoded == "" {
return nil, slidesScreenshotImageDataError(slideID, "empty image data")
}
imageBytes, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, slidesScreenshotImageDataCauseError(slideID, err, "decode screenshot: %s", err)
}
fileBase := strings.TrimSpace(outputName)
if fileBase == "" {
fileBase = slideID
}
if fileBase == "" {
fileBase = fallbackName
}
path, err := writeUniqueScreenshotFile(runtime, outputDir, fileBase, ext, imageBytes)
if err != nil {
return nil, err
}
return map[string]interface{}{
"slide_id": slideID,
"slide_number": slideScreenshotInt(item, "slide_number"),
"format": label,
"path": path,
"size": len(imageBytes),
}, nil
}
func slideScreenshotListFileBase(presentationID string, item map[string]interface{}, index int) string {
presentationID = strings.TrimSpace(presentationID)
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
slideNumber := slideScreenshotInt(item, "slide_number")
if presentationID != "" {
switch {
case slideNumber > 0 && slideID != "":
return fmt.Sprintf("%s_p%03d_%s", presentationID, slideNumber, slideID)
case slideNumber > 0:
return fmt.Sprintf("%s_p%03d", presentationID, slideNumber)
case slideID != "":
return fmt.Sprintf("%s_%s", presentationID, slideID)
}
}
if slideID != "" {
return slideID
}
if slideNumber := slideScreenshotInt(item, "slide_number"); slideNumber > 0 {
return fmt.Sprintf("slide-%d", slideNumber)
}
return fmt.Sprintf("slide-%d", index+1)
}
func slideScreenshotFormat(item map[string]interface{}) (string, string, error) {
format := slideScreenshotInt(item, "format")
switch format {
case 1:
return "png", "png", nil
case 2:
return "jpg", "jpeg", nil
default:
return "", "", errs.NewAPIError(errs.SubtypeInvalidResponse, "unsupported screenshot format %d", format)
}
}
func slidesScreenshotImageDataError(slideID string, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if slideID != "" {
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
}
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg)
}
func slidesScreenshotImageDataCauseError(slideID string, cause error, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
if slideID != "" {
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
}
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg).WithCause(cause)
}
func slideScreenshotInt(item map[string]interface{}, key string) int {
n, ok := util.ToFloat64(item[key])
if !ok {
return 0
}
return int(n)
}
func doSlidesScreenshotAPIJSONWithLogID(runtime *common.RuntimeContext, method string, apiPath string, query larkcore.QueryParams, body interface{}) (map[string]interface{}, error) {
req := &larkcore.ApiReq{
HttpMethod: method,
ApiPath: apiPath,
QueryParams: query,
}
if body != nil {
req.Body = body
}
resp, err := runtime.DoAPI(req)
if err != nil {
return nil, errs.WrapInternal(err)
}
data, err := runtime.ClassifyAPIResponse(resp)
if err != nil {
return data, err
}
if data == nil {
data = map[string]interface{}{}
}
if logID := strings.TrimSpace(resp.Header.Get("x-tt-logid")); logID != "" {
data["log_id"] = logID
}
return data, nil
}
func enrichSlidesScreenshotSelectorError(err error, slideNumbers []int) error {
if len(slideNumbers) == 0 {
return err
}
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
if p.Hint == "" {
p.Hint = "slide_numbers was rejected by the server; verify the page number exists in this presentation, or retry with --slide-id."
}
return err
}
func slidesScreenshotAPIDataError(data map[string]interface{}, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
err := errs.NewAPIError(errs.SubtypeInvalidResponse, "%s; raw_data=%v", msg, summarizeScreenshotAPIData(data))
if logID := strings.TrimSpace(common.GetString(data, "log_id")); logID != "" {
err = err.WithLogID(logID)
}
return err
}
func isSlidesScreenshotPassthroughError(err error) bool {
_, ok := errs.ProblemOf(err)
return ok
}
func summarizeScreenshotAPIData(v interface{}) interface{} {
switch x := v.(type) {
case map[string]interface{}:
out := make(map[string]interface{}, len(x))
for k, val := range x {
out[k] = summarizeScreenshotAPIData(val)
}
return out
case []interface{}:
out := make([]interface{}, 0, len(x))
for i, val := range x {
if i >= 20 {
out = append(out, fmt.Sprintf("<omitted %d more items>", len(x)-i))
break
}
out = append(out, summarizeScreenshotAPIData(val))
}
return out
case string:
if len(x) > 512 {
return fmt.Sprintf("<omitted string length=%d prefix=%q>", len(x), x[:64])
}
return x
default:
return x
}
}
func safeScreenshotFileBase(base string) string {
name := unsafeScreenshotFileCharRegex.ReplaceAllString(base, "_")
name = strings.Trim(name, "._-")
if name == "" {
name = "slide"
}
return name
}
func writeUniqueScreenshotFile(runtime *common.RuntimeContext, outputDir string, fileBase string, ext string, imageBytes []byte) (string, error) {
base := safeScreenshotFileBase(fileBase)
for i := 0; i < 1000; i++ {
candidateBase := base
if i > 0 {
candidateBase = fmt.Sprintf("%s_%d", base, i+1)
}
path := filepath.Join(outputDir, candidateBase+"."+ext)
if _, err := runtime.FileIO().Stat(path); err == nil {
continue
} else if !isScreenshotFileNotExist(err) {
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: %v", path, err).WithCause(err)
}
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(imageBytes)); err != nil {
return "", common.WrapSaveErrorTyped(err)
}
resolvedPath, err := runtime.ResolveSavePath(path)
if err != nil {
return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve saved screenshot path %s: %v", path, err).WithCause(err)
}
return resolvedPath, nil
}
path := filepath.Join(outputDir, base+"."+ext)
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: too many duplicate file names", path)
}
func isScreenshotFileNotExist(err error) bool {
return os.IsNotExist(err)
}

View File

@@ -0,0 +1,506 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
imageBytes := []byte("png-bytes")
jpegBytes := []byte("jpeg-bytes")
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_id": "slide_1",
"format": 1,
"data": base64.StdEncoding.EncodeToString(imageBytes),
},
{
"slide_id": "slide_2",
"slide_number": 2,
"format": 2,
"data": base64.StdEncoding.EncodeToString(jpegBytes),
},
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1",
"--output-dir", "shots",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
path := filepath.Join(dir, "shots", "pres_abc_slide_1.png")
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read screenshot: %v", err)
}
if string(gotBytes) != string(imageBytes) {
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
}
jpegPath := filepath.Join(dir, "shots", "pres_abc_p002_slide_2.jpg")
gotJPEGBytes, err := os.ReadFile(jpegPath)
if err != nil {
t.Fatalf("read jpeg screenshot: %v", err)
}
if string(gotJPEGBytes) != string(jpegBytes) {
t.Fatalf("written jpeg bytes = %q, want %q", gotJPEGBytes, jpegBytes)
}
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
}
data := decodeShortcutData(t, stdout)
if data["xml_presentation_id"] != "pres_abc" {
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
}
items, ok := data["screenshots"].([]interface{})
if !ok || len(items) != 2 {
t.Fatalf("screenshots = %#v, want two items", data["screenshots"])
}
item, _ := items[0].(map[string]interface{})
if item["slide_id"] != "slide_1" {
t.Fatalf("slide_id = %v, want slide_1", item["slide_id"])
}
gotPath := item["path"].(string)
if !filepath.IsAbs(gotPath) {
t.Fatalf("path = %v, want absolute path", gotPath)
}
if !strings.HasSuffix(gotPath, filepath.Join("shots", "pres_abc_slide_1.png")) {
t.Fatalf("path = %v, want shots/pres_abc_slide_1.png suffix", item["path"])
}
item2, _ := items[1].(map[string]interface{})
if item2["format"] != "jpeg" {
t.Fatalf("format = %v, want jpeg", item2["format"])
}
gotPath2 := item2["path"].(string)
if !filepath.IsAbs(gotPath2) {
t.Fatalf("path = %v, want absolute path", gotPath2)
}
if !strings.HasSuffix(gotPath2, filepath.Join("shots", "pres_abc_p002_slide_2.jpg")) {
t.Fatalf("path = %v, want shots/pres_abc_p002_slide_2.jpg suffix", item2["path"])
}
var body struct {
SlideIDs []string `json:"slide_ids"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideIDs) != 1 || body.SlideIDs[0] != "slide_1" {
t.Fatalf("slide_ids = %#v, want [slide_1]", body.SlideIDs)
}
}
func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_number": 2,
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes")),
},
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-number", "2",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body struct {
SlideNumbers []int `json:"slide_numbers"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideNumbers) != 1 || body.SlideNumbers[0] != 2 {
t.Fatalf("slide_numbers = %#v, want [2]", body.SlideNumbers)
}
path := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_p002.png")
if _, err := os.ReadFile(path); err != nil {
t.Fatalf("read screenshot without slide_id: %v", err)
}
}
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
outputDir := filepath.Join(dir, "shots")
if err := os.MkdirAll(outputDir, 0o755); err != nil {
t.Fatalf("create output dir: %v", err)
}
existingPath := filepath.Join(outputDir, "pres_abc_p002.png")
if err := os.WriteFile(existingPath, []byte("existing"), 0o644); err != nil {
t.Fatalf("write existing screenshot: %v", err)
}
imageBytes := []byte("new-png")
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_number": 2,
"format": 1,
"data": base64.StdEncoding.EncodeToString(imageBytes),
},
},
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-number", "2",
"--output-dir", "shots",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
gotExisting, err := os.ReadFile(existingPath)
if err != nil {
t.Fatalf("read existing screenshot: %v", err)
}
if string(gotExisting) != "existing" {
t.Fatalf("existing screenshot = %q, want unchanged", gotExisting)
}
newPath := filepath.Join(outputDir, "pres_abc_p002_2.png")
gotNew, err := os.ReadFile(newPath)
if err != nil {
t.Fatalf("read deduplicated screenshot: %v", err)
}
if string(gotNew) != string(imageBytes) {
t.Fatalf("deduplicated screenshot = %q, want %q", gotNew, imageBytes)
}
data := decodeShortcutData(t, stdout)
items, ok := data["screenshots"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("screenshots = %#v, want one item", data["screenshots"])
}
item, _ := items[0].(map[string]interface{})
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "pres_abc_p002_2.png")) {
t.Fatalf("path = %v, want shots/pres_abc_p002_2.png suffix", item["path"])
}
}
func TestSlidesScreenshotListRequiresSelector(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--slide-id or --slide-number is required") {
t.Fatalf("error = %v, want missing selector error", err)
}
}
func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
content := `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`
if err := os.WriteFile(filepath.Join(dir, "slide.xml"), []byte(content), 0o644); err != nil {
t.Fatalf("write input xml: %v", err)
}
imageBytes := []byte("rendered-png")
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/slide_image/render",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_image": map[string]interface{}{
"slide_id": "render_slide",
"slide_number": 1,
"format": 1,
"data": base64.StdEncoding.EncodeToString(imageBytes),
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", "@slide.xml",
"--output-dir", "shots",
"--output-name", "preview",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
path := filepath.Join(dir, "shots", "preview.png")
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read rendered screenshot: %v", err)
}
if string(gotBytes) != string(imageBytes) {
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
}
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
}
var body struct {
Content string `json:"content"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if body.Content != content {
t.Fatalf("content = %q, want input XML", body.Content)
}
data := decodeShortcutData(t, stdout)
items, ok := data["screenshots"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("screenshots = %#v, want one item", data["screenshots"])
}
item, _ := items[0].(map[string]interface{})
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "preview.png")) {
t.Fatalf("path = %v, want shots/preview.png suffix", item["path"])
}
}
func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--slide-id", "slide_1",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
t.Fatalf("error = %v, want content/slide selector conflict", err)
}
}
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--presentation", "pres_abc",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--presentation cannot be used with --content") {
t.Fatalf("error = %v, want presentation/content conflict", err)
}
}
func TestSlidesScreenshotDryRunSelectsListOrRenderAPI(t *testing.T) {
t.Run("list", func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-number", "2",
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/xml_presentations/pres_abc/slide_images") {
t.Fatalf("dry-run missing list endpoint: %s", out)
}
if !strings.Contains(out, "slide_numbers") {
t.Fatalf("dry-run missing slide_numbers body: %s", out)
}
})
t.Run("render", func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/slide_image/render") {
t.Fatalf("dry-run missing render endpoint: %s", out)
}
if !strings.Contains(out, "base64_output") {
t.Fatalf("dry-run missing base64 suppression note: %s", out)
}
})
}
func TestSlidesScreenshotRejectsBadOutputDir(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1",
"--output-dir", "../outside",
"--as", "user",
})
if err == nil {
t.Fatal("expected error for unsafe output dir")
}
if !strings.Contains(err.Error(), "--output-dir invalid") {
t.Fatalf("error = %v, want output-dir validation", err)
}
}
func TestSlidesScreenshotNoImagesErrorIncludesRawDataAndLogID(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Headers: map[string][]string{
"Content-Type": {"application/json"},
"X-Tt-Logid": {"log-123"},
},
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"unexpected": "shape",
},
},
})
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "pJJ",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed problem", err)
}
if p.LogID != "log-123" {
t.Fatalf("log_id = %v, want log-123", p.LogID)
}
if !strings.Contains(p.Message, "unexpected:shape") {
t.Fatalf("message = %q, want raw_data summary", p.Message)
}
}
func TestSlidesScreenshotSlideNumberAPIErrorAddsHint(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Headers: map[string][]string{
"Content-Type": {"application/json"},
"X-Tt-Logid": {"log-slide-number"},
},
Body: map[string]interface{}{
"code": 99992402,
"msg": "field validation failed",
},
})
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-number", "25",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed problem", err)
}
if p.LogID != "log-slide-number" {
t.Fatalf("log_id = %v, want log-slide-number", p.LogID)
}
if !strings.Contains(p.Hint, "--slide-id") {
t.Fatalf("hint = %q, want --slide-id guidance", p.Hint)
}
}

View File

@@ -18,6 +18,7 @@ metadata:
## 快速决策
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。

View File

@@ -0,0 +1,168 @@
# 权限治理 Command Patterns
本文只提供 `permission_governance` workflow 的具体 `lark-cli` 命令样例。只有进入对应 state 且需要拼装命令时才读取本文;命令可用范围仍以 [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) 的 `Command Map` 为准。
## 目录
- `目标解析`
- `目标发现`
- `事实读取`
- `写前确认与执行`
## 目标解析
```bash
lark-cli drive +inspect --url '<url>' --as user --format json
```
`/wiki/space/<space_id>` URL 是 Wiki space 范围,不要用 `drive +inspect` 当作单文档解析;直接提取 `space_id` 后进入 `DISCOVER_TARGETS`
## 目标发现
发现 Wiki space / node 下目标:
```bash
lark-cli wiki +node-list \
--space-id '<space_id>' --page-size 50 \
--page-all --page-limit 0 \
--as user --format json
lark-cli wiki +node-list \
--space-id '<space_id>' --parent-node-token '<node_token>' --page-size 50 \
--page-all --page-limit 0 \
--as user --format json
lark-cli wiki +node-list \
--space-id '<space_id>' --page-token '<PAGE_TOKEN>' --page-size 50 \
--as user --format json
```
解析返回时使用 `data.nodes`,不要读取顶层 `items``--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。
发现 Drive folder 下目标:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","page_size":200}' \
--as user --format json
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","page_size":200,"page_token":"<PAGE_TOKEN>"}' \
--as user --format json
```
## 事实读取
读取 metadata
```bash
lark-cli drive metas batch_query \
--data '{"request_docs":[{"doc_token":"<token>","doc_type":"<type>"}],"with_url":true}' \
--as user --format json
```
读取 public permission
```bash
lark-cli drive permission.public get \
--params '{"token":"<token>","type":"<type>"}' \
--as user --format json
```
按需读取访问统计:
```bash
lark-cli drive file.statistics get \
--params '{"file_token":"<token>","file_type":"<type>"}' \
--as user --format json
```
按需读取最近访问记录:
```bash
lark-cli drive file.view_records list \
--params '{"file_token":"<token>","file_type":"<type>","page_size":50}' \
--as user --format json
```
## 写前确认与执行
patch 前检查 manage-public permission
```bash
lark-cli drive permission.members auth \
--params '{"token":"<token>","type":"<type>","action":"manage_public"}' \
--as user --format json
```
patch 前读取当前 schema
```bash
lark-cli schema drive.permission.public.patch --format json
```
只 patch 当前 schema 支持的字段;对 Wiki 目标,必须省略 schema 明确标注为 Wiki 不支持的字段。
显式确认后 patch public permission
```bash
lark-cli drive permission.public patch \
--params '{"token":"<token>","type":"<type>"}' \
--data '{"link_share_entity":"closed","external_access":false}' \
--as user --yes --format json
```
显式确认后申请访问权限:
```bash
lark-cli drive +apply-permission \
--token '<url>' \
--perm view --remark '<reason>' --as user --format json
lark-cli drive +apply-permission \
--token '<bare-token>' --type '<type>' \
--perm view --remark '<reason>' --as user --format json
```
owner 转移前读取当前 schema
```bash
lark-cli schema drive.permission.members.transfer_owner --format json
```
显式确认后转移 owner
```bash
lark-cli drive permission.members transfer_owner \
--params '{"token":"<token>","type":"<type>","need_notification":true,"remove_old_owner":false,"old_owner_perm":"full_access","stay_put":true}' \
--data '{"member_id":"<new_owner_open_id>","member_type":"openid"}' \
--as user --yes --format json
```
`member_type` 只能使用当前 schema 支持的值:`email``openid``userid``appid`。如果用户只给姓名,必须先解析为明确身份或要求用户补充;不要猜测 `member_id`。批量 owner 转移必须逐个目标顺序执行。
secure label 写前枚举可用标签:
```bash
lark-cli drive +secure-label-list \
--page-size 10 --lang zh \
--as user --format json
lark-cli drive +secure-label-list \
--page-size 10 --page-token '<PAGE_TOKEN>' --lang zh \
--as user --format json
```
当用户给出的是标签名称、密级文案或不确定的 label ID 时,必须先枚举并解析为 `label-id`;写入确认里展示目标标签名称和 ID。找不到唯一标签时停止并让用户选择不要猜测。
显式确认后更新 secure label
```bash
lark-cli drive +secure-label-update \
--token '<url>' \
--label-id '<label-id>' --as user --format json
lark-cli drive +secure-label-update \
--token '<bare-token>' --type '<type>' \
--label-id '<label-id>' --as user --format json
```

View File

@@ -0,0 +1,424 @@
# 权限治理输出模板
本文只提供 `permission_governance` workflow 的用户可见输出模板。默认先给简短摘要;只有用户要求完整表格、需要写入确认,或结果大到需要结构化展示时才读取本文。
## 目录
- `输出策略`
- `Semantic Rendering`
- `定位与治理动作`
- `单目标公开性判断`
- `多目标明确列表诊断`
- `审计摘要`
- `容器安全诊断报告摘要`
- `可操作风险清单`
- `治理选择交互`
- `权限设置清单`
- `访问复核清单`
- `整改 dry-run`
- `批量权限申请确认`
- `owner 转移确认`
- `确认请求`
- `最终摘要`
## 输出策略
- 单目标默认输出审计摘要。
- 多目标明确列表默认输出逐目标诊断摘要;不要因为目标数大于 1 就套用容器递归发现报告。
- 用户可见结论默认跟随用户当前语言。用户用中文提问时输出中文,用户用英文提问时输出英文;混合语言时跟随主要语言。
- 单目标公开性判断默认输出业务表达,不直接展示 `link_share_entity``external_access_entity``external_access` 等底层字段名;只有用户要求 raw evidence、排障或完整清单 / artifact 场景才展示底层字段。
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“文档公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。它在官方语义中包含链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论;具体可判断字段以当前 CLI schema 和实际响应为准。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
- 容器目标默认输出安全诊断报告摘要:一句话结论、覆盖情况、风险分级、优先处理对象、建议下一步和剩余限制。
- 容器目标不要把风险按数量机械排序;外部公开、允许对外分享、缺失密级标签优先于复制 / 下载 / 评论这类依赖策略的候选项。
- 用户没有提供明确 policy 时,使用“候选风险 / 待复核 / 待策略确认”,不要写“违规 / 已泄露 / 已外部访问”。
- 容器安全诊断里不要把 `external_access=true` / `external_access_entity=open` 简写成“高风险”或“外部泄露”;用户可见说法应为“允许对外分享,需 owner 复核;这不等于已经存在外部协作者”。
- 风险对象展示按规模渐进披露1-10 个全部展示11-30 个展示全部高优先级待复核对象,中 / 低优先级只做分组摘要31-100 个按高优先级待复核分组展示 Top 5 和数量100+ 个只展示分组统计和 Top 样例。
- 当摘要未展示全部风险对象时,必须明确“完整清单包含 <count> 条”,并提供生成 Markdown / CSV / 飞书文档风险清单或整改 dry-run 的下一步。
- 只要发现需要处理的对象,最终回复必须给出可执行下一步 CTA。不能因为默认只读就只报告风险后结束。
- 完整风险清单是后续治理选择的输入Markdown / CSV / 飞书文档报告必须使用同一套字段和稳定 `risk_id`
- 写入前必须使用确认模板权限申请、文档公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
- 最终回复必须包含已完成事项、验证结果和剩余限制;异步权限申请审批不能表述为已完成授权。
## Semantic Rendering
面向用户的主结论优先渲染 `per_target_permission_assessment` 中的语义状态,并使用用户当前语言;底层字段名只在 raw evidence、排障或完整清单中保留。下表给出字段值到业务表达的标准映射其他语言应表达同等业务含义。
字段来源边界:下表同时覆盖官方 OpenAPI 语义和当前 / 未来 CLI schema。只有实际响应或当前 schema 返回的字段和值,才可渲染为确定状态;当前 installed CLI 未返回的字段(例如 `copy_entity``manage_collaborator_entity``external_access_entity`)或未出现的枚举值,只能在 raw response / schema 实际出现时使用,缺失时必须按 unknown / unsupported 处理,不要臆造。
| Raw field / value | Semantic State | 中文说法 | English phrasing |
|-------------------|----------------|----------|------------------|
| `link_share_entity=anyone_readable` | `link_access=public_readable` | 互联网上获得链接的任何人可阅读 | Anyone on the internet with the link can read |
| `link_share_entity=anyone_editable` | `link_access=public_editable` | 互联网上获得链接的任何人可编辑 | Anyone on the internet with the link can edit |
| `link_share_entity=partner_tenant_readable` | `link_access=partner_readable` | 关联组织内知道链接可读 | People in partner tenants with the link can read |
| `link_share_entity=partner_tenant_editable` | `link_access=partner_editable` | 关联组织内知道链接可编辑 | People in partner tenants with the link can edit |
| `link_share_entity=tenant_readable` | `link_access=tenant_readable` | 公司内知道链接可读 | People in the tenant with the link can read |
| `link_share_entity=tenant_editable` | `link_access=tenant_editable` | 公司内知道链接可编辑 | People in the tenant with the link can edit |
| link sharing empty / disabled | `link_access=closed` | 未开启链接分享 | Link sharing is disabled |
| `external_access_entity=open` or `external_access=true` | `external_sharing=open` | 允许分享到组织外;不等于已经存在外部协作者 | External sharing is open; this does not mean external collaborators already exist |
| `external_access_entity=allow_share_partner_tenant` | `external_sharing=partner_only` | 仅允许分享到关联组织 | Sharing is allowed only with partner tenants |
| `external_access_entity=closed` or `external_access=false` | `external_sharing=closed` | 当前不允许分享到组织外 | External sharing is disabled |
| `invite_external=true` | `external_invitation=enabled` | 当前允许邀请外部用户 | Inviting external users is enabled |
| `invite_external=false` | `external_invitation=disabled` | 当前不允许邀请外部用户 | Inviting external users is disabled |
| `share_entity=anyone` | `collaborator_org_scope=all_viewers_or_editors` | 所有可阅读或可编辑者可查看、添加、移除协作者 | All viewers or editors can view, add, and remove collaborators |
| `share_entity=same_tenant` | `collaborator_org_scope=tenant_viewers_or_editors` | 组织内可阅读或可编辑者可查看、添加、移除协作者 | Tenant viewers or editors can view, add, and remove collaborators |
| `manage_collaborator_entity=collaborator_can_view` | `collaborator_permission_scope=viewer` | 拥有可阅读权限的协作者可查看、添加、移除协作者 | Collaborators with view permission can view, add, and remove collaborators |
| `manage_collaborator_entity=collaborator_can_edit` | `collaborator_permission_scope=editor` | 拥有可编辑权限的协作者可查看、添加、移除协作者 | Collaborators with edit permission can view, add, and remove collaborators |
| `manage_collaborator_entity=collaborator_full_access` | `collaborator_permission_scope=full_access` | 拥有可管理权限的协作者可查看、添加、移除协作者 | Collaborators with full-access permission can view, add, and remove collaborators |
| `copy_entity=anyone_can_view` | `copy_scope=viewer` | 拥有可阅读权限的用户可复制内容 | Users with view permission can copy content |
| `copy_entity=anyone_can_edit` | `copy_scope=editor` | 拥有可编辑权限的用户可复制内容 | Users with edit permission can copy content |
| `copy_entity=only_full_access` | `copy_scope=full_access` | 仅拥有可管理权限的协作者可复制内容 | Only collaborators with full-access permission can copy content |
| `security_entity=anyone_can_view` | `security_scope=viewer` | 拥有可阅读权限的用户可创建副本、打印、下载 | Users with view permission can create copies, print, and download |
| `security_entity=anyone_can_edit` | `security_scope=editor` | 拥有可编辑权限的用户可创建副本、打印、下载 | Users with edit permission can create copies, print, and download |
| `security_entity=only_full_access` | `security_scope=full_access` | 仅拥有可管理权限的用户可创建副本、打印、下载 | Only users with full-access permission can create copies, print, and download |
| `comment_entity=anyone_can_view` | `comment_scope=viewer` | 拥有可阅读权限的用户可评论 | Users with view permission can comment |
| `comment_entity=anyone_can_edit` | `comment_scope=editor` | 拥有可编辑权限的用户可评论 | Users with edit permission can comment |
| `lock_switch=true` | `lock_state=locked_not_inheriting` | 已限制权限,不再继承父级页面权限 | The node is locked and no longer inherits parent-page permissions |
| `lock_switch=false` | `lock_state=not_locked_or_inheriting` | 未限制权限,可能继承父级页面权限 | The node is not locked and may inherit parent-page permissions |
| field absent / unsupported | `<state>=unknown` | 当前 schema 未返回,无法判断 | The current schema did not return this field, so it is unknown |
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前文档公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
| `sec_label_name` missing | `sec_label=missing` | 缺少密级标签 | Security label is missing |
## 定位与治理动作
风险对象必须能让用户直接定位和处理:
- 摘要中的每个优先处理对象必须包含 `risk_id``path/title``URL``type`、owner、sec_label、风险原因、关键证据和建议动作。
- 完整清单、访问复核清单、整改 dry-run 和写入确认都必须包含 URL。缺少 URL 时,展示 token / node_token并说明 URL 未能获取。
- 同名文档、shortcut 或副本必须用 path + URL 区分;不要只输出 title。
- 完整风险清单中的每条记录必须有稳定 `risk_id`,格式为 `PG-001``PG-002``risk_id` 在同一次诊断和后续 dry-run / 确认 / 验证中保持不变。
- 即使摘要只展示 Top 样例,也必须给样例分配稳定 `risk_id`;不能输出无法选择的标题列表。
- 建议动作必须和风险类型绑定:互联网公开链接优先建议关闭链接分享或收紧为组织内;允许对外分享优先建议 owner 复核或关闭对外分享;缺少密级标签优先建议补齐密级;复制 / 下载 / 评论范围只在用户 policy 明确时建议收紧。
- 写入动作只能作为下一步选项或确认请求出现。不要在诊断摘要里暗示已经执行缩权。
## 单目标公开性判断
`intent=public_exposure_check``target_scope=single_resource` 时,使用此模板。默认渲染 `target_count=1``per_target_permission_assessment`,跟随用户当前语言,不直接展示底层字段名;用户要求 raw evidence 时,再追加字段证据。
中文模板:
```text
结论:<不是对外公开 / 存在互联网公开链接 / 允许对外分享>。
目标:<title>
URL<url-or-token-if-url-unavailable>
类型:<type>
当前链接访问范围:<render link_access>
对外分享:<render external_sharing>
外部邀请:<render external_invitation or omit if unknown because field is absent>
协作者管理(组织维度):<render collaborator_org_scope>
协作者管理(权限维度):<render collaborator_permission_scope or omit if unknown because field is absent>
复制内容:<render copy_scope or omit if unknown because field is absent>
创建副本 / 打印 / 下载:<render security_scope>
评论:<render comment_scope>
Wiki 继承限制:<render lock_state or omit if unknown because field is absent>
检查边界:<render check_scope>
```
English template:
```text
Conclusion: <Not publicly accessible on the internet / A public internet link is enabled / External sharing is enabled>.
Target: <title>
URL: <url-or-token-if-url-unavailable>
Type: <type>
Current link access: <render link_access>
External sharing: <render external_sharing>
External invitations: <render external_invitation or omit if unknown because field is absent>
Collaborator management by tenant: <render collaborator_org_scope>
Collaborator management by permission: <render collaborator_permission_scope or omit if unknown because field is absent>
Copy content: <render copy_scope or omit if unknown because field is absent>
Create copies / print / download: <render security_scope>
Comments: <render comment_scope>
Wiki inheritance lock: <render lock_state or omit if unknown because field is absent>
Check boundary: <render check_scope>
```
Raw evidence, only when requested:
```text
Evidence fields:
- link_share_entity=<value>
- external_access_entity=<value>
- external_access=<value>
- invite_external=<value>
- share_entity=<value>
- manage_collaborator_entity=<value>
- copy_entity=<value>
- security_entity=<value>
- comment_entity=<value>
- lock_switch=<value>
```
## 多目标明确列表诊断
`target_scope=explicit_list` 时,使用此模板。该场景不执行容器递归发现;对用户提供的每个 URL / token 逐个生成 `per_target_permission_assessment`,再按风险分组聚合。权限语义和单目标、容器诊断完全复用,不新增判断模型。
```text
已完成只读权限诊断,没有做任何权限修改。
一句话结论:<N> 个目标中,<risk_count> 个存在待复核权限风险;<internet_public_count> 个存在互联网公开链接候选,<external_access_count> 个允许对外分享,<unknown_count> 个无法完整判断。
覆盖情况:
- 用户提供目标:<input_target_count>;成功解析:<resolved_count>
- 成功读取文档公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
逐目标结果1-10 个目标默认全部展示;超过 10 个时按 `摘要清单展开规则` 展示,并提示生成完整风险清单):
- <risk_id-or-item_id> <path-or-title> (<type>)
URL: <url-or-token-if-url-unavailable>
结论:<not_public / public_link_enabled / external_sharing_enabled / policy_review / unknown>
关键权限:<render link_access>; <render external_sharing>; <render security_scope>; <render comment_scope>
密级:<sec_label_name-or-missing-or-unknown>
待复核原因:<risk reason or none>
建议动作:<recommended action or no action>
分组摘要:
- 互联网公开链接候选:<count>;允许对外分享:<count>;公司内链接可访问 / 可编辑:<count>
- 复制 / 下载 / 打印 / 评论待策略确认:<count>;无法判断:<count and reason summary>
建议下一步:
- 处理明确的 <risk_id>,先生成只读 dry-run。
- 生成完整风险清单 artifact后续可按 `risk_id`、风险分组、URL 或 `selected=true` 选择治理范围;只看权限设置时改用 `权限设置清单`。
```
## 摘要清单展开规则
容器安全诊断的摘要必须兼顾可读性和可治理性。不要用固定 Top N 代替可处理清单。
| 风险对象数 | 摘要默认展示 | 必须提供的下一步 |
|------------|--------------|------------------|
| `0` | 只展示覆盖情况、未覆盖能力和剩余限制 | 如需更细审计,可生成权限设置清单 |
| `1-10` | 展示全部风险对象 | 可直接按 `risk_id` 生成 dry-run 或写入确认 |
| `11-30` | 展示全部高优先级待复核对象;中 / 低优先级做分组摘要 | 生成完整风险清单 artifact或按风险分组生成 dry-run |
| `31-100` | 每个高优先级待复核分组展示 Top 5附未展示数量 | 生成 Markdown / CSV / 飞书文档完整风险清单 |
| `100+` | 只展示分组统计、Top 样例和覆盖限制,不内联长表 | 强烈建议生成结构化风险清单后再选择治理范围 |
高优先级待复核对象包括:互联网公开链接、允许对外分享、允许对外分享且缺少 / 低于 policy 密级标签、公司内可编辑链接。协作者管理范围较宽默认归入中优先级待复核;只有用户 policy 明确要求严格协作者管理时才提升优先级。复制 / 下载 / 打印、评论范围在用户未提供明确 policy 时归入“待策略确认”,不要挤占高优先级清单。
摘要中的每个待复核对象必须包含 `risk_id`、path/title、URL、type、owner、sec_label、风险原因、关键证据和建议动作。对同一底层文档的多个 Wiki 入口或 shortcut必须用 URL 区分;如果建议合并治理,在建议动作里说明它们指向同一底层对象。
## 审计摘要
```text
目标:<title> (<type>)
URL<url-or-token-if-url-unavailable>
结论:<合规 / 待确认风险 / 无法完整判断>
证据:
- link_share_entity=<value>
- external_access_entity=<value>
- external_access=<value>
- invite_external=<value>
- share_entity=<value>
- manage_collaborator_entity=<value>
- copy_entity=<value>
- security_entity=<value>
- comment_entity=<value>
- lock_switch=<value>
- sec_label_name=<value-or-missing>
限制:<unsupported_checks or none>
建议动作:<read-only next step or proposed remediation>
```
## 容器安全诊断报告摘要
```text
已完成只读安全诊断,没有做任何权限修改。
一句话结论:<未发现互联网公开链接 / 存在互联网公开链接候选风险><external_access_count> 个文档允许对外分享,<missing_label_count> 个文档缺少密级标签。建议优先复核 <top_priority_group_or_paths>。
覆盖情况:
- 当前身份可见目标:<visible_count>
- 已成功检查文档公共访问和协作权限设置:<permission_checked_count>
- 读取失败 / 已删除 / 无权限:<failed_count>
- 未覆盖能力:<collaborator_list / inheritance / audit_log / view_records / none>
风险分级:
- 高优先级待复核:<internet_public_count> 个互联网公开链接候选;<external_access_count> 个允许对外分享;其中 <external_without_label_count> 个同时缺少密级标签。
- 中优先级待复核:<tenant_link_count> 个公司内知道链接可访问 / 可编辑;<wide_share_count> 个协作者管理范围较宽。
- 待策略确认:<security_count> 个复制 / 下载 / 打印范围待复核;<comment_count> 个评论范围待复核。
- 无法判断:<unsupported_or_unverified_summary>。
分级含义:
- 互联网公开链接:获得链接的任何人可能访问,最高优先级。
- 允许对外分享:外部分享能力已开启,需 owner 复核;不等于已经存在外部协作者。
- 公司内链接可访问:不是对外公开,但组织内扩散范围较宽。
- 复制 / 下载 / 打印 / 评论:是否需要收紧取决于业务 policy 和文档密级。
高优先级待复核清单:
> 按 `摘要清单展开规则` 展示。每个对象必须包含 `risk_id` 和 URL缺少 URL 时展示 token / node_token 和原因。若没有高优先级对象,只展示中优先级或待策略确认分组摘要。
- <risk_id> <path-or-title> (<type>)
URL: <url-or-token-if-url-unavailable>
Owner: <owner-or-unknown>
密级:<sec_label_name-or-missing-or-unknown>
待复核原因:<why high priority>
证据:<short user-language evidence, e.g. 对外分享=已开启;链接分享=未开启互联网公开链接>
建议动作:<recommended action>
未完全展开:
- 完整风险清单包含 <risk_manifest_count> 条;本摘要已展示 <shown_count> 条,未展示 <hidden_count> 条。
- 未展示分组:<risk_group=count summary or none>
建议下一步:
- 生成完整风险清单 artifact包含 `risk_id`、URL、owner、密级、证据字段、建议动作和 `selected` 列。
- 基于 risk_id、风险分组、owner、路径、URL 或 artifact 中 `selected=true` 的行生成只读整改 dry-run。
- 只针对最高优先级目标进入写入确认流程,例如关闭互联网公开链接或收紧对外分享;写入前仍需二次确认。
- 按 owner / 密级生成复核清单。
- 继续读取访问记录,判断低活跃高暴露。
剩余限制:
- <do not claim collaborator-list verification if unsupported>
- <external_access_entity=open or external_access=true only means sharing outside is allowed, not that external collaborators exist>
- <missing view_records / DLP / AI index status / audit log limitations>
```
## 可操作风险清单
完整风险清单用于让用户选择后续治理范围。Markdown / CSV / 飞书文档报告都必须包含以下字段;如果某种格式无法完整展示嵌套证据,使用短文本摘要,保留 `risk_id` 和 URL。
```text
范围:<explicit_list / wiki_space / wiki_node / drive_folder> <name-or-id>
生成时间:<timestamp>
用途:用户可按 risk_id、priority、risk_group、owner、path、URL 或 selected=true 选择治理对象。
| risk_id | priority | Path | URL | Type | Owner | sec_label | risk_group | evidence | recommended_action | current_setting | target_setting | selected | decision | status | skip_reason |
|---------|----------|------|-----|------|-------|-----------|------------|----------|--------------------|-----------------|----------------|----------|----------|--------|-------------|
| PG-001 | P1 | <path> | <url-or-token> | <type> | <owner-or-unknown> | <sec-label-or-missing> | <risk_group> | <short evidence> | <recommended-action> | <field=value> | <field=value-or-owner-review> | false | undecided | pending | <none-or-reason> |
```
字段规则:
- `risk_id` 按 priority、risk_group、normalized path、URL、canonical token / node_token 稳定排序生成URL 缺失时必须使用 token / node_token 作为 tie-breaker。同名、同路径、shortcut 或多个 Wiki 入口不能只靠 path 生成编号;同一次诊断中不得重复。
- `priority` 使用 `P0``P1``P2``PolicyReview``Unknown`;面向用户展示时可译为“最高优先级 / 高优先级待复核 / 中优先级待复核 / 待策略确认 / 无法判断”。
- `selected` 默认 `false`;用户可在 CSV / 飞书文档表格中改为 `true`,或在聊天中直接说 “处理 PG-001、PG-003”。
- `decision` 表示用户决策:`undecided``keep``dry_run``confirm_write``skip`
- `status` 表示执行状态:`pending``dry_run_ready``confirmed``executed``verified``failed``skipped`
- `target_setting` 是建议目标状态,不代表已执行;没有明确 policy 时只能写 owner review / policy review。
## 治理选择交互
用户基于完整风险清单继续治理时Agent 必须先解析选择范围,再生成只读 dry-run
```text
可接受的用户选择:
- 处理 PG-001、PG-003、PG-008把互联网公开链接关闭。
- 先处理所有 risk_group=internet_public_link不处理 external_access_only。
- 把 CSV / 飞书文档里 selected=true 的行生成整改 dry-run。
- PG-003 先跳过,只处理 PG-001。
Agent 必须回复:
- 已选择对象数:<count>
- 选择来源:<risk_id list / risk_group / selected=true / URL / path>
- 将执行的下一步:生成 dry-run不执行写入
- 需要跳过或重新确认的对象:<missing risk_id / unsupported / changed_since_report / no manage_public>
```
如果用户选择来自旧报告或外部 artifact生成 dry-run 前必须对所选目标重新读取当前权限。当前设置和报告快照不一致时,标记为 `changed_since_report`,不要直接沿用旧字段执行。
## 权限设置清单
```text
范围:<explicit_list / wiki_space / wiki_node / drive_folder> <name-or-id>
| Path | URL | Type | link_share_entity | external_access_entity / external_access | invite_external | share_entity | manage_collaborator_entity | copy_entity | security_entity | comment_entity | lock_switch | sec_label_name | 建议动作 | 限制 |
|------|-----|------|-------------------|------------------------------------------|-----------------|--------------|----------------------------|-------------|-----------------|----------------|-------------|----------------|----------|------|
| <path> | <url-or-token> | <type> | <value> | <value> | <value-or-unknown> | <value> | <value-or-unknown> | <value-or-unknown> | <value> | <value> | <value-or-unknown> | <value-or-missing> | <recommended-action> | <unsupported-or-none> |
```
## 访问复核清单
```text
范围:<wiki_space / wiki_node / drive_folder / explicit_list> <name-or-id>
复核对象数:<count>
| Owner | Path | URL | Type | 密级 | 风险标签 | 当前权限摘要 | 最近访问证据 | 建议动作 |
|-------|------|-----|------|------|----------|--------------|--------------|----------|
| <owner-or-unknown> | <path> | <url-or-token> | <type> | <sec-label-or-missing> | <labels> | <link/external/share/security/comment> | <uv/pv/last_view_or_unknown> | <keep / tighten / owner review / unsupported> |
限制:<unsupported_checks / discovery_blockers / none>
```
## 整改 dry-run
```text
将生成整改计划,不执行写入:
- 范围:<scope>
- 选择来源:<risk_id list / risk_group / selected=true artifact / URL list>
- 候选目标数:<count>
- 计划执行命令:<command family>
- 重新读取已对所选目标重新读取当前权限changed_since_report=<count>
- 字段变更:
- <risk_id> <path> (<url-or-token>): <field> <old> -> <new>
- 跳过项:<unsupported / no manage_public / unsupported type / missing policy>
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
- 有限回滚范围:<文档公共访问和协作权限设置快照字段 / 不适用>
请确认是否进入写入确认。
```
## 批量权限申请确认
```text
将逐个发起 <view / edit> 权限申请:
- 候选目标数:<count>
- 命令类型drive +apply-permission
- 风险write每个请求都会通知 owner
- 执行方式:按候选列表顺序逐个调用,失败项会单独记录
候选示例:
- <risk_id> <title> (<type>, <url-or-token>)<reason>
请确认是否对上述候选目标发起权限申请。
```
## owner 转移确认
```text
将逐个转移 owner
- 候选目标数:<count>
- 命令类型drive permission.members transfer_owner
- 风险high-risk-write会改变文档 owner可能影响原 owner 权限和文档所在位置
- 新 owner 映射:<same_new_owner / per_target_new_owner>
- 全局新 owner<member_id> (<member_type>);仅当所有候选目标的新 owner 相同时展示,否则省略
- 通知新 owner<need_notification>
- 原 owner 权限:<remove_old_owner=true / old_owner_perm>
- 个人空间位置:<stay_put>
- 执行方式:按候选列表顺序逐个调用,失败项会单独记录
- 验证方式:执行后重新读取 metadata ownermetadata 不支持的类型标记为 partial
- 回滚边界:不做自动回滚;如需恢复 owner必须另起一次反向 owner 转移确认
候选示例:
- <risk_id> <title> (<type>, <url-or-token>):当前 owner=<owner-or-unknown> -> 新 owner=<member_id> (<member_type>)
请确认是否对上述候选目标转移 owner。
```
## 确认请求
```text
将执行 <operation>
- 目标:<risk_id> <title> (<type>, <url-or-token>)
- 命令类型:<command family>
- 风险:<risk_level>
- 字段变更:
- <field>: <old> -> <new>
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
- 有限回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
请确认是否执行。
```
## 最终摘要
```text
已完成:<read checks / writes>
验证:<fresh read result or async permission-request approval note>
清单状态:<risk_id status updates / not applicable>
回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
剩余限制:<unsupported_checks / partial facts / approvals>
```

View File

@@ -0,0 +1,207 @@
# lark-drive 权限治理 Workflow
Workflow id: `permission_governance`
Risk / Structure: `R2` / `S2`
本文实现已注册的权限治理 workflow。执行前必须先读取 [`lark-drive-workflow.md`](lark-drive-workflow.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)并遵循共享执行协议、Artifact Contract、Workflow Loading、认证和写入确认规则。
## 适用范围
当用户要求检查或治理 Drive / Docs / Wiki 资产访问权限时,使用本 workflow。典型意图包括
- 单资源公开性、外部访问、公司内链接、分享 / 复制 / 下载 / 评论设置检查。
- 多资源、Wiki space / node、Drive folder 或个人文档库的权限风险诊断和权限设置清单。
- 访问复核、低活跃高暴露、权限申请、owner 转移、密级标签调整、AI Agent / RAG 前置权限治理。
- 只读整改 dry-run或经确认后的权限收紧 / 权限申请 / owner 转移 / 密级标签更新。
目标可以是明确 URL / token、小规模明确列表、Wiki space / Wiki node 或 Drive folder。容器范围必须先只读 `DISCOVER_TARGETS` 并产出覆盖摘要;这里的"所有文档"只表示当前身份在确认范围内可枚举到的文档。任何写入都必须再次确认。
单目标轻量路径:用户只问“是否对外公开 / 外部可访问 / 公司内链接可见”且目标是单个明确 URL / token 时,设置 `intent=public_exposure_check``target_scope=single_resource`,走 `PARSE_INTENT -> TARGET_INSPECT -> FACT_READ -> RISK_ASSESS -> DONE`。该路径是 `target_count=1` 的轻量输出模式,不是独立判断逻辑;不执行 `DISCOVER_TARGETS`、不生成 `risk_manifest` / `risk_id`,只输出结论、权限含义、检查边界和必要下一步。
## Target Set Evaluation
本 workflow 不按“单篇 / 多篇 / 容器”复制权限判断逻辑。所有范围先归一为 target set再对每个可审计目标生成 `per_target_permission_assessment`,最后按目标数量和风险分组聚合输出。
| target_scope | Target Collection | Output Mode |
|--------------|-------------------|-------------|
| `single_resource` | 直接解析一个 URL / token | `target_count=1` 时轻量渲染;不生成 `risk_manifest` |
| `explicit_list` | 用户给出的多个 URL / token 逐个 inspect / normalize | 逐目标渲染摘要;需要后续治理时生成稳定 `risk_id` |
| `wiki_space` / `wiki_node` / `drive_folder` | 先只读递归发现,再归一化为 `discovered_targets` | 输出覆盖情况、风险分组、可定位待复核对象和 artifact / dry-run CTA |
特殊的是目标收集和输出聚合,不是权限语义。`link_access``external_sharing``copy_scope``security_scope``comment_scope``sec_label``check_scope` 等语义字段必须在单目标、多目标明确列表和容器发现目标之间复用。
## 非目标
本 workflow 不处理:
- 目录组织、迁移、归档或清理;这类需求应使用知识整理 workflow。
- 内容审查、过期内容判断或知识质量评分。
- backup owner 补充、部门 / 项目负责人绑定、协作者创建 / 撤销、成员列表审计;本 workflow 只支持把 owner 转移给每个目标明确指定的新 owner不建模 backup owner 或负责人绑定关系。
- 文件夹自身公开权限审计或修复。`drive permission.public get` / `patch` 不支持 `type=folder`;必须记录到 `unsupported_checks`,然后继续读取文件夹下其他支持的文档事实。
- 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。
- 未按范围确认的批量写入。
不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut。
## Progressive Load Map
本表只规定每个 state 需要加载的额外上下文;命令可用范围以 `Command Map` 为准。需要拼装具体 `lark-cli` 命令时,再按需读取 [`lark-drive-workflow-permission-governance-commands.md`](lark-drive-workflow-permission-governance-commands.md)。
| State | Required Reference |
|-------|--------------------|
| `PARSE_INTENT` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) |
| `TARGET_INSPECT` | [`lark-drive-inspect.md`](lark-drive-inspect.md) |
| `DISCOVER_TARGETS` | 容器范围时读取 [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md) 或 [`lark-drive-files-list.md`](lark-drive-files-list.md) |
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及公开权限时再读取 `lark-cli schema drive.permission.public.get`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get``lark-cli schema drive.file.view_records.list` |
| `RISK_ASSESS` | 本文件的 `Risk Classification` |
| `EXEC_CONFIRM` | 只为用户选择的动作读取 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)、[`lark-drive-secure-label.md`](lark-drive-secure-label.md),或 `lark-cli schema drive.permission.public.patch` / `lark-cli schema drive.permission.members.transfer_owner`;需要确认模板时读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) |
| `EXECUTE` | 复用 `EXEC_CONFIRM` 已加载且已确认的写命令上下文 |
| `VERIFY` | 复用 `FACT_READ` 阶段使用的 read schemas |
## Runtime State Extension
本 workflow 在共享 `Artifact Contract` 基础上扩展以下字段组:
| Group | Fields | Meaning |
|-------|--------|---------|
| Scope | `intent`, `target_scope`, `targets`, `discovered_targets`, `coverage_summary`, `discovery_blockers` | 记录用户意图、确认范围、直接目标、容器发现目标和未覆盖范围 |
| Facts | `metadata_facts`, `public_permission_facts`, `activity_facts`, `manage_public_auth` | 记录 metadata、公共访问与协作权限、访问证据以及写前 `manage_public` 校验 |
| Assessment | `per_target_permission_assessments`, `risk_findings`, `unsupported_checks` | 记录逐目标语义判断、带 `risk_id` / URL / owner / sec_label / evidence / action 的风险发现,以及无法执行的检查 |
| Governance | `risk_manifest`, `selected_risk_items`, `access_review_items`, `permission_request_candidates`, `owner_transfer_candidates` | 支持用户按 `risk_id`、风险分组、owner、路径、URL 或 artifact `selected=true` 选择治理范围,并记录 owner 转移候选 |
| Execution | `remediation_plan`, `owner_transfer_plan`, `public_permission_snapshots` | 记录 dry-run / 已确认整改计划、owner 转移计划、字段 diff、验证方式和 public-permission 有限回滚快照 |
## Execution State Machine
| State | Protocol Step | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|-------|---------------|---------------|--------------------|---------------|------------|
| `PARSE_INTENT` | `route` / `scope` | 解析 intent、target scope、desired policy以及只读审计、单目标公开性判断、权限申请、owner 转移还是修复模式;单目标公开性判断设置 `intent=public_exposure_check``target_scope=single_resource` | 范围确认;如果缺少目标、新 owner 或期望动作,只问一个澄清问题 | 缺少 target / new owner / action或容器范围需要用户确认时为 `true` | `TARGET_INSPECT` |
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
| `DISCOVER_TARGETS` | `scope` / `read` | 对 Wiki space / node 或 Drive folder 递归只读枚举,归一化为 `discovered_targets`;记录 `discovery_blockers` | 发现进度和覆盖摘要;不展示内部 cursor/token除非用户要求 | 除非发现范围无法确认或全部被阻断,否则为 `false` | `FACT_READ` |
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的非 folder 目标执行 `drive permission.public get`;当 `intent=public_exposure_check``target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type只补读文档公共访问和协作权限设置在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
| `RISK_ASSESS` | `assess/plan` | 对每个可审计目标生成 `per_target_permission_assessment` 并分类证据;如用户提供 policy则对照 policy`public_exposure_check + single_resource` 只渲染单目标结论,不生成 `risk_id`owner 转移路径生成 `owner_transfer_candidates` / `owner_transfer_plan`治理路径构建可定位风险清单、访问复核清单、dry-run 整改计划或候选修复计划,完整清单必须生成稳定 `risk_id` | 带 priority、URL、risk_id、owner、sec_label 的 findings、confidence、review items、建议动作和下一步 CTA单目标公开性判断只输出结论和关键字段 | 治理路径为 `true`,单目标公开性判断为 `false` | `EXEC_CONFIRM` or `DONE` |
| `EXEC_CONFIRM` | `confirm` | 展示准确写入范围、command family、target count、risk、verification method | 确认请求 | `true` | `EXECUTE` or `DONE` |
| `EXECUTE` | `execute` | 只执行 `Command Map` 中已确认的写入 | 进度 / 结果摘要 | 除非被阻断,否则为 `false` | `VERIFY` |
| `VERIFY` | `verify` | 重新执行支持的读取,并与目标状态对比 | 验证表和剩余缺口 | `false` | `DONE` |
| `DONE` | `done` | 停止 | 最终回复,包含完成事项、验证结果和剩余风险 | `false` | End |
## Command Map
本 workflow 只能使用以下 command families
| State | Allowed Command Families | Purpose |
|-------|--------------------------|---------|
| `TARGET_INSPECT` | `drive +inspect` | 解析 URL、type、canonical token、title 和 wiki unwrap data |
| `DISCOVER_TARGETS` | `wiki +node-list` | 递归发现 Wiki space / node 下当前身份可见的节点 |
| `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 |
| `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata |
| `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 |
| `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 |
| `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 |
| `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs |
| `EXEC_CONFIRM` | `drive permission.members auth` | 文档公共访问和协作权限设置修改前检查 `action=manage_public` |
| `EXEC_CONFIRM` | `lark-cli schema drive.permission.members.transfer_owner` | owner 转移前读取当前字段、支持类型和高风险写入门禁 |
| `EXECUTE` | `drive +apply-permission` | 向 owner 提交 view/edit access request只允许单目标、小列表或已明确确认的候选列表逐个执行 |
| `EXECUTE` | `drive permission.public patch` | 修改已确认的 public/link settings必须传 `--yes` |
| `EXECUTE` | `drive permission.members transfer_owner` | 转移已确认目标的 owner必须传 `--yes` |
| `EXECUTE` | `drive +secure-label-update` | 设置已确认的 secure-label ID |
| `VERIFY` | `drive metas batch_query`, `drive permission.public get` | 验证支持的 metadata包括 owner、secure-label 和文档公共访问与协作权限设置变更;权限申请只能表述为已发起 |
## Command Patterns
本入口不内联命令样例。需要拼装具体 `lark-cli` 命令时,按当前 state 读取 [`lark-drive-workflow-permission-governance-commands.md`](lark-drive-workflow-permission-governance-commands.md)。命令是否允许执行仍以 `Command Map` 和写入规则为准。
## Discovery Rules
容器范围只能先做只读发现和覆盖摘要,不能在发现阶段执行权限申请、权限 patch 或密级更新。
通用规则:
1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers``unsupported_checks`
2. 发现阶段必须生成稳定 `path`。不要只保存 title同名文档必须能通过 path 或 token 区分。
3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc``sheet``file``wiki``bitable``docx``mindnote``minutes``slides`;未来新增类型以运行时 schema 为准。
4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`URL、owner、密级等 metadata 可能进入 `unsupported_checks`
5. `folder` 只作为递归容器,不执行 `permission.public get` / `patch`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut``catalog` 或缺少 stable token/type 的条目必须记录为 unsupported除非后续 API 明确解析出支持目标。
6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker不要默认展示内部 page token / cursor。
Wiki space / node 发现:
1. `/wiki/space/<space_id>` 直接解析为 `target_scope=wiki_space`。不要因为 `drive +inspect` 对该 URL 返回 not found 就停止。
2.`wiki +node-list --space-id <space_id>` 读取根节点;当节点 `has_child=true` 时,用该节点的 `node_token` 继续递归读取子节点。
3. Wiki 节点必须同时保留 `node_token``obj_token``obj_type`。权限读取优先用 `type=wiki` + `node_token` 表达 Wiki 节点权限;元数据补充可使用 `obj_type` + `obj_token`
4. 如果节点只有 `obj_token` / `obj_type`,但无法确认 Wiki 节点权限 token保留该目标为 partial并在 `unsupported_checks` 中说明只能读取底层对象或无法完整判断 Wiki 节点权限。
Drive folder 发现:
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。文件夹自身公开权限不支持;继续枚举其子文档。
2. 按 [`lark-drive-files-list.md`](lark-drive-files-list.md) 递归处理 `data.files``has_more``next_page_token`。不要把第一页数量当作完整范围。
3. 只对返回项中的 `folder` 继续递归;对子文档按 `type + token` 归一化为 `discovered_targets`
4. 如果某个目录分页失败、无 continuation token、权限不足或 API 报错,只阻断该目录分支,并在 `discovery_blockers` 中记录;继续处理其他可枚举分支。
## Fact Read Rules
1. `drive metas batch_query` 单次最多 200 个 `request_docs`;当 `targets``discovered_targets` 超过 200 个时,必须分批读取并合并结果。
2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks``partial`,不要阻断其他目标。
3. 对 Wiki 发现目标,公开权限读取优先使用 `type=wiki` + `node_token`metadata 可使用 `obj_type` + `obj_token` 补充 title、owner、URL 和 `sec_label_name`
4. 当 intent 是 `list_permission_settings` 时,只输出权限设置清单和覆盖限制,不主动生成修复计划。
5. 单目标、多目标明确列表和容器发现目标都必须复用同一套逐目标事实读取与语义归一逻辑差异只体现在目标来源、coverage summary 和输出聚合。
6. `permission_public` 用户可见含义是“文档公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed``manage_collaborator_entity``copy_entity``lock_switch` 等字段缺失时标记为 unknown不要伪造未识别字段保留在 raw evidence / partial note 中。
7. `drive file.statistics get``drive file.view_records list` 只在用户要求最近访问、活跃度、闲置暴露、访问复核,或用户提供的 policy 明确依赖活跃度时执行;不要为普通权限审计默认读取访问记录。
8. 访问统计 / 访问记录当前只对 `doc``docx``sheet``bitable``mindnote``wiki``file` 作为支持类型处理。其他类型必须进入 `unsupported_checks`,不能推断活跃度。
9. `view_records` 是访问证据,不是权限列表。没有返回访问记录只能表述为“未获得最近访问证据”或“低活跃候选”,不能表述为“无人有权限”。
## Risk Classification
风险标签只能作为 evidence labels。除非用户提供明确 policy否则不要表述为绝对违规、已泄露或已外部访问。
默认优先级面向用户决策,而不是制造告警感:
- `P0``link_share_entity=anyone_readable/anyone_editable`,互联网公开链接候选风险。
- `P1``external_access_entity=open` / `external_access=true`、关联组织访问、公司内链接可编辑,或外部分享且缺少 / 低于 policy 密级标签。
- `P2`:公司内知道链接可读、协作者管理范围较宽。
- `PolicyReview`:复制、创建副本、打印、下载、评论等依赖 policy 的设置;没有明确 policy 时不要称为高风险。
- `Unknown`读取失败、已删除、无权限、API 不支持、协作者名单 / 继承链 / DLP / AI 索引 / 审计日志未覆盖。
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前文档公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
`AI 检索暴露候选风险` 只是基于权限和标签的代理标签。除非另有工具明确返回索引状态,否则不要声称某个文档已经被 Agent、Copilot 或 RAG 索引。
## 写入规则
- 文档公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
- 如果 `manage_public_auth.auth_result=false`,禁止 patch。告诉用户需要具备 manage-public 权限的用户,或由 owner 操作。
- `drive permission.public get` 只用于 `drive +inspect``DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。
- 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。
- 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。
- `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。
- `permission_request_candidates` 可以来自用户直接提供的目标、明确列表或容器发现目标;只要能构造 token、type、权限类型和申请理由就可以进入候选。不要因为目标不在 `discovered_targets` 中而拒绝单目标 / 小列表权限申请。
- 容器范围内的"统一申请权限"必须先产出 `permission_request_candidates`。未展示候选目标、数量、权限类型和 owner 通知影响前,禁止调用 `drive +apply-permission`
- 用户显式确认批量权限申请后,也必须逐个目标顺序调用 `drive +apply-permission`,并在结果中区分已发起申请、失败、无法构造申请请求和未发现目标。
- `drive permission.members transfer_owner` 属于 owner 转移高风险写入。必须先确认目标、当前 owner、新 owner 的 `member_id` / `member_type``need_notification``remove_old_owner``old_owner_perm``stay_put`、执行顺序和验证方式;不能只凭姓名猜测新 owner。
- owner 转移没有 `permission.members auth` 的等价 precheck。执行前只能用 schema 和当前 metadata 做计划,执行后必须用 `drive metas batch_query` fresh read 验证 ownermetadata 不支持的类型必须把验证标记为 partial。
- 批量 owner 转移必须逐个顺序执行;失败项进入结果清单,不要重复执行已成功目标。`remove_old_owner=true``old_owner_perm` 降权必须单独在确认中高亮。
- 用户要求“生成整改方案 / dry-run / 先看看会改什么”时,只生成 `remediation_plan`不执行任何写命令。dry-run 必须包含 target count、field changes、跳过原因、验证方式和有限回滚范围。
- 用户基于完整风险清单选择对象时,必须先解析 `risk_id`、风险分组、URL 或 artifact 中 `selected=true` 的行,生成 `selected_risk_items`。无法匹配到当前 `risk_manifest` 的选择必须要求用户重新确认或重新读取清单。
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive permission.public get`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于文档公共访问和协作权限设置字段的有限回滚说明不覆盖协作者、owner、继承权限或密级标签。
- 如果用户要求批量收紧权限,必须按风险分层和目标顺序逐个执行;失败项进入结果清单,不要因为单个失败而重复执行已成功目标。
- 遇到 secure-label downgrade error `1063013` 时,停止重试,并告诉用户需要在文档 UI 中完成审批。
## 未来扩展边界
以下能力已有部分 CLI surface 或用户价值,但不要在当前 workflow 中作为可执行分支直接调用:
- `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。
- backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner并走本 workflow 的 owner-transfer 确认。
- `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。
- 当前 CLI 没有 `permission.members list`、完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
## 输出策略
- 默认 summary-first单目标输出简短审计摘要多目标明确列表输出逐目标摘要容器目标输出安全诊断报告摘要不堆叠字段计数。
- 单目标 `public_exposure_check` 按 outputs 的 `Semantic Rendering` 渲染 `per_target_permission_assessment`,输出用户语言结论和检查边界;默认不展示底层字段名、风险清单或整改 CTA。
- 容器安全诊断必须包含一句话结论、覆盖情况、风险分级、可定位待复核对象、建议下一步和剩余限制。
- 待复核对象必须包含稳定 `risk_id`、path/title、URL、type、owner、sec_label、风险原因、证据和建议动作缺少 URL 时展示 token / node_token 和原因。
- 容器摘要按规模渐进披露,不能固定 Top N未完全展开时必须说明完整清单总数并给出生成 artifact / dry-run / owner 复核清单等 CTA。
- 面向用户优先使用业务语言和“候选风险 / 待复核 / 待策略确认”;底层字段只作为证据。完整模板按需读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md)。
- 不要默认创建文件、飞书文档或长表格;最终回复必须包含已完成事项、验证结果和剩余限制。异步权限申请审批只能表述为“已发起申请”。

View File

@@ -0,0 +1,130 @@
# lark-drive Workflow 总框架
本文是 `lark-drive` workflow 总框架的运行协议和注册表。它面向 AI Agent 执行,只负责路由已纳入本总框架的 workflow。
`Workflow Registry` 是本总框架的唯一注册来源。未命中 registry 的请求必须按“未注册 workflow 处理”执行,不要按已有 workflow 类推扩展。
## 必读上下文
执行本总框架内的 workflow 前,必须先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
下游 reference 只能按需逐步加载。不要因为命中本总框架,就预加载所有 workflow 文件或相关 skill。
## 能力边界
`lark-drive` workflow 总框架以 `lark-drive` 作为 Drive / Docs / Wiki 资产编排的总入口。其他领域 skill 只有在已纳入本总框架的 workflow 明确需要时,才作为辅助能力加载。
| Layer | Owns | Must Not Own |
|-------|------|--------------|
| `lark-drive/SKILL.md` | 用户意图到具体 workflow entry 的短路由 | 长流程逻辑、未注册场景 |
| `lark-drive-workflow.md` | 共享运行协议、Artifact Contract、Workflow Registry、加载规则 | 非运行时背景说明、宽泛路线图、场景专项执行细节 |
| Registered workflow file | 场景范围、状态机、Command Map、确认门槛、验证规则 | 其他场景、隐藏写入、未被 CLI/API 支持的能力声明 |
## 执行协议
每个已纳入本总框架的 workflow 必须遵循同一条执行骨架:
```text
route -> scope -> read -> assess/plan -> confirm -> execute -> verify -> done
```
运行规则:
1. 在读取或写入资产前,先把用户意图解析到唯一一个已纳入本总框架的 workflow。
2. 在昂贵读取或写入规划前,先解析并确认 `target_scope`
3. 事实必须来自可执行 CLI 命令或被引用 skill不要只凭目录结构推断治理结论。
4. 无法执行的检查必须记录到 `unsupported_checks`,不能静默省略。
5. 写入前必须产出计划。每一次写入都需要用户对准确范围和 command family 显式确认。
6. CLI/API 支持验证时,写入后必须用 fresh read 验证。
7. 结束时进入 `done`,返回已完成事项、验证结果和剩余限制。不要把尚未完成的外部审批描述成已完成。
## Artifact Contract
每个已纳入本总框架的 workflow 必须维护以下内部字段:
| Field | Meaning |
|-------|---------|
| `workflow_id` | 本总框架注册的 workflow 名称,例如 `permission_governance` |
| `current_state` | 当前 workflow 状态 |
| `target_scope` | 已确认的目标范围和用户原始输入 |
| `identity` | 当前身份和执行视角,通常为 `user` |
| `facts` | 从 CLI 读取或引用 skill 获取的证据 |
| `plan_items` | 候选动作;每项包含 command family、target、risk、verification method |
| `unsupported_checks` | 因 CLI/API 覆盖、目标类型、认证或范围限制而无法执行的检查 |
| `partial` | 结果是否不完整,以及不完整原因 |
| `execution_results` | 已确认写入的执行结果 |
| `verification_results` | fresh read 验证结果,或明确的异步审批限制 |
用户可见输出默认使用简洁 chat summary。只有在用户要求、结果过大不适合聊天展示或当前 workflow 明确要求共享产物时,才创建本地文件或飞书文档。
## Workflow Entry Contract
每个已纳入本总框架的 workflow entry file 必须让 Agent 能直接判断和执行:
- 何时进入该 workflow以及哪些需求不属于该 workflow
- 如何映射到共享执行骨架的 state machine
- 当前 state 需要按需加载哪些 reference
- 哪些 command family 可用,以及读写风险边界;
- 写入前如何确认,写入后如何验证;
- 最终回复必须包含哪些字段,或使用哪些 output templates。
每个纳入本总框架的 workflow 默认从一个独立 reference 文件开始。只有当写入、回滚或验证流程复杂到影响可读性时,才继续拆 phase 文件。
## Risk / Structure Gate
每个纳入本总框架的 workflow 都必须同时声明 `Risk Level``Structure Level`。风险等级决定安全门槛;结构等级决定文件拆分。高风险写入不等于必须拆 phase。
Risk Level
| Level | Meaning | Runtime Requirement |
|-------|---------|---------------------|
| `R0` | read-only只读发现、分析、报告 | 记录事实来源、`unsupported_checks``partial` 原因 |
| `R1` | low-risk write创建草稿、生成临时产物等低风险写入 | 写前说明范围,写后返回结果链接或标识 |
| `R2` | high-risk write权限变更、批量移动、标签修改等高风险写入 | 写前计划、准确 diff、用户显式确认、fresh read 验证 |
| `R3` | destructive / recovery-sensitive write删除、自动归档、双向同步、rollback cleanup | 恢复边界、执行日志、分批策略、失败停止条件和单独确认 |
Structure Level
| Level | File Shape | When To Use |
|-------|------------|-------------|
| `S1` | compact entry only | 只读、轻量审计、简单计划,无复杂写入 |
| `S2` | entry + optional `commands` / `outputs` / `artifacts` references | 有命令样例、输出模板、少量高风险写入,但状态链可集中表达 |
| `S3` | entry + phase files + optional shared references | 多阶段写入、复杂验证、恢复 / rollback、长任务或分批执行 |
升级规则:
1. 新 workflow 默认从 `S1` 开始。
2. Entry file 超过约 300 行时,优先拆 `commands``outputs``artifacts` reference。
3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。
4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template不默认新增独立 workflow。
5. 已有样板:`permission_governance``R2/S2`;已发布的独立 `knowledge_organize``R2-R3/S3`,当前不作为本总框架 registry entry。
## 加载与拆分边界
- 每个纳入本总框架的场景默认只保留一个紧凑 workflow entry file。
- 不为未注册或未来场景创建占位 reference / registry entry。
- 只有 workflow 已经具备可执行规则时,才允许作为本总框架 workflow 出现在 `SKILL.md` 并加入 `Workflow Registry`
- 多文件 phase 拆分只用于执行、回滚或验证流程复杂到影响可读性的 `S3` 场景。
## Workflow Registry
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|----------|--------|------|-----------|------------|---------|
| `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 |
## Workflow Loading
当用户意图匹配到本总框架已注册 workflow 时:
1. 先读取本总框架文件。
2. 只读取 `Workflow Registry` 中命中的 entry file。
3. 按该 workflow 的 progressive load map 继续加载额外 reference。
4. 除非用户改变意图,或当前 workflow 明确路由到其他 workflow否则不要读取其他 workflow 文件。
## 未注册 workflow 处理
`Workflow Registry` 是本总框架的唯一注册来源。用户请求未列入 registry 的 workflow 或组合型治理场景时:
1. 明确说明该需求暂无纳入本总框架的 `lark-drive` workflow。
2. 只在不新增本总框架 workflow 行为的前提下,将请求收窄为现有 skill / CLI 可执行的原子操作。
3. 不要类比本总框架任何已注册 workflow 新增 state machine、artifact shape、风险分类、写入行为或验证结论。

View File

@@ -18,6 +18,7 @@ metadata:
| 大幅改写页面 | 先回读现有 XML写入新 plan再替换或重建相关页面 | `xml_presentations.get``+replace-slide``lark-slides-edit-workflows.md` |
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide``lark-slides-replace-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token回读全文或单页 XML保存 `xml_presentation_id``slide_id``revision_id` | `xml_presentations.get``xml_presentation.slide.get` |
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot``lark-slides-screenshot.md` |
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides``@./path` 占位符 |
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
@@ -82,6 +83,7 @@ lark-cli auth login --domain slides
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 截图:[`lark-slides-screenshot.md`](references/lark-slides-screenshot.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)

View File

@@ -0,0 +1,94 @@
# slides +screenshot
## 用途
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误。
## 命令
```bash
lark-cli slides +screenshot --as user \
--presentation '<xml_presentation_id 或 slides/wiki URL>' \
--slide-number 1
```
渲染本地 XML 内容:
```bash
lark-cli slides +screenshot --as user \
--content @slide.xml
```
## 参数
| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | list 模式必需 | `xml_presentation_id``/slides/` URL或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID多页截图时重复传入 |
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入 |
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file``-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
| `--output-name` | 否 | render 模式的输出文件名 stem未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 |
## 示例
### 单页截图
```bash
lark-cli slides +screenshot --as user \
--presentation slides_example_presentation_id \
--slide-number 1
```
### 多页截图
```bash
lark-cli slides +screenshot --as user \
--presentation slides_example_presentation_id \
--slide-number 1 \
--slide-number 2 \
--output-dir .lark-slides/screenshots/demo
```
### 渲染 XML 预览
```bash
lark-cli slides +screenshot --as user \
--content @.lark-slides/out/demo/slide.xml \
--output-name preview
```
## 返回值
返回 JSON 不包含 Base64 图片内容:
```json
{
"code": 0,
"data": {
"xml_presentation_id": "slides_example_presentation_id",
"output_dir": ".lark-slides/screenshots",
"screenshots": [
{
"slide_id": "slide_example_id",
"slide_number": 1,
"format": "png",
"path": "/abs/path/.lark-slides/screenshots/slides_example_presentation_id_p001_slide_example_id.png",
"size": 12345
}
]
},
"msg": "success"
}
```
## 注意事项
1. 优先使用 `slides +screenshot` 保存本地图片,不要把图片 Base64 打到 stdout。
2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id``--slide-number`
3. 本地 XML 预览时,传 `--content @file``--content -`,内容应为单个 `<slide>` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`
4. `slide_id` 是页面 short ID页码请用 `--slide-number`
5. list 模式默认文件名包含 presentation ID、页码和/或 slide ID文件已存在时自动追加 `_2``_3` 等后缀,避免覆盖旧截图。
6. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。

View File

@@ -2,10 +2,11 @@
本文档基于 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 整理,说明飞书 Slides XML SchemaSML 2.0)的核心结构和常用写法。
> **注意**:所有提交给 API 的 XML整篇或片段都**不要带 `<?xml ... ?>` 声明**——slides 后端会拒绝它,直接从根元素写起。标签白名单与文本转义规则见 [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
## 基本结构
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<title>演示文稿标题</title>
<slide>
@@ -224,7 +225,7 @@
| `src` 形式 | 说明 |
|---|---|
| `file_token`(如 `boxcnXXXXXXXXXXXXXXXXXXXXXX` | 通过 `slides +media-upload` 上传后返回的 token |
| `@<本地路径>`(如 `@./assets/chart.png` | **仅在 `slides +create --slides` 中可用**CLI 会自动上传该文件并替换为 file_token |
| `@<本地路径>`(如 `@./assets/chart.png` | **仅在 `slides +create --slides` 中可用**CLI 会自动上传该文件并替换为 file_token。路径也要按 XML 规则转义:文件名含 `&` 时写 `@./Q1&amp;Q2.png`CLI 反转义后查找文件 |
> **禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,`src="https://..."` 在 PPT 里通常显示破图。要用网图必须先 `curl`/下载到 CWD 内,再走上传流程拿 `file_token`。
@@ -305,7 +306,6 @@
## 完整示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<title>季度报告</title>
<theme>

View File

@@ -9,10 +9,30 @@
3. `<slide>` 直接子元素只有 `<style>``<data>``<note>`
4. 页面中的文本通常通过 `<content>` 表达,而不是把 `<title>``<body>` 直接挂在 `<slide>`
## 标签白名单与禁令(生成前必看)
**只使用本文档和 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 中列出的元素。白名单之外的标签一律不写**——后端引擎对未定义标签直接整页拒绝(`tag not supported`),不会降级渲染。这条规则覆盖一切未列出的标签,无需记黑名单,但以下是线上被拒实锤的高频踩坑标签,显式点名:
`<audio>``<video>``<timeline>``<animation>``<trigger>``<header>`
PPT 语义的「富媒体 / 动画 / 时间轴」需求请改用白名单内的替代元素:
| 想要的效果 | 用什么替代 |
|------|------|
| 数据图表 / 时间轴 / 流程图 | `chart*` 系列48 个图表元素)、`mermaid` |
| 自由绘图 / 复杂图形 | `whiteboard``shape` / `line` / `polyline` |
| 图标 / 插图 | `icon``img` |
| 音频 / 视频 | 无原生支持,用 `img` 截图 + 链接文本替代 |
### 文本转义与片段格式(提交被拒的常见原因)
- 文本里的字面 `&` 必须写成 `&amp;``<` 写成 `&lt;`——裸 `&`(如 `R&D`、URL 中的 `&`)是 XML 语法错误,整次提交都会被拒
- 只能用 XML 内置实体 `&amp;` `&lt;` `&gt;` `&apos;` `&quot;`**HTML 实体如 `&nbsp;``&mdash;` 不存在**,会直接解析失败,空格就写普通空格
- **`--slides` / `--parts` 等 XML 片段不要带 `<?xml ... ?>` 声明**——slides 后端会拒绝它,直接从根元素写起
## 最小可用示例
```xml
<?xml version="1.0" encoding="UTF-8"?>
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide>
<data>
@@ -132,7 +152,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
<img src="file_token_or_url" topLeftX="80" topLeftY="120" width="320" height="180"/>
```
`src` 只支持:`slides +media-upload` 返回的 `file_token`,或 `@<本地路径>` 占位符(仅 `+create --slides` 自动上传并替换)。**禁止使用 http(s) 外链 URL**——飞书 slides 渲染端不会代理外链图,外链 src 在 PPT 里通常不显示。本地图片详见 [lark-slides-create.md](lark-slides-create.md#本地图片path-占位符) / [lark-slides-media-upload.md](lark-slides-media-upload.md)。
`src` 只支持:`slides +media-upload` 返回的 `file_token`,或 `@<本地路径>` 占位符(仅 `+create --slides` 自动上传并替换)。占位符路径同样遵守 XML 转义规则:文件名含 `&` 时写 `src="@./Q1&amp;Q2.png"`CLI 会先反转义再查找本地文件。**禁止使用 http(s) 外链 URL**——飞书 slides 渲染端不会代理外链图,外链 src 在 PPT 里通常不显示。本地图片详见 [lark-slides-create.md](lark-slides-create.md#本地图片path-占位符) / [lark-slides-media-upload.md](lark-slides-media-upload.md)。
> **注意**`width`/`height` 是**裁剪后**的显示尺寸。比例和原图不一致时会自动裁剪(无法靠属性关闭),想避免裁剪就让 `width:height` 对齐原图比例。