Compare commits

..

1 Commits

Author SHA1 Message Date
wangweiming
35d130cd04 feat: add drive list comments shortcut 2026-07-10 11:56:20 +08:00
66 changed files with 1160 additions and 3475 deletions

View File

@@ -21,16 +21,12 @@ 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"
)
@@ -46,7 +42,6 @@ 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.
@@ -64,17 +59,6 @@ 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
@@ -87,21 +71,6 @@ 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
@@ -185,14 +154,6 @@ 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
@@ -214,16 +175,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
// 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
})
installTipsHelpFunc(rootCmd)
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
@@ -271,13 +223,6 @@ 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
}
@@ -288,52 +233,23 @@ 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.
denied, policyErr := applyUserPolicyPruning(rootCmd, pluginRules)
if policyErr != nil {
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
if len(pluginRules) > 0 {
installPluginConflictGuard(rootCmd, policyErr)
installPluginConflictGuard(rootCmd, err)
return f, rootCmd, nil
}
warnPolicyError(cfg.streams.ErrOut, policyErr)
warnPolicyError(cfg.streams.ErrOut, err)
}
// 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,7 +20,6 @@ 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{}
@@ -565,54 +564,3 @@ 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,9 +85,6 @@ 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

@@ -1,93 +0,0 @@
// 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,7 +13,6 @@ 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"
)
@@ -56,12 +55,7 @@ func configShowRun(opts *ConfigShowOptions) error {
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
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
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
}
users := "(no logged-in users)"
if len(app.Users) > 0 {

View File

@@ -1,130 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"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"}
fl.Value = &gatedFlagValue{name: flagName, inner: fl.Value}
gated = true
}
if gated {
installFlagGateRejection(root)
}
}
func isPolicyGatedFlag(fl *pflag.Flag) bool {
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
}
// gatedFlagValue rejects at parse time, before cobra's help/version fast
// paths (which never reach PersistentPreRunE). Its Set error carries
// cobra's own unknown-flag wording so the root FlagErrorFunc renders the
// same envelope an unregistered flag produces.
type gatedFlagValue struct {
name string
inner pflag.Value
}
func (g *gatedFlagValue) String() string { return g.inner.String() }
func (g *gatedFlagValue) Type() string { return g.inner.Type() }
func (g *gatedFlagValue) Set(string) error {
// Intermediate parse error, not a final envelope: pflag wraps it and
// the root FlagErrorFunc (flagDidYouMean) converts it to the typed
// unknown-flag validation error.
return errors.New("unknown flag: --" + g.name) //nolint:forbidigo // intermediate parse error; flagDidYouMean emits the typed envelope
}
// 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,7 +17,6 @@ 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"
)
@@ -36,15 +35,7 @@ const userPolicyFileName = "policy.yml"
//
// pluginRules carries Plugin.Restrict() contributions collected from
// the InstallAll phase; nil/empty is fine.
//
// 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)
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
// 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
@@ -74,7 +65,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 nil, lerr
return lerr
}
yamlRules = loaded
}
@@ -86,11 +77,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
})
if err != nil {
cmdpolicy.SetActive(nil)
return nil, err
return err
}
if len(rules) == 0 {
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
return nil, nil
return nil
}
// RuleName attributes a denial to a specific rule in the envelope.
@@ -103,39 +94,17 @@ 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, deniedMessage)
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
cmdpolicy.Apply(rootCmd, denied)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: rules,
Source: source,
DeniedPaths: len(denied),
DeniedByPath: denied,
Rules: rules,
Source: source,
DeniedPaths: len(denied),
})
// 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
return nil
}
// installPluginsAndHooks runs the InstallAll phase on the globally-
@@ -187,22 +156,7 @@ func recordInventory(installResult *internalplatform.InstallResult) {
AllowUnannotated: r.Rule.AllowUnannotated,
})
}
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))
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
}
// 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,7 +12,6 @@ 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
@@ -111,27 +110,6 @@ 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("skill customization is broken (reason_code %s); fix the conflicting SkillsOverlay (a plugin's EmbeddedSkills or the WithEmbeddedSkills build option)", 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 /

View File

@@ -1,199 +0,0 @@
// 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"
"github.com/larksuite/cli/internal/policystate"
)
// 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)
}
// Group convergence runs on both paths: with the exemptions retired
// (or merely concealed), nothing keeps their domain group alive, and
// it must present as absent like any fully-denied domain.
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
}
}
// A rule can declare DeniedMessage yet deny no command of its own;
// the synthesized diagnostics denial still speaks in its voice.
if message == "" {
if ap := cmdpolicy.GetActive(); ap != nil && ap.Source.Kind == cmdpolicy.SourcePlugin {
for _, r := range ap.Rules {
if r.DeniedMessage != "" {
message = r.DeniedMessage
break
}
}
}
}
diag := map[string]cmdpolicy.Denial{}
for _, path := range cmdpolicy.DiagnosticPaths() {
// The whole exemption chain retires: the leaf and every group
// between it and the top-level domain answer unavailable, not
// bare help at exit 0.
for p := path; strings.Contains(p, "/"); p = p[:strings.LastIndex(p, "/")] {
diag[p] = cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: source,
ReasonCode: "diagnostics_hidden",
Reason: "policy self-inspection hidden by the integrator",
DeniedMessage: message,
}
}
}
cmdpolicy.Apply(rootCmd, diag)
cmdpolicy.AppendActiveDenials(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
}
}
groupDenial := cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: sample.PolicySource,
RuleName: sample.RuleName,
ReasonCode: "all_children_denied",
Reason: "all child commands are denied",
DeniedMessage: sample.DeniedMessage,
}
cmdpolicy.Apply(rootCmd, map[string]cmdpolicy.Denial{
cmdpolicy.CanonicalPath(group): groupDenial,
})
// The bootstrap snapshot could not see this whole-domain denial
// (the exemptions were still alive then); record it now so
// render-time hints and policy introspection stop pointing into
// the converged domain.
policystate.AddPluginDeniedDomain(cmdpolicy.CanonicalPath(group))
cmdpolicy.AppendActiveDenials(map[string]cmdpolicy.Denial{
cmdpolicy.CanonicalPath(group): groupDenial,
})
}
}
// 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
}

View File

