Skip to content

Repository files navigation

agent-creds

Give autonomous coding agents real API access without giving them real API keys.

agent-creds runs Claude Code, Codex, Pi, or another command inside a deny-by-default network sandbox. The agent calls ordinary HTTPS APIs with a short-lived capability token. A per-agent Envoy and a shared Vault authorize the request, replace that token with the real credential, and forward it.

Your existing CLIs and SDKs keep working. The secret never enters the agent's environment, terminal output, tool results, or model context.

Quickstart · Examples · Guide · JavaScript extensions · Security model

agent-creds sandbox

Why agent-creds

Coding agents are most useful when they can operate real systems: inspect production data, update source control, send messages, or call an internal API. Putting the corresponding API keys in an agent's environment creates an uncomfortable failure mode: anything the process can read can eventually reach a prompt, tool log, dependency, or compromised subprocess.

agent-creds separates access from possession:

  • Credentials stay outside the sandbox. The agent receives a macaroon capability such as acm_..., never the upstream secret.
  • Egress is allowlisted. A sandbox can reach only the hosts declared for that project, and only through its Envoy.
  • Access can be narrower than the credential. Host, method, path, time, subject, and application-specific caveats constrain each token.
  • Applications remain unmodified. Environment variables, HTTP clients, CLIs, and SDKs send their usual authentication headers.
  • Deployment logic stays private. Trusted JavaScript extensions can exchange credentials, mint sessions, and enforce application policy without adding company-specific code to the public binary.
  • Many agents share one credential plane. Vault is a singleton; each sandbox gets its own Envoy and network boundary.

How it works

  PER AGENT

  bwrap / gVisor / runc
+----------------------+
| Codex, Claude, Pi    |
| CLIs, SDKs, git      |
| TOKEN=acm_...        |
+----------+-----------+
           |
           | configured hosts only
           v                                   SHARED DEPLOYMENT
+----------------------+                       +----------------------+
| Envoy                | --------------------> | Vault                |
| one per sandbox      | <-------------------- | one per deployment   |
+----------+-----------+   approved headers    | secrets + JS policy  |
           |                                   +----------------------+
           | real upstream credential
           v
+----------------------+
| Configured API       |
+----------------------+

For a credentialed request:

  1. adev creates the sandbox, its network namespace, and a scoped macaroon.
  2. The sandbox can resolve and connect only to configured upstreams.
  3. Envoy terminates the sandbox's TLS connection and asks Vault to authorize the request.
  4. Vault verifies the macaroon signature and every applicable caveat and policy.
  5. A built-in or JavaScript credential provider returns the real upstream headers.
  6. Envoy forwards the request. The agent sees the response, but never the credential used to obtain it.

The proxy handles the credential boundary; it is not a general secrets manager inside the sandbox.

Quickstart

This walkthrough launches Codex in a bwrap sandbox and gives one project narrow access to an API. Substitute your API's hostname, path, and bearer token. At the end, the configured request succeeds, an unconfigured request fails, and the raw token has never entered the sandbox.

Prerequisites

The bwrap runtime currently targets Linux and requires:

  • Go 1.24 or later
  • Docker with the Compose plugin
  • SOPS
  • bwrap, slirp4netns, unshare, and setpriv
  • zmx and a systemd user session
  • The agent CLI you intend to run, such as codex, on the host PATH

The container runtimes are also available: use runtime = "gvisor" with gVisor or runtime = "runc" with Docker's default runtime.

1. Build the host tools

$ git clone https://github.com/dtkav/agent-creds.git
$ cd agent-creds
$ make binaries
$ export PATH="$PWD/bin:$PATH"

The executables remain tied to this checkout: adev uses the files beside its bin/ directory to build sandboxes and start the credential plane.

2. Initialize Vault

$ actl vault init
Age key stored in keychain ...
Vault config: ~/.config/agent-creds/vault.yaml

Initialization creates independent macaroon signing and encryption keys, stores the age identity in your system keychain, and writes a SOPS-encrypted Vault configuration. Running it again is safe: an existing configuration is left in place.

Add the API token you want the agent to use:

$ actl vault edit

Add a secret group below the generated vault group:

# Add below `secrets.vault`; leave the generated keys unchanged.
  service:
    API_TOKEN: replace-with-your-api-token

Then replace the initial credentials: {} with:

credentials:
  service/dev:
    type: bearer
    token:
      $secret: service#API_TOKEN
    env: SERVICE_API_TOKEN

Important

Put raw credentials only below secrets. SOPS encrypts that subtree. Project configuration and credential definitions should contain $secret references, never copied API keys.

3. Configure a project

From the project the agent will work on, create agent-creds.toml:

[sandbox]
name = "api-demo"
runtime = "bwrap"
agent = "codex"

[upstream."api.example.com"]
credential = "/service/dev"
methods = ["GET"]
paths = ["/v1/me"]

The project file contains policy, not secrets, so it can be reviewed and committed with the rest of the project.

4. Start the agent

$ adev console

On first start, adev builds and launches the shared Vault, a per-project Envoy, and the sandbox. Vault also creates a unique SSH host key in its persistent Docker volume if one is missing.

Ask the agent to run:

$ curl -sS https://api.example.com/v1/me \
    -H "Authorization: Bearer $SERVICE_API_TOKEN"
<response from your API>

$ curl -sS https://example.com
curl: (6) Could not resolve host: example.com

Inside the sandbox, SERVICE_API_TOKEN starts with acm_. Your API receives the real token only after Vault approves the request.

Examples and guides

Guide

Run an agent

Bundled profiles configure the agent command, its development tools, and the network endpoints needed for login and API traffic:

[sandbox]
runtime = "bwrap"
agent = "claude"  # "codex" or "pi" also work

The profiles deliberately disable the agent's own approval prompts. The outer sandbox and credential plane are the security boundary.

Common commands:

$ adev                 # List all instances
$ adev console         # Start or attach to this project's agent
$ adev console review  # Use a named instance
$ adev start           # Start a bwrap instance in the background
$ adev stop            # Stop this project's instance
$ adev setup           # Configure credential access interactively

A bwrap agent runs in a zmx-hosted session, so it can detach and resume without losing the process. Multiple agents can run concurrently; they share Vault but not Envoy, tokens, generated configuration, or network namespaces.

Declare network and credential access

Every reachable upstream must appear in agent-creds.toml:

[sandbox]
name = "service-review"
runtime = "bwrap"
agent = "codex"

[upstream."api.example.com"]
credential = "/service/read"
methods = ["GET"]
paths = ["/v1/customers/**", "/v1/subscriptions/**"]

[upstream."api.github.com"]
# No credential: existing caller authentication passes through unchanged.
methods = ["GET"]
paths = ["/repos/example/**"]

The main upstream fields are:

Field Meaning
credential Vault credential path, such as /service/read
policy Trusted Vault policy path; selecting one requires a valid macaroon
methods Allowed HTTP methods; empty means all
paths Allowed path patterns; * matches one segment and ** matches many
mode credential (default) or identity
forward_token Accept an application-supplied macaroon instead of minting an environment token
scheme, port Upstream transport; defaults to HTTPS on 443
address, network Fixed origin and Envoy-only Docker network for private services

Changes to upstreams are watched. Envoy configuration and credential tokens are refreshed without rebuilding the entire environment when possible.

See agent-creds.example.toml for browser, CDP, identity-route, and plugin examples.

Configure credentials

Vault configuration lives at ~/.config/agent-creds/vault.yaml and is edited with:

$ actl vault edit
$ actl vault show --credentials
$ actl vault show --capabilities /service/read
$ actl vault credentials add /github/automation

The public Vault supports four credential types:

Type Configuration Injected authentication
bearer token Authorization: Bearer ...
basic username, password HTTP Basic authentication
oauth2 client ID/secret, refresh token, token URL Refreshed bearer access token
sigv4 region, service, access key ID/secret AWS Signature Version 4 headers

A complete built-in credential can also describe its intended capabilities:

credentials:
  service/read:
    type: bearer
    token:
      $secret: service#API_TOKEN
    env: SERVICE_API_TOKEN
    capabilities:
      hosts: [api.example.com]
      endpoints:
        - methods: [GET]
          paths: [/v1/customers/**, /v1/subscriptions/**]
          description: Read billing customers and subscriptions

Capabilities make access discoverable to adev setup; the project route and macaroon caveats provide request-time enforcement.

Scope access

For credentialed routes, adev derives host, method, and path caveats from the project configuration. The agent receives only the resulting capability, not a copy of the root key or upstream credential.

Macaroons can also carry:

  • validity windows;
  • an opaque application subject;
  • third-party attestation requirements; and
  • repeatable namespace=JSON application constraints.

Application constraints are attenuating: a holder can append more constraints offline but cannot remove existing ones. A policy must therefore understand the namespace, reject unknown namespaces, and evaluate every constraint conjunctively.

Authenticate an internal service

An identity route verifies a subject-scoped macaroon without selecting or injecting an upstream credential:

[upstream."records.internal"]
mode = "identity"
policy = "/records/read"
scheme = "http"
port = 8890
address = "records-service"
network = "records-network"
methods = ["POST"]
paths = ["/graphql"]

Vault strips caller-supplied x-agent-creds-* headers and writes verified identity facts itself. The service may use those facts for convenience, but Vault and its selected policy remain the authorization boundary.

Browser and CDP access

Host browser forwarding is separately allowlisted:

[[browser_target]]
url = "https://github.com/login/oauth*"

[[browser_target]]
url = "http://localhost:*"

CDP forwarding can expose only selected browser targets to Playwright, Puppeteer, or another client:

[sandbox]
use_host_browser_cdp = true

[[cdp_target]]
type = "page"
url = "*localhost:3000*"

An empty target list blocks all browser or CDP targets. Matching is conjunctive when a target specifies multiple fields.

Observe the credential plane

$ actl                 # TUI for all instances
$ actl status          # Current project and Vault connectivity
$ actl vault log       # Authorization and denial audit entries
$ docker compose logs -f vault

Authorization failures return 401 for invalid or expired authentication and 403 for a valid token that violates caveats or policy.

JavaScript extensions

Built-in providers cover common protocols. Deployment-specific exchanges and authorization rules belong in trusted JavaScript, loaded by Vault rather than compiled into the public binary.

Files ending in *.provider.js or *.policy.js are loaded from vault/providers.d by default. Docker Compose mounts that directory read-only, and this repository ignores it so local deployment logic is not accidentally committed. AGENT_CREDS_PROVIDER_PATH can select other files or directories. The bind mount is a development convenience; do not give an untrusted agent write access to this checkout while Vault is loading from it.

Standalone, syntax-checked examples live in examples/.

Example: exchange a long-lived secret for a session

Create vault/providers.d/acme-session.provider.js:

registerCredentialProvider({
  name: "acme-session",
  credentialType: "acme_session",
  cache: "credential",

  match: {
    hosts: ["api.acme.example"],
    methods: ["GET", "POST"],
    paths: ["/v1/**"],
  },

  validate(config) {
    if (!config.token_url) throw new Error("token_url is required");
    if (!config.client_id) throw new Error("client_id is required");
    if (!config.client_secret) throw new Error("client_secret is required");
  },

  resolve(_request, config) {
    const response = $http.request({
      method: "POST",
      url: config.token_url,
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        client_id: config.client_id,
        client_secret: config.client_secret,
      }),
    });

    if (response.status !== 200) {
      throw new Error("session exchange returned " + response.status);
    }

    const session = JSON.parse(response.body);
    return {
      headers: {
        authorization: "Bearer " + session.access_token,
      },
      expiresAt: $jwt.expiresAt(session.access_token),
      stop: true,
    };
  },
});

Select that credential type in vault.yaml:

secrets:
  acme:
    CLIENT_ID: deployment-client
    CLIENT_SECRET: deployment-secret

credentials:
  acme/prod:
    type: acme_session
    token_url: https://auth.acme.example/v1/session
    client_id:
      $secret: acme#CLIENT_ID
    client_secret:
      $secret: acme#CLIENT_SECRET
    env: ACME_API_TOKEN

Then select /acme/prod from a project upstream. The agent receives a macaroon in ACME_API_TOKEN; Vault performs the exchange and caches only the short-lived session until its JWT expiry.

Provider functions receive request facts and credential configuration:

resolve({
  credential,
  credentialType,
  host,
  method,
  path,
  headers,
}, config)

The runtime also exposes:

API Purpose
$http.request(options) Context-bound HTTP request; no redirects and a 4 MiB response limit
$exec.run(command, args, options) Execute a program directly without a local shell; supports an explicit child environment
$jwt.expiresAt(token) Read an unverified JWT exp for cache timing only
$log.debug/info/warn(message) Write provider diagnostics to Vault logs

cache: "credential" is opt-in and is safe only when every matching request can reuse the same headers. Cached results must include expiresAt. Registrations can be layered with priority, request matchers, header merging, and stop.

$exec.run inherits Vault's environment for backward compatibility. Trusted providers that pass secrets to a helper should replace it explicitly:

$exec.run("/usr/local/bin/session-helper", ["issue"], {
  inheritEnv: false,
  env: {
    HOME: "/tmp",
    SERVICE_ACCESS_TOKEN: config.access_token,
  },
});

Commands share the provider's 30-second request deadline. Standard output is limited to 4 MiB and error output to 64 KiB.

Example: enforce application policy

A policy receives only request facts produced after macaroon verification:

registerUpstreamPolicy({
  name: "records-scope",
  policyType: "records_scope",

  validate(config) {
    if (!config.service) throw new Error("service is required");
  },

  authorize(request, config) {
    if (!request.subject) {
      return { allow: false, reason: "subject required" };
    }

    for (const constraint of request.constraints) {
      if (constraint.namespace !== "records") {
        return { allow: false, reason: "unknown constraint namespace" };
      }
      if (!constraint.body.services.includes(config.service)) {
        return { allow: false, reason: "service excluded" };
      }
    }

    return true;
  },
});

Configure its implementation in Vault and select the path from a route:

policies:
  records/read:
    type: records_scope
    service: ledger
[upstream."records.internal"]
mode = "identity"
policy = "/records/read"

If a route and its credential both select policies, both must allow. A credential request carrying application constraints fails closed when no policy is selected.

Extension reloads are atomic: Vault builds and validates a complete new runtime pool before activating it. Syntax, registration, or validation errors leave the last known-good generation serving traffic.

Warning

JavaScript extensions are trusted deployment code. They run beside Vault, can receive resolved secret configuration, and may use the network or execute installed programs. Do not mount unreviewed scripts.

Security model

agent-creds assumes the host, Vault, Envoy, and reviewed JavaScript extensions are trusted. The agent, its commands, project dependencies, and network responses are untrusted.

The design provides:

  • no real upstream credentials in the sandbox;
  • deny-by-default egress with per-project host allowlists;
  • per-request macaroon verification and attenuation;
  • optional trusted policies over verified identity and application claims;
  • encrypted Vault configuration at rest with the age identity in the system keychain;
  • a unique, persistent Vault SSH host key generated on first start; and
  • audit records for authorization decisions and denials.

A configured upstream without a credential or policy is a passthrough route: non-macaroon authentication is forwarded unchanged. This is useful for agent OAuth and public APIs, but it does not protect a caller-supplied secret. Set STRICT_MODE=true to require macaroons globally. Selecting a credential, policy, or identity mode already requires the relevant verified token even when strict mode is off.

The sandbox trusts a generated CA so Envoy can terminate configured HTTPS connections. That CA is scoped to the sandbox infrastructure; protect its private key and generated instance directory as host credentials.

Reference

Environment variables

Variable Purpose
VAULT_CONFIG Decrypted vault.yaml path inside Vault
MACAROON_SIGNING_KEY Legacy/configless signing-key fallback
MACAROON_ENCRYPTION_KEY Legacy/configless third-party caveat key
TOKEN_PREFIX Macaroon prefix; defaults to acm_
STRICT_MODE Reject non-macaroon requests when true
AGENT_CREDS_PROVIDER_PATH Extension files/directories, using the OS path-list separator
AGENT_CREDS_PROVIDER_POOL JavaScript runtime pool size; CPU count capped at 8 by default
SSH_HOST_KEY Vault SSH private host-key path; the image defaults to /data/vault_host_key

Repository layout

agents/                 bundled Claude, Codex, and Pi profiles
cmd/actl/               Vault and instance control CLI
cmd/adev/               sandbox orchestrator
docs/guides/            end-to-end service guides
examples/               trusted JavaScript extension examples
plugins/                composable development-tool profiles
vault/                  credential service, providers, policies, and token code
vault/providers.d/      local trusted JavaScript extensions (ignored)
agent-creds.example.toml
docker-compose.yml

Development

$ make binaries
$ (cd cmd/actl && go test ./...)
$ (cd cmd/adev && go test ./...)
$ (cd vault && go test ./...)
$ docker compose build vault

License

MIT

About

ephemeral credentials for agents using macaroons and envoy as a transparent proxy

Resources

Stars

31 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages