Compare commits

...

6 Commits

Author SHA1 Message Date
zhaoyukun.yk
efa636f6b3 feat(extension): present restricted commands as absent and trim skills
Integrators that build their own lark-cli distribution can now ship a
package where everything they cut out truly disappears, instead of
lingering as half-disabled stubs that mislead users and agents.

Commands an integrator plugin restricts no longer show up in help or
completion, and invoking one (including asking for its help) answers
"command not included in this build" (subtype command_unavailable) with
no policy vocabulary and no dead-end recovery steering. Integrators can
replace that message with their own product wording via
Rule.DeniedMessage. Cutting a whole domain also retires everything that
points at it: --profile hides and rejects use when the profile domain
goes, the skills-setup footer, update notices, and the auth-login
recovery hint stop rendering when their domains go.

The policy self-inspection commands (config policy show / plugins show)
stay executable by default so operators can still see which rule locked
the build, hidden from help but available on request. Fully-managed
distributions can retire even those with HideDiagnostics().

Plugins can also trim the embedded skills to match: EmbeddedSkills
removes, overlays, or replaces entries over the CLI's built-in set, and
skills list/read plus every help pointer serve the same trimmed tree.

Users who write their own ~/.lark-cli/policy.yml see no change: their
denials stay visible and keep explaining how to adjust the policy they
own. Default builds are byte-for-byte unchanged.
2026-07-09 21:07:54 +08:00
YH-1600
519a600b62 docs: register knowledge organize workflow (#1828) 2026-07-09 18:15:24 +08:00
zhanghuanxu
d87d9b458a docs: require native charts in slide planning 2026-07-09 16:42:45 +08:00
zhanghuanxu
1173179b10 feat(slides): add slides chart demo reference 2026-07-09 16:42:45 +08:00
yballul-bytedance
74d8458635 feat(drive): Strengthen lark-drive high-risk write operations and read-only recognition boundaries. (#1801)
Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-09 11:49:17 +08:00
fangshuyu-768
80fadf1801 fix(drive): abort push on parent sibling limit (#1813) 2026-07-09 11:29:38 +08:00
66 changed files with 3028 additions and 167 deletions

View File

@@ -21,12 +21,16 @@ import (
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/skillpolicy"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -42,6 +46,7 @@ type buildConfig struct {
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
skillsOverlay *platform.SkillsOverlay
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
@@ -59,6 +64,17 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
}
}
// WithEmbeddedSkills customizes the CLI's embedded skills for a caller that builds
// the command tree directly instead of registering a plugin. It is the
// build-option analogue of a plugin's EmbeddedSkills(...): the same single-owner
// rule applies, so combining WithEmbeddedSkills with a plugin that also customizes
// skills aborts startup. nil is a no-op.
func WithEmbeddedSkills(spec *platform.SkillsOverlay) BuildOption {
return func(c *buildConfig) {
c.skillsOverlay = spec
}
}
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
// at build time. It is registered by the repo-root package main's init via
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
@@ -71,6 +87,21 @@ var embeddedSkillContent fs.FS
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// withEmbeddedSkillsOwner labels a WithEmbeddedSkills contribution in the skill resolver so a
// WithEmbeddedSkills + plugin-Skills collision names a stable, non-plugin owner.
const withEmbeddedSkillsOwner = "cmd.WithEmbeddedSkills"
// resolveSkillContent composes the effective embedded skill tree from the CLI
// default, an optional WithEmbeddedSkills spec, and plugin SkillsOverlays, enforcing the
// single-owner rule across all sources.
func resolveSkillContent(cfg *buildConfig, pluginSkills []skillpolicy.PluginSkill) (fs.FS, error) {
sources := pluginSkills
if cfg.skillsOverlay != nil {
sources = append([]skillpolicy.PluginSkill{{PluginName: withEmbeddedSkillsOwner, SkillsOverlay: cfg.skillsOverlay}}, sources...)
}
return skillpolicy.Resolve(embeddedSkillContent, sources)
}
// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
@@ -154,6 +185,14 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
cfg.streams = cmdutil.SystemIO()
}
// Reset every process-global snapshot up front, not only inside
// applyUserPolicyPruning: the skipPlugins and install-failure paths
// return before pruning runs, and a previous build's state must not
// leak into this one (long-lived embedders, test sequences).
policystate.SetPluginDeniedDomains(nil)
cmdpolicy.SetActive(nil)
internalplatform.SetActiveInventory(nil)
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
@@ -175,7 +214,16 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
installTipsHelpFunc(rootCmd)
// The skill-content getter also gates on the skills command domain:
// every pointer it feeds renders as `lark-cli skills read ...`, so with
// that domain plugin-denied the pointers would all be dead ends even
// though the content itself still exists.
installTipsHelpFunc(rootCmd, func() fs.FS {
if policystate.DomainDeniedByPlugin("skills") {
return nil
}
return f.SkillContent
})
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
@@ -223,6 +271,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
}
if cfg.skipPlugins {
installHelpCommand(rootCmd)
resolved, err := resolveSkillContent(cfg, nil)
if err != nil {
installPluginSkillErrorGuard(rootCmd, err)
return f, rootCmd, nil
}
f.SkillContent = resolved
recordInventory(nil)
return f, rootCmd, nil
}
@@ -233,23 +288,52 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
return f, rootCmd, nil
}
var pluginRules []cmdpolicy.PluginRule
var pluginSkills []skillpolicy.PluginSkill
var registry *hook.Registry
if installResult != nil {
pluginRules = installResult.PluginRules
pluginSkills = installResult.PluginSkills
registry = installResult.Registry
}
// Policy errors fail-CLOSED when a plugin contributed (security
// intent must not be silently dropped); yaml-only errors fail-OPEN
// with a warning so a typo can't lock the user out.
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
denied, policyErr := applyUserPolicyPruning(rootCmd, pluginRules)
if policyErr != nil {
if len(pluginRules) > 0 {
installPluginConflictGuard(rootCmd, err)
installPluginConflictGuard(rootCmd, policyErr)
return f, rootCmd, nil
}
warnPolicyError(cfg.streams.ErrOut, err)
warnPolicyError(cfg.streams.ErrOut, policyErr)
}
// The custom help command attaches AFTER policy evaluation on purpose:
// it is a framework meta command, and inside the evaluated tree an
// allow-list rule (Allow: ["im/**"]) would deny it as
// domain_not_allowed — cobra's stock help command is likewise attached
// only at Execute time and never evaluated.
installHelpCommand(rootCmd)
// Presentation passes: a capability an integrator plugin denied
// presents as absent — retired global flags (--profile), no skills
// footer, diagnostics hidden or retired. Enforcement stays with
// cmdpolicy.Apply above; these only shape help and fixed hints.
applyPluginPresentation(rootCmd, installResult, denied)
// Resolve the embedded skill tree BEFORE wiring hooks: an invalid
// SkillsOverlay must fail fast, before wireHooks emits Startup, so a Startup
// side effect is never stranded without its Shutdown. Both skill readers
// -- `skills list`/`read` and the --help guidance -- then read this one
// f.SkillContent. Fails closed: never silently ship defaults once a
// customization is declared.
resolvedSkills, skillErr := resolveSkillContent(cfg, pluginSkills)
if skillErr != nil {
installPluginSkillErrorGuard(rootCmd, skillErr)
return f, rootCmd, nil
}
f.SkillContent = resolvedSkills
if registry != nil {
if err := wireHooks(ctx, rootCmd, registry); err != nil {
installPluginLifecycleErrorGuard(rootCmd, err)

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
)
type noopConfigKeychain struct{}
@@ -564,3 +565,54 @@ func TestPrintLangPreferenceConfirmation(t *testing.T) {
}
})
}
// The "no active profile" hint points at `lark-cli profile list`; that pointer
// must be gated on the profile domain still being present. When a plugin denies
// the profile domain, the hint would be a dead end, so it is omitted — the error
// itself is unchanged.
func TestConfigShowRun_ProfileHintGatedByPluginDenial(t *testing.T) {
multi := &core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
hintOf := func(t *testing.T) string {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configShowRun(&ConfigShowOptions{Factory: f})
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T %v", err, err)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Fatalf("subtype = %q, want not_configured", cfgErr.Subtype)
}
return cfgErr.Hint
}
t.Run("profile domain present: hint points at profile list", func(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
if h := hintOf(t); !strings.Contains(h, "lark-cli profile list") {
t.Errorf("hint = %q, want it to reference `lark-cli profile list`", h)
}
})
t.Run("profile domain plugin-denied: hint omitted", func(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
policystate.SetPluginDeniedDomains(map[string]bool{"profile": true})
if h := hintOf(t); h != "" {
t.Errorf("hint = %q, want empty when the profile domain is plugin-denied", h)
}
})
}

View File

@@ -85,6 +85,9 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
if len(p.Rules) > 0 {
entry["rules"] = p.Rules
}
if p.EmbeddedSkills != nil {
entry["embedded_skills"] = p.EmbeddedSkills
}
entry["hooks"] = map[string]any{
"observers": p.Observers,
"wrappers": p.Wrappers,

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"encoding/json"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
internalplatform "github.com/larksuite/cli/internal/platform"
)
// config plugins show must surface a plugin's EmbeddedSkills contribution in
// the rendered JSON, not only in the internal inventory struct: this command is
// the operator's window into what a fork trimmed, so the Allow/Remove/Overlay/
// Base summary has to reach stdout. Guards the render layer, which asserting the
// inventory struct alone does not exercise.
func TestConfigPluginsShow_RendersEmbeddedSkills(t *testing.T) {
internalplatform.SetActiveInventory(&internalplatform.Inventory{
Plugins: []internalplatform.PluginEntry{{
Name: "acme",
Version: "1.0",
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
EmbeddedSkills: &internalplatform.SkillsOverlayView{
Allow: []string{"lark-im"},
Remove: []string{"lark-a"},
Overlay: true,
Base: true,
},
}},
})
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
out := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
if err := runConfigPluginsShow(f); err != nil {
t.Fatalf("show: %v", err)
}
var got struct {
Plugins []struct {
EmbeddedSkills *internalplatform.SkillsOverlayView `json:"embedded_skills"`
} `json:"plugins"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("not json: %v\n%s", err, out.String())
}
if len(got.Plugins) != 1 {
t.Fatalf("want 1 plugin, got %d", len(got.Plugins))
}
es := got.Plugins[0].EmbeddedSkills
if es == nil {
t.Fatalf("embedded_skills missing from rendered output:\n%s", out.String())
}
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
len(es.Remove) != 1 || es.Remove[0] != "lark-a" ||
!es.Overlay || !es.Base {
t.Errorf("embedded_skills summary mismatch: %+v", es)
}
}
// A plugin that did not customize embedded skills must not emit an
// embedded_skills key, so the field's presence is a reliable signal that a fork
// trimmed the tree.
func TestConfigPluginsShow_OmitsEmbeddedSkillsWhenAbsent(t *testing.T) {
internalplatform.SetActiveInventory(&internalplatform.Inventory{
Plugins: []internalplatform.PluginEntry{{
Name: "acme",
Version: "1.0",
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
}},
})
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
out := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
if err := runConfigPluginsShow(f); err != nil {
t.Fatalf("show: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
t.Fatalf("not json: %v", err)
}
plugins, ok := raw["plugins"].([]any)
if !ok || len(plugins) != 1 {
t.Fatalf("want 1 plugin in output, got: %s", out.String())
}
if _, ok := plugins[0].(map[string]any)["embedded_skills"]; ok {
t.Errorf("embedded_skills must be omitted when the plugin customized no skills; got:\n%s", out.String())
}
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
"github.com/spf13/cobra"
)
@@ -55,7 +56,12 @@ func configShowRun(opts *ConfigShowOptions) error {
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
e := errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile")
// No recovery hint when the profile domain is absent from this build.
if !policystate.DomainDeniedByPlugin("profile") {
e = e.WithHint("run: lark-cli profile list")
}
return e
}
users := "(no logged-in users)"
if len(app.Users) > 0 {

109
cmd/flag_gate.go Normal file
View File

@@ -0,0 +1,109 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
)
// globalFlagDomains maps each root persistent flag to the command domain
// it belongs to. A new domain-tied global flag must add a row.
var globalFlagDomains = map[string]string{
"profile": "profile",
}
// flagGateAnnotation distinguishes a policy-retired flag from one hidden
// cosmetically (single-app mode force-shows the latter in root help).
const flagGateAnnotation = "lark:policy_denied_flag"
// applyPluginFlagGate hides and rejects the global flags whose whole
// domain a plugin denied. yaml denials do not gate flags. Must run after
// RegisterGlobalFlags and cmdpolicy.Apply.
func applyPluginFlagGate(root *cobra.Command, denied map[string]cmdpolicy.Denial) {
gated := false
for flagName, domain := range globalFlagDomains {
d, ok := denied[domain]
if !ok || !cmdpolicy.IsPluginPolicySource(d.PolicySource) {
continue
}
fl := root.PersistentFlags().Lookup(flagName)
if fl == nil {
continue
}
fl.Hidden = true
if fl.Annotations == nil {
fl.Annotations = map[string][]string{}
}
fl.Annotations[flagGateAnnotation] = []string{"true"}
gated = true
}
if gated {
installFlagGateRejection(root)
}
}
func isPolicyGatedFlag(fl *pflag.Flag) bool {
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
}
// installFlagGateRejection rejects gated flags right after parsing.
// pflag has no runtime unregister, so the flag still parses; and cobra
// resolves PersistentPreRunE "first non-nil wins" walking up from the
// leaf, so every command carrying its own (cmd/auth, cmd/config) must be
// wrapped too, not just the root.
func installFlagGateRejection(root *cobra.Command) {
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
if prev := c.PersistentPreRunE; prev != nil {
c.PersistentPreRunE = func(cc *cobra.Command, args []string) error {
if err := rejectGatedFlags(cc); err != nil {
return err
}
return prev(cc, args)
}
}
for _, child := range c.Commands() {
walk(child)
}
}
prevRun := root.PersistentPreRun
root.PersistentPreRun = nil
root.PersistentPreRunE = func(c *cobra.Command, args []string) error {
if err := rejectGatedFlags(c); err != nil {
return err
}
if prevRun != nil {
prevRun(c, args)
}
return nil
}
for _, child := range root.Commands() {
walk(child)
}
}
// rejectGatedFlags fails a set policy-retired flag with the same shape an
// unregistered flag produces (see flagDidYouMean).
//
// VisitAll + fl.Changed, not Visit: cobra parses a persistent flag on the leaf
// command's merged flagset, so root.PersistentFlags()'s "changed" table stays
// empty and Visit (which only walks that table) never fires on the dispatch
// path. The flag objects are shared by pointer across the merge, so fl.Changed
// reflects a real leaf-level parse.
func rejectGatedFlags(c *cobra.Command) error {
var rejected error
c.Root().PersistentFlags().VisitAll(func(fl *pflag.Flag) {
if rejected == nil && fl.Changed && isPolicyGatedFlag(fl) {
rejected = errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+fl.Name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + fl.Name, Reason: "unknown flag"}).
WithHint("run `%s --help` to see valid flags", c.CommandPath())
}
})
return rejected
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -35,7 +36,15 @@ const userPolicyFileName = "policy.yml"
//
// pluginRules carries Plugin.Restrict() contributions collected from
// the InstallAll phase; nil/empty is fine.
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
//
// The returned denied map (nil when no rule denied anything) feeds the
// post-pruning presentation passes in build.go: the global-flag gate and
// the fixed-hint filter both key off which domains a plugin denied.
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) (map[string]cmdpolicy.Denial, error) {
// Reset up front so every early return leaves the process-global
// snapshot clean; the success path re-populates it.
policystate.SetPluginDeniedDomains(nil)
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
// yaml). When a plugin contributed rules we therefore do NOT even
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
@@ -65,7 +74,7 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
// show` reports "no policy" instead of a stale rule that
// doesn't reflect the current command tree.
cmdpolicy.SetActive(nil)
return lerr
return nil, lerr
}
yamlRules = loaded
}
@@ -77,11 +86,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
})
if err != nil {
cmdpolicy.SetActive(nil)
return err
return nil, err
}
if len(rules) == 0 {
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
return nil
return nil, nil
}
// RuleName attributes a denial to a specific rule in the envelope.
@@ -94,17 +103,39 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
ruleName = rules[0].Name
}
// DeniedMessage is build-level: the first non-empty message across
// the single owner's rules speaks for all of them.
deniedMessage := ""
for _, r := range rules {
if r.DeniedMessage != "" {
deniedMessage = r.DeniedMessage
break
}
}
engine := cmdpolicy.NewSet(rules)
decisions := engine.EvaluateAll(rootCmd)
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName, deniedMessage)
cmdpolicy.Apply(rootCmd, denied)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: rules,
Source: source,
DeniedPaths: len(denied),
Rules: rules,
Source: source,
DeniedPaths: len(denied),
DeniedByPath: denied,
})
return nil
// Whole-domain denials surface as aggregate entries keyed by the
// bare domain name (no slash); record them for render-time hint
// emitters (internal/auth, internal/client, the notice provider).
pluginDomains := map[string]bool{}
for path, d := range denied {
if !strings.Contains(path, "/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
pluginDomains[path] = true
}
}
policystate.SetPluginDeniedDomains(pluginDomains)
return denied, nil
}
// installPluginsAndHooks runs the InstallAll phase on the globally-
@@ -156,7 +187,22 @@ func recordInventory(installResult *internalplatform.InstallResult) {
AllowUnannotated: r.Rule.AllowUnannotated,
})
}
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
skillSrcs := make([]internalplatform.SkillsInventorySource, 0, len(installResult.PluginSkills))
for _, ps := range installResult.PluginSkills {
if ps.SkillsOverlay == nil {
continue
}
skillSrcs = append(skillSrcs, internalplatform.SkillsInventorySource{
PluginName: ps.PluginName,
View: internalplatform.SkillsOverlayView{
Allow: ps.SkillsOverlay.Allow,
Remove: ps.SkillsOverlay.Remove,
Overlay: ps.SkillsOverlay.Overlay != nil,
Base: ps.SkillsOverlay.Base != nil,
},
})
}
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs, skillSrcs))
}
// wireHooks installs Observer/Wrapper hooks onto every runnable command

View File

@@ -116,7 +116,7 @@ max_risk: write
`)
root := fakeTree(t)
if err := applyUserPolicyPruning(root, nil); err != nil {
if _, err := applyUserPolicyPruning(root, nil); err != nil {
t.Fatalf("apply policy: %v", err)
}
@@ -175,7 +175,7 @@ func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
tmpHome(t) // home set but no policy.yml written
root := fakeTree(t)
if err := applyUserPolicyPruning(root, nil); err != nil {
if _, err := applyUserPolicyPruning(root, nil); err != nil {
t.Fatalf("missing policy should not error, got %v", err)
}
@@ -196,7 +196,7 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
writePolicy(t, cfgDir, "::: not yaml :::")
root := fakeTree(t)
err := applyUserPolicyPruning(root, nil)
_, err := applyUserPolicyPruning(root, nil)
if err == nil {
t.Fatalf("malformed yaml should produce an error")
}
@@ -221,7 +221,7 @@ func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
}},
}
root := fakeTree(t)
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
if _, err := applyUserPolicyPruning(root, pluginRules); err != nil {
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
}
@@ -243,7 +243,7 @@ func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
writePolicy(t, cfgDir, "max_risk: nukem\n")
root := fakeTree(t)
err := applyUserPolicyPruning(root, nil)
_, err := applyUserPolicyPruning(root, nil)
if err == nil {
t.Fatalf("invalid MaxRisk should produce an error")
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/skillpolicy"
)
// installFatalGuard wires a fail-closed guard at every cobra dispatch
@@ -110,6 +111,27 @@ func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
installFatalGuard(rootCmd, makeErr)
}
// installPluginSkillErrorGuard surfaces a plugin SkillsOverlay configuration
// error before any command runs. Two failure modes, split by reason code:
//
// - "invalid_skills_overlay" - a Remove/Overlay that cannot compose
// - "multiple_skills_overlay_plugins" - two plugins each customizing skills
//
// The CLI must NOT silently fall back to default skills once an
// integrator has declared a customization.
func installPluginSkillErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() error {
reasonCode := internalplatform.ReasonInvalidSkillsOverlay
if errors.Is(err, skillpolicy.ErrMultipleSkillsOverlays) {
reasonCode = internalplatform.ReasonMultipleSkillsOverlays
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithHint("plugin skill customization is broken (reason_code %s); fix the plugin's SkillsOverlay or remove the conflicting plugin", reasonCode).
WithCause(err)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a typed validation error (failed_precondition). The hint's
// reason code splits returned-error vs panic so consumers (audit /

169
cmd/presentation.go Normal file
View File

@@ -0,0 +1,169 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdpolicy"
internalplatform "github.com/larksuite/cli/internal/platform"
)
// applyPluginPresentation makes plugin-denied capabilities present as
// absent: retired flags, no skills footer, diagnostics hidden or retired.
// Presentation only — enforcement happened in cmdpolicy.Apply.
func applyPluginPresentation(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
applyPluginFlagGate(rootCmd, denied)
if domainDeniedByPlugin(denied, "skills") {
rootCmd.SetUsageTemplate(strings.Replace(rootUsageTemplate, skillsSetupFooter, "", 1))
}
if installResult != nil && hideDiagnosticsOwner(installResult.Plugins) != "" {
retireDiagnostics(rootCmd, installResult, denied)
} else {
concealDiagnostics(rootCmd, denied)
}
}
// domainDeniedByPlugin reads the freshly-built denied map, unlike
// policystate.DomainDeniedByPlugin which serves render-time consumers.
func domainDeniedByPlugin(denied map[string]cmdpolicy.Denial, domain string) bool {
d, ok := denied[domain]
return ok && cmdpolicy.IsPluginPolicySource(d.PolicySource)
}
// hideDiagnosticsOwner returns the plugin that declared HideDiagnostics,
// or "". The host already enforced that it also Restricts.
func hideDiagnosticsOwner(plugins []internalplatform.PluginInfo) string {
for _, p := range plugins {
if p.Capabilities.HideDiagnostics {
return p.Name
}
}
return ""
}
// retireDiagnostics installs the unavailable presentation on the
// diagnostic exemptions, same as any other plugin-denied command.
func retireDiagnostics(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
source := "plugin:" + hideDiagnosticsOwner(installResult.Plugins)
message := ""
for _, d := range denied {
if cmdpolicy.IsPluginPolicySource(d.PolicySource) && d.DeniedMessage != "" {
message = d.DeniedMessage
break
}
}
diag := map[string]cmdpolicy.Denial{}
for _, path := range cmdpolicy.DiagnosticPaths() {
diag[path] = cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: source,
ReasonCode: "diagnostics_hidden",
Reason: "policy self-inspection hidden by the integrator",
DeniedMessage: message,
}
}
cmdpolicy.Apply(rootCmd, diag)
}
// concealDiagnostics hides the diagnostic exemptions from help — without
// touching their RunE, so they stay dispatchable — once every non-exempt
// sibling in their domain is already hidden. The group itself then gets
// the unavailable stub: it escaped denial aggregation only because the
// exemptions kept a runnable descendant alive, and its unknown-subcommand
// guard RunE would otherwise keep it listed in help.
func concealDiagnostics(rootCmd *cobra.Command, denied map[string]cmdpolicy.Denial) {
for _, group := range diagnosticDomainGroups(rootCmd) {
if !pluginDeniedUnder(denied, cmdpolicy.CanonicalPath(group)) {
continue
}
exemptAncestors := map[*cobra.Command]bool{}
for _, path := range cmdpolicy.DiagnosticPaths() {
for c := findByPath(rootCmd, path); c != nil && c != group; c = c.Parent() {
exemptAncestors[c] = true
}
}
allOthersHidden := true
for _, child := range group.Commands() {
if child.Name() == "help" || exemptAncestors[child] {
continue
}
if !child.Hidden {
allOthersHidden = false
break
}
}
if !allOthersHidden {
continue
}
for c := range exemptAncestors {
c.Hidden = true
}
var sample cmdpolicy.Denial
for path, d := range denied {
if strings.HasPrefix(path, cmdpolicy.CanonicalPath(group)+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
sample = d
break
}
}
cmdpolicy.Apply(rootCmd, map[string]cmdpolicy.Denial{
cmdpolicy.CanonicalPath(group): {
Layer: cmdpolicy.LayerPolicy,
PolicySource: sample.PolicySource,
RuleName: sample.RuleName,
ReasonCode: "all_children_denied",
Reason: "all child commands are denied",
DeniedMessage: sample.DeniedMessage,
},
})
}
}
// diagnosticDomainGroups returns the top-level groups containing a
// diagnostic exemption (today just `config`).
func diagnosticDomainGroups(rootCmd *cobra.Command) []*cobra.Command {
seen := map[*cobra.Command]bool{}
var out []*cobra.Command
for _, path := range cmdpolicy.DiagnosticPaths() {
top, _, _ := strings.Cut(path, "/")
if c := findByPath(rootCmd, top); c != nil && !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out
}
func pluginDeniedUnder(denied map[string]cmdpolicy.Denial, prefix string) bool {
for path, d := range denied {
if strings.HasPrefix(path, prefix+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
return true
}
}
return false
}
// findByPath resolves a canonical slash path (e.g. "config/policy/show")
// to the command node, or nil.
func findByPath(rootCmd *cobra.Command, path string) *cobra.Command {
cur := rootCmd
for _, seg := range strings.Split(path, "/") {
var next *cobra.Command
for _, child := range cur.Commands() {
if child.Name() == seg {
next = child
break
}
}
if next == nil {
return nil
}
cur = next
}
return cur
}

397
cmd/presentation_test.go Normal file
View File

@@ -0,0 +1,397 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/update"
)
// restrictingPlugin registers a plugin that denies the given globs; extra
// customizes the builder further (nil for none).
func restrictingPlugin(t *testing.T, deny []string, extra func(*platform.Builder) *platform.Builder) {
t.Helper()
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
t.Cleanup(func() { policystate.ResetForTesting() })
b := platform.NewPlugin("acme", "1.0").Restrict(&platform.Rule{Deny: deny})
if extra != nil {
b = extra(b)
}
platform.Register(b.MustBuild())
}
// runnableUnder returns the first runnable non-exempt descendant under
// the named top-level group of the real command tree (the diagnostic
// exemptions keep their original RunE and would not exercise the stub).
func runnableUnder(t *testing.T, root *cobra.Command, group string) *cobra.Command {
t.Helper()
g := findByPath(root, group)
if g == nil {
t.Fatalf("group %q not found in command tree", group)
}
var find func(c *cobra.Command) *cobra.Command
find = func(c *cobra.Command) *cobra.Command {
if c.RunE != nil && len(c.Commands()) == 0 && !cmdpolicy.IsDiagnosticPath(cmdpolicy.CanonicalPath(c)) {
return c
}
for _, child := range c.Commands() {
if leaf := find(child); leaf != nil {
return leaf
}
}
return nil
}
leaf := find(g)
if leaf == nil {
t.Fatalf("no runnable non-exempt leaf under %q", group)
}
return leaf
}
// A plugin-denied command answers with command_unavailable: default
// message, no hint, no policy vocabulary.
func TestBuildInternal_pluginDenyPresentsUnavailable(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := runnableUnder(t, root, "config")
err := leaf.RunE(leaf, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("subtype = %q, want command_unavailable", ve.Subtype)
}
if ve.Message != cmdpolicy.DefaultUnavailableMessage || ve.Hint != "" {
t.Errorf("message=%q hint=%q, want default message and empty hint", ve.Message, ve.Hint)
}
}
// Rule.DeniedMessage speaks in the integrator's product voice.
func TestBuildInternal_deniedMessageOverride(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
t.Cleanup(func() { policystate.ResetForTesting() })
platform.Register(platform.NewPlugin("acme", "1.0").
Restrict(&platform.Rule{Deny: []string{"config/**"}, DeniedMessage: "not part of acme cli"}).
MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := runnableUnder(t, root, "config")
err := leaf.RunE(leaf, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Message != "not part of acme cli" {
t.Errorf("message = %q, want the integrator's DeniedMessage", ve.Message)
}
}
// Explicit help on a plugin-denied command must not render the original
// usage; it answers with the same unavailable envelope.
func TestBuildInternal_pluginDenyHelpIntercepted(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := runnableUnder(t, root, "config")
var buf bytes.Buffer
leaf.SetOut(&buf)
leaf.SetErr(&buf)
root.HelpFunc()(leaf, nil)
out := buf.String()
if !strings.Contains(out, "command_unavailable") {
t.Errorf("explicit help must answer command_unavailable, got:\n%s", out)
}
if strings.Contains(out, "Usage:") {
t.Errorf("explicit help must not render the original usage, got:\n%s", out)
}
// yaml-source presentation is untouched: a command outside the denied
// domain still renders normal help.
var normal bytes.Buffer
alive := findByPath(root, "skills")
alive.SetOut(&normal)
alive.SetErr(&normal)
root.HelpFunc()(alive, nil)
if strings.Contains(normal.String(), "command_unavailable") {
t.Errorf("non-denied command help must render normally, got:\n%s", normal.String())
}
}
// `lark-cli help <plugin-restricted-cmd>` must fail with the typed
// unavailable error (exit non-zero), not print an envelope and exit 0.
func TestBuildInternal_helpCommandReturnsUnavailable(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
helpCmd := findByPath(root, "help")
if helpCmd == nil || helpCmd.RunE == nil {
t.Fatal("custom help command not installed")
}
err := helpCmd.RunE(helpCmd, []string{"config"})
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("help on a restricted command must return command_unavailable, got %v", err)
}
// A live target renders normally and returns nil.
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
if err := helpCmd.RunE(helpCmd, []string{"skills"}); err != nil {
t.Errorf("help on a live command must succeed, got %v", err)
}
}
// help is a framework meta command: an allow-list rule must not deny it.
// `help <live-cmd>` renders; `help <denied-cmd>` returns unavailable.
func TestBuildInternal_helpSurvivesAllowList(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
t.Cleanup(func() { policystate.ResetForTesting() })
platform.Register(platform.NewPlugin("acme", "1.0").
Restrict(&platform.Rule{Allow: []string{"im/**"}, AllowUnannotated: true}).
MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
helpCmd := findByPath(root, "help")
if helpCmd == nil || helpCmd.Annotations[cmdpolicy.AnnotationDenialLayer] != "" {
t.Fatalf("help must not be policy-denied under an allow-list; annotations=%v", helpCmd.Annotations)
}
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
if err := helpCmd.RunE(helpCmd, []string{"im"}); err != nil {
t.Errorf("help on an allowed domain must render, got %v", err)
}
err := helpCmd.RunE(helpCmd, []string{"config"})
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("help on a denied domain must return command_unavailable, got %v", err)
}
}
// The plugin inventory surfaces the new contributions so `config plugins
// show` can answer "what did this build customize".
func TestBuildInternal_inventoryCoversNewCapabilities(t *testing.T) {
tmpHome(t)
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
restrictingPlugin(t, []string{"profile/**"}, func(b *platform.Builder) *platform.Builder {
return b.HideDiagnostics().
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
})
buildInternal(context.Background(), buildInvocationForTest(t))
inv := internalplatform.GetActiveInventory()
if inv == nil || len(inv.Plugins) != 1 {
t.Fatalf("inventory = %+v, want 1 plugin", inv)
}
p := inv.Plugins[0]
if !p.Capabilities.HideDiagnostics {
t.Error("inventory must surface HideDiagnostics")
}
if p.EmbeddedSkills == nil || len(p.EmbeddedSkills.Remove) != 1 || p.EmbeddedSkills.Remove[0] != "lark-a" {
t.Errorf("inventory must summarise EmbeddedSkills, got %+v", p.EmbeddedSkills)
}
}
// Denying the whole profile domain retires --profile: hidden from help,
// and setting it fails like an unknown flag.
func TestBuildInternal_profileFlagGate(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
fl := root.PersistentFlags().Lookup("profile")
if fl == nil {
t.Fatal("--profile not registered")
}
if !fl.Hidden || !isPolicyGatedFlag(fl) {
t.Errorf("flag should be hidden and policy-gated; hidden=%v gated=%v", fl.Hidden, isPolicyGatedFlag(fl))
}
// Setting the flag (as cobra's parse would) must be rejected as unknown.
if err := root.PersistentFlags().Set("profile", "prod"); err != nil {
t.Fatalf("Set: %v", err)
}
err := rejectGatedFlags(root)
var ve *errs.ValidationError
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
t.Errorf("setting a gated flag must fail as unknown flag, got %v", err)
}
}
// The gate must also fire on the real dispatch path. cobra parses a persistent
// flag on the leaf command's merged flagset, so a gate that walks
// root.PersistentFlags()'s changed table is a no-op once the flag is passed to
// a subcommand. Drive root.Execute end to end and confirm the gated --profile
// is rejected before the command body runs.
func TestBuildInternal_profileFlagGate_RejectsOnDispatch(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
ranBody := false
root.AddCommand(&cobra.Command{
Use: "gateprobe",
RunE: func(*cobra.Command, []string) error { ranBody = true; return nil },
})
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"--profile", "prod", "gateprobe"})
err := root.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
t.Errorf("a gated --profile passed on the dispatch path must fail as unknown flag, got %v", err)
}
if ranBody {
t.Error("gated --profile must be rejected before the command body runs")
}
}
// Without the profile domain denied, the gate stays inert.
func TestBuildInternal_profileFlagUntouchedWithoutDenial(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
if fl := root.PersistentFlags().Lookup("profile"); isPolicyGatedFlag(fl) {
t.Error("--profile must not be gated when its domain is not denied")
}
}
// Denying the skills domain drops the root-help skills-setup footer.
func TestBuildInternal_skillsFooterSuppressed(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
if strings.Contains(root.UsageTemplate(), "Skills setup") {
t.Error("skills-setup footer must be dropped when the skills domain is denied")
}
// Control: without the denial the footer stays.
platform.ResetForTesting()
policystate.ResetForTesting()
_, root2, _ := buildInternal(context.Background(), buildInvocationForTest(t))
if !strings.Contains(root2.UsageTemplate(), "Skills setup") {
t.Error("skills-setup footer must stay in the default build")
}
}
// Denying the skills command domain kills every `skills read` pointer in
// domain help, even when the skill content itself is still embedded — the
// command the pointers name is absent, so they would all be dead ends.
func TestBuildInternal_skillsDomainDenyKillsHelpPointers(t *testing.T) {
tmpHome(t)
withBaseSkills(t, map[string]string{"lark-im/SKILL.md": "---\ndescription: im\n---\n"})
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
im := findByPath(root, "im")
if im == nil {
t.Fatal("im domain not in tree")
}
var buf bytes.Buffer
im.SetOut(&buf)
im.SetErr(&buf)
root.HelpFunc()(im, nil)
if strings.Contains(buf.String(), "skills read") {
t.Errorf("domain help must not point at the denied skills command, got:\n%s", buf.String())
}
}
// Denying the update domain silences the _notice providers that would
// steer the caller to `lark-cli update`.
func TestComposePendingNotice_updateDomainDenied(t *testing.T) {
update.SetPending(&update.UpdateInfo{Current: "1.0.0", Latest: "1.0.1"})
t.Cleanup(func() { update.SetPending(nil) })
t.Cleanup(policystate.ResetForTesting)
policystate.SetPluginDeniedDomains(map[string]bool{"update": true})
if got := composePendingNotice(); got != nil {
t.Errorf("notices must be silenced with the update domain denied, got %+v", got)
}
policystate.SetPluginDeniedDomains(nil)
if got := composePendingNotice(); got == nil {
t.Error("notices must render without the denial")
}
}
// Default: diagnostics stay executable but leave help when their whole
// domain is denied — cobra then drops the empty config group entirely.
func TestBuildInternal_concealDiagnostics(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
show := findByPath(root, "config/policy/show")
if show == nil {
t.Fatal("config policy show not in tree")
}
if !show.Hidden {
t.Error("exempt diagnostic should be hidden from help when its domain is denied")
}
// Still dispatchable: its RunE is the original, not an unavailable stub.
if show.Annotations[cmdpolicy.AnnotationDenialLayer] != "" {
t.Error("exempt diagnostic must not carry a denial stub by default")
}
if cfg := findByPath(root, "config"); cfg.IsAvailableCommand() {
t.Error("config group with no visible children must drop from help")
}
}
// HideDiagnostics retires the exemptions like any other denied command.
func TestBuildInternal_hideDiagnostics(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
return b.HideDiagnostics()
})
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
show := findByPath(root, "config/policy/show")
if show == nil {
t.Fatal("config policy show not in tree")
}
err := show.RunE(show, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("hidden diagnostic must answer command_unavailable, got %v", err)
}
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/policystate"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
@@ -105,8 +106,14 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
},
RunE: func(c *cobra.Command, _ []string) error {
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
hint := fmt.Sprintf("denied by %s policy (reason_code %s)", cd.Layer, cd.ReasonCode)
// The switch-policy pointer names `config strict-mode`; with
// the config domain plugin-denied it would be a dead end.
if !policystate.DomainDeniedByPlugin("config") {
hint += "; " + stubHint
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
WithHint("%s", hint).
WithCause(cd)
},
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
"github.com/spf13/cobra"
)
@@ -379,3 +380,41 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
t.Errorf("denial annotation overwritten or missing")
}
}
// The strict-mode stub's RunE appends a `config strict-mode` switch-policy
// pointer to its hint — but only when the config domain is still present. With
// the config domain plugin-denied that pointer is a dead end, so it is omitted;
// the denial itself (message, subtype) is unchanged.
func TestStrictModeStub_ConfigHintGatedByPluginDenial(t *testing.T) {
hintOf := func(t *testing.T) string {
t.Helper()
child := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
stub := strictModeStubFrom(child, core.StrictModeBot)
err := stub.RunE(stub, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %q, want failed_precondition", verr.Subtype)
}
return verr.Hint
}
t.Run("config domain present: switch-policy pointer included", func(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
if h := hintOf(t); !strings.Contains(h, "config strict-mode") {
t.Errorf("hint = %q, want it to reference `config strict-mode`", h)
}
})
t.Run("config domain plugin-denied: switch-policy pointer omitted", func(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
policystate.SetPluginDeniedDomains(map[string]bool{"config": true})
if h := hintOf(t); strings.Contains(h, "config strict-mode") {
t.Errorf("hint = %q, want no `config strict-mode` pointer when config is plugin-denied", h)
}
})
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"sort"
"strings"
@@ -21,6 +22,7 @@ import (
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -80,11 +82,16 @@ Global Flags:
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}` + skillsSetupFooter + `
`
// skillsSetupFooter is the root-help pointer at the human one-time skills
// setup. Split out so the presentation pass can drop it from the template
// when an integrator plugin denies the skills domain.
const skillsSetupFooter = `{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
@@ -167,6 +174,10 @@ func setupNotices() {
// Extracted from Execute so the composition is unit-testable.
func composePendingNotice() map[string]interface{} {
notice := map[string]interface{}{}
// All three notices steer the caller to `lark-cli update`.
if policystate.DomainDeniedByPlugin("update") {
return nil
}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
@@ -658,16 +669,80 @@ func visibleFlagNames(c *cobra.Command) []string {
return names
}
// installHelpCommand replaces cobra's default help command so that
// `lark-cli help <plugin-restricted-cmd>` returns a typed error (exit 2)
// instead of printing an envelope and exiting 0 — cobra's stock help
// command has no error channel.
func installHelpCommand(root *cobra.Command) {
helpCmd := &cobra.Command{
Use: "help [command]",
Short: "Help about any command",
Long: "Help provides help for any command in the application.",
RunE: func(c *cobra.Command, args []string) error {
target, _, err := root.Find(args)
if err != nil || target == nil {
c.Printf("Unknown help topic %#q\n", args)
return root.Usage()
}
if msg, ok := unavailableHelpMessage(target); ok {
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg)
}
target.InitDefaultHelpFlag()
return target.Help()
},
}
// help attaches after policy evaluation (framework meta command, never
// policy-evaluated); the read annotation is defensive in case a future
// pass re-evaluates the finished tree.
cmdutil.SetRisk(helpCmd, "read")
cmdutil.DisableAuthCheck(helpCmd)
root.SetHelpCommand(helpCmd)
// SetHelpCommand alone defers attachment to Execute's
// InitDefaultHelpCmd; add it now so the built tree is complete
// (InitDefaultHelpCmd re-adds idempotently).
root.AddCommand(helpCmd)
}
// unavailableHelpMessage returns the message to render in place of help
// for a plugin-restricted command. yaml-source denials keep original help.
func unavailableHelpMessage(cmd *cobra.Command) (string, bool) {
if cmd == nil || cmd.Annotations == nil {
return "", false
}
if cmd.Annotations[cmdpolicy.AnnotationDenialLayer] != cmdpolicy.LayerPolicy ||
!cmdpolicy.IsPluginPolicySource(cmd.Annotations[cmdpolicy.AnnotationDenialSource]) {
return "", false
}
if msg := cmd.Annotations[cmdpolicy.AnnotationDenialMessage]; msg != "" {
return msg, true
}
return cmdpolicy.DefaultUnavailableMessage, true
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips. It also force-shows global
// flags that are normally hidden in single-app mode (currently --profile)
// when rendering the root command's own help, so users discovering the CLI
// still see them at `lark-cli --help`.
func installTipsHelpFunc(root *cobra.Command) {
//
// skillContent is read lazily at help-render time (not captured up front) so
// the domain-guide pointer reflects the resolved skill tree -- the same
// f.SkillContent that `skills list`/`read` serve -- even though plugin skill
// customization is applied after this help func is installed.
func installTipsHelpFunc(root *cobra.Command, skillContent func() fs.FS) {
defaultHelp := root.HelpFunc()
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
// Explicit help on a plugin-restricted command answers the same
// unavailable envelope as its RunE stub, not the original usage.
if msg, ok := unavailableHelpMessage(cmd); ok {
output.WriteTypedErrorEnvelope(cmd.ErrOrStderr(),
errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg), "")
return
}
if cmd == root {
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
// Force-show flags hidden by single-app mode; never a
// policy-retired one.
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden && !isPolicyGatedFlag(f) {
f.Hidden = false
defer func() { f.Hidden = true }()
}
@@ -675,15 +750,15 @@ func installTipsHelpFunc(root *cobra.Command) {
// Domain and method commands compose their agent guidance into Long lazily
// here (shortcuts attach after service registration); both skip the generic
// bottom-of-help append below.
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
if service.PrepareDomainHelp(cmd, skillContent()) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
if service.PrepareMethodHelp(cmd, skillContent()) {
defaultHelp(cmd, args)
return
}
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
if service.PrepareShortcutHelp(cmd, skillContent()) {
defaultHelp(cmd, args)
return
}

View File

@@ -5,6 +5,7 @@ package cmd
import (
"bytes"
"io/fs"
"strings"
"testing"
@@ -12,6 +13,10 @@ import (
"github.com/spf13/cobra"
)
// nilSkills is the skill-content getter used by help-func tests that do
// not exercise the domain-guide pointer.
func nilSkills() fs.FS { return nil }
// rendersHelp runs the wrapped help func and returns stdout.
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
t.Helper()
@@ -24,7 +29,7 @@ func rendersHelp(t *testing.T, cmd *cobra.Command) string {
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
installTipsHelpFunc(root, nilSkills)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")
@@ -38,7 +43,7 @@ func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
installTipsHelpFunc(root, nilSkills)
child := &cobra.Command{Use: "list", Short: "list items"}
root.AddCommand(child)
@@ -51,7 +56,7 @@ func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root)
installTipsHelpFunc(root, nilSkills)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")

View File

@@ -297,6 +297,26 @@ func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
}
// A service domain carries only a Short at help time; it seeds the base.
// The domain-guide pointer is likewise gated: removing the domain's skill
// drops the pointer instead of leaving it dangling.
func TestPrepareDomainHelp_GatesGuidePointerOnFS(t *testing.T) {
present := domainCmd("Consume and manage real-time events", "")
if !PrepareDomainHelp(present, fstest.MapFS{"lark-event/SKILL.md": &fstest.MapFile{Data: []byte("x")}}) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.Contains(present.Long, "lark-cli skills read lark-event") {
t.Errorf("skill present should emit the domain-guide pointer; got:\n%s", present.Long)
}
removed := domainCmd("Consume and manage real-time events", "")
if !PrepareDomainHelp(removed, fstest.MapFS{}) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if strings.Contains(removed.Long, "skills read lark-event") {
t.Errorf("removed skill must leave no domain-guide pointer; got:\n%s", removed.Long)
}
}
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
dom := domainCmd("Message and group chat management", "")
if !PrepareDomainHelp(dom, nil) {

View File

@@ -0,0 +1,227 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"errors"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/skillcontent"
)
// withBaseSkills swaps the process-global embedded skill tree for the
// duration of a test, restoring it afterward.
func withBaseSkills(t *testing.T, files map[string]string) {
t.Helper()
base := fstest.MapFS{}
for p, content := range files {
base[p] = &fstest.MapFile{Data: []byte(content)}
}
saved := embeddedSkillContent
t.Cleanup(func() { embeddedSkillContent = saved })
embeddedSkillContent = base
}
// A plugin's SkillsOverlay must reshape the tree the factory serves: skills
// list/read read f.SkillContent, so a resolved removal/overlay shows up here.
// (The --help pointers are gated on the same f.SkillContent; that gating is
// covered by the PrepareDomainHelp/PrepareMethodHelp tests in cmd/service.)
func TestBuildInternal_appliesPluginSkillsOverlay(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
})
overlay := fstest.MapFS{
"lark-new/SKILL.md": &fstest.MapFile{Data: []byte("---\ndescription: new\n---\n")},
}
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{
Remove: []string{"lark-shared"},
Overlay: overlay,
}).MustBuild())
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
if f.SkillContent == nil {
t.Fatal("f.SkillContent is nil after skill resolution")
}
skills, err := skillcontent.New(f.SkillContent).List()
if err != nil {
t.Fatalf("List: %v", err)
}
var names []string
for _, s := range skills {
names = append(names, s.Name)
}
if got := strings.Join(names, ","); got != "lark-a,lark-b,lark-new" {
t.Errorf("skills = %q, want lark-a,lark-b,lark-new (shared removed, new added)", got)
}
}
// Two plugins each customizing skills must abort at dispatch with a
// structured envelope carrying reason_code multiple_skills_overlay_plugins, not
// silently fall back to the default tree.
func TestBuildInternal_multipleSkillPluginsGuard(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
platform.Register(platform.NewPlugin("globex", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("skill conflict guard path should yield nil registry")
}
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatal("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
t.Errorf("hint should surface reason_code multiple_skills_overlay_plugins, got %q", verr.Hint)
}
}
// Allow keeps only the listed skills from the base — a CLI upgrade adding
// new embedded skills cannot widen an allow-listed build.
func TestBuildInternal_appliesAllowList(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
t.Cleanup(func() { policystate.ResetForTesting() })
withBaseSkills(t, map[string]string{
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
"lark-c/SKILL.md": "---\ndescription: c\n---\n",
})
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Allow: []string{"lark-a", "lark-c"}}).
MustBuild())
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
skills, err := skillcontent.New(f.SkillContent).List()
if err != nil {
t.Fatalf("List: %v", err)
}
var names []string
for _, s := range skills {
names = append(names, s.Name)
}
if got := strings.Join(names, ","); got != "lark-a,lark-c" {
t.Errorf("skills = %q, want lark-a,lark-c (allow-list)", got)
}
}
// A plugin whose SkillsOverlay cannot compose (Remove naming a skill absent
// from the base) must abort with reason_code invalid_skills_overlay.
func TestBuildInternal_invalidSkillsOverlayGuard(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-does-not-exist"}}).MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatal("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if !strings.Contains(verr.Hint, "invalid_skills_overlay") {
t.Errorf("hint should surface reason_code invalid_skills_overlay, got %q", verr.Hint)
}
}
// WithEmbeddedSkills customizes skills for a caller that builds the tree directly
// (no plugin) — the build-option analogue of a plugin's EmbeddedSkills(...).
func TestBuildInternal_withSkillsOption(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
})
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t),
WithEmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}}))
skills, err := skillcontent.New(f.SkillContent).List()
if err != nil {
t.Fatalf("List: %v", err)
}
var names []string
for _, s := range skills {
names = append(names, s.Name)
}
if got := strings.Join(names, ","); got != "lark-a" {
t.Errorf("skills = %q, want lark-a (shared removed via WithEmbeddedSkills)", got)
}
}
// WithEmbeddedSkills and a plugin's EmbeddedSkills() are two owners of skill content; the
// single-owner rule aborts startup.
func TestBuildInternal_withSkillsPlusPluginConflicts(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t),
WithEmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}))
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatal("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
t.Errorf("WithEmbeddedSkills + plugin should conflict; hint = %q", verr.Hint)
}
}

View File

@@ -14,6 +14,7 @@ const (
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeCommandUnavailable Subtype = "command_unavailable" // command not included in this build (integrator-restricted distribution); absent, not gated
)
// CategoryAuthentication subtypes

View File

@@ -60,6 +60,7 @@ You should see `audit` in the plugin list.
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
| `EmbeddedSkills(SkillsOverlay)` | Bootstrap-time, ≤1 per plugin | No (customizes embedded skills) |
### Plugin lifecycle
@@ -79,7 +80,7 @@ sequenceDiagram
Host->>SDK: InstallAll()
SDK->>Plugin: Capabilities()
SDK->>Plugin: Install(Registrar)
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
Plugin->>SDK: Observe / Wrap / Restrict / EmbeddedSkills / On(Startup,Shutdown)
SDK->>Plugin: On(Startup) fire
Note over Host,Plugin: Each command dispatch
@@ -113,6 +114,35 @@ the rejected dispatch.
widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which
may itself list several rules under `rules:`) is shadowed by any plugin
Restrict.
- A plugin may call `EmbeddedSkills()` at most once to customize the embedded
skill tree — `Allow` keeps only the listed skills (the allow-list
counterpart of `Rule.Allow`, so a CLI upgrade cannot widen the build;
`Remove` wins over `Allow`, and `Overlay` entries are exempt), `Remove`
drops skills, `Overlay` adds/replaces ones, or swap the whole `Base`
layered over the CLI default. Unlike `Restrict()`
it is NOT a security boundary and does not imply `FailClosed`: it
shapes fail-open guidance content. Removing a skill only drops its
`skills read` / `--help` guidance — it does NOT disable the matching
commands (use `Restrict()` for that). Only ONE plugin per binary may
contribute a `SkillsOverlay`; two DISTINCT plugins is a deliberate
`multiple_skills_overlay_plugins` error.
- A command denied by a **plugin** Rule presents as absent, not as
forbidden: it leaves `--help` and completion, explicit help on it is
intercepted, and invoking it answers `subtype=command_unavailable`
with no policy vocabulary and no recovery hint. Set
`Rule.DeniedMessage` to replace the default
"command not included in this build" with your product's own wording.
yaml-source denials keep the classic `command_denied` presentation —
the user owns that policy and needs to see how to adjust it. Denying a
whole domain also retires what points at it: `--profile` (profile
domain) hides and rejects use, the root-help skills footer (skills),
update notices (update), and the `auth login` recovery hint (auth)
stop rendering.
- `config policy show` / `config plugins show` stay executable under any
plugin policy (hidden from help when their domain is denied) so an
operator can still inspect the rule that locked the build. An
integrator shipping a fully-managed distribution opts out with
`HideDiagnostics()` — requires `Restrict()` on the same plugin.
- The `Wrap` factory runs **once per command dispatch**, not at
install time. Long-lived state (clients, caches, metrics counters)
must live on the Plugin struct or in package-level variables.
@@ -153,6 +183,8 @@ messages are localised and may change between releases.
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
| `invalid_skills_overlay` | Staging fault (`EmbeddedSkills(nil)` / second call in one plugin) honours FailurePolicy; a `SkillsOverlay` that can't compose (`Remove` names a skill absent from the base, `Overlay` entry lacks `SKILL.md`) always aborts via a fatal dispatch guard | Mixed — see left |
| `multiple_skills_overlay_plugins` | Two or more DISTINCT plugins each contributed a `SkillsOverlay` (only one may own skill content) | No — always aborts (dispatch guard) |
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
| `install_panic` | `Plugin.Install` panicked | Yes |
@@ -187,8 +219,8 @@ should additionally check `detail.layer == "policy"`.
- [Runnable example: audit observer](./examples/audit-observer/)
- [Runnable example: read-only policy](./examples/readonly-policy/)
- Builder API: see [`builder.go`](./builder.go) for the full DSL
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`,
`MustBuild`).
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `EmbeddedSkills`,
`HideDiagnostics`, `FailOpen`/`FailClosed`, `MustBuild`).
- Inventory diagnostic: run `lark-cli config plugins show` after
installing your plugin to see hooks/rules attributed to your plugin
name.

View File

@@ -36,8 +36,9 @@ type Builder struct {
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
actions []func(Registrar)
rules []*Rule
skillsOverlay *SkillsOverlay
hookNames map[string]bool
errs []error
@@ -81,6 +82,15 @@ func (b *Builder) FailClosed() *Builder {
return b
}
// HideDiagnostics retires the policy self-inspection commands
// (`config policy show`, `config plugins show`), which otherwise stay
// executable under a plugin policy as the operator's escape hatch.
// Requires Restrict() on the same plugin; Build fails otherwise.
func (b *Builder) HideDiagnostics() *Builder {
b.caps.HideDiagnostics = true
return b
}
// Observer registers an Observer. Multiple calls accumulate.
func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder {
if !b.validateHookName(hookName, "observer") {
@@ -145,6 +155,35 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
return b
}
// EmbeddedSkills contributes a SkillsOverlay (see SkillsOverlay)
// customizing the CLI's embedded skill content. Unlike Restrict it does
// NOT imply FailClosed: skill customization is guidance content, not a
// security boundary. A plugin owns at most one SkillsOverlay, so calling
// EmbeddedSkills more than once is a build error.
func (b *Builder) EmbeddedSkills(spec *SkillsOverlay) *Builder {
if spec == nil {
b.errs = append(b.errs, errors.New("EmbeddedSkills(nil): spec must not be nil"))
return b
}
if b.skillsOverlay != nil {
b.errs = append(b.errs, errors.New("EmbeddedSkills() called more than once; a plugin owns at most one SkillsOverlay"))
return b
}
b.skillsOverlay = cloneSkillsOverlay(spec)
return b
}
// cloneSkillsOverlay snapshots the caller's spec so a later mutation of the
// same *SkillsOverlay cannot alter the staged copy. The Remove slice is
// copied; Overlay/Base are fs.FS handles retained by reference (an fs.FS
// is a read-only view, not caller-mutable state).
func cloneSkillsOverlay(spec *SkillsOverlay) *SkillsOverlay {
cp := *spec
cp.Allow = append([]string(nil), spec.Allow...)
cp.Remove = append([]string(nil), spec.Remove...)
return &cp
}
// Build returns the configured Plugin, or an error if any builder
// step found a fault. MustBuild panics on the same error.
//
@@ -155,15 +194,20 @@ func (b *Builder) Build() (Plugin, error) {
b.errs = append(b.errs, errors.New(
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
}
if b.caps.HideDiagnostics && len(b.rules) == 0 {
b.errs = append(b.errs, errors.New(
"HideDiagnostics() requires Restrict(): there is no integrator policy to hide"))
}
if len(b.errs) > 0 {
return nil, errors.Join(b.errs...)
}
return &builtPlugin{
name: b.name,
version: b.version,
caps: b.caps,
actions: b.actions,
rules: b.rules,
name: b.name,
version: b.version,
caps: b.caps,
actions: b.actions,
rules: b.rules,
skillsOverlay: b.skillsOverlay,
}, nil
}
@@ -202,11 +246,12 @@ func (b *Builder) validateHookName(hookName, kind string) bool {
// builtPlugin is the Plugin implementation the builder emits.
type builtPlugin struct {
name string
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
name string
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
skillsOverlay *SkillsOverlay
}
func (p *builtPlugin) Name() string { return p.name }
@@ -216,6 +261,9 @@ func (p *builtPlugin) Install(r Registrar) error {
for _, rule := range p.rules {
r.Restrict(rule)
}
if p.skillsOverlay != nil {
r.EmbeddedSkills(p.skillsOverlay)
}
for _, action := range p.actions {
action(r)
}

View File

@@ -14,11 +14,13 @@ import (
// recorder Registrar captures everything a builder schedules so the
// test can assert what Install produced without involving the host.
type recorder struct {
observers int
wrappers int
lifecycles int
rule *platform.Rule // last rule (existing single-rule assertions)
rules []*platform.Rule // every rule, in Restrict order
observers int
wrappers int
lifecycles int
rule *platform.Rule // last rule (existing single-rule assertions)
rules []*platform.Rule // every rule, in Restrict order
skillsOverlay *platform.SkillsOverlay
skillCalls int
}
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
@@ -30,6 +32,10 @@ func (r *recorder) Restrict(rule *platform.Rule) {
r.rule = rule
r.rules = append(r.rules, rule)
}
func (r *recorder) EmbeddedSkills(spec *platform.SkillsOverlay) {
r.skillsOverlay = spec
r.skillCalls++
}
// Restrict must snapshot each rule: a caller that reuses and mutates the
// same *Rule object across two Restrict calls must still get two distinct
@@ -211,3 +217,78 @@ func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
}
}
// EmbeddedSkills() must snapshot Remove: a caller that mutates the same slice
// after the call must still get the value staged at call time.
func TestBuilder_skillsInstalledAndCloned(t *testing.T) {
remove := []string{"lark-shared"}
b := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: remove})
remove[0] = "mutated"
p, err := b.Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
r := &recorder{}
if err := p.Install(r); err != nil {
t.Fatalf("Install: %v", err)
}
if r.skillCalls != 1 {
t.Fatalf("Skills calls = %d, want 1", r.skillCalls)
}
if r.skillsOverlay == nil || len(r.skillsOverlay.Remove) != 1 || r.skillsOverlay.Remove[0] != "lark-shared" {
t.Errorf("staged Remove leaked later mutation: %+v", r.skillsOverlay)
}
}
func TestBuilder_skillsNilRejected(t *testing.T) {
_, err := platform.NewPlugin("p", "0").EmbeddedSkills(nil).Build()
if err == nil {
t.Fatal("EmbeddedSkills(nil) must produce error")
}
}
func TestBuilder_skillsTwiceRejected(t *testing.T) {
_, err := platform.NewPlugin("p", "0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}}).
Build()
if err == nil {
t.Fatal("calling EmbeddedSkills() twice must produce error")
}
}
// EmbeddedSkills() customizes guidance content, not a security boundary, so
// unlike Restrict() it must NOT force FailClosed.
func TestBuilder_skillsDoesNotForceFailClosed(t *testing.T) {
p, err := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
caps := p.Capabilities()
if caps.Restricts {
t.Error("EmbeddedSkills() must not set Restricts")
}
if caps.FailurePolicy != platform.FailOpen {
t.Errorf("FailurePolicy = %v, want FailOpen (default)", caps.FailurePolicy)
}
}
// HideDiagnostics only makes sense alongside Restrict: without a rule
// there is no integrator policy whose inspection could be hidden.
func TestBuilder_hideDiagnosticsRequiresRestrict(t *testing.T) {
_, err := platform.NewPlugin("p", "0").HideDiagnostics().Build()
if err == nil {
t.Fatal("HideDiagnostics() without Restrict() must fail Build")
}
p, err := platform.NewPlugin("p", "0").
Restrict(&platform.Rule{Deny: []string{"config/**"}}).
HideDiagnostics().
Build()
if err != nil {
t.Fatalf("HideDiagnostics()+Restrict() must build: %v", err)
}
if !p.Capabilities().HideDiagnostics {
t.Error("Capabilities.HideDiagnostics must be set")
}
}

View File

@@ -47,4 +47,9 @@ type Capabilities struct {
// constants above; the framework requires FailClosed whenever
// Restricts=true.
FailurePolicy FailurePolicy
// HideDiagnostics retires the policy self-inspection commands
// (`config policy show`, `config plugins show`) from the build.
// Requires Restricts=true; the install fails otherwise.
HideDiagnostics bool
}

View File

@@ -35,4 +35,18 @@ type Registrar interface {
// Plugin rules take precedence over the yaml source; two distinct
// plugins both calling Restrict abort startup.
Restrict(r *Rule)
// Skills contributes a SkillsOverlay customizing the CLI's embedded skill
// content (see SkillsOverlay). Skill content has a single owner: a second
// customizing plugin, or a SkillsOverlay that cannot compose, aborts
// startup unconditionally. A malformed call inside one plugin --
// EmbeddedSkills(nil) or a second EmbeddedSkills() -- is a staging fault handled like
// any Install error (FailClosed aborts, FailOpen skips); the Builder
// rejects it earlier, at MustBuild.
//
// Unlike Restrict, EmbeddedSkills is not a security boundary -- it shapes
// fail-open guidance content. Removing a skill drops its guidance but
// does not disable any command; use Restrict to actually block a
// command.
EmbeddedSkills(spec *SkillsOverlay)
}

View File

@@ -6,8 +6,9 @@ package platform
// Rule is the declarative policy rule data structure. yaml files and
// Plugin.Restrict() both produce the same Rule.
//
// At any moment there is at most one effective Rule -- the resolver decides
// which source wins (Plugin > yaml > none). This package only defines the
// At any moment there is at most one effective SOURCE of rules -- the
// resolver decides which wins (Plugin > yaml > none); the winning source
// may contribute several scoped rules. This package only defines the
// shape; selection lives in internal/cmdpolicy.
//
// The four filter fields are joined by AND. See the engine's Evaluate for
@@ -57,4 +58,11 @@ type Rule struct {
// No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so
// platform stays free of a yaml library dependency.
AllowUnannotated bool `json:"allow_unannotated,omitempty"`
// DeniedMessage replaces the default "command not included in this
// build" message for plugin-denied commands. The message is
// build-level: the first non-empty DeniedMessage across the owning
// plugin's rules applies to every denial, so declare it once. yaml
// rules ignore it (they keep the command-denied presentation).
DeniedMessage string `json:"denied_message,omitempty"`
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
import "io/fs"
// SkillsOverlay declares how a plugin customizes the CLI's embedded
// skill content, contributed via Builder.EmbeddedSkills. At most one
// source may own skill content; two customizing plugins abort startup.
//
// Allow / Remove mirror Rule's Allow / Deny: an allow-list keeps only
// what it names, a remove-list drops what it names, and Remove wins
// over Allow. Composition order is fixed: Base (or the CLI default) ->
// Allow -> Remove -> Overlay, a same-named skill resolving to Overlay.
//
// Skills are addressed by exact name (a directory carrying SKILL.md,
// e.g. "lark-doc"), not by command path and not by glob — the skill
// list is flat, so misspellings abort startup instead of silently
// matching nothing. Removing a skill only drops its guidance; it does
// not disable any command (use Restrict for that).
type SkillsOverlay struct {
// Allow, when non-empty, keeps only these skills (by name) from the
// base tree — the allow-list counterpart of Rule.Allow. Skills the
// CLI adds in future versions stay out of the build until listed
// here, which a Remove-only spec cannot guarantee. A name not
// present in the base aborts startup. Overlay entries are exempt:
// content the integrator explicitly ships needs no allow-listing.
Allow []string
// Remove hides these skills, by name (e.g. "lark-shared"), from the
// base tree; it wins over Allow, mirroring Rule's Deny-over-Allow. A
// name not present in the base aborts startup rather than being
// silently ignored.
Remove []string
// Overlay contributes skills laid over the base: a same-named skill
// replaces the base's entirely, a new name adds one. It is rooted at
// the skill list (entries like "my-skill/SKILL.md"); each top-level
// entry must be a "<name>/" directory containing SKILL.md. Any fs.FS
// works (embed.FS, os.DirFS, fstest.MapFS); embed.FS is not required.
Overlay fs.FS
// Base replaces the entire base skill tree instead of layering over
// the CLI default. nil keeps the CLI default. Rare: most integrators
// leave it nil and use Remove/Overlay so unchanged skills follow the
// CLI version with no copy to maintain.
Base fs.FS
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
)
const (
@@ -41,11 +42,15 @@ func (e *NeedAuthorizationError) Error() string {
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
// errors.As / errors.Is traversal.
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
WithUserOpenID(userOpenID).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
// No recovery hint when the auth domain is absent from this build.
if !policystate.DomainDeniedByPlugin("auth") {
e = e.WithHint("run: lark-cli auth login to re-authorize")
}
return e
}
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/policystate"
)
// The auth-login recovery hint points into the auth domain; when an
// integrator plugin denied that whole domain the hint would be a dead
// end, so it stays off the error.
func TestNeedUserAuthorization_hintFollowsAuthDomain(t *testing.T) {
t.Cleanup(policystate.ResetForTesting)
policystate.SetPluginDeniedDomains(nil)
if e := NewNeedUserAuthorizationError("ou_x"); !strings.Contains(e.Hint, "auth login") {
t.Errorf("default build must keep the auth login hint, got %q", e.Hint)
}
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
if e := NewNeedUserAuthorizationError("ou_x"); e.Hint != "" {
t.Errorf("auth-denied build must not steer to auth login, got %q", e.Hint)
}
}

View File

@@ -23,6 +23,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/util"
)
@@ -71,10 +72,14 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
// for the defensive empty-token branch) and is preserved for errors.Is /
// errors.Unwrap traversal without being serialized on the wire.
func newTokenMissingError(as core.Identity, cause error) error {
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"no access token available for %s", as).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(cause)
// No recovery hint when the auth domain is absent from this build.
if !policystate.DomainDeniedByPlugin("auth") {
e = e.WithHint("run: lark-cli auth login to re-authorize")
}
return e
}
// buildApiReq converts a RawApiRequest into SDK types and collects

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/policystate"
)
// Same gate as internal/auth: the token-missing recovery hint points into
// the auth domain and stays off the error when a plugin denied it.
func TestTokenMissing_hintFollowsAuthDomain(t *testing.T) {
t.Cleanup(policystate.ResetForTesting)
policystate.SetPluginDeniedDomains(nil)
var ae *errs.AuthenticationError
if err := newTokenMissingError(core.AsUser, nil); !errors.As(err, &ae) || !strings.Contains(ae.Hint, "auth login") {
t.Errorf("default build must keep the auth login hint, got %v", err)
}
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
if err := newTokenMissingError(core.AsUser, nil); !errors.As(err, &ae) || ae.Hint != "" {
t.Errorf("auth-denied build must not steer to auth login, got %v", err)
}
}

View File

@@ -23,6 +23,9 @@ type ActivePolicy struct {
Rules []*platform.Rule
Source ResolveSource
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
// DeniedByPath is the full post-aggregation denial map.
DeniedByPath map[string]Denial
}
var (
@@ -81,6 +84,12 @@ func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
cp.Rules[i] = &rule
}
}
if in.DeniedByPath != nil {
cp.DeniedByPath = make(map[string]Denial, len(in.DeniedByPath))
for k, v := range in.DeniedByPath {
cp.DeniedByPath[k] = v
}
}
return &cp
}

View File

@@ -70,7 +70,7 @@ func TestBuildDeniedByPath_parentAggregationAllChildrenDenied(t *testing.T) {
}
denied := cmdpolicy.BuildDeniedByPath(root, decisions,
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent")
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent", "")
// Both leaves denied.
if _, ok := denied["im/+send"]; !ok {
@@ -110,7 +110,7 @@ func TestBuildDeniedByPath_partialDenialKeepsParent(t *testing.T) {
Deny: []string{"docs/+delete"},
})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy")
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy", "")
if _, ok := denied["docs"]; ok {
t.Errorf("parent 'docs' must NOT be denied when some children are allowed")
@@ -129,7 +129,7 @@ func TestBuildDeniedByPath_rootNeverDenied(t *testing.T) {
root := buildTree()
e := cmdpolicy.New(&platform.Rule{Allow: []string{"nonexistent/**"}})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "")
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "", "")
// Every leaf should be denied. We do not assert on the root entry
// because Apply skips the root regardless; the contract is "root
@@ -156,7 +156,7 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
Allow: []string{"docs"},
})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "")
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "", "")
// docs/+delete denied (path doesn't match Allow=["docs"]).
if _, ok := denied["docs/+delete"]; !ok {
@@ -170,13 +170,13 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
// Apply returns a typed *errs.ValidationError that exposes BOTH paths
// consumers rely on:
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition
// subtype + exit code 2)
// 1. cmd/root.go's envelope writer (errs.ProblemOf; plugin-source
// denials use subtype command_unavailable + exit code 2)
// 2. in-process consumers extracting the platform.CommandDeniedError as
// the typed error's Cause via errors.As
//
// The policy metadata (layer / policy_source / rule_name / reason_code)
// is folded into the Hint text rather than a separate detail map.
// Plugin-source denials keep the policy metadata OFF the wire (no hint,
// no source / rule vocabulary); it stays reachable on the Cause only.
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
root := buildTree()
denied := map[string]cmdpolicy.Denial{
@@ -196,29 +196,31 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
t.Fatalf("denied command should return error")
}
// Path 1: typed-envelope view. The denial is a failed_precondition
// ValidationError so cmd/root.go renders the structured envelope and
// the process exits 2 (ExitValidation).
// Path 1: typed-envelope view. A plugin-source denial presents as
// "command unavailable": the capability is absent from this build, so
// the envelope carries no policy metadata and no recovery hint.
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
if ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeCommandUnavailable)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
}
// The policy metadata is folded into the Hint text: reason_code,
// policy_source, and rule_name must all be discoverable there.
if !strings.Contains(ve.Hint, "write_not_allowed") {
t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint)
if ve.Message != cmdpolicy.DefaultUnavailableMessage {
t.Errorf("message = %q, want default unavailable message", ve.Message)
}
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
// No hint, no policy vocabulary: the wire must not steer the caller
// toward a policy the integrator locked into the build.
if ve.Hint != "" {
t.Errorf("plugin-source denial must carry no hint, got %q", ve.Hint)
}
if !strings.Contains(ve.Hint, "secaudit-policy") {
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
for _, leak := range []string{"policy", "plugin:secaudit", "secaudit-policy", "write_not_allowed"} {
if strings.Contains(ve.Message, leak) {
t.Errorf("message leaks %q: %q", leak, ve.Message)
}
}
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
@@ -301,7 +303,7 @@ func TestHasRunnableDescendant_ignoresAnnotatedPureGroup(t *testing.T) {
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
decisions := e.EvaluateAll(root)
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "", "")
if _, ok := denied["docs"]; !ok {
t.Fatalf("docs should be aggregated as fully denied (pure-group children excluded from live count); map=%+v", denied)
@@ -332,7 +334,7 @@ func TestBuildDeniedByPath_aggregatesAnnotatedPureGroup(t *testing.T) {
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
decisions := e.EvaluateAll(root)
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "", "")
if _, ok := denied["drive"]; !ok {
t.Fatalf("aggregator must install drive denial when all children denied; map=%+v", denied)

View File

@@ -4,6 +4,8 @@
package cmdpolicy
import (
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -73,6 +75,10 @@ const (
AnnotationDenialLayer = "lark:policy_denied_layer"
AnnotationDenialSource = "lark:policy_denied_source"
// AnnotationDenialMessage carries the resolved unavailable message
// for the cmd layer's help interceptor (plugin-source denials only).
AnnotationDenialMessage = "lark:policy_denied_message"
// AnnotationPureGroup marks a cobra.Command that is logically a
// parent-only group but had a RunE attached by the bootstrap-time
// unknown-subcommand guard. The engine treats annotated commands
@@ -124,6 +130,37 @@ func BuildDenialError(path string, d Denial) *errs.ValidationError {
WithCause(cd)
}
// IsPluginPolicySource reports whether a policy source names a plugin.
// Plugin sources select the "command unavailable" presentation.
func IsPluginPolicySource(source string) bool {
return strings.HasPrefix(source, "plugin:")
}
// DefaultUnavailableMessage is the message shown when a plugin-restricted
// command is invoked and the integrator supplied no Rule.DeniedMessage.
const DefaultUnavailableMessage = "command not included in this build"
// messageOf resolves the effective unavailable message for a denial.
func messageOf(d Denial) string {
if d.DeniedMessage != "" {
return d.DeniedMessage
}
return DefaultUnavailableMessage
}
// BuildUnavailableError is the plugin-source counterpart of
// BuildDenialError: no hint, no policy vocabulary on the wire. The
// *platform.CommandDeniedError stays reachable as the Cause for
// in-process consumers; Cause is never serialized.
func BuildUnavailableError(path string, d Denial) *errs.ValidationError {
msg := d.DeniedMessage
if msg == "" {
msg = DefaultUnavailableMessage
}
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg).
WithCause(CommandDeniedFromDenial(path, d))
}
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
// which does RemoveCommand+AddCommand (changing the pointer), we modify
// the existing node so any external reference (snapshots, alias targets)
@@ -194,11 +231,19 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
cmd.Annotations[AnnotationDenialSource] = d.PolicySource
denial := d // capture by value for the closure
cmd.RunE = func(c *cobra.Command, args []string) error {
// The typed message carries the user-facing semantic ("a command
// was denied"); the hint carries the layer / source / rule
// distinction ("policy" vs "strict_mode") for debugging.
return BuildDenialError(path, denial)
if IsPluginPolicySource(d.PolicySource) {
// The message annotation feeds the cmd layer's help interceptor.
cmd.Annotations[AnnotationDenialMessage] = messageOf(d)
cmd.RunE = func(c *cobra.Command, args []string) error {
return BuildUnavailableError(path, denial)
}
} else {
cmd.RunE = func(c *cobra.Command, args []string) error {
// The typed message carries the user-facing semantic ("a command
// was denied"); the hint carries the layer / source / rule
// distinction ("policy" vs "strict_mode") for debugging.
return BuildDenialError(path, denial)
}
}
// Clear any pre-existing Run hook: cobra prefers RunE when both are
// set, but leaving a stale Run around is a foot-gun for future

View File

@@ -25,6 +25,10 @@ type Denial struct {
RuleName string // matched Rule.Name (if any)
ReasonCode string // closed enum, see docs/extension/reason-codes.md
Reason string // human-readable
// DeniedMessage is Rule.DeniedMessage for the plugin-source
// unavailable presentation; empty means the default message.
DeniedMessage string
}
// ChildDenial is what AggregateChildren consumes — it pairs a Denial

View File

@@ -27,3 +27,15 @@ var diagnosticPaths = map[string]bool{
func IsDiagnosticPath(path string) bool {
return diagnosticPaths[path]
}
// DiagnosticPaths returns the exempt self-inspection command paths, for
// the presentation layer: an integrator's HideDiagnostics retires exactly
// this set, and the help-concealment pass hides it from listings when the
// surrounding domain is plugin-denied.
func DiagnosticPaths() []string {
out := make([]string, 0, len(diagnosticPaths))
for p := range diagnosticPaths {
out = append(out, p)
}
return out
}

View File

@@ -269,8 +269,9 @@ func mergeDenials(rules []*platform.Rule, denials []Decision) Decision {
// so `--help` and similar remain available.
//
// source / ruleName populate PolicySource and RuleName on the produced
// Denial values, so envelope output can attribute denials.
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string) map[string]Denial {
// Denial values, so envelope output can attribute denials. deniedMessage
// is build-level and applies uniformly, aggregates included.
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string, deniedMessage string) map[string]Denial {
out := map[string]Denial{}
sourceLabel := policySourceLabel(source)
@@ -287,6 +288,13 @@ func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, sourc
}
aggregateParents(root, out)
if deniedMessage != "" {
for path, d := range out {
d.DeniedMessage = deniedMessage
out[path] = d
}
}
return out
}

View File

@@ -34,7 +34,7 @@ func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) {
cmdpolicy.ResolveSource{
Kind: cmdpolicy.SourceYAML,
Name: "/Users/alice/.lark-cli/policy.yml", // simulate an absolute path
}, "my-readonly-rule")
}, "my-readonly-rule", "")
cmdpolicy.Apply(root, denied)
err := leaf.RunE(leaf, nil)
@@ -77,7 +77,7 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"},
"secaudit-policy")
"secaudit-policy", "")
cmdpolicy.Apply(root, denied)
err := leaf.RunE(leaf, nil)
@@ -85,10 +85,17 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// The plugin name IS surfaced (in-binary, part of the contract): it
// must appear in the Hint so an integrator debugging a denial knows
// which plugin fired.
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
// A plugin-source denial presents as absent: no hint, no plugin name
// on the wire. Plugin attribution moves to the in-process Cause
// (*platform.CommandDeniedError) for integrators debugging a denial.
if ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeCommandUnavailable)
}
if ve.Hint != "" || strings.Contains(ve.Message, "plugin:secaudit") {
t.Errorf("wire must not expose the plugin source; hint=%q message=%q", ve.Hint, ve.Message)
}
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) || cd.PolicySource != "plugin:secaudit" {
t.Errorf("in-process Cause must carry plugin:secaudit, got %+v", cd)
}
}

View File

@@ -113,6 +113,7 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},

View File

@@ -17,6 +17,7 @@ var driveCodeMeta = map[int]CodeMeta{
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied

View File

@@ -53,4 +53,12 @@ const (
ReasonInstallPanic = "install_panic"
ReasonDuplicatePluginName = "duplicate_plugin_name"
ReasonMultipleRestricts = "multiple_restrict_plugins"
// ReasonInvalidSkillsOverlay flags a plugin's SkillsOverlay that cannot
// compose -- EmbeddedSkills() called twice, a Remove naming a skill absent
// from the base, or an Overlay entry missing SKILL.md.
ReasonInvalidSkillsOverlay = "invalid_skills_overlay"
// ReasonMultipleSkillsOverlays flags two or more plugins each contributing
// a SkillsOverlay; only one may own skill content (mirrors
// ReasonMultipleRestricts).
ReasonMultipleSkillsOverlays = "multiple_skills_overlay_plugins"
)

View File

@@ -11,6 +11,7 @@ import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/skillpolicy"
)
// PluginInfo is the metadata of a successfully-installed plugin,
@@ -29,9 +30,10 @@ type PluginInfo struct {
// every plugin that committed successfully (FailOpen-skipped plugins
// are absent), for downstream diagnostics.
type InstallResult struct {
Registry *hook.Registry
PluginRules []cmdpolicy.PluginRule
Plugins []PluginInfo
Registry *hook.Registry
PluginRules []cmdpolicy.PluginRule
PluginSkills []skillpolicy.PluginSkill
Plugins []PluginInfo
}
// InstallAll runs every registered plugin through the staging
@@ -142,6 +144,16 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
}
}
// The Builder rejects this at Build time; hand-written plugins are
// caught here (authoring error, aborts unconditionally).
if caps.HideDiagnostics && !caps.Restricts {
return &PluginInstallError{
PluginName: name,
ReasonCode: ReasonInvalidCapability,
Reason: "HideDiagnostics=true requires Restricts=true",
}
}
// Version compatibility check. Two distinct failure modes:
//
// 1. Parse error (constraint is malformed, e.g. ">=abc")
@@ -207,6 +219,12 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
Rule: rule,
})
}
if staging.skillsOverlay != nil {
result.PluginSkills = append(result.PluginSkills, skillpolicy.PluginSkill{
PluginName: name,
SkillsOverlay: staging.skillsOverlay,
})
}
// Record the plugin in the inventory. Version is fetched here under
// a recover-wrapped helper so a plugin's Version() panic does not

View File

@@ -431,3 +431,79 @@ func TestInstallAll_multipleRestrictPerPlugin(t *testing.T) {
result.PluginRules[0].Rule.Name, result.PluginRules[1].Rule.Name)
}
}
// skillPlugin contributes a SkillsOverlay via r.EmbeddedSkills; the install pipeline
// must capture it into PluginSkills for the skill resolver.
type skillPlugin struct{}
func (skillPlugin) Name() string { return "skiller" }
func (skillPlugin) Version() string { return "1.0.0" }
func (skillPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailOpen}
}
func (skillPlugin) Install(r platform.Registrar) error {
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}})
return nil
}
func TestInstallAll_skillsCommitted(t *testing.T) {
result, err := internalplatform.InstallAll([]platform.Plugin{skillPlugin{}}, nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
if len(result.PluginSkills) != 1 {
t.Fatalf("PluginSkills = %d, want 1", len(result.PluginSkills))
}
ps := result.PluginSkills[0]
if ps.PluginName != "skiller" {
t.Errorf("PluginName = %q, want skiller", ps.PluginName)
}
if ps.SkillsOverlay == nil || len(ps.SkillsOverlay.Remove) != 1 || ps.SkillsOverlay.Remove[0] != "lark-shared" {
t.Errorf("Spec = %+v, want Remove=[lark-shared]", ps.SkillsOverlay)
}
}
// doubleSkillPlugin calls r.EmbeddedSkills twice; staging must reject it. Declared
// FailClosed so the staging error aborts InstallAll deterministically.
type doubleSkillPlugin struct{}
func (doubleSkillPlugin) Name() string { return "double-skiller" }
func (doubleSkillPlugin) Version() string { return "1.0.0" }
func (doubleSkillPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (doubleSkillPlugin) Install(r platform.Registrar) error {
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}})
return nil
}
func TestInstallAll_skillsCalledTwice_aborts(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{doubleSkillPlugin{}}, nil)
if err == nil {
t.Fatal("calling r.EmbeddedSkills twice must abort a FailClosed plugin")
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidSkillsOverlay {
t.Errorf("err = %v, want PluginInstallError reason_code %s", err, internalplatform.ReasonInvalidSkillsOverlay)
}
}
// hideDiagnosticsOnlyPlugin declares HideDiagnostics without Restricts —
// an authoring error the host rejects unconditionally.
type hideDiagnosticsOnlyPlugin struct{}
func (hideDiagnosticsOnlyPlugin) Name() string { return "hider" }
func (hideDiagnosticsOnlyPlugin) Version() string { return "1.0.0" }
func (hideDiagnosticsOnlyPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{HideDiagnostics: true, FailurePolicy: platform.FailOpen}
}
func (hideDiagnosticsOnlyPlugin) Install(platform.Registrar) error { return nil }
func TestInstallAll_hideDiagnosticsRequiresRestricts(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{hideDiagnosticsOnlyPlugin{}}, nil)
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidCapability {
t.Fatalf("HideDiagnostics without Restricts must abort with invalid_capability, got %v", err)
}
}

View File

@@ -29,6 +29,10 @@ type PluginEntry struct {
// plugin did not call r.Restrict.
Rules []*RuleView
// EmbeddedSkills summarises the plugin's EmbeddedSkills contribution;
// nil when the plugin did not customize embedded skills.
EmbeddedSkills *SkillsOverlayView `json:"embedded_skills,omitempty"`
Observers []HookEntry
Wrappers []HookEntry
Lifecycles []HookEntry
@@ -41,6 +45,7 @@ type CapabilitiesView struct {
Restricts bool `json:"restricts"`
FailurePolicy string `json:"failure_policy"`
RequiredCLIVersion string `json:"required_cli_version,omitempty"`
HideDiagnostics bool `json:"hide_diagnostics,omitempty"`
}
// NewCapabilitiesView converts a platform.Capabilities value into the
@@ -50,6 +55,7 @@ func NewCapabilitiesView(c platform.Capabilities) CapabilitiesView {
Restricts: c.Restricts,
FailurePolicy: failurePolicyLabel(c.FailurePolicy),
RequiredCLIVersion: c.RequiredCLIVersion,
HideDiagnostics: c.HideDiagnostics,
}
}
@@ -74,6 +80,22 @@ type RuleView struct {
AllowUnannotated bool `json:"allow_unannotated"`
}
// SkillsOverlayView is the displayable summary of an EmbeddedSkills
// contribution. Overlay / Base report presence only (an fs.FS has no
// display form).
type SkillsOverlayView struct {
Allow []string `json:"allow,omitempty"`
Remove []string `json:"remove,omitempty"`
Overlay bool `json:"overlay"`
Base bool `json:"base"`
}
// SkillsInventorySource pairs a plugin name with its overlay summary.
type SkillsInventorySource struct {
PluginName string
View SkillsOverlayView
}
// Inventory is the full snapshot.
type Inventory struct {
Plugins []PluginEntry
@@ -108,7 +130,7 @@ type RuleInventorySource struct {
// Hooks are attributed to plugins by the namespaced name convention:
// each entry's Name starts with "<plugin>.", and we group by the
// leading segment up to the first dot.
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource) *Inventory {
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource, skills []SkillsInventorySource) *Inventory {
byPlugin := make(map[string]*PluginEntry, len(plugins))
out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))}
for _, p := range plugins {
@@ -162,6 +184,15 @@ func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, ru
})
}
}
for _, sk := range skills {
if entry := byPlugin[sk.PluginName]; entry != nil {
v := sk.View
v.Allow = append([]string(nil), sk.View.Allow...)
v.Remove = append([]string(nil), sk.View.Remove...)
entry.EmbeddedSkills = &v
}
}
return out
}
@@ -263,6 +294,12 @@ func cloneInventory(in *Inventory) *Inventory {
entry.Rules[j] = &rv
}
}
if p.EmbeddedSkills != nil {
sv := *p.EmbeddedSkills
sv.Allow = append([]string(nil), p.EmbeddedSkills.Allow...)
sv.Remove = append([]string(nil), p.EmbeddedSkills.Remove...)
entry.EmbeddedSkills = &sv
}
entry.Observers = append([]HookEntry(nil), p.Observers...)
entry.Wrappers = append([]HookEntry(nil), p.Wrappers...)
entry.Lifecycles = append([]HookEntry(nil), p.Lifecycles...)

View File

@@ -36,7 +36,7 @@ func TestBuildInventory_groupsByPluginName(t *testing.T) {
{PluginName: "a", RuleName: "a-rule", Allow: []string{"docs/**"}, MaxRisk: "read"},
}
inv := internalplatform.BuildInventory(plugins, r, rules)
inv := internalplatform.BuildInventory(plugins, r, rules, nil)
if got := len(inv.Plugins); got != 2 {
t.Fatalf("Plugins len = %d, want 2", got)
@@ -89,7 +89,7 @@ func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
{PluginName: "a", RuleName: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"},
}
inv := internalplatform.BuildInventory(plugins, nil, rules)
inv := internalplatform.BuildInventory(plugins, nil, rules, nil)
a := findPlugin(inv, "a")
if a == nil {
t.Fatalf("missing entry a")
@@ -103,12 +103,45 @@ func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
}
func TestBuildInventory_empty(t *testing.T) {
inv := internalplatform.BuildInventory(nil, nil, nil)
inv := internalplatform.BuildInventory(nil, nil, nil, nil)
if got := len(inv.Plugins); got != 0 {
t.Errorf("Plugins len = %d, want 0", got)
}
}
// A non-nil SkillsInventorySource must surface on the owning plugin's entry as
// an EmbeddedSkills summary, and BuildInventory must clone the Allow/Remove
// slices so a later mutation of the source cannot corrupt the recorded view.
func TestBuildInventory_populatesAndClonesEmbeddedSkills(t *testing.T) {
plugins := []internalplatform.PluginInventorySource{{Name: "acme", Version: "1.0"}}
allow := []string{"lark-im"}
remove := []string{"lark-shared"}
skills := []internalplatform.SkillsInventorySource{
{PluginName: "acme", View: internalplatform.SkillsOverlayView{
Allow: allow, Remove: remove, Overlay: true, Base: false,
}},
}
inv := internalplatform.BuildInventory(plugins, nil, nil, skills)
entry := findPlugin(inv, "acme")
if entry == nil || entry.EmbeddedSkills == nil {
t.Fatalf("acme entry missing EmbeddedSkills: %+v", entry)
}
es := entry.EmbeddedSkills
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
len(es.Remove) != 1 || es.Remove[0] != "lark-shared" ||
!es.Overlay || es.Base {
t.Errorf("EmbeddedSkills summary mismatch: %+v", es)
}
// Mutating the source slices must not leak into the recorded view.
allow[0] = "MUTATED"
remove[0] = "MUTATED"
if es.Allow[0] != "lark-im" || es.Remove[0] != "lark-shared" {
t.Errorf("EmbeddedSkills slices not cloned; source mutation leaked: %+v", es)
}
}
func findPlugin(inv *internalplatform.Inventory, name string) *internalplatform.PluginEntry {
for i := range inv.Plugins {
if inv.Plugins[i].Name == name {

View File

@@ -41,6 +41,13 @@ type stagingRegistrar struct {
// can detect the call.
actuallyRestricted bool
// skillsOverlay holds the staged Skills contribution, captured for the
// host to feed into the skill resolver later. nil means the plugin
// did not call r.EmbeddedSkills. overlaySet records that the call happened so a
// second call in the same plugin can be rejected.
skillsOverlay *platform.SkillsOverlay
overlaySet bool
// seenHookNames detects duplicate hookName within this plugin's
// Install call.
seenHookNames map[string]bool
@@ -144,6 +151,25 @@ func (r *stagingRegistrar) Restrict(rule *platform.Rule) {
r.rules = append(r.rules, &cp)
}
func (r *stagingRegistrar) EmbeddedSkills(spec *platform.SkillsOverlay) {
if r.overlaySet {
r.bufferErr(ReasonInvalidSkillsOverlay, "EmbeddedSkills() called more than once in the same plugin")
return
}
r.overlaySet = true
if spec == nil {
r.bufferErr(ReasonInvalidSkillsOverlay, "EmbeddedSkills(nil)")
return
}
// Defensive clone: freeze Remove so a plugin cannot mutate it after
// Install returns. Overlay/Base are read-only fs.FS views retained by
// reference.
cp := *spec
cp.Allow = append([]string(nil), spec.Allow...)
cp.Remove = append([]string(nil), spec.Remove...)
r.skillsOverlay = &cp
}
// --- helpers ---
func (r *stagingRegistrar) namespaced(name string) string {

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package policystate answers "did an integrator plugin deny this whole
// command domain?" for render-time hint emitters. It is dependency-free
// because internal/auth and internal/client sit below internal/cmdpolicy
// in the import graph and cannot ask it directly. Written once by the
// bootstrap after policy pruning; read-only thereafter.
package policystate
import "sync"
var (
mu sync.RWMutex
pluginDeniedDomains map[string]bool
)
// SetPluginDeniedDomains records the plugin-denied top-level domains.
// nil clears.
func SetPluginDeniedDomains(domains map[string]bool) {
mu.Lock()
defer mu.Unlock()
if domains == nil {
pluginDeniedDomains = nil
return
}
cp := make(map[string]bool, len(domains))
for d, v := range domains {
cp[d] = v
}
pluginDeniedDomains = cp
}
// DomainDeniedByPlugin reports whether the whole top-level domain was
// denied by an integrator plugin. yaml denials never register here.
func DomainDeniedByPlugin(domain string) bool {
mu.RLock()
defer mu.RUnlock()
return pluginDeniedDomains[domain]
}
// ResetForTesting clears the recorded state.
func ResetForTesting() {
mu.Lock()
defer mu.Unlock()
pluginDeniedDomains = nil
}

View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillpolicy
import (
"io"
"io/fs"
"sort"
"strings"
"time"
)
// overlayFS layers an upper skill tree over a lower one at skill (top
// path segment) granularity: a skill present in upper is served wholly
// from upper, a skill named in removed is hidden, and everything else
// falls through to lower. It is the runtime form of a SkillsOverlay's
// Base -> Remove -> Overlay composition.
//
// It implements the io/fs fast-path interfaces (ReadDir/Stat/ReadFile)
// so the io/fs helpers route through the merge instead of hitting a
// single underlying tree.
type overlayFS struct {
lower fs.FS // base (or SkillsOverlay.Base); may be nil
upper fs.FS // SkillsOverlay.Overlay; may be nil
removed map[string]bool // skill names whited out from lower
upperNames map[string]bool // skill names owned by upper
}
var (
_ fs.FS = (*overlayFS)(nil)
_ fs.ReadDirFS = (*overlayFS)(nil)
_ fs.StatFS = (*overlayFS)(nil)
_ fs.ReadFileFS = (*overlayFS)(nil)
)
func newOverlayFS(lower, upper fs.FS, remove []string) *overlayFS {
o := &overlayFS{
lower: lower,
upper: upper,
removed: make(map[string]bool, len(remove)),
upperNames: map[string]bool{},
}
for _, r := range remove {
o.removed[r] = true
}
if upper != nil {
if entries, err := fs.ReadDir(upper, "."); err == nil {
for _, e := range entries {
if e.IsDir() {
o.upperNames[e.Name()] = true
}
}
}
}
return o
}
// route picks the tree owning name by its top path segment. whiteout is
// true when name belongs to a removed skill (present in neither tree).
func (o *overlayFS) route(name string) (target fs.FS, whiteout bool) {
top := name
if i := strings.IndexByte(name, '/'); i >= 0 {
top = name[:i]
}
switch {
case o.upperNames[top]:
return o.upper, false
case o.removed[top]:
return nil, true
default:
return o.lower, false
}
}
func (o *overlayFS) Open(name string) (fs.File, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
}
if name == "." {
entries, err := o.ReadDir(".")
if err != nil {
return nil, err
}
return &rootDir{entries: entries}, nil
}
target, whiteout := o.route(name)
if whiteout || target == nil {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
return target.Open(name)
}
func (o *overlayFS) Stat(name string) (fs.FileInfo, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid}
}
if name == "." {
return rootInfo{}, nil
}
target, whiteout := o.route(name)
if whiteout || target == nil {
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
}
return fs.Stat(target, name)
}
func (o *overlayFS) ReadFile(name string) ([]byte, error) {
if !fs.ValidPath(name) || name == "." {
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrInvalid}
}
target, whiteout := o.route(name)
if whiteout || target == nil {
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist}
}
return fs.ReadFile(target, name)
}
func (o *overlayFS) ReadDir(name string) ([]fs.DirEntry, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid}
}
if name != "." {
target, whiteout := o.route(name)
if whiteout || target == nil {
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
}
return fs.ReadDir(target, name)
}
return o.mergedRoot()
}
// mergedRoot lists the top-level skills: every upper skill, plus every
// lower skill that is neither removed nor shadowed by a same-named upper.
// Sorted so listings are deterministic.
func (o *overlayFS) mergedRoot() ([]fs.DirEntry, error) {
seen := map[string]bool{}
var out []fs.DirEntry
if o.upper != nil {
entries, err := fs.ReadDir(o.upper, ".")
if err != nil {
return nil, err
}
for _, e := range entries {
if !e.IsDir() {
continue
}
out = append(out, e)
seen[e.Name()] = true
}
}
if o.lower != nil {
entries, err := fs.ReadDir(o.lower, ".")
if err != nil {
return nil, err
}
for _, e := range entries {
if o.removed[e.Name()] || seen[e.Name()] {
continue
}
out = append(out, e)
seen[e.Name()] = true
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out, nil
}
// rootInfo is the synthetic FileInfo for the overlay's "." directory,
// which has no single backing entry in either tree.
type rootInfo struct{}
func (rootInfo) Name() string { return "." }
func (rootInfo) Size() int64 { return 0 }
func (rootInfo) Mode() fs.FileMode { return fs.ModeDir | 0o555 }
func (rootInfo) ModTime() time.Time { return time.Time{} }
func (rootInfo) IsDir() bool { return true }
func (rootInfo) Sys() any { return nil }
// rootDir is the synthetic directory file returned by Open(".").
type rootDir struct {
entries []fs.DirEntry
off int
}
func (d *rootDir) Stat() (fs.FileInfo, error) { return rootInfo{}, nil }
func (d *rootDir) Close() error { return nil }
func (d *rootDir) Read([]byte) (int, error) {
return 0, &fs.PathError{Op: "read", Path: ".", Err: fs.ErrInvalid}
}
func (d *rootDir) ReadDir(n int) ([]fs.DirEntry, error) {
if n <= 0 {
rest := d.entries[d.off:]
d.off = len(d.entries)
return rest, nil
}
if d.off >= len(d.entries) {
return nil, io.EOF
}
end := d.off + n
if end > len(d.entries) {
end = len(d.entries)
}
part := d.entries[d.off:end]
d.off = end
return part, nil
}

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skillpolicy composes the CLI's effective embedded skill tree
// from a base skill FS and at most one plugin-supplied SkillsOverlay. It is
// the skill-side analogue of internal/cmdpolicy: plugins contribute a
// delta over a base, and one resolver produces the single tree that both
// skill readers consume -- `skills list`/`read` and the --help
// domain-guide pointer.
package skillpolicy
import (
"errors"
"fmt"
"io/fs"
"strings"
"github.com/larksuite/cli/extension/platform"
)
// PluginSkill pairs a plugin name with the SkillsOverlay it contributed, so a
// conflict can be attributed to specific owners. Mirrors
// cmdpolicy.PluginRule.
type PluginSkill struct {
PluginName string
SkillsOverlay *platform.SkillsOverlay
}
// ErrMultipleSkillsOverlays reports that more than one plugin tried to
// customize skill content. Mirrors cmdpolicy.ErrMultipleRestricts: only
// one owner is allowed so independent plugins cannot silently overwrite
// each other's skill tree.
var ErrMultipleSkillsOverlays = errors.New("multiple plugins customized skills; only one plugin may own skill content")
// Resolve composes the effective skill tree from base and the supplied
// specs. base is the CLI's embedded skill FS (nil when the build embeds
// none). With no spec it returns base unchanged. With exactly one spec it
// applies, in fixed order, Base override -> Allow -> Remove -> Overlay,
// returning an overlay FS in which a same-named skill resolves to
// Overlay. Two or more distinct owners is a configuration error.
func Resolve(base fs.FS, specs []PluginSkill) (fs.FS, error) {
owners := distinctOwners(specs)
if len(owners) > 1 {
return nil, fmt.Errorf("%w: %v", ErrMultipleSkillsOverlays, owners)
}
if len(specs) == 0 || specs[0].SkillsOverlay == nil {
return base, nil
}
owner, spec := specs[0].PluginName, specs[0].SkillsOverlay
lower := base
if spec.Base != nil {
lower = spec.Base
}
if err := validate(lower, spec); err != nil {
return nil, fmt.Errorf("plugin %q skill spec: %w", owner, err)
}
if lower == nil && spec.Overlay == nil {
return nil, nil
}
return newOverlayFS(lower, spec.Overlay, effectiveRemove(lower, spec)), nil
}
// effectiveRemove folds a non-empty Allow into the remove set: every
// base skill not allow-listed is treated as removed, and explicit
// Remove entries win regardless (Remove-over-Allow, mirroring Rule's
// Deny-over-Allow). Overlay entries are never folded in — the overlay
// FS resolves them from the upper layer before the whiteout applies.
func effectiveRemove(lower fs.FS, spec *platform.SkillsOverlay) []string {
if len(spec.Allow) == 0 {
return spec.Remove
}
allowed := map[string]bool{}
for _, name := range spec.Allow {
allowed[name] = true
}
removed := append([]string(nil), spec.Remove...)
entries, err := fs.ReadDir(lower, ".")
if err != nil {
return removed // validate already vetted lower; nothing more to fold
}
for _, e := range entries {
if e.IsDir() && !allowed[e.Name()] {
removed = append(removed, e.Name())
}
}
return removed
}
// distinctOwners returns the unique contributing plugin names in
// first-seen order. Mirrors cmdpolicy.distinctOwners.
func distinctOwners(specs []PluginSkill) []string {
seen := map[string]bool{}
owners := make([]string, 0, len(specs))
for _, s := range specs {
if !seen[s.PluginName] {
seen[s.PluginName] = true
owners = append(owners, s.PluginName)
}
}
return owners
}
// validate rejects a spec that cannot compose cleanly, so the failure
// surfaces at startup with a named cause rather than as a silently
// missing skill at read time.
func validate(lower fs.FS, spec *platform.SkillsOverlay) error {
for _, name := range spec.Allow {
if !isSkillName(name) {
return fmt.Errorf("Allow: %q is not a valid skill name", name)
}
if !skillExists(lower, name) {
return fmt.Errorf("Allow: skill %q is not in the base tree", name)
}
}
for _, name := range spec.Remove {
if !isSkillName(name) {
return fmt.Errorf("Remove: %q is not a valid skill name", name)
}
if !skillExists(lower, name) {
return fmt.Errorf("Remove: skill %q is not in the base tree", name)
}
}
if spec.Overlay != nil {
entries, err := fs.ReadDir(spec.Overlay, ".")
if err != nil {
return fmt.Errorf("Overlay: cannot read root: %w", err)
}
for _, e := range entries {
if !e.IsDir() {
return fmt.Errorf("Overlay: %q is not a directory; every Overlay entry must be a <skill>/ dir", e.Name())
}
if !skillExists(spec.Overlay, e.Name()) {
return fmt.Errorf("Overlay: skill %q is missing SKILL.md", e.Name())
}
}
}
return nil
}
// skillExists reports whether fsys holds a skill named name -- a
// directory carrying SKILL.md, the shape internal/skillcontent treats as
// a skill.
func skillExists(fsys fs.FS, name string) bool {
if fsys == nil {
return false
}
info, err := fs.Stat(fsys, name+"/SKILL.md")
return err == nil && !info.IsDir()
}
// isSkillName rejects empty, dotted, or path-bearing names so Remove
// cannot smuggle a traversal or match outside the top level.
func isSkillName(name string) bool {
return name != "" && name != "." && name != ".." && !strings.ContainsAny(name, `/\`)
}

View File

@@ -0,0 +1,272 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillpolicy
import (
"errors"
"io/fs"
"sort"
"testing"
"testing/fstest"
"github.com/larksuite/cli/extension/platform"
)
// skillFS builds an in-memory skill tree; each map entry is a path -> content.
func skillFS(files map[string]string) fstest.MapFS {
m := fstest.MapFS{}
for p, content := range files {
m[p] = &fstest.MapFile{Data: []byte(content)}
}
return m
}
// baseTree is a representative base: three skills, one with a reference file.
func baseTree() fstest.MapFS {
return skillFS(map[string]string{
"lark-a/SKILL.md": "base a",
"lark-a/references/x.md": "base a ref",
"lark-b/SKILL.md": "base b",
"lark-shared/SKILL.md": "base shared",
})
}
// topLevel returns the sorted top-level skill names of fsys.
func topLevel(t *testing.T, fsys fs.FS) []string {
t.Helper()
entries, err := fs.ReadDir(fsys, ".")
if err != nil {
t.Fatalf("ReadDir(.): %v", err)
}
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
sort.Strings(names)
return names
}
func readFile(t *testing.T, fsys fs.FS, name string) string {
t.Helper()
data, err := fs.ReadFile(fsys, name)
if err != nil {
t.Fatalf("ReadFile(%q): %v", name, err)
}
return string(data)
}
func mustResolve(t *testing.T, base fs.FS, spec *platform.SkillsOverlay) fs.FS {
t.Helper()
got, err := Resolve(base, []PluginSkill{{PluginName: "acme", SkillsOverlay: spec}})
if err != nil {
t.Fatalf("Resolve: unexpected error: %v", err)
}
return got
}
func TestResolve_NoSpecs_ReturnsBaseUnchanged(t *testing.T) {
base := baseTree()
got, err := Resolve(base, nil)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if want := []string{"lark-a", "lark-b", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
}
}
func TestResolve_Remove_HidesSkill(t *testing.T) {
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Remove: []string{"lark-shared"}})
if want := []string{"lark-a", "lark-b"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
}
// The removed skill is gone for the affordance reader too (Stat gates it).
if _, err := fs.Stat(got, "lark-shared/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Stat removed skill: err = %v, want ErrNotExist", err)
}
// Surviving skills still read from base.
if c := readFile(t, got, "lark-a/SKILL.md"); c != "base a" {
t.Errorf("lark-a content = %q, want %q", c, "base a")
}
}
func TestResolve_Overlay_AddsNewSkill(t *testing.T) {
overlay := skillFS(map[string]string{"lark-new/SKILL.md": "new skill"})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
if want := []string{"lark-a", "lark-b", "lark-new", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
}
if c := readFile(t, got, "lark-new/SKILL.md"); c != "new skill" {
t.Errorf("lark-new content = %q, want %q", c, "new skill")
}
}
func TestResolve_Overlay_ReplacesSameNameWholeSkill(t *testing.T) {
overlay := skillFS(map[string]string{"lark-a/SKILL.md": "overlaid a"})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
// Same-named skill resolves to the overlay (upper wins).
if c := readFile(t, got, "lark-a/SKILL.md"); c != "overlaid a" {
t.Errorf("lark-a content = %q, want overlaid", c)
}
// Replacement is whole-skill: the base's reference file is shadowed, not merged.
if _, err := fs.Stat(got, "lark-a/references/x.md"); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("base reference should be shadowed by overlay skill; err = %v, want ErrNotExist", err)
}
}
func TestResolve_Base_ReplacesEntireTree(t *testing.T) {
replacement := skillFS(map[string]string{"lark-only/SKILL.md": "only"})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Base: replacement})
if want := []string{"lark-only"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
}
if _, err := fs.Stat(got, "lark-a/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("base skill should be gone after Base replace; err = %v", err)
}
}
func TestResolve_Base_WithRemoveAndOverlay(t *testing.T) {
replacement := skillFS(map[string]string{
"lark-p/SKILL.md": "p",
"lark-q/SKILL.md": "q",
})
overlay := skillFS(map[string]string{"lark-r/SKILL.md": "r"})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
Base: replacement,
Remove: []string{"lark-q"},
Overlay: overlay,
})
if want := []string{"lark-p", "lark-r"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
}
}
// Allow keeps only the listed base skills — the allow-list counterpart
// of Rule.Allow, so a CLI upgrade adding new embedded skills cannot
// widen an allow-listed build.
func TestResolve_Allow_KeepsOnlyListed(t *testing.T) {
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Allow: []string{"lark-a"}})
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
}
if _, err := fs.Stat(got, "lark-b/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("non-allow-listed skill must be absent; err = %v", err)
}
// Kept skills still read from base, references included.
if c := readFile(t, got, "lark-a/references/x.md"); c != "base a ref" {
t.Errorf("kept skill content = %q, want base content", c)
}
}
// Remove wins over Allow, mirroring Rule's Deny-over-Allow.
func TestResolve_RemoveWinsOverAllow(t *testing.T) {
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
Allow: []string{"lark-a", "lark-b"},
Remove: []string{"lark-b"},
})
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
}
}
// Overlay entries are exempt from Allow: content the integrator
// explicitly ships needs no allow-listing.
func TestResolve_OverlayExemptFromAllow(t *testing.T) {
overlay := skillFS(map[string]string{"acme-guide/SKILL.md": "mine"})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
Allow: []string{"lark-a"},
Overlay: overlay,
})
if want := []string{"acme-guide", "lark-a"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
}
}
// An Allow name absent from the base aborts startup, same as Remove.
func TestResolve_AllowUnknown_Errors(t *testing.T) {
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Allow: []string{"lark-nope"}}}})
if err == nil {
t.Fatal("expected error allow-listing a skill absent from base")
}
}
func TestResolve_RemoveUnknown_Errors(t *testing.T) {
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-nope"}}}})
if err == nil {
t.Fatal("expected error removing a skill absent from base")
}
}
func TestResolve_RemoveInvalidName_Errors(t *testing.T) {
for _, bad := range []string{"", ".", "..", "a/b", `a\b`} {
if _, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{bad}}}}); err == nil {
t.Errorf("Remove %q: expected error, got nil", bad)
}
}
}
func TestResolve_OverlayMissingSKILLmd_Errors(t *testing.T) {
overlay := skillFS(map[string]string{"lark-bad/other.md": "no skill.md here"})
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
if err == nil {
t.Fatal("expected error for overlay entry missing SKILL.md")
}
}
func TestResolve_OverlayNonDirEntry_Errors(t *testing.T) {
overlay := skillFS(map[string]string{"loose.md": "top-level file, not a skill dir"})
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
if err == nil {
t.Fatal("expected error for non-directory overlay entry")
}
}
func TestResolve_TwoOwners_Errors(t *testing.T) {
_, err := Resolve(baseTree(), []PluginSkill{
{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-a"}}},
{PluginName: "globex", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-b"}}},
})
if !errors.Is(err, ErrMultipleSkillsOverlays) {
t.Fatalf("err = %v, want ErrMultipleSkillsOverlays", err)
}
}
// TestResolve_ComposedConformsToFS runs the composed tree through the
// standard io/fs conformance checker to catch Open/ReadDir/Stat drift.
func TestResolve_ComposedConformsToFS(t *testing.T) {
overlay := skillFS(map[string]string{
"lark-new/SKILL.md": "new",
"lark-new/references/y.md": "new ref",
})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
Remove: []string{"lark-shared"},
Overlay: overlay,
})
if err := fstest.TestFS(got,
"lark-a/SKILL.md",
"lark-a/references/x.md",
"lark-b/SKILL.md",
"lark-new/SKILL.md",
"lark-new/references/y.md",
); err != nil {
t.Fatalf("composed FS failed fs conformance: %v", err)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -623,6 +623,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
decision.Class = "rate_limited"
decision.Terminal = true
case problem.Code == 1062507:
decision.Class = "parent_sibling_limit"
decision.Terminal = true
decision.Hint = "The destination parent folder has reached its child-count limit. Clean up that folder, choose another --folder-token, or split the upload across subfolders before retrying."
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
decision.Class = "file_size_limit"
case problem.Code == 1062009:

View File

@@ -1334,6 +1334,75 @@ func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
}
}
func TestDrivePushAbortsAfterCreateFolderParentSiblingLimit(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
t.Fatalf("MkdirAll a: %v", err)
}
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
t.Fatalf("MkdirAll b: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 1062507,
"msg": "parent node out of sibling num.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "parent_sibling_limit" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1062507) || item["subtype"] != "quota_exceeded" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "child-count limit") {
t.Fatalf("hint should explain the destination folder child-count limit, got item=%#v", item)
}
for _, item := range items {
if item["rel_path"] == "b" {
t.Fatalf("parent sibling limit must abort before b, got items=%#v", items)
}
}
}
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -1,7 +1,7 @@
---
name: lark-drive
version: 1.0.0
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
metadata:
requires:
bins: ["lark-cli"]
@@ -21,8 +21,10 @@ metadata:
## 快速决策
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token先用 `lark-cli drive +inspect` 获取底层 `token``type`,不要把 wiki token 直接当 `file_token``params.file_token` 传源文档 token`data.folder_token` 传目标文件夹 token`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
- 高风险写操作删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、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)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / 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) 了解。
- 用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时仍按资源类型、URL 路径和 token 路由到本 skill不要因为域名不是飞书而回退到 WebFetch。
@@ -162,7 +164,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
>
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`必须按 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md)模板通过 `--params` 传 `folder_token` / `page_token`并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`使用前先读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md),按模板通过 `--params` 传并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
### files
@@ -204,10 +206,12 @@ lark-cli drive <resource> <method> [flags] # 调用 API
### file.statistics
- `get` — 获取文件统计信息
- 获取 docx / 文件统计信息时,建议优先使用 typed flags`lark-cli drive file.statistics get --file-token <token> --file-type <type> --format json``--params` JSON 也支持,适合批量拼装或 raw 参数场景。
### file.view_records
- `list` — 获取文档的访问者记录
- 查看 docx 最近访问记录、返回 open_id、最多 N 条时,建议优先使用 typed flags`lark-cli drive file.view_records list --file-token <docx_token> --file-type docx --page-size <N> --viewer-id-type open_id --format json``--params` JSON 也支持,适合批量拼装、分页续跑或 raw 参数场景。
### file.comment.reply.reactions