@@ -1,653 +0,0 @@
// 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))
}
// The gate rejects at the Value level: cobra's parse (help/version
// fast paths included) can never set a gated flag.
err := root.PersistentFlags().Set("profile", "prod")
if err == nil || !strings.Contains(err.Error(), "unknown flag: --profile") {
t.Errorf("setting a gated flag must fail as unknown flag at parse time, 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")
}
}
// HideDiagnostics retires the exemptions — and with them gone, nothing keeps
// the config group alive: it must converge exactly like any fully-denied
// domain (absent from help, bare invocation and explicit help both answer
// command_unavailable). Guards the group level, which the leaf-only test
// below does not.
func TestBuildInternal_hideDiagnosticsConvergesDomainGroup(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
return b.HideDiagnostics()
})
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
cfg := findByPath(root, "config")
if cfg == nil {
t.Fatal("config group not in tree")
}
if cfg.IsAvailableCommand() {
t.Error("config group must leave help/completion when HideDiagnostics retires its last live descendants")
}
if cfg.RunE == nil {
t.Fatal("converged config group must carry an unavailable stub RunE")
}
err := cfg.RunE(cfg, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("bare `config` must answer command_unavailable, got %v", err)
}
var buf bytes.Buffer
cfg.SetOut(&buf)
cfg.SetErr(&buf)
root.HelpFunc()(cfg, nil)
out := buf.String()
if !strings.Contains(out, "command_unavailable") {
t.Errorf("`config --help` must answer command_unavailable, got:\n%.200s", out)
}
if strings.Contains(out, "Usage:") {
t.Errorf("`config --help` must not render the original usage, got:\n%.200s", out)
}
// The converged domain must also reach the render-time snapshot, so
// fixed hints (e.g. the strict-mode stub's `config strict-mode`
// pointer) stop pointing at a domain this build presents as absent.
if !policystate.DomainDeniedByPlugin("config") {
t.Error("converged config domain must be recorded for render-time hint gating")
}
}
// 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)
}
}
// A plugin Wrapper that swallows errors (returns nil without calling next)
// must not defeat the help meta command's unavailable interception: `help
// <restricted>` is framework presentation, not business dispatch, so it stays
// outside the Wrap chain and keeps answering command_unavailable.
func TestBuildInternal_helpCommandImmuneToWrapperSwallow(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
return b.Wrap("swallow", platform.All(), func(platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error { return 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 answer command_unavailable even under a swallowing Wrapper, got %v", err)
}
}
// A gated --profile must be rejected at parse time, so cobra's help/version
// fast paths (which never reach PersistentPreRunE) cannot slip past the gate.
func TestBuildInternal_gatedProfileRejectedOnHelpFastPath(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
for _, args := range [][]string{
{"--profile", "prod", "--help"},
{"--profile", "prod", "--version"},
{"--profile", "prod", "skills", "--help"},
} {
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs(args)
err := root.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) || !strings.Contains(ve.Message, `unknown flag "--profile"`) {
t.Errorf("%v: gated --profile must fail as unknown flag on the fast path, got %v", args, err)
}
}
// Un-gated flags parse normally (no over-rejection).
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"--help"})
if err := root.Execute(); err != nil {
t.Errorf("bare --help must still work, got %v", err)
}
}
// With HideDiagnostics, every group on the exemption chain answers
// unavailable — bare `config policy` / `config plugins` included, not only
// the show leaves.
func TestBuildInternal_retireDiagnosticsCoversIntermediates(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
return b.HideDiagnostics()
})
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
for _, path := range []string{"config/policy", "config/plugins", "config/policy/show", "config/plugins/show"} {
c := findByPath(root, path)
if c == nil || c.RunE == nil {
t.Fatalf("%s missing or without RunE", path)
}
err := c.RunE(c, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("%s must answer command_unavailable, got %v", path, err)
}
}
}
// Rule.DeniedMessage reaches the synthesized diagnostics denial even when
// the rule itself denies no command.
func TestBuildInternal_deniedMessageAppliesToRetiredDiagnostics(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{"zz-not-a-cmd/**"}, DeniedMessage: "not part of acme cli"}).
HideDiagnostics().
MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
c := findByPath(root, "config/policy/show")
err := c.RunE(c, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected validation error, got %T %v", err, err)
}
if ve.Message != "not part of acme cli" {
t.Errorf("retired diagnostic message = %q, want the rule's DeniedMessage", ve.Message)
}
}
// The custom help command keeps stock cobra fidelity: subcommand completion
// works (denied commands, being hidden, never appear), and its own help
// carries no Risk line the stock command lacks.
func TestBuildInternal_helpCompletionMatchesStock(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
h := findByPath(root, "help")
if h.ValidArgsFunction == nil {
t.Fatal("help must offer subcommand completion like stock cobra")
}
comps, _ := h.ValidArgsFunction(h, nil, "")
joined := strings.Join(comps, "\n")
if !strings.Contains(joined, "im\t") {
t.Errorf("help completion must offer live commands, got:\n%s", joined)
}
if strings.Contains(joined, "config\t") {
t.Errorf("help completion must not offer a denied domain, got:\n%s", joined)
}
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"help", "help"})
if err := root.Execute(); err != nil {
t.Fatalf("help help: %v", err)
}
if strings.Contains(buf.String(), "Risk:") {
t.Errorf("help's own help must not carry a Risk line, got:\n%s", buf.String())
}
}
// A plugin-denied command's local flags leave shell completion: the command
// presents as absent, so its flag surface must not be enumerable.
func TestBuildInternal_deniedCommandFlagsLeaveCompletion(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"skills/read"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"__complete", "skills", "read", "--"})
_ = root.Execute()
if strings.Contains(buf.String(), "--json") {
t.Errorf("denied command's local flags must not complete, got:\n%s", buf.String())
}
}
// Presentation-time denials (retired diagnostics, converged groups) reach the
// recorded ActivePolicy, so `config policy show` reflects the shipped tree.
func TestBuildInternal_activePolicyIncludesRetiredDiagnostics(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
return b.HideDiagnostics()
})
buildInternal(context.Background(), buildInvocationForTest(t))
ap := cmdpolicy.GetActive()
if ap == nil {
t.Fatal("no active policy recorded")
}
for _, path := range []string{"config", "config/policy", "config/policy/show", "config/plugins", "config/plugins/show"} {
if _, ok := ap.DeniedByPath[path]; !ok {
t.Errorf("ActivePolicy.DeniedByPath missing %q (presentation-time denial not recorded)", path)
}
}
}
// A bare gated --profile (no value) presents as unknown, same as a set one:
// pflag's "needs an argument" path must not leak a different shape.
func TestBuildInternal_gatedProfileBareFlagPresentsUnknown(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"--profile"})
err := root.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) || !strings.Contains(ve.Message, `unknown flag "--profile"`) {
t.Errorf("bare gated --profile must present as unknown flag, got %v", err)
}
}
// A plugin-denied command offers no positional completion either.
func TestBuildInternal_deniedCommandPositionalsLeaveCompletion(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"skills/read"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"__complete", "skills", "read", ""})
_ = root.Execute()
if strings.Contains(buf.String(), "lark-") {
t.Errorf("denied command must not complete positional args, got:\n%s", buf.String())
}
}

View File

@@ -13,7 +13,6 @@ 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.
@@ -106,14 +105,8 @@ 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("%s", hint).
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
WithCause(cd)
},
}

View File

@@ -14,7 +14,6 @@ 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"
)
@@ -380,41 +379,3 @@ 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,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"sort"
"strings"
@@ -22,7 +21,6 @@ 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"
@@ -82,16 +80,11 @@ 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}}` + skillsSetupFooter + `
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}}
`
// 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
@@ -174,10 +167,6 @@ 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,
@@ -611,15 +600,6 @@ func isLarkDomain(c *cobra.Command) bool {
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
// A policy-gated flag invoked bare ("flag needs an argument")
// never reaches its rejecting Value; it still presents as
// unregistered, exactly like a set one.
if gated, ok := gatedFlagFromNeedsArg(c, ferr); ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+gated, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + gated, Reason: "unknown flag"}).
WithHint("run `%s --help` to see valid flags", c.CommandPath())
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
@@ -642,25 +622,6 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
WithHint("%s", hint)
}
// gatedFlagFromNeedsArg reports whether ferr is pflag's "flag needs an
// argument: --name" for a policy-gated flag on this command's flag set.
func gatedFlagFromNeedsArg(c *cobra.Command, ferr error) (string, bool) {
const p = "flag needs an argument: --"
msg := ferr.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
name := msg[i+len(p):]
if j := strings.IndexAny(name, " \t"); j >= 0 {
name = name[:j]
}
if fl := c.Root().PersistentFlags().Lookup(name); isPolicyGatedFlag(fl) {
return name, true
}
return "", false
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
@@ -697,103 +658,16 @@ 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.\n" +
"Simply type " + root.DisplayName() + " help [path to command] for full details.",
ValidArgsFunction: func(c *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Mirrors cobra's stock help completion: available (non-hidden)
// subcommands of the resolved path — a denied command is
// Hidden and therefore never offered.
cmd, _, e := root.Find(args)
if e != nil || cmd == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var comps []string
for _, sub := range cmd.Commands() {
if !sub.IsAvailableCommand() && sub.Name() != "help" {
continue
}
if strings.HasPrefix(sub.Name(), toComplete) {
comps = append(comps, sub.Name()+"\t"+sub.Short)
}
}
return comps, cobra.ShellCompDirectiveNoFileComp
},
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). No risk annotation: it would render a "Risk:"
// line that stock cobra help output does not carry.
cmdutil.DisableAuthCheck(helpCmd)
if helpCmd.Annotations == nil {
helpCmd.Annotations = map[string]string{}
}
helpCmd.Annotations[cmdpolicy.AnnotationFrameworkMeta] = "true"
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`.
//
// 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) {
func installTipsHelpFunc(root *cobra.Command) {
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 {
// 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) {
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
f.Hidden = false
defer func() { f.Hidden = true }()
}
@@ -801,15 +675,15 @@ func installTipsHelpFunc(root *cobra.Command, skillContent func() fs.FS) {
// 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, skillContent()) {
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd, skillContent()) {
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareShortcutHelp(cmd, skillContent()) {
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}

View File

@@ -5,7 +5,6 @@ package cmd
import (
"bytes"
"io/fs"
"strings"
"testing"
@@ -13,10 +12,6 @@ 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()
@@ -29,7 +24,7 @@ func rendersHelp(t *testing.T, cmd *cobra.Command) string {
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root, nilSkills)
installTipsHelpFunc(root)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")
@@ -43,7 +38,7 @@ func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root, nilSkills)
installTipsHelpFunc(root)
child := &cobra.Command{Use: "list", Short: "list items"}
root.AddCommand(child)
@@ -56,7 +51,7 @@ func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root, nilSkills)
installTipsHelpFunc(root)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")

View File

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

@@ -1,227 +0,0 @@
// 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,7 +14,6 @@ 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,7 +60,6 @@ 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
@@ -80,7 +79,7 @@ sequenceDiagram
Host->>SDK: InstallAll()
SDK->>Plugin: Capabilities()
SDK->>Plugin: Install(Registrar)
Plugin->>SDK: Observe / Wrap / Restrict / EmbeddedSkills / On(Startup,Shutdown)
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
SDK->>Plugin: On(Startup) fire
Note over Host,Plugin: Each command dispatch
@@ -114,35 +113,6 @@ 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.
@@ -183,8 +153,6 @@ 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 |
@@ -219,8 +187,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`, `EmbeddedSkills`,
`HideDiagnostics`, `FailOpen`/`FailClosed`, `MustBuild`).
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `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,9 +36,8 @@ type Builder struct {
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
skillsOverlay *SkillsOverlay
actions []func(Registrar)
rules []*Rule
hookNames map[string]bool
errs []error
@@ -82,15 +81,6 @@ 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") {
@@ -155,35 +145,6 @@ 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.
//
@@ -194,20 +155,15 @@ 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,
skillsOverlay: b.skillsOverlay,
name: b.name,
version: b.version,
caps: b.caps,
actions: b.actions,
rules: b.rules,
}, nil
}
@@ -246,12 +202,11 @@ 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
skillsOverlay *SkillsOverlay
name string
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
}
func (p *builtPlugin) Name() string { return p.name }
@@ -261,15 +216,6 @@ func (p *builtPlugin) Install(r Registrar) error {
for _, rule := range p.rules {
r.Restrict(rule)
}
if p.skillsOverlay != nil {
sr, ok := r.(EmbeddedSkillsRegistrar)
if !ok {
// Fail closed: a declared skill customization must never be
// silently dropped by a host that cannot honour it.
return errors.New("host registrar does not support EmbeddedSkills")
}
sr.EmbeddedSkills(p.skillsOverlay)
}
for _, action := range p.actions {
action(r)
}

View File

@@ -14,13 +14,11 @@ 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
skillsOverlay *platform.SkillsOverlay
skillCalls int
observers int
wrappers int
lifecycles int
rule *platform.Rule // last rule (existing single-rule assertions)
rules []*platform.Rule // every rule, in Restrict order
}
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
@@ -32,10 +30,6 @@ 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
@@ -217,78 +211,3 @@ 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,9 +47,4 @@ 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

