From 4679236be88923c39bff80e38a5c4cbae39eab52 Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Mon, 7 Sep 2026 14:19:33 -0500 Subject: [PATCH 01/20] Remove initial envoy implementation --- .gitignore | 1 + .goreleaser.yml | 6 - go.mod | 3 + pkg/cmd/agent.go | 380 ++++++++++++++++++++++++++++++++++++ pkg/cmd/agent_test.go | 28 +++ pkg/cmd/proxy.go | 184 +++++++++++++++++ pkg/configuration/flags.go | 2 +- pkg/models/config.go | 3 +- pkg/proxy/config.go | 113 +++++++++++ pkg/proxy/config_test.go | 98 ++++++++++ pkg/proxy/doppler_source.go | 91 +++++++++ pkg/proxy/engine.go | 91 +++++++++ pkg/proxy/maskedhash.go | 43 ++++ 13 files changed, 1035 insertions(+), 8 deletions(-) create mode 100644 pkg/cmd/agent.go create mode 100644 pkg/cmd/agent_test.go create mode 100644 pkg/cmd/proxy.go create mode 100644 pkg/proxy/config.go create mode 100644 pkg/proxy/config_test.go create mode 100644 pkg/proxy/doppler_source.go create mode 100644 pkg/proxy/engine.go create mode 100644 pkg/proxy/maskedhash.go 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..251f3536 --- /dev/null +++ b/pkg/cmd/agent.go @@ -0,0 +1,380 @@ +/* +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" + "net/url" + "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)) + } + // Keep the per-run proxy token (userinfo) from agent.env's HTTPS_PROXY and + // repoint only the host at this boundary. Dropping it would hand the agent a + // credential-less proxy URL and every request would get a 407. + proxyURL := fmt.Sprintf("http://%s%s:%d", proxyUserinfo(rawEnv), 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 +} + +// proxyUserinfo returns the "user:pass@" prefix from the agent env's HTTPS_PROXY +// (the per-run proxy token), or "" if none. Used so `agent enforce` keeps the +// credential when it repoints the proxy host, instead of dropping it. +func proxyUserinfo(rawEnv []byte) string { + for _, line := range strings.Split(string(rawEnv), "\n") { + line = strings.TrimSpace(line) + v, ok := strings.CutPrefix(line, "HTTPS_PROXY=") + if !ok { + v, ok = strings.CutPrefix(line, "HTTP_PROXY=") + } + if !ok { + continue + } + v = strings.Trim(v, `'"`) // agent.env shell-quotes values + if u, err := url.Parse(v); err == nil && u.User != nil { + return u.User.String() + "@" + } + } + return "" +} + +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/agent_test.go b/pkg/cmd/agent_test.go new file mode 100644 index 00000000..38d830e7 --- /dev/null +++ b/pkg/cmd/agent_test.go @@ -0,0 +1,28 @@ +/* +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. +*/ + +package cmd + +import "testing" + +// TestProxyUserinfo: `agent enforce` must keep the per-run proxy token from +// agent.env's HTTPS_PROXY when it repoints the proxy host — dropping it 407s every +// agent request. +func TestProxyUserinfo(t *testing.T) { + cases := []struct{ name, env, want string }{ + {"tokened", "HTTPS_PROXY='http://doppler:abc123@127.0.0.1:14322'\n", "doppler:abc123@"}, + {"tokened double-quoted", `HTTPS_PROXY="http://doppler:abc123@127.0.0.1:14322"`, "doppler:abc123@"}, + {"no userinfo", "HTTPS_PROXY='http://127.0.0.1:14322'\n", ""}, + {"no proxy line", "FOO=bar\nBAZ=qux\n", ""}, + {"http_proxy fallback", "HTTP_PROXY='http://doppler:xyz@127.0.0.1:14322'\n", "doppler:xyz@"}, + } + for _, c := range cases { + if got := proxyUserinfo([]byte(c.env)); got != c.want { + t.Errorf("%s: proxyUserinfo = %q, want %q", c.name, got, c.want) + } + } +} 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, + }) + }) +} From 524bcdf39f83aaad721ebb515e77b7f417f12f6c Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Tue, 8 Sep 2026 12:00:47 -0500 Subject: [PATCH 02/20] Add bindings block to proxy config deny is the default so the operator, not the agent, picks where a credential goes --- pkg/cmd/proxy.go | 35 ++++++++++++++++++++++++- pkg/proxy/config.go | 41 +++++++++++++++++++++++++++++ pkg/proxy/config_test.go | 56 ++++++++++++++++++++++++++++++++++++++++ pkg/proxy/engine.go | 3 +++ pkg/proxy/maskedhash.go | 1 + 5 files changed, 135 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/proxy.go b/pkg/cmd/proxy.go index 326229c3..34e4f82d 100644 --- a/pkg/cmd/proxy.go +++ b/pkg/cmd/proxy.go @@ -117,6 +117,13 @@ var proxyStartCmd = &cobra.Command{ flagPassthrough, _ := cmd.Flags().GetStringSlice("passthrough") passthrough := proxy.MergePassthrough(proxyConfig, flagPassthrough) upstreamProxy, _ := cmd.Flags().GetString("upstream-proxy") + binding, err := proxyConfig.BindingResolver() + if err != nil { + utils.HandleError(err, "invalid bindings in the proxy config") + } + + secrets := proxy.NewDopplerSource(localConfig) + warnShapeMismatches(binding, secrets) // 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 @@ -129,13 +136,14 @@ var proxyStartCmd = &cobra.Command{ engine, err := factory(proxy.Options{ ListenAddr: address, - Secrets: proxy.NewDopplerSource(localConfig), + Secrets: secrets, DataDir: dataDir, LogWriter: io.MultiWriter(os.Stderr, logFile), AgentEnvPath: agentproxy.AgentEnvPath(dataDir), PassthroughHosts: passthrough, UpstreamProxy: upstreamProxy, ProxyAuthToken: proxyToken, + Binding: binding, }) if err != nil { utils.HandleError(err) @@ -153,6 +161,31 @@ var proxyStartCmd = &cobra.Command{ }, } +// warnShapeMismatches logs each rule that points a recognizable token at another +// provider's host. The rule still wins at runtime, since a proxy or an enterprise +// host is a legitimate reason, but the mismatch is worth a look before the agent +// finds out. +func warnShapeMismatches(binding agentproxy.BindingResolver, secrets agentproxy.SecretSource) { + rules, ok := binding.(*agentproxy.RuleResolver) + if !ok { + return + } + ctx := context.Background() + names, err := secrets.List(ctx) + if err != nil { + return // the engine reports the load failure itself + } + values := make(map[string]string, len(names)) + for _, name := range names { + if v, err := secrets.Fetch(ctx, agentproxy.SecretRef{Name: name}); err == nil { + values[name] = v + } + } + for _, warning := range rules.Validate(values) { + utils.LogWarning(warning) + } +} + 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") diff --git a/pkg/proxy/config.go b/pkg/proxy/config.go index caa83901..b7e4be15 100644 --- a/pkg/proxy/config.go +++ b/pkg/proxy/config.go @@ -19,8 +19,10 @@ package proxy import ( "bytes" "errors" + "fmt" "os" + agentproxy "github.com/DopplerHQ/agent-proxy" "gopkg.in/yaml.v3" ) @@ -34,6 +36,29 @@ type ProxyConfig struct { // Passthrough lists hostnames the proxy blind-tunnels instead of // intercepting (no TLS termination, no injection). Passthrough []string `yaml:"passthrough"` + + // Bindings declares where each secret may be injected, by secret name. A + // secret with no entry falls under Unbound. + Bindings map[string][]agentproxy.Rule `yaml:"bindings"` + + // Unbound is the policy for a secret with no bindings entry: "deny" (the + // default) refuses it everywhere, "trust-first-use" pins it to the first + // host the agent sends it to. + Unbound string `yaml:"unbound"` +} + +// BindingResolver builds the resolver the proxy authorizes injection with. +func (c *ProxyConfig) BindingResolver() (agentproxy.BindingResolver, error) { + var policy agentproxy.UnboundPolicy + switch c.Unbound { + case "", "deny": + policy = agentproxy.UnboundDeny + case "trust-first-use": + policy = agentproxy.UnboundTOFU + default: + return nil, fmt.Errorf("unbound must be deny or trust-first-use, got %q", c.Unbound) + } + return agentproxy.NewRuleResolver(c.Bindings, policy), nil } // starterConfig is written on first run so the operator has an editable file, @@ -62,6 +87,22 @@ passthrough: - claude.com - statsig.anthropic.com - sentry.io + +# Where each secret may be injected. A secret with no entry is refused everywhere +# unless unbound below says otherwise. paths are globs matched per segment (** spans +# segments) and methods are optional; both default to any. +# bindings: +# GITHUB_TOKEN: +# - host: api.github.com +# paths: ["/repos/**", "/user"] +# methods: [GET, POST] +# STRIPE_KEY: +# - host: api.stripe.com + +# Policy for a secret with no bindings entry. deny refuses it everywhere and +# logs the host it was sent to. trust-first-use pins it to the first host the +# agent uses, which lets the agent decide where the credential goes. +# unbound: deny ` // LoadOrScaffold loads the proxy config from path. If the file does not exist it diff --git a/pkg/proxy/config_test.go b/pkg/proxy/config_test.go index b7f6b6c9..e1460671 100644 --- a/pkg/proxy/config_test.go +++ b/pkg/proxy/config_test.go @@ -21,6 +21,8 @@ import ( "path/filepath" "slices" "testing" + + agentproxy "github.com/DopplerHQ/agent-proxy" ) func TestLoadOrScaffold(t *testing.T) { @@ -96,3 +98,57 @@ func TestParsePassthroughList(t *testing.T) { t.Fatalf("passthrough = %v", cfg.Passthrough) } } + +func TestParseBindings(t *testing.T) { + cfg, err := parseProxyConfig([]byte(` +bindings: + GITHUB_TOKEN: + - host: api.github.com + paths: ["/repos/**"] + methods: [GET] + STRIPE_KEY: + - host: api.stripe.com +unbound: trust-first-use +`)) + if err != nil { + t.Fatal(err) + } + if len(cfg.Bindings) != 2 { + t.Fatalf("bindings = %v", cfg.Bindings) + } + gh := cfg.Bindings["GITHUB_TOKEN"] + if len(gh) != 1 || gh[0].Host != "api.github.com" || !slices.Equal(gh[0].Paths, []string{"/repos/**"}) || !slices.Equal(gh[0].Methods, []string{"GET"}) { + t.Fatalf("GITHUB_TOKEN rules = %+v", gh) + } + if cfg.Unbound != "trust-first-use" { + t.Fatalf("unbound = %q", cfg.Unbound) + } + if _, err := cfg.BindingResolver(); err != nil { + t.Fatal(err) + } +} + +// With no bindings block at all, an unrecognizable secret is refused everywhere. +func TestBindingResolverDefaultsToDeny(t *testing.T) { + r, err := (&ProxyConfig{}).BindingResolver() + if err != nil { + t.Fatal(err) + } + ok, why := r.Allowed(agentproxy.BindingRequest{ + Name: "DB_PASSWORD", + Value: "plain-database-password", + Dest: agentproxy.Destination{Host: "db.example.com:443", Path: "/", Method: "GET"}, + }) + if ok { + t.Fatal("an undeclared secret must be refused by default") + } + if why == "" { + t.Fatal("refusal should carry a reason") + } +} + +func TestBindingResolverRejectsUnknownPolicy(t *testing.T) { + if _, err := (&ProxyConfig{Unbound: "maybe"}).BindingResolver(); err == nil { + t.Fatal("an unknown unbound policy must be an error") + } +} diff --git a/pkg/proxy/engine.go b/pkg/proxy/engine.go index b7e3b0f2..ce60d448 100644 --- a/pkg/proxy/engine.go +++ b/pkg/proxy/engine.go @@ -53,6 +53,9 @@ type Options struct { // from every client (as a Basic Proxy-Authorization) and embeds it in the agent // env so standard clients send it automatically. ProxyAuthToken string + // Binding authorizes each injection by destination. Nil means the engine's + // own default. + Binding agentproxy.BindingResolver } // Factory builds an Engine from Options. diff --git a/pkg/proxy/maskedhash.go b/pkg/proxy/maskedhash.go index 246fd055..a9fdc17f 100644 --- a/pkg/proxy/maskedhash.go +++ b/pkg/proxy/maskedhash.go @@ -38,6 +38,7 @@ func init() { PassthroughHosts: opts.PassthroughHosts, UpstreamProxy: opts.UpstreamProxy, ProxyAuthToken: opts.ProxyAuthToken, + Binding: opts.Binding, }) }) } From 91d8aa9eb96955967cf62697e2277a00671c0be1 Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Tue, 8 Sep 2026 12:00:48 -0500 Subject: [PATCH 03/20] Refresh secrets on TTL in proxy start dopplerSource fetched once, so a rotated value would be served until restart --- pkg/cmd/proxy.go | 7 ++-- pkg/proxy/doppler_source.go | 64 +++++++++++++++++++------------------ 2 files changed, 38 insertions(+), 33 deletions(-) diff --git a/pkg/cmd/proxy.go b/pkg/cmd/proxy.go index 34e4f82d..ecae5a2d 100644 --- a/pkg/cmd/proxy.go +++ b/pkg/cmd/proxy.go @@ -122,7 +122,10 @@ var proxyStartCmd = &cobra.Command{ utils.HandleError(err, "invalid bindings in the proxy config") } - secrets := proxy.NewDopplerSource(localConfig) + logOut := io.MultiWriter(os.Stderr, logFile) + secrets := agentproxy.NewRefreshingSource(proxy.NewDopplerSource(localConfig), agentproxy.RefreshOptions{ + Logf: func(format string, args ...any) { fmt.Fprintf(logOut, format+"\n", args...) }, + }) warnShapeMismatches(binding, secrets) // Mint a per-run credential the proxy requires from every client, so a @@ -138,7 +141,7 @@ var proxyStartCmd = &cobra.Command{ ListenAddr: address, Secrets: secrets, DataDir: dataDir, - LogWriter: io.MultiWriter(os.Stderr, logFile), + LogWriter: logOut, AgentEnvPath: agentproxy.AgentEnvPath(dataDir), PassthroughHosts: passthrough, UpstreamProxy: upstreamProxy, diff --git a/pkg/proxy/doppler_source.go b/pkg/proxy/doppler_source.go index 2226dff6..1a124c99 100644 --- a/pkg/proxy/doppler_source.go +++ b/pkg/proxy/doppler_source.go @@ -29,17 +29,14 @@ import ( // 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. +// `doppler run` uses. Every List fetches the config's secrets and serves the +// following Fetch calls from that snapshot, which is the shape RefreshingSource +// drives on each TTL. type dopplerSource struct { config models.ScopedOptions - once sync.Once + mu sync.Mutex secrets map[string]string - loadErr error } // NewDopplerSource returns a SecretSource backed by the resolved CLI config. @@ -47,31 +44,31 @@ 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 - } +// load fetches the config's secrets and replaces the snapshot. +func (s *dopplerSource) load() (map[string]string, error) { + computed, err := controllers.GetSecrets(s.config) + if !err.IsNil() { + return nil, err.Unwrap() + } + m := make(map[string]string, len(computed)) + for name, cs := range computed { + if cs.ComputedValue != nil { + m[name] = *cs.ComputedValue } - s.secrets = m - }) + } + s.mu.Lock() + s.secrets = m + s.mu.Unlock() + return m, nil } func (s *dopplerSource) List(_ context.Context) ([]string, error) { - s.load() - if s.loadErr != nil { - return nil, s.loadErr + m, err := s.load() + if err != nil { + return nil, err } - names := make([]string, 0, len(s.secrets)) - for name := range s.secrets { + names := make([]string, 0, len(m)) + for name := range m { names = append(names, name) } sort.Strings(names) @@ -79,11 +76,16 @@ func (s *dopplerSource) List(_ context.Context) ([]string, error) { } func (s *dopplerSource) Fetch(_ context.Context, ref agentproxy.SecretRef) (string, error) { - s.load() - if s.loadErr != nil { - return "", s.loadErr + s.mu.Lock() + m := s.secrets + s.mu.Unlock() + if m == nil { + var err error + if m, err = s.load(); err != nil { + return "", err + } } - value, ok := s.secrets[ref.Name] + value, ok := m[ref.Name] if !ok { return "", fmt.Errorf("secret %q not found in the configured Doppler config", ref.Name) } From 95b3914117b0240b47b5c41d73c122c5f3312a0e Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Tue, 8 Sep 2026 12:00:49 -0500 Subject: [PATCH 04/20] Add allow-private-egress flag --- pkg/cmd/proxy.go | 21 ++++++++++++--------- pkg/proxy/engine.go | 3 +++ pkg/proxy/maskedhash.go | 19 ++++++++++--------- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/proxy.go b/pkg/cmd/proxy.go index ecae5a2d..1b822942 100644 --- a/pkg/cmd/proxy.go +++ b/pkg/cmd/proxy.go @@ -117,6 +117,7 @@ var proxyStartCmd = &cobra.Command{ flagPassthrough, _ := cmd.Flags().GetStringSlice("passthrough") passthrough := proxy.MergePassthrough(proxyConfig, flagPassthrough) upstreamProxy, _ := cmd.Flags().GetString("upstream-proxy") + allowPrivateEgress, _ := cmd.Flags().GetBool("allow-private-egress") binding, err := proxyConfig.BindingResolver() if err != nil { utils.HandleError(err, "invalid bindings in the proxy config") @@ -138,15 +139,16 @@ var proxyStartCmd = &cobra.Command{ } engine, err := factory(proxy.Options{ - ListenAddr: address, - Secrets: secrets, - DataDir: dataDir, - LogWriter: logOut, - AgentEnvPath: agentproxy.AgentEnvPath(dataDir), - PassthroughHosts: passthrough, - UpstreamProxy: upstreamProxy, - ProxyAuthToken: proxyToken, - Binding: binding, + ListenAddr: address, + Secrets: secrets, + DataDir: dataDir, + LogWriter: logOut, + AgentEnvPath: agentproxy.AgentEnvPath(dataDir), + PassthroughHosts: passthrough, + UpstreamProxy: upstreamProxy, + ProxyAuthToken: proxyToken, + Binding: binding, + AllowPrivateEgress: allowPrivateEgress, }) if err != nil { utils.HandleError(err) @@ -196,6 +198,7 @@ func init() { 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)") + proxyStartCmd.Flags().Bool("allow-private-egress", false, "let the proxy connect to loopback and private-network addresses (local development against a local upstream only)") // 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)") diff --git a/pkg/proxy/engine.go b/pkg/proxy/engine.go index ce60d448..99804d85 100644 --- a/pkg/proxy/engine.go +++ b/pkg/proxy/engine.go @@ -56,6 +56,9 @@ type Options struct { // Binding authorizes each injection by destination. Nil means the engine's // own default. Binding agentproxy.BindingResolver + // AllowPrivateEgress lets the proxy connect to loopback and private-network + // addresses, for local development against a local upstream. + AllowPrivateEgress bool } // Factory builds an Engine from Options. diff --git a/pkg/proxy/maskedhash.go b/pkg/proxy/maskedhash.go index a9fdc17f..3d44e33c 100644 --- a/pkg/proxy/maskedhash.go +++ b/pkg/proxy/maskedhash.go @@ -30,15 +30,16 @@ import ( 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, - Binding: opts.Binding, + ListenAddr: opts.ListenAddr, + Secrets: opts.Secrets, + DataDir: opts.DataDir, + LogWriter: opts.LogWriter, + AgentEnvPath: opts.AgentEnvPath, + PassthroughHosts: opts.PassthroughHosts, + UpstreamProxy: opts.UpstreamProxy, + ProxyAuthToken: opts.ProxyAuthToken, + Binding: opts.Binding, + AllowPrivateEgress: opts.AllowPrivateEgress, }) }) } From c4bbd2ee808fe190662f8420e1cac52c153a5b14 Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Tue, 8 Sep 2026 17:11:07 -0500 Subject: [PATCH 05/20] Test that proxy start settings reach the engine --- pkg/cmd/proxy.go | 69 ++++++++++++++++++++++++++------------ pkg/cmd/proxy_test.go | 77 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 21 deletions(-) create mode 100644 pkg/cmd/proxy_test.go diff --git a/pkg/cmd/proxy.go b/pkg/cmd/proxy.go index 1b822942..cfb8c6a3 100644 --- a/pkg/cmd/proxy.go +++ b/pkg/cmd/proxy.go @@ -118,16 +118,6 @@ var proxyStartCmd = &cobra.Command{ passthrough := proxy.MergePassthrough(proxyConfig, flagPassthrough) upstreamProxy, _ := cmd.Flags().GetString("upstream-proxy") allowPrivateEgress, _ := cmd.Flags().GetBool("allow-private-egress") - binding, err := proxyConfig.BindingResolver() - if err != nil { - utils.HandleError(err, "invalid bindings in the proxy config") - } - - logOut := io.MultiWriter(os.Stderr, logFile) - secrets := agentproxy.NewRefreshingSource(proxy.NewDopplerSource(localConfig), agentproxy.RefreshOptions{ - Logf: func(format string, args ...any) { fmt.Fprintf(logOut, format+"\n", args...) }, - }) - warnShapeMismatches(binding, secrets) // 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 @@ -138,18 +128,22 @@ var proxyStartCmd = &cobra.Command{ utils.HandleError(err, "unable to generate the per-run proxy token") } - engine, err := factory(proxy.Options{ - ListenAddr: address, - Secrets: secrets, - DataDir: dataDir, - LogWriter: logOut, - AgentEnvPath: agentproxy.AgentEnvPath(dataDir), - PassthroughHosts: passthrough, - UpstreamProxy: upstreamProxy, - ProxyAuthToken: proxyToken, - Binding: binding, - AllowPrivateEgress: allowPrivateEgress, + opts, err := engineOptions(proxyConfig, proxyStartInputs{ + address: address, + dataDir: dataDir, + logOut: io.MultiWriter(os.Stderr, logFile), + passthrough: passthrough, + upstreamProxy: upstreamProxy, + proxyToken: proxyToken, + allowPrivateEgress: allowPrivateEgress, + source: proxy.NewDopplerSource(localConfig), }) + if err != nil { + utils.HandleError(err, "invalid bindings in the proxy config") + } + warnShapeMismatches(opts.Binding, opts.Secrets) + + engine, err := factory(opts) if err != nil { utils.HandleError(err) } @@ -166,6 +160,39 @@ var proxyStartCmd = &cobra.Command{ }, } +// proxyStartInputs are the resolved flags proxy start turns into engine options. +type proxyStartInputs struct { + address, dataDir, upstreamProxy, proxyToken string + allowPrivateEgress bool + passthrough []string + logOut io.Writer + source agentproxy.SecretSource +} + +// engineOptions is the one place config and flags become engine options, so a +// test can assert each setting actually reaches the engine. +func engineOptions(cfg *proxy.ProxyConfig, in proxyStartInputs) (proxy.Options, error) { + binding, err := cfg.BindingResolver() + if err != nil { + return proxy.Options{}, err + } + secrets := agentproxy.NewRefreshingSource(in.source, agentproxy.RefreshOptions{ + Logf: func(format string, args ...any) { fmt.Fprintf(in.logOut, format+"\n", args...) }, + }) + return proxy.Options{ + ListenAddr: in.address, + Secrets: secrets, + DataDir: in.dataDir, + LogWriter: in.logOut, + AgentEnvPath: agentproxy.AgentEnvPath(in.dataDir), + PassthroughHosts: in.passthrough, + UpstreamProxy: in.upstreamProxy, + ProxyAuthToken: in.proxyToken, + Binding: binding, + AllowPrivateEgress: in.allowPrivateEgress, + }, nil +} + // warnShapeMismatches logs each rule that points a recognizable token at another // provider's host. The rule still wins at runtime, since a proxy or an enterprise // host is a legitimate reason, but the mismatch is worth a look before the agent diff --git a/pkg/cmd/proxy_test.go b/pkg/cmd/proxy_test.go new file mode 100644 index 00000000..35e09f22 --- /dev/null +++ b/pkg/cmd/proxy_test.go @@ -0,0 +1,77 @@ +/* +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. +*/ + +package cmd + +import ( + "context" + "io" + "path/filepath" + "testing" + + agentproxy "github.com/DopplerHQ/agent-proxy" + "github.com/DopplerHQ/cli/pkg/proxy" +) + +type staticSource map[string]string + +func (s staticSource) List(context.Context) ([]string, error) { + names := make([]string, 0, len(s)) + for n := range s { + names = append(names, n) + } + return names, nil +} + +func (s staticSource) Fetch(_ context.Context, ref agentproxy.SecretRef) (string, error) { + return s[ref.Name], nil +} + +// Every setting proxy start resolves has to reach the engine; a dropped field +// here would silently disable a feature. +func TestEngineOptionsCarryEverySetting(t *testing.T) { + dir := t.TempDir() + cfg := &proxy.ProxyConfig{Bindings: map[string][]agentproxy.Rule{"GH": {{Host: "api.github.com"}}}} + opts, err := engineOptions(cfg, proxyStartInputs{ + address: "127.0.0.1:14322", + dataDir: dir, + logOut: io.Discard, + passthrough: []string{"api.anthropic.com"}, + upstreamProxy: "http://127.0.0.1:3128", + proxyToken: "per-run-token", + allowPrivateEgress: true, + source: staticSource{"GH": "ghp_x"}, + }) + if err != nil { + t.Fatal(err) + } + if !opts.AllowPrivateEgress || opts.ProxyAuthToken != "per-run-token" || opts.UpstreamProxy != "http://127.0.0.1:3128" || opts.ListenAddr != "127.0.0.1:14322" { + t.Fatalf("flags did not reach the engine: %+v", opts) + } + if opts.AgentEnvPath != filepath.Join(dir, "agent.env") || opts.DataDir != dir { + t.Fatalf("data dir paths wrong: %+v", opts) + } + if _, ok := opts.Secrets.(*agentproxy.RefreshingSource); !ok { + t.Fatalf("secrets should be wrapped in RefreshingSource, got %T", opts.Secrets) + } + rules, ok := opts.Binding.(*agentproxy.RuleResolver) + if !ok { + t.Fatalf("binding should be the rule resolver, got %T", opts.Binding) + } + if allowed, _ := rules.Allowed(agentproxy.BindingRequest{Name: "GH", Value: "ghp_x", Dest: agentproxy.Destination{Host: "api.github.com:443", Path: "/", Method: "GET"}}); !allowed { + t.Fatal("the declared rule should allow its host") + } + if allowed, _ := rules.Allowed(agentproxy.BindingRequest{Name: "DB", Value: "plain-value", Dest: agentproxy.Destination{Host: "db.example.com:443", Path: "/", Method: "GET"}}); allowed { + t.Fatal("an undeclared secret must be refused by default") + } +} + +func TestEngineOptionsRejectUnknownUnboundPolicy(t *testing.T) { + if _, err := engineOptions(&proxy.ProxyConfig{Unbound: "maybe"}, proxyStartInputs{logOut: io.Discard, source: staticSource{}}); err == nil { + t.Fatal("an unknown unbound policy must be an error") + } +} From b8e62d7f96025d6adf61e6091a43323b8eb32791 Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Tue, 8 Sep 2026 12:01:37 -0500 Subject: [PATCH 06/20] Check agent cannot read credential source in doctor --- pkg/cmd/agent.go | 70 ++++++++++++++++++++++++++++++++++++++++--- pkg/cmd/agent_test.go | 42 +++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go index 251f3536..363955b6 100644 --- a/pkg/cmd/agent.go +++ b/pkg/cmd/agent.go @@ -25,6 +25,8 @@ import ( "os" "os/signal" "os/user" + "path/filepath" + "runtime" "strconv" "strings" "syscall" @@ -148,7 +150,7 @@ var agentDoctorCmd = &cobra.Command{ } } - report := verify.Doctor{Enforced: enforced, Checks: agentChecks(proxyURL, caPath, strictDNS, testURL)}.Run() + report := verify.Doctor{Enforced: enforced, Checks: agentChecks(proxyURL, caPath, strictDNS, testURL, resolveCredentialSources(cmd))}.Run() report.Render(os.Stdout) os.Exit(report.ExitCode()) }, @@ -157,8 +159,8 @@ var agentDoctorCmd = &cobra.Command{ // 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{ +func agentChecks(proxyURL, caPath string, strictDNS bool, testURL string, credentialSources []string) []verify.Check { + checks := []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"), @@ -177,6 +179,49 @@ func agentChecks(proxyURL, caPath string, strictDNS bool, testURL string) []veri verify.EnvAbsent("DOPPLER_TOKEN"), verify.EnvNoTokenShapes("real token shapes", "dp.st.", "dp.pt."), } + // masking only holds while the agent cannot read the brokered secrets off disk + for _, p := range credentialSources { + checks = append(checks, verify.FileUnreadable("agent cannot read "+p, p)) + } + return checks +} + +// developerHome is the home of the person whose secrets the proxy brokers: the +// user behind sudo under `agent enforce`, otherwise the current user. +func developerHome() string { + if dev := os.Getenv("SUDO_USER"); dev != "" { + if u, err := user.Lookup(dev); err == nil { + return u.HomeDir + } + } + if home, err := os.UserHomeDir(); err == nil { + return home + } + return "" +} + +// credentialSources are the files holding what the proxy brokers on the agent's +// behalf: the developer's Doppler config and the proxy CA key. Files rather +// than their directories, since a directory the agent cannot list still lets it +// open a file inside by name. +func credentialSources(devHome, dataDir string) []string { + var out []string + if devHome != "" { + out = append(out, filepath.Join(devHome, ".doppler", ".doppler.yaml")) + } + if dataDir != "" { + out = append(out, filepath.Join(dataDir, "ca.key")) + } + return out +} + +// dataDirUnder is agentproxy.DefaultDataDir for another user's home, following +// the platform default. --proxy-data-dir covers an XDG_CONFIG_HOME override. +func dataDirUnder(home string) string { + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support", "agent-proxy") + } + return filepath.Join(home, ".config", "agent-proxy") } // agentEnforceCmd installs the sandbox contract IN PLACE — inside a box the user @@ -268,10 +313,14 @@ var agentEnforceCmd = &cobra.Command{ env = enforce.OverrideEnv(env, overrides) env = enforce.RemoveEnv(env, "DOPPLER_TOKEN", "NO_PROXY", "no_proxy") + // Resolved here, while SUDO_USER is still in the environment; Enforce clears + // the environment before the preflight runs as the agent. + sources := resolveCredentialSources(cmd) + // 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 := verify.Doctor{Enforced: true, Checks: agentChecks(proxyURL, caPath, strictDNS, testURL, sources)}.Run() rep.Render(os.Stderr) if rep.Failed() { return errors.New("sandbox contract check failed; refusing to launch the agent") @@ -297,6 +346,17 @@ var agentEnforceCmd = &cobra.Command{ }, } +// resolveCredentialSources reads the developer's home and the proxy data dir +// from the current environment and flags. +func resolveCredentialSources(cmd *cobra.Command) []string { + devHome := developerHome() + dataDir, _ := cmd.Flags().GetString("proxy-data-dir") + if dataDir == "" { + dataDir = dataDirUnder(devHome) + } + return credentialSources(devHome, dataDir) +} + // 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) @@ -364,6 +424,7 @@ func init() { 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") + agentDoctorCmd.Flags().String("proxy-data-dir", "", "proxy data directory holding the CA key (default: the developer's platform config dir)") agentCmd.AddCommand(agentDoctorCmd) agentEnforceCmd.Flags().String("strategy", "shared-box", "egress lock strategy: shared-box (compose onto an existing firewall) or owned-container (flush)") @@ -374,6 +435,7 @@ func init() { 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") + agentEnforceCmd.Flags().String("proxy-data-dir", "", "proxy data directory holding the CA key (default: the developer's platform config dir)") agentCmd.AddCommand(agentEnforceCmd) rootCmd.AddCommand(agentCmd) diff --git a/pkg/cmd/agent_test.go b/pkg/cmd/agent_test.go index 38d830e7..d3cb1747 100644 --- a/pkg/cmd/agent_test.go +++ b/pkg/cmd/agent_test.go @@ -7,7 +7,13 @@ you may not use this file except in compliance with the License. package cmd -import "testing" +import ( + "os" + "os/user" + "path/filepath" + "slices" + "testing" +) // TestProxyUserinfo: `agent enforce` must keep the per-run proxy token from // agent.env's HTTPS_PROXY when it repoints the proxy host — dropping it 407s every @@ -26,3 +32,37 @@ func TestProxyUserinfo(t *testing.T) { } } } + +// The preflight opens the files that hold brokered secrets, by name. A directory +// would prove nothing, since one the agent cannot list still lets it open a file +// inside. +func TestCredentialSourcesAreConcreteFiles(t *testing.T) { + got := credentialSources("/home/dev", "/home/dev/.config/agent-proxy") + want := []string{ + filepath.Join("/home/dev", ".doppler", ".doppler.yaml"), + filepath.Join("/home/dev", ".config", "agent-proxy", "ca.key"), + } + if !slices.Equal(got, want) { + t.Fatalf("credentialSources = %v, want %v", got, want) + } + if len(credentialSources("", "")) != 0 { + t.Fatal("with nothing resolved there is nothing to check") + } +} + +// Under sudo the developer is SUDO_USER, not the root that runs enforce. +func TestDeveloperHomeFollowsSudoUser(t *testing.T) { + me, err := user.Current() + if err != nil { + t.Skip(err) + } + t.Setenv("SUDO_USER", me.Username) + if got := developerHome(); got != me.HomeDir { + t.Fatalf("developerHome under sudo = %q, want %q", got, me.HomeDir) + } + t.Setenv("SUDO_USER", "") + home, _ := os.UserHomeDir() + if got := developerHome(); got != home { + t.Fatalf("developerHome without sudo = %q, want %q", got, home) + } +} From 5529bea1598721e19b2577a34ad69ff49b24f0b6 Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Tue, 8 Sep 2026 20:57:25 -0500 Subject: [PATCH 07/20] Parse credential methods from the proxy config `doppler-proxy.yaml` can now declare a non-static credential method per secret under a `methods: block` entry, static (default), oauth2_client_credentials, or aws_sigv4. This mirrors the existing bindings: block. A CredentialMethod DTO (snake_case yaml) maps to agentproxy.MethodConfig, threaded through Options -> agentproxy.Config alongside Binding. The starter config documents each method with a commented example. This wires the agent-proxy credential-method framework (OAuth2 client-credentials + AWS SigV4) so it's usable end to end. The operator declares token_url/client_id/scopes for OAuth2 or service/region/access_key_id for SigV4, and the agent still only holds the mask. Tests: the methods block parses (oauth2 + sigv4) and maps to the registry; the setting reaches the engine (control: dropping the wiring fails the test); binding/static behavior unchanged. Builds + tests locally against the sibling agent-proxy (replace => ../agent-proxy); CI goes green once agent-proxy is merged and go.mod is pinned. Release Note: (internal) Parse credential methods from doppler-proxy.yaml --- pkg/cmd/proxy.go | 1 + pkg/cmd/proxy_test.go | 8 +++++- pkg/proxy/config.go | 61 ++++++++++++++++++++++++++++++++++++++++ pkg/proxy/config_test.go | 38 +++++++++++++++++++++++++ pkg/proxy/engine.go | 3 ++ pkg/proxy/maskedhash.go | 1 + 6 files changed, 111 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/proxy.go b/pkg/cmd/proxy.go index cfb8c6a3..e89db4a1 100644 --- a/pkg/cmd/proxy.go +++ b/pkg/cmd/proxy.go @@ -190,6 +190,7 @@ func engineOptions(cfg *proxy.ProxyConfig, in proxyStartInputs) (proxy.Options, ProxyAuthToken: in.proxyToken, Binding: binding, AllowPrivateEgress: in.allowPrivateEgress, + Methods: cfg.MethodConfigs(), }, nil } diff --git a/pkg/cmd/proxy_test.go b/pkg/cmd/proxy_test.go index 35e09f22..d1bb2d23 100644 --- a/pkg/cmd/proxy_test.go +++ b/pkg/cmd/proxy_test.go @@ -35,7 +35,10 @@ func (s staticSource) Fetch(_ context.Context, ref agentproxy.SecretRef) (string // here would silently disable a feature. func TestEngineOptionsCarryEverySetting(t *testing.T) { dir := t.TempDir() - cfg := &proxy.ProxyConfig{Bindings: map[string][]agentproxy.Rule{"GH": {{Host: "api.github.com"}}}} + cfg := &proxy.ProxyConfig{ + Bindings: map[string][]agentproxy.Rule{"GH": {{Host: "api.github.com"}}}, + Methods: map[string]proxy.CredentialMethod{"OA": {Kind: "oauth2_client_credentials", TokenURL: "https://p/token", ClientID: "cid"}}, + } opts, err := engineOptions(cfg, proxyStartInputs{ address: "127.0.0.1:14322", dataDir: dir, @@ -68,6 +71,9 @@ func TestEngineOptionsCarryEverySetting(t *testing.T) { if allowed, _ := rules.Allowed(agentproxy.BindingRequest{Name: "DB", Value: "plain-value", Dest: agentproxy.Destination{Host: "db.example.com:443", Path: "/", Method: "GET"}}); allowed { t.Fatal("an undeclared secret must be refused by default") } + if m := opts.Methods["OA"]; m.Kind != "oauth2_client_credentials" || m.TokenURL != "https://p/token" { + t.Fatalf("credential method did not reach the engine: %+v", opts.Methods) + } } func TestEngineOptionsRejectUnknownUnboundPolicy(t *testing.T) { diff --git a/pkg/proxy/config.go b/pkg/proxy/config.go index b7e4be15..27d3b827 100644 --- a/pkg/proxy/config.go +++ b/pkg/proxy/config.go @@ -45,6 +45,29 @@ type ProxyConfig struct { // default) refuses it everywhere, "trust-first-use" pins it to the first // host the agent sends it to. Unbound string `yaml:"unbound"` + + // Methods declares a non-static credential method per secret name. A secret with + // no entry uses the static method: its masked value is swapped in a header. + Methods map[string]CredentialMethod `yaml:"methods"` +} + +// CredentialMethod is how a secret is brokered onto a request (doppler-proxy.yaml). +// It maps to agentproxy.MethodConfig. +type CredentialMethod struct { + // Kind: "static" (default), "oauth2_client_credentials", or "aws_sigv4". + Kind string `yaml:"kind"` + + // OAuth2 client-credentials (kind: oauth2_client_credentials). The secret's value + // is the client secret; the proxy exchanges it for a bearer and injects that. + TokenURL string `yaml:"token_url"` + ClientID string `yaml:"client_id"` + Scopes []string `yaml:"scopes"` + + // AWS SigV4 (kind: aws_sigv4). The secret is the AWS secret access key; access_key_id + // names the secret holding the access key id. Region defaults to us-east-1. + Service string `yaml:"service"` + Region string `yaml:"region"` + AccessKeyID string `yaml:"access_key_id"` } // BindingResolver builds the resolver the proxy authorizes injection with. @@ -61,6 +84,27 @@ func (c *ProxyConfig) BindingResolver() (agentproxy.BindingResolver, error) { return agentproxy.NewRuleResolver(c.Bindings, policy), nil } +// MethodConfigs maps the user's credential-method declarations to the agent-proxy +// method registry. Returns nil when none are declared (every secret is static). +func (c *ProxyConfig) MethodConfigs() map[string]agentproxy.MethodConfig { + if len(c.Methods) == 0 { + return nil + } + out := make(map[string]agentproxy.MethodConfig, len(c.Methods)) + for name, m := range c.Methods { + out[name] = agentproxy.MethodConfig{ + Kind: m.Kind, + TokenURL: m.TokenURL, + ClientID: m.ClientID, + Scopes: m.Scopes, + Service: m.Service, + Region: m.Region, + AccessKeyID: m.AccessKeyID, + } + } + return out +} + // 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). @@ -103,6 +147,23 @@ passthrough: # logs the host it was sent to. trust-first-use pins it to the first host the # agent uses, which lets the agent decide where the credential goes. # unbound: deny + +# Non-static credential methods, by secret name. A secret omitted here is injected as +# its literal value (static). oauth2_client_credentials exchanges the secret for a +# bearer at token_url and injects that; aws_sigv4 signs the whole request with the AWS +# secret access key (access_key_id names the secret holding the key id; region defaults +# to us-east-1). In every case the agent only ever holds the mask. +# methods: +# MY_OAUTH_SECRET: +# kind: oauth2_client_credentials +# token_url: https://provider.example.com/oauth/token +# client_id: your-client-id +# scopes: [read, write] +# AWS_SECRET_ACCESS_KEY: +# kind: aws_sigv4 +# service: s3 +# region: us-east-1 +# access_key_id: AWS_ACCESS_KEY_ID ` // LoadOrScaffold loads the proxy config from path. If the file does not exist it diff --git a/pkg/proxy/config_test.go b/pkg/proxy/config_test.go index e1460671..cf656fc0 100644 --- a/pkg/proxy/config_test.go +++ b/pkg/proxy/config_test.go @@ -152,3 +152,41 @@ func TestBindingResolverRejectsUnknownPolicy(t *testing.T) { t.Fatal("an unknown unbound policy must be an error") } } + +func TestParseMethods(t *testing.T) { + cfg, err := parseProxyConfig([]byte(` +methods: + OAUTH_SECRET: + kind: oauth2_client_credentials + token_url: https://provider.example.com/oauth/token + client_id: cid + scopes: [read, write] + AWS_SECRET_ACCESS_KEY: + kind: aws_sigv4 + service: s3 + region: us-west-2 + access_key_id: AWS_ACCESS_KEY_ID +`)) + if err != nil { + t.Fatal(err) + } + o := cfg.Methods["OAUTH_SECRET"] + if o.Kind != "oauth2_client_credentials" || o.TokenURL != "https://provider.example.com/oauth/token" || o.ClientID != "cid" || !slices.Equal(o.Scopes, []string{"read", "write"}) { + t.Fatalf("oauth method = %+v", o) + } + a := cfg.Methods["AWS_SECRET_ACCESS_KEY"] + if a.Kind != "aws_sigv4" || a.Service != "s3" || a.Region != "us-west-2" || a.AccessKeyID != "AWS_ACCESS_KEY_ID" { + t.Fatalf("sigv4 method = %+v", a) + } + // snake_case yaml maps cleanly to the agent-proxy method registry. + m := cfg.MethodConfigs() + if m["OAUTH_SECRET"].TokenURL != "https://provider.example.com/oauth/token" || m["AWS_SECRET_ACCESS_KEY"].AccessKeyID != "AWS_ACCESS_KEY_ID" { + t.Fatalf("MethodConfigs mapping wrong: %+v", m) + } +} + +func TestMethodConfigsNilWhenEmpty(t *testing.T) { + if got := (&ProxyConfig{}).MethodConfigs(); got != nil { + t.Fatalf("expected nil methods when none declared, got %v", got) + } +} diff --git a/pkg/proxy/engine.go b/pkg/proxy/engine.go index 99804d85..fae07060 100644 --- a/pkg/proxy/engine.go +++ b/pkg/proxy/engine.go @@ -59,6 +59,9 @@ type Options struct { // AllowPrivateEgress lets the proxy connect to loopback and private-network // addresses, for local development against a local upstream. AllowPrivateEgress bool + // Methods declares a non-static credential brokering method per secret name + // (OAuth2 client-credentials, AWS SigV4). Empty means every secret is static. + Methods map[string]agentproxy.MethodConfig } // Factory builds an Engine from Options. diff --git a/pkg/proxy/maskedhash.go b/pkg/proxy/maskedhash.go index 3d44e33c..f9ac7a1f 100644 --- a/pkg/proxy/maskedhash.go +++ b/pkg/proxy/maskedhash.go @@ -40,6 +40,7 @@ func init() { ProxyAuthToken: opts.ProxyAuthToken, Binding: opts.Binding, AllowPrivateEgress: opts.AllowPrivateEgress, + Methods: opts.Methods, }) }) } From 9fa16ce87f54e927b6c0906cd5a00561936baaa5 Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Tue, 8 Sep 2026 21:19:11 -0500 Subject: [PATCH 08/20] Probe IPv6 egress in the agent doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared-box egress lock writes only iptables rules, so the agent's IPv6 traffic is unrestricted wherever the container has an IPv6 route — external routes and `::1` services (e.g. a local Postgres) alike. The doctor probed only IPv4 literals, so it reported a clean, contained result on a box whose IPv6 egress was wide open. Extract the clause-1 egress targets into egressProbeTargets and add two IPv6 probes: an external literal (2606:4700:4700::1111) and an IPv6 loopback service port ([::1]:5432). EgressBlockedTCP already dials "tcp", so bracketed IPv6 literals work with no change to the verify package. Test asserts the list covers both IP families and that every entry is an IP literal (a hostname would fail at resolution and falsely look contained) — wiring only, no dialing, so it can't go flaky; control: dropping the IPv6 entries fails it. The dial behavior itself is covered by EgressBlockedTCP's own tests. Buildable against the current agent-proxy verify API (local replace); the go.mod pin lands with the other agent-proxy follow-ups once that module is in main. --- pkg/cmd/agent.go | 31 +++++++++++++++++++++++++------ pkg/cmd/agent_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go index 363955b6..c915960b 100644 --- a/pkg/cmd/agent.go +++ b/pkg/cmd/agent.go @@ -159,12 +159,31 @@ var agentDoctorCmd = &cobra.Command{ // 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. +// egressProbeTargets are the IP:port literals clause 1 proves are directly +// unreachable from the agent. They span both IP families on purpose: a +// shared-box lock that only writes iptables rules leaves the agent's IPv6 +// egress wide open wherever the container has an IPv6 route, so an IPv4-only +// probe list reports "contained" on a box that isn't. The list mixes external +// routes (the agent must not reach the internet directly) with an IPv6 loopback +// service port (a `::1` Postgres or the like is egress the lock must also cut, +// and netfilter's IPv4 chain never sees it). Every entry is an IP literal, never +// a hostname — a blocked resolver would make a hostname dial fail at resolution +// and falsely look contained. +var egressProbeTargets = []string{ + "1.1.1.1:443", // IPv4 external + "8.8.8.8:443", // IPv4 external + "1.1.1.1:80", // IPv4 external (plaintext) + "[2606:4700:4700::1111]:443", // IPv6 external — an IPv4-only iptables lock never covers this + "[::1]:5432", // IPv6 loopback — a local service (e.g. Postgres) the agent must not reach +} + func agentChecks(proxyURL, caPath string, strictDNS bool, testURL string, credentialSources []string) []verify.Check { - checks := []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"), + // clause 1 — egress containment (adversarial: dial by IP literal, both families) + var checks []verify.Check + for _, addr := range egressProbeTargets { + checks = append(checks, verify.EgressBlockedTCP(addr)) + } + checks = append(checks, verify.EgressDNS("8.8.8.8:53", strictDNS), // proxy reachability verify.ProxyReachable(proxyURL), @@ -178,7 +197,7 @@ func agentChecks(proxyURL, caPath string, strictDNS bool, testURL string, creden // credential hygiene (Doppler-specific) verify.EnvAbsent("DOPPLER_TOKEN"), verify.EnvNoTokenShapes("real token shapes", "dp.st.", "dp.pt."), - } + ) // masking only holds while the agent cannot read the brokered secrets off disk for _, p := range credentialSources { checks = append(checks, verify.FileUnreadable("agent cannot read "+p, p)) diff --git a/pkg/cmd/agent_test.go b/pkg/cmd/agent_test.go index d3cb1747..3a28b705 100644 --- a/pkg/cmd/agent_test.go +++ b/pkg/cmd/agent_test.go @@ -8,6 +8,7 @@ you may not use this file except in compliance with the License. package cmd import ( + "net" "os" "os/user" "path/filepath" @@ -15,6 +16,42 @@ import ( "testing" ) +// The egress probes must cover BOTH IP families. A shared-box lock writes only +// iptables rules, so an IPv4-only probe list reports "contained" while the +// agent's IPv6 egress — external routes and `::1` services alike — is wide +// open. This asserts the wiring (both families, all IP literals) without dialing, +// so it can't go flaky; EgressBlockedTCP's own tests cover the dial behavior. +func TestEgressProbesCoverBothIPFamilies(t *testing.T) { + var v4, v6External, v6Loopback bool + for _, addr := range egressProbeTargets { + host, _, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("probe %q is not a valid host:port: %v", addr, err) + } + ip := net.ParseIP(host) + if ip == nil { + t.Fatalf("probe %q must be an IP literal, got host %q (a hostname would fail at resolution and falsely look contained)", addr, host) + } + switch { + case ip.To4() != nil: + v4 = true + case ip.IsLoopback(): + v6Loopback = true + default: + v6External = true + } + } + if !v4 { + t.Error("no IPv4 egress probe") + } + if !v6External { + t.Error("no external IPv6 egress probe; an IPv4-only iptables lock leaves IPv6 egress open") + } + if !v6Loopback { + t.Error("no IPv6 loopback egress probe; `::1` services bypass an IPv4-only lock") + } +} + // TestProxyUserinfo: `agent enforce` must keep the per-run proxy token from // agent.env's HTTPS_PROXY when it repoints the proxy host — dropping it 407s every // agent request. From b03c04369c3d1f84f8967e863337610cfc59648f Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Tue, 8 Sep 2026 22:34:04 -0500 Subject: [PATCH 09/20] Pare down the scaffolded passthrough list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sentry (a third-party, world-writable error sink) and statsig telemetry were blind-tunneled — unexamined holes that never reach the audit log. Both work fine intercepted, so drop them and document why each survivor stays. Removing a host from the list only causes it to be intercepted, never blocked, so the list should hold just the agent's control plane and the auth endpoints that break under interception. --- pkg/proxy/config.go | 19 +++++++++++-------- pkg/proxy/config_test.go | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/pkg/proxy/config.go b/pkg/proxy/config.go index b7e4be15..6ca29c31 100644 --- a/pkg/proxy/config.go +++ b/pkg/proxy/config.go @@ -74,19 +74,22 @@ const starterConfig = `# doppler-proxy.yaml — configuration for the Doppler ag # --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. +# Hosts the proxy BLIND-TUNNELS instead of intercepting: no TLS termination, no +# credential injection, and nothing in the audit log. Every entry is a hole we chose, +# so keep this list as short as possible. Removing a host does NOT block it — the host +# is simply intercepted instead (the agent trusts the proxy CA), so it still works and +# is now examined. Only two kinds of host belong here: the agent's own control plane +# that we deliberately don't inspect, and auth endpoints that BREAK under interception +# because they reject an unexpected CA. Matching is exact — there is no wildcard. passthrough: + # Claude's model API — the endpoint the agent exists to use. Passed through so the + # agent's own model traffic is never intercepted. - api.anthropic.com + # Claude Code login/session and OAuth token refresh. Auth endpoints reject the proxy's + # unexpected CA, so intercepting these breaks sign-in. Kept to Anthropic's first party. - console.anthropic.com - claude.ai - claude.com - - statsig.anthropic.com - - sentry.io # Where each secret may be injected. A secret with no entry is refused everywhere # unless unbound below says otherwise. paths are globs matched per segment (** spans diff --git a/pkg/proxy/config_test.go b/pkg/proxy/config_test.go index e1460671..fa08431d 100644 --- a/pkg/proxy/config_test.go +++ b/pkg/proxy/config_test.go @@ -58,6 +58,29 @@ func TestLoadOrScaffold(t *testing.T) { } } +// ENG-9723: the scaffolded passthrough list is a set of blind holes — no audit, no +// injection — so it must stay minimal. A third-party error sink (sentry.io) or +// telemetry (statsig.anthropic.com) must not be blind-tunneled: they work fine +// intercepted, and a Sentry DSN is a world-writable exfil endpoint. +func TestScaffoldedPassthroughDropsTelemetryHoles(t *testing.T) { + cfg, err := parseProxyConfig([]byte(starterConfig)) + if err != nil { + t.Fatal(err) + } + banned := map[string]string{ + "sentry.io": "a third-party, world-writable error sink", + "statsig.anthropic.com": "telemetry", + } + for _, h := range cfg.Passthrough { + if why, bad := banned[h]; bad { + t.Errorf("passthrough must not blind-tunnel %q (%s) — it works intercepted", h, why) + } + } + if !slices.Contains(cfg.Passthrough, "api.anthropic.com") { + t.Error("api.anthropic.com must remain — the agent cannot function without its model API") + } +} + func TestLoadOrScaffoldRewritesEmptyFile(t *testing.T) { path := filepath.Join(t.TempDir(), "doppler-proxy.yaml") // Pre-create an empty (blank) file — the bug case. From c933e9e4ab2c646bffc84c3a28bd68f935d30520 Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Tue, 8 Sep 2026 21:36:09 -0500 Subject: [PATCH 10/20] Stop passing a CA path to enforce agent-proxy dropped enforce.Config.CACertPath (it no longer installs a system-trust CA), so setting the field no longer compiles. The agent env's CA vars carry the trust the preflight doctor already verifies. --- pkg/cmd/agent.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go index c915960b..f2db9c94 100644 --- a/pkg/cmd/agent.go +++ b/pkg/cmd/agent.go @@ -313,6 +313,11 @@ var agentEnforceCmd = &cobra.Command{ "NODE_EXTRA_CA_CERTS": caPath, "CURL_CA_BUNDLE": caPath, "SSL_CERT_FILE": caPath, + // git and Python's requests honor their own CA vars, not the three above; + // without these, git over HTTPS to an intercepted host fails in the enforced + // box now that enforce no longer installs the CA into the system trust store. + "GIT_SSL_CAINFO": caPath, + "REQUESTS_CA_BUNDLE": caPath, // Enforce clears the environment before exec, so the essential process // vars for the dropped-privilege agent must be set explicitly. "HOME": u.HomeDir, @@ -347,10 +352,11 @@ var agentEnforceCmd = &cobra.Command{ return nil } + // No CA path is passed: Enforce no longer installs a system-trust CA (ENG-9745); + // the agent env's CA vars carry that trust instead. err = enforce.Enforce(enforce.Config{ Strategy: strat, Params: enforce.Params{ProxyIP: proxyIP, ProxyPort: proxyPort, AgentUID: uid}, - CACertPath: caPath, AgentUID: uid, AgentGID: gid, AgentGroups: groups, From 49c5b0f2b8d987e77bf1bc0fd4d93c8625665934 Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Tue, 8 Sep 2026 21:37:20 -0500 Subject: [PATCH 11/20] Check the capability bounding set in doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapts the CLI to two hardening changes in the agent-proxy `enforce`/`verify` packages. This PR makes it so agent-proxy's `enforce` no longer installs the proxy CA into the system trust store. That method trusted an arbitrary CA box-wide and let a hostile data dir have root copy a symlinked `ca.crt` into a world-readable path. The `Config.CACertPath` field is gone with it, so the CLI's `enforce.Config` literal no longer compiles until it stops settingit. Nothing is lost: the proxy already writes `NODE_EXTRA_CA_CERTS`, `CURL_CA_BUNDLE` and `SSL_CERT_FILE` (all → the data-dir `ca.crt`) into the agent env, so Node, curl and Go trust the proxy without a system-store install, and the preflight doctor still verifies that trust via `CATrustEnv` + `CACertValid(caPath)`. `caPath` stays for exactly that check. `TestPrivilegeChecksCoverBoundingSet` asserts the list covers both the effective and bounding sets (a check's `Name` is set regardless of platform/result, so it reads names without dialing or touching `/proc`. control: dropping the check fails it). The checks' own pass/fail logic is covered by agent-proxy's cap tests. ### CI note Red until agent-proxy lands in main and `go.mod` is pinned (the module currently builds against a local `replace => ../agent-proxy`). Tracked as ENG-9769; not part of this diff. Release Note: (internal) Drop system-trust CA in favor of enforce script --- pkg/cmd/agent.go | 22 ++++++++++++++++++---- pkg/cmd/agent_test.go | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go index f2db9c94..3850d247 100644 --- a/pkg/cmd/agent.go +++ b/pkg/cmd/agent.go @@ -191,10 +191,11 @@ func agentChecks(proxyURL, caPath string, strictDNS bool, testURL string, creden verify.CACertValid(caPath), verify.CATrustEnv(), verify.CAEndToEnd(proxyURL, testURL), - // clause 2 — privilege - verify.UIDNotRoot(), - verify.NetAdminAbsent(), - // credential hygiene (Doppler-specific) + ) + // clause 2 — privilege + checks = append(checks, privilegeChecks()...) + // credential hygiene (Doppler-specific) + checks = append(checks, verify.EnvAbsent("DOPPLER_TOKEN"), verify.EnvNoTokenShapes("real token shapes", "dp.st.", "dp.pt."), ) @@ -205,6 +206,19 @@ func agentChecks(proxyURL, caPath string, strictDNS bool, testURL string, creden return checks } +// privilegeChecks proves clause 2: the agent runs unprivileged AND cannot regain +// the capability it would need to unlock its own egress. A clean effective set +// (NetAdminAbsent) is not enough on its own — while CAP_NET_ADMIN remains in the +// bounding set, a file-capability or setuid binary can hand it back — so the +// bounding set must be clean too (NetAdminNotAcquirable, ENG-9749). +func privilegeChecks() []verify.Check { + return []verify.Check{ + verify.UIDNotRoot(), + verify.NetAdminAbsent(), + verify.NetAdminNotAcquirable(), + } +} + // developerHome is the home of the person whose secrets the proxy brokers: the // user behind sudo under `agent enforce`, otherwise the current user. func developerHome() string { diff --git a/pkg/cmd/agent_test.go b/pkg/cmd/agent_test.go index 3a28b705..4b3cd7a7 100644 --- a/pkg/cmd/agent_test.go +++ b/pkg/cmd/agent_test.go @@ -52,6 +52,24 @@ func TestEgressProbesCoverBothIPFamilies(t *testing.T) { } } +// Clause 2 must cover BOTH the effective capability set and the bounding set: a +// clean effective set still lets a file-capability or setuid binary hand +// CAP_NET_ADMIN back, so a bounding-set check is required too (ENG-9749). A +// check's Name is set regardless of platform or result, so this reads the names +// without dialing the network or depending on /proc — it can't go flaky. +func TestPrivilegeChecksCoverBoundingSet(t *testing.T) { + var names []string + for _, c := range privilegeChecks() { + names = append(names, c().Name) + } + if !slices.Contains(names, "CAP_NET_ADMIN") { + t.Errorf("privilege checks must include the effective-set NET_ADMIN check; got %v", names) + } + if !slices.Contains(names, "CAP_NET_ADMIN (bounding set)") { + t.Errorf("privilege checks must also cover the capability bounding set; got %v", names) + } +} + // TestProxyUserinfo: `agent enforce` must keep the per-run proxy token from // agent.env's HTTPS_PROXY when it repoints the proxy host — dropping it 407s every // agent request. From c49b9e8c303002e8de05ebbd231ecc3e66306af5 Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Tue, 8 Sep 2026 22:52:20 -0500 Subject: [PATCH 12/20] Pre-seed scaffolded bindings from secret names An explicit secret-to-upstream mapping is where most of these vulnerabilities get closed, but an operator faced with a blank example rarely writes one. On first run the scaffolded config now documents the binding shape (host/paths/methods) and lists one commented stub per secret in the user's own Doppler config, so they edit real names instead of transcribing a generic example. Every stub stays commented, so a fresh scaffold still injects nothing until a host is filled in; with no names reachable it falls back to the provider example. The existing --proxy-config flag already lets the file live anywhere. --- pkg/cmd/proxy.go | 11 +++++- pkg/proxy/config.go | 82 ++++++++++++++++++++++++++++++---------- pkg/proxy/config_test.go | 44 +++++++++++++++++++-- 3 files changed, 112 insertions(+), 25 deletions(-) diff --git a/pkg/cmd/proxy.go b/pkg/cmd/proxy.go index e89db4a1..27277f2d 100644 --- a/pkg/cmd/proxy.go +++ b/pkg/cmd/proxy.go @@ -94,7 +94,14 @@ var proxyStartCmd = &cobra.Command{ if proxyConfigPath == "" { proxyConfigPath = filepath.Join(dataDir, "doppler-proxy.yaml") } - proxyConfig, created, err := proxy.LoadOrScaffold(proxyConfigPath) + // The Doppler-backed secret source, reused below for the engine. On first run + // its secret names also pre-seed the scaffolded bindings template so the + // operator edits real entries instead of a generic example. + source := proxy.NewDopplerSource(localConfig) + proxyConfig, created, err := proxy.LoadOrScaffold(proxyConfigPath, func() []string { + names, _ := source.List(context.Background()) + return names + }) if err != nil { utils.HandleError(err, "unable to load the proxy config") } @@ -136,7 +143,7 @@ var proxyStartCmd = &cobra.Command{ upstreamProxy: upstreamProxy, proxyToken: proxyToken, allowPrivateEgress: allowPrivateEgress, - source: proxy.NewDopplerSource(localConfig), + source: source, }) if err != nil { utils.HandleError(err, "invalid bindings in the proxy config") diff --git a/pkg/proxy/config.go b/pkg/proxy/config.go index 3e9f21ec..b338d7e1 100644 --- a/pkg/proxy/config.go +++ b/pkg/proxy/config.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "os" + "strings" agentproxy "github.com/DopplerHQ/agent-proxy" "gopkg.in/yaml.v3" @@ -105,10 +106,10 @@ func (c *ProxyConfig) MethodConfigs() map[string]agentproxy.MethodConfig { return out } -// 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. +// starterConfigHead is the top of the scaffolded config (addressing + passthrough). +// The bindings section between it and starterConfigTail is generated by +// scaffoldBindings so it can be pre-seeded with the operator's own secret names. +const starterConfigHead = `# 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 @@ -135,17 +136,10 @@ passthrough: - claude.ai - claude.com -# Where each secret may be injected. A secret with no entry is refused everywhere -# unless unbound below says otherwise. paths are globs matched per segment (** spans -# segments) and methods are optional; both default to any. -# bindings: -# GITHUB_TOKEN: -# - host: api.github.com -# paths: ["/repos/**", "/user"] -# methods: [GET, POST] -# STRIPE_KEY: -# - host: api.stripe.com +` +// starterConfigTail closes out the scaffolded config after the bindings section. +const starterConfigTail = ` # Policy for a secret with no bindings entry. deny refuses it everywhere and # logs the host it was sent to. trust-first-use pins it to the first host the # agent uses, which lets the agent decide where the credential goes. @@ -169,9 +163,54 @@ passthrough: # access_key_id: AWS_ACCESS_KEY_ID ` -// 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) { +// scaffoldBindings renders the commented "bindings" section of the starter config. +// A binding is where security actually happens — it maps a secret NAME to the upstream +// host(s) it may reach — so the template documents the shape and, when the caller can +// enumerate the operator's secret names, pre-seeds one commented stub per secret. That +// way the operator edits real entries instead of transcribing a generic example; with +// no names available it falls back to provider examples. Every stub is commented, so a +// freshly scaffolded config injects nothing until the operator fills in a host. +func scaffoldBindings(secretNames []string) string { + var b strings.Builder + b.WriteString(`# Bindings: where each secret may be injected. A binding maps a secret NAME to the +# upstream host(s) it may reach. A secret with no binding is refused everywhere (see +# unbound, below), so this is how you tell the proxy which destination each secret is for. +# host — upstream hostname the secret may reach (required) +# paths — optional glob list; ** spans path segments. Omit to allow any path. +# methods — optional [GET, POST, ...]. Omit to allow any method. +# For reference: GITHUB_TOKEN -> api.github.com, STRIPE_SECRET_KEY -> api.stripe.com, +# OPENAI_API_KEY -> api.openai.com. +# +# Uncomment "bindings:" and set the host for each secret you want the agent to use. +# bindings: +`) + names := secretNames + if len(names) == 0 { + // No secret names available — show generic examples instead. + names = []string{"GITHUB_TOKEN", "STRIPE_KEY"} + } + for i, n := range names { + b.WriteString("# " + n + ":\n") + b.WriteString("# - host: \n") + if i == 0 { + b.WriteString("# # paths: [\"/repos/**\"] # optional: restrict to path globs\n") + b.WriteString("# # methods: [GET, POST] # optional: restrict to HTTP methods\n") + } + } + return b.String() +} + +// buildStarterConfig assembles the scaffolded config, pre-seeding the bindings section +// with the given secret names when available. +func buildStarterConfig(secretNames []string) string { + return starterConfigHead + scaffoldBindings(secretNames) + starterConfigTail +} + +// LoadOrScaffold loads the proxy config from path. If the file does not exist (or is +// blank) it writes the starter config and returns it with created=true. secretNames is +// called only when scaffolding, to pre-seed the bindings template with the operator's +// own secret names; it may be nil. +func LoadOrScaffold(path string, secretNames func() []string) (cfg *ProxyConfig, created bool, err error) { data, err := os.ReadFile(path) if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, false, err @@ -180,10 +219,15 @@ func LoadOrScaffold(path string) (cfg *ProxyConfig, created bool, err error) { // 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 { + var names []string + if secretNames != nil { + names = secretNames() + } + content := buildStarterConfig(names) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { return nil, false, err } - cfg, err = parseProxyConfig([]byte(starterConfig)) + cfg, err = parseProxyConfig([]byte(content)) return cfg, true, err } cfg, err = parseProxyConfig(data) diff --git a/pkg/proxy/config_test.go b/pkg/proxy/config_test.go index b47f128a..8a44f084 100644 --- a/pkg/proxy/config_test.go +++ b/pkg/proxy/config_test.go @@ -20,15 +20,51 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" agentproxy "github.com/DopplerHQ/agent-proxy" ) +// On first run the scaffolded config pre-seeds the bindings section with the +// operator's own secret names (ENG-9770) — a commented stub per secret — so they +// edit real entries. The stubs stay commented, so a fresh scaffold injects nothing +// until a host is filled in. +func TestScaffoldSeedsBindingStubsFromSecretNames(t *testing.T) { + path := filepath.Join(t.TempDir(), "doppler-proxy.yaml") + cfg, created, err := LoadOrScaffold(path, func() []string { return []string{"DATABASE_URL", "GITHUB_TOKEN"} }) + if err != nil || !created { + t.Fatalf("scaffold: created=%v err=%v", created, err) + } + data, _ := os.ReadFile(path) + for _, name := range []string{"DATABASE_URL", "GITHUB_TOKEN"} { + if !strings.Contains(string(data), "# "+name+":") { + t.Errorf("scaffolded config missing a binding stub for %q\n%s", name, data) + } + } + // The stubs are commented, so nothing is actually bound yet. + if len(cfg.Bindings) != 0 { + t.Errorf("scaffolded stubs must be commented (inactive), got bindings %v", cfg.Bindings) + } +} + +// With no secret names available, scaffolding falls back to the generic provider +// example rather than an empty bindings section. +func TestScaffoldFallsBackToExampleWithoutNames(t *testing.T) { + path := filepath.Join(t.TempDir(), "doppler-proxy.yaml") + if _, _, err := LoadOrScaffold(path, nil); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), "# GITHUB_TOKEN:") { + t.Errorf("fallback scaffold should carry the GITHUB_TOKEN example\n%s", data) + } +} + func TestLoadOrScaffold(t *testing.T) { path := filepath.Join(t.TempDir(), "doppler-proxy.yaml") - cfg, created, err := LoadOrScaffold(path) + cfg, created, err := LoadOrScaffold(path, nil) if err != nil { t.Fatal(err) } @@ -46,7 +82,7 @@ func TestLoadOrScaffold(t *testing.T) { } // A second load reads the existing file — not scaffolded again. - cfg2, created2, err := LoadOrScaffold(path) + cfg2, created2, err := LoadOrScaffold(path, nil) if err != nil { t.Fatal(err) } @@ -63,7 +99,7 @@ func TestLoadOrScaffold(t *testing.T) { // telemetry (statsig.anthropic.com) must not be blind-tunneled: they work fine // intercepted, and a Sentry DSN is a world-writable exfil endpoint. func TestScaffoldedPassthroughDropsTelemetryHoles(t *testing.T) { - cfg, err := parseProxyConfig([]byte(starterConfig)) + cfg, err := parseProxyConfig([]byte(buildStarterConfig(nil))) if err != nil { t.Fatal(err) } @@ -87,7 +123,7 @@ func TestLoadOrScaffoldRewritesEmptyFile(t *testing.T) { if err := os.WriteFile(path, []byte(" \n"), 0o644); err != nil { t.Fatal(err) } - cfg, created, err := LoadOrScaffold(path) + cfg, created, err := LoadOrScaffold(path, nil) if err != nil { t.Fatal(err) } From 6fe76efe9866b158003243a7aea168907ac31bed Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Wed, 9 Sep 2026 15:41:38 -0500 Subject: [PATCH 13/20] Add pass_by_value to proxy config names the secrets the agent has to hold for real, typically its own model provider token whose host is passed through --- pkg/cmd/proxy.go | 1 + pkg/cmd/proxy_test.go | 8 ++++++-- pkg/proxy/config.go | 11 +++++++++++ pkg/proxy/config_test.go | 10 ++++++++++ pkg/proxy/engine.go | 2 ++ pkg/proxy/maskedhash.go | 1 + 6 files changed, 31 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/proxy.go b/pkg/cmd/proxy.go index 27277f2d..b3af98e7 100644 --- a/pkg/cmd/proxy.go +++ b/pkg/cmd/proxy.go @@ -198,6 +198,7 @@ func engineOptions(cfg *proxy.ProxyConfig, in proxyStartInputs) (proxy.Options, Binding: binding, AllowPrivateEgress: in.allowPrivateEgress, Methods: cfg.MethodConfigs(), + PassByValue: cfg.PassByValue, }, nil } diff --git a/pkg/cmd/proxy_test.go b/pkg/cmd/proxy_test.go index d1bb2d23..3b960bfb 100644 --- a/pkg/cmd/proxy_test.go +++ b/pkg/cmd/proxy_test.go @@ -36,8 +36,9 @@ func (s staticSource) Fetch(_ context.Context, ref agentproxy.SecretRef) (string func TestEngineOptionsCarryEverySetting(t *testing.T) { dir := t.TempDir() cfg := &proxy.ProxyConfig{ - Bindings: map[string][]agentproxy.Rule{"GH": {{Host: "api.github.com"}}}, - Methods: map[string]proxy.CredentialMethod{"OA": {Kind: "oauth2_client_credentials", TokenURL: "https://p/token", ClientID: "cid"}}, + Bindings: map[string][]agentproxy.Rule{"GH": {{Host: "api.github.com"}}}, + Methods: map[string]proxy.CredentialMethod{"OA": {Kind: "oauth2_client_credentials", TokenURL: "https://p/token", ClientID: "cid"}}, + PassByValue: []string{"MODEL_TOKEN"}, } opts, err := engineOptions(cfg, proxyStartInputs{ address: "127.0.0.1:14322", @@ -58,6 +59,9 @@ func TestEngineOptionsCarryEverySetting(t *testing.T) { if opts.AgentEnvPath != filepath.Join(dir, "agent.env") || opts.DataDir != dir { t.Fatalf("data dir paths wrong: %+v", opts) } + if len(opts.PassByValue) != 1 || opts.PassByValue[0] != "MODEL_TOKEN" { + t.Fatalf("pass_by_value did not reach the engine: %+v", opts.PassByValue) + } if _, ok := opts.Secrets.(*agentproxy.RefreshingSource); !ok { t.Fatalf("secrets should be wrapped in RefreshingSource, got %T", opts.Secrets) } diff --git a/pkg/proxy/config.go b/pkg/proxy/config.go index b338d7e1..5f52f2a3 100644 --- a/pkg/proxy/config.go +++ b/pkg/proxy/config.go @@ -50,6 +50,10 @@ type ProxyConfig struct { // Methods declares a non-static credential method per secret name. A secret with // no entry uses the static method: its masked value is swapped in a header. Methods map[string]CredentialMethod `yaml:"methods"` + + // PassByValue names secrets the agent holds for real rather than as a mask, + // typically its own model provider token whose host is passed through. + PassByValue []string `yaml:"pass_by_value"` } // CredentialMethod is how a secret is brokered onto a request (doppler-proxy.yaml). @@ -161,6 +165,13 @@ const starterConfigTail = ` # service: s3 # region: us-east-1 # access_key_id: AWS_ACCESS_KEY_ID + +# Secrets the agent has to hold for real, typically its own model provider token. +# Their host belongs on the passthrough list above, so the injector never sees +# them. The proxy writes them into agent.env as plaintext and refuses any +# intercepted request that carries one. +# pass_by_value: +# - MODEL_PROVIDER_TOKEN ` // scaffoldBindings renders the commented "bindings" section of the starter config. diff --git a/pkg/proxy/config_test.go b/pkg/proxy/config_test.go index 8a44f084..3e86ad3b 100644 --- a/pkg/proxy/config_test.go +++ b/pkg/proxy/config_test.go @@ -249,3 +249,13 @@ func TestMethodConfigsNilWhenEmpty(t *testing.T) { t.Fatalf("expected nil methods when none declared, got %v", got) } } + +func TestParsePassByValue(t *testing.T) { + cfg, err := parseProxyConfig([]byte("pass_by_value:\n - MODEL_TOKEN\n - OTHER\n")) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(cfg.PassByValue, []string{"MODEL_TOKEN", "OTHER"}) { + t.Fatalf("pass_by_value = %v", cfg.PassByValue) + } +} diff --git a/pkg/proxy/engine.go b/pkg/proxy/engine.go index fae07060..5b68dbef 100644 --- a/pkg/proxy/engine.go +++ b/pkg/proxy/engine.go @@ -62,6 +62,8 @@ type Options struct { // Methods declares a non-static credential brokering method per secret name // (OAuth2 client-credentials, AWS SigV4). Empty means every secret is static. Methods map[string]agentproxy.MethodConfig + // PassByValue names the secrets written to the agent env as real values. + PassByValue []string } // Factory builds an Engine from Options. diff --git a/pkg/proxy/maskedhash.go b/pkg/proxy/maskedhash.go index f9ac7a1f..e0c48187 100644 --- a/pkg/proxy/maskedhash.go +++ b/pkg/proxy/maskedhash.go @@ -41,6 +41,7 @@ func init() { Binding: opts.Binding, AllowPrivateEgress: opts.AllowPrivateEgress, Methods: opts.Methods, + PassByValue: opts.PassByValue, }) }) } From d627da0ba91b6b07dfdbe12baca6e880cc1cee3a Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Wed, 9 Sep 2026 15:41:38 -0500 Subject: [PATCH 14/20] Stop forwarding agent auth from host shell the agent's token now comes from the Doppler config via pass_by_value, so the CLI no longer needs to know any agent's variable names --- pkg/cmd/agent.go | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go index 3850d247..186735c8 100644 --- a/pkg/cmd/agent.go +++ b/pkg/cmd/agent.go @@ -67,23 +67,6 @@ var agentRunCmd = &cobra.Command{ } } - // 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, @@ -91,7 +74,6 @@ var agentRunCmd = &cobra.Command{ Command: args, DockerBin: dockerBin, Interactive: true, - Env: env, } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -340,13 +322,6 @@ var agentEnforceCmd = &cobra.Command{ "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") From 9610ba238cfff31fbc5a27d622f6b4455f81289c Mon Sep 17 00:00:00 2001 From: Mike Sellitto Date: Wed, 9 Sep 2026 15:42:12 -0500 Subject: [PATCH 15/20] Update go.mod and go.sum for agent-proxy dependencies --- go.mod | 5 +++++ go.sum | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/go.mod b/go.mod index eb288fd6..3c4f7654 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,11 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require ( + golang.org/x/net v0.56.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect +) + require ( github.com/DopplerHQ/agent-proxy v0.0.0-00010101000000-000000000000 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect diff --git a/go.sum b/go.sum index 838e4d98..f683b234 100644 --- a/go.sum +++ b/go.sum @@ -128,6 +128,10 @@ golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2d golang.org/x/exp v0.0.0-20260718201538-764159d718ef h1:LkZ48HFgy/TvhTI0bcWkjgFkgLyKUwcTbDjS0DUjw+A= golang.org/x/exp v0.0.0-20260718201538-764159d718ef/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= From f46fb2c46d1355217e2cb9098de6f42d1e6be119 Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Thu, 10 Sep 2026 11:38:10 -0500 Subject: [PATCH 16/20] Make the CLI name and config location injectable A demo distribution of the fork (the agent-proxy build) must not collide with a customer's production doppler CLI: it needs a distinct name on PATH and its own config dir so it can't read or clobber their real credentials. Rather than fork the code, add build-time-injectable identity in pkg/version (ProgramName, ConfigDirName, ConfigFileName, already the -ldflags -X home alongside ProgramVersion): the cobra root Use and the config path derive from these, and a renamed build (IsRenamed) drops the self-update command and the startup update check so it never overwrites itself with the official binary. The default build is unchanged (doppler / ~/.doppler); the demo build sets doppler-agent / ~/.doppler-agent via ldflags in the demo goreleaser config (next). --- pkg/cmd/root.go | 2 +- pkg/cmd/update.go | 7 ++++++- pkg/configuration/branding_test.go | 25 +++++++++++++++++++++++++ pkg/configuration/config.go | 5 +++-- pkg/controllers/update.go | 2 +- pkg/version/rename_test.go | 17 +++++++++++++++++ pkg/version/version.go | 26 ++++++++++++++++++++++++++ 7 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 pkg/configuration/branding_test.go create mode 100644 pkg/version/rename_test.go diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 9e651a20..b3712716 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -36,7 +36,7 @@ import ( var printConfig = false var rootCmd = &cobra.Command{ - Use: "doppler", + Use: version.ProgramName, Short: "The official Doppler CLI", Args: cobra.NoArgs, PersistentPreRun: func(cmd *cobra.Command, args []string) { diff --git a/pkg/cmd/update.go b/pkg/cmd/update.go index d213a2aa..5602d835 100644 --- a/pkg/cmd/update.go +++ b/pkg/cmd/update.go @@ -19,6 +19,7 @@ import ( "github.com/DopplerHQ/cli/pkg/controllers" "github.com/DopplerHQ/cli/pkg/models" "github.com/DopplerHQ/cli/pkg/utils" + "github.com/DopplerHQ/cli/pkg/version" "github.com/spf13/cobra" ) @@ -48,5 +49,9 @@ var updateCmd = &cobra.Command{ func init() { updateCmd.Flags().BoolP("force", "f", false, "install the latest CLI regardless of whether there's an update available") - rootCmd.AddCommand(updateCmd) + // A rebranded distribution (e.g. the agent-proxy demo build) must not self-update: + // `update` fetches the official doppler release, which would overwrite this binary. + if !version.IsRenamed() { + rootCmd.AddCommand(updateCmd) + } } diff --git a/pkg/configuration/branding_test.go b/pkg/configuration/branding_test.go new file mode 100644 index 00000000..9985632e --- /dev/null +++ b/pkg/configuration/branding_test.go @@ -0,0 +1,25 @@ +package configuration + +import ( + "path/filepath" + "testing" + + "github.com/DopplerHQ/cli/pkg/utils" + "github.com/DopplerHQ/cli/pkg/version" +) + +// The on-disk config location must derive from the injectable branding vars, so a +// renamed build (e.g. doppler-agent) reads/writes ~/.doppler-agent and can't touch a +// production doppler install's credentials. +func TestConfigPathsFollowBranding(t *testing.T) { + if configFileName != version.ConfigFileName { + t.Fatalf("configFileName = %q, want it wired to version.ConfigFileName %q", configFileName, version.ConfigFileName) + } + wantDir := filepath.Join(utils.HomeDir(), version.ConfigDirName) + if UserConfigDir != wantDir { + t.Fatalf("default UserConfigDir = %q, want %q (from version.ConfigDirName)", UserConfigDir, wantDir) + } + if UserConfigFile != filepath.Join(wantDir, version.ConfigFileName) { + t.Fatalf("UserConfigFile = %q, want it under the branded dir/file", UserConfigFile) + } +} diff --git a/pkg/configuration/config.go b/pkg/configuration/config.go index 88721452..08dc85a9 100644 --- a/pkg/configuration/config.go +++ b/pkg/configuration/config.go @@ -27,6 +27,7 @@ import ( "github.com/DopplerHQ/cli/pkg/models" "github.com/DopplerHQ/cli/pkg/utils" + "github.com/DopplerHQ/cli/pkg/version" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) @@ -49,13 +50,13 @@ var Scope = "." // CanReadEnv whether configuration can be read from the environment var CanReadEnv = true -var configFileName = ".doppler.yaml" +var configFileName = version.ConfigFileName var configContents models.ConfigFile var configUid = -1 var configGid = -1 func init() { - SetConfigDir(filepath.Join(utils.HomeDir(), ".doppler")) + SetConfigDir(filepath.Join(utils.HomeDir(), version.ConfigDirName)) } func SetConfigDir(dir string) { diff --git a/pkg/controllers/update.go b/pkg/controllers/update.go index d2e9c5b0..6883391c 100644 --- a/pkg/controllers/update.go +++ b/pkg/controllers/update.go @@ -61,7 +61,7 @@ func CheckUpdate(command string) (bool, models.VersionCheck) { } } - if !version.PerformVersionCheck || version.IsDevelopment() { + if !version.PerformVersionCheck || version.IsDevelopment() || version.IsRenamed() { return false, models.VersionCheck{} } diff --git a/pkg/version/rename_test.go b/pkg/version/rename_test.go new file mode 100644 index 00000000..2301c8be --- /dev/null +++ b/pkg/version/rename_test.go @@ -0,0 +1,17 @@ +package version + +import "testing" + +func TestIsRenamed(t *testing.T) { + orig := ProgramName + defer func() { ProgramName = orig }() + + ProgramName = "doppler" + if IsRenamed() { + t.Error("the official doppler build must not report as renamed") + } + ProgramName = "doppler-agent" + if !IsRenamed() { + t.Error("a rebranded build (doppler-agent) must report as renamed") + } +} diff --git a/pkg/version/version.go b/pkg/version/version.go index 82bcac16..9368e8a5 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -24,6 +24,32 @@ import ( // ProgramVersion the current version of this program var ProgramVersion = "dev" +// ProgramName is the invoked command name (cobra Use / help text) and the identity the +// update self-management keys on. ConfigDirName / ConfigFileName locate the on-disk +// config under the user's home. All three are build-time-injectable so a renamed +// distribution — e.g. the agent-proxy demo build — can flip its name and config location +// without forking the code: +// +// -ldflags "-X github.com/DopplerHQ/cli/pkg/version.ProgramName=doppler-agent \ +// -X github.com/DopplerHQ/cli/pkg/version.ConfigDirName=.doppler-agent \ +// -X github.com/DopplerHQ/cli/pkg/version.ConfigFileName=.doppler-agent.yaml" +// +// The point is a rebranded build never collides with a customer's production `doppler`: +// a distinct name on PATH, and a separate config dir so it can't read or clobber their +// real credentials. +var ( + ProgramName = "doppler" + ConfigDirName = ".doppler" + ConfigFileName = ".doppler.yaml" +) + +// IsRenamed reports whether this is a rebranded distribution rather than the official +// doppler CLI. A renamed build turns off update self-management (the `update` command and +// the startup check) — it must never fetch and overwrite itself with the production binary. +func IsRenamed() bool { + return ProgramName != "doppler" +} + // Version semver type Version struct { Major int16 From 363e3e7d288863952eb5140c5aa251072c904360 Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Thu, 10 Sep 2026 13:44:36 -0500 Subject: [PATCH 17/20] Add a demo goreleaser config for the doppler-agent build A separate config from the production .goreleaser.yml so the pilot-customer build can't disturb the real release. It builds a doppler-agent binary for the big four targets (darwin/linux x amd64/arm64), flips the identity with the pkg/version ldflags, ships GCS-only (no GitHub release, brew, Docker, or apt/rpm), and uploads archives + checksums to a placeholder bucket the release workflow authenticates to. Won't build in CI until the agent-proxy stacks land in main and go.mod is pinned (ENG-9769). --- .goreleaser.demo.yml | 71 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .goreleaser.demo.yml diff --git a/.goreleaser.demo.yml b/.goreleaser.demo.yml new file mode 100644 index 00000000..8e5caa62 --- /dev/null +++ b/.goreleaser.demo.yml @@ -0,0 +1,71 @@ +version: 2 +project_name: doppler-agent + +# Demo/preview distribution of the CLI fork (which bundles the agent-proxy) for a handful +# of pilot customers. Kept SEPARATE from .goreleaser.yml (the production release) on +# purpose: a distinct binary name and config dir so it never collides with a customer's +# real `doppler` install on PATH or in ~/.doppler. +# +# The code's defaults are unchanged (doppler / ~/.doppler); the ldflags below flip the +# identity at build time via the injectable vars in pkg/version. See that package for +# the full explanation. +# +# PREREQUISITE (ENG-9769): the CLI must build against a PINNED agent-proxy — the +# `replace => ../agent-proxy` in go.mod has to be removed and a real version required. +# Until the agent-proxy stacks land in main and go.mod is pinned, this config builds +# only locally (with the replace), not in CI. + +before: + hooks: + - go mod download + +builds: + - id: doppler-agent + binary: doppler-agent + env: + - CGO_ENABLED=0 + # The big 4: covers every Mac and Linux dev box / devcontainer a pilot customer runs. + # `doppler agent enforce` is Linux-only, but `proxy start` / `agent run` work on macOS + # via Docker Desktop. Add windows/amd64 here only if a customer needs it. + goos: + - darwin + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + - -X github.com/DopplerHQ/cli/pkg/version.ProgramVersion=v{{.Version}} + # Build-time identity — the rename lives entirely here, not in the code: + - -X github.com/DopplerHQ/cli/pkg/version.ProgramName=doppler-agent + - -X github.com/DopplerHQ/cli/pkg/version.ConfigDirName=.doppler-agent + - -X github.com/DopplerHQ/cli/pkg/version.ConfigFileName=.doppler-agent.yaml + +archives: + - id: doppler-agent + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- if eq .Os "darwin" }}macOS + {{- else }}{{ .Os }}{{ end }}_ + {{- .Arch }} + files: + - README.md + - LICENSE + +checksum: + name_template: checksums.txt + algorithm: sha256 + +# No GitHub release, brew tap, Docker image, or apt/rpm packages — the demo ships only as +# archives in GCS, fetched by install.sh. +release: + disable: true + +# Upload the archives + checksums to GCS. The bucket is a placeholder until infra +# provisions one; the workflow authenticates with GOOGLE_APPLICATION_CREDENTIALS +# (a demo service-account key), the same mechanism the production release uses. +blobs: + - provider: gs + bucket: PLACEHOLDER_DEMO_BUCKET # TODO(infra): replace with the real demo GCS bucket + directory: "doppler-agent/{{ .Version }}" From 3b5d61f4e03004a99dc728ddbf0af0d60db25fa3 Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Thu, 10 Sep 2026 13:47:47 -0500 Subject: [PATCH 18/20] Add the demo install script install-demo.sh is the one-line customer installer for the doppler-agent preview: detect OS/arch, fetch the matching archive from the demo GCS bucket, verify its sha256, and drop doppler-agent on PATH (never doppler). Bucket is a placeholder until infra provisions it. --- scripts/install-demo.sh | 72 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 scripts/install-demo.sh diff --git a/scripts/install-demo.sh b/scripts/install-demo.sh new file mode 100644 index 00000000..41687f27 --- /dev/null +++ b/scripts/install-demo.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# install-demo.sh — one-line installer for the `doppler-agent` preview build (the CLI +# fork that bundles the agent-proxy), served from GCS. Deliberately minimal compared to +# the production scripts/install.sh: no package managers, no GPG — just fetch the archive +# for this OS/arch, verify its sha256, and drop `doppler-agent` on PATH. +# +# curl -fsSL https://storage.googleapis.com/PLACEHOLDER_DEMO_BUCKET/install.sh | sh +# +# It installs `doppler-agent` (never `doppler`), so it can't collide with a production +# Doppler CLI, and the binary keeps its state in ~/.doppler-agent. +set -eu + +BUCKET="${DOPPLER_AGENT_BUCKET:-PLACEHOLDER_DEMO_BUCKET}" # TODO(infra): real demo bucket +BASE="https://storage.googleapis.com/${BUCKET}/doppler-agent" +INSTALL_DIR="${DOPPLER_AGENT_INSTALL_DIR:-/usr/local/bin}" + +log() { printf '%s\n' "$*" >&2; } +fail() { log "ERROR: $*"; exit 1; } + +command -v curl >/dev/null 2>&1 || fail "curl is required" +command -v tar >/dev/null 2>&1 || fail "tar is required" + +# --- OS --- +case "$(uname -s)" in + Darwin) os="macOS" ;; # matches the goreleaser archive name for darwin + Linux) os="linux" ;; + *) fail "unsupported OS '$(uname -s)' (this build ships macOS and Linux only)" ;; +esac + +# --- arch --- +case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) fail "unsupported architecture '$(uname -m)' (this build ships amd64 and arm64 only)" ;; +esac + +# --- version: an explicit override, else the `latest` marker the release workflow writes --- +version="${DOPPLER_AGENT_VERSION:-}" +[ -n "$version" ] || version="$(curl -fsSL "${BASE}/latest")" || fail "could not read the latest version from ${BASE}/latest" +version="${version#v}" # goreleaser paths/names use the version without a leading 'v' + +archive="doppler-agent_${version}_${os}_${arch}.tar.gz" +url="${BASE}/${version}/${archive}" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +log "Downloading ${archive} …" +curl -fsSL --proto '=https' --tlsv1.2 "$url" -o "${tmp}/${archive}" || fail "download failed: $url" + +# --- verify checksum (best-effort: the checksums file is published alongside) --- +if curl -fsSL "${BASE}/${version}/checksums.txt" -o "${tmp}/checksums.txt" 2>/dev/null; then + want="$(grep " ${archive}\$" "${tmp}/checksums.txt" | awk '{print $1}')" + if [ -n "$want" ]; then + got="$( (command -v sha256sum >/dev/null 2>&1 && sha256sum "${tmp}/${archive}" || shasum -a 256 "${tmp}/${archive}") | awk '{print $1}')" + [ "$want" = "$got" ] || fail "checksum mismatch for ${archive} (want ${want}, got ${got})" + log "Checksum verified." + fi +fi + +tar -xzf "${tmp}/${archive}" -C "$tmp" doppler-agent || fail "could not extract doppler-agent from the archive" + +# --- install, falling back to a user-writable dir if the default needs root --- +if [ ! -w "$INSTALL_DIR" ] && [ "$(id -u)" -ne 0 ]; then + INSTALL_DIR="${HOME}/.local/bin" + mkdir -p "$INSTALL_DIR" + log "No write access to /usr/local/bin; installing to ${INSTALL_DIR} (make sure it's on your PATH)." +fi +install -m 0755 "${tmp}/doppler-agent" "${INSTALL_DIR}/doppler-agent" || fail "could not install to ${INSTALL_DIR}" + +log "Installed doppler-agent ${version} to ${INSTALL_DIR}/doppler-agent" +log "Run: doppler-agent proxy start" From 980078a3e63f9df02285d799b8426bfd8246cda6 Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Thu, 10 Sep 2026 14:32:32 -0500 Subject: [PATCH 19/20] Depend on agent-proxy as a tagged module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point the 8 import sites and go.mod at github.com/DopplerTest/agent-proxy — the module's new real home — and require v0.1.0 instead of the local replace => ../agent-proxy. This is what lets CI build the demo binary without a checkout of the private agent-proxy repo sitting next to the CLI. The agent-proxy transitive deps (aws-sdk-go-v2, smithy-go, x/net) are tidied into go.mod at their 1.25-safe versions. go.sum gets the v0.1.0 module hash once that tag is pushed; until then, local dev adds an uncommitted replace => ../agent-proxy. --- .gitignore | 2 ++ go.mod | 6 +++--- go.sum | 6 ++++++ pkg/cmd/agent.go | 8 ++++---- pkg/cmd/proxy.go | 2 +- pkg/cmd/proxy_test.go | 2 +- pkg/proxy/config.go | 2 +- pkg/proxy/config_test.go | 2 +- pkg/proxy/doppler_source.go | 2 +- pkg/proxy/engine.go | 4 ++-- pkg/proxy/maskedhash.go | 2 +- 11 files changed, 23 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index dcdbf311..deb9a4dc 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ completions/ # IDEs .idea/ .vscode/ +go.work +go.work.sum diff --git a/go.mod b/go.mod index 3c4f7654..3bd3caae 100644 --- a/go.mod +++ b/go.mod @@ -26,12 +26,14 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.46.0 // indirect + github.com/aws/smithy-go v1.28.1 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect ) require ( - github.com/DopplerHQ/agent-proxy v0.0.0-00010101000000-000000000000 + github.com/DopplerTest/agent-proxy v0.1.0 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 @@ -60,5 +62,3 @@ 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/go.sum b/go.sum index f683b234..0c479730 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/AlecAivazis/survey/v2 v2.3.6 h1:NvTuVHISgTHEHeBFqt6BHOe4Ny/NwGZr7w+F8 github.com/AlecAivazis/survey/v2 v2.3.6/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= github.com/DopplerHQ/gocui v0.1.0 h1:koC9KoJsJCLrhmU7kd3APEzyeteU4h+3+rxogvjtLHk= github.com/DopplerHQ/gocui v0.1.0/go.mod h1:sh6LfDRF5KYZbKXdyTgZ62eVhx1dIVTTKxsTzD9Qmg4= +github.com/DopplerTest/agent-proxy v0.1.0 h1:7RFRf7KspEBZ7qGTgKRFE/xb9SaxqanK+d7Nz+i706o= +github.com/DopplerTest/agent-proxy v0.1.0/go.mod h1:/4mBC4sVO32mUZWNi2KDwjymrJybOV4LHsQSK6g8Cfw= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= @@ -9,6 +11,10 @@ github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go-v2 v1.46.0 h1:1kt7m/EKcEHt5mlyyxx9cSlMddRPIKbjb6DIQsu4HPk= +github.com/aws/aws-sdk-go-v2 v1.46.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU= +github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ= +github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go index 186735c8..1b733613 100644 --- a/pkg/cmd/agent.go +++ b/pkg/cmd/agent.go @@ -31,10 +31,10 @@ import ( "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" + agentproxy "github.com/DopplerTest/agent-proxy" + "github.com/DopplerTest/agent-proxy/enforce" + "github.com/DopplerTest/agent-proxy/sandbox" + "github.com/DopplerTest/agent-proxy/verify" "github.com/DopplerHQ/cli/pkg/utils" "github.com/spf13/cobra" ) diff --git a/pkg/cmd/proxy.go b/pkg/cmd/proxy.go index b3af98e7..b2bcd4b9 100644 --- a/pkg/cmd/proxy.go +++ b/pkg/cmd/proxy.go @@ -29,7 +29,7 @@ import ( "strings" "syscall" - agentproxy "github.com/DopplerHQ/agent-proxy" + agentproxy "github.com/DopplerTest/agent-proxy" "github.com/DopplerHQ/cli/pkg/configuration" "github.com/DopplerHQ/cli/pkg/proxy" "github.com/DopplerHQ/cli/pkg/utils" diff --git a/pkg/cmd/proxy_test.go b/pkg/cmd/proxy_test.go index 3b960bfb..654e4a15 100644 --- a/pkg/cmd/proxy_test.go +++ b/pkg/cmd/proxy_test.go @@ -13,7 +13,7 @@ import ( "path/filepath" "testing" - agentproxy "github.com/DopplerHQ/agent-proxy" + agentproxy "github.com/DopplerTest/agent-proxy" "github.com/DopplerHQ/cli/pkg/proxy" ) diff --git a/pkg/proxy/config.go b/pkg/proxy/config.go index 5f52f2a3..a8416b11 100644 --- a/pkg/proxy/config.go +++ b/pkg/proxy/config.go @@ -23,7 +23,7 @@ import ( "os" "strings" - agentproxy "github.com/DopplerHQ/agent-proxy" + agentproxy "github.com/DopplerTest/agent-proxy" "gopkg.in/yaml.v3" ) diff --git a/pkg/proxy/config_test.go b/pkg/proxy/config_test.go index 3e86ad3b..b0c9eaec 100644 --- a/pkg/proxy/config_test.go +++ b/pkg/proxy/config_test.go @@ -23,7 +23,7 @@ import ( "strings" "testing" - agentproxy "github.com/DopplerHQ/agent-proxy" + agentproxy "github.com/DopplerTest/agent-proxy" ) // On first run the scaffolded config pre-seeds the bindings section with the diff --git a/pkg/proxy/doppler_source.go b/pkg/proxy/doppler_source.go index 1a124c99..ca055424 100644 --- a/pkg/proxy/doppler_source.go +++ b/pkg/proxy/doppler_source.go @@ -22,7 +22,7 @@ import ( "sort" "sync" - agentproxy "github.com/DopplerHQ/agent-proxy" + agentproxy "github.com/DopplerTest/agent-proxy" "github.com/DopplerHQ/cli/pkg/controllers" "github.com/DopplerHQ/cli/pkg/models" ) diff --git a/pkg/proxy/engine.go b/pkg/proxy/engine.go index 5b68dbef..4913626a 100644 --- a/pkg/proxy/engine.go +++ b/pkg/proxy/engine.go @@ -19,7 +19,7 @@ limitations under the License. // --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 +// The proxy runtime itself lives in the separate github.com/DopplerTest/agent-proxy // module; this package is where the CLI plugs into it. package proxy @@ -28,7 +28,7 @@ import ( "io" "sort" - agentproxy "github.com/DopplerHQ/agent-proxy" + agentproxy "github.com/DopplerTest/agent-proxy" ) // Engine is any runnable proxy implementation. The surface is intentionally diff --git a/pkg/proxy/maskedhash.go b/pkg/proxy/maskedhash.go index e0c48187..17bac99b 100644 --- a/pkg/proxy/maskedhash.go +++ b/pkg/proxy/maskedhash.go @@ -17,7 +17,7 @@ limitations under the License. package proxy import ( - agentproxy "github.com/DopplerHQ/agent-proxy" + agentproxy "github.com/DopplerTest/agent-proxy" ) // init registers the "masked-hash" engine: the per-secret-hash proxy backed by From 183baebd79cf53895c3109becf3f712ad36d610d Mon Sep 17 00:00:00 2001 From: Austin Moses Date: Thu, 10 Sep 2026 13:47:49 -0500 Subject: [PATCH 20/20] Add the demo release workflow release-demo.yml (workflow_dispatch) builds via .goreleaser.demo.yml and uploads the doppler-agent archives to GCS, plus a latest marker and the install script. It fetches the private agent-proxy module with a read token (GOPRIVATE + a git insteadOf rewrite) and reads the Go version from go.mod. GCP_KEY_DEMO, AGENT_PROXY_READ_TOKEN, and the bucket are placeholders until infra provisions them. --- .github/workflows/release-demo.yml | 79 ++++++++++++++++++++++++++++++ .goreleaser.demo.yml | 8 +-- 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release-demo.yml diff --git a/.github/workflows/release-demo.yml b/.github/workflows/release-demo.yml new file mode 100644 index 00000000..032651c6 --- /dev/null +++ b/.github/workflows/release-demo.yml @@ -0,0 +1,79 @@ +name: release-demo + +# Build the `doppler-agent` preview (this CLI fork, which bundles the agent-proxy) and +# publish it to GCS for pilot customers. Manual trigger only; entirely separate from the +# production `release` workflow. +# +# agent-proxy is a private module (github.com/DopplerTest/agent-proxy), so the runner +# needs read access to fetch it — see the "private module access" step below. The +# customer never touches it: agent-proxy is statically linked into the shipped binary. +# +# Secrets used (demo-scoped, distinct from the production release): +# GCP_KEY_DEMO — service-account key with write access to the demo bucket. +# AGENT_PROXY_READ_TOKEN — token with read access to DopplerTest/agent-proxy, so `go` +# can fetch the private module during the build. +# And replace PLACEHOLDER_DEMO_BUCKET below with the real bucket once infra provisions it. + +on: + workflow_dispatch: + inputs: + version: + description: "Version to publish, e.g. 0.1.0" + required: true + type: string + +permissions: + contents: read + +env: + DEMO_BUCKET: PLACEHOLDER_DEMO_BUCKET # TODO(infra): real demo GCS bucket + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # goreleaser needs tags/history + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Private module access (github.com/DopplerTest/agent-proxy) + run: | + git config --global \ + url."https://x-access-token:${AGENT_PROXY_READ_TOKEN}@github.com/DopplerTest/".insteadOf \ + "https://github.com/DopplerTest/" + echo "GOPRIVATE=github.com/DopplerTest/*" >> "$GITHUB_ENV" + env: + AGENT_PROXY_READ_TOKEN: ${{ secrets.AGENT_PROXY_READ_TOKEN }} + + - name: Tag this commit for goreleaser + run: git tag "v${{ inputs.version }}" + + - name: Write GCP credentials + run: | + printf '%s' "$GCP_KEY_DEMO" > "$RUNNER_TEMP/gcp.json" + echo "GOOGLE_APPLICATION_CREDENTIALS=$RUNNER_TEMP/gcp.json" >> "$GITHUB_ENV" + env: + GCP_KEY_DEMO: ${{ secrets.GCP_KEY_DEMO }} + + - name: Install goreleaser + run: | + echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list + sudo apt update + sudo apt install -y goreleaser + + - name: Validate config + run: goreleaser check -f .goreleaser.demo.yml + + - name: Build + upload archives to GCS + run: goreleaser release -f .goreleaser.demo.yml --clean + + - name: Publish the latest marker + install script + run: | + printf '%s' "${{ inputs.version }}" | gcloud storage cp - "gs://${DEMO_BUCKET}/doppler-agent/latest" + gcloud storage cp scripts/install-demo.sh "gs://${DEMO_BUCKET}/install.sh" diff --git a/.goreleaser.demo.yml b/.goreleaser.demo.yml index 8e5caa62..a89eb300 100644 --- a/.goreleaser.demo.yml +++ b/.goreleaser.demo.yml @@ -10,10 +10,10 @@ project_name: doppler-agent # identity at build time via the injectable vars in pkg/version. See that package for # the full explanation. # -# PREREQUISITE (ENG-9769): the CLI must build against a PINNED agent-proxy — the -# `replace => ../agent-proxy` in go.mod has to be removed and a real version required. -# Until the agent-proxy stacks land in main and go.mod is pinned, this config builds -# only locally (with the replace), not in CI. +# The CLI depends on agent-proxy as a tagged private module (github.com/DopplerTest/ +# agent-proxy); go.mod requires a real version, no local replace. CI fetches it with a +# read token (see release-demo.yml). For local builds, add an uncommitted +# `replace github.com/DopplerTest/agent-proxy => ../agent-proxy`. before: hooks: