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/.gitignore b/.gitignore index 1e070300..deb9a4dc 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ completions/ # IDEs .idea/ +.vscode/ +go.work +go.work.sum diff --git a/.goreleaser.demo.yml b/.goreleaser.demo.yml new file mode 100644 index 00000000..a89eb300 --- /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. +# +# 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: + - 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 }}" 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..3bd3caae 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +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/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 diff --git a/go.sum b/go.sum index 838e4d98..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= @@ -128,6 +134,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= diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go new file mode 100644 index 00000000..1b733613 --- /dev/null +++ b/pkg/cmd/agent.go @@ -0,0 +1,456 @@ +/* +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" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + + 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" +) + +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)) + } + } + + cfg := sandbox.Config{ + ProxyPort: proxyPort, + CACertPath: caPath, + AgentEnvPath: envPath, + Command: args, + DockerBin: dockerBin, + Interactive: true, + } + + 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, resolveCredentialSources(cmd))}.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. +// 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 { + // 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), + // clause 3 — CA trust + verify.CACertValid(caPath), + verify.CATrustEnv(), + verify.CAEndToEnd(proxyURL, testURL), + ) + // 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."), + ) + // 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 +} + +// 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 { + 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 +// 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, + // 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, + "USER": agentUser, + "LOGNAME": agentUser, + "PATH": envOr("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"), + "TERM": envOr("TERM", "xterm"), + } + env := enforce.ParseAgentEnv(string(rawEnv)) + 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, sources)}.Run() + rep.Render(os.Stderr) + if rep.Failed() { + return errors.New("sandbox contract check failed; refusing to launch the agent") + } + 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}, + 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") + } + }, +} + +// 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) + 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") + 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)") + 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") + 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 new file mode 100644 index 00000000..4b3cd7a7 --- /dev/null +++ b/pkg/cmd/agent_test.go @@ -0,0 +1,123 @@ +/* +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 ( + "net" + "os" + "os/user" + "path/filepath" + "slices" + "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") + } +} + +// 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. +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) + } + } +} + +// 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) + } +} diff --git a/pkg/cmd/proxy.go b/pkg/cmd/proxy.go new file mode 100644 index 00000000..b2bcd4b9 --- /dev/null +++ b/pkg/cmd/proxy.go @@ -0,0 +1,259 @@ +/* +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/DopplerTest/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") + } + // 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") + } + 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") + allowPrivateEgress, _ := cmd.Flags().GetBool("allow-private-egress") + + // 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") + } + + opts, err := engineOptions(proxyConfig, proxyStartInputs{ + address: address, + dataDir: dataDir, + logOut: io.MultiWriter(os.Stderr, logFile), + passthrough: passthrough, + upstreamProxy: upstreamProxy, + proxyToken: proxyToken, + allowPrivateEgress: allowPrivateEgress, + source: source, + }) + 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) + } + + // 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) + } + }, +} + +// 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, + Methods: cfg.MethodConfigs(), + PassByValue: cfg.PassByValue, + }, 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 +// 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") + 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)") + 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)") + 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/cmd/proxy_test.go b/pkg/cmd/proxy_test.go new file mode 100644 index 00000000..654e4a15 --- /dev/null +++ b/pkg/cmd/proxy_test.go @@ -0,0 +1,87 @@ +/* +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/DopplerTest/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"}}}, + 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", + 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 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) + } + 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") + } + 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) { + 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") + } +} 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/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/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/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..a8416b11 --- /dev/null +++ b/pkg/proxy/config.go @@ -0,0 +1,273 @@ +/* +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" + "fmt" + "os" + "strings" + + agentproxy "github.com/DopplerTest/agent-proxy" + "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"` + + // 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"` + + // 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). +// 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. +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 +} + +// 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 +} + +// 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 +# 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, 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 + +` + +// 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. +# 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 + +# 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. +// 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 + } + // 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 { + 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(content)) + 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..b0c9eaec --- /dev/null +++ b/pkg/proxy/config_test.go @@ -0,0 +1,261 @@ +/* +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" + "strings" + "testing" + + agentproxy "github.com/DopplerTest/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, nil) + 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, nil) + 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") + } +} + +// 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(buildStarterConfig(nil))) + 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. + if err := os.WriteFile(path, []byte(" \n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, created, err := LoadOrScaffold(path, nil) + 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) + } +} + +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") + } +} + +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) + } +} + +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/doppler_source.go b/pkg/proxy/doppler_source.go new file mode 100644 index 00000000..ca055424 --- /dev/null +++ b/pkg/proxy/doppler_source.go @@ -0,0 +1,93 @@ +/* +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/DopplerTest/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. 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 + + mu sync.Mutex + secrets map[string]string +} + +// 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 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.mu.Lock() + s.secrets = m + s.mu.Unlock() + return m, nil +} + +func (s *dopplerSource) List(_ context.Context) ([]string, error) { + m, err := s.load() + if err != nil { + return nil, err + } + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + sort.Strings(names) + return names, nil +} + +func (s *dopplerSource) Fetch(_ context.Context, ref agentproxy.SecretRef) (string, error) { + 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 := m[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..4913626a --- /dev/null +++ b/pkg/proxy/engine.go @@ -0,0 +1,102 @@ +/* +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/DopplerTest/agent-proxy +// module; this package is where the CLI plugs into it. +package proxy + +import ( + "context" + "io" + "sort" + + agentproxy "github.com/DopplerTest/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 + // 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 + // 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. +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..17bac99b --- /dev/null +++ b/pkg/proxy/maskedhash.go @@ -0,0 +1,47 @@ +/* +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/DopplerTest/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, + Binding: opts.Binding, + AllowPrivateEgress: opts.AllowPrivateEgress, + Methods: opts.Methods, + PassByValue: opts.PassByValue, + }) + }) +} 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 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"