Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions cmd/config/auth_proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package config

import (
"fmt"
"os"

"github.com/spf13/cobra"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/sidecar"
)

func NewCmdConfigAuthProxy(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth-proxy",
Short: "Manage trusted auth proxy hosts",
}
cmd.AddCommand(newCmdConfigAuthProxyTrust(f))
cmd.AddCommand(newCmdConfigAuthProxyUntrust(f))
cmd.AddCommand(newCmdConfigAuthProxyList(f))
return cmd
}

func newCmdConfigAuthProxyTrust(f *cmdutil.Factory) *cobra.Command {
var yes bool
cmd := &cobra.Command{
Use: "trust https://host[:port]",
Short: "Trust a remote HTTPS auth proxy host",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigAuthProxyTrust(f, args[0], yes)
},
}
cmd.Flags().BoolVar(&yes, "yes", false, "confirm trusting this remote auth proxy host")
cmdutil.SetRisk(cmd, cmdutil.RiskHighRiskWrite)
return cmd
}

func newCmdConfigAuthProxyUntrust(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "untrust host[:port]",
Short: "Remove a trusted remote auth proxy host",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigAuthProxyUntrust(f, args[0])
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}

func newCmdConfigAuthProxyList(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List trusted remote auth proxy hosts",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := core.LoadAuthProxyConfig()
if err != nil {
return output.Errorf(output.ExitValidation, "config", "failed to load auth proxy config: %v", err)
}
output.PrintJson(f.IOStreams.Out, map[string]any{
"trustedHosts": cfg.TrustedHosts,
})
return nil
},
}
cmdutil.SetRisk(cmd, "read")
return cmd
}

func runConfigAuthProxyTrust(f *cmdutil.Factory, rawHost string, confirmed bool) error {
if err := rejectAgentAuthProxyTrust(); err != nil {
return err
}
host, err := sidecar.NormalizeRemoteProxyTrustHost(rawHost)
if err != nil {
return output.ErrValidation("invalid auth proxy host: %v", err)
}
if !confirmed {
return authProxyTrustConfirmationRequired(host)
}

changed := false
if err := core.UpdateAuthProxyConfig(func(cfg *core.AuthProxyConfig) {
for _, existing := range cfg.TrustedHosts {
normalized, err := sidecar.NormalizeRemoteProxyTrustHost(existing)
if err == nil && normalized == host {
return
}
}
cfg.TrustedHosts = append(cfg.TrustedHosts, host)
changed = true
}); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save auth proxy trust config: %v", err)
}

if changed {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Trusted auth proxy host %q", host))
} else {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Auth proxy host %q already trusted", host))
}
output.PrintJson(f.IOStreams.Out, map[string]any{
"trustedHost": host,
"changed": changed,
})
return nil
}

func rejectAgentAuthProxyTrust() error {
ws := core.DetectWorkspaceFromEnv(os.Getenv)
if ws.IsLocal() {
return nil
}
return &errs.ValidationError{Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: fmt.Sprintf("refusing to trust a remote auth proxy from %s agent workspace; run this command outside the agent environment", ws.Display()),
Hint: "run `lark-cli config auth-proxy trust https://host[:port] --yes` from a normal user shell outside the agent environment after verifying the host.",
}}
}

func authProxyTrustConfirmationRequired(host string) error {
return &errs.ConfirmationRequiredError{
Problem: errs.Problem{
Category: errs.CategoryConfirmation,
Subtype: errs.SubtypeConfirmationRequired,
Message: fmt.Sprintf("trusting remote auth proxy host %q requires confirmation", host),
Hint: "Trusting a remote auth proxy allows future lark-cli requests, proxy session material, and business request bodies to flow through that host. Rerun from a normal user shell with --yes only after verifying the host.",
},
Risk: cmdutil.RiskHighRiskWrite,
Action: "config auth-proxy trust",
}
}

func runConfigAuthProxyUntrust(f *cmdutil.Factory, rawHost string) error {
host, err := sidecar.NormalizeRemoteProxyTrustHost(rawHost)
if err != nil {
return output.ErrValidation("invalid auth proxy host: %v", err)
}

changed := false
if err := core.UpdateAuthProxyConfig(func(cfg *core.AuthProxyConfig) {
next := cfg.TrustedHosts[:0]
for _, existing := range cfg.TrustedHosts {
normalized, err := sidecar.NormalizeRemoteProxyTrustHost(existing)
if err == nil && normalized == host {
changed = true
continue
}
next = append(next, existing)
}
cfg.TrustedHosts = next
}); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save auth proxy trust config: %v", err)
}

if changed {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Removed trusted auth proxy host %q", host))
} else {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Auth proxy host %q was not trusted", host))
}
output.PrintJson(f.IOStreams.Out, map[string]any{
"trustedHost": host,
"changed": changed,
})
return nil
}
189 changes: 189 additions & 0 deletions cmd/config/auth_proxy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package config

import (
"errors"
"strings"
"testing"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)

func TestConfigAuthProxyTrustCmd_RiskAndYesFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := newCmdConfigAuthProxyTrust(f)

risk, ok := cmdutil.GetRisk(cmd)
if !ok || risk != cmdutil.RiskHighRiskWrite {
t.Fatalf("risk = %q, %v; want %q", risk, ok, cmdutil.RiskHighRiskWrite)
}
if cmd.Flags().Lookup("yes") == nil {
t.Fatal("trust command should expose --yes confirmation flag")
}
}

