Skip to content

Commit bc94f30

Browse files
author
zhaoyukun.yk
committed
feat(extension): present restricted commands as absent and trim skills
Integrators that build their own lark-cli distribution can now ship a package where everything they cut out truly disappears, instead of lingering as half-disabled stubs that mislead users and agents. Commands an integrator plugin restricts no longer show up in help or completion, and invoking one (including asking for its help) answers "command not included in this build" (subtype command_unavailable) with no policy vocabulary and no dead-end recovery steering. Integrators can replace that message with their own product wording via Rule.DeniedMessage. Cutting a whole domain also retires everything that points at it: --profile hides and rejects use when the profile domain goes, the skills-setup footer, update notices, and the auth-login recovery hint stop rendering when their domains go. The policy self-inspection commands (config policy show / plugins show) stay executable by default so operators can still see which rule locked the build, hidden from help but available on request. Fully-managed distributions can retire even those with HideDiagnostics(). Plugins can also trim the embedded skills to match: EmbeddedSkills removes, overlays, or replaces entries over the CLI's built-in set, and skills list/read plus every help pointer serve the same trimmed tree. Users who write their own ~/.lark-cli/policy.yml see no change: their denials stay visible and keep explaining how to adjust the policy they own. Default builds are byte-for-byte unchanged.
1 parent 452734f commit bc94f30

54 files changed

Lines changed: 3532 additions & 109 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎cmd/build.go‎

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,16 @@ import (
2121
cmdupdate "github.com/larksuite/cli/cmd/update"
2222
"github.com/larksuite/cli/cmd/whoami"
2323
_ "github.com/larksuite/cli/events"
24+
"github.com/larksuite/cli/extension/platform"
2425
"github.com/larksuite/cli/internal/apicatalog"
2526
"github.com/larksuite/cli/internal/build"
2627
"github.com/larksuite/cli/internal/cmdpolicy"
2728
"github.com/larksuite/cli/internal/cmdutil"
2829
"github.com/larksuite/cli/internal/hook"
2930
"github.com/larksuite/cli/internal/keychain"
31+
internalplatform "github.com/larksuite/cli/internal/platform"
32+
"github.com/larksuite/cli/internal/policystate"
33+
"github.com/larksuite/cli/internal/skillpolicy"
3034
"github.com/larksuite/cli/shortcuts"
3135
"github.com/spf13/cobra"
3236
)
@@ -42,6 +46,7 @@ type buildConfig struct {
4246
skipStrictMode bool
4347
skipService bool
4448
serviceCatalog *apicatalog.Catalog
49+
skillsOverlay *platform.SkillsOverlay
4550
}
4651

4752
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
@@ -59,6 +64,17 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
5964
}
6065
}
6166

67+
// WithEmbeddedSkills customizes the CLI's embedded skills for a caller that builds
68+
// the command tree directly instead of registering a plugin. It is the
69+
// build-option analogue of a plugin's EmbeddedSkills(...): the same single-owner
70+
// rule applies, so combining WithEmbeddedSkills with a plugin that also customizes
71+
// skills aborts startup. nil is a no-op.
72+
func WithEmbeddedSkills(spec *platform.SkillsOverlay) BuildOption {
73+
return func(c *buildConfig) {
74+
c.skillsOverlay = spec
75+
}
76+
}
77+
6278
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
6379
// at build time. It is registered by the repo-root package main's init via
6480
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
@@ -71,6 +87,21 @@ var embeddedSkillContent fs.FS
7187
// supply its own skill content.
7288
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
7389

90+
// withEmbeddedSkillsOwner labels a WithEmbeddedSkills contribution in the skill resolver so a
91+
// WithEmbeddedSkills + plugin-Skills collision names a stable, non-plugin owner.
92+
const withEmbeddedSkillsOwner = "cmd.WithEmbeddedSkills"
93+
94+
// resolveSkillContent composes the effective embedded skill tree from the CLI
95+
// default, an optional WithEmbeddedSkills spec, and plugin SkillsOverlays, enforcing the
96+
// single-owner rule across all sources.
97+
func resolveSkillContent(cfg *buildConfig, pluginSkills []skillpolicy.PluginSkill) (fs.FS, error) {
98+
sources := pluginSkills
99+
if cfg.skillsOverlay != nil {
100+
sources = append([]skillpolicy.PluginSkill{{PluginName: withEmbeddedSkillsOwner, SkillsOverlay: cfg.skillsOverlay}}, sources...)
101+
}
102+
return skillpolicy.Resolve(embeddedSkillContent, sources)
103+
}
104+
74105
// HideProfile sets the visibility policy for the root-level --profile flag.
75106
// When hide is true the flag stays registered (so existing invocations still
76107
// parse) but is omitted from help and shell completion. Typically called as
@@ -154,6 +185,14 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
154185
cfg.streams = cmdutil.SystemIO()
155186
}
156187

