diff --git a/.gitignore b/.gitignore index 1e070300..dcdbf311 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ completions/ # IDEs .idea/ +.vscode/ diff --git a/.goreleaser.yml b/.goreleaser.yml index c66bac19..02cec0e8 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -111,7 +111,6 @@ dockers_v2: - doppler platforms: - linux/amd64 - - linux/arm64 images: - dopplerhq/cli - gcr.io/dopplerhq/cli @@ -127,11 +126,6 @@ dockers_v2: sbom: false flags: - "--provenance=false" - hooks: - # runs after the images are pushed but before the GitHub release is cut. Keep the platform list in sync with `platforms` above - post: - - cmd: ./scripts/release/verify-images.sh {{ .IsSnapshot }} linux/amd64,linux/arm64 {{ range .Images }}{{ . }} {{ end }} - output: true homebrew_casks: - name: doppler diff --git a/go.mod b/go.mod index 69a40879..eb288fd6 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( ) require ( + github.com/DopplerHQ/agent-proxy v0.0.0-00010101000000-000000000000 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -54,3 +55,5 @@ require ( golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.40.0 // indirect ) + +replace github.com/DopplerHQ/agent-proxy => ../agent-proxy diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go new file mode 100644 index 00000000..af458d2d --- /dev/null +++ b/pkg/cmd/agent.go @@ -0,0 +1,355 @@ +/* +Copyright © 2026 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "os/signal" + "os/user" + "strconv" + "strings" + "syscall" + + agentproxy "github.com/DopplerHQ/agent-proxy" + "github.com/DopplerHQ/agent-proxy/enforce" + "github.com/DopplerHQ/agent-proxy/sandbox" + "github.com/DopplerHQ/agent-proxy/verify" + "github.com/DopplerHQ/cli/pkg/utils" + "github.com/spf13/cobra" +) + +var agentCmd = &cobra.Command{ + Use: "agent", + Short: "Run AI agents against the credential proxy (experimental)", + Args: cobra.NoArgs, +} + +var agentRunCmd = &cobra.Command{ + Use: "run -- ", + Short: "Run a command inside a locked-down sandbox whose only egress is the proxy", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + proxyPort, _ := cmd.Flags().GetInt("proxy-port") + rebuild, _ := cmd.Flags().GetBool("rebuild") + dockerBin, _ := cmd.Flags().GetString("docker") + + // Resolve the proxy's artifacts using the shared path helpers. + dataDir := agentproxy.DefaultDataDir() + caPath := agentproxy.CACertPath(dataDir) + envPath := agentproxy.AgentEnvPath(dataDir) + + for _, p := range []string{caPath, envPath} { + if _, err := os.Stat(p); err != nil { + utils.HandleError(fmt.Errorf( + "proxy artifacts not found (%s). Start the proxy first, bound to an address the sandbox can reach:\n doppler proxy start --address 0.0.0.0:%d", + p, proxyPort)) + } + } + + // Forward the agent's own model-auth token(s) into the sandbox if set on + // the host (Claude Code can't do its interactive browser login inside a + // container). These are separate from the masked target-API secrets. + var env, names []string + for _, k := range []string{"CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"} { + if v := os.Getenv(k); v != "" { + env = append(env, k+"="+v) // by value + names = append(names, k) + } + } + if len(names) == 0 { + utils.LogWarning("No CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY is set — Claude cannot log in inside the sandbox (its browser OAuth can't reach a container).") + utils.LogWarning("Fix: run `claude setup-token` on your host, then `export CLAUDE_CODE_OAUTH_TOKEN=` and re-run this in the SAME shell.") + } else { + utils.Log(fmt.Sprintf("Forwarding agent auth into the sandbox: %s", strings.Join(names, ", "))) + } + + cfg := sandbox.Config{ + ProxyPort: proxyPort, + CACertPath: caPath, + AgentEnvPath: envPath, + Command: args, + DockerBin: dockerBin, + Interactive: true, + Env: env, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if rebuild { + utils.Log("Rebuilding sandbox image…") + if err := sandbox.BuildImage(ctx, cfg); err != nil { + utils.HandleError(err, "failed to build the sandbox image") + } + } else { + utils.Log("Preparing sandbox image (first run may take a few minutes)…") + if err := sandbox.EnsureImage(ctx, cfg); err != nil { + utils.HandleError(err, "failed to prepare the sandbox image") + } + } + + if err := sandbox.Run(ctx, cfg); err != nil { + utils.HandleError(err, "sandbox exited with an error") + } + }, +} + +// agentDoctorCmd verifies the sandbox contract for the environment it's run in. +// It is the same verifier the enforced paths invoke internally as a preflight; +// as a standalone command it doubles as a diagnostic ("why can't the agent reach +// GitHub?"). Run it AS the agent — same user, network, and env the agent gets. +var agentDoctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Verify the sandbox contract (egress containment, CA trust, privilege, hygiene)", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + enforced, _ := cmd.Flags().GetBool("enforced") + strictDNS, _ := cmd.Flags().GetBool("strict-dns") + testURL, _ := cmd.Flags().GetString("test-url") + + // The proxy the agent is meant to use: its HTTPS_PROXY, falling back to + // the default listen address. + proxyURL, _ := cmd.Flags().GetString("proxy") + if proxyURL == "" { + if v := firstEnv("HTTPS_PROXY", "https_proxy"); v != "" { + proxyURL = v + } else { + proxyURL = "http://127.0.0.1:14322" + } + } + + // The proxy CA: prefer an explicit flag, then the vars the agent trusts, + // then the default on-disk location. + caPath, _ := cmd.Flags().GetString("ca") + if caPath == "" { + if v := firstEnv("NODE_EXTRA_CA_CERTS", "CURL_CA_BUNDLE", "SSL_CERT_FILE"); v != "" { + caPath = v + } else { + caPath = agentproxy.CACertPath(agentproxy.DefaultDataDir()) + } + } + + report := verify.Doctor{Enforced: enforced, Checks: agentChecks(proxyURL, caPath, strictDNS, testURL)}.Run() + report.Render(os.Stdout) + os.Exit(report.ExitCode()) + }, +} + +// agentChecks is the standard contract check-list, shared by `agent doctor` and +// the preflight `agent enforce` runs before launching the agent — so both assert +// exactly the same contract. +func agentChecks(proxyURL, caPath string, strictDNS bool, testURL string) []verify.Check { + return []verify.Check{ + // clause 1 — egress containment (adversarial: dial by IP literal) + verify.EgressBlockedTCP("1.1.1.1:443"), + verify.EgressBlockedTCP("8.8.8.8:443"), + verify.EgressBlockedTCP("1.1.1.1:80"), + verify.EgressDNS("8.8.8.8:53", strictDNS), + // proxy reachability + verify.ProxyReachable(proxyURL), + // clause 3 — CA trust + verify.CACertValid(caPath), + verify.CATrustEnv(), + verify.CAEndToEnd(proxyURL, testURL), + // clause 2 — privilege + verify.UIDNotRoot(), + verify.NetAdminAbsent(), + // credential hygiene (Doppler-specific) + verify.EnvAbsent("DOPPLER_TOKEN"), + verify.EnvNoTokenShapes("real token shapes", "dp.st.", "dp.pt."), + } +} + +// agentEnforceCmd installs the sandbox contract IN PLACE — inside a box the user +// already has (a devcontainer, a VM) — then runs the agent. It locks the agent's +// egress to only the proxy, drops to an unprivileged user, runs the doctor +// preflight, and execs the command. Must be run as root (e.g. via sudo, or from +// a devcontainer feature's init). Linux only. +var agentEnforceCmd = &cobra.Command{ + Use: "enforce -- ", + Short: "Lock egress to the proxy in place, drop privileges, and run the agent (Linux, root)", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + strategyName, _ := cmd.Flags().GetString("strategy") + agentUser, _ := cmd.Flags().GetString("agent-user") + proxyHost, _ := cmd.Flags().GetString("proxy-host") + proxyPort, _ := cmd.Flags().GetInt("proxy-port") + strictDNS, _ := cmd.Flags().GetBool("strict-dns") + testURL, _ := cmd.Flags().GetString("test-url") + + var strat enforce.Strategy + switch strategyName { + case "owned-container": + strat = enforce.OwnedContainer{} + case "shared-box", "": + strat = enforce.SharedBox{} + default: + utils.HandleError(fmt.Errorf("unknown strategy %q (want owned-container or shared-box)", strategyName)) + } + + // Resolve the unprivileged agent user we'll drop to. + u, err := user.Lookup(agentUser) + if err != nil { + utils.HandleError(fmt.Errorf("agent user %q not found: %w. Create it (the devcontainer feature does this) or pass --agent-user", agentUser, err)) + } + uid, gid, groups := resolveUser(u) + + // The firewall rule needs an IP; the proxy env keeps the host name. + proxyIP := proxyHost + if net.ParseIP(proxyHost) == nil { + ips, err := net.LookupHost(proxyHost) + if err != nil || len(ips) == 0 { + utils.HandleError(fmt.Errorf("could not resolve proxy host %q: %w", proxyHost, err)) + } + proxyIP = ips[0] + } + + // CA path: flag, else default on-disk location. + caPath, _ := cmd.Flags().GetString("ca") + if caPath == "" { + caPath = agentproxy.CACertPath(agentproxy.DefaultDataDir()) + } + + // Build the agent env from the proxy's agent.env, repointing the proxy and + // CA vars at this boundary and stripping anything the agent must not hold. + envPath, _ := cmd.Flags().GetString("agent-env") + if envPath == "" { + envPath = agentproxy.AgentEnvPath(agentproxy.DefaultDataDir()) + } + rawEnv, err := os.ReadFile(envPath) + if err != nil { + utils.HandleError(fmt.Errorf("reading agent env %s: %w. Start the proxy first", envPath, err)) + } + proxyURL := fmt.Sprintf("http://%s:%d", proxyHost, proxyPort) + overrides := map[string]string{ + "HTTPS_PROXY": proxyURL, + "HTTP_PROXY": proxyURL, + "NODE_EXTRA_CA_CERTS": caPath, + "CURL_CA_BUNDLE": caPath, + "SSL_CERT_FILE": caPath, + // Enforce clears the environment before exec, so the essential process + // vars for the dropped-privilege agent must be set explicitly. + "HOME": u.HomeDir, + "USER": agentUser, + "LOGNAME": agentUser, + "PATH": envOr("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"), + "TERM": envOr("TERM", "xterm"), + } + // Forward the agent's own model auth if present (Claude can't do its browser + // login in a sandbox). Separate from the masked target-API secrets. + for _, k := range []string{"CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"} { + if v := os.Getenv(k); v != "" { + overrides[k] = v + } + } + env := enforce.ParseAgentEnv(string(rawEnv)) + env = enforce.OverrideEnv(env, overrides) + env = enforce.RemoveEnv(env, "DOPPLER_TOKEN", "NO_PROXY", "no_proxy") + + // The preflight is the same contract doctor asserts, run as the agent user + // after the lock. It fails the launch if the sandbox isn't sound. + preflight := func() error { + rep := verify.Doctor{Enforced: true, Checks: agentChecks(proxyURL, caPath, strictDNS, testURL)}.Run() + rep.Render(os.Stderr) + if rep.Failed() { + return errors.New("sandbox contract check failed; refusing to launch the agent") + } + return nil + } + + err = enforce.Enforce(enforce.Config{ + Strategy: strat, + Params: enforce.Params{ProxyIP: proxyIP, ProxyPort: proxyPort, AgentUID: uid}, + CACertPath: caPath, + AgentUID: uid, + AgentGID: gid, + AgentGroups: groups, + Env: env, + Command: args, + Preflight: preflight, + Logf: func(f string, a ...any) { utils.Log(fmt.Sprintf(f, a...)) }, + }) + if err != nil { + utils.HandleError(err, "enforce failed") + } + }, +} + +// resolveUser turns an os/user.User into numeric uid/gid and supplementary gids. +func resolveUser(u *user.User) (uid, gid int, groups []int) { + uid, _ = strconv.Atoi(u.Uid) + gid, _ = strconv.Atoi(u.Gid) + if gidStrs, err := u.GroupIds(); err == nil { + for _, g := range gidStrs { + if n, err := strconv.Atoi(g); err == nil { + groups = append(groups, n) + } + } + } + if len(groups) == 0 { + groups = []int{gid} + } + return uid, gid, groups +} + +// firstEnv returns the first non-empty value among the given env var names. +func firstEnv(names ...string) string { + for _, n := range names { + if v := os.Getenv(n); v != "" { + return v + } + } + return "" +} + +// envOr returns the env var's value, or fallback if it's unset/empty. +func envOr(name, fallback string) string { + if v := os.Getenv(name); v != "" { + return v + } + return fallback +} + +func init() { + agentRunCmd.Flags().Int("proxy-port", 14322, "port the credential proxy is listening on") + agentRunCmd.Flags().Bool("rebuild", false, "rebuild the sandbox image before running") + agentRunCmd.Flags().String("docker", "docker", "container CLI to use (docker, podman, ...)") + agentCmd.AddCommand(agentRunCmd) + + agentDoctorCmd.Flags().Bool("enforced", false, "assert the full contract: an egress-containment failure is fatal") + agentDoctorCmd.Flags().Bool("strict-dns", false, "treat an open external DNS resolver as a failure, not a warning") + agentDoctorCmd.Flags().String("proxy", "", "proxy URL the agent should use (default $HTTPS_PROXY or http://127.0.0.1:14322)") + agentDoctorCmd.Flags().String("ca", "", "proxy CA cert path (default $NODE_EXTRA_CA_CERTS or /ca.crt)") + agentDoctorCmd.Flags().String("test-url", "https://example.com", "URL fetched through the proxy to test end-to-end CA trust") + agentCmd.AddCommand(agentDoctorCmd) + + agentEnforceCmd.Flags().String("strategy", "shared-box", "egress lock strategy: shared-box (compose onto an existing firewall) or owned-container (flush)") + agentEnforceCmd.Flags().String("agent-user", "agent", "unprivileged user to drop to before running the agent") + agentEnforceCmd.Flags().String("proxy-host", "127.0.0.1", "host the credential proxy is reachable at from inside this boundary") + agentEnforceCmd.Flags().Int("proxy-port", 14322, "port the credential proxy is listening on") + agentEnforceCmd.Flags().String("ca", "", "proxy CA cert path (default /ca.crt)") + agentEnforceCmd.Flags().String("agent-env", "", "path to the proxy's agent.env (default /agent.env)") + agentEnforceCmd.Flags().Bool("strict-dns", false, "treat an open external DNS resolver as a preflight failure") + agentEnforceCmd.Flags().String("test-url", "https://example.com", "URL fetched through the proxy to test end-to-end CA trust") + agentCmd.AddCommand(agentEnforceCmd) + + rootCmd.AddCommand(agentCmd) +} diff --git a/pkg/cmd/proxy.go b/pkg/cmd/proxy.go new file mode 100644 index 00000000..326229c3 --- /dev/null +++ b/pkg/cmd/proxy.go @@ -0,0 +1,184 @@ +/* +Copyright © 2026 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + agentproxy "github.com/DopplerHQ/agent-proxy" + "github.com/DopplerHQ/cli/pkg/configuration" + "github.com/DopplerHQ/cli/pkg/proxy" + "github.com/DopplerHQ/cli/pkg/utils" + "github.com/spf13/cobra" +) + +var proxyCmd = &cobra.Command{ + Use: "proxy", + Short: "Run a credential-injecting proxy for AI agents (experimental)", + Args: cobra.NoArgs, +} + +var proxyStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the agent proxy", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + engineName, _ := cmd.Flags().GetString("engine") + address, _ := cmd.Flags().GetString("address") + + // Resolve the CLI's auth + scope the same way `doppler run` does. + localConfig := configuration.LocalConfig(cmd) + utils.RequireValue("token", localConfig.Token.Value) + + // A config-scoped service token (dp.st.) carries its own project/config. + // Otherwise we need a selected project + config — guide the user to + // `doppler setup` instead of failing later with a raw API error. + tokenIsConfigScoped := strings.HasPrefix(localConfig.Token.Value, "dp.st.") + if !tokenIsConfigScoped && (localConfig.EnclaveProject.Value == "" || localConfig.EnclaveConfig.Value == "") { + utils.HandleError(errors.New("no project/config selected. Run `doppler setup`, pass --project and --config, or use a scoped service token")) + } + + // Look up the requested engine in the registry. This indirection is the + // pluggability seam: --engine selects which proxy implementation runs. + factory, ok := proxy.Get(engineName) + if !ok { + utils.HandleError(fmt.Errorf("unknown proxy engine %q (available: %s)", engineName, strings.Join(proxy.Names(), ", "))) + } + + // Resolve where the proxy keeps its data (CA) and writes its log. Create it + // up front — on a fresh machine it doesn't exist yet, and the log file and + // scaffolded config are written into it before the engine's own MkdirAll. + dataDir := agentproxy.DefaultDataDir() + if err := os.MkdirAll(dataDir, 0o700); err != nil { + utils.HandleError(err, "unable to create the proxy data directory") + } + logPath, _ := cmd.Flags().GetString("log-file") + if logPath == "" { + logPath = filepath.Join(dataDir, "proxy.log") + } + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + utils.HandleError(err, "unable to open proxy log file") + } + defer logFile.Close() + + // Build the engine, injecting the real Doppler-backed secret source. + // Logs go to both the terminal and the log file. + // Load the user-editable proxy config (scaffolding it, pre-filled with the + // Anthropic passthrough, on first run). The --passthrough flag appends. + proxyConfigPath, _ := cmd.Flags().GetString("proxy-config") + if proxyConfigPath == "" { + proxyConfigPath = filepath.Join(dataDir, "doppler-proxy.yaml") + } + proxyConfig, created, err := proxy.LoadOrScaffold(proxyConfigPath) + if err != nil { + utils.HandleError(err, "unable to load the proxy config") + } + if created { + utils.Log(fmt.Sprintf("Created starter proxy config: %s", proxyConfigPath)) + } else { + utils.Log(fmt.Sprintf("Proxy config: %s", proxyConfigPath)) + } + utils.Log(" (edit it to set passthrough hosts, then restart)") + + // Address precedence: --address flag (if explicitly set) > config + // listen_address > the flag's built-in default. The scaffolded default is + // 0.0.0.0, which serves both host tools and the sandbox container; the + // per-run proxy token (below) is what keeps a broad bind from being an open + // proxy. + if !cmd.Flags().Changed("address") && proxyConfig.ListenAddress != "" { + address = proxyConfig.ListenAddress + } + + flagPassthrough, _ := cmd.Flags().GetStringSlice("passthrough") + passthrough := proxy.MergePassthrough(proxyConfig, flagPassthrough) + upstreamProxy, _ := cmd.Flags().GetString("upstream-proxy") + + // Mint a per-run credential the proxy requires from every client, so a + // broadly-bound or shared-network listener isn't an open forward proxy. It's + // embedded in the agent env's proxy URL, so configured clients send it + // automatically. + proxyToken, err := mintProxyToken() + if err != nil { + utils.HandleError(err, "unable to generate the per-run proxy token") + } + + engine, err := factory(proxy.Options{ + ListenAddr: address, + Secrets: proxy.NewDopplerSource(localConfig), + DataDir: dataDir, + LogWriter: io.MultiWriter(os.Stderr, logFile), + AgentEnvPath: agentproxy.AgentEnvPath(dataDir), + PassthroughHosts: passthrough, + UpstreamProxy: upstreamProxy, + ProxyAuthToken: proxyToken, + }) + if err != nil { + utils.HandleError(err) + } + + // Cancel the context on Ctrl-C / SIGTERM so the engine shuts down cleanly. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + utils.Log(fmt.Sprintf("Starting proxy engine %q on %s (press Ctrl-C to stop)", engineName, address)) + utils.Log(fmt.Sprintf("Logs: %s", logPath)) + if err := engine.Start(ctx); err != nil { + utils.HandleError(err) + } + }, +} + +func init() { + proxyStartCmd.Flags().String("engine", "masked-hash", "proxy engine to run") + proxyStartCmd.Flags().String("address", "0.0.0.0:14322", "address the proxy listens on; serves host + sandbox (set 127.0.0.1 for loopback-only, no sandbox). Overrides listen_address in the proxy config") + proxyStartCmd.Flags().String("log-file", "", "write proxy logs to this file (default /proxy.log)") + proxyStartCmd.Flags().String("proxy-config", "", "path to the proxy YAML config (default /doppler-proxy.yaml, scaffolded on first run)") + proxyStartCmd.Flags().StringSlice("passthrough", nil, "extra hostnames to blind-tunnel, appended to the config's passthrough list") + proxyStartCmd.Flags().String("upstream-proxy", "", "chain the proxy's own outbound connections through another HTTP proxy (e.g. http://127.0.0.1:3128 in a devcontainer)") + // Project/config resolve from `doppler setup` scope by default; these flags + // override it (same behavior as `doppler run`). + proxyStartCmd.Flags().StringP("project", "p", "", "project (e.g. backend)") + if err := proxyStartCmd.RegisterFlagCompletionFunc("project", projectIDsValidArgs); err != nil { + utils.HandleError(err) + } + proxyStartCmd.Flags().StringP("config", "c", "", "config (e.g. dev)") + if err := proxyStartCmd.RegisterFlagCompletionFunc("config", configNamesValidArgs); err != nil { + utils.HandleError(err) + } + proxyCmd.AddCommand(proxyStartCmd) + rootCmd.AddCommand(proxyCmd) +} + +// mintProxyToken returns a fresh, high-entropy per-run credential (256 bits, hex). +func mintProxyToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/pkg/configuration/flags.go b/pkg/configuration/flags.go index 209b85b0..53bc87cc 100644 --- a/pkg/configuration/flags.go +++ b/pkg/configuration/flags.go @@ -58,7 +58,7 @@ func SetFlag(flag string, enable bool) { func GetFlagDefault(flag string) bool { switch flag { case models.FlagAnalytics: - return false + return true case models.FlagEnvWarning: return true case models.FlagUpdateCheck: diff --git a/pkg/models/config.go b/pkg/models/config.go index 8983819b..5c414315 100644 --- a/pkg/models/config.go +++ b/pkg/models/config.go @@ -45,7 +45,8 @@ type VersionCheck struct { } type AnalyticsOptions struct { - // Deprecated: retained only for interop with CLI versions that predate the 'flags' property. + // we use the key 'disable' rather than 'enable' because blank value are automatically parsed as 'false', + // and we want this feature to be enabled by default Disable bool `yaml:"disable"` } diff --git a/pkg/proxy/config.go b/pkg/proxy/config.go new file mode 100644 index 00000000..caa83901 --- /dev/null +++ b/pkg/proxy/config.go @@ -0,0 +1,113 @@ +/* +Copyright © 2026 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + "bytes" + "errors" + "os" + + "gopkg.in/yaml.v3" +) + +// ProxyConfig is the user-editable proxy configuration (doppler-proxy.yaml). +type ProxyConfig struct { + // ListenAddress is the address the proxy binds. Defaults (via the starter + // config) to 0.0.0.0:14322 so the `doppler agent run` sandbox can reach it. + // The --address flag overrides this. + ListenAddress string `yaml:"listen_address"` + + // Passthrough lists hostnames the proxy blind-tunnels instead of + // intercepting (no TLS termination, no injection). + Passthrough []string `yaml:"passthrough"` +} + +// starterConfig is written on first run so the operator has an editable file, +// pre-filled with sensible defaults (an AI agent's control-plane is passed +// through so its own traffic isn't intercepted). +const starterConfig = `# doppler-proxy.yaml — configuration for the Doppler agent credential proxy. +# Edit this file, then restart the proxy to apply changes. + +# Address the proxy listens on. 0.0.0.0 serves both host tools (via 127.0.0.1) and +# the ` + "`doppler agent run`" + ` sandbox container (via the docker bridge). Every client +# must present the per-run proxy token, so a broad bind is not an open proxy. Set +# 127.0.0.1 to bind loopback only (the sandbox container cannot reach that). +# --address overrides this. +listen_address: 0.0.0.0:14322 + +# Hosts the proxy BLIND-TUNNELS instead of intercepting: no TLS termination and +# no credential injection. Put an agent's own control-plane here so its traffic +# passes through untouched (e.g. an AI agent reaching its model provider). This +# must include the agent's AUTH domains too — intercepting them breaks login +# (auth endpoints reject an unexpected CA), so Claude's login/session domains are +# passed through alongside its model endpoint. +passthrough: + - api.anthropic.com + - console.anthropic.com + - claude.ai + - claude.com + - statsig.anthropic.com + - sentry.io +` + +// LoadOrScaffold loads the proxy config from path. If the file does not exist it +// writes the starter config and returns it with created=true. +func LoadOrScaffold(path string) (cfg *ProxyConfig, created bool, err error) { + data, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, false, err + } + // Write the starter config when the file is missing OR empty — so a stray blank + // file (e.g. from an interrupted write) still gets populated on startup instead + // of silently loading as an empty config. + if errors.Is(err, os.ErrNotExist) || len(bytes.TrimSpace(data)) == 0 { + if err := os.WriteFile(path, []byte(starterConfig), 0o644); err != nil { + return nil, false, err + } + cfg, err = parseProxyConfig([]byte(starterConfig)) + return cfg, true, err + } + cfg, err = parseProxyConfig(data) + return cfg, false, err +} + +func parseProxyConfig(data []byte) (*ProxyConfig, error) { + var cfg ProxyConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +// MergePassthrough returns the config's passthrough hosts plus any extras, +// de-duplicated and order-preserving (config entries first). +func MergePassthrough(cfg *ProxyConfig, extra []string) []string { + return mergeHostLists(cfg.Passthrough, extra) +} + +func mergeHostLists(base, extra []string) []string { + seen := map[string]bool{} + var out []string + for _, s := range append(append([]string{}, base...), extra...) { + if s == "" || seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} diff --git a/pkg/proxy/config_test.go b/pkg/proxy/config_test.go new file mode 100644 index 00000000..b7f6b6c9 --- /dev/null +++ b/pkg/proxy/config_test.go @@ -0,0 +1,98 @@ +/* +Copyright © 2026 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +func TestLoadOrScaffold(t *testing.T) { + path := filepath.Join(t.TempDir(), "doppler-proxy.yaml") + + cfg, created, err := LoadOrScaffold(path) + if err != nil { + t.Fatal(err) + } + if !created { + t.Fatal("expected the config to be scaffolded on first run") + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("config file was not written: %v", err) + } + if !slices.Contains(cfg.Passthrough, "api.anthropic.com") { + t.Fatalf("starter config missing api.anthropic.com; got %v", cfg.Passthrough) + } + if cfg.ListenAddress != "0.0.0.0:14322" { + t.Fatalf("starter config listen_address = %q, want 0.0.0.0:14322", cfg.ListenAddress) + } + + // A second load reads the existing file — not scaffolded again. + cfg2, created2, err := LoadOrScaffold(path) + if err != nil { + t.Fatal(err) + } + if created2 { + t.Fatal("expected created=false when the file already exists") + } + if !slices.Equal(cfg.Passthrough, cfg2.Passthrough) { + t.Fatal("passthrough changed across reloads") + } +} + +func TestLoadOrScaffoldRewritesEmptyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "doppler-proxy.yaml") + // Pre-create an empty (blank) file — the bug case. + if err := os.WriteFile(path, []byte(" \n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, created, err := LoadOrScaffold(path) + if err != nil { + t.Fatal(err) + } + if !created { + t.Fatal("an empty file should be (re)scaffolded, created=true") + } + if !slices.Contains(cfg.Passthrough, "api.anthropic.com") { + t.Fatalf("scaffolded config not populated; got %v", cfg.Passthrough) + } + data, _ := os.ReadFile(path) + if len(data) == 0 { + t.Fatal("file is still empty after scaffold") + } +} + +func TestMergePassthrough(t *testing.T) { + cfg := &ProxyConfig{Passthrough: []string{"a.com", "b.com"}} + got := MergePassthrough(cfg, []string{"b.com", "c.com", ""}) + want := []string{"a.com", "b.com", "c.com"} + if !slices.Equal(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestParsePassthroughList(t *testing.T) { + cfg, err := parseProxyConfig([]byte("passthrough:\n - a.com\n - b.com\n")) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(cfg.Passthrough, []string{"a.com", "b.com"}) { + t.Fatalf("passthrough = %v", cfg.Passthrough) + } +} diff --git a/pkg/proxy/doppler_source.go b/pkg/proxy/doppler_source.go new file mode 100644 index 00000000..2226dff6 --- /dev/null +++ b/pkg/proxy/doppler_source.go @@ -0,0 +1,91 @@ +/* +Copyright © 2026 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + "context" + "fmt" + "sort" + "sync" + + agentproxy "github.com/DopplerHQ/agent-proxy" + "github.com/DopplerHQ/cli/pkg/controllers" + "github.com/DopplerHQ/cli/pkg/models" +) + +// dopplerSource is the real SecretSource: it reads the configured project/config +// from Doppler using the CLI's existing auth + API client — the same path +// `doppler run` uses. It fetches the config's secrets once (eagerly, on first +// use) and serves List/Fetch from that snapshot. +// +// This is the file the boundary promised would be the *only* change to make the +// proxy real — agent-proxy is untouched. +type dopplerSource struct { + config models.ScopedOptions + + once sync.Once + secrets map[string]string + loadErr error +} + +// NewDopplerSource returns a SecretSource backed by the resolved CLI config. +func NewDopplerSource(config models.ScopedOptions) agentproxy.SecretSource { + return &dopplerSource{config: config} +} + +// load fetches the config's secrets exactly once. +func (s *dopplerSource) load() { + s.once.Do(func() { + computed, err := controllers.GetSecrets(s.config) + if !err.IsNil() { + s.loadErr = err.Unwrap() + return + } + m := make(map[string]string, len(computed)) + for name, cs := range computed { + if cs.ComputedValue != nil { + m[name] = *cs.ComputedValue + } + } + s.secrets = m + }) +} + +func (s *dopplerSource) List(_ context.Context) ([]string, error) { + s.load() + if s.loadErr != nil { + return nil, s.loadErr + } + names := make([]string, 0, len(s.secrets)) + for name := range s.secrets { + names = append(names, name) + } + sort.Strings(names) + return names, nil +} + +func (s *dopplerSource) Fetch(_ context.Context, ref agentproxy.SecretRef) (string, error) { + s.load() + if s.loadErr != nil { + return "", s.loadErr + } + value, ok := s.secrets[ref.Name] + if !ok { + return "", fmt.Errorf("secret %q not found in the configured Doppler config", ref.Name) + } + return value, nil +} diff --git a/pkg/proxy/engine.go b/pkg/proxy/engine.go new file mode 100644 index 00000000..b7e3b0f2 --- /dev/null +++ b/pkg/proxy/engine.go @@ -0,0 +1,91 @@ +/* +Copyright © 2026 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package proxy is the CLI's integration layer for agent proxies. It defines +// the small Engine contract the CLI runs, a registry so `doppler proxy start +// --engine ` can pick an implementation, and the Doppler-backed +// capabilities (secret fetching, later auditing) injected into an engine. +// +// The proxy runtime itself lives in the separate github.com/DopplerHQ/agent-proxy +// module; this package is where the CLI plugs into it. +package proxy + +import ( + "context" + "io" + "sort" + + agentproxy "github.com/DopplerHQ/agent-proxy" +) + +// Engine is any runnable proxy implementation. The surface is intentionally +// tiny — just Start — so the CLI treats every engine interchangeably and can +// swap them via the --engine flag. +type Engine interface { + Start(ctx context.Context) error +} + +// Options is what the CLI hands to an engine factory: the capabilities it +// injects (today just the secret fetcher) plus operational settings. It grows +// as engines need more, without changing the Engine contract. +type Options struct { + ListenAddr string + Secrets agentproxy.SecretSource + DataDir string + LogWriter io.Writer + AgentEnvPath string + PassthroughHosts []string + UpstreamProxy string + // ProxyAuthToken is a per-run credential the CLI mints; the engine requires it + // from every client (as a Basic Proxy-Authorization) and embeds it in the agent + // env so standard clients send it automatically. + ProxyAuthToken string +} + +// Factory builds an Engine from Options. +type Factory func(opts Options) (Engine, error) + +// registry maps an engine name to its factory. Implementations populate it from +// their package init(), which is what makes engines pluggable. +// +// An Envoy engine was prototyped and is intentionally NOT shipped in this binary. +// It's preserved on the `austin/agent-proxy` branch (its adapter was pkg/proxy/ +// envoy.go; the Envoy data plane lives in the agent-proxy repo's `envoy/` package on +// `austin/envoy-engine`). To bring it back, restore that adapter and its config +// surface — it self-registers here. See ai-proxy-docs/envoy-parked.md and ENG-9728. +var registry = map[string]Factory{} + +// Register makes an engine available under name. +func Register(name string, f Factory) { + registry[name] = f +} + +// Get returns the factory registered under name. +func Get(name string) (Factory, bool) { + f, ok := registry[name] + return f, ok +} + +// Names returns the registered engine names, sorted — handy for help text and +// "unknown engine" errors. +func Names() []string { + names := make([]string, 0, len(registry)) + for n := range registry { + names = append(names, n) + } + sort.Strings(names) + return names +} diff --git a/pkg/proxy/maskedhash.go b/pkg/proxy/maskedhash.go new file mode 100644 index 00000000..246fd055 --- /dev/null +++ b/pkg/proxy/maskedhash.go @@ -0,0 +1,43 @@ +/* +Copyright © 2026 Doppler + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proxy + +import ( + agentproxy "github.com/DopplerHQ/agent-proxy" +) + +// init registers the "masked-hash" engine: the per-secret-hash proxy backed by +// the agent-proxy runtime. The factory builds an agent-proxy Server, injecting +// the CLI's capabilities. Because *agentproxy.Server has a Start(ctx) method, it +// satisfies our Engine interface implicitly — no adapter needed. +// +// Additional engines register themselves the same way, which is what makes the +// --engine flag pluggable. +func init() { + Register("masked-hash", func(opts Options) (Engine, error) { + return agentproxy.New(agentproxy.Config{ + ListenAddr: opts.ListenAddr, + Secrets: opts.Secrets, + DataDir: opts.DataDir, + LogWriter: opts.LogWriter, + AgentEnvPath: opts.AgentEnvPath, + PassthroughHosts: opts.PassthroughHosts, + UpstreamProxy: opts.UpstreamProxy, + ProxyAuthToken: opts.ProxyAuthToken, + }) + }) +}