View File

@@ -7,6 +7,18 @@
> [!CAUTION]
> 这是**高风险写操作**。CLI 层要求显式传 `--yes`;如果用户已经明确要求删除且目标明确,直接执行并带上 `--yes`。
> “目标明确”表示用户给出了可解析为 `file-token` + `type` 的具体 URL/token或对你刚列出的可解析资源列表逐项/整批确认删除。按“没用的”“临时的”“疑似重复的”“全部旧文件”等描述搜索出来的候选属于待确认目标;这类请求先列候选、说明筛选依据和影响范围,然后停止等待确认。
## 删除前门槛
执行 `drive +delete --yes` 前同时满足:
| 条件 | 可执行信号 |
|------|------------|
| 具体目标 | 单个可解析为 `file-token` + `type` 的 URL/token或用户确认过且可解析的资源列表 |
| 执行确认 | 用户在本轮明确说确认删除这些具体目标 |
若缺少任一条件,使用 `drive +search``drive +inspect` 或只读 API 收集候选并回复待确认清单启发式规则打开时间、标题模式、owner、文件类型等只能作为候选筛选依据不能升级为删除确认。执行 `drive +delete` 时必须使用解析后的 `--file-token``--type`
## 命令

View File

@@ -41,12 +41,37 @@ lark-cli drive files list \
也可以省略 `folder_token` 字段来请求根目录,但在 Agent 编排中建议显式传空字符串,避免把“忘记传参数”和“确认请求根目录”混在一起。
## 按时间排序
默认不要传 `order_by` / `direction`;服务端会按默认顺序返回。只有用户明确要求按创建时间或编辑时间排序时,才使用服务端排序参数。
按创建时间升序列出当前文件夹直接子项:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","order_by":"CreatedTime","direction":"ASC","page_size":200}' \
--format json
```
按编辑时间降序列出当前文件夹直接子项:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","order_by":"EditedTime","direction":"DESC","page_size":200}' \
--format json
```
以上示例返回排序后的当前页;如果返回 `has_more=true`,保持相同 `folder_token` / `order_by` / `direction` / `page_size`,把 `next_page_token` 放入 `page_token` 继续翻页。
## 参数规则
1. `folder_token` 必须放在 `--params` JSON 里;不要使用不存在的 `--folder-token` flag。
2. `page_token` 必须放在 `--params` JSON 里;不要依赖 shell 变量拼接不完整的 JSON。
3. `page_size` 建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因
4. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构
3. 默认不要传 `order_by` / `direction`;只有用户明确要求按创建时间 / 编辑时间排序时才使用服务端排序参数
4. 排序参数映射:创建时间 -> `order_by:"CreatedTime"`;编辑时间 / 修改时间 -> `order_by:"EditedTime"`;升序 -> `direction:"ASC"`;降序 -> `direction:"DESC"`。不要省略排序参数后再用 Python / shell 客户端排序替代
5. 排序查询建议带 `page_size:200` 减少翻页;只有用户要求完整分页、递归盘点、大目录全量导出,或当前页返回 `has_more=true` 后继续翻页时,才加入 `page_token`
6. `page_size` 在分页、递归盘点或全量导出时建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因。
7. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构。
## 返回结构与解析

