mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:
- a fixed nine-category taxonomy on the wire, each mapped to a
stable shell exit code (authentication/authorization/config = 3,
network = 4, internal = 5, policy = 6, confirmation = 10)
- identity-aware detail fields (missing_scopes, requested_scopes,
granted_scopes, console_url, log_id, retryable, hint) carried
uniformly on the envelope
- a single canonical policy envelope at exit 6; the legacy
auth_error carve-out is retired
- per-subtype canonical message + hint that preserves Lark's
diagnostic phrasing and routes recovery to the right actor:
app developer (app_scope_not_applied), user (missing_scope,
token_scope_insufficient, user_unauthorized), or tenant admin
(app_unavailable, app_disabled)
- wrong app credentials classify as config/invalid_client whether
surfaced by the Open API endpoint (99991543) or the tenant
access-token mint endpoint (10003 / 10014), instead of
collapsing to a transport error or api/unknown
- local shortcut scope preflight emits the same
authorization/missing_scope envelope (identity + deterministic
missing-scope set) used by the post-call permission path, so AI
consumers read the same structured shape from precheck and from
server-returned permission denial
- streaming download/upload failures keep the same network subtype
split (timeout / TLS / DNS / transport) as the non-stream path
instead of collapsing every cause to a generic transport failure
- console_url is carried only on the bot-perspective
app_scope_not_applied envelope (where the recovery action is
"developer applies the scope at the developer console"); the
user-perspective missing_scope envelope drops the field, since
the only actionable user recovery is `lark-cli auth login --scope`
and pointing an end user at a console they cannot modify is
misleading
- bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
Type tags to wire 'config' with the original module name kept
as a metric label
All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
181 lines
6.3 KiB
Go
181 lines
6.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package config
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// NewCmdConfigStrictMode creates the "config strict-mode" subcommand.
|
|
func NewCmdConfigStrictMode(f *cmdutil.Factory) *cobra.Command {
|
|
var global bool
|
|
var reset bool
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "strict-mode [bot|user|off]",
|
|
Short: "View or set strict mode (identity restriction policy)",
|
|
Long: `View or set strict mode — the identity restriction policy.
|
|
|
|
bot only bot identity allowed (user commands hidden)
|
|
user only user identity allowed (bot commands hidden)
|
|
off no restriction (default)
|
|
|
|
No args: show current mode. Switching does NOT require re-bind.
|
|
|
|
For AI agents: this is a security policy. DO NOT switch without
|
|
explicit user confirmation — never run on your own initiative.`,
|
|
Example: ` lark-cli config strict-mode # show current
|
|
lark-cli config strict-mode user # switch (after user confirms)
|
|
lark-cli config strict-mode bot --global # set globally
|
|
lark-cli config strict-mode --reset # clear profile override`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
multi, err := core.LoadOrNotConfigured()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if reset {
|
|
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
|
if app == nil {
|
|
return core.NoActiveProfileError()
|
|
}
|
|
return resetStrictMode(f, multi, app, global, args)
|
|
}
|
|
if len(args) == 0 {
|
|
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
|
if app == nil {
|
|
return core.NoActiveProfileError()
|
|
}
|
|
return showStrictMode(cmd.Context(), f, multi, app)
|
|
}
|
|
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
|
if !global && app == nil {
|
|
return core.NoActiveProfileError()
|
|
}
|
|
return setStrictMode(f, multi, app, args[0], global)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)")
|
|
cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global")
|
|
cmdutil.SetRisk(cmd, "write")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
|
if global {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset")
|
|
}
|
|
if len(args) > 0 {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset")
|
|
}
|
|
app.StrictMode = nil
|
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
|
}
|
|
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
|
return nil
|
|
}
|
|
|
|
func showStrictMode(ctx context.Context, f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig) error {
|
|
// Runtime effective mode from credential provider chain is the source of truth.
|
|
runtime := f.ResolveStrictMode(ctx)
|
|
configMode, configSource := resolveStrictModeStatus(multi, app)
|
|
|
|
if runtime != configMode {
|
|
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: credential provider)\n", runtime)
|
|
return nil
|
|
}
|
|
fmt.Fprintf(f.IOStreams.Out, "strict-mode: %s (source: %s)\n", configMode, configSource)
|
|
return nil
|
|
}
|
|
|
|
func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, value string, global bool) error {
|
|
mode := core.StrictMode(value)
|
|
switch mode {
|
|
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
|
default:
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value)
|
|
}
|
|
|
|
// Capture the old mode at the SAME scope being changed, so we can warn
|
|
// only when the policy actually expands user-identity at that scope.
|
|
// --global → compare raw multi.StrictMode (profiles with explicit
|
|
// overrides are unaffected; their warning comes from the existing
|
|
// "profile %q has strict-mode explicitly set" notice below).
|
|
// profile → compare effective mode (override > global > default), so
|
|
// a profile flipping from inherited bot to explicit off still warns.
|
|
// The previous version always used the profile's effective mode, which
|
|
// false-positived (--global change while current profile has an explicit
|
|
// override) and false-negatived (--global broadening that doesn't affect
|
|
// the current profile but does affect other inheriting profiles).
|
|
var oldMode core.StrictMode
|
|
if global {
|
|
oldMode = multi.StrictMode
|
|
} else {
|
|
oldMode, _ = resolveStrictModeStatus(multi, app)
|
|
}
|
|
|
|
if global {
|
|
multi.StrictMode = mode
|
|
for _, a := range multi.Apps {
|
|
if a.StrictMode != nil && *a.StrictMode != mode {
|
|
fmt.Fprintf(f.IOStreams.ErrOut,
|
|
"Warning: profile %q has strict-mode explicitly set to %q, "+
|
|
"which overrides the global setting. "+
|
|
"Use --reset in that profile to inherit global.\n",
|
|
a.ProfileName(), *a.StrictMode)
|
|
}
|
|
}
|
|
} else {
|
|
if app == nil {
|
|
return core.NoActiveProfileError()
|
|
}
|
|
app.StrictMode = &mode
|
|
}
|
|
|
|
if err := core.SaveMultiAppConfig(multi); err != nil {
|
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
|
}
|
|
|
|
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
|
fmt.Fprintln(f.IOStreams.ErrOut, "⚠️ "+strictModeRelaxLang(app).IdentityEscalationMessage)
|
|
}
|
|
|
|
scope := "profile"
|
|
if global {
|
|
scope = "global"
|
|
}
|
|
fmt.Fprintf(f.IOStreams.ErrOut, "Strict mode set to %s (%s)\n", mode, scope)
|
|
return nil
|
|
}
|
|
|
|
// strictModeRelaxLang picks the bind-message bundle whose language matches the
|
|
// active profile's Lang setting. Falls back to bindMsgZh when no profile is
|
|
// available (global mutation with no current app).
|
|
func strictModeRelaxLang(app *core.AppConfig) *bindMsg {
|
|
if app != nil {
|
|
return getBindMsg(app.Lang)
|
|
}
|
|
return getBindMsg("")
|
|
}
|
|
|
|
func resolveStrictModeStatus(multi *core.MultiAppConfig, app *core.AppConfig) (core.StrictMode, string) {
|
|
if app != nil && app.StrictMode != nil {
|
|
return *app.StrictMode, fmt.Sprintf("profile %q", app.ProfileName())
|
|
}
|
|
if multi.StrictMode.IsActive() {
|
|
return multi.StrictMode, "global"
|
|
}
|
|
return core.StrictModeOff, "global (default)"
|
|
}
|