mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 18:34:03 +08:00
Compare commits
3 Commits
feat/plugi
...
fix/public
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c4a1e0188 | ||
|
|
d87d9b458a | ||
|
|
1173179b10 |
92
cmd/build.go
92
cmd/build.go
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
109
cmd/flag_gate.go
109
cmd/flag_gate.go
@@ -1,109 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
)
|
||||
|
||||
// globalFlagDomains maps each root persistent flag to the command domain
|
||||
// it belongs to. A new domain-tied global flag must add a row.
|
||||
var globalFlagDomains = map[string]string{
|
||||
"profile": "profile",
|
||||
}
|
||||
|
||||
// flagGateAnnotation distinguishes a policy-retired flag from one hidden
|
||||
// cosmetically (single-app mode force-shows the latter in root help).
|
||||
const flagGateAnnotation = "lark:policy_denied_flag"
|
||||
|
||||
// applyPluginFlagGate hides and rejects the global flags whose whole
|
||||
// domain a plugin denied. yaml denials do not gate flags. Must run after
|
||||
// RegisterGlobalFlags and cmdpolicy.Apply.
|
||||
func applyPluginFlagGate(root *cobra.Command, denied map[string]cmdpolicy.Denial) {
|
||||
gated := false
|
||||
for flagName, domain := range globalFlagDomains {
|
||||
d, ok := denied[domain]
|
||||
if !ok || !cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||
continue
|
||||
}
|
||||
fl := root.PersistentFlags().Lookup(flagName)
|
||||
if fl == nil {
|
||||
continue
|
||||
}
|
||||
fl.Hidden = true
|
||||
if fl.Annotations == nil {
|
||||
fl.Annotations = map[string][]string{}
|
||||
}
|
||||
fl.Annotations[flagGateAnnotation] = []string{"true"}
|
||||
gated = true
|
||||
}
|
||||
if gated {
|
||||
installFlagGateRejection(root)
|
||||
}
|
||||
}
|
||||
|
||||
func isPolicyGatedFlag(fl *pflag.Flag) bool {
|
||||
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
|
||||
}
|
||||
|
||||
// installFlagGateRejection rejects gated flags right after parsing.
|
||||
// pflag has no runtime unregister, so the flag still parses; and cobra
|
||||
// resolves PersistentPreRunE "first non-nil wins" walking up from the
|
||||
// leaf, so every command carrying its own (cmd/auth, cmd/config) must be
|
||||
// wrapped too, not just the root.
|
||||
func installFlagGateRejection(root *cobra.Command) {
|
||||
var walk func(c *cobra.Command)
|
||||
walk = func(c *cobra.Command) {
|
||||
if prev := c.PersistentPreRunE; prev != nil {
|
||||
c.PersistentPreRunE = func(cc *cobra.Command, args []string) error {
|
||||
if err := rejectGatedFlags(cc); err != nil {
|
||||
return err
|
||||
}
|
||||
return prev(cc, args)
|
||||
}
|
||||
}
|
||||
for _, child := range c.Commands() {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
prevRun := root.PersistentPreRun
|
||||
root.PersistentPreRun = nil
|
||||
root.PersistentPreRunE = func(c *cobra.Command, args []string) error {
|
||||
if err := rejectGatedFlags(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if prevRun != nil {
|
||||
prevRun(c, args)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, child := range root.Commands() {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
|
||||
// rejectGatedFlags fails a set policy-retired flag with the same shape an
|
||||
// unregistered flag produces (see flagDidYouMean).
|
||||
//
|
||||
// VisitAll + fl.Changed, not Visit: cobra parses a persistent flag on the leaf
|
||||
// command's merged flagset, so root.PersistentFlags()'s "changed" table stays
|
||||
// empty and Visit (which only walks that table) never fires on the dispatch
|
||||
// path. The flag objects are shared by pointer across the merge, so fl.Changed
|
||||
// reflects a real leaf-level parse.
|
||||
func rejectGatedFlags(c *cobra.Command) error {
|
||||
var rejected error
|
||||
c.Root().PersistentFlags().VisitAll(func(fl *pflag.Flag) {
|
||||
if rejected == nil && fl.Changed && isPolicyGatedFlag(fl) {
|
||||
rejected = errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown flag %q for %q", "--"+fl.Name, c.CommandPath()).
|
||||
WithParams(errs.InvalidParam{Name: "--" + fl.Name, Reason: "unknown flag"}).
|
||||
WithHint("run `%s --help` to see valid flags", c.CommandPath())
|
||||
}
|
||||
})
|
||||
return rejected
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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("plugin skill customization is broken (reason_code %s); fix the plugin's SkillsOverlay or remove the conflicting plugin", reasonCode).
|
||||
WithCause(err)
|
||||
}
|
||||
installFatalGuard(rootCmd, makeErr)
|
||||
}
|
||||
|
||||
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
|
||||
// failure as a typed validation error (failed_precondition). The hint's
|
||||
// reason code splits returned-error vs panic so consumers (audit /
|
||||
|
||||
@@ -1,169 +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"
|
||||
)
|
||||
|
||||
// applyPluginPresentation makes plugin-denied capabilities present as
|
||||
// absent: retired flags, no skills footer, diagnostics hidden or retired.
|
||||
// Presentation only — enforcement happened in cmdpolicy.Apply.
|
||||
func applyPluginPresentation(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
|
||||
applyPluginFlagGate(rootCmd, denied)
|
||||
|
||||
if domainDeniedByPlugin(denied, "skills") {
|
||||
rootCmd.SetUsageTemplate(strings.Replace(rootUsageTemplate, skillsSetupFooter, "", 1))
|
||||
}
|
||||
|
||||
if installResult != nil && hideDiagnosticsOwner(installResult.Plugins) != "" {
|
||||
retireDiagnostics(rootCmd, installResult, denied)
|
||||
} else {
|
||||
concealDiagnostics(rootCmd, denied)
|
||||
}
|
||||
}
|
||||
|
||||
// domainDeniedByPlugin reads the freshly-built denied map, unlike
|
||||
// policystate.DomainDeniedByPlugin which serves render-time consumers.
|
||||
func domainDeniedByPlugin(denied map[string]cmdpolicy.Denial, domain string) bool {
|
||||
d, ok := denied[domain]
|
||||
return ok && cmdpolicy.IsPluginPolicySource(d.PolicySource)
|
||||
}
|
||||
|
||||
// hideDiagnosticsOwner returns the plugin that declared HideDiagnostics,
|
||||
// or "". The host already enforced that it also Restricts.
|
||||
func hideDiagnosticsOwner(plugins []internalplatform.PluginInfo) string {
|
||||
for _, p := range plugins {
|
||||
if p.Capabilities.HideDiagnostics {
|
||||
return p.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// retireDiagnostics installs the unavailable presentation on the
|
||||
// diagnostic exemptions, same as any other plugin-denied command.
|
||||
func retireDiagnostics(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
|
||||
source := "plugin:" + hideDiagnosticsOwner(installResult.Plugins)
|
||||
message := ""
|
||||
for _, d := range denied {
|
||||
if cmdpolicy.IsPluginPolicySource(d.PolicySource) && d.DeniedMessage != "" {
|
||||
message = d.DeniedMessage
|
||||
break
|
||||
}
|
||||
}
|
||||
diag := map[string]cmdpolicy.Denial{}
|
||||
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||
diag[path] = cmdpolicy.Denial{
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: source,
|
||||
ReasonCode: "diagnostics_hidden",
|
||||
Reason: "policy self-inspection hidden by the integrator",
|
||||
DeniedMessage: message,
|
||||
}
|
||||
}
|
||||
cmdpolicy.Apply(rootCmd, diag)
|
||||
}
|
||||
|
||||
// concealDiagnostics hides the diagnostic exemptions from help — without
|
||||
// touching their RunE, so they stay dispatchable — once every non-exempt
|
||||
// sibling in their domain is already hidden. The group itself then gets
|
||||
// the unavailable stub: it escaped denial aggregation only because the
|
||||
// exemptions kept a runnable descendant alive, and its unknown-subcommand
|
||||
// guard RunE would otherwise keep it listed in help.
|
||||
func concealDiagnostics(rootCmd *cobra.Command, denied map[string]cmdpolicy.Denial) {
|
||||
for _, group := range diagnosticDomainGroups(rootCmd) {
|
||||
if !pluginDeniedUnder(denied, cmdpolicy.CanonicalPath(group)) {
|
||||
continue
|
||||
}
|
||||
exemptAncestors := map[*cobra.Command]bool{}
|
||||
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||
for c := findByPath(rootCmd, path); c != nil && c != group; c = c.Parent() {
|
||||
exemptAncestors[c] = true
|
||||
}
|
||||
}
|
||||
allOthersHidden := true
|
||||
for _, child := range group.Commands() {
|
||||
if child.Name() == "help" || exemptAncestors[child] {
|
||||
continue
|
||||
}
|
||||
if !child.Hidden {
|
||||
allOthersHidden = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allOthersHidden {
|
||||
continue
|
||||
}
|
||||
for c := range exemptAncestors {
|
||||
c.Hidden = true
|
||||
}
|
||||
var sample cmdpolicy.Denial
|
||||
for path, d := range denied {
|
||||
if strings.HasPrefix(path, cmdpolicy.CanonicalPath(group)+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||
sample = d
|
||||
break
|
||||
}
|
||||
}
|
||||
cmdpolicy.Apply(rootCmd, map[string]cmdpolicy.Denial{
|
||||
cmdpolicy.CanonicalPath(group): {
|
||||
Layer: cmdpolicy.LayerPolicy,
|
||||
PolicySource: sample.PolicySource,
|
||||
RuleName: sample.RuleName,
|
||||
ReasonCode: "all_children_denied",
|
||||
Reason: "all child commands are denied",
|
||||
DeniedMessage: sample.DeniedMessage,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// diagnosticDomainGroups returns the top-level groups containing a
|
||||
// diagnostic exemption (today just `config`).
|
||||
func diagnosticDomainGroups(rootCmd *cobra.Command) []*cobra.Command {
|
||||
seen := map[*cobra.Command]bool{}
|
||||
var out []*cobra.Command
|
||||
for _, path := range cmdpolicy.DiagnosticPaths() {
|
||||
top, _, _ := strings.Cut(path, "/")
|
||||
if c := findByPath(rootCmd, top); c != nil && !seen[c] {
|
||||
seen[c] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pluginDeniedUnder(denied map[string]cmdpolicy.Denial, prefix string) bool {
|
||||
for path, d := range denied {
|
||||
if strings.HasPrefix(path, prefix+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findByPath resolves a canonical slash path (e.g. "config/policy/show")
|
||||
// to the command node, or nil.
|
||||
func findByPath(rootCmd *cobra.Command, path string) *cobra.Command {
|
||||
cur := rootCmd
|
||||
for _, seg := range strings.Split(path, "/") {
|
||||
var next *cobra.Command
|
||||
for _, child := range cur.Commands() {
|
||||
if child.Name() == seg {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
@@ -1,397 +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))
|
||||
}
|
||||
|
||||
// Setting the flag (as cobra's parse would) must be rejected as unknown.
|
||||
if err := root.PersistentFlags().Set("profile", "prod"); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
err := rejectGatedFlags(root)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
|
||||
t.Errorf("setting a gated flag must fail as unknown flag, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The gate must also fire on the real dispatch path. cobra parses a persistent
|
||||
// flag on the leaf command's merged flagset, so a gate that walks
|
||||
// root.PersistentFlags()'s changed table is a no-op once the flag is passed to
|
||||
// a subcommand. Drive root.Execute end to end and confirm the gated --profile
|
||||
// is rejected before the command body runs.
|
||||
func TestBuildInternal_profileFlagGate_RejectsOnDispatch(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
ranBody := false
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "gateprobe",
|
||||
RunE: func(*cobra.Command, []string) error { ranBody = true; return nil },
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
root.SetOut(&buf)
|
||||
root.SetErr(&buf)
|
||||
root.SetArgs([]string{"--profile", "prod", "gateprobe"})
|
||||
|
||||
err := root.Execute()
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
|
||||
t.Errorf("a gated --profile passed on the dispatch path must fail as unknown flag, got %v", err)
|
||||
}
|
||||
if ranBody {
|
||||
t.Error("gated --profile must be rejected before the command body runs")
|
||||
}
|
||||
}
|
||||
|
||||
// Without the profile domain denied, the gate stays inert.
|
||||
func TestBuildInternal_profileFlagUntouchedWithoutDenial(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if fl := root.PersistentFlags().Lookup("profile"); isPolicyGatedFlag(fl) {
|
||||
t.Error("--profile must not be gated when its domain is not denied")
|
||||
}
|
||||
}
|
||||
|
||||
// Denying the skills domain drops the root-help skills-setup footer.
|
||||
func TestBuildInternal_skillsFooterSuppressed(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if strings.Contains(root.UsageTemplate(), "Skills setup") {
|
||||
t.Error("skills-setup footer must be dropped when the skills domain is denied")
|
||||
}
|
||||
|
||||
// Control: without the denial the footer stays.
|
||||
platform.ResetForTesting()
|
||||
policystate.ResetForTesting()
|
||||
_, root2, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
if !strings.Contains(root2.UsageTemplate(), "Skills setup") {
|
||||
t.Error("skills-setup footer must stay in the default build")
|
||||
}
|
||||
}
|
||||
|
||||
// Denying the skills command domain kills every `skills read` pointer in
|
||||
// domain help, even when the skill content itself is still embedded — the
|
||||
// command the pointers name is absent, so they would all be dead ends.
|
||||
func TestBuildInternal_skillsDomainDenyKillsHelpPointers(t *testing.T) {
|
||||
tmpHome(t)
|
||||
withBaseSkills(t, map[string]string{"lark-im/SKILL.md": "---\ndescription: im\n---\n"})
|
||||
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
im := findByPath(root, "im")
|
||||
if im == nil {
|
||||
t.Fatal("im domain not in tree")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
im.SetOut(&buf)
|
||||
im.SetErr(&buf)
|
||||
root.HelpFunc()(im, nil)
|
||||
if strings.Contains(buf.String(), "skills read") {
|
||||
t.Errorf("domain help must not point at the denied skills command, got:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Denying the update domain silences the _notice providers that would
|
||||
// steer the caller to `lark-cli update`.
|
||||
func TestComposePendingNotice_updateDomainDenied(t *testing.T) {
|
||||
update.SetPending(&update.UpdateInfo{Current: "1.0.0", Latest: "1.0.1"})
|
||||
t.Cleanup(func() { update.SetPending(nil) })
|
||||
t.Cleanup(policystate.ResetForTesting)
|
||||
|
||||
policystate.SetPluginDeniedDomains(map[string]bool{"update": true})
|
||||
if got := composePendingNotice(); got != nil {
|
||||
t.Errorf("notices must be silenced with the update domain denied, got %+v", got)
|
||||
}
|
||||
|
||||
policystate.SetPluginDeniedDomains(nil)
|
||||
if got := composePendingNotice(); got == nil {
|
||||
t.Error("notices must render without the denial")
|
||||
}
|
||||
}
|
||||
|
||||
// Default: diagnostics stay executable but leave help when their whole
|
||||
// domain is denied — cobra then drops the empty config group entirely.
|
||||
func TestBuildInternal_concealDiagnostics(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, nil)
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
show := findByPath(root, "config/policy/show")
|
||||
if show == nil {
|
||||
t.Fatal("config policy show not in tree")
|
||||
}
|
||||
if !show.Hidden {
|
||||
t.Error("exempt diagnostic should be hidden from help when its domain is denied")
|
||||
}
|
||||
// Still dispatchable: its RunE is the original, not an unavailable stub.
|
||||
if show.Annotations[cmdpolicy.AnnotationDenialLayer] != "" {
|
||||
t.Error("exempt diagnostic must not carry a denial stub by default")
|
||||
}
|
||||
if cfg := findByPath(root, "config"); cfg.IsAvailableCommand() {
|
||||
t.Error("config group with no visible children must drop from help")
|
||||
}
|
||||
}
|
||||
|
||||
// HideDiagnostics retires the exemptions like any other denied command.
|
||||
func TestBuildInternal_hideDiagnostics(t *testing.T) {
|
||||
tmpHome(t)
|
||||
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
|
||||
return b.HideDiagnostics()
|
||||
})
|
||||
|
||||
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
|
||||
|
||||
show := findByPath(root, "config/policy/show")
|
||||
if show == nil {
|
||||
t.Fatal("config policy show not in tree")
|
||||
}
|
||||
err := show.RunE(show, nil)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
|
||||
t.Errorf("hidden diagnostic must answer command_unavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
91
cmd/root.go
91
cmd/root.go
@@ -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,
|
||||
@@ -669,80 +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.",
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
target, _, err := root.Find(args)
|
||||
if err != nil || target == nil {
|
||||
c.Printf("Unknown help topic %#q\n", args)
|
||||
return root.Usage()
|
||||
}
|
||||
if msg, ok := unavailableHelpMessage(target); ok {
|
||||
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg)
|
||||
}
|
||||
target.InitDefaultHelpFlag()
|
||||
return target.Help()
|
||||
},
|
||||
}
|
||||
// help attaches after policy evaluation (framework meta command, never
|
||||
// policy-evaluated); the read annotation is defensive in case a future
|
||||
// pass re-evaluates the finished tree.
|
||||
cmdutil.SetRisk(helpCmd, "read")
|
||||
cmdutil.DisableAuthCheck(helpCmd)
|
||||
root.SetHelpCommand(helpCmd)
|
||||
// SetHelpCommand alone defers attachment to Execute's
|
||||
// InitDefaultHelpCmd; add it now so the built tree is complete
|
||||
// (InitDefaultHelpCmd re-adds idempotently).
|
||||
root.AddCommand(helpCmd)
|
||||
}
|
||||
|
||||
// unavailableHelpMessage returns the message to render in place of help
|
||||
// for a plugin-restricted command. yaml-source denials keep original help.
|
||||
func unavailableHelpMessage(cmd *cobra.Command) (string, bool) {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return "", false
|
||||
}
|
||||
if cmd.Annotations[cmdpolicy.AnnotationDenialLayer] != cmdpolicy.LayerPolicy ||
|
||||
!cmdpolicy.IsPluginPolicySource(cmd.Annotations[cmdpolicy.AnnotationDenialSource]) {
|
||||
return "", false
|
||||
}
|
||||
if msg := cmd.Annotations[cmdpolicy.AnnotationDenialMessage]; msg != "" {
|
||||
return msg, true
|
||||
}
|
||||
return cmdpolicy.DefaultUnavailableMessage, true
|
||||
}
|
||||
|
||||
// installTipsHelpFunc wraps the default help function to append a TIPS section
|
||||
// when a command has tips set via cmdutil.SetTips. It also force-shows global
|
||||
// flags that are normally hidden in single-app mode (currently --profile)
|
||||
// when rendering the root command's own help, so users discovering the CLI
|
||||
// still see them at `lark-cli --help`.
|
||||
//
|
||||
// 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 }()
|
||||
}
|
||||
@@ -750,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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,9 +216,6 @@ func (p *builtPlugin) Install(r Registrar) error {
|
||||
for _, rule := range p.rules {
|
||||
r.Restrict(rule)
|
||||
}
|
||||
if p.skillsOverlay != nil {
|
||||
r.EmbeddedSkills(p.skillsOverlay)
|
||||
}
|
||||
for _, action := range p.actions {
|
||||
action(r)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -35,18 +35,4 @@ type Registrar interface {
|
||||
// Plugin rules take precedence over the yaml source; two distinct
|
||||
// plugins both calling Restrict abort startup.
|
||||
Restrict(r *Rule)
|
||||
|
||||
// Skills contributes a SkillsOverlay customizing the CLI's embedded skill
|
||||
// content (see SkillsOverlay). Skill content has a single owner: a second
|
||||
// customizing plugin, or a SkillsOverlay that cannot compose, aborts
|
||||
// startup unconditionally. A malformed call inside one plugin --
|
||||
// EmbeddedSkills(nil) or a second EmbeddedSkills() -- is a staging fault handled like
|
||||
// any Install error (FailClosed aborts, FailOpen skips); the Builder
|
||||
// rejects it earlier, at MustBuild.
|
||||
//
|
||||
// Unlike Restrict, EmbeddedSkills is not a security boundary -- it shapes
|
||||
// fail-open guidance content. Removing a skill drops its guidance but
|
||||
// does not disable any command; use Restrict to actually block a
|
||||
// command.
|
||||
EmbeddedSkills(spec *SkillsOverlay)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -84,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
package cmdpolicy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -75,10 +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"
|
||||
|
||||
// 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
|
||||
@@ -130,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)
|
||||
@@ -231,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -431,79 +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 {
|
||||
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInstallAll_skillsCommitted(t *testing.T) {
|
||||
result, err := internalplatform.InstallAll([]platform.Plugin{skillPlugin{}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallAll: %v", err)
|
||||
}
|
||||
if len(result.PluginSkills) != 1 {
|
||||
t.Fatalf("PluginSkills = %d, want 1", len(result.PluginSkills))
|
||||
}
|
||||
ps := result.PluginSkills[0]
|
||||
if ps.PluginName != "skiller" {
|
||||
t.Errorf("PluginName = %q, want skiller", ps.PluginName)
|
||||
}
|
||||
if ps.SkillsOverlay == nil || len(ps.SkillsOverlay.Remove) != 1 || ps.SkillsOverlay.Remove[0] != "lark-shared" {
|
||||
t.Errorf("Spec = %+v, want Remove=[lark-shared]", ps.SkillsOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
// doubleSkillPlugin calls r.EmbeddedSkills twice; staging must reject it. Declared
|
||||
// FailClosed so the staging error aborts InstallAll deterministically.
|
||||
type doubleSkillPlugin struct{}
|
||||
|
||||
func (doubleSkillPlugin) Name() string { return "double-skiller" }
|
||||
func (doubleSkillPlugin) Version() string { return "1.0.0" }
|
||||
func (doubleSkillPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{FailurePolicy: platform.FailClosed}
|
||||
}
|
||||
func (doubleSkillPlugin) Install(r platform.Registrar) error {
|
||||
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
|
||||
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInstallAll_skillsCalledTwice_aborts(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{doubleSkillPlugin{}}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("calling r.EmbeddedSkills twice must abort a FailClosed plugin")
|
||||
}
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidSkillsOverlay {
|
||||
t.Errorf("err = %v, want PluginInstallError reason_code %s", err, internalplatform.ReasonInvalidSkillsOverlay)
|
||||
}
|
||||
}
|
||||
|
||||
// hideDiagnosticsOnlyPlugin declares HideDiagnostics without Restricts —
|
||||
// an authoring error the host rejects unconditionally.
|
||||
type hideDiagnosticsOnlyPlugin struct{}
|
||||
|
||||
func (hideDiagnosticsOnlyPlugin) Name() string { return "hider" }
|
||||
func (hideDiagnosticsOnlyPlugin) Version() string { return "1.0.0" }
|
||||
func (hideDiagnosticsOnlyPlugin) Capabilities() platform.Capabilities {
|
||||
return platform.Capabilities{HideDiagnostics: true, FailurePolicy: platform.FailOpen}
|
||||
}
|
||||
func (hideDiagnosticsOnlyPlugin) Install(platform.Registrar) error { return nil }
|
||||
|
||||
func TestInstallAll_hideDiagnosticsRequiresRestricts(t *testing.T) {
|
||||
_, err := internalplatform.InstallAll([]platform.Plugin{hideDiagnosticsOnlyPlugin{}}, nil)
|
||||
var pi *internalplatform.PluginInstallError
|
||||
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidCapability {
|
||||
t.Fatalf("HideDiagnostics without Restricts must abort with invalid_capability, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,47 +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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -134,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isCredentialIdentifierField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||
return false
|
||||
}
|
||||
@@ -225,6 +228,17 @@ func isTokenMetadataField(key string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isCredentialIdentifierField(key string) bool {
|
||||
switch {
|
||||
case key == "api_key_id":
|
||||
return true
|
||||
case strings.HasSuffix(key, "_api_key_id"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPaginationOrSyncTokenField(key string) bool {
|
||||
switch key {
|
||||
case "page_token",
|
||||
@@ -493,12 +507,18 @@ func isBenignCodeCredentialExpression(file, line, match, value string) bool {
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) || credentialShapedValue(value) {
|
||||
return false
|
||||
if !sourceCodeFile(file) {
|
||||
return testFixtureFile(file) && testFixtureCredentialLiteral(normalized)
|
||||
}
|
||||
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
|
||||
return isBenignTypedCredentialRHS(rhs)
|
||||
}
|
||||
if testFixtureFile(file) && testFixtureCredentialLiteral(normalized) {
|
||||
return true
|
||||
}
|
||||
if credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
|
||||
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
|
||||
return true
|
||||
@@ -568,7 +588,7 @@ func credentialAssignmentRawValueQuoted(match string) bool {
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
|
||||
case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -593,6 +613,9 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
|
||||
sourceCodeFakeOrPlaceholderLiteral(literal) ||
|
||||
sourceCodeCredentialTermLiteral(literal) ||
|
||||
sourceCodeCredentialPrefixLiteral(literal) ||
|
||||
sourceCodeLowEvidenceFixtureLiteral(literal) ||
|
||||
sourceCodeStringExpressionLiteral(literal) ||
|
||||
sourceCodeSyntheticIdentifierLiteral(literal) ||
|
||||
sourceCodeVocabularyLiteral(literal) ||
|
||||
sourceCodeSchemaTypeLiteral(literal) ||
|
||||
benignCredentialStatusLiteral(literal)
|
||||
@@ -685,6 +708,143 @@ func sourceCodeCredentialPrefixLiteral(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func sourceCodeLowEvidenceFixtureLiteral(value string) bool {
|
||||
normalized := normalizeFixtureCredentialLiteral(value)
|
||||
return simpleFixtureCredentialLiteral(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeStringExpressionLiteral(value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" ||
|
||||
credentialShapedIdentifier(strings.ToLower(normalized)) ||
|
||||
highEntropyCredentialValue(strings.ToLower(normalized)) {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(normalized, "${") ||
|
||||
strings.Contains(normalized, "$(") ||
|
||||
(strings.Contains(normalized, `\b`) && strings.ContainsAny(normalized, "|[]{}()+*?"))
|
||||
}
|
||||
|
||||
func sourceCodeSyntheticIdentifierLiteral(value string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" ||
|
||||
strings.HasPrefix(normalized, "real_") ||
|
||||
strings.HasPrefix(normalized, "real-") ||
|
||||
credentialShapedIdentifier(normalized) ||
|
||||
highEntropyCredentialValue(normalized) ||
|
||||
!delimitedPlaceholderIdentifier(normalized) ||
|
||||
!strings.ContainsAny(normalized, "_-") {
|
||||
return false
|
||||
}
|
||||
var parts int
|
||||
for _, part := range strings.FieldsFunc(normalized, func(r rune) bool {
|
||||
return r == '_' || r == '-'
|
||||
}) {
|
||||
if part != "" {
|
||||
parts++
|
||||
}
|
||||
}
|
||||
return parts >= 2
|
||||
}
|
||||
|
||||
func testFixtureCredentialLiteral(value string) bool {
|
||||
normalized := normalizeFixtureCredentialLiteral(value)
|
||||
if simpleFixtureCredentialLiteral(normalized) {
|
||||
return true
|
||||
}
|
||||
switch normalized {
|
||||
case "real-token",
|
||||
"real-tenant-access-token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFixtureCredentialLiteral(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`)))
|
||||
if before, _, ok := strings.Cut(normalized, `\n`); ok {
|
||||
normalized = before
|
||||
}
|
||||
if before, _, ok := strings.Cut(normalized, `\r`); ok {
|
||||
normalized = before
|
||||
}
|
||||
if before, _, ok := strings.Cut(normalized, "\n"); ok {
|
||||
normalized = before
|
||||
}
|
||||
if before, _, ok := strings.Cut(normalized, "\r"); ok {
|
||||
normalized = before
|
||||
}
|
||||
for {
|
||||
next := strings.TrimSuffix(normalized, `\n`)
|
||||
next = strings.TrimSuffix(next, `\r`)
|
||||
next = strings.TrimSuffix(next, "\n")
|
||||
next = strings.TrimSuffix(next, "\r")
|
||||
if next == normalized {
|
||||
break
|
||||
}
|
||||
normalized = strings.TrimSpace(next)
|
||||
}
|
||||
normalized = strings.TrimSpace(normalized)
|
||||
normalized = strings.TrimPrefix(normalized, `\"`)
|
||||
normalized = strings.TrimSuffix(normalized, `\"`)
|
||||
return strings.TrimSpace(strings.Trim(normalized, `"'`))
|
||||
}
|
||||
|
||||
func simpleFixtureCredentialLiteral(value string) bool {
|
||||
if value == "" || credentialShapedIdentifier(value) || highEntropyCredentialValue(value) {
|
||||
return false
|
||||
}
|
||||
if len(value) <= 3 && delimitedPlaceholderIdentifier(value) {
|
||||
return true
|
||||
}
|
||||
switch value {
|
||||
case "pass",
|
||||
"pat-token",
|
||||
"password",
|
||||
"pat_abc",
|
||||
"pw",
|
||||
"s3cret",
|
||||
"secret",
|
||||
"secret_only",
|
||||
"test":
|
||||
return true
|
||||
default:
|
||||
return allXPlaceholder(value) || humanReadableFixtureCredentialLiteral(value)
|
||||
}
|
||||
}
|
||||
|
||||
func humanReadableFixtureCredentialLiteral(value string) bool {
|
||||
if !delimitedPlaceholderIdentifier(value) {
|
||||
return false
|
||||
}
|
||||
normalized := strings.ReplaceAll(value, "-", "_")
|
||||
for _, marker := range []string{
|
||||
"auto",
|
||||
"dummy",
|
||||
"example",
|
||||
"fake",
|
||||
"fixture",
|
||||
"hermes",
|
||||
"new",
|
||||
"replace",
|
||||
"restored",
|
||||
"sample",
|
||||
"super",
|
||||
"test",
|
||||
"valid",
|
||||
} {
|
||||
if normalized == marker ||
|
||||
strings.HasPrefix(normalized, marker) ||
|
||||
strings.HasSuffix(normalized, marker) ||
|
||||
strings.Contains(normalized, marker+"_") ||
|
||||
strings.Contains(normalized, "_"+marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sourceCodeVocabularyLiteral(value string) bool {
|
||||
switch strings.ToLower(value) {
|
||||
case "bot", "tenant", "user":
|
||||
@@ -753,7 +913,7 @@ func codeIdentifier(value string) bool {
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
case "true", "false", "null", "nil", "{", "[", `\`:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -980,6 +1140,7 @@ func credentialURLPasswordFixture(password string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||
switch normalized {
|
||||
case "p",
|
||||
"p%40ss",
|
||||
"pass",
|
||||
"password",
|
||||
"pat_abc",
|
||||
@@ -1002,6 +1163,20 @@ func sourceOrTestFixtureFile(file string) bool {
|
||||
strings.Contains(normalized, "/fixtures/")
|
||||
}
|
||||
|
||||
func testFixtureFile(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
base := filepath.Base(normalized)
|
||||
return strings.Contains(base, "_test.") ||
|
||||
strings.Contains(base, ".test.") ||
|
||||
strings.Contains(base, "sample") ||
|
||||
strings.HasPrefix(normalized, "tests/") ||
|
||||
strings.HasPrefix(normalized, "testdata/") ||
|
||||
strings.HasPrefix(normalized, "fixtures/") ||
|
||||
strings.Contains(normalized, "/tests/") ||
|
||||
strings.Contains(normalized, "/testdata/") ||
|
||||
strings.Contains(normalized, "/fixtures/")
|
||||
}
|
||||
|
||||
func warnForPrivateIPv4(file string) bool {
|
||||
normalized := filepath.ToSlash(file)
|
||||
if sourceOrTestFixtureFile(normalized) {
|
||||
|
||||
@@ -648,6 +648,7 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
||||
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||
`proxy := "http://user:pass@proxy:8080"`,
|
||||
`proxy := "http://user:p%40ss@proxy:8080/path"`,
|
||||
`repo := "https://u:t@h/r.git"`,
|
||||
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||
@@ -840,7 +841,24 @@ func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{
|
||||
`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`,
|
||||
`cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`,
|
||||
`rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`,
|
||||
`cred := credential.NewCredentialProvider([]extcred.Provider{&fakeExtProvider{token: "real-token"}}, nil, nil, nil)`,
|
||||
`const realToken = "real-tenant-access-token"`,
|
||||
`envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"`,
|
||||
`content := "# Hermes config\nFEISHU_APP_ID=cli_abc123\nFEISHU_APP_SECRET=supersecret\n"`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600)`,
|
||||
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600)`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {`,
|
||||
`if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {`,
|
||||
`if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {`,
|
||||
`return &core.CliConfig{AppID: "dummy", AppSecret: "dummy"}`,
|
||||
`os.WriteFile(path, []byte("API_KEY=replace-me\n"), 0600)`,
|
||||
`body := "APP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n"`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
|
||||
@@ -848,8 +866,41 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialIdentifierFields(t *testing.T) {
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "k1",`,
|
||||
`"secret_id": "s1",`,
|
||||
`"token_id": "t1",`,
|
||||
`"private_key_id": "pk1",`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("credential identifier fields should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileDetectsCredentialShapedIdentifierFieldValues(t *testing.T) {
|
||||
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
|
||||
`"api_key_id": "real-api-key-id",`,
|
||||
`"token_id": "ghp_1234567890abcdef1234567890abcdef1234",`,
|
||||
}, "\n")+"\n"))
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("credential-shaped identifier field findings = %d, want 2: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
|
||||
got := ScanFile("fixtures/minutes_detail.go", []byte(strings.Join([]string{
|
||||
"var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)",
|
||||
"REALISTIC_TOKEN_RE=\"\\\"${TOKEN_BODY}\\\"|\\`${TOKEN_BODY}\\`|\\\\b${TOKEN_BODY}\\\\b\"",
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
|
||||
@@ -927,6 +978,22 @@ func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsSourceCodeSyntheticCredentialIdentifiers(t *testing.T) {
|
||||
got := ScanFile("fixtures/sheets_media.go", []byte(strings.Join([]string{
|
||||
`const fakeOfficeTokenPrefix = "fake_office_"`,
|
||||
`const localOfficeTokenPrefix = "local_office_"`,
|
||||
`const imageLiveSecretMarker = "img_live_secret"`,
|
||||
`const imageProdKeyMarker = "img_prod_key"`,
|
||||
`if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {`,
|
||||
`if strings.HasPrefix(spreadsheetToken, localOfficeTokenPrefix) {`,
|
||||
}, "\n")+"\n"))
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("source code token prefix references should not be credential findings: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
|
||||
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
|
||||
`app_secret=***`,
|
||||
|
||||
@@ -1,209 +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
|
||||
upperNames map[string]bool // skill names owned by upper
|
||||
}
|
||||
|
||||
var (
|
||||
_ fs.FS = (*overlayFS)(nil)
|
||||
_ fs.ReadDirFS = (*overlayFS)(nil)
|
||||
_ fs.StatFS = (*overlayFS)(nil)
|
||||
_ fs.ReadFileFS = (*overlayFS)(nil)
|
||||
)
|
||||
|
||||
func newOverlayFS(lower, upper fs.FS, remove []string) *overlayFS {
|
||||
o := &overlayFS{
|
||||
lower: lower,
|
||||
upper: upper,
|
||||
removed: make(map[string]bool, len(remove)),
|
||||
upperNames: map[string]bool{},
|
||||
}
|
||||
for _, r := range remove {
|
||||
o.removed[r] = true
|
||||
}
|
||||
if upper != nil {
|
||||
if entries, err := fs.ReadDir(upper, "."); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
o.upperNames[e.Name()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// route picks the tree owning name by its top path segment. whiteout is
|
||||
// true when name belongs to a removed skill (present in neither tree).
|
||||
func (o *overlayFS) route(name string) (target fs.FS, whiteout bool) {
|
||||
top := name
|
||||
if i := strings.IndexByte(name, '/'); i >= 0 {
|
||||
top = name[:i]
|
||||
}
|
||||
switch {
|
||||
case o.upperNames[top]:
|
||||
return o.upper, false
|
||||
case o.removed[top]:
|
||||
return nil, true
|
||||
default:
|
||||
return o.lower, false
|
||||
}
|
||||
}
|
||||
|
||||
func (o *overlayFS) Open(name string) (fs.File, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
if name == "." {
|
||||
entries, err := o.ReadDir(".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rootDir{entries: entries}, nil
|
||||
}
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return target.Open(name)
|
||||
}
|
||||
|
||||
func (o *overlayFS) Stat(name string) (fs.FileInfo, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
if name == "." {
|
||||
return rootInfo{}, nil
|
||||
}
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return fs.Stat(target, name)
|
||||
}
|
||||
|
||||
func (o *overlayFS) ReadFile(name string) ([]byte, error) {
|
||||
if !fs.ValidPath(name) || name == "." {
|
||||
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return fs.ReadFile(target, name)
|
||||
}
|
||||
|
||||
func (o *overlayFS) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
if !fs.ValidPath(name) {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid}
|
||||
}
|
||||
if name != "." {
|
||||
target, whiteout := o.route(name)
|
||||
if whiteout || target == nil {
|
||||
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
return fs.ReadDir(target, name)
|
||||
}
|
||||
return o.mergedRoot()
|
||||
}
|
||||
|
||||
// mergedRoot lists the top-level skills: every upper skill, plus every
|
||||
// lower skill that is neither removed nor shadowed by a same-named upper.
|
||||
// Sorted so listings are deterministic.
|
||||
func (o *overlayFS) mergedRoot() ([]fs.DirEntry, error) {
|
||||
seen := map[string]bool{}
|
||||
var out []fs.DirEntry
|
||||
if o.upper != nil {
|
||||
entries, err := fs.ReadDir(o.upper, ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
seen[e.Name()] = true
|
||||
}
|
||||
}
|
||||
if o.lower != nil {
|
||||
entries, err := fs.ReadDir(o.lower, ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if o.removed[e.Name()] || seen[e.Name()] {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
seen[e.Name()] = true
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rootInfo is the synthetic FileInfo for the overlay's "." directory,
|
||||
// which has no single backing entry in either tree.
|
||||
type rootInfo struct{}
|
||||
|
||||
func (rootInfo) Name() string { return "." }
|
||||
func (rootInfo) Size() int64 { return 0 }
|
||||
func (rootInfo) Mode() fs.FileMode { return fs.ModeDir | 0o555 }
|
||||
func (rootInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (rootInfo) IsDir() bool { return true }
|
||||
func (rootInfo) Sys() any { return nil }
|
||||
|
||||
// rootDir is the synthetic directory file returned by Open(".").
|
||||
type rootDir struct {
|
||||
entries []fs.DirEntry
|
||||
off int
|
||||
}
|
||||
|
||||
func (d *rootDir) Stat() (fs.FileInfo, error) { return rootInfo{}, nil }
|
||||
func (d *rootDir) Close() error { return nil }
|
||||
|
||||
func (d *rootDir) Read([]byte) (int, error) {
|
||||
return 0, &fs.PathError{Op: "read", Path: ".", Err: fs.ErrInvalid}
|
||||
}
|
||||
|
||||
func (d *rootDir) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||
if n <= 0 {
|
||||
rest := d.entries[d.off:]
|
||||
d.off = len(d.entries)
|
||||
return rest, nil
|
||||
}
|
||||
if d.off >= len(d.entries) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
end := d.off + n
|
||||
if end > len(d.entries) {
|
||||
end = len(d.entries)
|
||||
}
|
||||
part := d.entries[d.off:end]
|
||||
d.off = end
|
||||
return part, nil
|
||||
}
|
||||
@@ -1,156 +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, effectiveRemove(lower, spec)), nil
|
||||
}
|
||||
|
||||
// effectiveRemove folds a non-empty Allow into the remove set: every
|
||||
// base skill not allow-listed is treated as removed, and explicit
|
||||
// Remove entries win regardless (Remove-over-Allow, mirroring Rule's
|
||||
// Deny-over-Allow). Overlay entries are never folded in — the overlay
|
||||
// FS resolves them from the upper layer before the whiteout applies.
|
||||
func effectiveRemove(lower fs.FS, spec *platform.SkillsOverlay) []string {
|
||||
if len(spec.Allow) == 0 {
|
||||
return spec.Remove
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, name := range spec.Allow {
|
||||
allowed[name] = true
|
||||
}
|
||||
removed := append([]string(nil), spec.Remove...)
|
||||
entries, err := fs.ReadDir(lower, ".")
|
||||
if err != nil {
|
||||
return removed // validate already vetted lower; nothing more to fold
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && !allowed[e.Name()] {
|
||||
removed = append(removed, e.Name())
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// distinctOwners returns the unique contributing plugin names in
|
||||
// first-seen order. Mirrors cmdpolicy.distinctOwners.
|
||||
func distinctOwners(specs []PluginSkill) []string {
|
||||
seen := map[string]bool{}
|
||||
owners := make([]string, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
if !seen[s.PluginName] {
|
||||
seen[s.PluginName] = true
|
||||
owners = append(owners, s.PluginName)
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
// validate rejects a spec that cannot compose cleanly, so the failure
|
||||
// surfaces at startup with a named cause rather than as a silently
|
||||
// missing skill at read time.
|
||||
func validate(lower fs.FS, spec *platform.SkillsOverlay) error {
|
||||
for _, name := range spec.Allow {
|
||||
if !isSkillName(name) {
|
||||
return fmt.Errorf("Allow: %q is not a valid skill name", name)
|
||||
}
|
||||
if !skillExists(lower, name) {
|
||||
return fmt.Errorf("Allow: skill %q is not in the base tree", name)
|
||||
}
|
||||
}
|
||||
for _, name := range spec.Remove {
|
||||
if !isSkillName(name) {
|
||||
return fmt.Errorf("Remove: %q is not a valid skill name", name)
|
||||
}
|
||||
if !skillExists(lower, name) {
|
||||
return fmt.Errorf("Remove: skill %q is not in the base tree", name)
|
||||
}
|
||||
}
|
||||
if spec.Overlay != nil {
|
||||
entries, err := fs.ReadDir(spec.Overlay, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Overlay: cannot read root: %w", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
return fmt.Errorf("Overlay: %q is not a directory; every Overlay entry must be a <skill>/ dir", e.Name())
|
||||
}
|
||||
if !skillExists(spec.Overlay, e.Name()) {
|
||||
return fmt.Errorf("Overlay: skill %q is missing SKILL.md", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// skillExists reports whether fsys holds a skill named name -- a
|
||||
// directory carrying SKILL.md, the shape internal/skillcontent treats as
|
||||
// a skill.
|
||||
func skillExists(fsys fs.FS, name string) bool {
|
||||
if fsys == nil {
|
||||
return false
|
||||
}
|
||||
info, err := fs.Stat(fsys, name+"/SKILL.md")
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// isSkillName rejects empty, dotted, or path-bearing names so Remove
|
||||
// cannot smuggle a traversal or match outside the top level.
|
||||
func isSkillName(name string) bool {
|
||||
return name != "" && name != "." && name != ".." && !strings.ContainsAny(name, `/\`)
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillpolicy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// skillFS builds an in-memory skill tree; each map entry is a path -> content.
|
||||
func skillFS(files map[string]string) fstest.MapFS {
|
||||
m := fstest.MapFS{}
|
||||
for p, content := range files {
|
||||
m[p] = &fstest.MapFile{Data: []byte(content)}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// baseTree is a representative base: three skills, one with a reference file.
|
||||
func baseTree() fstest.MapFS {
|
||||
return skillFS(map[string]string{
|
||||
"lark-a/SKILL.md": "base a",
|
||||
"lark-a/references/x.md": "base a ref",
|
||||
"lark-b/SKILL.md": "base b",
|
||||
"lark-shared/SKILL.md": "base shared",
|
||||
})
|
||||
}
|
||||
|
||||
// topLevel returns the sorted top-level skill names of fsys.
|
||||
func topLevel(t *testing.T, fsys fs.FS) []string {
|
||||
t.Helper()
|
||||
entries, err := fs.ReadDir(fsys, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir(.): %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, fsys fs.FS, name string) string {
|
||||
t.Helper()
|
||||
data, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q): %v", name, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func mustResolve(t *testing.T, base fs.FS, spec *platform.SkillsOverlay) fs.FS {
|
||||
t.Helper()
|
||||
got, err := Resolve(base, []PluginSkill{{PluginName: "acme", SkillsOverlay: spec}})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: unexpected error: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestResolve_NoSpecs_ReturnsBaseUnchanged(t *testing.T) {
|
||||
base := baseTree()
|
||||
got, err := Resolve(base, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if want := []string{"lark-a", "lark-b", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Remove_HidesSkill(t *testing.T) {
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Remove: []string{"lark-shared"}})
|
||||
|
||||
if want := []string{"lark-a", "lark-b"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
// The removed skill is gone for the affordance reader too (Stat gates it).
|
||||
if _, err := fs.Stat(got, "lark-shared/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("Stat removed skill: err = %v, want ErrNotExist", err)
|
||||
}
|
||||
// Surviving skills still read from base.
|
||||
if c := readFile(t, got, "lark-a/SKILL.md"); c != "base a" {
|
||||
t.Errorf("lark-a content = %q, want %q", c, "base a")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Overlay_AddsNewSkill(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"lark-new/SKILL.md": "new skill"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
|
||||
|
||||
if want := []string{"lark-a", "lark-b", "lark-new", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
if c := readFile(t, got, "lark-new/SKILL.md"); c != "new skill" {
|
||||
t.Errorf("lark-new content = %q, want %q", c, "new skill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Overlay_ReplacesSameNameWholeSkill(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"lark-a/SKILL.md": "overlaid a"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
|
||||
|
||||
// Same-named skill resolves to the overlay (upper wins).
|
||||
if c := readFile(t, got, "lark-a/SKILL.md"); c != "overlaid a" {
|
||||
t.Errorf("lark-a content = %q, want overlaid", c)
|
||||
}
|
||||
// Replacement is whole-skill: the base's reference file is shadowed, not merged.
|
||||
if _, err := fs.Stat(got, "lark-a/references/x.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("base reference should be shadowed by overlay skill; err = %v, want ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Base_ReplacesEntireTree(t *testing.T) {
|
||||
replacement := skillFS(map[string]string{"lark-only/SKILL.md": "only"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Base: replacement})
|
||||
|
||||
if want := []string{"lark-only"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
if _, err := fs.Stat(got, "lark-a/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("base skill should be gone after Base replace; err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Base_WithRemoveAndOverlay(t *testing.T) {
|
||||
replacement := skillFS(map[string]string{
|
||||
"lark-p/SKILL.md": "p",
|
||||
"lark-q/SKILL.md": "q",
|
||||
})
|
||||
overlay := skillFS(map[string]string{"lark-r/SKILL.md": "r"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Base: replacement,
|
||||
Remove: []string{"lark-q"},
|
||||
Overlay: overlay,
|
||||
})
|
||||
if want := []string{"lark-p", "lark-r"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow keeps only the listed base skills — the allow-list counterpart
|
||||
// of Rule.Allow, so a CLI upgrade adding new embedded skills cannot
|
||||
// widen an allow-listed build.
|
||||
func TestResolve_Allow_KeepsOnlyListed(t *testing.T) {
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Allow: []string{"lark-a"}})
|
||||
|
||||
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
if _, err := fs.Stat(got, "lark-b/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("non-allow-listed skill must be absent; err = %v", err)
|
||||
}
|
||||
// Kept skills still read from base, references included.
|
||||
if c := readFile(t, got, "lark-a/references/x.md"); c != "base a ref" {
|
||||
t.Errorf("kept skill content = %q, want base content", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove wins over Allow, mirroring Rule's Deny-over-Allow.
|
||||
func TestResolve_RemoveWinsOverAllow(t *testing.T) {
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Allow: []string{"lark-a", "lark-b"},
|
||||
Remove: []string{"lark-b"},
|
||||
})
|
||||
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay entries are exempt from Allow: content the integrator
|
||||
// explicitly ships needs no allow-listing.
|
||||
func TestResolve_OverlayExemptFromAllow(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"acme-guide/SKILL.md": "mine"})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Allow: []string{"lark-a"},
|
||||
Overlay: overlay,
|
||||
})
|
||||
if want := []string{"acme-guide", "lark-a"}; !equalStrings(topLevel(t, got), want) {
|
||||
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
|
||||
}
|
||||
}
|
||||
|
||||
// An Allow name absent from the base aborts startup, same as Remove.
|
||||
func TestResolve_AllowUnknown_Errors(t *testing.T) {
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Allow: []string{"lark-nope"}}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error allow-listing a skill absent from base")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RemoveUnknown_Errors(t *testing.T) {
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-nope"}}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error removing a skill absent from base")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_RemoveInvalidName_Errors(t *testing.T) {
|
||||
for _, bad := range []string{"", ".", "..", "a/b", `a\b`} {
|
||||
if _, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{bad}}}}); err == nil {
|
||||
t.Errorf("Remove %q: expected error, got nil", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_OverlayMissingSKILLmd_Errors(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"lark-bad/other.md": "no skill.md here"})
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for overlay entry missing SKILL.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_OverlayNonDirEntry_Errors(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{"loose.md": "top-level file, not a skill dir"})
|
||||
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-directory overlay entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_TwoOwners_Errors(t *testing.T) {
|
||||
_, err := Resolve(baseTree(), []PluginSkill{
|
||||
{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-a"}}},
|
||||
{PluginName: "globex", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-b"}}},
|
||||
})
|
||||
if !errors.Is(err, ErrMultipleSkillsOverlays) {
|
||||
t.Fatalf("err = %v, want ErrMultipleSkillsOverlays", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_ComposedConformsToFS runs the composed tree through the
|
||||
// standard io/fs conformance checker to catch Open/ReadDir/Stat drift.
|
||||
func TestResolve_ComposedConformsToFS(t *testing.T) {
|
||||
overlay := skillFS(map[string]string{
|
||||
"lark-new/SKILL.md": "new",
|
||||
"lark-new/references/y.md": "new ref",
|
||||
})
|
||||
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
|
||||
Remove: []string{"lark-shared"},
|
||||
Overlay: overlay,
|
||||
})
|
||||
if err := fstest.TestFS(got,
|
||||
"lark-a/SKILL.md",
|
||||
"lark-a/references/x.md",
|
||||
"lark-b/SKILL.md",
|
||||
"lark-new/SKILL.md",
|
||||
"lark-new/references/y.md",
|
||||
); err != nil {
|
||||
t.Fatalf("composed FS failed fs conformance: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
## Core Rules
|
||||
|
||||
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
|
||||
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
|
||||
- Every planned asset must include a fallback visual plan. The fallback can use native charts, tables, whiteboard diagrams, placeholder regions, or XML shapes, text, and arrows as appropriate.
|
||||
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
|
||||
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
|
||||
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
|
||||
@@ -43,7 +43,7 @@ For a page without a meaningful asset need, use:
|
||||
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
|
||||
- `icon`: small semantic symbol for a concept, step, role, or status.
|
||||
- `logo`: brand, product, team, or customer mark.
|
||||
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
|
||||
- `chart`: column, bar, line, area, radar, pie, doughnut/ring, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
|
||||
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
|
||||
- `screenshot`: product UI, terminal output, workflow state, or page capture.
|
||||
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
|
||||
@@ -64,11 +64,23 @@ Match asset type to slide role:
|
||||
|
||||
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
|
||||
|
||||
For `asset_type: "chart"`:
|
||||
|
||||
- If the visual is a supported standard data chart — column, bar, line, area, radar, pie, doughnut/ring, or combo — `fallback_if_missing` must still render as a native `<chart>`.
|
||||
- Do not imitate supported standard data visuals with manual drawing primitives or `<whiteboard>`.
|
||||
- Choose the data source explicitly:
|
||||
- `user_provided`: when the user provides concrete values, tables, CSV, or metric lists, use those values and do not replace them with mock data.
|
||||
- `mock_placeholder`: when the user asks for a placeholder, template, example, or chart position to replace later, use mock data in a native `<chart>`.
|
||||
- `mock_required_by_intent`: when the user does not provide concrete values but asks for data expression, charts, trends, comparisons, or distributions, use mock data in a native `<chart>`.
|
||||
- Mock data must be labeled as `模拟数据,仅占位,待替换真实数据` or equivalent. Do not present mock values as facts.
|
||||
- Manual drawing fallbacks are allowed only for unsupported chart types such as scatter, funnel, waterfall-like custom visuals, or decorative non-data visuals.
|
||||
|
||||
`fallback_if_missing` must be concrete enough to turn into XML, for example:
|
||||
|
||||
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
|
||||
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
|
||||
- "Render a mini bar chart with 4 bars using shapes and value labels."
|
||||
- "Render a native `<chart>` using the user-provided series."
|
||||
- "Render a native `<chart>` with mock placeholder values and label it as `模拟数据,仅占位,待替换真实数据`."
|
||||
- "Use a bordered placeholder panel with product area labels, not an empty image."
|
||||
|
||||
Weak fallbacks to avoid:
|
||||
@@ -118,7 +130,7 @@ Business comparison page:
|
||||
When generating XML:
|
||||
|
||||
1. If an asset exists and the workflow supports it, place it in the planned visual region.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with the planned XML-native element type. Supported standard data visuals still use native `<chart>`; other fallbacks may use shapes, text, lines, arrows, tables, whiteboard diagrams, or placeholder panels.
|
||||
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
|
||||
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
|
||||
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
`<whiteboard>` 放在 `<data>` 内,内部可放 **SVG** 或 **Mermaid**,用于绘制流程图、时序图、架构图、散点图、漏斗图、自定义图标、装饰图案等 `<chart>` 和 `<shape>` 难以覆盖的视觉内容。
|
||||
|
||||
普通柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图和组合图应优先使用原生 `<chart>`。除非用户明确要求像素级自定义,或图表类型确实不受 `<chart>` 支持,否则不要用 `<whiteboard>` + SVG / Mermaid 重画这些标准图表。
|
||||
|
||||
> 前置条件:使用本文档前先阅读 [lark-slides SKILL.md](../SKILL.md)。
|
||||
|
||||
---
|
||||
@@ -12,13 +14,13 @@
|
||||
|
||||
| 场景 | 推荐元素 |
|
||||
|------|---------|
|
||||
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
|
||||
| 散点图、漏斗图(`<chart>` 不支持) | `<whiteboard>` SVG |
|
||||
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/环/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
|
||||
| 散点图、漏斗图(`<chart>` 不支持)或其他非原生数据视觉 | `<whiteboard>` SVG |
|
||||
| 流程图、时序图、架构图、类图、ER 图等拓扑图 | `<whiteboard>` Mermaid 或 SVG |
|
||||
| 自定义图标、徽标、示意性图形(需要 path/polygon 精确控制) | `<whiteboard>` SVG |
|
||||
| 进度条、波浪背景、装饰图案、像素级自定义可视化 | `<whiteboard>` SVG |
|
||||
|
||||
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG 手绘——原生渲染更省力且质量更高。
|
||||
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG / Mermaid 手绘——原生渲染更省力、结构更稳定,也更容易被回读和后续编辑。
|
||||
|
||||
---
|
||||
|
||||
@@ -39,9 +41,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
|
||||
## SVG 还是 Mermaid?
|
||||
|
||||
选择分两步:**先看图表类型,再看当前模型身份**。
|
||||
选择分三步:**先排除原生 `<chart>`,再判断 whiteboard 类型,最后看当前模型身份**。
|
||||
|
||||
### 第一步:图表类型优先判断
|
||||
### 第一步:先确认是否应该使用 `<chart>`
|
||||
|
||||
如果内容是柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图或组合图,返回使用原生 `<chart>`,不要继续套用本文档的 SVG / Mermaid 路径。
|
||||
|
||||
### 第二步:whiteboard 类型优先判断
|
||||
|
||||
以下类型**推荐 Mermaid**,自动布局、代码简洁;如需精确匹配品牌配色或自定义节点样式,可改用 SVG:
|
||||
|
||||
@@ -50,20 +56,19 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
| 流程图、决策树、架构图 | `flowchart TD` / `flowchart LR` |
|
||||
| 时序图 | `sequenceDiagram` |
|
||||
| 类图 | `classDiagram` |
|
||||
| 饼图 | `pie` |
|
||||
| 甘特图 | `gantt` |
|
||||
| 状态图 | `stateDiagram-v2` |
|
||||
| 思维导图 | `mindmap` |
|
||||
| ER 图 | `erDiagram` |
|
||||
|
||||
### 第二步:数据图表与装饰元素按模型身份选路径
|
||||
### 第三步:非原生图表与装饰元素按模型身份选路径
|
||||
|
||||
上表以外的场景(散点图、漏斗图、进度条、时间线、波浪背景、星点纹理等)需要精确控制坐标和配色,SVG 表达力更强,但各模型生成 SVG 的能力有差异:
|
||||
|
||||
| 模型身份 | 路径 |
|
||||
|----------|------|
|
||||
| Claude / Gemini / GPT / GLM | **SVG** — 精确控制坐标、颜色、透明度 |
|
||||
| Doubao / Seed / Other | **Mermaid** — 用 `pie`、`gantt` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
|
||||
| Doubao / Seed / Other | **Mermaid** — 用 `gantt`、`flowchart` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
|
||||
|
||||
> **先自报身份再选路径**:在决定使用 SVG 之前,确认当前模型属于哪一类。不要跳过这一步。
|
||||
|
||||
@@ -73,13 +78,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角(0,0),与 slide 坐标
|
||||
|
||||
### ⚠️ 设计品质要求
|
||||
|
||||
在 slide 里嵌入 `<whiteboard>` 的目的是**提升视觉质量**,不是把数字堆进去。
|
||||
在 slide 里嵌入 `<whiteboard>` 的目的是**表达原生 `<chart>` 或基础 `<shape>` 难以覆盖的视觉关系**,不是把标准数据图表手绘一遍。
|
||||
|
||||
- **不要只用矩形加文字应付**:通篇纯白底色 + 方块 + 黑字等于白做,这是不及格输出
|
||||
- **数据图表必须有坐标系**:坐标轴、网格线、数值标注缺一不可,不要只画柱子或点
|
||||
- **非原生数据视觉必须有坐标系**:散点、漏斗等仍要有必要的坐标轴、刻度、数值标注或分段说明,不要只画点或色块
|
||||
- **字号必须有层级**:标题 ≠ 标签 ≠ 数值,混用同一字号会消灭视觉焦点
|
||||
- **配色要与 slide 主题呼应**:深色 slide 背景下图表用透明底或深色卡片;浅色背景下避免再加纯白底块
|
||||
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、折线面积、点装饰等细节拉开与默认模板的差距
|
||||
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、清晰分组、节点状态等细节拉开与默认模板的差距
|
||||
- **写 SVG 前先判断背景亮度**:背景亮度 < 30% 时,装饰元素"对比不足"比"过强"危害更大,宁重勿轻;
|
||||
- **装饰层次用亮度跳跃,不用线性叠透明度**:`α=0.04→0.08→0.12` 的等差递增在深色底上几乎看不出差异(相邻层亮度差 ≈20);正确做法是非线性跳跃如 `0.10→0.40→0.70→1.0`,相邻层亮度差 ≥60。
|
||||
|
||||
@@ -106,11 +111,11 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
|
||||
|
||||
| 元素 | 说明 | 典型用途 |
|
||||
|------|------|---------|
|
||||
| `<rect>` | 矩形,支持 `rx` 圆角 | 柱图、卡片、进度条 |
|
||||
| `<rect>` | 矩形,支持 `rx` 圆角 | 卡片、进度条、分段色块 |
|
||||
| `<circle>` | 圆 | 节点、装饰点、环形图 |
|
||||
| `<ellipse>` | 椭圆 | 自定义轮廓图形 |
|
||||
| `<line>` | 直线 | 坐标轴、分隔线 |
|
||||
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、折线、弧形 |
|
||||
| `<line>` | 直线 | 轴线、分隔线、连接线 |
|
||||
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、曲线、弧形 |
|
||||
| `<text>` | 文本,支持中文 | 标签、数值 |
|
||||
| `<polygon>` | 多边形 | 箭头、星形、面积填充 |
|
||||
| `<g>` | 分组 | 批量变换、语义分组 |
|
||||
@@ -123,27 +128,25 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
|
||||
---
|
||||
### 元素计算
|
||||
|
||||
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围不限于数据图表——装饰性点阵、等间距圆、重复图案同样适用。
|
||||
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围包括散点、漏斗、装饰性点阵、等间距圆、重复图案等;普通柱状图、折线图、饼图仍应回到原生 `<chart>`。
|
||||
|
||||
> **主动去算**:写 SVG 之前先运行脚本,把输出当注释贴在 `<svg>` 开头,再照着填坐标。估值几乎每次都需要反复调整,跳过这步反而更慢。
|
||||
|
||||
**数据图表(柱状图范式)**
|
||||
**散点图 / 装饰性点阵范式**
|
||||
|
||||
```python
|
||||
W, H = 360, 260
|
||||
origin_x, origin_y = 50, 216 # 左下角,SVG Y 轴向下
|
||||
cw, ch = 290, 184
|
||||
|
||||
data, y_max = [120, 160, 90], 200
|
||||
bar_w = int(cw / len(data) * 0.62)
|
||||
for i, v in enumerate(data):
|
||||
cx = round(origin_x + (i + 0.5) * cw / len(data))
|
||||
y = round(origin_y - v / y_max * ch)
|
||||
print(f"bar-{i}: x={cx - bar_w//2} y={y} w={bar_w} h={round(origin_y - y)}")
|
||||
points = [(12, 40), (28, 80), (45, 65)]
|
||||
x_min, x_max, y_min, y_max = 0, 50, 0, 100
|
||||
for i, (xv, yv) in enumerate(points):
|
||||
x = round(origin_x + (xv - x_min) / (x_max - x_min) * cw)
|
||||
y = round(origin_y - (yv - y_min) / (y_max - y_min) * ch)
|
||||
print(f"point-{i}: cx={x} cy={y}")
|
||||
```
|
||||
|
||||
折线图:`x = origin_x + i/(n-1)*cw`,`y = origin_y - (v-y_min)/(y_max-y_min)*ch`。
|
||||
|
||||
**装饰性元素(等间距范式)**
|
||||
|
||||
```python
|
||||
@@ -160,9 +163,9 @@ for i in range(n):
|
||||
```python
|
||||
# 每个元素登记 (x, y, w, h),含 stroke 外扩
|
||||
elements = [
|
||||
(10, 20, 80, 160), # bar-0
|
||||
(107, 10, 80, 170), # bar-1
|
||||
(204, 40, 80, 140), # bar-2
|
||||
(10, 20, 80, 160), # item-0
|
||||
(107, 10, 80, 170), # item-1
|
||||
(204, 40, 80, 140), # item-2
|
||||
(0, 0, 300, 1), # x-axis
|
||||
]
|
||||
|
||||
@@ -261,7 +264,6 @@ print(f"whiteboard width={wb_w} height={wb_h}")
|
||||
| 流程图 | `flowchart TD` / `flowchart LR` | 业务流程、决策树、工作流 |
|
||||
| 时序图 | `sequenceDiagram` | 系统交互、API 调用链 |
|
||||
| 甘特图 | `gantt` | 项目计划、里程碑 |
|
||||
| 饼图 | `pie` | 占比数据 |
|
||||
| 类图 | `classDiagram` | 对象关系、架构设计 |
|
||||
| ER 图 | `erDiagram` | 数据库结构 |
|
||||
| 状态图 | `stateDiagram-v2` | 状态机、生命周期 |
|
||||
@@ -279,7 +281,6 @@ Mermaid 图表会自动撑满 whiteboard 区域。建议:
|
||||
|---------|-----------|------------|
|
||||
| 流程图(5-8 节点) | 720-816 | 300-400 |
|
||||
| 时序图(3-5 参与者) | 720-816 | 320-420 |
|
||||
| 饼图 | 500-600 | 300-360 |
|
||||
| 甘特图 | 816 | 280-360 |
|
||||
| 思维导图 | 816 | 380-480 |
|
||||
|
||||
@@ -307,7 +308,7 @@ Mermaid 语法包含 `[`、`>`、`-->`,不用 CDATA 直接写会破坏 XML 解
|
||||
- [ ] 文字 `y` 坐标为 baseline 位置,最小值 ≥ font-size(避免被裁切)
|
||||
|
||||
**SVG 模式——视觉品质检查:**
|
||||
- [ ] 坐标轴、网格线、数值标注齐全,没有"裸柱子"或"裸折线"
|
||||
- [ ] 非原生数据视觉有必要的坐标轴、网格线、数值标注或分段说明,没有"裸点"或无解释色块
|
||||
- [ ] 字号有层级:标题 > 数值 > 轴标签,非全部相同
|
||||
- [ ] 单一数据系列用同一颜色,多系列用不同颜色且对比充足
|
||||
- [ ] 轴标签与图表元素互不遮挡,留有足够空间
|
||||
|
||||
@@ -119,6 +119,34 @@ Each slide must include:
|
||||
- `text_density`: `low`, `medium`, or `high`.
|
||||
- `speaker_intent`: why the speaker needs this page and how it advances the story.
|
||||
|
||||
Optional slide fields:
|
||||
|
||||
- `chart_contract`: required when the page plan includes a standard data chart that `<chart>` supports. Use this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"chart_contract": {
|
||||
"required": true,
|
||||
"render_as": "native_chart",
|
||||
"chart_type": "line",
|
||||
"data_source": "mock_placeholder",
|
||||
"data_series_required": true,
|
||||
"placeholder_label_required": true,
|
||||
"manual_shape_fallback_allowed": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `chart_contract.required == true`, XML generation must produce a `<chart>` element on that slide. A shape, line, polyline, or whiteboard approximation does not satisfy the plan.
|
||||
|
||||
`data_source` must be one of:
|
||||
|
||||
- `user_provided`: the user supplied concrete values, tables, CSV, or metric lists; use them and do not replace them with mock data.
|
||||
- `mock_placeholder`: the user asked for a placeholder, template, example, or later-replaceable chart position; use mock data in native `<chart>`.
|
||||
- `mock_required_by_intent`: the user did not provide concrete values but asked for data expression, charts, trends, comparisons, or distributions; use mock data in native `<chart>`.
|
||||
|
||||
`data_series_required` means the generated XML must include `<chartData>`. It does not require user-provided real-world values. When real values are unavailable but chart expression is part of the user's intent, write mock or placeholder values into native `<chart>` and label them clearly instead of switching to manual drawing primitives or metric blocks.
|
||||
|
||||
## Layout Vocabulary
|
||||
|
||||
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
|
||||
@@ -183,6 +211,7 @@ Use an object for one planned asset, an array for multiple real needs, or `asset
|
||||
- `purpose`: why this asset helps the page's key message.
|
||||
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
|
||||
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
|
||||
- `chart_contract`: when `asset_type` is `chart` and the visual is a supported standard data chart, set this optional slide-level field so generation is locked to native `<chart>`.
|
||||
|
||||
For detailed rules and examples, read `asset-planning.md`.
|
||||
|
||||
@@ -190,7 +219,7 @@ Good examples:
|
||||
|
||||
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
|
||||
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Render a native `<chart>` using the provided series when available; otherwise render a native `<chart>` with mock placeholder values and label it as 模拟数据,仅占位,待替换真实数据."}`
|
||||
|
||||
## XML Generation Contract
|
||||
|
||||
@@ -201,6 +230,7 @@ Before writing each slide XML, map the plan fields to concrete decisions:
|
||||
- `visual_focus` determines the largest visual region or emphasized object.
|
||||
- `text_density` caps visible text volume.
|
||||
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
|
||||
- `chart_contract` locks supported standard data charts to native `<chart>` output. Manual approximations are allowed only when the planned chart type is unsupported by `<chart>` or when the visual is explicitly non-data/decorative.
|
||||
|
||||
After creating the PPT, fetch the presentation and verify:
|
||||
|
||||
|
||||
1
skills/lark-slides/references/slides_chart_demo.xml
Normal file
1
skills/lark-slides/references/slides_chart_demo.xml
Normal file
File diff suppressed because one or more lines are too long
@@ -3018,7 +3018,7 @@
|
||||
|
||||
子元素(mermaid 与 svg 二选一):
|
||||
- mermaid: Mermaid 源码文本, 可使用 CDATA 包裹
|
||||
适用场景: 流程图、时序图、思维导图、类图、甘特图、饼图等结构化图表
|
||||
适用场景: 流程图、时序图、思维导图、类图、甘特图、ER 图、用户旅程等结构图
|
||||
特点: 用简短的文本声明描述图表逻辑, 由渲染引擎自动布局, 无需手动计算坐标
|
||||
示例: <mermaid><![CDATA[flowchart TD\n A[开始] --> B[结束]]]></mermaid>
|
||||
- svg: SVG 内容
|
||||
|
||||
@@ -263,7 +263,56 @@
|
||||
- `<chartLegend>`
|
||||
- `<chartTooltip>`
|
||||
|
||||
如果要写图表 XML,建议直接以 XSD 为准,不要自行发明更简化的 chart DSL。
|
||||
完整图表类型覆盖示例见 [slides_chart_demo.xml](slides_chart_demo.xml),其中包含柱状、条形、折线、面积、饼 / 环、雷达等原生 `<chart>` 示例,以及散点、气泡、漏斗、帕累托、瀑布等 `<whiteboard>` SVG 图表示例。
|
||||
|
||||
组合图示例(来自 [slides_chart_demo.xml](slides_chart_demo.xml)):
|
||||
|
||||
```xml
|
||||
<chart width="556" height="350" topLeftX="42" topLeftY="132">
|
||||
<chartPlotArea>
|
||||
<chartPlot type="combo">
|
||||
<chartExtra/>
|
||||
<chartSeriesList>
|
||||
<chartSeries index="1" comboType="column"/>
|
||||
<chartSeries index="2" comboType="line" yAxisPosition="right">
|
||||
<chartTooltip format="0%"/>
|
||||
</chartSeries>
|
||||
</chartSeriesList>
|
||||
</chartPlot>
|
||||
<chartAxes>
|
||||
<chartAxis type="x">
|
||||
<chartLabel fontSize="10"/>
|
||||
</chartAxis>
|
||||
<chartAxis type="y" position="left">
|
||||
<chartGridLine color="rgb(226, 232, 240)"/>
|
||||
<chartLabel fontSize="10"/>
|
||||
</chartAxis>
|
||||
<chartAxis type="y" position="right">
|
||||
<chartLabel fontSize="10" format="0%"/>
|
||||
</chartAxis>
|
||||
</chartAxes>
|
||||
</chartPlotArea>
|
||||
<chartLegend position="bottom" fontSize="11"/>
|
||||
<chartData>
|
||||
<dim1>
|
||||
<chartField name="季度">24Q1,24Q2,24Q3,24Q4,25Q1,25Q2,25Q3,25Q4</chartField>
|
||||
</dim1>
|
||||
<dim2>
|
||||
<chartField name="营收">180,195,210,245,220,238,258,296</chartField>
|
||||
<chartField name="增速">0.08,0.12,0.15,0.18,0.22,0.22,0.23,0.21</chartField>
|
||||
</dim2>
|
||||
</chartData>
|
||||
<chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">营收(亿美元, 左轴) · 同比增速(%, 右轴)</chartTitle>
|
||||
<chartStyle>
|
||||
<chartBackground color="rgba(0, 0, 0, 0)"/>
|
||||
<chartBorder color="rgb(222, 224, 227)" width="0"/>
|
||||
<chartColorTheme>
|
||||
<color value="rgb(28, 71, 120)"/>
|
||||
<color value="rgb(240, 129, 54)"/>
|
||||
</chartColorTheme>
|
||||
</chartStyle>
|
||||
</chart>
|
||||
```
|
||||
|
||||
## 样式元素
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
### whiteboard
|
||||
|
||||
```xml
|
||||
<!-- SVG 模式:数据图表、装饰元素 -->
|
||||
<!-- SVG 模式:<chart> 不支持的图表或自定义视觉、装饰元素 -->
|
||||
<whiteboard topLeftX="580" topLeftY="120" width="340" height="280">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="60" y="80" width="40" height="140" rx="3" fill="rgba(59,130,246,0.85)"/>
|
||||
|
||||
Reference in New Issue
Block a user