mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +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
177 lines
5.1 KiB
Go
177 lines
5.1 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package credential
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
|
|
"github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/keychain"
|
|
|
|
extcred "github.com/larksuite/cli/extension/credential"
|
|
)
|
|
|
|
// DefaultAccountProvider resolves account from config.json via keychain.
|
|
type DefaultAccountProvider struct {
|
|
keychain func() keychain.KeychainAccess
|
|
profile string
|
|
}
|
|
|
|
func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string) *DefaultAccountProvider {
|
|
if kc == nil {
|
|
kc = keychain.Default
|
|
}
|
|
return &DefaultAccountProvider{keychain: kc, profile: profile}
|
|
}
|
|
|
|
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
|
// Load config once — used for both credentials and strict mode.
|
|
multi, err := core.LoadMultiAppConfig()
|
|
if err != nil {
|
|
return nil, core.NotConfiguredError()
|
|
}
|
|
|
|
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg.SupportedIdentities = strictModeToIdentitySupport(multi, p.profile)
|
|
return AccountFromCliConfig(cfg), nil
|
|
}
|
|
|
|
// strictModeToIdentitySupport maps the config-level strict mode to
|
|
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
|
|
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {
|
|
app := multi.CurrentAppConfig(profileOverride)
|
|
var mode core.StrictMode
|
|
if app != nil && app.StrictMode != nil {
|
|
mode = *app.StrictMode
|
|
} else {
|
|
mode = multi.StrictMode
|
|
}
|
|
switch mode {
|
|
case core.StrictModeBot:
|
|
return uint8(extcred.SupportsBot)
|
|
case core.StrictModeUser:
|
|
return uint8(extcred.SupportsUser)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// DefaultTokenProvider resolves UAT/TAT using keychain + direct HTTP calls.
|
|
// No SDK/LarkClient dependency — eliminates circular dependency with Factory.
|
|
type DefaultTokenProvider struct {
|
|
defaultAcct *DefaultAccountProvider
|
|
httpClient func() (*http.Client, error)
|
|
errOut io.Writer
|
|
|
|
tatOnce sync.Once
|
|
tatResult *TokenResult
|
|
tatErr error
|
|
}
|
|
|
|
func NewDefaultTokenProvider(defaultAcct *DefaultAccountProvider, httpClient func() (*http.Client, error), errOut io.Writer) *DefaultTokenProvider {
|
|
return &DefaultTokenProvider{defaultAcct: defaultAcct, httpClient: httpClient, errOut: errOut}
|
|
}
|
|
|
|
func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
|
switch req.Type {
|
|
case TokenTypeUAT:
|
|
return p.resolveUAT(ctx)
|
|
case TokenTypeTAT:
|
|
return p.resolveTAT(ctx)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported token type: %s", req.Type)
|
|
}
|
|
}
|
|
|
|
// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT
|
|
// may be refreshed between calls and GetValidAccessToken handles its own caching.
|
|
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) {
|
|
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpClient, err := p.httpClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token, err := auth.GetValidAccessToken(httpClient, auth.NewUATCallOptions(acct.ToCliConfig(), p.errOut))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stored := auth.GetStoredToken(acct.AppID, acct.UserOpenId)
|
|
scopes := ""
|
|
if stored != nil {
|
|
scopes = stored.Scope
|
|
}
|
|
return &TokenResult{Token: token, Scopes: scopes}, nil
|
|
}
|
|
|
|
// resolveTAT resolves a tenant access token. Result is cached after first call.
|
|
// NOTE: Uses sync.Once — only the context from the first call is used.
|
|
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
|
p.tatOnce.Do(func() {
|
|
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
|
})
|
|
return p.tatResult, p.tatErr
|
|
}
|
|
|
|
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, error) {
|
|
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpClient, err := p.httpClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ep := core.ResolveEndpoints(acct.Brand)
|
|
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
|
|
|
body, err := json.Marshal(map[string]string{
|
|
"app_id": acct.AppID,
|
|
"app_secret": acct.AppSecret,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
var result struct {
|
|
Code int `json:"code"`
|
|
Msg string `json:"msg"`
|
|
TenantAccessToken string `json:"tenant_access_token"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
|
|
}
|
|
if result.Code != 0 {
|
|
return nil, fmt.Errorf("TAT API error: [%d] %s", result.Code, result.Msg)
|
|
}
|
|
return &TokenResult{Token: result.TenantAccessToken}, nil
|
|
}
|