@@ -36,22 +36,3 @@ type Registrar interface {
// plugins both calling Restrict abort startup.
Restrict(r *Rule)
}
// EmbeddedSkillsRegistrar is the optional extension a host registrar
// implements to accept embedded-skill customization. It is deliberately NOT
// part of Registrar: every exported symbol in this package is a stability
// contract, and widening Registrar would break existing third-party
// implementations (fakes, decorators, custom hosts). A Builder-built plugin
// type-asserts for this interface at Install time and fails closed when the
// host lacks it -- a declared customization is never silently dropped.
//
// Skill content has a single owner: a second customizing plugin, or a
// SkillsOverlay that cannot compose, aborts startup unconditionally. 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.
type EmbeddedSkillsRegistrar interface {
// EmbeddedSkills contributes a SkillsOverlay customizing the CLI's
// embedded skill content (see SkillsOverlay).
EmbeddedSkills(spec *SkillsOverlay)
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
import (
"context"
"testing"
)
// legacyRegistrarFake is a downstream Registrar implementation written
// BEFORE EmbeddedSkills existed. It must keep compiling: doc.go declares
// every exported symbol a stability contract, so Registrar can never widen.
// If this file goes red, a method was added to Registrar -- move it to an
// optional extension interface instead (see EmbeddedSkillsRegistrar).
type legacyRegistrarFake struct{}
func (legacyRegistrarFake) Observe(When, string, Selector, Observer) {}
func (legacyRegistrarFake) Wrap(string, Selector, Wrapper) {}
func (legacyRegistrarFake) On(LifecycleEvent, string, LifecycleHandler) {}
func (legacyRegistrarFake) Restrict(*Rule) {}
var _ Registrar = legacyRegistrarFake{}
// skillsRegistrarFake opts into the optional extension.
type skillsRegistrarFake struct {
legacyRegistrarFake
got *SkillsOverlay
}
func (f *skillsRegistrarFake) EmbeddedSkills(spec *SkillsOverlay) { f.got = spec }
var _ EmbeddedSkillsRegistrar = (*skillsRegistrarFake)(nil)
// Drive the legacy surface through the interface so the fake stays a
// faithful stand-in for downstream usage (and none of it reads as dead code).
func TestLegacyRegistrarFake_ImplementsContractSurface(t *testing.T) {
var r Registrar = legacyRegistrarFake{}
r.Observe(Before, "x.obs", All(), func(context.Context, Invocation) {})
r.Wrap("x.wrap", All(), func(next Handler) Handler { return next })
r.On(Startup, "x.boot", func(context.Context, *LifecycleContext) error { return nil })
r.Restrict(&Rule{Deny: []string{"config/**"}})
}
// A plugin that declared EmbeddedSkills fails closed against a host whose
// registrar lacks the optional extension, and succeeds against one that has it.
func TestBuiltPlugin_embeddedSkillsRequiresOptionalInterface(t *testing.T) {
p := NewPlugin("acme", "1.0").
EmbeddedSkills(&SkillsOverlay{Remove: []string{"lark-a"}}).
MustBuild()
if err := p.Install(legacyRegistrarFake{}); err == nil {
t.Error("Install must fail closed when the host cannot honour EmbeddedSkills")
}
host := &skillsRegistrarFake{}
if err := p.Install(host); err != nil {
t.Fatalf("Install: %v", err)
}
if host.got == nil || len(host.got.Remove) != 1 || host.got.Remove[0] != "lark-a" {
t.Errorf("SkillsOverlay must reach the opted-in host, got %+v", host.got)
}
}

View File

@@ -6,9 +6,8 @@ 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 SOURCE of rules -- the
// resolver decides which wins (Plugin > yaml > none); the winning source
// may contribute several scoped rules. This package only defines the
// At any moment there is at most one effective Rule -- the resolver decides
// which source wins (Plugin > yaml > none). 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
@@ -58,11 +57,4 @@ 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

@@ -1,49 +0,0 @@
// 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,7 +9,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
)
const (
@@ -42,15 +41,11 @@ 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 {
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
return 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

@@ -1,28 +0,0 @@
// 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,7 +23,6 @@ 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"
)
@@ -72,14 +71,10 @@ 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 {
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
return 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

@@ -1,31 +0,0 @@
// 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,9 +23,6 @@ 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 (
@@ -50,27 +47,6 @@ func SetActive(p *ActivePolicy) {
activePolicy = cloneActivePolicy(p)
}
// AppendActiveDenials merges presentation-time denials (retired or
// converged diagnostics) into the recorded snapshot, so `config policy show`
// and other introspection reflect the tree that actually shipped rather
// than only the bootstrap-time aggregate.
func AppendActiveDenials(extra map[string]Denial) {
activeMu.Lock()
defer activeMu.Unlock()
if activePolicy == nil || len(extra) == 0 {
return
}
if activePolicy.DeniedByPath == nil {
activePolicy.DeniedByPath = map[string]Denial{}
}
for path, d := range extra {
if _, dup := activePolicy.DeniedByPath[path]; !dup {
activePolicy.DeniedPaths++
}
activePolicy.DeniedByPath[path] = d
}
}
// GetActive returns a deep copy of the recorded policy, or nil if
// bootstrap has not finished or no rule applied. Callers can freely
// mutate the result — including the embedded Rule slices — without
@@ -105,12 +81,6 @@ 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; plugin-source
// denials use subtype command_unavailable + exit code 2)
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition
// subtype + exit code 2)
// 2. in-process consumers extracting the platform.CommandDeniedError as
// the typed error's Cause via errors.As
//
// Plugin-source denials keep the policy metadata OFF the wire (no hint,
// no source / rule vocabulary); it stays reachable on the Cause only.
// The policy metadata (layer / policy_source / rule_name / reason_code)
// is folded into the Hint text rather than a separate detail map.
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
root := buildTree()
denied := map[string]cmdpolicy.Denial{
@@ -196,31 +196,29 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
t.Fatalf("denied command should return error")
}
// 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.
// 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).
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeCommandUnavailable)
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
}
if ve.Message != cmdpolicy.DefaultUnavailableMessage {
t.Errorf("message = %q, want default unavailable message", ve.Message)
// 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)
}
// 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, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, 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)
}
if !strings.Contains(ve.Hint, "secaudit-policy") {
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
}
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
@@ -303,7 +301,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)
@@ -334,7 +332,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,10 +4,7 @@
package cmdpolicy
import (
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
@@ -76,16 +73,6 @@ 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"
// AnnotationFrameworkMeta marks a framework meta command (help): its
// RunE is framework presentation, dispatched outside the plugin Wrap
// chain so a Wrapper cannot swallow or rewrite its result. Observers
// still see the invocation.
AnnotationFrameworkMeta = "lark:framework_meta"
// 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
@@ -137,37 +124,6 @@ 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)
@@ -202,15 +158,6 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
}
cmd.Hidden = true
cmd.DisableFlagParsing = true
// A plugin-denied command presents as absent: its local flags leave
// shell completion too. Hidden, not removed — yaml denials keep the
// full usage render, and the plugin help path never renders usage.
if IsPluginPolicySource(d.PolicySource) {
cmd.Flags().VisitAll(func(f *pflag.Flag) { f.Hidden = true })
cmd.ValidArgsFunction = func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
}
}
// Bypass cobra's pre-RunE gates that would otherwise short-circuit
// before the wrapped RunE (= where observers + denial guard live):
@@ -247,19 +194,11 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
cmd.Annotations[AnnotationDenialSource] = d.PolicySource
denial := d // capture by value for the closure
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)
}
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,10 +25,6 @@ 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,15 +27,3 @@ 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,9 +269,8 @@ 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. 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 {
// Denial values, so envelope output can attribute denials.
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string) map[string]Denial {
out := map[string]Denial{}
sourceLabel := policySourceLabel(source)
@@ -288,13 +287,6 @@ 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,17 +85,10 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// 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)
// 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)
}
}

View File

@@ -8,7 +8,6 @@ import (
"os"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/policystate"
)
// isMalformedConfigError reports whether a config load failure indicates a
@@ -82,22 +81,14 @@ const (
func NotConfiguredError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
e := errs.NewConfigError(errs.SubtypeNotConfigured, "not configured")
// With the config domain absent from this build the init/bind
// commands do not exist; no hint beats a dead-end hint.
if !policystate.DomainDeniedByPlugin("config") {
e = e.WithHint("%s", localInitHint)
}
return e
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured").
WithHint("%s", localInitHint)
}
// Agent workspace: the workspace name appears only in the message, never
// in the wire subtype, which stays not_configured.
e := errs.NewConfigError(errs.SubtypeNotConfigured,
"%s context detected but lark-cli is not bound to it", ws.Display())
if !policystate.DomainDeniedByPlugin("config") {
e = e.WithHint("%s", agentBindHint)
}
return e
return errs.NewConfigError(errs.SubtypeNotConfigured,
"%s context detected but lark-cli is not bound to it", ws.Display()).
WithHint("%s", agentBindHint)
}
// reconfigureHint returns the workspace-aware "fix it from scratch" hint

View File

