mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
refactor(cmdpolicy): pure Resolve + drop path redaction & verbose comments
- Resolve becomes a pure function; I/O moves to LoadYAMLPolicy so precedence selection can be unit-tested without vfs mocks - ActivePolicy drops YAMLPath; config policy show JSON loses yaml_path and yaml_shadowed (and the TOCTOU stat that surfaced them) - RedactHomeDir and path_test.go removed: the home-dir folding was only earning its keep through the now-deleted yaml_path field - cmd/build.go bootstrap block trimmed from 71 to 39 lines by cutting PR-rationale comments; one note kept for the fail-CLOSED-vs-fail-OPEN business rule - cmd/config/config.go: parent Long no longer hard-codes hidden command hints, matching their Hidden:true intent Change-Id: Icfbb818ce3ef523c63286bfbed34c49be08ed6a2
This commit is contained in:
39
cmd/build.go
39
cmd/build.go
@@ -133,26 +133,13 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
|
|
||||||
installUnknownSubcommandGuard(rootCmd)
|
installUnknownSubcommandGuard(rootCmd)
|
||||||
|
|
||||||
// Prune commands incompatible with strict mode.
|
|
||||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
|
||||||
pruneForStrictMode(rootCmd, mode)
|
pruneForStrictMode(rootCmd, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the platform host: install registered plugins, collecting
|
|
||||||
// their Restrict() contributions and their hooks. FailClosed
|
|
||||||
// failures (and untrusted-config failures like restricts_mismatch)
|
|
||||||
// are abort-worthy: InstallAll returns an error in those cases.
|
|
||||||
// We honour that by installing a PersistentPreRunE that emits
|
|
||||||
// the structured envelope at command-dispatch time -- buildInternal
|
|
||||||
// itself cannot return an error without breaking its assembly
|
|
||||||
// contract, but cobra runs PersistentPreRunE before any RunE so
|
|
||||||
// the user sees the error on their very next invocation.
|
|
||||||
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
|
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
|
||||||
if installErr != nil {
|
if installErr != nil {
|
||||||
installPluginInstallErrorGuard(rootCmd, installErr)
|
installPluginInstallErrorGuard(rootCmd, installErr)
|
||||||
// Stop wiring more state from a failed install -- the rest of
|
|
||||||
// the function would only matter if the CLI is allowed to
|
|
||||||
// proceed normally, which it isn't.
|
|
||||||
return f, rootCmd, nil
|
return f, rootCmd, nil
|
||||||
}
|
}
|
||||||
var pluginRules []cmdpolicy.PluginRule
|
var pluginRules []cmdpolicy.PluginRule
|
||||||
@@ -162,16 +149,9 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
registry = installResult.Registry
|
registry = installResult.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply user-layer command pruning: yaml + Plugin.Restrict.
|
// Policy errors fail-CLOSED when a plugin contributed (security
|
||||||
//
|
// intent must not be silently dropped); yaml-only errors fail-OPEN
|
||||||
// **Error policy splits by source**:
|
// with a warning so a typo can't lock the user out.
|
||||||
// - Plugin path (any pluginRules contributed): a validation or
|
|
||||||
// conflict error is a HARD failure -- the plugin author asked
|
|
||||||
// for a security policy, silently dropping it would leave the
|
|
||||||
// CLI more permissive than intended. Abort via the conflict
|
|
||||||
// guard so every command surfaces the structured envelope.
|
|
||||||
// - yaml-only path: stays fail-OPEN with a warning. A user typo
|
|
||||||
// in policy.yml must not lock them out of every command.
|
|
||||||
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
|
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
|
||||||
if len(pluginRules) > 0 {
|
if len(pluginRules) > 0 {
|
||||||
installPluginConflictGuard(rootCmd, err)
|
installPluginConflictGuard(rootCmd, err)
|
||||||
@@ -180,15 +160,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
warnPolicyError(cfg.streams.ErrOut, err)
|
warnPolicyError(cfg.streams.ErrOut, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install hooks onto the (now-pruned) command tree and emit the
|
|
||||||
// Startup lifecycle event so Plugin.On(Startup) handlers can run.
|
|
||||||
//
|
|
||||||
// Startup handler error or panic is a HARD failure: a plugin's
|
|
||||||
// Startup logic is part of its install contract, and silently
|
|
||||||
// continuing would mean the plugin's invariants do not hold while
|
|
||||||
// the rest of its hooks (Wrap / Observe) still fire. Install the
|
|
||||||
// plugin_lifecycle guard and short-circuit so every subsequent
|
|
||||||
// dispatch surfaces the envelope.
|
|
||||||
if registry != nil {
|
if registry != nil {
|
||||||
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
||||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||||
@@ -196,10 +167,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the plugin inventory so `config plugins show` can answer
|
|
||||||
// "what plugins / hooks / rules are currently in effect" without
|
|
||||||
// re-calling plugin methods at display time.
|
|
||||||
recordInventory(installResult)
|
recordInventory(installResult)
|
||||||
|
|
||||||
return f, rootCmd, registry
|
return f, rootCmd, registry
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "config",
|
Use: "config",
|
||||||
Short: "Global CLI configuration management",
|
Short: "Global CLI configuration management",
|
||||||
Long: `Global CLI configuration management.
|
|
||||||
|
|
||||||
Diagnostic (hidden) commands — runnable but omitted from --help:
|
|
||||||
lark-cli config policy show Inspect active user-layer policy
|
|
||||||
lark-cli config plugins show Inspect installed plugins and hooks`,
|
|
||||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
|
// Replicate rootCmd's PersistentPreRun behaviour: cobra stops at the first
|
||||||
// PersistentPreRun[E] found walking up the chain, so the root-level
|
// PersistentPreRun[E] found walking up the chain, so the root-level
|
||||||
|
|||||||
@@ -4,9 +4,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
@@ -14,24 +11,13 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewCmdConfigPolicy returns the `config policy` group. Subcommands:
|
|
||||||
//
|
|
||||||
// show - print the resolved user-layer Rule + source + denied count
|
|
||||||
//
|
|
||||||
// The command writes a structured JSON envelope so AI agents and CI
|
|
||||||
// integrations can parse the result.
|
|
||||||
func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
|
func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "policy",
|
Use: "policy",
|
||||||
Hidden: true, // diagnostic-only; kept callable, omitted from --help to reduce noise
|
Hidden: true,
|
||||||
Short: "Inspect the user-layer command policy",
|
Short: "Inspect the user-layer command policy",
|
||||||
// The parent `config` group has a PersistentPreRunE that calls
|
// Override parent's RequireBuiltinCredentialProvider check; this
|
||||||
// RequireBuiltinCredentialProvider, which returns external_provider
|
// group is read-only diagnostic and must work under any provider.
|
||||||
// when env credentials are set. `policy show` is a READ-ONLY
|
|
||||||
// diagnostic command and does not modify credentials, so it must
|
|
||||||
// work regardless of which credential provider is active. A
|
|
||||||
// leaf-level no-op PersistentPreRunE wins under cobra's "first
|
|
||||||
// walking up" rule and bypasses the parent check.
|
|
||||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||||
c.SilenceUsage = true
|
c.SilenceUsage = true
|
||||||
return nil
|
return nil
|
||||||
@@ -44,17 +30,8 @@ func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
|
|||||||
func newCmdConfigPolicyShow(f *cmdutil.Factory) *cobra.Command {
|
func newCmdConfigPolicyShow(f *cmdutil.Factory) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "show",
|
Use: "show",
|
||||||
Hidden: true, // diagnostic-only; kept callable, omitted from --help to reduce noise
|
Hidden: true,
|
||||||
Short: "Show the active user-layer policy (Plugin.Restrict / yaml / none)",
|
Short: "Show the active user-layer policy (plugin / yaml / none)",
|
||||||
Long: `Print the policy currently in effect after bootstrap, including:
|
|
||||||
|
|
||||||
- source: "plugin:<name>" / "yaml" / "none"
|
|
||||||
- rule: the resolved Rule (Allow / Deny / MaxRisk / Identities)
|
|
||||||
- yaml_path: the file location that was examined (informational)
|
|
||||||
- yaml_shadowed: true when a plugin Restrict overrides the yaml
|
|
||||||
|
|
||||||
A "denied_paths" count reflects the number of commands the engine
|
|
||||||
marked as denied after father-group aggregation.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runConfigPolicyShow(f)
|
return runConfigPolicyShow(f)
|
||||||
},
|
},
|
||||||
@@ -64,9 +41,6 @@ marked as denied after father-group aggregation.`,
|
|||||||
func runConfigPolicyShow(f *cmdutil.Factory) error {
|
func runConfigPolicyShow(f *cmdutil.Factory) error {
|
||||||
active := cmdpolicy.GetActive()
|
active := cmdpolicy.GetActive()
|
||||||
if active == nil {
|
if active == nil {
|
||||||
// Bootstrap not yet recorded -- happens when the command is
|
|
||||||
// invoked from a context that bypassed buildInternal (only test
|
|
||||||
// shells should hit this).
|
|
||||||
output.PrintJson(f.IOStreams.Out, map[string]any{
|
output.PrintJson(f.IOStreams.Out, map[string]any{
|
||||||
"source": string(cmdpolicy.SourceNone),
|
"source": string(cmdpolicy.SourceNone),
|
||||||
"note": "no policy recorded; bootstrap did not run pruning",
|
"note": "no policy recorded; bootstrap did not run pruning",
|
||||||
@@ -74,20 +48,13 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// `yaml_path` and the yaml-source `source_name` leak the user's home
|
sourceName := ""
|
||||||
// directory in their raw form (e.g. /Users/alice/.lark-cli/policy.yml).
|
if active.Source.Kind == cmdpolicy.SourcePlugin {
|
||||||
// `config policy show` is read by AI agents and CI logs, so we redact
|
sourceName = active.Source.Name
|
||||||
// the prefix before emitting -- same rule as policySourceLabel for
|
|
||||||
// envelopes. For plugin sources, Source.Name is the plugin name (no
|
|
||||||
// path) and is surfaced verbatim.
|
|
||||||
sourceName := active.Source.Name
|
|
||||||
if active.Source.Kind == cmdpolicy.SourceYAML {
|
|
||||||
sourceName = cmdpolicy.RedactHomeDir(sourceName)
|
|
||||||
}
|
}
|
||||||
out := map[string]any{
|
out := map[string]any{
|
||||||
"source": string(active.Source.Kind),
|
"source": string(active.Source.Kind),
|
||||||
"source_name": sourceName,
|
"source_name": sourceName,
|
||||||
"yaml_path": cmdpolicy.RedactHomeDir(active.YAMLPath),
|
|
||||||
"denied_paths": active.DeniedPaths,
|
"denied_paths": active.DeniedPaths,
|
||||||
}
|
}
|
||||||
if active.Rule != nil {
|
if active.Rule != nil {
|
||||||
@@ -101,15 +68,6 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
|
|||||||
"allow_unannotated": active.Rule.AllowUnannotated,
|
"allow_unannotated": active.Rule.AllowUnannotated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Surface the yaml-shadowed case so a user wondering "why is my
|
|
||||||
// yaml ignored?" sees it immediately.
|
|
||||||
if active.Source.Kind == cmdpolicy.SourcePlugin && active.YAMLPath != "" {
|
|
||||||
if _, err := os.Stat(active.YAMLPath); err == nil {
|
|
||||||
out["yaml_shadowed"] = true
|
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut,
|
|
||||||
"note: a plugin contributed Restrict(); yaml IGNORED")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
output.PrintJson(f.IOStreams.Out, out)
|
output.PrintJson(f.IOStreams.Out, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/platform"
|
"github.com/larksuite/cli/extension/platform"
|
||||||
@@ -48,8 +46,7 @@ func TestConfigPolicyShow_NoActivePolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// When bootstrap recorded an active plugin Rule, `show` emits the rule
|
// When bootstrap recorded an active plugin Rule, `show` emits the rule
|
||||||
// plus its source. yaml_shadowed is true when a yaml file exists but a
|
// plus its source.
|
||||||
// plugin overrode it; verified separately below.
|
|
||||||
func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||||
cmdpolicy.ResetActiveForTesting()
|
cmdpolicy.ResetActiveForTesting()
|
||||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||||
@@ -95,34 +92,39 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a yaml file exists AND a plugin Rule won, show should warn the
|
// `source_name` must be empty when source=yaml. The yaml path is
|
||||||
// user "yaml IGNORED" so they're not surprised that their yaml is
|
// deliberately not surfaced (matches engine envelope convention,
|
||||||
// inert.
|
// avoids leaking the user's home dir to AI agents / CI logs). The
|
||||||
func TestConfigPolicyShow_YamlShadowedWarning(t *testing.T) {
|
// rule's "name:" field is the disambiguator users should rely on.
|
||||||
|
func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
|
||||||
cmdpolicy.ResetActiveForTesting()
|
cmdpolicy.ResetActiveForTesting()
|
||||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
yamlPath := filepath.Join(dir, "policy.yml")
|
|
||||||
if err := os.WriteFile(yamlPath, []byte("name: shadowed\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("write yaml: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rule: &platform.Rule{Name: "plug"},
|
Rule: &platform.Rule{Name: "my-yaml-rule"},
|
||||||
Source: cmdpolicy.ResolveSource{
|
Source: cmdpolicy.ResolveSource{
|
||||||
Kind: cmdpolicy.SourcePlugin,
|
Kind: cmdpolicy.SourceYAML,
|
||||||
Name: "plug",
|
Name: "/Users/alice/.lark-cli/policy.yml",
|
||||||
},
|
},
|
||||||
YAMLPath: yamlPath,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
f, _, errOut := newPolicyTestFactory()
|
f, out, _ := newPolicyTestFactory()
|
||||||
if err := runConfigPolicyShow(f); err != nil {
|
if err := runConfigPolicyShow(f); err != nil {
|
||||||
t.Fatalf("show: %v", err)
|
t.Fatalf("show: %v", err)
|
||||||
}
|
}
|
||||||
if !bytes.Contains(errOut.Bytes(), []byte("yaml IGNORED")) {
|
var got map[string]any
|
||||||
t.Errorf("expected 'yaml IGNORED' warning, got: %q", errOut.String())
|
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("not json: %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
if got["source"] != "yaml" {
|
||||||
|
t.Errorf("source = %v, want yaml", got["source"])
|
||||||
|
}
|
||||||
|
if got["source_name"] != "" {
|
||||||
|
t.Errorf("source_name = %q, want empty (yaml path must not leak)", got["source_name"])
|
||||||
|
}
|
||||||
|
// The path must not appear anywhere in the envelope.
|
||||||
|
if bytes.Contains(out.Bytes(), []byte("/Users/alice")) {
|
||||||
|
t.Errorf("envelope leaked yaml path: %s", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
|||||||
yamlPath = ""
|
yamlPath = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
rule, source, err := cmdpolicy.Resolve(pluginRules, yamlPath)
|
yamlRule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Yaml-only failures are fail-OPEN at the caller (warn and
|
// Yaml-only failures are fail-OPEN at the caller (warn and
|
||||||
// continue), but the active-policy snapshot is process-global
|
// continue), but the active-policy snapshot is process-global
|
||||||
@@ -53,11 +53,18 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
|||||||
cmdpolicy.SetActive(nil)
|
cmdpolicy.SetActive(nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rule, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||||
|
PluginRules: pluginRules,
|
||||||
|
YAMLRule: yamlRule,
|
||||||
|
YAMLPath: yamlPath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
cmdpolicy.SetActive(nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
if rule == nil {
|
if rule == nil {
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||||
Source: source,
|
|
||||||
YAMLPath: yamlPath,
|
|
||||||
})
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +73,9 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
|||||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
|
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
|
||||||
cmdpolicy.Apply(rootCmd, denied)
|
cmdpolicy.Apply(rootCmd, denied)
|
||||||
|
|
||||||
// Record the active policy so `config policy show` can read it.
|
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rule: rule,
|
Rule: rule,
|
||||||
Source: source,
|
Source: source,
|
||||||
YAMLPath: yamlPath,
|
|
||||||
DeniedPaths: len(denied),
|
DeniedPaths: len(denied),
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ import (
|
|||||||
type ActivePolicy struct {
|
type ActivePolicy struct {
|
||||||
Rule *platform.Rule
|
Rule *platform.Rule
|
||||||
Source ResolveSource
|
Source ResolveSource
|
||||||
YAMLPath string // path examined, populated even when yaml was shadowed by a plugin Rule
|
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
||||||
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -58,8 +57,8 @@ func GetActive() *ActivePolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cloneActivePolicy deep-copies the top-level struct plus the embedded
|
// cloneActivePolicy deep-copies the top-level struct plus the embedded
|
||||||
// Rule's slice fields. Other fields (Source, YAMLPath, DeniedPaths)
|
// Rule's slice fields. Other fields (Source, DeniedPaths) are value
|
||||||
// are value types so the struct copy already disjoints them.
|
// types so the struct copy already disjoints them.
|
||||||
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
|
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -4,28 +4,15 @@
|
|||||||
package cmdpolicy
|
package cmdpolicy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// CanonicalPath returns the rootless slash-separated path used everywhere in
|
// CanonicalPath returns the rootless slash-separated path used everywhere in
|
||||||
// the pruning framework. Cobra's CommandPath() yields space-separated
|
// the pruning framework. Cobra's CommandPath() yields space-separated
|
||||||
// segments ("lark-cli docs +update"); doublestar globs ("docs/**") require
|
// segments ("lark-cli docs +update"); doublestar globs ("docs/**") require
|
||||||
// slashes, so all internal lookups go through this conversion.
|
// slashes, so all internal lookups go through this conversion.
|
||||||
//
|
|
||||||
// Algorithm:
|
|
||||||
//
|
|
||||||
// 1. Collect cmd.Use first words from the command up to (but not including)
|
|
||||||
// the root, in reverse order.
|
|
||||||
// 2. Reverse the collection and join with "/".
|
|
||||||
//
|
|
||||||
// The root (the binary's own command, no parent) is stripped. For a command
|
|
||||||
// with no parent, the returned path is just its own Use word.
|
|
||||||
func CanonicalPath(cmd *cobra.Command) string {
|
func CanonicalPath(cmd *cobra.Command) string {
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -34,19 +21,15 @@ func CanonicalPath(cmd *cobra.Command) string {
|
|||||||
for c := cmd; c != nil && c.HasParent(); c = c.Parent() {
|
for c := cmd; c != nil && c.HasParent(); c = c.Parent() {
|
||||||
parts = append(parts, useName(c))
|
parts = append(parts, useName(c))
|
||||||
}
|
}
|
||||||
// reverse
|
|
||||||
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
|
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
|
||||||
parts[i], parts[j] = parts[j], parts[i]
|
parts[i], parts[j] = parts[j], parts[i]
|
||||||
}
|
}
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
// orphan command -- return its own name so callers still see
|
|
||||||
// something stable.
|
|
||||||
return useName(cmd)
|
return useName(cmd)
|
||||||
}
|
}
|
||||||
return strings.Join(parts, "/")
|
return strings.Join(parts, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// useName extracts the first word of cmd.Use ("update [flags] <doc>" -> "update").
|
|
||||||
func useName(cmd *cobra.Command) string {
|
func useName(cmd *cobra.Command) string {
|
||||||
name := cmd.Use
|
name := cmd.Use
|
||||||
if i := strings.IndexByte(name, ' '); i >= 0 {
|
if i := strings.IndexByte(name, ' '); i >= 0 {
|
||||||
@@ -54,73 +37,3 @@ func useName(cmd *cobra.Command) string {
|
|||||||
}
|
}
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// RedactHomeDir collapses environment-rooted prefixes so path strings
|
|
||||||
// can be safely surfaced through `config policy show` and resolver
|
|
||||||
// error messages without leaking the user's filesystem layout to AI
|
|
||||||
// agents / CI logs.
|
|
||||||
//
|
|
||||||
// It folds, in priority order:
|
|
||||||
// 1. core.GetBaseConfigDir() (typically ~/.lark-cli, or a custom
|
|
||||||
// directory under LARKSUITE_CLI_CONFIG_DIR — e.g.
|
|
||||||
// "/private/tmp/sandbox/.lark-cli" in a sandboxed run) → "<config>"
|
|
||||||
// 2. The user's home directory → "~"
|
|
||||||
//
|
|
||||||
// (1) runs first so a `LARKSUITE_CLI_CONFIG_DIR` pointing outside `$HOME`
|
|
||||||
// still produces a stable, non-identifying label. When neither prefix
|
|
||||||
// matches, the input is returned unchanged — those cases don't leak
|
|
||||||
// anything that wasn't already passed in by the caller.
|
|
||||||
//
|
|
||||||
// The implementation operates on the cleaned strings (no
|
|
||||||
// `filepath.Abs`) because the depguard / forbidigo lint policy bans
|
|
||||||
// direct filesystem access from internal/. All real call sites pass
|
|
||||||
// already-absolute paths (`core.GetBaseConfigDir()` returns absolute
|
|
||||||
// when LARKSUITE_CLI_CONFIG_DIR or $HOME is set; resolver builds
|
|
||||||
// yamlPath via filepath.Join on that absolute root). A relative input
|
|
||||||
// simply falls through the prefix checks and is returned unchanged.
|
|
||||||
func RedactHomeDir(path string) string {
|
|
||||||
if path == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
clean := filepath.Clean(path)
|
|
||||||
|
|
||||||
if rel, ok := foldPrefix(clean, core.GetBaseConfigDir()); ok {
|
|
||||||
if rel == "" {
|
|
||||||
return "<config>"
|
|
||||||
}
|
|
||||||
return "<config>/" + rel
|
|
||||||
}
|
|
||||||
|
|
||||||
home, err := vfs.UserHomeDir()
|
|
||||||
if err != nil || home == "" {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
if rel, ok := foldPrefix(clean, home); ok {
|
|
||||||
if rel == "" {
|
|
||||||
return "~"
|
|
||||||
}
|
|
||||||
return "~/" + rel
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// foldPrefix reports whether path lives at or beneath prefix; on hit
|
|
||||||
// it returns the slash-form relative tail (empty when path == prefix).
|
|
||||||
// filepath.Rel itself rejects the relative-vs-absolute mismatch case
|
|
||||||
// with an error, so a relative input against an absolute prefix (or
|
|
||||||
// vice versa) falls through to the "not a hit" branch — no extra
|
|
||||||
// validation needed.
|
|
||||||
func foldPrefix(path, prefix string) (string, bool) {
|
|
||||||
if prefix == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
cleanPrefix := filepath.Clean(prefix)
|
|
||||||
rel, err := filepath.Rel(cleanPrefix, path)
|
|
||||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if rel == "." {
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
return filepath.ToSlash(rel), true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package cmdpolicy_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RedactHomeDir folds two prefixes:
|
|
||||||
//
|
|
||||||
// 1. core.GetBaseConfigDir() → "<config>" (covers the
|
|
||||||
// LARKSUITE_CLI_CONFIG_DIR override, which is the only way a real
|
|
||||||
// deployment writes the policy file outside $HOME).
|
|
||||||
// 2. The user's home dir → "~" (catches the conventional
|
|
||||||
// ~/.lark-cli/policy.yml path when no override is set).
|
|
||||||
//
|
|
||||||
// Both folds run in path-prefix space (not string-prefix), so a path
|
|
||||||
// like "/Usersfoo" never gets folded against "/Users".
|
|
||||||
func TestRedactHomeDir_foldsConfigDirOverride(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
|
||||||
|
|
||||||
policyPath := filepath.Join(tmp, "policy.yml")
|
|
||||||
got := cmdpolicy.RedactHomeDir(policyPath)
|
|
||||||
if got != "<config>/policy.yml" {
|
|
||||||
t.Errorf("override path = %q, want <config>/policy.yml", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A path that equals the config dir itself collapses to "<config>".
|
|
||||||
if got := cmdpolicy.RedactHomeDir(tmp); got != "<config>" {
|
|
||||||
t.Errorf("exact-prefix path = %q, want <config>", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A path outside both the config dir and $HOME stays absolute. This is
|
|
||||||
// the "no leak introduced" property: redaction never invents a label
|
|
||||||
// for something it doesn't recognise.
|
|
||||||
func TestRedactHomeDir_unrelatedPathUnchanged(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "/var/lib/lark-cli")
|
|
||||||
|
|
||||||
path := "/etc/random/file.yml"
|
|
||||||
if got := cmdpolicy.RedactHomeDir(path); got != path {
|
|
||||||
t.Errorf("unrelated path = %q, want %q (unchanged)", got, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty input round-trips. Callers (e.g. `config policy show` with
|
|
||||||
// no yaml configured) rely on this.
|
|
||||||
func TestRedactHomeDir_emptyStays(t *testing.T) {
|
|
||||||
if got := cmdpolicy.RedactHomeDir(""); got != "" {
|
|
||||||
t.Errorf("empty input = %q, want empty string", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,9 +13,6 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SourceKind describes which source contributed the active Rule. Surfaced
|
|
||||||
// by `config policy show` so users can tell at a glance whether their yaml
|
|
||||||
// is being shadowed by a plugin.
|
|
||||||
type SourceKind string
|
type SourceKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -24,88 +21,72 @@ const (
|
|||||||
SourceNone SourceKind = "none"
|
SourceNone SourceKind = "none"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResolveSource is the metadata about which rule won.
|
|
||||||
type ResolveSource struct {
|
type ResolveSource struct {
|
||||||
Kind SourceKind
|
Kind SourceKind
|
||||||
Name string // plugin name when Kind=plugin; file path when Kind=yaml; "" otherwise
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PluginRule represents a single Restrict() contribution. The Hook surface
|
|
||||||
// (next milestone) will collect these via Plugin.Install -> r.Restrict; for
|
|
||||||
// now the package consumer (Bootstrap pipeline) just hands in the slice.
|
|
||||||
//
|
|
||||||
// More than one entry is a configuration error (single-rule policy) -- the
|
|
||||||
// resolver reports it as a typed error so the bootstrap can abort.
|
|
||||||
type PluginRule struct {
|
type PluginRule struct {
|
||||||
PluginName string
|
PluginName string
|
||||||
Rule *platform.Rule
|
Rule *platform.Rule
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrMultipleRestricts is returned when 2+ plugins both contribute a Rule.
|
type Sources struct {
|
||||||
// The bootstrap pipeline must treat this as fail-closed (start-up abort);
|
PluginRules []PluginRule
|
||||||
// resolving by silent priority would mask a configuration mistake.
|
YAMLRule *platform.Rule
|
||||||
|
YAMLPath string
|
||||||
|
}
|
||||||
|
|
||||||
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one is permitted")
|
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one is permitted")
|
||||||
|
|
||||||
// Resolve picks the active Rule from the configured sources. Precedence:
|
// Resolve picks by precedence: plugin > yaml > none. Pure function; load
|
||||||
//
|
// yaml via LoadYAMLPolicy first. Winner is validated.
|
||||||
// plugin contribution > yaml file at yamlPath > no rule
|
func Resolve(s Sources) (*platform.Rule, ResolveSource, error) {
|
||||||
//
|
if len(s.PluginRules) > 1 {
|
||||||
// pluginRules may be nil/empty. yamlPath may be "" (skip yaml).
|
names := make([]string, len(s.PluginRules))
|
||||||
//
|
for i, pr := range s.PluginRules {
|
||||||
// The chosen Rule is validated through ValidateRule before being returned
|
|
||||||
// -- bad MaxRisk strings, malformed globs, or unknown identities all
|
|
||||||
// abort the resolve with a typed error so the bootstrap pipeline can
|
|
||||||
// honour the plugin's FailurePolicy. A typo in a policy plugin must
|
|
||||||
// never silently fail-open by reaching the engine.
|
|
||||||
//
|
|
||||||
// The returned Rule pointer is owned by the caller; resolver does not
|
|
||||||
// retain a reference.
|
|
||||||
func Resolve(pluginRules []PluginRule, yamlPath string) (*platform.Rule, ResolveSource, error) {
|
|
||||||
switch len(pluginRules) {
|
|
||||||
case 0:
|
|
||||||
// fall through to yaml
|
|
||||||
case 1:
|
|
||||||
rule := pluginRules[0].Rule
|
|
||||||
if err := ValidateRule(rule); err != nil {
|
|
||||||
return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pluginRules[0].PluginName, err)
|
|
||||||
}
|
|
||||||
return rule, ResolveSource{Kind: SourcePlugin, Name: pluginRules[0].PluginName}, nil
|
|
||||||
default:
|
|
||||||
names := make([]string, len(pluginRules))
|
|
||||||
for i, pr := range pluginRules {
|
|
||||||
names[i] = pr.PluginName
|
names[i] = pr.PluginName
|
||||||
}
|
}
|
||||||
return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, names)
|
return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, names)
|
||||||
}
|
}
|
||||||
|
|
||||||
if yamlPath != "" {
|
if len(s.PluginRules) == 1 {
|
||||||
// vfs.Stat lets callers swap in an in-memory FS for tests. The
|
pr := s.PluginRules[0]
|
||||||
// errors here surface as typed os.ErrNotExist when the file is
|
if err := ValidateRule(pr.Rule); err != nil {
|
||||||
// absent, just like a direct os.ReadFile call would.
|
return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err)
|
||||||
//
|
|
||||||
// Error messages use the home-dir-redacted form so the user's
|
|
||||||
// absolute path doesn't reach agents / CI logs through the
|
|
||||||
// warnPolicyError stderr line.
|
|
||||||
display := RedactHomeDir(yamlPath)
|
|
||||||
if _, err := vfs.Stat(yamlPath); err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, ResolveSource{Kind: SourceNone}, nil
|
|
||||||
}
|
|
||||||
return nil, ResolveSource{}, fmt.Errorf("stat policy yaml %q: %w", display, err)
|
|
||||||
}
|
}
|
||||||
data, err := vfs.ReadFile(yamlPath)
|
return pr.Rule, ResolveSource{Kind: SourcePlugin, Name: pr.PluginName}, nil
|
||||||
if err != nil {
|
}
|
||||||
return nil, ResolveSource{}, fmt.Errorf("read policy yaml %q: %w", display, err)
|
|
||||||
|
if s.YAMLRule != nil {
|
||||||
|
if err := ValidateRule(s.YAMLRule); err != nil {
|
||||||
|
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err)
|
||||||
}
|
}
|
||||||
rule, err := pyaml.Parse(data)
|
return s.YAMLRule, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil
|
||||||
if err != nil {
|
|
||||||
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", display, err)
|
|
||||||
}
|
|
||||||
if err := ValidateRule(rule); err != nil {
|
|
||||||
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", display, err)
|
|
||||||
}
|
|
||||||
return rule, ResolveSource{Kind: SourceYAML, Name: yamlPath}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ResolveSource{Kind: SourceNone}, nil
|
return nil, ResolveSource{Kind: SourceNone}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadYAMLPolicy returns (nil, nil) when path is empty or file is absent,
|
||||||
|
// so callers can pass the result straight into Sources.YAMLRule.
|
||||||
|
func LoadYAMLPolicy(path string) (*platform.Rule, error) {
|
||||||
|
if path == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if _, err := vfs.Stat(path); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("stat policy yaml %q: %w", path, err)
|
||||||
|
}
|
||||||
|
data, err := vfs.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read policy yaml %q: %w", path, err)
|
||||||
|
}
|
||||||
|
rule, err := pyaml.Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("policy yaml %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import (
|
|||||||
|
|
||||||
func TestResolve_singlePluginWins(t *testing.T) {
|
func TestResolve_singlePluginWins(t *testing.T) {
|
||||||
rule := &platform.Rule{Name: "secaudit"}
|
rule := &platform.Rule{Name: "secaudit"}
|
||||||
got, src, err := cmdpolicy.Resolve([]cmdpolicy.PluginRule{
|
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||||
{PluginName: "secaudit", Rule: rule},
|
PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: rule}},
|
||||||
}, "")
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve err: %v", err)
|
t.Fatalf("Resolve err: %v", err)
|
||||||
}
|
}
|
||||||
@@ -27,17 +27,13 @@ func TestResolve_singlePluginWins(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestResolve_pluginShadowsYaml(t *testing.T) {
|
func TestResolve_pluginShadowsYaml(t *testing.T) {
|
||||||
dir := t.TempDir()
|
|
||||||
yamlPath := filepath.Join(dir, "policy.yml")
|
|
||||||
if err := os.WriteFile(yamlPath, []byte("name: from-yaml\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("write yaml: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pluginRule := &platform.Rule{Name: "from-plugin"}
|
pluginRule := &platform.Rule{Name: "from-plugin"}
|
||||||
got, src, err := cmdpolicy.Resolve(
|
yamlRule := &platform.Rule{Name: "from-yaml"}
|
||||||
[]cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}},
|
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||||
yamlPath,
|
PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}},
|
||||||
)
|
YAMLRule: yamlRule,
|
||||||
|
YAMLPath: "/some/policy.yml",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve err: %v", err)
|
t.Fatalf("Resolve err: %v", err)
|
||||||
}
|
}
|
||||||
@@ -47,49 +43,24 @@ func TestResolve_pluginShadowsYaml(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestResolve_yamlWhenNoPlugin(t *testing.T) {
|
func TestResolve_yamlWhenNoPlugin(t *testing.T) {
|
||||||
dir := t.TempDir()
|
yamlRule := &platform.Rule{Name: "from-yaml", MaxRisk: "read"}
|
||||||
yamlPath := filepath.Join(dir, "policy.yml")
|
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||||
if err := os.WriteFile(yamlPath, []byte("name: from-yaml\nmax_risk: read\n"), 0o644); err != nil {
|
YAMLRule: yamlRule,
|
||||||
t.Fatalf("write yaml: %v", err)
|
YAMLPath: "/some/policy.yml",
|
||||||
}
|
})
|
||||||
|
|
||||||
got, src, err := cmdpolicy.Resolve(nil, yamlPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve err: %v", err)
|
t.Fatalf("Resolve err: %v", err)
|
||||||
}
|
}
|
||||||
if got.Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML {
|
if got.Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML {
|
||||||
t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src)
|
t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src)
|
||||||
}
|
}
|
||||||
}
|
if src.Name != "/some/policy.yml" {
|
||||||
|
t.Errorf("yaml source Name should carry path, got %q", src.Name)
|
||||||
func TestResolve_missingYamlIsNoRule(t *testing.T) {
|
|
||||||
// A guaranteed-missing path under t.TempDir() keeps the test
|
|
||||||
// hermetic — a stray `/nonexistent/policy.yml` could in principle
|
|
||||||
// exist on some sandbox runners and make the assertion misleading.
|
|
||||||
missing := filepath.Join(t.TempDir(), "absent-policy.yml")
|
|
||||||
got, src, err := cmdpolicy.Resolve(nil, missing)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("missing yaml should not error, got %v", err)
|
|
||||||
}
|
|
||||||
if got != nil || src.Kind != cmdpolicy.SourceNone {
|
|
||||||
t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two plugins both contributing a Rule must produce the typed error so the
|
|
||||||
// bootstrap pipeline aborts (hard-constraint #7).
|
|
||||||
func TestResolve_multipleRestrictIsError(t *testing.T) {
|
|
||||||
_, _, err := cmdpolicy.Resolve([]cmdpolicy.PluginRule{
|
|
||||||
{PluginName: "a", Rule: &platform.Rule{Name: "a"}},
|
|
||||||
{PluginName: "b", Rule: &platform.Rule{Name: "b"}},
|
|
||||||
}, "")
|
|
||||||
if !errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
|
||||||
t.Fatalf("err = %v, want ErrMultipleRestricts", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolve_emptyEverythingIsNone(t *testing.T) {
|
func TestResolve_emptyEverythingIsNone(t *testing.T) {
|
||||||
got, src, err := cmdpolicy.Resolve(nil, "")
|
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve err: %v", err)
|
t.Fatalf("Resolve err: %v", err)
|
||||||
}
|
}
|
||||||
@@ -97,3 +68,56 @@ func TestResolve_emptyEverythingIsNone(t *testing.T) {
|
|||||||
t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src)
|
t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two plugins both contributing a Rule must produce the typed error so
|
||||||
|
// the bootstrap pipeline aborts (hard-constraint #7).
|
||||||
|
func TestResolve_multipleRestrictIsError(t *testing.T) {
|
||||||
|
_, _, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||||
|
PluginRules: []cmdpolicy.PluginRule{
|
||||||
|
{PluginName: "a", Rule: &platform.Rule{Name: "a"}},
|
||||||
|
{PluginName: "b", Rule: &platform.Rule{Name: "b"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
|
||||||
|
t.Fatalf("err = %v, want ErrMultipleRestricts", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadYAMLPolicy: missing file returns (nil, nil) silently so callers
|
||||||
|
// can pass the result straight into Sources.YAMLRule without special-
|
||||||
|
// casing not-exist.
|
||||||
|
func TestLoadYAMLPolicy_missingIsSilent(t *testing.T) {
|
||||||
|
missing := filepath.Join(t.TempDir(), "absent-policy.yml")
|
||||||
|
rule, err := cmdpolicy.LoadYAMLPolicy(missing)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("missing yaml should not error, got %v", err)
|
||||||
|
}
|
||||||
|
if rule != nil {
|
||||||
|
t.Fatalf("missing yaml should return nil rule, got %+v", rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadYAMLPolicy_emptyPathIsNoop(t *testing.T) {
|
||||||
|
rule, err := cmdpolicy.LoadYAMLPolicy("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty path should not error, got %v", err)
|
||||||
|
}
|
||||||
|
if rule != nil {
|
||||||
|
t.Fatalf("empty path should return nil rule, got %+v", rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadYAMLPolicy_parsesValid(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
yamlPath := filepath.Join(dir, "policy.yml")
|
||||||
|
if err := os.WriteFile(yamlPath, []byte("name: from-yaml\nmax_risk: read\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write yaml: %v", err)
|
||||||
|
}
|
||||||
|
rule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadYAMLPolicy err: %v", err)
|
||||||
|
}
|
||||||
|
if rule == nil || rule.Name != "from-yaml" {
|
||||||
|
t.Fatalf("expected rule with name=from-yaml, got %+v", rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user