View File

@@ -47,4 +47,6 @@ JSON 输出包含以下字段:
- `--url` 为必填参数
-`--url` 是 bare token非完整 URL`--type` 也是必填的
- wiki URL 会自动调用 `get_node` API 解包,输出中 `type``token` 是底层文档的类型和 token
- `+inspect` 只用于识别/消歧;如果任务已能通过 URL 路径形态完成路由判断,不必把它作为所有 Drive 操作的通用前置步骤
- `+inspect` 失败后不要自动切到写接口继续尝试先按错误提示处理权限、scope 或链接问题
- 支持 `--dry-run` 查看将调用的 API 步骤

View File

@@ -10,6 +10,18 @@
如果用户只是想向文档 owner 申请访问权限,优先使用 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)。
## 公开权限修改前门槛
公开权限修改是高风险写操作。执行 `drive permission.public patch --yes` 前同时确认:
| 条件 | 可执行信号 |
|------|------------|
| 具体目标 | 单个 URL/token或用户确认过的资源列表 |
| 公开范围 | 用户明确选择组织内/互联网、可读/可编辑等具体 `link_share_entity` 档位 |
| 执行确认 | 用户在本轮确认按该目标和范围执行 |
“开放一下”“共享给大家”“让大家能看”只表达目标状态不包含具体公开范围。先列出可选范围并停止等待用户选择公开档位必须来自用户选择CLI 的 `--yes` 只表示已获得用户对该档位的执行确认。
## 公开权限错误码
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope它们通常表示租户、对外分享或文档密级策略拦截。