188+
// Reset every process-global snapshot up front, not only inside
189+
// applyUserPolicyPruning: the skipPlugins and install-failure paths
190+
// return before pruning runs, and a previous build's state must not
191+
// leak into this one (long-lived embedders, test sequences).
192+
policystate.SetPluginDeniedDomains(nil)
193+
cmdpolicy.SetActive(nil)
194+
internalplatform.SetActiveInventory(nil)
195+
157196
f := cmdutil.NewDefault(cfg.streams, inv)
158197
if cfg.keychain != nil {
159198
f.Keychain = cfg.keychain
@@ -175,7 +214,16 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
175214
// rootUsageTemplate.
176215
rootCmd.SetUsageTemplate(rootUsageTemplate)
177216

178-
installTipsHelpFunc(rootCmd)
217+
// The skill-content getter also gates on the skills command domain:
218+
// every pointer it feeds renders as `lark-cli skills read ...`, so with
219+
// that domain plugin-denied the pointers would all be dead ends even
220+
// though the content itself still exists.
221+
installTipsHelpFunc(rootCmd, func() fs.FS {
222+
if policystate.DomainDeniedByPlugin("skills") {
223+
return nil
224+
}
225+
return f.SkillContent
226+
})
179227
rootCmd.SilenceErrors = true
180228
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
181229
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
@@ -223,6 +271,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
223271
}
224272

225273
if cfg.skipPlugins {
274+
installHelpCommand(rootCmd)
275+
resolved, err := resolveSkillContent(cfg, nil)
276+
if err != nil {
277+
installPluginSkillErrorGuard(rootCmd, err)
278+
return f, rootCmd, nil
279+
}
280+
f.SkillContent = resolved
226281
recordInventory(nil)
227282
return f, rootCmd, nil
228283
}
@@ -233,22 +288,51 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
233288
return f, rootCmd, nil
234289
}
235290
var pluginRules []cmdpolicy.PluginRule
291+
var pluginSkills []skillpolicy.PluginSkill
236292
var registry *hook.Registry
237293
if installResult != nil {
238294
pluginRules = installResult.PluginRules
295+
pluginSkills = installResult.PluginSkills
239296
registry = installResult.Registry
240297
}
241298

242299
// Policy errors fail-CLOSED when a plugin contributed (security
243300
// intent must not be silently dropped); yaml-only errors fail-OPEN
244301
// with a warning so a typo can't lock the user out.
245-
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
302+
denied, policyErr := applyUserPolicyPruning(rootCmd, pluginRules)
303+
if policyErr != nil {
246304
if len(pluginRules) > 0 {
247-
installPluginConflictGuard(rootCmd, err)
305+
installPluginConflictGuard(rootCmd, policyErr)
248306
return f, rootCmd, nil
249307
}
250-
warnPolicyError(cfg.streams.ErrOut, err)
308+
warnPolicyError(cfg.streams.ErrOut, policyErr)
309+
}
310+
311+
// The custom help command attaches AFTER policy evaluation on purpose:
312+
// it is a framework meta command, and inside the evaluated tree an
313+
// allow-list rule (Allow: ["im/**"]) would deny it as
314+
// domain_not_allowed — cobra's stock help command is likewise attached
315+
// only at Execute time and never evaluated.
316+
installHelpCommand(rootCmd)
317+
318+
// Presentation passes: a capability an integrator plugin denied
319+
// presents as absent — retired global flags (--profile), no skills
320+
// footer, diagnostics hidden or retired. Enforcement stays with
321+
// cmdpolicy.Apply above; these only shape help and fixed hints.
322+
applyPluginPresentation(rootCmd, installResult, denied)
323+
324+
// Resolve the embedded skill tree BEFORE wiring hooks: an invalid
325+
// SkillsOverlay must fail fast, before wireHooks emits Startup, so a Startup
326+
// side effect is never stranded without its Shutdown. Both skill readers
327+
// -- `skills list`/`read` and the --help guidance -- then read this one
328+
// f.SkillContent. Fails closed: never silently ship defaults once a
329+
// customization is declared.
330+
resolvedSkills, skillErr := resolveSkillContent(cfg, pluginSkills)
331+
if skillErr != nil {
332+
installPluginSkillErrorGuard(rootCmd, skillErr)
333+
return f, rootCmd, nil
251334
}
335+
f.SkillContent = resolvedSkills
252336

