diff --git a/internal/README.md b/internal/README.md index 0d460fb..723e164 100644 --- a/internal/README.md +++ b/internal/README.md @@ -34,7 +34,8 @@ These apply to every client-mode command. Each command selects the agent it talk | `--agent-card ` | `-a` | Agent Card reference: a host/origin (the well-known path is appended), a full card URL, or a local file path. The card is resolved and a transport negotiated. | | `--endpoint ` | `-e` | Agent interface URL for a direct connection, skipping card resolution. Must be paired with exactly one `--transport`. Mutually exclusive with `--agent-card`. | | `--transport ` | | Transport preference: `rest`, `jsonrpc`, `grpc`. Repeatable and ordered (highest preference first). With `--agent-card` it overrides the card's preference order; with `--endpoint` exactly one is required. | -| `--insecure` | | Use plaintext for the built-in gRPC client. By default it uses TLS and verifies the server certificate against the system trust store. | +| `--insecure` | | Use plaintext for the built-in gRPC client. By default it uses TLS and verifies the server certificate against the system trust store, or the certificates in `--ca-cert`. | +| `--ca-cert ` | | Trust only the CA certificates in the PEM file `` for TLS. Applies to the card request and the REST, JSON-RPC and gRPC transports. Not read from the local `.env`. | | `--output ` | `-o` | Output format: `text` (default), `json` (indented), or `jsonl` (one compact JSON object per line). | | `--svc-param ` | | Service parameter (repeatable). The chosen transport defines how it's passed. Split on the first `=`. | | `--auth ` | | Shorthand for `--svc-param "Authorization="`. | @@ -312,7 +313,7 @@ credential-helper: `--credential-helper` and `A2ACLI_CREDENTIAL_HELPER` take `[scope=]path`, like Bazel's `--credential_helper`: a path with no arguments. A scope is a host without a port (`example.com`), or `*.example.com` for the domain and its subdomains. Helpers are chosen per request: the card request uses the helper for the card reference as given (`--agent-card` or the positional URL), and A2A calls use the helper for the interface URL the client connects to. That URL is also the `uri` the helper receives. Unlike Bazel, which sends the full URL of each request, a2a does not append the `/.well-known/agent-card.json` path or the A2A method path. The helper with the most specific matching scope runs; among equal scopes the last one wins. Its headers are sent unless `--auth` or `--svc-param` set the same header. a2a runs a helper once per URL and reuses its headers until they expire. `--timeout` bounds each run. A helper that exits non-zero fails the command; a helper with no credentials for the URL prints `{}`. -a2a executes the configured helper command, so `credential-helper` and `--unix-socket` are not read from the local `.env` found by walking up from the current directory: a `.env` in a cloned repository could otherwise run a command or redirect requests. a2a ignores them there with a warning. Set them with the flag, the environment, `~/.config/a2a-cli/config.yaml`, or a file passed with `--config`. +a2a executes the configured helper command, so `credential-helper`, `--unix-socket` and `--ca-cert` are not read from the local `.env` found by walking up from the current directory: a `.env` in a cloned repository could otherwise run a command, redirect requests or choose the certificates a2a trusts. a2a ignores them there with a warning. Set them with the flag, the environment, `~/.config/a2a-cli/config.yaml`, or a file passed with `--config`. ### `auth get` - Inspect credentials diff --git a/internal/cacert/cacert.go b/internal/cacert/cacert.go new file mode 100644 index 0000000..6318fcd --- /dev/null +++ b/internal/cacert/cacert.go @@ -0,0 +1,35 @@ +// Copyright 2026 The A2A Authors +// +// 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 cacert loads the CA certificates to trust for TLS connections. +package cacert + +import ( + "crypto/x509" + "errors" + "os" +) + +// Load returns a pool of the PEM-encoded certificates in the file at path. +func Load(path string) (*x509.CertPool, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(data) { + return nil, errors.New("no PEM certificates found") + } + return pool, nil +} diff --git a/internal/cacert/cacert_test.go b/internal/cacert/cacert_test.go new file mode 100644 index 0000000..77c7ec9 --- /dev/null +++ b/internal/cacert/cacert_test.go @@ -0,0 +1,92 @@ +// Copyright 2026 The A2A Authors +// +// 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 cacert + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "path/filepath" + "testing" + "time" + + "github.com/a2aproject/a2a-cli/internal/testutil" +) + +func TestLoad(t *testing.T) { + t.Parallel() + first, second := newCACert(t, "first"), newCACert(t, "second") + tests := []struct { + name string + path string + want []*x509.Certificate + wantErr bool + }{ + {name: "one certificate", path: testutil.MustWriteTmpCertFile(t, first), want: []*x509.Certificate{first}}, + {name: "bundle", path: testutil.MustWriteTmpCertFile(t, first, second), want: []*x509.Certificate{first, second}}, + {name: "no certificates", path: testutil.MustWriteTmpCertFile(t), wantErr: true}, + {name: "missing file", path: filepath.Join(t.TempDir(), "missing.pem"), wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := Load(tc.path) + if tc.wantErr { + if err == nil { + t.Fatalf("Load(%q) error = nil, want error", tc.path) + } + return + } + if err != nil { + t.Fatalf("Load(%q) error = %v, want nil", tc.path, err) + } + want := x509.NewCertPool() + for _, cert := range tc.want { + want.AddCert(cert) + } + if !got.Equal(want) { + t.Fatalf("Load(%q) pool differs from the certificates in the file", tc.path) + } + }) + } +} + +func newCACert(t *testing.T, name string) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("ecdsa.GenerateKey() error = %v", err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: name}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("x509.CreateCertificate() error = %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("x509.ParseCertificate() error = %v", err) + } + return cert +} diff --git a/internal/cli/client.go b/internal/cli/client.go index 98207d0..adc7ef5 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -16,6 +16,7 @@ package cli import ( "context" + "crypto/tls" "fmt" "net/http" "net/url" @@ -47,6 +48,9 @@ func warnInsecureCredential(cfg *globalConfig) { func newAgentClient(ctx context.Context, cfg *globalConfig, extraOpts ...a2aclient.FactoryOption) (*a2aclient.Client, error) { warnInsecureCredential(cfg) + if err := cfg.initTLS(); err != nil { + return nil, err + } switch { case cfg.url != "" && cfg.agentCard.IsSet(): return nil, clierr.Usage("--endpoint and --agent-card are mutually exclusive") @@ -118,6 +122,9 @@ func newClientFromCard(ctx context.Context, cfg *globalConfig, ref string, extra // resolveCard fetches the agent card with every service parameter sent as a request // header, so a gateway authenticating the card fetch sees what the transport sends. func resolveCard(ctx context.Context, cfg *globalConfig, ref string) (*a2a.AgentCard, error) { + if err := cfg.initTLS(); err != nil { + return nil, err + } var opts []agentcard.ResolveOption for k, vals := range cfg.svcParams.Params() { opts = append(opts, agentcard.WithRequestHeader(k, strings.Join(vals, ", "))) @@ -144,7 +151,7 @@ func cardResolver(cfg *globalConfig) *agentcard.Resolver { } func clientFactoryOpts(cfg *globalConfig) []a2aclient.FactoryOption { - creds := credentials.NewTLS(nil) + creds := credentials.NewTLS(&tls.Config{RootCAs: cfg.rootCAs}) if cfg.insecureGRPC { creds = insecure.NewCredentials() } diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index e896757..7e094bf 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -15,12 +15,12 @@ package cli import ( - "crypto/x509" "fmt" "net" + "net/http" "net/http/httptest" "os" - "os/exec" + "path/filepath" "strings" "testing" @@ -37,17 +37,6 @@ import ( func TestGRPCTransportSecurity(t *testing.T) { t.Parallel() - // SetFallbackRoots changes the whole process's trust store. - const child = "A2ACLI_TEST_GRPC_ROOTS" - if os.Getenv(child) != "1" { - cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestGRPCTransportSecurity$") - cmd.Env = append(os.Environ(), child+"=1", "GODEBUG=x509usefallbackroots=1") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("gRPC transport tests: %v\n%s", err, out) - } - return - } - handler := a2asrv.NewHandler(localsrv.NewEchoExecutor()) srv := grpc.NewServer() t.Cleanup(srv.Stop) @@ -70,14 +59,7 @@ func TestGRPCTransportSecurity(t *testing.T) { }() plainAddr := lis.Addr().String() - _, err = runCMD(t, "send", "--endpoint", secured.URL, "--transport", "grpc", "--timeout", "3s", "hi") - if err == nil || !strings.Contains(err.Error(), "certificate") { - t.Fatalf("send with untrusted certificate error = %v, want certificate verification failure", err) - } - - roots := x509.NewCertPool() - roots.AddCert(secured.Certificate()) - x509.SetFallbackRoots(roots) + caCert := testutil.MustWriteTmpCertFile(t, secured.Certificate()) wrongHost := fmt.Sprintf("passthrough:///localhost:%d", secured.Listener.Addr().(*net.TCPAddr).Port) cardPath := func(address string) string { return testutil.MustWriteTmpCardFile(t, &a2a.AgentCard{ @@ -92,13 +74,15 @@ func TestGRPCTransportSecurity(t *testing.T) { wantErr string wantHint string }{ - {name: "TLS endpoint", args: []string{"--endpoint", secured.URL}}, - {name: "TLS Agent Card", args: []string{"--agent-card", cardPath(secured.URL)}}, - {name: "TLS 0.3 endpoint", args: []string{"--endpoint", secured.URL, "--a2a-version", "0.3"}}, - {name: "TLS Agent Card with bare address", args: []string{"--agent-card", cardPath(secured.Listener.Addr().String())}}, + {name: "TLS endpoint", args: []string{"--endpoint", secured.URL, "--ca-cert", caCert}}, + {name: "TLS Agent Card", args: []string{"--agent-card", cardPath(secured.URL), "--ca-cert", caCert}}, + {name: "TLS 0.3 endpoint", args: []string{"--endpoint", secured.URL, "--a2a-version", "0.3", "--ca-cert", caCert}}, + {name: "TLS Agent Card with bare address", args: []string{"--agent-card", cardPath(secured.Listener.Addr().String()), "--ca-cert", caCert}}, {name: "plaintext Agent Card", args: []string{"--agent-card", cardPath(plainAddr), "--insecure"}}, {name: "explicit plaintext", args: []string{"--endpoint", "http://" + plainAddr, "--insecure"}}, - {name: "mismatched hostname", args: []string{"--endpoint", wrongHost}, wantErr: "certificate"}, + {name: "explicit plaintext with a CA certificate", args: []string{"--endpoint", "http://" + plainAddr, "--insecure", "--ca-cert", caCert}}, + {name: "untrusted certificate", args: []string{"--endpoint", secured.URL}, wantErr: "certificate"}, + {name: "mismatched hostname", args: []string{"--endpoint", wrongHost, "--ca-cert", caCert}, wantErr: "certificate"}, {name: "plaintext server without --insecure", args: []string{"--endpoint", "http://" + plainAddr}, wantErr: "TLS handshake", wantHint: "--insecure"}, } for _, tc := range tests { @@ -124,3 +108,90 @@ func TestGRPCTransportSecurity(t *testing.T) { }) } } + +func TestCACert(t *testing.T) { + t.Parallel() + mux := http.NewServeMux() + mux.Handle("/", a2asrv.NewRESTHandler(a2asrv.NewHandler(localsrv.NewEchoExecutor()))) + server := httptest.NewTLSServer(mux) + t.Cleanup(server.Close) + mux.Handle(a2asrv.WellKnownAgentCardPath, a2asrv.NewStaticAgentCardHandler(newAgentCard(server.URL, a2a.AgentCapabilities{}))) + caCert := testutil.MustWriteTmpCertFile(t, server.Certificate()) + + // t.TempDir paths can exceed the Unix socket path length limit. + dir, err := os.MkdirTemp("", "a2a") + if err != nil { + t.Fatalf("os.MkdirTemp() error = %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(dir); err != nil { + t.Errorf("removing %s: %v", dir, err) + } + }) + sock := filepath.Join(dir, "a2a.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatalf("net.Listen(%q) error = %v", sock, err) + } + overSocket := &httptest.Server{Listener: ln, Config: &http.Server{Handler: mux}} + overSocket.StartTLS() + t.Cleanup(overSocket.Close) + + tests := []struct { + name string + args []string + wantErr string + }{ + {name: "send with Agent Card", args: []string{"send", "--agent-card", server.URL, "--ca-cert", caCert, "hello"}}, + {name: "send to endpoint", args: []string{"send", "--endpoint", server.URL, "--transport", "rest", "--ca-cert", caCert, "hello"}}, + {name: "card get", args: []string{"card", "get", server.URL, "--ca-cert", caCert}}, + {name: "send over a Unix socket", args: []string{"send", "--unix-socket", sock, "--agent-card", "https://127.0.0.1", "--ca-cert", caCert, "hello"}}, + {name: "untrusted certificate", args: []string{"send", "--agent-card", server.URL, "hello"}, wantErr: "certificate"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := runCMD(t, tc.args...) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("a2a %s error = %v, want %q", strings.Join(tc.args, " "), err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("a2a %s error = %v", strings.Join(tc.args, " "), err) + } + }) + } +} + +func TestCACertFile(t *testing.T) { + t.Parallel() + missing := filepath.Join(t.TempDir(), "missing.pem") + empty := testutil.MustWriteTmpCertFile(t) + tests := []struct { + name string + args []string + wantUsageErr bool + }{ + {name: "send with a missing file", args: []string{"send", "-a", "https://agent.example.com", "--ca-cert", missing, "hello"}, wantUsageErr: true}, + {name: "send with no certificates", args: []string{"send", "-a", "https://agent.example.com", "--ca-cert", empty, "hello"}, wantUsageErr: true}, + {name: "card get with a missing file", args: []string{"card", "get", "https://agent.example.com", "--ca-cert", missing}, wantUsageErr: true}, + {name: "config show with a missing file", args: []string{"config", "show", "--ca-cert", missing}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + _, err := runCMD(t, tc.args...) + if !tc.wantUsageErr { + if err != nil { + t.Fatalf("a2a %s error = %v, want nil", strings.Join(tc.args, " "), err) + } + return + } + if err == nil || clierr.Classify(err).Code != clierr.CodeUsage { + t.Fatalf("a2a %s error = %v, want a usage error", strings.Join(tc.args, " "), err) + } + }) + } +} diff --git a/internal/cli/credentials.go b/internal/cli/credentials.go index 6b3e956..13a8fb2 100644 --- a/internal/cli/credentials.go +++ b/internal/cli/credentials.go @@ -16,13 +16,17 @@ package cli import ( "context" + "crypto/tls" "fmt" + "net/http" "net/url" "strings" "sync" "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-cli/internal/cacert" + "github.com/a2aproject/a2a-cli/internal/clierr" "github.com/a2aproject/a2a-cli/internal/credhelper" "github.com/a2aproject/a2a-cli/internal/unixsocket" ) @@ -34,6 +38,28 @@ func (g *globalConfig) initHTTPTransport() { } } +// initTLS makes HTTP and gRPC connections trust only the certificates in +// --ca-cert, if set. It runs when a command connects, so a bad file does not +// fail commands that make no requests, such as config show. +func (g *globalConfig) initTLS() error { + if g.caCert == "" || g.rootCAs != nil { + return nil + } + roots, err := cacert.Load(g.caCert) + if err != nil { + return clierr.Usage(fmt.Sprintf("reading --ca-cert %q: %v", g.caCert, err)) + } + base := http.DefaultTransport.(*http.Transport) + if t, ok := g.httpTransport.(*http.Transport); ok { + base = t // --unix-socket + } + transport := base.Clone() + transport.TLSClientConfig = &tls.Config{RootCAs: roots} + g.httpTransport = transport + g.rootCAs = roots + return nil +} + // initCredentials sets up --credential-helper. Helpers run only when a // request is made to a host in their scope. func (g *globalConfig) initCredentials() { diff --git a/internal/cli/credentials_test.go b/internal/cli/credentials_test.go index d672ba0..72ec14d 100644 --- a/internal/cli/credentials_test.go +++ b/internal/cli/credentials_test.go @@ -285,6 +285,7 @@ func TestCredentialFlagsIgnoredFromDiscoveredEnv(t *testing.T) { }{ {name: "credential helper", env: "A2ACLI_CREDENTIAL_HELPER=false", flag: "credential-helper"}, {name: "unix socket", env: "A2ACLI_UNIX_SOCKET=/nonexistent/a2a.sock", flag: "unix-socket"}, + {name: "CA certificate", env: "A2ACLI_CA_CERT=/nonexistent/ca.pem", flag: "ca-cert"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/cli/root.go b/internal/cli/root.go index beab8c1..c912b42 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -17,6 +17,7 @@ package cli import ( "context" + "crypto/x509" "fmt" "io" "net/http" @@ -75,6 +76,7 @@ type globalConfig struct { timeout time.Duration verbose bool insecureGRPC bool + caCert string unixSocket string configPath string @@ -88,6 +90,7 @@ type globalConfig struct { errOut io.Writer httpTransport http.RoundTripper + rootCAs *x509.CertPool handlers []handleEventFunc *output.Printer @@ -205,10 +208,11 @@ func newRootCmd(cfg *globalConfig, deps deps) (*cobra.Command, error) { pf.DurationVar(&cfg.timeout, "timeout", 30*time.Second, "Request timeout") pf.BoolVarP(&cfg.verbose, "verbose", "v", false, "Verbose output to stderr") pf.BoolVar(&cfg.insecureGRPC, "insecure", false, "Use insecure (plaintext) gRPC transport credentials") + pf.StringVar(&cfg.caCert, "ca-cert", "", "Trust only the CA certificates in this PEM file for TLS connections") pf.StringVar(&cfg.unixSocket, "unix-socket", "", "Send every HTTP request through the Unix socket at this path") pf.Var(&cfg.credentialHelpers, "credential-helper", "Credential helper returning the headers for an agent: [scope=]path (repeatable; see a2a auth get --help)") pf.StringVar(&cfg.configPath, "config", "", "Load configuration from an explicit file (.yaml, .json, or .env) in place of the local .env") - for _, name := range []string{"unix-socket", "credential-helper"} { + for _, name := range []string{"unix-socket", "credential-helper", "ca-cert"} { if err := pf.SetAnnotation(name, clicfg.AnnotationNotFromDiscoveredFile, []string{"true"}); err != nil { return nil, err } diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 42ab3ff..f74c76f 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -16,7 +16,9 @@ package testutil import ( + "crypto/x509" "encoding/json" + "encoding/pem" "os" "path/filepath" "strings" @@ -36,6 +38,21 @@ func AllArtifactText(task *a2a.Task) string { return sb.String() } +// MustWriteTmpCertFile writes certs PEM-encoded to a ca.pem file in a temporary +// directory and returns its path. +func MustWriteTmpCertFile(t *testing.T, certs ...*x509.Certificate) string { + t.Helper() + var data []byte + for _, cert := range certs { + data = append(data, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})...) + } + path := filepath.Join(t.TempDir(), "ca.pem") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + return path +} + // MustWriteTmpCardFile creates a card.json file in a temporary directory on the local // file system and returns its path. func MustWriteTmpCardFile(t *testing.T, card *a2a.AgentCard) string {