View File

@@ -143,6 +143,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
| `permission_denied` | `1061004` / HTTP 403 | 当前身份无权操作目标资源 | 停止重试检查目标文件夹权限、身份类型user / bot和资源可见性 |
| `invalid_api_parameters` | `1061002` | API 参数被服务端拒绝 | 停止重试,检查 `--folder-token`、覆盖模式、`file_token`、文件名和上传参数;不要对同一参数组合批量重试 |
| `parent_node_missing` | `1061044` | 上传 / 建目录使用的父文件夹不存在或当前身份不可见 | 停止重试,检查 `--folder-token` 是否仍存在、是否有权限、父目录是否在 push 过程中被删除;不要继续上传同一目录树 |
| `parent_sibling_limit` | `1062507` | 目标父文件夹单层子节点数量超过上限 | 停止重试,清理目标目录、换一个 `--folder-token`,或把上传内容拆到多个子目录 |
| `rate_limited` | `99991400` | 触发频控 | 停止当前批次,退避后再重试 |
| `server_error` | `1061001` / `2200` | Drive 服务端异常 | 停止当前批次,稍后重试;保留 `log_id` 便于排查 |

View File

@@ -1,6 +1,12 @@
# 知识整理工作流
This file is the single entry point for the knowledge organization workflow. It defines the global contract, state machine, and progressive loading map. Stage-specific rules live in phase files and MUST be loaded only when the workflow reaches the corresponding state.
Workflow id: `knowledge_organize`
Risk / Structure: `R2-R3` / `S3`
This file implements the registered knowledge organization workflow. Before execution, the agent MUST read [`lark-drive-workflow.md`](lark-drive-workflow.md) and [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md), and follow the shared execution protocol, Artifact Contract, Workflow Loading rules, authentication rules, and write confirmation rules.
It defines the workflow-specific state machine and progressive loading map. Stage-specific rules live in phase files and MUST be loaded only when the workflow reaches the corresponding state.
Phase files are references for this workflow, not independent skills. Do not route user requests directly to a phase file.
@@ -80,7 +86,7 @@ When this workflow is triggered, the agent MUST:
## Runtime State
Agent MUST maintain these internal fields during one workflow run:
This workflow extends the shared Artifact Contract. Agent MUST maintain these internal fields during one workflow run:
| Field | Meaning |
|-------|---------|
@@ -114,24 +120,24 @@ Agent MUST maintain these internal fields during one workflow run:
## Execution State Machine
| State | Entry Condition | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|-------|-----------------|---------------|--------------------|---------------|------------|
| `PARSE_SCOPE` | Workflow triggered | Load discovery phase; parse target, environment, identity, and target type | Scope confirmation or clarification question | `true` | `INVENTORY` |
| `INVENTORY` | Scope confirmed | Load discovery phase; recursively list resources and build `resource_items` | Inventory progress / summary; continue automatically unless blocked | `false` unless blocked | `CONTENT_READ` |
| `CONTENT_READ` | Inventory complete | Load analysis phase; identify low-confidence items and perform mandatory partial read when needed | Low-confidence read summary | `false` unless auth / permission blocks | `ISSUE_ANALYSIS` |
| `ISSUE_ANALYSIS` | Resource list and partial reads ready | Load analysis phase; detect structure problems, evidence, and organization approach | Inventory result, problems, organization approach, and decision options | `true` | `RULE_GENERATION` |
| `RULE_GENERATION` | User confirms organization approach | Load analysis phase; generate classification rules and `target_tree` | No separate stop; target tree is shown with plan generation | `false` | `PLAN_GENERATION` |
| `PLAN_GENERATION` | Target tree ready | Load planning phase; generate complete internal `plan_items`; show target tree plus plan overview or page | Target tree and plan overview / paginated plan page | `true` | `EXEC_CONFIRM` |
| `EXEC_CONFIRM` | User wants execution | Load planning phase; ask user to choose execution scope | Execution options and write-operation summary | `true` | `EXECUTE` or `DONE` |
| `EXECUTE` | User explicitly confirmed execution scope | Load execution phase; execute only whitelisted write operations for confirmed scope while maintaining internal recovery state | Progress reports for large or long-running execution; if blocked after successful moves, ask whether to try restoring to `整理前的位置` | `false` unless blocked / recovery offered | `VERIFY`, `ROLLBACK_CONFIRM`, or `DONE` |
| `VERIFY` | Execution finished | Load execution phase; rescan target scope and compare actual path/token against plan | Verification table and final summary; if serious mismatches exist, ask whether to try restoring to `整理前的位置` | `false` unless recovery offered | `DONE` or `ROLLBACK_CONFIRM` |
| `ROLLBACK_CONFIRM` | User asks to restore after execution failure / verification mismatch / explicit rollback request | Load rollback phase; generate internal `rollback_plan`; ask whether to execute recovery | Recoverable scope and restore confirmation | `true` | `ROLLBACK` or `DONE` |
| `ROLLBACK` | User explicitly confirms restore execution | Load rollback phase; execute confirmed reverse moves only | Recovery progress / result | `false` | `ROLLBACK_VERIFY` |
| `ROLLBACK_VERIFY` | Recovery execution finished | Load rollback phase; verify restored locations and decide whether cleanup candidates exist | Recovery verification result | `false` | `ROLLBACK_CLEANUP_CONFIRM` or `DONE` |
| `ROLLBACK_CLEANUP_CONFIRM` | Cleanup candidates exist after recovery, or user asks to clean workflow-created empty folders / nodes | Load rollback phase; generate cleanup plan and ask for delete confirmation | Cleanup candidates and delete confirmation | `true` | `ROLLBACK_CLEANUP` or `DONE` |
| `ROLLBACK_CLEANUP` | User explicitly confirms cleanup deletion | Load rollback phase; delete only confirmed workflow-created safe-empty folders / nodes | Cleanup progress / result | `false` | `ROLLBACK_CLEANUP_VERIFY` |
| `ROLLBACK_CLEANUP_VERIFY` | Cleanup deletion finished | Load rollback phase; verify deleted cleanup targets | Cleanup verification result | `false` | `DONE` |
| `DONE` | No more action | Stop | Final answer | `false` | End |
| State | Protocol Step | Entry Condition | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|-------|---------------|-----------------|---------------|--------------------|---------------|------------|
| `PARSE_SCOPE` | `route` / `scope` | Workflow triggered | Load discovery phase; parse target, environment, identity, and target type | Scope confirmation or clarification question | `true` | `INVENTORY` |
| `INVENTORY` | `read` | Scope confirmed | Load discovery phase; recursively list resources and build `resource_items` | Inventory progress / summary; continue automatically unless blocked | `false` unless blocked | `CONTENT_READ` |
| `CONTENT_READ` | `read` | Inventory complete | Load analysis phase; identify low-confidence items and perform mandatory partial read when needed | Low-confidence read summary | `false` unless auth / permission blocks | `ISSUE_ANALYSIS` |
| `ISSUE_ANALYSIS` | `assess` / `plan` | Resource list and partial reads ready | Load analysis phase; detect structure problems, evidence, and organization approach | Inventory result, problems, organization approach, and decision options | `true` | `RULE_GENERATION` |
| `RULE_GENERATION` | `assess` / `plan` | User confirms organization approach | Load analysis phase; generate classification rules and `target_tree` | No separate stop; target tree is shown with plan generation | `false` | `PLAN_GENERATION` |
| `PLAN_GENERATION` | `assess` / `plan` | Target tree ready | Load planning phase; generate complete internal `plan_items`; show target tree plus plan overview or page | Target tree and plan overview / paginated plan page | `true` | `EXEC_CONFIRM` |
| `EXEC_CONFIRM` | `confirm` | User wants execution | Load planning phase; ask user to choose execution scope | Execution options and write-operation summary | `true` | `EXECUTE` or `DONE` |
| `EXECUTE` | `execute` | User explicitly confirmed execution scope | Load execution phase; execute only whitelisted write operations for confirmed scope while maintaining internal recovery state | Progress reports for large or long-running execution; if blocked after successful moves, ask whether to try restoring to `整理前的位置` | `false` unless blocked / recovery offered | `VERIFY`, `ROLLBACK_CONFIRM`, or `DONE` |
| `VERIFY` | `verify` | Execution finished | Load execution phase; rescan target scope and compare actual path/token against plan | Verification table and final summary; if serious mismatches exist, ask whether to try restoring to `整理前的位置` | `false` unless recovery offered | `DONE` or `ROLLBACK_CONFIRM` |
| `ROLLBACK_CONFIRM` | `recovery confirm` | User asks to restore after execution failure / verification mismatch / explicit rollback request | Load rollback phase; generate internal `rollback_plan`; ask whether to execute recovery | Recoverable scope and restore confirmation | `true` | `ROLLBACK` or `DONE` |
| `ROLLBACK` | `recovery execute` | User explicitly confirms restore execution | Load rollback phase; execute confirmed reverse moves only | Recovery progress / result | `false` | `ROLLBACK_VERIFY` |
| `ROLLBACK_VERIFY` | `recovery verify` | Recovery execution finished | Load rollback phase; verify restored locations and decide whether cleanup candidates exist | Recovery verification result | `false` | `ROLLBACK_CLEANUP_CONFIRM` or `DONE` |
| `ROLLBACK_CLEANUP_CONFIRM` | `cleanup confirm` | Cleanup candidates exist after recovery, or user asks to clean workflow-created empty folders / nodes | Load rollback phase; generate cleanup plan and ask for delete confirmation | Cleanup candidates and delete confirmation | `true` | `ROLLBACK_CLEANUP` or `DONE` |
| `ROLLBACK_CLEANUP` | `cleanup execute` | User explicitly confirms cleanup deletion | Load rollback phase; delete only confirmed workflow-created safe-empty folders / nodes | Cleanup progress / result | `false` | `ROLLBACK_CLEANUP_VERIFY` |
| `ROLLBACK_CLEANUP_VERIFY` | `cleanup verify` | Cleanup deletion finished | Load rollback phase; verify deleted cleanup targets | Cleanup verification result | `false` | `DONE` |
| `DONE` | `done` | No more action | Stop | Final answer | `false` | End |
## Progressive Load Map