@@ -1,38 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/policystate"
)
// The not-configured recovery hint points at `config init`/`config bind`;
// with the config domain absent from the build it must not render. The error
// itself (subtype, message) is unchanged.
func TestNotConfiguredError_hintFollowsConfigDomain(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
var ce *errs.ConfigError
if err := NotConfiguredError(); !errors.As(err, &ce) || ce.Hint == "" || !strings.Contains(ce.Hint, "config") {
t.Fatalf("default build must hint at a config command, got %+v", err)
}
policystate.SetPluginDeniedDomains(map[string]bool{"config": true})
if err := NotConfiguredError(); !errors.As(err, &ce) {
t.Fatalf("expected *errs.ConfigError, got %T", err)
} else {
if ce.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured (gate must not change the error)", ce.Subtype)
}
if ce.Hint != "" {
t.Errorf("config-denied build must omit the recovery hint, got %q", ce.Hint)
}
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/policystate"
)
// ClassifyContext is the contextual data BuildAPIError uses to populate
@@ -371,24 +370,13 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
}
return "the app developer must apply for the required scope(s) at the developer console"
case errs.SubtypeMissingScope:
// With the auth domain absent from this build there is no login
// command to point at; no hint beats a dead-end hint.
if policystate.DomainDeniedByPlugin("auth") {
return ""
}
if len(missing) > 0 {
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authorize the user with the updated scope set", strings.Join(missing, " "))
}
return "run `lark-cli auth login` to re-authorize the user with the updated scope set"
case errs.SubtypeTokenScopeInsufficient:
if policystate.DomainDeniedByPlugin("auth") {
return "check the token's granted scopes"
}
return "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued"
case errs.SubtypeUserUnauthorized:
if policystate.DomainDeniedByPlugin("auth") {
return "the operation may be blocked by external-chat or admin policy"
}
return "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy"
case errs.SubtypeAppUnavailable:
return "ask the tenant admin to check the app's install status in the Lark admin console"

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/policystate"
)
// Permission recovery hints point at `auth login`; with the auth domain
// absent from the build those pointers must not render. The tenant-admin and
// console-side hints are not auth-login recoveries and stay.
func TestPermissionHint_followsAuthDomain(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
for _, st := range []errs.Subtype{errs.SubtypeMissingScope, errs.SubtypeTokenScopeInsufficient, errs.SubtypeUserUnauthorized} {
if h := PermissionHint([]string{"im:message"}, "user", st, ""); !strings.Contains(h, "auth login") {
t.Errorf("%s: default build hint should mention auth login, got %q", st, h)
}
}
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
for _, st := range []errs.Subtype{errs.SubtypeMissingScope, errs.SubtypeTokenScopeInsufficient, errs.SubtypeUserUnauthorized} {
if h := PermissionHint([]string{"im:message"}, "user", st, ""); strings.Contains(h, "auth login") {
t.Errorf("%s: auth-denied build must not point at auth login, got %q", st, h)
}
}
// Non-auth-login recoveries are untouched.
if h := PermissionHint(nil, "bot", errs.SubtypeAppScopeNotApplied, "https://example.com"); !strings.Contains(h, "developer console") {
t.Errorf("console hint must survive the auth gate, got %q", h)
}
}

View File