func TestConfigAuthProxyTrustCmd_RequiresConfirmation(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := newCmdConfigAuthProxyTrust(f)
cmd.SetArgs([]string{"https://gate.example.com"})

err := cmd.Execute()
if err == nil {
t.Fatal("expected confirmation error without --yes")
}
var confirmErr *errs.ConfirmationRequiredError
if !errors.As(err, &confirmErr) {
t.Fatalf("error = %T %v, want *errs.ConfirmationRequiredError", err, err)
}
if confirmErr.Risk != cmdutil.RiskHighRiskWrite {
t.Fatalf("risk = %q, want %q", confirmErr.Risk, cmdutil.RiskHighRiskWrite)
}
if confirmErr.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("subtype = %q, want %q", confirmErr.Subtype, errs.SubtypeConfirmationRequired)
}

cfg, loadErr := core.LoadAuthProxyConfig()
if loadErr != nil {
t.Fatalf("LoadAuthProxyConfig() error = %v", loadErr)
}
if len(cfg.TrustedHosts) != 0 {
t.Fatalf("TrustedHosts = %#v, want empty after refused confirmation", cfg.TrustedHosts)
}
}

func TestConfigAuthProxyTrustCmd_RefusesAgentWorkspaceEvenWithYes(t *testing.T) {
tests := []struct {
name string
env map[string]string
}{
{name: "openclaw", env: map[string]string{"OPENCLAW_HOME": "/tmp/openclaw"}},
{name: "hermes", env: map[string]string{"HERMES_HOME": "/tmp/hermes"}},
{name: "lark channel", env: map[string]string{"LARK_CHANNEL": "1"}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for k, v := range tt.env {
t.Setenv(k, v)
}

f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := newCmdConfigAuthProxyTrust(f)
cmd.SetArgs([]string{"https://gate.example.com", "--yes"})

err := cmd.Execute()
if err == nil {
t.Fatal("expected trust to be refused in agent workspace")
}
if !strings.Contains(err.Error(), "outside the agent environment") {
t.Fatalf("error = %v, want outside-agent guidance", err)
}

cfg, loadErr := core.LoadAuthProxyConfig()
if loadErr != nil {
t.Fatalf("LoadAuthProxyConfig() error = %v", loadErr)
}
if len(cfg.TrustedHosts) != 0 {
t.Fatalf("TrustedHosts = %#v, want empty after agent refusal", cfg.TrustedHosts)
}
})
}
}

func TestConfigAuthProxyTrustCmd_ConfirmedLocalTrustsHost(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := newCmdConfigAuthProxyTrust(f)
cmd.SetArgs([]string{"https://gate.example.com", "--yes"})

if err := cmd.Execute(); err != nil {
t.Fatalf("cmd.Execute() error = %v", err)
}

cfg, err := core.LoadAuthProxyConfig()
if err != nil {
t.Fatalf("LoadAuthProxyConfig() error = %v", err)
}
if len(cfg.TrustedHosts) != 1 || cfg.TrustedHosts[0] != "gate.example.com" {
t.Fatalf("TrustedHosts = %#v, want gate.example.com", cfg.TrustedHosts)
}
}

func TestConfigAuthProxyTrustRun_PersistsCanonicalTrustedHost(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)

if err := runConfigAuthProxyTrust(f, "https://GATE.example.com:443", true); err != nil {
t.Fatalf("runConfigAuthProxyTrust() error = %v", err)
}

cfg, err := core.LoadAuthProxyConfig()
if err != nil {
t.Fatalf("LoadAuthProxyConfig() error = %v", err)
}
if len(cfg.TrustedHosts) != 1 || cfg.TrustedHosts[0] != "gate.example.com" {
t.Fatalf("TrustedHosts = %#v, want gate.example.com", cfg.TrustedHosts)
}
}

func TestConfigAuthProxyTrustRun_IsIdempotent(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)

if err := runConfigAuthProxyTrust(f, "https://gate.example.com", true); err != nil {
t.Fatalf("first trust error = %v", err)
}
if err := runConfigAuthProxyTrust(f, "gate.example.com", true); err != nil {
t.Fatalf("second trust error = %v", err)
}

cfg, err := core.LoadAuthProxyConfig()
if err != nil {
t.Fatalf("LoadAuthProxyConfig() error = %v", err)
}
if len(cfg.TrustedHosts) != 1 || cfg.TrustedHosts[0] != "gate.example.com" {
t.Fatalf("TrustedHosts = %#v, want one gate.example.com", cfg.TrustedHosts)
}
}

func TestConfigAuthProxyTrustRun_RejectsHTTP(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)

if err := runConfigAuthProxyTrust(f, "http://gate.example.com", true); err == nil {
t.Fatal("expected HTTP auth proxy trust to be rejected")
}
}

func TestConfigAuthProxyUntrustRun_RemovesTrustedHost(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)

if err := runConfigAuthProxyTrust(f, "https://gate.example.com", true); err != nil {
t.Fatalf("trust error = %v", err)
}
if err := runConfigAuthProxyUntrust(f, "gate.example.com"); err != nil {
t.Fatalf("untrust error = %v", err)
}

cfg, err := core.LoadAuthProxyConfig()
if err != nil {
t.Fatalf("LoadAuthProxyConfig() error = %v", err)
}
if len(cfg.TrustedHosts) != 0 {
t.Fatalf("TrustedHosts = %#v, want empty", cfg.TrustedHosts)
}
}
1 change: 1 addition & 0 deletions cmd/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigBind(f, nil))
cmd.AddCommand(NewCmdConfigRemove(f, nil))
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigAuthProxy(f))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
Expand Down
Loading
Loading