Skip to content
Connect agents

Run agents in a sandbox

Run each user's agent in a Kubernetes sandbox that does not hold credentials. The Keycard egress proxy allows only the destinations you list and adds a credential for the user to each request.

This guide runs an agent in a Kubernetes pod that does not hold credentials and reaches only the destinations you list. The egress proxy runs beside the agent, allows or denies each outbound connection, and gets a credential from your Zone for each request that needs one. Each user gets their own sandbox, and the Zone issues credentials for that user only; Isolate an agent’s network access explains why.

The steps use the reference sandbox, which runs on agent-sandbox. Treat it as a working example to adapt, since the Zone configuration is the same for any deployment.

flowchart LR
    A[Agent] -->|"captured by iptables"| P[Egress proxy sidecar]
    P -->|"ServiceAccount token, user ID"| K[Keycard Zone]
    K -->|"credential for the user"| P
    P -->|"request with credential"| R[Resource]
  1. An init container installs iptables rules in the pod that send the agent’s outbound TCP and DNS to the proxy and drop everything else.
  2. The proxy allows a request only when an endpoint in its configuration covers the host, port, and path.
  3. For an endpoint with a credential, the proxy runs an impersonation token exchange, authenticating with the pod’s projected ServiceAccount token and naming the user.
  4. The Zone checks the token against your cluster’s issuer, evaluates policy, and returns a credential for that user and Resource, which the proxy writes into the request.
  • A Kubernetes cluster that supports native sidecar containers, which are on by default from Kubernetes 1.29 (Sidecar Containers).
  • A ServiceAccount token issuer the Zone can fetch over HTTPS, such as the OIDC issuer of an EKS or AKS cluster; the Zone reads <issuer>/.well-known/openid-configuration and the keys it names.
  • Permission to create a namespace at the privileged Pod Security level, because the init container needs NET_ADMIN and NET_RAW.
  • A Keycard Zone with a User for each person whose agent will run, and a Provider for each upstream, such as GitHub.
  • Each of those Users has authorized the brokered Resources once, so Keycard holds their grant. Act on Behalf of Absent Users shows a landing page for this step.
  • An organization service account with access to the Zone, which the setup tool authenticates with and the sandbox never uses.
  • A clone of keycardai/agent-sandbox-examples, and mise to install its pinned tools with mise install.

zone-setup in the reference repository creates every Zone object the proxy needs, from one values file. You can run it again safely, because it finds what exists and changes only what differs. If you manage the Zone another way, What zone-setup creates lists each object.

  1. Write the values file

    Copy zone-setup.example.yaml to zone-setup.yaml and fill it in:

    management_api_url: https://api.keycard.ai
    zone_url: <keycard-issuer-url>
    management_service_account:
    client_id: <service-account-client-id>
    # Copy it from the cluster exactly, including any trailing slash.
    issuer_url: <cluster-oidc-issuer-url>
    namespace: agents
    users:
    - email: troy.barnes@greendale.edu
    - email: abed.nadir@greendale.edu
    resources:
    - identifier: https://api.github.com
    name: GitHub
    prefix: true
    credential_provider: <github-provider-identifier>
    - identifier: https://github.com
    name: GitHub git
    prefix: true
    credential_provider: <github-provider-identifier>

    Each Resource identifier must equal the canonical URL of a proxy endpoint. The proxy sends that URL as the exchange’s resource, and the Zone matches it exactly, so write https://api.github.com, not https://api.github.com/.

    For an upstream that takes a static API key, set secret_env to the name of an environment variable that holds the key, in place of credential_provider. zone-setup stores the key in the Zone, and the proxy gets it for each request without the agent seeing it.

  2. Run zone-setup

    Terminal window
    export KEYCARD_MANAGEMENT_CLIENT_SECRET=<service-account-client-secret>
    go run ./cmd/zone-setup --values zone-setup.yaml > zone-setup.out.yaml

    Add --dry-run first to print what it would change. zone-setup stops if another Policy Set is active in the Zone, and because --replace-active-policy-set replaces that set for everyone in the Zone, read the active set before you use the flag.

    zone-setup.out.yaml holds the IDs the next section uses: k8s_sandbox for the Zone, and one sandbox_user_access entry per user.

Object Setting
Provider Issuer URL set to the cluster’s issuer, without client credentials; the Zone discovers the signing keys.
Application Identifier urn:keycard:sandbox-proxy:<namespace>, consent set to implicit, because the user is not present to answer a consent screen.
Resources One per resources entry, with its credential Provider or stored secret.
Dependencies Each Resource is a dependency of the Application, so a new dependency applies to every user’s sandbox.
Workload Identity credentials One per user on the Application, with the cluster’s issuer as its Provider and system:serviceaccount:<namespace>:<user-id> as its subject. Its ID is the proxy’s client_id.
Policies Two per user, in a Policy Set named Kubernetes Sandbox, which zone-setup activates.

The ServiceAccount for each user is named after the user’s Keycard ID, and the two Policies tie that ServiceAccount to that user. The first lets the Application impersonate the user, for its dependencies only, and only with that user’s ServiceAccount token from your cluster:

permit (
principal is Keycard::Application,
action,
resource
) when {
principal.identifier == "urn:keycard:sandbox-proxy:agents" &&
context has impersonate && context.impersonate == true &&
context has subject && context.subject.identifier == "<user-identifier>" &&
context has actor_claims && context.actor_claims has issuer_claims &&
context.actor_claims.issuer_claims.contains({"key": "iss", "value": "<cluster-oidc-issuer-url>"}) &&
context.actor_claims.issuer_claims.contains({"key": "sub", "value": "system:serviceaccount:agents:<user-id>"}) &&
principal.dependencies.contains(resource)
};

The second lets the user reach Resources through that same ServiceAccount:

permit (
principal is Keycard::User,
action,
resource
) when {
principal.identifier == "<user-identifier>" &&
context has actor_claims && context.actor_claims has issuer_claims &&
context.actor_claims.issuer_claims.contains({"key": "iss", "value": "<cluster-oidc-issuer-url>"}) &&
context.actor_claims.issuer_claims.contains({"key": "sub", "value": "system:serviceaccount:agents:<user-id>"})
};

Both must permit the exchange. When you remove a user from the values file and run zone-setup again, it removes their Policies from the set and archives them, so their sandbox stops getting credentials. Policy Schema lists the attributes these conditions read.

  1. Pin the images

    The egress proxy publishes three images to ghcr.io/keycardai: egress-netsetup, egress-proxy, and sandbox-base. Each release attaches digests.txt with the digest of each image; name images by digest, because tags move. Build your agent’s image FROM sandbox-base, which installs the proxy’s CA before the agent starts.

  2. Install the cluster chart

    Create install-values.yaml:

    zoneURL: <keycard-issuer-url>
    images:
    netsetup: ghcr.io/keycardai/egress-netsetup@sha256:<digest>
    proxy: ghcr.io/keycardai/egress-proxy@sha256:<digest>
    agent: <your-agent-image>@sha256:<digest>
    rbac:
    subjects:
    - kind: Group
    name: sandbox-admins
    apiGroup: rbac.authorization.k8s.io

    Then install it:

    Terminal window
    ./install/install.sh --context <kube-context> --values install-values.yaml

    The script installs the agent-sandbox controller and the chart, which creates the agents namespace at the privileged Pod Security level, the sandbox pod template, a sandbox-admin Role bound to rbac.subjects, and a NetworkPolicy that denies ingress and allows egress only to the cluster DNS and to TCP 443 on public addresses.

  3. List the endpoints

    Copy sandbox/values.example.yaml to sandbox-values.yaml. Set the Zone URL, your cluster’s pod and service ranges, and the endpoints the agent may reach:

    zoneURL: <keycard-issuer-url>
    policy:
    clusterCIDRs:
    - <pod-cidr>
    - <service-cidr>
    endpoints:
    - url: https://api.github.com
    credential:
    placement: {kind: header, name: Authorization, template: "Bearer {credential}"}
    - url: https://github.com
    credential:
    placement: {kind: basic, user: x-access-token}
    - url: https://pypi.org
    - url: https://files.pythonhosted.org

    An endpoint with credential gets a credential from the Zone; for an endpoint without one, the proxy allows the request and does not call the Zone. The basic placement lets git clone and git push work over HTTPS without a credential helper. Egress proxy lists every placement.

  4. Create a sandbox for each user

    Pass the user’s entry from zone-setup.out.yaml:

    Terminal window
    go run ./cmd/new-sandbox --values sandbox-values.yaml \
    --namespace agents \
    --user-id <user-id> --user-email troy.barnes@greendale.edu \
    --service-account system:serviceaccount:agents:<user-id> \
    --client-id <client-id> \
    --provider-id <provider-id> --application-id <application-id> \
    --application-identifier urn:keycard:sandbox-proxy:agents

    new-sandbox creates the user’s ServiceAccount, the proxy’s configuration as a ConfigMap, and the Sandbox, and only the proxy container mounts the ConfigMap and the ServiceAccount token. Add --dry-run to print the manifests without applying them.

Find the sandbox’s pod with kubectl -n agents get pods, then run three requests from its agent container:

Terminal window
kubectl -n agents exec <sandbox-pod> -c agent -- curl -sS -o /dev/null -w '%{http_code}\n' https://pypi.org/simple/
kubectl -n agents exec <sandbox-pod> -c agent -- curl -sS https://api.github.com/user
kubectl -n agents exec <sandbox-pod> -c agent -- curl -sS https://www.google.com/
  1. The first prints 200; the endpoint does not have a credential, so the proxy does not call the Zone.
  2. The second returns the GitHub account of the sandbox’s user, even though the agent did not send a token.
  3. The third fails with “Could not resolve host”, because the proxy’s resolver answers NXDOMAIN for any name that is not an endpoint host.

Read the proxy’s decisions:

Terminal window
kubectl -n agents logs <sandbox-pod> -c egress-proxy | grep '"decision"'

Each line is one decision with the sandbox ID, the destination, the verdict, and the reason. Then open the egress proxy Application in Keycard Console and select its Activity tab, which shows one credentials:issue for the GitHub request and the sandbox’s user; the other two requests do not appear there.

The proxy names the reason for each refusal in the X-Keycard-Deny-Reason response header and in its audit record.

A credentialed request returns 403 exchange_denied

The Zone refused the exchange on policy. Check that:

  • The user’s two Policies are in the active Policy Set.
  • The Resource is a dependency of the Application.
  • The sandbox runs as the ServiceAccount named after its user. A sandbox configured for one user and running as another user’s ServiceAccount always gets this answer.
A credentialed request returns 502 exchange_failed

The exchange did not complete, and the record’s decision.error holds the Zone’s description.

  • invalid_client means the Zone did not match the token to a Workload Identity credential. Compare the Provider’s Issuer URL with the token’s iss character for character, and check that the Zone can fetch the issuer’s discovery document.
  • insufficient_authorization means the user has not authorized that Resource yet.
The proxy never becomes ready

Read its log with kubectl -n agents logs <sandbox-pod> -c egress-proxy. Before it serves traffic, the proxy checks that the init container’s receipt matches its own ports and user ID, that it does not hold NET_ADMIN, and that net.ipv4.ip_unprivileged_port_start is above its ports. If a check fails, the proxy does not start, and its log names the failed check, usually with the setting to change. The egress proxy’s security model lists each check.