Skip to main content

Credential Brokering: Keep Secrets Out of the Sandbox

An agent that calls an authenticated API needs a credential, and a credential inside the sandbox is a credential a prompt injection can send somewhere else. In this module you configure AgentVisor™ so the agent uses an API key without ever holding it, then watch the host swap in the real value only for the destination you named.

What You'll Learn​

  • Configuring a proxy.credentials rule with a bearer_token resolver
  • Seeing that the sandbox holds a symbolic mav-tok-... token, never the real secret
  • Seeing the real value reach an allowed destination, and only that destination
  • Starting cleanly in environments where a credential isn't set (on_missing_env_var)
  • (Optional) Intercepting OAuth tokens from a response body with response_intercept

Prerequisites​

This module continues in the project you finished in the Basic tutorial. If you skipped it, scaffold the same agent:

agentvisor template create langgraph/tutorial-agent
cd tutorial-agent

Docker must be running, and Temporal and Ollama must be up. In the project directory:

docker compose up -d

No real API key is needed. You'll use a dummy secret and httpbin.org, which echoes back the request headers it receives, so you can see exactly what the upstream was sent.

How It Works​

Agent (sandbox) Host runtime Upstream
DEMO_API_KEY=mav-tok-… ──────▶ policy check
Authorization: Bearer mav-tok-… swap token → real secret ──────▶ Authorization: Bearer <real>

The host generates a random mav-tok-... token for each credential rule and injects it into the sandbox as an environment variable. The agent uses it like any API key. The host checks policy first, and only then replaces the token with the real credential, and only when the request's destination matches the rule. The real secret never enters the sandbox.

Step 1: Give the Agent a Tool That Uses a Credential​

The agent needs something that sends a credential. Add a call_api tool to agent.py, next to fetch_url:

@tool
def call_api(url: str) -> str:
"""Call an authenticated API with the DEMO_API_KEY credential.

Args:
url: The absolute http:// or https:// URL to call.
"""
key = os.environ.get("DEMO_API_KEY", "<unset>")
try:
response = requests.get(
url, headers={"Authorization": f"Bearer {key}"}, timeout=10
)
response.raise_for_status()
return f"agent holds: {key}\nHTTP {response.status_code}: {response.text[:500]}"
except requests.exceptions.RequestException as e:
return f"agent holds: {key}\nRequest blocked or failed: {e}"

The tool reports the key the agent holds along with the response, so you can compare the two. Register it by changing the tools list:

tools = [fetch_url, call_api, *get_mcp_tools()]

As with fetch <url>, let a message of the form call <url> run the tool directly, so the results don't depend on a small local model choosing to call it. Add this pattern beside FETCH_COMMAND:

CALL_COMMAND = re.compile(r"^\s*call\s+(https?://\S+)\s*$", re.IGNORECASE)

and this block in chat, just before the line that builds conversation:

# "call <url>" from the user calls the call_api tool directly.
if messages and isinstance(messages[-1], HumanMessage):
match = CALL_COMMAND.match(str(messages[-1].content))
if match:
call = {"name": "call_api", "args": {"url": match.group(1)}, "id": f"call_{uuid.uuid4().hex[:12]}"}
return {"messages": [AIMessage(content="", tool_calls=[call])]}

Let Policy Reach a Second Host​

The agent's policy only allows httpbin.org for tools. For Step 4 you need a second hostname that policy also allows but your credential rule won't cover: eu.httpbin.org, a different hostname served by the same echo service. In policies/domain.yml, widen the two places that name httpbin.org. In the http-fetch policy:

allow if {
helpers.is_authenticated
regex.match("^(eu\\.)?httpbin\\.org(:\\d+)?(/.*)?$", http_target)
}

and in the fetch-endpoints resource selector:

selector:
- "mrn:agentvisor:http:(eu\\.)?httpbin\\.org.*"

Both hostnames are now allowed by policy. Whether the credential is substituted for them is a separate decision, made by the credential rule.

Step 2: Configure the Credential Rule​

Add credentials under the proxy: section of agentvisor.yaml, keeping the loopback entries that are already there:

proxy:
ssrf_allowed_cidrs:
- "127.0.0.1/32"
- "::1/128"
credentials:
- name: demo-api
guest_env_var: DEMO_API_KEY # the symbolic token is injected here
destinations: # the real secret is substituted only for these hosts
- "httpbin\\.org"
resolver:
type: bearer_token
source: env
env_var: DEMO_API_KEY # the host environment variable holding the real secret

Two environments are involved, and both happen to use the name DEMO_API_KEY:

  • resolver.env_var names a variable in the host's environment, where the real secret lives.
  • guest_env_var names the variable the sandbox receives, which holds the symbolic token instead.

destinations is required. Each entry is a regular expression matched against the whole hostname, so httpbin\.org covers httpbin.org and nothing that merely contains or ends with it.

Step 3: Run It and Inspect What the Agent Holds​

Set a dummy secret in the shell that starts AgentVisor, then serve:

export DEMO_API_KEY=demo-secret-value-1234
agentvisor serve .

In a second terminal, call the allowed destination:

THREAD=$(curl -sX POST http://localhost:8090/threads | jq -r '.thread_id')

curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=60s" \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "call https://httpbin.org/headers"}]}}' \
| jq -r '[.output.messages[] | select(.type == "tool")] | last | .content'

You should see output like this:

agent holds: mav-tok-850c1b4ceaf8c8a50a8d07bf41e6590d
HTTP 200: {
"headers": {
"Authorization": "Bearer demo-secret-value-1234",
"Host": "httpbin.org",
...
}
}

The first line is what the agent has: a mav-tok-... placeholder (yours will differ). The Authorization header in the second part is what httpbin received: your real secret. The agent sent the placeholder; the host made the swap on the way out, after policy allowed the request.

Substitution only happens over https:// on the standard port

A rule substitutes only for https:// requests to port 443, or http:// to port 80 when the rule sets allow_insecure: true. A request to https://api.example.com:8443/ receives the placeholder as-is. This keeps the real secret off cleartext connections and off unexpected ports.

Step 4: See the Token Stay Worthless Elsewhere​

Now send the same call to the other hostname. Policy allows it, but the credential rule doesn't cover it:

curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=60s" \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "call https://eu.httpbin.org/headers"}]}}' \
| jq -r '[.output.messages[] | select(.type == "tool")] | last | .content'

This time the Authorization header httpbin echoes back is the placeholder:

agent holds: mav-tok-850c1b4ceaf8c8a50a8d07bf41e6590d
HTTP 200: {
"headers": {
"Authorization": "Bearer mav-tok-850c1b4ceaf8c8a50a8d07bf41e6590d",
"Host": "eu.httpbin.org",
...
}
}

eu.httpbin.org is not httpbin.org, and destination patterns are anchored to the full hostname, so no substitution happened. This is the exfiltration defense in action: if a compromised agent sends its token to a host you didn't name, the receiver gets a string that is useless anywhere else.

Finally, try a host that policy itself denies:

curl -sX POST "http://localhost:8090/threads/$THREAD/runs?wait=60s" \
-H "Content-Type: application/json" \
-d '{"input": {"messages": [{"role": "user", "content": "call https://example.com/"}]}}' \
| jq -r '[.output.messages[] | select(.type == "tool")] | last | .content'

The request is blocked with a 403 Forbidden before any credential is considered. Policy and credentials are two separate checks: policy decides whether the request may go at all, and the credential rule decides whether the real secret travels with it.

Step 5: Start Cleanly When a Credential Isn't Set​

Stop the server, unset the secret and start again:

unset DEMO_API_KEY
agentvisor serve .

By default, startup fails and names the missing variable:

failed to resolve credential "demo-api": bearer_token resolver: env var "DEMO_API_KEY" is not set

Failing is right for production, but it gets in the way when one agentvisor.yaml is shared across dev, staging and production and some keys are intentionally absent outside production. Set credentials.on_missing_env_var to warn, either in agentvisor.yaml:

credentials:
on_missing_env_var: warn

or with the environment variable AGENTVISOR_CREDENTIALS_ON_MISSING_ENV_VAR=warn. Startup now succeeds and logs a warning that names the rule and the variable:

credential env var not set; rule will be skipped at runtime {"credential": "demo-api", "env_var": "DEMO_API_KEY"}

The skipped rule never substitutes a real credential, so the agent cannot use that API. Every rule whose variable is set keeps working normally. Restore the variable and the default before moving on.

Optional: Brokering OAuth Tokens with response_intercept​

bearer_token brokers a secret that already exists on the host. An OAuth login is different: the real access_token and refresh_token arrive in the response body of the token endpoint, which would put them inside the sandbox. The response_intercept resolver rewrites them on the way back.

This section needs a GitHub account and an OAuth app with the device flow enabled. Skip it if you don't have one; nothing later depends on it.

Add a rule alongside demo-api:

proxy:
credentials:
- name: github-oauth
destinations:
- "github\\.com"
resolver:
type: response_intercept
url_patterns:
- "https://github.com/login/oauth/access_token"
token_fields:
- field: access_token
token_format: "gho_mav{random}"
- field: refresh_token
token_format: "ghr_mav{random}"

Policy must also allow github.com, so add it to the hostnames your policy permits for tools, as you did for eu.httpbin.org. Then run the device flow from the agent with your app's client ID: request a device code from https://github.com/login/device/code, approve it in the browser, and poll https://github.com/login/oauth/access_token. The response the agent receives contains tokens like gho_mav1a2b3c4d5e6f... instead of real gho_... values. When the agent later calls api.github.com with that token, the host substitutes the real one, provided a standard rule such as bearer_token covers that destination. The mapping lives only in host memory, per principal, and expires after 15 minutes by default.

Next Steps​

You now have an agent that uses an authenticated API while holding nothing worth stealing.