@@ -107,11 +107,7 @@ func wrapRunE(cmd *cobra.Command, reg *Registry, snapshot CommandViewSource) {
// If denied, run the originalRunE directly (it is the denyStub
// installed by cmdpolicy.Apply). The Wrap chain is bypassed.
var err error
if inv.DeniedByPolicy() || isFrameworkMeta(c) {
// Framework meta commands (help) are presentation, not
// business dispatch: a plugin Wrapper must not swallow or
// rewrite their result (e.g. the command_unavailable answer
// for a restricted target). Observers above still ran.
if inv.DeniedByPolicy() {
err = invokeOriginal(ctx, c, args, originalRunE, originalRun)
} else {
// Compose matching Wrappers around the originalRunE. Each
@@ -338,13 +334,6 @@ var stderr = func() interface{ Write(p []byte) (int, error) } {
//
// This indirection lets us avoid an import cycle between hook and
// pruning packages.
// isFrameworkMeta reports whether the command is a framework meta command
// (stamped by cmd's installHelpCommand). Key duplicated by value, matching
// the denial keys below: hook sits beneath cmdpolicy in the import graph.
func isFrameworkMeta(c *cobra.Command) bool {
return c.Annotations["lark:framework_meta"] == "true"
}
func populateInvocationDenial(inv *invocation, c *cobra.Command) {
const layerKey = "lark:policy_denied_layer"
const sourceKey = "lark:policy_denied_source"

View File

@@ -412,46 +412,3 @@ func equalStrings(a, b []string) bool {
}
return true
}
// A framework meta command (help, stamped lark:framework_meta) dispatches
// outside the Wrap chain: a swallowing Wrapper must not intercept it, and its
// own RunE result must come through unchanged.
func TestInstall_frameworkMetaBypassesWrapChain(t *testing.T) {
root := &cobra.Command{Use: "root"}
metaRan := false
sentinel := errors.New("meta result")
leaf := &cobra.Command{
Use: "help",
RunE: func(*cobra.Command, []string) error {
metaRan = true
return sentinel
},
Annotations: map[string]string{"lark:framework_meta": "true"},
}
root.AddCommand(leaf)
reg := hook.NewRegistry()
wrapCalled := false
reg.AddWrapper(hook.WrapperEntry{
Name: "swallow", Selector: platform.All(),
Fn: func(next platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error {
wrapCalled = true
return nil
}
},
})
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "help"}})
err := leaf.RunE(leaf, nil)
if wrapCalled {
t.Error("Wrap chain must not run for a framework meta command")
}
if !metaRan {
t.Error("meta command's own RunE must run")
}
if !errors.Is(err, sentinel) {
t.Errorf("meta result must pass through unchanged, got %v", err)
}
}

View File

@@ -53,12 +53,4 @@ 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,7 +11,6 @@ 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,
@@ -30,10 +29,9 @@ 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
PluginSkills []skillpolicy.PluginSkill
Plugins []PluginInfo
Registry *hook.Registry
PluginRules []cmdpolicy.PluginRule
Plugins []PluginInfo
}
// InstallAll runs every registered plugin through the staging
@@ -144,16 +142,6 @@ 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")
@@ -219,12 +207,6 @@ 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,84 +431,3 @@ 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 {
sr, ok := r.(platform.EmbeddedSkillsRegistrar)
if !ok {
return errors.New("host registrar does not support EmbeddedSkills")
}
sr.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 {
sr := r.(platform.EmbeddedSkillsRegistrar)
sr.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
sr.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,10 +29,6 @@ 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
@@ -45,7 +41,6 @@ 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
@@ -55,7 +50,6 @@ func NewCapabilitiesView(c platform.Capabilities) CapabilitiesView {
Restricts: c.Restricts,
FailurePolicy: failurePolicyLabel(c.FailurePolicy),
RequiredCLIVersion: c.RequiredCLIVersion,
HideDiagnostics: c.HideDiagnostics,
}
}
@@ -80,22 +74,6 @@ 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
@@ -130,7 +108,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, skills []SkillsInventorySource) *Inventory {
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource) *Inventory {
byPlugin := make(map[string]*PluginEntry, len(plugins))
out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))}
for _, p := range plugins {
@@ -184,15 +162,6 @@ 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
}
@@ -294,12 +263,6 @@ 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, nil)
inv := internalplatform.BuildInventory(plugins, r, rules)
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, nil)
inv := internalplatform.BuildInventory(plugins, nil, rules)
a := findPlugin(inv, "a")
if a == nil {
t.Fatalf("missing entry a")
@@ -103,45 +103,12 @@ func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
}
func TestBuildInventory_empty(t *testing.T) {
inv := internalplatform.BuildInventory(nil, nil, nil, nil)
inv := internalplatform.BuildInventory(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,13 +41,6 @@ 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
@@ -151,25 +144,6 @@ 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

@@ -1,60 +0,0 @@
// 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
}
// AddPluginDeniedDomain records one more plugin-denied domain after the
// bootstrap snapshot: presentation-time convergence (a domain whose last
// live descendants were retired) discovers whole-domain denials the
// bootstrap aggregate cannot see.
func AddPluginDeniedDomain(domain string) {
mu.Lock()
defer mu.Unlock()
if pluginDeniedDomains == nil {
pluginDeniedDomains = map[string]bool{}
}
pluginDeniedDomains[domain] = true
}
// 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

@@ -1,224 +0,0 @@
// 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
allowed map[string]bool // nil = no allow-list; gates lower at access time
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, allow []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
}
// The allow-list gates every access, not a resolve-time snapshot: a
// skill added to the lower FS after composition stays outside the
// build unless allow-listed.
if len(allow) > 0 {
o.allowed = make(map[string]bool, len(allow))
for _, a := range allow {
o.allowed[a] = 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
case o.allowed != nil && !o.allowed[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
}
if o.allowed != nil && !o.allowed[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

@@ -1,158 +0,0 @@
// 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, spec.Remove, spec.Allow), nil
}
// 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 {
if spec.Base != nil {
if _, err := fs.ReadDir(spec.Base, "."); err != nil {
return fmt.Errorf("Base: cannot read root: %w", err)
}
}
for _, name := range spec.Allow {
if !isSkillName(name) {
return fmt.Errorf("Allow: %q is not a valid skill name", name)
}
ok, err := skillExists(lower, name)
if err != nil {
return fmt.Errorf("Allow: probing skill %q: %w", name, err)
}
if !ok {
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)
}
ok, err := skillExists(lower, name)
if err != nil {
return fmt.Errorf("Remove: probing skill %q: %w", name, err)
}
if !ok {
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 !isSkillName(e.Name()) {
return fmt.Errorf("Overlay: %q is not a valid skill name", e.Name())
}
ok, err := skillExists(spec.Overlay, e.Name())
if err != nil {
return fmt.Errorf("Overlay: probing skill %q: %w", e.Name(), err)
}
if !ok {
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, error) {
if fsys == nil {
return false, nil
}
info, err := fs.Stat(fsys, name+"/SKILL.md")
switch {
case err == nil:
return !info.IsDir(), nil
case errors.Is(err, fs.ErrNotExist) || errors.Is(err, fs.ErrInvalid):
return false, nil
default:
// A permission or I/O fault is a real cause, not absence.
return false, err
}
}
// 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

@@ -1,330 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillpolicy
import (
"errors"
"io/fs"
"slices"
"sort"
"strings"
"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)
}
gotNames := topLevel(t, got)
if want := []string{"lark-a", "lark-b", "lark-shared"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, want)
}
}
func TestResolve_Remove_HidesSkill(t *testing.T) {
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Remove: []string{"lark-shared"}})
gotNames := topLevel(t, got)
if want := []string{"lark-a", "lark-b"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, 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})
gotNames := topLevel(t, got)
if want := []string{"lark-a", "lark-b", "lark-new", "lark-shared"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, 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})
gotNames := topLevel(t, got)
if want := []string{"lark-only"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, 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,
})
gotNames := topLevel(t, got)
if want := []string{"lark-p", "lark-r"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, 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"}})
gotNames := topLevel(t, got)
if want := []string{"lark-a"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, 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"},
})
gotNames := topLevel(t, got)
if want := []string{"lark-a"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, 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,
})
gotNames := topLevel(t, got)
if want := []string{"acme-guide", "lark-a"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, 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)
}
}
// brokenFS fails every Open, standing in for an integrator Base that points at
// a missing or unreadable tree.
type brokenFS struct{}
func (brokenFS) Open(string) (fs.File, error) { return nil, fs.ErrNotExist }
// A custom Base whose root cannot be read must fail at Resolve (startup), not
// surface later as an empty `skills list`.
func TestResolve_unreadableBaseFailsFast(t *testing.T) {
_, err := Resolve(baseTree(), []PluginSkill{{
PluginName: "acme",
SkillsOverlay: &platform.SkillsOverlay{Base: brokenFS{}},
}})
if err == nil || !strings.Contains(err.Error(), "Base") {
t.Fatalf("unreadable Base must fail fast with a named error, got %v", err)
}
}
// Overlay top-level directories are skill names and must pass the same name
// rule as Allow/Remove; a name the reader can never serve (backslash and
// friends) fails composition instead of being listed but unreadable.
func TestResolve_overlayInvalidSkillNameFailsFast(t *testing.T) {
overlay := skillFS(map[string]string{`bad\name/SKILL.md`: "x"})
_, err := Resolve(baseTree(), []PluginSkill{{
PluginName: "acme",
SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay},
}})
if err == nil || !strings.Contains(err.Error(), "not a valid skill name") {
t.Fatalf("invalid overlay skill name must fail fast, got %v", err)
}
}
// The allow-list is an access-time gate, not a resolve-time snapshot: a
// skill added to the base FS after composition must stay invisible to both
// list and read unless allow-listed.
func TestResolve_allowGatesSkillsAddedAfterResolve(t *testing.T) {
base := baseTree()
got, err := Resolve(base, []PluginSkill{{
PluginName: "acme",
SkillsOverlay: &platform.SkillsOverlay{Allow: []string{"lark-a"}},
}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if want := []string{"lark-a"}; !slices.Equal(topLevel(t, got), want) {
t.Fatalf("top level = %v, want %v", topLevel(t, got), want)
}
// Mutate the base afterwards, as a CLI upgrade (or a misbehaving
// integrator FS) would.
base["lark-late/SKILL.md"] = &fstest.MapFile{Data: []byte("late")}
if names := topLevel(t, got); !slices.Equal(names, []string{"lark-a"}) {
t.Errorf("late-added skill leaked into the listing: %v", names)
}
if _, err := fs.ReadFile(got, "lark-late/SKILL.md"); err == nil {
t.Error("late-added skill must not be readable through the allow gate")
}
}

View File

@@ -0,0 +1,336 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveListCommentsDefaultPageSize = 50
driveListCommentsDefaultSolvedStatus = "false"
driveListCommentsDefaultScope = "all"
driveListCommentsDefaultUserIDType = "open_id"
)
var driveListCommentsTypes = []string{"doc", "docx", "sheet", "file", "slides", "bitable", "base", "wiki"}
type driveListCommentsRef struct {
Token string
Type string
SourceFlag string
}
type driveListCommentsTarget struct {
FileToken string
FileType string
}
type driveListCommentsSpec struct {
Ref driveListCommentsRef
PageSize int
PageToken string
SolvedStatus string
CommentScope string
NeedReaction bool
NeedRelation bool
UserIDType string
}
// DriveListComments lists document comments through the Drive comments API,
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
var DriveListComments = common.Shortcut{
Service: "drive",
Command: "+list-comments",
Description: "List comments for doc/docx/sheet/file/slides/base(bitable), with URL parsing and Wiki token unwrapping",
Risk: "read",
Scopes: []string{"docs:document.comment:read"},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/slides/base/bitable/wiki); Wiki URLs are unwrapped automatically"},
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveListCommentsTypes},
{Name: "solved-status", Default: driveListCommentsDefaultSolvedStatus, Desc: "comment solved filter: false=unresolved, true=solved, all=all comments", Enum: []string{"false", "true", "all"}},
{Name: "comment-scope", Default: driveListCommentsDefaultScope, Desc: "comment scope filter: all=all comments, whole=full-document comments, partial=local/selection comments", Enum: []string{"all", "whole", "partial"}},
{Name: "need-reaction", Type: "bool", Desc: "include reaction data on comment cards"},
{Name: "need-relation", Type: "bool", Desc: "include docx comment relation data; ignored for non-docx targets"},
{Name: "page-size", Type: "int", Default: "50", Desc: "page size, 1-100"},
{Name: "page-token", Desc: "pagination token from previous response"},
{Name: "user-id-type", Default: driveListCommentsDefaultUserIDType, Desc: "user ID type in the response", Enum: []string{"user_id", "union_id", "open_id"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return err
}
return validateDriveListCommentsSpec(spec)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if err := validateDriveListCommentsSpec(spec); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveListCommentsDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return err
}
if err := validateDriveListCommentsSpec(spec); err != nil {
return err
}
target, err := resolveDriveListCommentsTarget(ctx, runtime, spec.Ref)
if err != nil {
return err
}
params := buildDriveListCommentsParams(spec, target.FileType)
path := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments", validate.EncodePathSegment(target.FileToken))
data, err := runtime.CallAPITyped("GET", path, params, nil)
if err != nil {
return err
}
runtime.Out(buildDriveListCommentsOutput(target, data), nil)
return nil
},
}
func readDriveListCommentsSpec(runtime *common.RuntimeContext) (driveListCommentsSpec, error) {
ref, err := resolveDriveListCommentsInput(runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveListCommentsSpec{}, err
}
return driveListCommentsSpec{
Ref: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
SolvedStatus: strings.TrimSpace(runtime.Str("solved-status")),
CommentScope: strings.TrimSpace(runtime.Str("comment-scope")),
NeedReaction: runtime.Bool("need-reaction"),
NeedRelation: runtime.Bool("need-relation"),
UserIDType: strings.TrimSpace(runtime.Str("user-id-type")),
}, nil
}
func validateDriveListCommentsSpec(spec driveListCommentsSpec) error {
if spec.PageSize < 1 || spec.PageSize > 100 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 100").WithParam("--page-size")
}
if _, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --solved-status %q; allowed: false, true, all", spec.SolvedStatus).WithParam("--solved-status")
}
if _, ok := driveListCommentsScopeParam(spec.CommentScope); !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --comment-scope %q; allowed: all, whole, partial", spec.CommentScope).WithParam("--comment-scope")
}
if spec.UserIDType != "user_id" && spec.UserIDType != "union_id" && spec.UserIDType != "open_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --user-id-type %q; allowed: user_id, union_id, open_id", spec.UserIDType).WithParam("--user-id-type")
}
return nil
}
func resolveDriveListCommentsInput(urlInput, tokenInput, explicitType string) (driveListCommentsRef, error) {
urlInput = strings.TrimSpace(urlInput)
tokenInput = strings.TrimSpace(tokenInput)
if urlInput != "" && tokenInput != "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
}
if urlInput == "" && tokenInput == "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
}
raw := urlInput
sourceFlag := "--url"
if raw == "" {
raw = tokenInput
sourceFlag = "--token"
}
inputType := normalizeDriveListCommentsType(strings.ToLower(strings.TrimSpace(explicitType)))
if ref, ok := common.ParseResourceURL(raw); ok {
refType := normalizeDriveListCommentsType(ref.Type)
if inputType != "" && inputType != refType {
return driveListCommentsRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
refType,
).WithParam("--type")
}
if !driveListCommentsTypeSupported(refType) {
return driveListCommentsRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s resource type %q; comments list supports doc, docx, sheet, file, slides, bitable/base, and wiki",
sourceFlag,
refType,
).WithParam(sourceFlag)
}
return driveListCommentsRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
}
if strings.Contains(raw, "://") {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
}
if strings.ContainsAny(raw, "/?#") {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
}
if inputType == "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, slides, bitable, base, wiki)", sourceFlag).WithParam("--type")
}
if !driveListCommentsTypeSupported(inputType) {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, slides, bitable, base, wiki", inputType).WithParam("--type")
}
return driveListCommentsRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
}
func normalizeDriveListCommentsType(docType string) string {
switch strings.TrimSpace(docType) {
case "base":
return "bitable"
default:
return strings.TrimSpace(docType)
}
}
func driveListCommentsTypeSupported(docType string) bool {
switch normalizeDriveListCommentsType(docType) {
case "doc", "docx", "sheet", "file", "slides", "bitable", "wiki":
return true
default:
return false
}
}
func resolveDriveListCommentsTarget(ctx context.Context, runtime *common.RuntimeContext, ref driveListCommentsRef) (driveListCommentsTarget, error) {
if ref.Type != "wiki" {
return driveListCommentsTarget{FileToken: ref.Token, FileType: ref.Type}, nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(ref.Token))
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": ref.Token},
nil,
)
if err != nil {
return driveListCommentsTarget{}, err
}
node := common.GetMap(data, "node")
objType := normalizeDriveListCommentsType(common.GetString(node, "obj_type"))
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return driveListCommentsTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if !driveListCommentsTypeSupported(objType) || objType == "wiki" {
return driveListCommentsTarget{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki resolved to %q, but comments list only supports doc, docx, sheet, file, slides, and bitable",
objType,
).WithParam(ref.SourceFlag)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
return driveListCommentsTarget{FileToken: objToken, FileType: objType}, nil
}
func buildDriveListCommentsDryRun(spec driveListCommentsSpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
params := buildDriveListCommentsParams(spec, "<obj_type from step 1>")
if spec.NeedRelation {
params["need_relation"] = "<sent only when obj_type is docx>"
}
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> list comments").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
GET("/open-apis/drive/v1/files/<obj_token from step 1>/comments").
Desc("[2] List comments on resolved document").
Params(params)
}
return common.NewDryRunAPI().
Desc("1-step request: list comments").
GET("/open-apis/drive/v1/files/:file_token/comments").
Params(buildDriveListCommentsParams(spec, spec.Ref.Type)).
Set("file_token", spec.Ref.Token)
}
func buildDriveListCommentsParams(spec driveListCommentsSpec, fileType string) map[string]interface{} {
params := map[string]interface{}{
"file_type": fileType,
"page_size": spec.PageSize,
"user_id_type": spec.UserIDType,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
if value, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); ok && value != nil {
params["is_solved"] = *value
}
if value, ok := driveListCommentsScopeParam(spec.CommentScope); ok && value != nil {
params["is_whole"] = *value
}
if spec.NeedReaction {
params["need_reaction"] = true
}
if spec.NeedRelation && fileType == "docx" {
params["need_relation"] = true
}
return params
}
func driveListCommentsSolvedStatusParam(status string) (*bool, bool) {
switch strings.TrimSpace(status) {
case "false", "":
value := false
return &value, true
case "true":
value := true
return &value, true
case "all":
return nil, true
default:
return nil, false
}
}
func driveListCommentsScopeParam(scope string) (*bool, bool) {
switch strings.TrimSpace(scope) {
case "all", "":
return nil, true
case "whole":
value := true
return &value, true
case "partial":
value := false
return &value, true
default:
return nil, false
}
}
func buildDriveListCommentsOutput(target driveListCommentsTarget, data map[string]interface{}) map[string]interface{} {
items := common.GetSlice(data, "items")
return map[string]interface{}{
"file_token": target.FileToken,
"file_type": target.FileType,
"items": items,
"has_more": common.GetBool(data, "has_more"),
"page_token": common.GetString(data, "page_token"),
"count": len(items),
}
}