View File

@@ -97,7 +97,7 @@ Structure Level
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
5. 已有样板:`permission_governance``R2/S2``knowledge_organize``R2-R3/S3`
## 加载与拆分边界
@@ -111,6 +111,7 @@ Structure Level
| 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 转移、密级标签调整 |
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
## Workflow Loading

View File

@@ -7,7 +7,7 @@
## Core Rules
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
- Every planned asset must include a fallback visual plan. The fallback can use native charts, tables, whiteboard diagrams, placeholder regions, or XML shapes, text, and arrows as appropriate.
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
@@ -43,7 +43,7 @@ For a page without a meaningful asset need, use:
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
- `icon`: small semantic symbol for a concept, step, role, or status.
- `logo`: brand, product, team, or customer mark.
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
- `chart`: column, bar, line, area, radar, pie, doughnut/ring, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
- `screenshot`: product UI, terminal output, workflow state, or page capture.
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
@@ -64,11 +64,23 @@ Match asset type to slide role:
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
For `asset_type: "chart"`:
- If the visual is a supported standard data chart — column, bar, line, area, radar, pie, doughnut/ring, or combo — `fallback_if_missing` must still render as a native `<chart>`.
- Do not imitate supported standard data visuals with manual drawing primitives or `<whiteboard>`.
- Choose the data source explicitly:
- `user_provided`: when the user provides concrete values, tables, CSV, or metric lists, use those values and do not replace them with mock data.
- `mock_placeholder`: when the user asks for a placeholder, template, example, or chart position to replace later, use mock data in a native `<chart>`.
- `mock_required_by_intent`: when the user does not provide concrete values but asks for data expression, charts, trends, comparisons, or distributions, use mock data in a native `<chart>`.
- Mock data must be labeled as `模拟数据,仅占位,待替换真实数据` or equivalent. Do not present mock values as facts.
- Manual drawing fallbacks are allowed only for unsupported chart types such as scatter, funnel, waterfall-like custom visuals, or decorative non-data visuals.
`fallback_if_missing` must be concrete enough to turn into XML, for example:
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
- "Render a mini bar chart with 4 bars using shapes and value labels."
- "Render a native `<chart>` using the user-provided series."
- "Render a native `<chart>` with mock placeholder values and label it as `模拟数据,仅占位,待替换真实数据`."
- "Use a bordered placeholder panel with product area labels, not an empty image."
Weak fallbacks to avoid:
@@ -118,7 +130,7 @@ Business comparison page:
When generating XML:
1. If an asset exists and the workflow supports it, place it in the planned visual region.
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
2. If no asset exists, immediately render `fallback_if_missing` with the planned XML-native element type. Supported standard data visuals still use native `<chart>`; other fallbacks may use shapes, text, lines, arrows, tables, whiteboard diagrams, or placeholder panels.
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.

