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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ These apply to every client-mode command. Each command selects the agent it talk
| `--agent-card <ref>` | `-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 <ref>` | `-e` | Agent interface URL for a direct connection, skipping card resolution. Must be paired with exactly one `--transport`. Mutually exclusive with `--agent-card`. |
| `--transport <name>` | | 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 <file>` | | Trust only the CA certificates in the PEM file `<file>` for TLS. Applies to the card request and the REST, JSON-RPC and gRPC transports. Not read from the local `.env`. |
| `--output <fmt>` | `-o` | Output format: `text` (default), `json` (indented), or `jsonl` (one compact JSON object per line). |
| `--svc-param <k=v>` | | Service parameter (repeatable). The chosen transport defines how it's passed. Split on the first `=`. |
| `--auth <creds>` | | Shorthand for `--svc-param "Authorization=<creds>"`. |
Expand Down Expand Up @@ -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

Expand Down
35 changes: 35 additions & 0 deletions internal/cacert/cacert.go
Original file line number Diff line number Diff line change
@@ -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
}
92 changes: 92 additions & 0 deletions internal/cacert/cacert_test.go
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 8 additions & 1 deletion internal/cli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package cli

import (
"context"
"crypto/tls"
"fmt"
"net/http"
"net/url"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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, ", ")))
Expand All @@ -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()
}
Expand Down
123 changes: 97 additions & 26 deletions internal/cli/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
package cli

import (
"crypto/x509"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

Expand All @@ -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)
Expand All @@ -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{
Expand All @@ -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 {
Expand All @@ -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)
}
})
}
}
26 changes: 26 additions & 0 deletions internal/cli/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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() {
Expand Down
Loading
Loading