253337
if registry != nil {
254338
if err := wireHooks(ctx, rootCmd, registry); err != nil {

‎cmd/config/config_test.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/larksuite/cli/internal/i18n"
2121
"github.com/larksuite/cli/internal/keychain"
2222
"github.com/larksuite/cli/internal/output"
23+
"github.com/larksuite/cli/internal/policystate"
2324
)
2425

2526
type noopConfigKeychain struct{}
@@ -564,3 +565,54 @@ func TestPrintLangPreferenceConfirmation(t *testing.T) {
564565
}
565566
})
566567
}
568+
569+
// The "no active profile" hint points at `lark-cli profile list`; that pointer
570+
// must be gated on the profile domain still being present. When a plugin denies
571+
// the profile domain, the hint would be a dead end, so it is omitted — the error
572+
// itself is unchanged.
573+
func TestConfigShowRun_ProfileHintGatedByPluginDenial(t *testing.T) {
574+
multi := &core.MultiAppConfig{
575+
CurrentApp: "missing",
576+
Apps: []core.AppConfig{{
577+
Name: "default",
578+
AppId: "app-default",
579+
AppSecret: core.PlainSecret("secret-default"),
580+
Brand: core.BrandFeishu,
581+
}},
582+
}
583+
584+
hintOf := func(t *testing.T) string {
585+
t.Helper()
586+
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
587+
if err := core.SaveMultiAppConfig(multi); err != nil {
588+
t.Fatalf("SaveMultiAppConfig() error = %v", err)
589+
}
590+
f, _, _, _ := cmdutil.TestFactory(t, nil)
591+
err := configShowRun(&ConfigShowOptions{Factory: f})
592+
var cfgErr *errs.ConfigError
593+
if !errors.As(err, &cfgErr) {
594+
t.Fatalf("expected *errs.ConfigError, got %T %v", err, err)
595+
}
596+
if cfgErr.Subtype != errs.SubtypeNotConfigured {
597+
t.Fatalf("subtype = %q, want not_configured", cfgErr.Subtype)
598+
}
599+
return cfgErr.Hint
600+
}
601+
602+
t.Run("profile domain present: hint points at profile list", func(t *testing.T) {
603+
policystate.ResetForTesting()
604+
t.Cleanup(policystate.ResetForTesting)
605+
if h := hintOf(t); !strings.Contains(h, "lark-cli profile list") {
606+
t.Errorf("hint = %q, want it to reference `lark-cli profile list`", h)
607+
}
608+
})
609+
610+
t.Run("profile domain plugin-denied: hint omitted", func(t *testing.T) {
611+
policystate.ResetForTesting()
612+
t.Cleanup(policystate.ResetForTesting)
613+
policystate.SetPluginDeniedDomains(map[string]bool{"profile": true})
614+
if h := hintOf(t); h != "" {
615+
t.Errorf("hint = %q, want empty when the profile domain is plugin-denied", h)
616+
}
617+
})
618+
}

