mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +08:00
AI agents running inside OpenClaw / Hermes were routinely creating a parallel
app via `config init --new` instead of binding to the agent's existing app,
because every "not configured" hint and several deny errors hard-coded
`config init` regardless of workspace. Once bound, the same agents could
silently grant themselves user identity (impersonation) without the user
ever seeing a risk message in chat.
Changes:
- Introduce `core.NotConfiguredError` / `NoActiveProfileError` /
`reconfigureHint` helpers that branch on `CurrentWorkspace()`. In agent
workspaces they point at `lark-cli config bind --help` (a help page, not
a ready-to-run command) so AI must read the binding workflow and confirm
identity preset with the user before acting. In local terminals they
preserve the previous `config init --new` guidance.
- Migrate every `config init` hint that should be workspace-aware:
RequireConfigForProfile, default credential provider, credential provider
fallback, secret-resolve mismatch, config show, strict-mode entry-point
errors, default-as, profile use/rename/remove, auth list, doctor's
config_file check (which now also wraps the OS-level "no such file"
noise into the user-shaped "not configured" message).
- Refuse `config init` when run inside an OpenClaw / Hermes workspace by
default; add `--force-init` for the rare case the user genuinely wants
a parallel app. Without this guard, hint fixes were undone the moment
AI ignored them.
- Rewrite the strict-mode deny errors in cmd/auth/login.go, cmd/prune.go,
and internal/cmdutil/factory.go. The previous "AI agents are strictly
prohibited from modifying this setting" terminated AI reasoning while
providing no real gate. New errors point at `config strict-mode --help`
with the legitimate confirmation flow and explicitly note that switching
does NOT require re-bind. Integration test envelopes updated.
- Tighten `config bind --help` and `config strict-mode --help` to encode
the user-confirmation discipline directly: identity preset semantics
(bot-only vs user-default), "DO NOT switch without explicit user
confirmation", and a cross-reference clarifying that `config bind` is
for changing the underlying app while `config strict-mode` is the
policy-only switch (resolves an ambiguity an audit run found).
- Surface user-identity (impersonation) risk at every config write that
newly grants it, by reusing the canonical IdentityEscalationMessage
string from bind_messages.go:
- `noticeUserDefaultRisk` fires on flag-mode bind landing on
user-default, including the first-time case `warnIdentityEscalation`
misses (it requires a previous bot lock).
- `setStrictMode` warns when transitioning bot → user or bot → off
(newly permits user identity); stays quiet on narrowing changes
and on off → user (off already permitted user).
- Add tests: notconfigured_test.go (workspace branches),
init_guard_test.go (refuse + --force-init bypass), bind_warning_test.go
(user-default warning fires; bot-only does not), strict_mode_warning_test.go
(5 transitions covering both warn and no-warn paths).
Two follow-ups intentionally deferred: the keychain master-key hint at
internal/keychain/keychain.go:42 still suggests `config init` because the
keychain package can't import core (would be circular); fixing requires
either parameterizing the hint via callback or extracting workspace into
its own package. The lark-shared skill doc still tells AI to run
`config init` for first-time setup; updating the skill is in scope for
a follow-up PR.
Change-Id: I02273e044d9e061d211ceaa4f3ed5a3fb28325b3
382 lines
11 KiB
Go
382 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package credential
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
|
|
extcred "github.com/larksuite/cli/extension/credential"
|
|
"github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/core"
|
|
)
|
|
|
|
// DefaultAccountResolver is implemented by the default account provider.
|
|
type DefaultAccountResolver interface {
|
|
ResolveAccount(ctx context.Context) (*Account, error)
|
|
}
|
|
|
|
// DefaultTokenResolver is implemented by the default token provider.
|
|
type DefaultTokenResolver interface {
|
|
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
|
|
}
|
|
|
|
var (
|
|
getStoredToken = auth.GetStoredToken
|
|
getStoredTokenStatus = auth.TokenStatus
|
|
)
|
|
|
|
type credentialSource interface {
|
|
Name() string
|
|
TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error)
|
|
ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error)
|
|
}
|
|
|
|
type extensionTokenSource struct {
|
|
provider extcred.Provider
|
|
}
|
|
|
|
func (s extensionTokenSource) Name() string { return s.provider.Name() }
|
|
|
|
func (s extensionTokenSource) TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error) {
|
|
tok, err := s.provider.ResolveToken(ctx, extcred.TokenSpec{
|
|
Type: extcred.TokenType(req.Type.String()),
|
|
AppID: req.AppID,
|
|
})
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if tok == nil {
|
|
return nil, false, nil
|
|
}
|
|
if tok.Value == "" {
|
|
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "empty token"}
|
|
}
|
|
return &TokenResult{Token: tok.Value, Scopes: tok.Scopes}, true, nil
|
|
}
|
|
|
|
func (s extensionTokenSource) ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error) {
|
|
hint := &IdentityHint{}
|
|
if acct == nil {
|
|
return hint, nil
|
|
}
|
|
hint.DefaultAs = acct.DefaultAs
|
|
// Extension sources verify user identity via enrichUserInfo, so a resolved
|
|
// UserOpenId is sufficient here; no keychain-backed token status lookup is needed.
|
|
if acct.UserOpenId != "" {
|
|
hint.AutoAs = core.AsUser
|
|
return hint, nil
|
|
}
|
|
ids := extcred.IdentitySupport(acct.SupportedIdentities)
|
|
switch {
|
|
case ids.UserOnly():
|
|
hint.AutoAs = core.AsUser
|
|
case ids.BotOnly():
|
|
hint.AutoAs = core.AsBot
|
|
}
|
|
return hint, nil
|
|
}
|
|
|
|
type defaultTokenSource struct {
|
|
resolver DefaultTokenResolver
|
|
}
|
|
|
|
func (s defaultTokenSource) Name() string { return "default" }
|
|
|
|
func (s defaultTokenSource) TryResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, bool, error) {
|
|
if s.resolver == nil {
|
|
return nil, false, nil
|
|
}
|
|
result, err := s.resolver.ResolveToken(ctx, req)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if result == nil {
|
|
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "nil token result"}
|
|
}
|
|
if result.Token == "" {
|
|
return nil, false, &MalformedTokenResultError{Source: s.Name(), Type: req.Type, Reason: "empty token"}
|
|
}
|
|
return result, true, nil
|
|
}
|
|
|
|
func (s defaultTokenSource) ResolveIdentityHint(ctx context.Context, acct *Account) (*IdentityHint, error) {
|
|
hint := &IdentityHint{}
|
|
if acct == nil {
|
|
return hint, nil
|
|
}
|
|
hint.DefaultAs = acct.DefaultAs
|
|
if acct.UserOpenId == "" {
|
|
hint.AutoAs = core.AsBot
|
|
return hint, nil
|
|
}
|
|
stored := getStoredToken(acct.AppID, acct.UserOpenId)
|
|
if stored == nil {
|
|
hint.AutoAs = core.AsBot
|
|
return hint, nil
|
|
}
|
|
if getStoredTokenStatus(stored) == "expired" {
|
|
hint.AutoAs = core.AsBot
|
|
return hint, nil
|
|
}
|
|
hint.AutoAs = core.AsUser
|
|
return hint, nil
|
|
}
|
|
|
|
// CredentialProvider is the unified entry point for all credential resolution.
|
|
type CredentialProvider struct {
|
|
providers []extcred.Provider
|
|
defaultAcct DefaultAccountResolver
|
|
defaultToken DefaultTokenResolver
|
|
httpClient func() (*http.Client, error)
|
|
warnOut io.Writer
|
|
|
|
accountOnce sync.Once
|
|
account *Account
|
|
accountErr error
|
|
selectedSource credentialSource
|
|
|
|
hintOnce sync.Once
|
|
hint *IdentityHint
|
|
hintErr error
|
|
}
|
|
|
|
// NewCredentialProvider creates a CredentialProvider.
|
|
func NewCredentialProvider(providers []extcred.Provider, defaultAcct DefaultAccountResolver, defaultToken DefaultTokenResolver, httpClient func() (*http.Client, error)) *CredentialProvider {
|
|
return &CredentialProvider{
|
|
providers: providers,
|
|
defaultAcct: defaultAcct,
|
|
defaultToken: defaultToken,
|
|
httpClient: httpClient,
|
|
}
|
|
}
|
|
|
|
func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
|
|
p.warnOut = warnOut
|
|
return p
|
|
}
|
|
|
|
// ResolveAccount resolves app credentials. Result is cached after first call.
|
|
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
|
// Subsequent calls return the cached result regardless of their context.
|
|
// This is acceptable for CLI (single invocation per process) but not for long-running servers.
|
|
func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
|
p.accountOnce.Do(func() {
|
|
p.account, p.accountErr = p.doResolveAccount(ctx)
|
|
})
|
|
return p.account, p.accountErr
|
|
}
|
|
|
|
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
|
for _, prov := range p.providers {
|
|
acct, err := prov.ResolveAccount(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if acct != nil {
|
|
internal := convertAccount(acct)
|
|
source := extensionTokenSource{provider: prov}
|
|
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
|
if p.warnOut != nil {
|
|
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
|
}
|
|
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
|
// (used for strict mode) is already set by the provider.
|
|
// Clear unverified user identity for safety.
|
|
internal.UserOpenId = ""
|
|
internal.UserName = ""
|
|
}
|
|
p.selectedSource = source
|
|
return internal, nil
|
|
}
|
|
}
|
|
if p.defaultAcct != nil {
|
|
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
|
return acct, nil
|
|
}
|
|
return nil, core.NotConfiguredError()
|
|
}
|
|
|
|
// enrichUserInfo resolves user identity when extension provides a UAT.
|
|
// If UAT is available, user_info API call is mandatory (security: verify token validity).
|
|
// If no UAT from extension, falls back to provider-supplied OpenID.
|
|
func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account, source credentialSource) error {
|
|
if p.httpClient == nil || source == nil {
|
|
return nil
|
|
}
|
|
tok, found, err := source.TryResolveToken(ctx, TokenSpec{Type: TokenTypeUAT, AppID: acct.AppID})
|
|
if err != nil {
|
|
var blockErr *extcred.BlockError
|
|
if errors.As(err, &blockErr) {
|
|
return nil // provider explicitly blocks UAT; skip enrichment
|
|
}
|
|
return fmt.Errorf("failed to resolve UAT for user identity verification: %w", err)
|
|
}
|
|
if !found {
|
|
return nil
|
|
}
|
|
// Have UAT — must verify and resolve identity
|
|
hc, err := p.httpClient()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get HTTP client for user_info: %w", err)
|
|
}
|
|
info, err := fetchUserInfo(ctx, hc, acct.Brand, tok.Token)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to verify user identity: %w", err)
|
|
}
|
|
acct.UserOpenId = info.OpenID
|
|
acct.UserName = info.Name
|
|
return nil
|
|
}
|
|
|
|
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
|
|
if p.selectedSource != nil {
|
|
return p.selectedSource, nil
|
|
}
|
|
if p.defaultAcct == nil {
|
|
return nil, nil
|
|
}
|
|
if _, err := p.ResolveAccount(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if p.selectedSource == nil {
|
|
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
|
|
}
|
|
return p.selectedSource, nil
|
|
}
|
|
|
|
func resolveTokenFromSource(ctx context.Context, source credentialSource, req TokenSpec) (*TokenResult, error) {
|
|
result, found, err := source.TryResolveToken(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !found {
|
|
return nil, &TokenUnavailableError{Source: source.Name(), Type: req.Type}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ResolveIdentityHint resolves default/auto identity guidance from the selected source.
|
|
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
|
// This matches ResolveAccount and keeps identity decisions stable within one CLI invocation.
|
|
func (p *CredentialProvider) ResolveIdentityHint(ctx context.Context) (*IdentityHint, error) {
|
|
p.hintOnce.Do(func() {
|
|
p.hint, p.hintErr = p.doResolveIdentityHint(ctx)
|
|
})
|
|
return p.hint, p.hintErr
|
|
}
|
|
|
|
func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*IdentityHint, error) {
|
|
acct, err := p.ResolveAccount(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if acct == nil {
|
|
return &IdentityHint{}, nil
|
|
}
|
|
source, err := p.selectedCredentialSource(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if source == nil {
|
|
return &IdentityHint{}, nil
|
|
}
|
|
hint, err := source.ResolveIdentityHint(ctx, acct)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hint == nil {
|
|
return &IdentityHint{}, nil
|
|
}
|
|
return hint, nil
|
|
}
|
|
|
|
// ResolveToken resolves an access token.
|
|
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
|
source, err := p.selectedCredentialSource(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if source != nil {
|
|
return resolveTokenFromSource(ctx, source, req)
|
|
}
|
|
|
|
for _, prov := range p.providers {
|
|
source := extensionTokenSource{provider: prov}
|
|
result, found, err := source.TryResolveToken(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if found {
|
|
return result, nil
|
|
}
|
|
}
|
|
source = defaultTokenSource{resolver: p.defaultToken}
|
|
result, found, err := source.TryResolveToken(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if found {
|
|
return result, nil
|
|
}
|
|
return nil, &TokenUnavailableError{Type: req.Type}
|
|
}
|
|
|
|
// ActiveExtensionProviderName reports whether an extension provider is managing
|
|
// credentials. It probes p.providers (extension providers only, not defaultAcct)
|
|
// and returns the name of the first engaged provider.
|
|
//
|
|
// "Engaged" means: ResolveAccount returns a non-nil account, OR returns a
|
|
// *extcred.BlockError (provider configured but misconfigured — still counts as
|
|
// external). Any other error is propagated to the caller.
|
|
//
|
|
// Returns ("", nil) when no extension provider is active (built-in keychain path).
|
|
// Safe to call multiple times — probes providers directly without the sync.Once cache.
|
|
func (p *CredentialProvider) ActiveExtensionProviderName(ctx context.Context) (string, error) {
|
|
for _, prov := range p.providers {
|
|
acct, err := prov.ResolveAccount(ctx)
|
|
if err != nil {
|
|
var blockErr *extcred.BlockError
|
|
if errors.As(err, &blockErr) {
|
|
name := blockErr.Provider
|
|
if name == "" {
|
|
name = prov.Name()
|
|
}
|
|
if name == "" {
|
|
name = "external"
|
|
}
|
|
return name, nil
|
|
}
|
|
return "", err
|
|
}
|
|
if acct != nil {
|
|
if name := prov.Name(); name != "" {
|
|
return name, nil
|
|
}
|
|
return "external", nil
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func convertAccount(ext *extcred.Account) *Account {
|
|
return &Account{
|
|
AppID: ext.AppID,
|
|
AppSecret: ext.AppSecret,
|
|
Brand: core.LarkBrand(ext.Brand),
|
|
DefaultAs: core.Identity(ext.DefaultAs),
|
|
ProfileName: ext.ProfileName,
|
|
UserOpenId: ext.OpenID,
|
|
SupportedIdentities: uint8(ext.SupportedIdentities),
|
|
}
|
|
}
|