View File

@@ -2,6 +2,8 @@
`<whiteboard>` 放在 `<data>` 内,内部可放 **SVG****Mermaid**,用于绘制流程图、时序图、架构图、散点图、漏斗图、自定义图标、装饰图案等 `<chart>``<shape>` 难以覆盖的视觉内容。
普通柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图和组合图应优先使用原生 `<chart>`。除非用户明确要求像素级自定义,或图表类型确实不受 `<chart>` 支持,否则不要用 `<whiteboard>` + SVG / Mermaid 重画这些标准图表。
> 前置条件:使用本文档前先阅读 [lark-slides SKILL.md](../SKILL.md)。
---
@@ -12,13 +14,13 @@
| 场景 | 推荐元素 |
|------|---------|
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
| 散点图、漏斗图(`<chart>` 不支持) | `<whiteboard>` SVG |
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/环/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
| 散点图、漏斗图(`<chart>` 不支持)或其他非原生数据视觉 | `<whiteboard>` SVG |
| 流程图、时序图、架构图、类图、ER 图等拓扑图 | `<whiteboard>` Mermaid 或 SVG |
| 自定义图标、徽标、示意性图形(需要 path/polygon 精确控制) | `<whiteboard>` SVG |
| 进度条、波浪背景、装饰图案、像素级自定义可视化 | `<whiteboard>` SVG |
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG 手绘——原生渲染更省力且质量更高
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG / Mermaid 手绘——原生渲染更省力、结构更稳定,也更容易被回读和后续编辑
---
@@ -39,9 +41,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
## SVG 还是 Mermaid
选择分步:**先看图表类型,看当前模型身份**。
选择分步:**先排除原生 `<chart>`,再判断 whiteboard 类型,最后看当前模型身份**。
### 第一步:图表类型优先判断
### 第一步:先确认是否应该使用 `<chart>`
如果内容是柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图或组合图,返回使用原生 `<chart>`,不要继续套用本文档的 SVG / Mermaid 路径。
### 第二步whiteboard 类型优先判断
以下类型**推荐 Mermaid**,自动布局、代码简洁;如需精确匹配品牌配色或自定义节点样式,可改用 SVG
@@ -50,20 +56,19 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
| 流程图、决策树、架构图 | `flowchart TD` / `flowchart LR` |
| 时序图 | `sequenceDiagram` |
| 类图 | `classDiagram` |
| 饼图 | `pie` |
| 甘特图 | `gantt` |
| 状态图 | `stateDiagram-v2` |
| 思维导图 | `mindmap` |
| ER 图 | `erDiagram` |
### 第步:数据图表与装饰元素按模型身份选路径
### 第步:非原生图表与装饰元素按模型身份选路径
上表以外的场景散点图、漏斗图、进度条、时间线、波浪背景、星点纹理等需要精确控制坐标和配色SVG 表达力更强,但各模型生成 SVG 的能力有差异:
| 模型身份 | 路径 |
|----------|------|
| Claude / Gemini / GPT / GLM | **SVG** — 精确控制坐标、颜色、透明度 |
| Doubao / Seed / Other | **Mermaid** — 用 `pie``gantt` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
| Doubao / Seed / Other | **Mermaid** — 用 `gantt``flowchart` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
> **先自报身份再选路径**:在决定使用 SVG 之前,确认当前模型属于哪一类。不要跳过这一步。
@@ -73,13 +78,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
### ⚠️ 设计品质要求
在 slide 里嵌入 `<whiteboard>` 的目的是**提升视觉质量**,不是把数字堆进去
在 slide 里嵌入 `<whiteboard>` 的目的是**表达原生 `<chart>` 或基础 `<shape>` 难以覆盖的视觉关系**,不是把标准数据图表手绘一遍
- **不要只用矩形加文字应付**:通篇纯白底色 + 方块 + 黑字等于白做,这是不及格输出
- **数据图表必须有坐标系**:坐标轴、网格线、数值标注缺一不可,不要只画柱子或
- **非原生数据视觉必须有坐标系**散点、漏斗等仍要有必要的坐标轴、刻度、数值标注或分段说明,不要只画点或色块
- **字号必须有层级**:标题 ≠ 标签 ≠ 数值,混用同一字号会消灭视觉焦点
- **配色要与 slide 主题呼应**:深色 slide 背景下图表用透明底或深色卡片;浅色背景下避免再加纯白底块
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、折线面积、点装饰等细节拉开与默认模板的差距
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、清晰分组、节点状态等细节拉开与默认模板的差距
- **写 SVG 前先判断背景亮度**:背景亮度 < 30% 时,装饰元素"对比不足"比"过强"危害更大,宁重勿轻;
- **装饰层次用亮度跳跃,不用线性叠透明度**`α=0.04→0.08→0.12` 的等差递增在深色底上几乎看不出差异(相邻层亮度差 ≈20正确做法是非线性跳跃如 `0.10→0.40→0.70→1.0`,相邻层亮度差 ≥60。
@@ -106,11 +111,11 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
| 元素 | 说明 | 典型用途 |
|------|------|---------|
| `<rect>` | 矩形,支持 `rx` 圆角 | 柱图、卡片、进度条 |
| `<rect>` | 矩形,支持 `rx` 圆角 | 卡片、进度条、分段色块 |
| `<circle>` | 圆 | 节点、装饰点、环形图 |
| `<ellipse>` | 椭圆 | 自定义轮廓图形 |
| `<line>` | 直线 | 坐标轴、分隔线 |
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、线、弧形 |
| `<line>` | 直线 | 轴线、分隔线、连接线 |
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、线、弧形 |
| `<text>` | 文本,支持中文 | 标签、数值 |
| `<polygon>` | 多边形 | 箭头、星形、面积填充 |
| `<g>` | 分组 | 批量变换、语义分组 |
@@ -123,27 +128,25 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
---
### 元素计算
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围不限于数据图表——装饰性点阵、等间距圆、重复图案同样适用
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围包括散点、漏斗、装饰性点阵、等间距圆、重复图案等;普通柱状图、折线图、饼图仍应回到原生 `<chart>`
> **主动去算**:写 SVG 之前先运行脚本,把输出当注释贴在 `<svg>` 开头,再照着填坐标。估值几乎每次都需要反复调整,跳过这步反而更慢。
**数据图表(柱状图范式**
**散点图 / 装饰性点阵范式**
```python
W, H = 360, 260
origin_x, origin_y = 50, 216 # 左下角SVG Y 轴向下
cw, ch = 290, 184
data, y_max = [120, 160, 90], 200
bar_w = int(cw / len(data) * 0.62)
for i, v in enumerate(data):
cx = round(origin_x + (i + 0.5) * cw / len(data))
y = round(origin_y - v / y_max * ch)
print(f"bar-{i}: x={cx - bar_w//2} y={y} w={bar_w} h={round(origin_y - y)}")
points = [(12, 40), (28, 80), (45, 65)]
x_min, x_max, y_min, y_max = 0, 50, 0, 100
for i, (xv, yv) in enumerate(points):
x = round(origin_x + (xv - x_min) / (x_max - x_min) * cw)
y = round(origin_y - (yv - y_min) / (y_max - y_min) * ch)
print(f"point-{i}: cx={x} cy={y}")
```
折线图:`x = origin_x + i/(n-1)*cw``y = origin_y - (v-y_min)/(y_max-y_min)*ch`
**装饰性元素(等间距范式)**
```python
@@ -160,9 +163,9 @@ for i in range(n):
```python
# 每个元素登记 (x, y, w, h),含 stroke 外扩
elements = [
(10, 20, 80, 160), # bar-0
(107, 10, 80, 170), # bar-1
(204, 40, 80, 140), # bar-2
(10, 20, 80, 160), # item-0
(107, 10, 80, 170), # item-1
(204, 40, 80, 140), # item-2
(0, 0, 300, 1), # x-axis
]
@@ -261,7 +264,6 @@ print(f"whiteboard width={wb_w} height={wb_h}")
| 流程图 | `flowchart TD` / `flowchart LR` | 业务流程、决策树、工作流 |
| 时序图 | `sequenceDiagram` | 系统交互、API 调用链 |
| 甘特图 | `gantt` | 项目计划、里程碑 |
| 饼图 | `pie` | 占比数据 |
| 类图 | `classDiagram` | 对象关系、架构设计 |
| ER 图 | `erDiagram` | 数据库结构 |
| 状态图 | `stateDiagram-v2` | 状态机、生命周期 |
@@ -279,7 +281,6 @@ Mermaid 图表会自动撑满 whiteboard 区域。建议:
|---------|-----------|------------|
| 流程图5-8 节点) | 720-816 | 300-400 |
| 时序图3-5 参与者) | 720-816 | 320-420 |
| 饼图 | 500-600 | 300-360 |
| 甘特图 | 816 | 280-360 |
| 思维导图 | 816 | 380-480 |
@@ -307,7 +308,7 @@ Mermaid 语法包含 `[`、`>`、`-->`,不用 CDATA 直接写会破坏 XML 解
- [ ] 文字 `y` 坐标为 baseline 位置,最小值 ≥ font-size避免被裁切
**SVG 模式——视觉品质检查:**
- [ ] 坐标轴、网格线、数值标注齐全,没有"裸柱子"或"裸折线"
- [ ] 非原生数据视觉有必要的坐标轴、网格线、数值标注或分段说明,没有"裸点"或无解释色块
- [ ] 字号有层级:标题 > 数值 > 轴标签,非全部相同
- [ ] 单一数据系列用同一颜色,多系列用不同颜色且对比充足
- [ ] 轴标签与图表元素互不遮挡,留有足够空间

View File

@@ -119,6 +119,34 @@ Each slide must include:
- `text_density`: `low`, `medium`, or `high`.
- `speaker_intent`: why the speaker needs this page and how it advances the story.
Optional slide fields:
- `chart_contract`: required when the page plan includes a standard data chart that `<chart>` supports. Use this shape:
```json
{
"chart_contract": {
"required": true,
"render_as": "native_chart",
"chart_type": "line",
"data_source": "mock_placeholder",
"data_series_required": true,
"placeholder_label_required": true,
"manual_shape_fallback_allowed": false
}
}
```
When `chart_contract.required == true`, XML generation must produce a `<chart>` element on that slide. A shape, line, polyline, or whiteboard approximation does not satisfy the plan.
`data_source` must be one of:
- `user_provided`: the user supplied concrete values, tables, CSV, or metric lists; use them and do not replace them with mock data.
- `mock_placeholder`: the user asked for a placeholder, template, example, or later-replaceable chart position; use mock data in native `<chart>`.
- `mock_required_by_intent`: the user did not provide concrete values but asked for data expression, charts, trends, comparisons, or distributions; use mock data in native `<chart>`.
`data_series_required` means the generated XML must include `<chartData>`. It does not require user-provided real-world values. When real values are unavailable but chart expression is part of the user's intent, write mock or placeholder values into native `<chart>` and label them clearly instead of switching to manual drawing primitives or metric blocks.
## Layout Vocabulary
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
@@ -183,6 +211,7 @@ Use an object for one planned asset, an array for multiple real needs, or `asset
- `purpose`: why this asset helps the page's key message.
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
- `chart_contract`: when `asset_type` is `chart` and the visual is a supported standard data chart, set this optional slide-level field so generation is locked to native `<chart>`.
For detailed rules and examples, read `asset-planning.md`.
@@ -190,7 +219,7 @@ Good examples:
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Render a native `<chart>` using the provided series when available; otherwise render a native `<chart>` with mock placeholder values and label it as 模拟数据,仅占位,待替换真实数据."}`
## XML Generation Contract
@@ -201,6 +230,7 @@ Before writing each slide XML, map the plan fields to concrete decisions:
- `visual_focus` determines the largest visual region or emphasized object.
- `text_density` caps visible text volume.
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
- `chart_contract` locks supported standard data charts to native `<chart>` output. Manual approximations are allowed only when the planned chart type is unsupported by `<chart>` or when the visual is explicitly non-data/decorative.
After creating the PPT, fetch the presentation and verify:

File diff suppressed because one or more lines are too long

View File

@@ -3018,7 +3018,7 @@
子元素(mermaid 与 svg 二选一):
- mermaid: Mermaid 源码文本, 可使用 CDATA 包裹
适用场景: 流程图、时序图、思维导图、类图、甘特图、饼图等结构化图表
适用场景: 流程图、时序图、思维导图、类图、甘特图、ER 图、用户旅程等结构图
特点: 用简短的文本声明描述图表逻辑, 由渲染引擎自动布局, 无需手动计算坐标
示例: &lt;mermaid&gt;&lt;![CDATA[flowchart TD\n A[开始] --&gt; B[结束]]]&gt;&lt;/mermaid&gt;
- svg: SVG 内容

View File

@@ -263,7 +263,56 @@
- `<chartLegend>`
- `<chartTooltip>`
如果要写图表 XML建议直接以 XSD 为准,不要自行发明更简化的 chart DSL
完整图表类型覆盖示例见 [slides_chart_demo.xml](slides_chart_demo.xml),其中包含柱状、条形、折线、面积、饼 / 环、雷达等原生 `<chart>` 示例,以及散点、气泡、漏斗、帕累托、瀑布等 `<whiteboard>` SVG 图表示例
组合图示例(来自 [slides_chart_demo.xml](slides_chart_demo.xml)
```xml
<chart width="556" height="350" topLeftX="42" topLeftY="132">
<chartPlotArea>
<chartPlot type="combo">
<chartExtra/>
<chartSeriesList>
<chartSeries index="1" comboType="column"/>
<chartSeries index="2" comboType="line" yAxisPosition="right">
<chartTooltip format="0%"/>
</chartSeries>
</chartSeriesList>
</chartPlot>
<chartAxes>
<chartAxis type="x">
<chartLabel fontSize="10"/>
</chartAxis>
<chartAxis type="y" position="left">
<chartGridLine color="rgb(226, 232, 240)"/>
<chartLabel fontSize="10"/>
</chartAxis>
<chartAxis type="y" position="right">
<chartLabel fontSize="10" format="0%"/>
</chartAxis>
</chartAxes>
</chartPlotArea>
<chartLegend position="bottom" fontSize="11"/>
<chartData>
<dim1>
<chartField name="季度">24Q1,24Q2,24Q3,24Q4,25Q1,25Q2,25Q3,25Q4</chartField>
</dim1>
<dim2>
<chartField name="营收">180,195,210,245,220,238,258,296</chartField>
<chartField name="增速">0.08,0.12,0.15,0.18,0.22,0.22,0.23,0.21</chartField>
</dim2>
</chartData>
<chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">营收(亿美元, 左轴) · 同比增速(%, 右轴)</chartTitle>
<chartStyle>
<chartBackground color="rgba(0, 0, 0, 0)"/>
<chartBorder color="rgb(222, 224, 227)" width="0"/>
<chartColorTheme>
<color value="rgb(28, 71, 120)"/>
<color value="rgb(240, 129, 54)"/>
</chartColorTheme>
</chartStyle>
</chart>
```
## 样式元素

View File

@@ -147,7 +147,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
### whiteboard
```xml
<!-- SVG 模式:数据图表、装饰元素 -->
<!-- SVG 模式:<chart> 不支持的图表或自定义视觉、装饰元素 -->
<whiteboard topLeftX="580" topLeftY="120" width="340" height="280">
<svg xmlns="http://www.w3.org/2000/svg">
<rect x="60" y="80" width="40" height="140" rx="3" fill="rgba(59,130,246,0.85)"/>

View File

@@ -24,7 +24,7 @@ metadata:
## 快速决策
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow-knowledge-organize.md`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md)该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
- URL`.../wiki/<token>``lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`