View File

@@ -0,0 +1,365 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestResolveDriveListCommentsInput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
urlInput string
rawInput string
docType string
wantResource string
wantType string
wantErr string
wantParam string
}{
{
name: "url docx",
urlInput: "https://example.larksuite.com/docx/docxResource?from=wiki",
wantResource: "docxResource",
wantType: "docx",
},
{
name: "token flag also accepts url",
rawInput: "https://example.larksuite.com/base/bitableResource",
wantResource: "bitableResource",
wantType: "bitable",
},
{
name: "bare wiki token",
rawInput: "wikiResource",
docType: "wiki",
wantResource: "wikiResource",
wantType: "wiki",
},
{
name: "url and token mutually exclusive",
urlInput: "https://example.larksuite.com/docx/docxResource",
rawInput: "docxResource",
wantErr: "mutually exclusive",
wantParam: "--url",
},
{
name: "bare token needs type",
rawInput: "docxResource",
wantErr: "--type is required",
wantParam: "--type",
},
{
name: "type conflicts with url",
urlInput: "https://example.larksuite.com/wiki/wikiResource",
docType: "docx",
wantErr: "conflicts",
wantParam: "--type",
},
{
name: "unsupported url type",
urlInput: "https://example.larksuite.com/drive/folder/folderResource",
wantErr: "unsupported",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveDriveListCommentsInput(tt.urlInput, tt.rawInput, tt.docType)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveListCommentsValidationError(t, err, tt.wantParam)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Token != tt.wantResource || got.Type != tt.wantType {
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantResource, tt.wantType)
}
})
}
}
func TestValidateDriveListCommentsSpec(t *testing.T) {
t.Parallel()
valid := driveListCommentsSpec{
PageSize: 50,
SolvedStatus: "false",
CommentScope: "all",
UserIDType: "open_id",
}
tests := []struct {
name string
mutate func(*driveListCommentsSpec)
wantParam string
}{
{
name: "invalid page size",
mutate: func(spec *driveListCommentsSpec) {
spec.PageSize = 0
},
wantParam: "--page-size",
},
{
name: "invalid solved status",
mutate: func(spec *driveListCommentsSpec) {
spec.SolvedStatus = "open"
},
wantParam: "--solved-status",
},
{
name: "invalid comment scope",
mutate: func(spec *driveListCommentsSpec) {
spec.CommentScope = "inline"
},
wantParam: "--comment-scope",
},
{
name: "invalid user id type",
mutate: func(spec *driveListCommentsSpec) {
spec.UserIDType = "email"
},
wantParam: "--user-id-type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
spec := valid
tt.mutate(&spec)
err := validateDriveListCommentsSpec(spec)
if err == nil {
t.Fatal("expected validation error, got nil")
}
assertDriveListCommentsValidationError(t, err, tt.wantParam)
})
}
}
func assertDriveListCommentsValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", validationErr.Category, errs.CategoryValidation)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
}
if cause := errors.Unwrap(err); cause != nil {
t.Fatalf("unexpected cause on direct validation error: %v", cause)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected errs.ProblemOf to recognize typed error: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("problem category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestBuildDriveListCommentsParams(t *testing.T) {
t.Parallel()
defaultSpec := driveListCommentsSpec{
PageSize: 50,
SolvedStatus: "false",
CommentScope: "all",
UserIDType: "open_id",
}
defaultParams := buildDriveListCommentsParams(defaultSpec, "docx")
if got := defaultParams["is_solved"]; got != false {
t.Fatalf("default is_solved = %#v, want false", got)
}
if _, ok := defaultParams["is_whole"]; ok {
t.Fatalf("default params should omit is_whole: %#v", defaultParams)
}
if got := defaultParams["user_id_type"]; got != "open_id" {
t.Fatalf("user_id_type = %#v, want open_id", got)
}
allPartialSpec := driveListCommentsSpec{
PageSize: 100,
PageToken: "next",
SolvedStatus: "all",
CommentScope: "partial",
NeedReaction: true,
NeedRelation: true,
UserIDType: "union_id",
}
allPartialParams := buildDriveListCommentsParams(allPartialSpec, "docx")
if _, ok := allPartialParams["is_solved"]; ok {
t.Fatalf("solved-status all should omit is_solved: %#v", allPartialParams)
}
if got := allPartialParams["is_whole"]; got != false {
t.Fatalf("comment-scope partial is_whole = %#v, want false", got)
}
if got := allPartialParams["need_reaction"]; got != true {
t.Fatalf("need_reaction = %#v, want true", got)
}
if got := allPartialParams["need_relation"]; got != true {
t.Fatalf("need_relation = %#v, want true for docx", got)
}
if got := allPartialParams["page_token"]; got != "next" {
t.Fatalf("page_token = %#v, want next", got)
}
sheetParams := buildDriveListCommentsParams(allPartialSpec, "sheet")
if _, ok := sheetParams["need_relation"]; ok {
t.Fatalf("need_relation should be ignored for non-docx: %#v", sheetParams)
}
}
func TestDriveListCommentsExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
if got := query.Get("is_solved"); got != "false" {
t.Errorf("is_solved = %q, want false", got)
}
if got := query.Get("is_whole"); got != "" {
t.Errorf("is_whole = %q, want omitted", got)
}
if got := query.Get("user_id_type"); got != "open_id" {
t.Errorf("user_id_type = %q, want open_id", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{
{"comment_id": "comment_1", "is_solved": false},
},
"has_more": true,
"page_token": "next",
},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxResource" {
t.Fatalf("file_token = %q, want docxResource", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
t.Fatalf("file_type = %q, want docx", got)
}
if got := data["count"]; got != float64(1) {
t.Fatalf("count = %#v, want 1", got)
}
}
func TestDriveListCommentsExecuteWikiResolvesToDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("token"); got != "wikiResource" {
t.Errorf("wiki token = %q, want wikiResource", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxFromWikiResource",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxFromWikiResource/comments",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("is_solved"); got != "" {
t.Errorf("is_solved = %q, want omitted for solved-status all", got)
}
if got := query.Get("is_whole"); got != "true" {
t.Errorf("is_whole = %q, want true", got)
}
if got := query.Get("need_relation"); got != "true" {
t.Errorf("need_relation = %q, want true for resolved docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--token", "wikiResource",
"--type", "wiki",
"--solved-status", "all",
"--comment-scope", "whole",
"--need-relation",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWikiResource" {
t.Fatalf("file_token = %q, want docxFromWikiResource", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
t.Fatalf("file_type = %q, want docx", got)
}
}

View File

@@ -15,6 +15,7 @@ func Shortcuts() []common.Shortcut {
DrivePreview,
DriveCover,
DriveAddComment,
DriveListComments,
DriveExport,
DriveExportDownload,
DriveImport,

View File

@@ -20,14 +20,15 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+download",
"+preview",
"+cover",
"+add-comment",
"+list-comments",
"+export",
"+export-download",
"+import",
"+version-history",
"+version-get",
"+version-revert",
"+version-delete",
"+add-comment",
"+export",
"+export-download",
"+import",
"+move",
"+delete",
"+status",

View File

@@ -26,7 +26,8 @@ metadata:
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.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) 了解
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `drive +list-comments --need-relation` 返回评论位置,其他类型会静默忽略该参数;具体用法需要先阅读 [`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。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
@@ -80,13 +81,14 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
| 列出文档评论 | `file_token` | 同添加评论 |
| 列出文档评论 | URL 或 `file_token` | 优先使用 `drive +list-comments --url '<url>'`wiki URL/token 会自动解析到底层真实 token/type |
### 评论能力入口
- 添加评论优先使用 [`+add-comment`](references/lark-drive-add-comment.md)review / 审阅 / 校对场景默认尽量创建局部评论,不要把多个可定位问题合并为一条全文评论。
- 获取评论列表优先使用 [`+list-comments`](references/lark-drive-list-comments.md):推荐传 `--url`,支持 wiki 自动解包;参数细节见 reference。
- 评论查询、统计、排序、回复限制,先读 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md);其他文档类型暂不支持返回定位字段
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`;其他文档类型会静默忽略该参数
- reaction / 表情相关操作先读 [`lark-drive-reactions.md`](references/lark-drive-reactions.md);只有用户明确需要 reaction 信息时才带 `need_reaction=true`
- `drive +add-comment``--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`CLI 会将其拆分后写入 `anchor.block_id``anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis``--full-comment`
@@ -139,6 +141,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 |
| [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides/base(bitable) 添加评论,也支持解析到这些类型的 wiki URL评论统计、回复和 reaction 细则见 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。 |
| [`+list-comments`](references/lark-drive-list-comments.md) | 获取 doc/docx/sheet/file/slides/base(bitable) 评论列表;优先传 URL支持 wiki 自动解包。 |
| [`+export`](references/lark-drive-export.md) | 将 doc/docx/sheet/bitable/slides 导出为本地文件。 |
| [`+export-download`](references/lark-drive-export-download.md) | 根据导出产物的 file_token 下载文件。 |
| [`+import`](references/lark-drive-import.md) | 将本地文件导入为飞书在线文档、表格、多维表格或幻灯片。 |

View File

@@ -1,15 +1,27 @@
# 文档评论定位字段
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,docx 文档的评论查询必须带 `need_relation=true`
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,优先使用 `drive +list-comments --need-relation` 查询 docx 评论位置
## 适用范围
- 当前只有 `file_type=docx` 支持通过 `need_relation=true` 查询评论的位置,并返回可用于定位正文 block 的 `relation``parent_type``parent_token` 等字段。
- 其他文件类型暂不支持通过 `need_relation` 查询评论位置。遇到 sheet、bitable、slides、普通文件等类型的评论时不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
- `drive +list-comments` 会在目标不是 docx 时静默忽略 `--need-relation`,避免把无效参数传给 OpenAPI。遇到 sheet、bitable、slides、普通文件等类型的评论时不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
## 调用方式
分页列出评论时,`need_relation` 放在 query params
分页列出评论时,优先传 URLWiki URL / Wiki token 会自动解析到底层真实 token/type
```bash
lark-cli drive +list-comments --url '<docx_or_wiki_url>' --need-relation
```
如果只有 Wiki token显式传 `--type wiki`
```bash
lark-cli drive +list-comments --token '<wiki_token>' --type wiki --need-relation
```
只有在需要未被 shortcut 暴露的底层参数时,才直接调用 raw OpenAPI。此时把 `need_relation` 放在 query params
```bash
lark-cli drive file.comments list \
@@ -126,7 +138,7 @@ lark-cli docs +fetch --doc '<doc_token_or_url>' --detail with-ids
## 定位流程
1. 确认目标是 `file_type=docx`;只有 docx 文档支持通过 `need_relation` 查询评论位置。
2.`drive file.comments list` `drive file.comments batch_query` 获取评论,并带 `need_relation=true`
2.`drive +list-comments --need-relation` 获取评论;已知评论 ID 且需要批量查询时,可用 `drive file.comments batch_query` 并带 `need_relation=true`raw `drive file.comments list` 仅作为低层参数兜底。
3.`docs +fetch --detail with-ids` 获取文档内容。
4. 对每条评论先看 `relation`
- 如果存在 `relation.relation`,解析这个 JSON 字符串。

View File

@@ -1,6 +1,6 @@
# Drive 评论查询、统计与回复指南
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md)reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md)获取评论列表优先使用 [`lark-drive-list-comments.md`](lark-drive-list-comments.md)reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
## 评论模式
@@ -16,14 +16,21 @@
## 查询默认口径
`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数
优先使用 `drive +list-comments`,不要优先手写 `drive file.comments list`。shortcut 默认 `--solved-status false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论仅当用户明确要求包含已解决评论时,才`--solved-status all`。只查已解决评论时传 `--solved-status true`
```bash
# 默认查询:仅未解决评论
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx","is_solved":false}'
lark-cli drive +list-comments --url '<DOC_URL>'
# 全部评论:包含已解决和未解决
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status all
# 已解决评论
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status true
# 裸 wiki token
lark-cli drive +list-comments --token '<WIKI_TOKEN>' --type wiki
# 包含已解决评论:仅当用户明确要求时使用
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx"}'
```
## 评论卡片与统计
@@ -53,12 +60,13 @@ lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"doc
## batch_query 与 list
- `drive file.comments batch_query` 用于已知评论 ID 后的批量查询,需要传入具体评论 ID 列表。
- `drive file.comments list` 用于分页获取评论列表,适合统计评论数、遍历所有评论、获取最新或最后 N 条评论等场景
- `drive +list-comments` 用于分页获取评论列表;如果要统计全量评论数、遍历包含已解决评论在内的所有评论、获取全量最新评论或最后 N 条评论,请先传 `--solved-status all` 并拉完所有分页。它会处理 URL、wiki token 和 token/type 匹配问题
- `drive file.comments list` 是原生命令。需要 shortcut 未暴露的字段时才使用。
## 评论定位字段
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。
- 其他文档类型暂不支持返回定位字段
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`
- `--need-relation` 仅 docx 生效;其他文档类型会静默忽略
## 原生 API

View File

@@ -0,0 +1,106 @@
# drive +list-comments
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。
列出 doc/docx/sheet/file/slides/base(bitable) 的评论卡片。优先传 URLshortcut 会自动识别类型;如果传 wiki URL 或 `--token <wiki_token> --type wiki`,会先解析到真实文档。原生 `drive file.comments list` 仍保留用于需要特殊参数的场景。
## 命令
```bash
# 推荐:直接传 URL。默认只查未解决评论。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>"
# 查询全部评论,包括已解决和未解决。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--solved-status all
# 查询已解决评论。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--solved-status true
# 只查全文评论或局部评论。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--comment-scope whole
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--comment-scope partial
# wiki URL 会自动解包。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>"
# 裸 wiki token 也支持,但必须显式声明 --type wiki。
lark-cli drive +list-comments \
--token "<WIKI_TOKEN>" \
--type wiki
# 裸真实 token 需要声明真实类型。
lark-cli drive +list-comments \
--token "<DOCX_TOKEN>" \
--type docx \
--page-size 100
# docx 需要评论定位关系时再带 need-relation非 docx 会静默忽略。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--need-relation
# 分页续跑。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--page-size 100 \
--page-token "<NEXT_PAGE_TOKEN>"
# 预览请求链路,不发真实请求。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/wiki URLwiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | `doc``docx``sheet``file``slides``bitable``base``wiki`。传 `base`CLI 会按 `bitable` 类型处理。 |
| `--solved-status` | 否 | `false` / `true` / `all`,默认 `false``false` 查未解决评论;`true` 查已解决评论;`all` 查全部评论。 |
| `--comment-scope` | 否 | `all` / `whole` / `partial`,默认 `all``all` 查全部范围;`whole` 查全文评论;`partial` 查局部评论。 |
| `--need-reaction` | 否 | 是否返回评论卡片上的 reaction 数据;只有用户明确需要 reaction 时才带。 |
| `--need-relation` | 否 | docx 评论定位关系字段;仅 docx 生效,非 docx 静默忽略。需要定位正文时先读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。 |
| `--page-size` | 否 | 默认 50最大 100。 |
| `--page-token` | 否 | 分页游标;本 shortcut 不自动翻页,按返回的 `page_token` 继续请求下一页。 |
| `--user-id-type` | 否 | `user_id` / `union_id` / `open_id`,默认 `open_id`。 |
## 行为说明
- 默认 `--solved-status false`,即默认只查未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论;仅当用户明确要求包含已解决评论时,才传 `--solved-status all`
- `--comment-scope all` 查全部范围;`whole` 查全文评论;`partial` 查局部/选区评论。
- URL 输入时不需要传 `--type`;如果 URL 类型和显式 `--type` 冲突shortcut 会返回 validation error建议移除 `--type`
- wiki 输入会自动解析到真实文档再查询评论列表。JSON 输出不额外返回 wiki token 或 wiki node。
- 输出中的 `items` 保留评论卡片字段,外层补充 `file_token``file_type``has_more``page_token``count``count` 是当前页返回的评论卡片数。
- 如果需要批量按评论 ID 查询、获取更多回复、创建/编辑/删除回复,继续使用原生 `drive file.comments batch_query``drive file.comment.replys.*`
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"items": [],
"has_more": false,
"page_token": "",
"count": 0
}
```
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-drive-comments-guide](lark-drive-comments-guide.md) -- 评论统计、回复限制和原生 API 说明
- [lark-drive-comment-location](lark-drive-comment-location.md) -- 使用 `need_relation` 定位 docx 正文

View File

@@ -1,9 +1,9 @@
# Drive CLI E2E Coverage
## Metrics
- Denominator: 31 leaf commands
- Covered: 10
- Coverage: 32.3%
- Denominator: 32 leaf commands
- Covered: 11
- Coverage: 34.4%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
@@ -12,7 +12,8 @@
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
- TestDriveListCommentsDryRun_DocxDefaults / TestDriveListCommentsDryRun_WikiToken: dry-run coverage for `drive +list-comments`; asserts URL parsing to `files/:token/comments`, default `is_solved=false`, default omitted `is_whole`, `user_id_type=open_id`, and Wiki token orchestration (`get_node -> comments.list`) without live API calls.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up.
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
@@ -26,6 +27,7 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_DocxDefaults; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_WikiToken; drive_add_comment_workflow_test.go::TestDriveAddCommentMarkdownFileWorkflow | `--url`; `--token + --type wiki`; `--solved-status=false\|all`; `--comment-scope=all\|partial`; `--need-relation`; `--page-size`; `--user-id-type` | dry-run locks URL/token parsing, default unresolved filter, omitted all-scope filter, and Wiki unwrap request shape; opt-in live workflow verifies a created file comment can be listed back |
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |

View File

@@ -81,4 +81,41 @@ func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
if got := gjson.Get(commentResult.Stdout, "data.file_extension").String(); got != ".md" {
t.Fatalf("data.file_extension=%q, want .md\nstdout:\n%s", got, commentResult.Stdout)
}
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+list-comments",
"--token", fileToken,
"--type", "file",
"--solved-status", "all",
"--page-size", "100",
},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || !driveCommentListContainsID(result.Stdout, commentID)
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
if got := gjson.Get(listResult.Stdout, "data.file_token").String(); got != fileToken {
t.Fatalf("list data.file_token=%q, want %q\nstdout:\n%s", got, fileToken, listResult.Stdout)
}
if got := gjson.Get(listResult.Stdout, "data.file_type").String(); got != "file" {
t.Fatalf("list data.file_type=%q, want file\nstdout:\n%s", got, listResult.Stdout)
}
if !driveCommentListContainsID(listResult.Stdout, commentID) {
t.Fatalf("list comments did not include comment_id %q\nstdout:\n%s", commentID, listResult.Stdout)
}
}
func driveCommentListContainsID(stdout, commentID string) bool {
for _, item := range gjson.Get(stdout, "data.items").Array() {
if item.Get("comment_id").String() == commentID {
return true
}
}
return false
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveListCommentsDryRun_DocxDefaults(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+list-comments",
"--url", "https://example.larksuite.com/docx/docxDryRunCommentList",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCommentList/comments" {
t.Fatalf("api.0.url=%q, want comments list\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.file_type").String(); got != "docx" {
t.Fatalf("api.0.params.file_type=%q, want docx\nstdout:\n%s", got, out)
}
isSolved := gjson.Get(out, "api.0.params.is_solved")
if !isSolved.Exists() || isSolved.Bool() {
t.Fatalf("api.0.params.is_solved=%v, want explicit false\nstdout:\n%s", isSolved.Value(), out)
}
if gjson.Get(out, "api.0.params.is_whole").Exists() {
t.Fatalf("api.0.params.is_whole should be omitted by default\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 50 {
t.Fatalf("api.0.params.page_size=%d, want 50\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.user_id_type").String(); got != "open_id" {
t.Fatalf("api.0.params.user_id_type=%q, want open_id\nstdout:\n%s", got, out)
}
}
func TestDriveListCommentsDryRun_WikiToken(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+list-comments",
"--token", "wikiDryRunCommentList",
"--type", "wiki",
"--solved-status", "all",
"--comment-scope", "partial",
"--need-relation",
"--page-size", "99",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunCommentList" {
t.Fatalf("api.0.params.token=%q, want wikiDryRunCommentList\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/<obj_token from step 1>/comments" {
t.Fatalf("api.1.url=%q, want resolved comments list placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.file_type").String(); got != "<obj_type from step 1>" {
t.Fatalf("api.1.params.file_type=%q, want obj_type placeholder\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.1.params.is_solved").Exists() {
t.Fatalf("api.1.params.is_solved should be omitted for solved-status all\nstdout:\n%s", out)
}
isWhole := gjson.Get(out, "api.1.params.is_whole")
if !isWhole.Exists() || isWhole.Bool() {
t.Fatalf("api.1.params.is_whole=%v, want explicit false for partial\nstdout:\n%s", isWhole.Value(), out)
}
if got := gjson.Get(out, "api.1.params.need_relation").String(); got != "<sent only when obj_type is docx>" {
t.Fatalf("api.1.params.need_relation=%q, want conditional placeholder\nstdout:\n%s", got, out)
}
}

View File

@@ -26,6 +26,14 @@ var driveDeleteVisibilityWait = clie2e.WaitOptions{
Interval: driveDeleteVisibilityPoll,
}
var driveDeleteRetry = clie2e.RetryOptions{
Attempts: 6,
InitialDelay: 2 * time.Second,
MaxDelay: 8 * time.Second,
BackoffMultiple: 2,
ShouldRetry: clie2e.ResultHasRetryableError,
}
// CreateDriveFolder creates a Drive folder, optionally under a parent folder, and
// deletes it during parent cleanup.
func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string, parentFolderToken string) string {
@@ -80,10 +88,10 @@ func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs
defaultAs = "bot"
}
deleteResult, deleteErr := clie2e.RunCmd(ctx, clie2e.Request{
deleteResult, deleteErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
DefaultAs: defaultAs,
})
}, driveDeleteRetry)
if deleteErr != nil || deleteResult == nil {
return deleteResult, deleteErr
}

View File

@@ -23,6 +23,20 @@ func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, na
}
func TestDeleteDriveResourceAndVerify(t *testing.T) {
t.Run("retries retryable delete contention", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
t.Setenv("FAKE_DRIVE_DELETE_RETRYABLE_ATTEMPTS", "2")
t.Setenv("FAKE_DRIVE_DELETE_STATE", filepath.Join(t.TempDir(), "delete-attempts"))
t.Setenv("FAKE_DRIVE_META_EMPTY", "1")
withFastDriveDeleteRetry(t)
result, err := DeleteDriveResourceAndVerify(context.Background(), "fld_retry", "folder", "bot")
require.NotNil(t, result)
require.NoError(t, err)
assert.Equal(t, 0, result.ExitCode)
})
t.Run("successful delete with stale meta returns cleanup warning", func(t *testing.T) {
fake := mustWriteDriveCleanupFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
@@ -51,11 +65,41 @@ func TestDeleteDriveResourceAndVerify(t *testing.T) {
})
}
func withFastDriveDeleteRetry(t *testing.T) {
t.Helper()
original := driveDeleteRetry
driveDeleteRetry = clie2e.RetryOptions{
Attempts: 3,
InitialDelay: time.Millisecond,
MaxDelay: time.Millisecond,
BackoffMultiple: 2,
ShouldRetry: clie2e.ResultHasRetryableError,
}
t.Cleanup(func() {
driveDeleteRetry = original
})
}
func mustWriteDriveCleanupFakeCLI(t *testing.T) string {
t.Helper()
script := `#!/bin/sh
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
if [ -n "$FAKE_DRIVE_DELETE_RETRYABLE_ATTEMPTS" ]; then
state="$FAKE_DRIVE_DELETE_STATE"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
next=$((count + 1))
echo "$next" > "$state"
if [ "$count" -lt "$FAKE_DRIVE_DELETE_RETRYABLE_ATTEMPTS" ]; then
echo "Deleting folder fake..." >&2
echo '{"ok":false,"error":{"type":"api","code":1061045,"message":"resource contention occurred, please retry.","retryable":true}}' >&2
exit 1
fi
fi
if [ "${FAKE_DRIVE_DELETE_EXIT:-0}" != "0" ]; then
echo '{"ok":false,"error":{"type":"api","message":"delete failed"}}' >&2
exit "$FAKE_DRIVE_DELETE_EXIT"
@@ -65,6 +109,10 @@ if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
fi
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
if [ "${FAKE_DRIVE_META_EMPTY:-0}" = "1" ]; then
echo '{"ok":true,"data":{"metas":[]}}'
exit 0
fi
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
exit 0
fi