Files
larksuite-cli/cmd/platform_bootstrap.go
zhaoyukun.yk aeaed0884e feat(extension): present restricted commands as absent and trim skills
Integrators that build their own lark-cli distribution can now ship a
package where everything they cut out truly disappears, instead of
lingering as half-disabled stubs that mislead users and agents.

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

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

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

Users who write their own ~/.lark-cli/policy.yml see no change: their
denials stay visible and keep explaining how to adjust the policy they
own. Default builds are byte-for-byte unchanged.
2026-07-11 20:24:01 +08:00

345 lines
12 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"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"
)
// userPolicyFileName is the conventional filename for the user-layer Rule.
// Lives under ~/.lark-cli/ to match the rest of the CLI's user-state
// directory.
const userPolicyFileName = "policy.yml"
// applyUserPolicyPruning resolves the user-layer Rule from plugin
// contributions and/or ~/.lark-cli/policy.yml and installs denyStubs
// for commands it rejects.
//
// Missing yaml is not an error -- the CLI runs with no user-layer
// restriction. A malformed Rule (bad MaxRisk enum, malformed glob, etc.)
// surfaces via the returned error; the caller decides how to handle it.
//
// 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)
// 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
// error once a plugin is present, so reading a malformed yaml here
// would let an unrelated broken file on the user's machine abort a
// plugin-governed binary -- exactly the file the plugin is supposed
// to shadow. Skipping the read keeps the shadow contract honest.
var (
yamlRules []*platform.Rule
yamlPath string
)
if len(pluginRules) == 0 {
p, perr := userPolicyPath()
if perr != nil {
// No user home dir means we cannot locate the policy. Treat
// the same as "file missing": no pruning, no error. This keeps
// non-interactive CI environments (no HOME set) running.
p = ""
}
yamlPath = p
loaded, lerr := cmdpolicy.LoadYAMLPolicy(yamlPath)
if lerr != nil {
// Yaml-only failures are fail-OPEN at the caller (warn and
// continue), but the active-policy snapshot is process-global
// and may still carry data from a previous build in long-lived
// embedders / tests. Clear it explicitly so `config policy
// show` reports "no policy" instead of a stale rule that
// doesn't reflect the current command tree.
cmdpolicy.SetActive(nil)
return nil, lerr
}
yamlRules = loaded
}
rules, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: pluginRules,
YAMLRules: yamlRules,
YAMLPath: yamlPath,
})
if err != nil {
cmdpolicy.SetActive(nil)
return nil, err
}
if len(rules) == 0 {
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
return nil, nil
}
// RuleName attributes a denial to a specific rule in the envelope.
// With a single rule that is unambiguous and preserves the legacy
// envelope verbatim; with several rules a denial means "no rule
// granted it", which has no single owner, so the field is left empty
// and reason_code=no_matching_rule carries the meaning instead.
ruleName := ""
if len(rules) == 1 {
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)
cmdpolicy.Apply(rootCmd, denied)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: rules,
Source: source,
DeniedPaths: len(denied),
DeniedByPath: 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
}
// installPluginsAndHooks runs the InstallAll phase on the globally-
// registered plugins, returning the Plugin.Restrict contributions for
// cmdpolicy and the populated hook.Registry for the runtime wrapper.
// Errors from FailClosed plugins propagate; FailOpen failures are
// warned to errOut and the loop continues.
func installPluginsAndHooks(errOut io.Writer) (*internalplatform.InstallResult, error) {
plugins := platform.RegisteredPlugins()
if len(plugins) == 0 {
return &internalplatform.InstallResult{Registry: nil}, nil
}
return internalplatform.InstallAll(plugins, errOut)
}
// recordInventory builds and stores the plugin inventory snapshot for
// diagnostic commands (config plugins show) to read at runtime. Called
// once from build.go after applyUserPolicyPruning + wireHooks succeed.
func recordInventory(installResult *internalplatform.InstallResult) {
if installResult == nil {
internalplatform.SetActiveInventory(nil)
return
}
pluginSrcs := make([]internalplatform.PluginInventorySource, 0, len(installResult.Plugins))
for _, p := range installResult.Plugins {
pluginSrcs = append(pluginSrcs, internalplatform.PluginInventorySource{
Name: p.Name,
Version: p.Version,
Capabilities: p.Capabilities,
})
}
ruleSrcs := make([]internalplatform.RuleInventorySource, 0, len(installResult.PluginRules))
for _, r := range installResult.PluginRules {
if r.Rule == nil {
continue
}
idents := make([]string, len(r.Rule.Identities))
for i, id := range r.Rule.Identities {
idents[i] = string(id)
}
ruleSrcs = append(ruleSrcs, internalplatform.RuleInventorySource{
PluginName: r.PluginName,
Allow: r.Rule.Allow,
Deny: r.Rule.Deny,
MaxRisk: string(r.Rule.MaxRisk),
Identities: idents,
RuleName: r.Rule.Name,
Desc: r.Rule.Description,
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))
}
// wireHooks installs Observer/Wrapper hooks onto every runnable command
// and emits the Startup lifecycle event. The registry may be nil when
// no plugin contributed any hook -- the function short-circuits in
// that case to avoid useless RunE wrapping.
func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry) error {
if reg == nil {
return nil
}
hook.Install(rootCmd, reg, cobraCommandViewSource{})
return hook.Emit(ctx, reg, platform.Startup, nil)
}
// cobraCommandViewSource is the default CommandViewSource: it returns a
// live view over the *cobra.Command. Strict-mode's Remove+Add stub
// (cmd/prune.go::strictModeStubFrom) explicitly forwards the original
// annotations + Short/Long so the live view keeps reporting Risk /
// Identities / Domain through the replacement. User-layer policy
// (cmdpolicy/apply.go::installDenyStub) mutates in place, preserving
// metadata trivially.
type cobraCommandViewSource struct{}
func (cobraCommandViewSource) View(cmd *cobra.Command) platform.CommandView {
return cobraCommandView{cmd: cmd}
}
// cobraCommandView adapts *cobra.Command to the CommandView interface.
type cobraCommandView struct {
cmd *cobra.Command
}
func (v cobraCommandView) Path() string {
return cmdpolicy.CanonicalPath(v.cmd)
}
func (v cobraCommandView) Domain() string {
for c := v.cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if v, ok := c.Annotations["cmdmeta.domain"]; ok && v != "" {
return v
}
}
return ""
}
func (v cobraCommandView) Risk() (platform.Risk, bool) {
for c := v.cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if r, ok := c.Annotations["risk_level"]; ok && r != "" {
return platform.Risk(r), true
}
}
return "", false
}
func (v cobraCommandView) Identities() []platform.Identity {
for c := v.cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if raw, ok := c.Annotations["lark:supportedIdentities"]; ok && raw != "" {
parts := splitCSV(raw)
out := make([]platform.Identity, len(parts))
for i, p := range parts {
out[i] = platform.Identity(p)
}
return out
}
}
return nil
}
func (v cobraCommandView) Annotation(key string) (string, bool) {
if v.cmd.Annotations == nil {
return "", false
}
s, ok := v.cmd.Annotations[key]
return s, ok
}
// splitCSV is a tiny csv-without-quotes helper. The
// lark:supportedIdentities annotation is always plain
// "user" / "bot" / "user,bot" without escaping.
func splitCSV(s string) []string {
out := []string{}
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ',' {
out = append(out, s[start:i])
start = i + 1
}
}
out = append(out, s[start:])
return out
}
// userPolicyPath returns the path of <baseConfigDir>/policy.yml.
//
// The base directory honours LARKSUITE_CLI_CONFIG_DIR (via
// core.GetBaseConfigDir) so that test isolation, container deployments
// and per-Agent config overrides all see a consistent policy location.
// Using vfs.UserHomeDir directly here would silently bypass the env
// override and route every test through the real ~/.lark-cli.
//
// The error return is retained for caller compatibility but is always
// nil today: GetBaseConfigDir falls back to a relative ".lark-cli" when
// the home dir can't be resolved, and the resolver already treats a
// missing file as "no policy".
func userPolicyPath() (string, error) {
return filepath.Join(core.GetBaseConfigDir(), userPolicyFileName), nil
}
// warnPolicyError writes a one-line stderr warning when the user policy
// fails to load. V1 yaml errors are fail-OPEN -- the CLI keeps running
// without policy enforcement so the user can fix the typo. Plugin-supplied
// rules are fail-CLOSED instead because integrators take a code-level
// responsibility for them.
//
// Wrapped errors may carry the absolute policy path (os.PathError); fold
// the home prefix to "~" before emitting so stderr piped into agents /
// CI logs does not leak the user's home directory.
func warnPolicyError(errOut io.Writer, err error) {
if err == nil {
return
}
fmt.Fprintf(errOut, "warning: user policy not applied: %s\n", redactHome(err.Error()))
}
func redactHome(s string) string {
if home, err := vfs.UserHomeDir(); err == nil && home != "" {
s = strings.ReplaceAll(s, home, "~")
}
return s
}