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
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.
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:
adevcreates the sandbox, its network namespace, and a scoped macaroon.- The sandbox can resolve and connect only to configured upstreams.
- Envoy terminates the sandbox's TLS connection and asks Vault to authorize the request.
- Vault verifies the macaroon signature and every applicable caveat and policy.
- A built-in or JavaScript credential provider returns the real upstream headers.
- 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.
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.
The bwrap runtime currently targets Linux and requires:
- Go 1.24 or later
- Docker with the Compose plugin
- SOPS
bwrap,slirp4netns,unshare, andsetpriv- zmx and a systemd user session
- The agent CLI you intend to run, such as
codex, on the hostPATH
The container runtimes are also available: use runtime = "gvisor" with
gVisor or
runtime = "runc" with Docker's default runtime.
$ 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.
$ actl vault init
Age key stored in keychain ...
Vault config: ~/.config/agent-creds/vault.yamlInitialization 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 editAdd a secret group below the generated vault group:
# Add below `secrets.vault`; leave the generated keys unchanged.
service:
API_TOKEN: replace-with-your-api-tokenThen replace the initial credentials: {} with:
credentials:
service/dev:
type: bearer
token:
$secret: service#API_TOKEN
env: SERVICE_API_TOKENImportant
Put raw credentials only below secrets. SOPS encrypts that subtree.
Project configuration and credential definitions should contain
$secret references, never copied API keys.
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.
$ adev consoleOn 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.comInside the sandbox, SERVICE_API_TOKEN starts with acm_. Your API receives
the real token only after Vault approves the request.
- Protect a Slack bot token with the built-in bearer
provider and verify it with
auth.test. - Protect a Stripe API key with HTTP Basic authentication and a path-scoped test-mode credential.
- Load trusted JavaScript extensions, including a command-backed session provider and a subject/scope policy.
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 workThe 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 interactivelyA 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.
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.
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/automationThe 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 subscriptionsCapabilities make access discoverable to adev setup; the project route and
macaroon caveats provide request-time enforcement.
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=JSONapplication 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.
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.
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.
$ actl # TUI for all instances
$ actl status # Current project and Vault connectivity
$ actl vault log # Authorization and denial audit entries
$ docker compose logs -f vaultAuthorization failures return 401 for invalid or expired authentication
and 403 for a valid token that violates caveats or policy.
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/.
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_TOKENThen 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.
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.
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.
| 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 |
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
$ make binaries
$ (cd cmd/actl && go test ./...)
$ (cd cmd/adev && go test ./...)
$ (cd vault && go test ./...)
$ docker compose build vaultMIT