‎cmd/config/plugins.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
8585
if len(p.Rules) > 0 {
8686
entry["rules"] = p.Rules
8787
}
88+
if p.EmbeddedSkills != nil {
89+
entry["embedded_skills"] = p.EmbeddedSkills
90+
}
8891
entry["hooks"] = map[string]any{
8992
"observers": p.Observers,
9093
"wrappers": p.Wrappers,

‎cmd/config/plugins_test.go‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package config
5+
6+
import (
7+
"bytes"
8+
"encoding/json"
9+
"testing"
10+
11+
"github.com/larksuite/cli/internal/cmdutil"
12+
internalplatform "github.com/larksuite/cli/internal/platform"
13+
)
14+
15+
// config plugins show must surface a plugin's EmbeddedSkills contribution in
16+
// the rendered JSON, not only in the internal inventory struct: this command is
17+
// the operator's window into what a fork trimmed, so the Allow/Remove/Overlay/
18+
// Base summary has to reach stdout. Guards the render layer, which asserting the
19+
// inventory struct alone does not exercise.
20+
func TestConfigPluginsShow_RendersEmbeddedSkills(t *testing.T) {
21+
internalplatform.SetActiveInventory(&internalplatform.Inventory{
22+
Plugins: []internalplatform.PluginEntry{{
23+
Name: "acme",
24+
Version: "1.0",
25+
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
26+
EmbeddedSkills: &internalplatform.SkillsOverlayView{
27+
Allow: []string{"lark-im"},
28+
Remove: []string{"lark-a"},
29+
Overlay: true,
30+
Base: true,
31+
},
32+
}},
33+
})
34+
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
35+
36+
out := &bytes.Buffer{}
37+
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
38+
if err := runConfigPluginsShow(f); err != nil {
39+
t.Fatalf("show: %v", err)
40+
}
41+
42+
var got struct {
43+
Plugins []struct {
44+
EmbeddedSkills *internalplatform.SkillsOverlayView `json:"embedded_skills"`
45+
} `json:"plugins"`
46+
}
47+
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
48+
t.Fatalf("not json: %v\n%s", err, out.String())
49+
}
50+
if len(got.Plugins) != 1 {
51+
t.Fatalf("want 1 plugin, got %d", len(got.Plugins))
52+
}
53+
es := got.Plugins[0].EmbeddedSkills
54+
if es == nil {
55+
t.Fatalf("embedded_skills missing from rendered output:\n%s", out.String())
56+
}
57+
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
58+
len(es.Remove) != 1 || es.Remove[0] != "lark-a" ||
59+
!es.Overlay || !es.Base {
60+
t.Errorf("embedded_skills summary mismatch: %+v", es)
61+
}
62+
}
63+
64+
// A plugin that did not customize embedded skills must not emit an
65+
// embedded_skills key, so the field's presence is a reliable signal that a fork
66+
// trimmed the tree.
67+
func TestConfigPluginsShow_OmitsEmbeddedSkillsWhenAbsent(t *testing.T) {
68+
internalplatform.SetActiveInventory(&internalplatform.Inventory{
69+
Plugins: []internalplatform.PluginEntry{{
70+
Name: "acme",
71+
Version: "1.0",
72+
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
73+
}},
74+
})
75+
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
76+
77+
out := &bytes.Buffer{}
78+
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
79+
if err := runConfigPluginsShow(f); err != nil {
80+
t.Fatalf("show: %v", err)
81+
}
82+
var raw map[string]any
83+
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
84+
t.Fatalf("not json: %v", err)
85+
}
86+
plugins, ok := raw["plugins"].([]any)
87+
if !ok || len(plugins) != 1 {
88+
t.Fatalf("want 1 plugin in output, got: %s", out.String())
89+
}
90+
if _, ok := plugins[0].(map[string]any)["embedded_skills"]; ok {
91+
t.Errorf("embedded_skills must be omitted when the plugin customized no skills; got:\n%s", out.String())
92+
}
93+
}

‎cmd/config/show.go‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/larksuite/cli/internal/cmdutil"
1414
"github.com/larksuite/cli/internal/core"
1515
"github.com/larksuite/cli/internal/output"
16+
"github.com/larksuite/cli/internal/policystate"
1617
"github.com/spf13/cobra"
1718
)
1819

@@ -55,7 +56,12 @@ func configShowRun(opts *ConfigShowOptions) error {
5556
}
5657
app := config.CurrentAppConfig(f.Invocation.Profile)
5758
if app == nil {
58-
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
59+
e := errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile")
60+
// No recovery hint when the profile domain is absent from this build.
61+
if !policystate.DomainDeniedByPlugin("profile") {
62+
e = e.WithHint("run: lark-cli profile list")
63+
}
64+
return e
5965
}
6066
users := "(no logged-in users)"
6167
if len(app.Users) > 0 {

0 commit comments

Comments
 (0)