mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.
Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift
Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.
Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.
At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.
First PR in the feat/error-contract-* series.
148 lines
5.4 KiB
Go
148 lines
5.4 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// pruneForStrictMode removes commands incompatible with the active strict mode.
|
|
func pruneForStrictMode(root *cobra.Command, mode core.StrictMode) {
|
|
pruneIncompatible(root, mode)
|
|
pruneEmpty(root)
|
|
}
|
|
|
|
// pruneIncompatible recursively replaces commands whose annotation declares
|
|
// identities incompatible with the forced identity. Commands without annotation are kept.
|
|
// Hidden stubs preserve direct execution so users get a strict-mode error instead
|
|
// of Cobra's generic "unknown flag" fallback from the parent command.
|
|
func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
|
|
forced := string(mode.ForcedIdentity())
|
|
var toRemove []*cobra.Command
|
|
var toAdd []*cobra.Command
|
|
for _, child := range parent.Commands() {
|
|
ids := cmdutil.GetSupportedIdentities(child)
|
|
if ids != nil && !slices.Contains(ids, forced) {
|
|
toRemove = append(toRemove, child)
|
|
toAdd = append(toAdd, strictModeStubFrom(child, mode))
|
|
continue
|
|
}
|
|
pruneIncompatible(child, mode)
|
|
}
|
|
if len(toRemove) > 0 {
|
|
parent.RemoveCommand(toRemove...)
|
|
parent.AddCommand(toAdd...)
|
|
}
|
|
}
|
|
|
|
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
|
|
// The denial annotations let the hook layer's populateInvocationDenial
|
|
// recognise this command as denied, so the Wrap chain is physically
|
|
// isolated (wrapRunE takes the DeniedByPolicy branch and calls the
|
|
// stub RunE directly). Without these, a plugin Wrapper registered
|
|
// against platform.All() could intercept and silently swallow the
|
|
// strict-mode error -- breaking strict-mode's "hard boundary" contract.
|
|
//
|
|
// Args + PersistentPreRunE overrides mirror cmdpolicy/apply.go::installDenyStub:
|
|
//
|
|
// - Args=ArbitraryArgs: with DisableFlagParsing the user's flags
|
|
// look like positional args; the original child's Args validator
|
|
// (e.g. cobra.NoArgs) would fire BEFORE RunE and produce a
|
|
// cobra usage error instead of our strict_mode envelope.
|
|
//
|
|
// - PersistentPreRunE no-op: cmd/auth/auth.go declares a parent
|
|
// PersistentPreRunE that returns external_provider when env
|
|
// credentials are set. Cobra's "first wins walking up" would
|
|
// pick auth's instead of our denial. A leaf-level no-op makes
|
|
// cobra stop here and proceed to the wrapped RunE.
|
|
//
|
|
// strict-mode keeps its short Message + independent Hint and
|
|
// composes the shared detail.* / wrapped-CommandDeniedError shape
|
|
// by hand; BuildDenialError would override Message with the
|
|
// CommandDeniedError.Error() long form.
|
|
stubMessage := fmt.Sprintf(
|
|
"strict mode is %q, only %s-identity commands are available",
|
|
mode, mode.ForcedIdentity())
|
|
const stubHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
|
|
denial := cmdpolicy.Denial{
|
|
Layer: cmdpolicy.LayerStrictMode,
|
|
PolicySource: "strict-mode",
|
|
ReasonCode: "identity_not_supported",
|
|
Reason: stubMessage,
|
|
}
|
|
// Preserve the original command's annotations (risk_level,
|
|
// lark:supportedIdentities, cmdmeta.domain, ...) and help text so
|
|
// audit / compliance observers can still see what was denied.
|
|
// Stamp the denial annotations on top.
|
|
annotations := make(map[string]string, len(child.Annotations)+2)
|
|
for k, v := range child.Annotations {
|
|
annotations[k] = v
|
|
}
|
|
annotations[cmdpolicy.AnnotationDenialLayer] = cmdpolicy.LayerStrictMode
|
|
annotations[cmdpolicy.AnnotationDenialSource] = "strict-mode"
|
|
|
|
return &cobra.Command{
|
|
Use: child.Use,
|
|
Aliases: append([]string(nil), child.Aliases...),
|
|
Short: child.Short,
|
|
Long: child.Long,
|
|
Hidden: true,
|
|
DisableFlagParsing: true,
|
|
Args: cobra.ArbitraryArgs,
|
|
Annotations: annotations,
|
|
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
|
c.SilenceUsage = true
|
|
return nil
|
|
},
|
|
RunE: func(c *cobra.Command, _ []string) error {
|
|
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
|
// Legacy *output.ExitError producer: this literal predates the
|
|
// typed error contract introduced by errs/. New denial sites MUST
|
|
// NOT construct *output.ExitError directly — they should return a
|
|
// typed *errs.XxxError once the cmdpolicy framework migrates.
|
|
return &output.ExitError{
|
|
Code: output.ExitValidation,
|
|
Detail: &output.ErrDetail{
|
|
Type: "command_denied",
|
|
Message: stubMessage,
|
|
Hint: stubHint,
|
|
Detail: cmdpolicy.DenialDetailMap(cd),
|
|
},
|
|
Err: cd,
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
// pruneEmpty recursively removes group commands (no Run/RunE) that have
|
|
// no remaining subcommands after pruning. If only hidden stubs remain, keep
|
|
// the group hidden so direct execution still resolves to the stub path.
|
|
func pruneEmpty(parent *cobra.Command) {
|
|
var toRemove []*cobra.Command
|
|
for _, child := range parent.Commands() {
|
|
pruneEmpty(child)
|
|
if child.Run != nil || child.RunE != nil {
|
|
continue
|
|
}
|
|
switch {
|
|
case child.HasAvailableSubCommands():
|
|
case len(child.Commands()) > 0:
|
|
child.Hidden = true
|
|
default:
|
|
toRemove = append(toRemove, child)
|
|
}
|
|
}
|
|
if len(toRemove) > 0 {
|
|
parent.RemoveCommand(toRemove...)
|
|
}
|
|
}
|