# Credentials and outbound access

A sandbox reaches only the hosts you allow, and it never holds your API keys. You keep keys and tokens in your workspace's credential store, and caas.sh adds them to a sandbox's HTTPS requests after the requests leave the sandbox. Code inside the sandbox can't print, log or leak a key it never sees.

Three settings work together:

- The **egress policy** of each sandbox: the hosts it may reach.
- **Credentials**: keys and tokens your workspace keeps encrypted and never shows again.
- **Egress credential rules**: which credential goes to which host, and in which header.

## Choose the hosts a sandbox can reach

Every sandbox has an egress policy: an ordered list of `allow` and `deny` rules. The first rule that matches a request's host decides it, and the list always ends with "everything else: deny". A new sandbox's policy allows exactly the hosts you list when you create it.

### Allow hosts when you create a sandbox

- **Console:** in **New sandbox**, list the hosts under **Allowed outbound hosts**, one per line. Deploying an MCP server has the same field.
- **CLI:** pass `--egress-host` once per host, with `caas run` or `caas mcp deploy`:

  ```sh
  caas run --egress-host api.github.com -- node -e "fetch('https://api.github.com/zen').then(r => r.text()).then(console.log)"
  ```

  A command that starts with `npx` gets `registry.npmjs.org` added for you.

- **Files:** list hosts under `egress:` in an MCP server file or a workflow file.

You can list up to 32 exact host names, such as `pypi.org`. To add wildcards or deny rules, edit the policy after the sandbox is created.

### Edit the egress policy

In the console, open the sandbox and choose **Edit** on its **Network policy** card (on an MCP server's page, the **Outbound policy** card). Add rules with **Add rule**, put them in order with **Up** and **Down**, and choose **Save policy**.

With the CLI, `caas egress policy set` replaces the whole policy with the `--allow` and `--deny` rules, in the order you give them:

```sh
caas egress policy set <sandbox-id> --deny gist.github.com --allow github.com --allow '*.github.com'
caas egress policy show <sandbox-id>
```

- A rule's host is exact, such as `pypi.org`, or a wildcard such as `*.github.com`, which matches every subdomain of github.com but not github.com itself. To allow both, add both.
- The policy can't allow `*`, and the closing "everything else: deny" can't be changed.
- A policy has at most 64 rules.
- A saved policy applies to a running sandbox at once.
- Owners and operators can change a policy. Everyone in the workspace can read it.

### Hosts that are always or never reachable

- `registry.caas.sh` is always reachable, so a sandbox can pull images from your registry.
- While a sandbox sets up, it can also reach what the setup needs: the Alpine package mirror (to install `git` or `npm` when the image lacks them), its repository's Git host (to clone it), and `registry.npmjs.org` (to install an MCP server's packages). These hosts are dropped once setup finishes.
- A sandbox never reaches an IP address, `localhost` or an internal name, and it reaches only hosts under these top-level domains: .ai, .app, .au, .biz, .br, .ca, .cc, .ch, .cloud, .cn, .co, .com, .de, .dev, .edu, .es, .eu, .fr, .gg, .gov, .in, .info, .io, .it, .jp, .kr, .ly, .me, .net, .nl, .org, .page, .pl, .run, .se, .sh, .so, .tech, .tv, .uk, .us and .xyz.

## Read the egress log

The egress log lists the requests a sandbox tried to make, and what happened to each one.

- **Console:** the sandbox's page shows the newest 10 under **Outbound activity**, each with its time, method, host and path, and its result: **Allowed**, **Allowed · key added**, **Blocked** or **Blocked · key unavailable**.
- **CLI:** `caas sandboxes egress` lists them newest first, 100 at a time. Each line has the time, method, host and path, whether the request was allowed or blocked, the rule that decided, the response status, the bytes the sandbox sent, and `key added` when a credential was added. To see older requests, pass the printed `Next cursor` value:

  ```sh
  caas sandboxes egress <sandbox-id>
  caas sandboxes egress <sandbox-id> --cursor <next-cursor>
  ```

| Rule | Result | Why |
| --- | --- | --- |
| `allowlist` | Allowed | An allow rule of the policy matched the host. |
| `setup` | Allowed | The host is one only the sandbox's setup reaches. |
| `registry` | Allowed | The request went to `registry.caas.sh`. |
| `policy` | Blocked | A deny rule matched the host. |
| `default` | Blocked | No rule matched, so "everything else: deny" applied. |
| `destination` | Blocked | No sandbox can reach this host: an IP address, or a domain outside those listed above. |
| `credential` | Blocked | A rule adds a credential for this host, but the credential couldn't be used: it needs reconnecting, is renewing, or was removed. |

Each sandbox keeps its newest 1,000 allowed and newest 1,000 blocked requests, for up to seven days. The log keeps each request's path, but never its query string, headers or body. Sandboxes that run workflow steps keep no egress log.

The bytes a sandbox sends to hosts it's allowed to reach bill as egress, at $0.06 / GB. Requests to `registry.caas.sh` don't count. See [pricing](/pricing.html).

## Connect a credential

A credential is a key or token that your workspace keeps encrypted in its credential store. Once you connect it, caas.sh never shows it again: not in the console, the CLI, the API, a log or the audit log. Only owners can connect, change and remove credentials.

In the console, open **Settings** and choose **Manage credentials**. The **Credentials** page lists your credentials, your egress credential rules, and the OAuth and GitHub apps that credentials connect through.

Each credential has a name of capitals, digits and `_`, up to 64 characters, such as `GITHUB_TOKEN`. It can't start with a digit or with `CAAS_`. A sandbox whose rules add the credential sees its name as an environment variable, and workflow steps list the name under `secrets:`.

| Method | CLI option | What you do | What caas.sh adds to requests |
| --- | --- | --- | --- |
| API key | `--api-key` | Paste a key or token. | The key as you gave it. |
| OAuth | `--oauth APP` | Sign in through an OAuth app you added. | An access token, refreshed for you. |
| GitHub App | `--github-app APP` | Install a GitHub App you added. | Short-lived installation tokens, minted for you. |
| GitHub user | `--github` | Sign in to GitHub. | Your GitHub user token, refreshed for you. |
| Codex (ChatGPT) | `--codex` | Enter a device code at OpenAI. | Nothing: no rule can add it. |

### Register the callback URL

An OAuth app or a GitHub App sends you back to caas.sh after you sign in or install it. Register this URL with the provider:

```text
https://api.caas.sh/v1/credential-interactions/callback
```

- For an OAuth app, it is the redirect URI (some providers call it the callback URL).
- For a GitHub App, set it as both the **Callback URL** and the **Setup URL**.

### Paste an API key or token

In the console's **Credentials** card, enter a **Name**, choose **API key** as the **Method**, paste the key under **Key (API key)**, and choose **Connect**. The credential is ready at once.

The CLI reads the key from standard input, never from an option:

```sh
caas credentials connect OPENAI_API_KEY --api-key < openai-key.txt
printf '%s' "$NPM_TOKEN" | caas credentials connect NPM_TOKEN --api-key
```

A key can be up to 16 KiB. A credential is added to no request until a rule names it: see [Add credentials to requests](#add-credentials-to-requests).

### Sign in with OAuth

1. With the provider, create an OAuth app (client), set its redirect URI to the [callback URL](#register-the-callback-url), and note its client ID and client secret.
2. Add the app to caas.sh. In the console's **OAuth and GitHub apps** card, enter a **Name**, choose **OAuth app** as the **Type**, choose **Google**, **GitLab** or **Linear** under **Provider (OAuth)**, enter the **Client ID**, the **Scopes (OAuth)** and, under **Secret**, the client secret, and choose **Add app**. With the CLI, the client secret comes from standard input:

   ```sh
   caas credentials apps add google --type oauth2 --preset google \
     --client-id 1234.apps.googleusercontent.com \
     --scope https://www.googleapis.com/auth/drive.readonly < client-secret.txt
   ```

   List every scope that credentials connected through the app may ask for, 1 to 64. The app's name is 1 to 32 lowercase letters, digits and hyphens.

   For another provider, choose **Custom** and enter its **Authorize URL (custom OAuth)** and **Token URL (custom OAuth)**: https, on a public host, without a port. With the CLI:

   ```sh
   caas credentials apps add acme --type oauth2 \
     --authorize-url https://auth.example.com/oauth/authorize \
     --token-url https://auth.example.com/oauth/token \
     --client-id my-client-id --scope read --scope write < client-secret.txt
   ```

   Add `--token-auth basic` if the token endpoint takes the client secret with HTTP Basic authentication rather than in the request body. caas.sh accepts a custom app only on an authorization server it supports, and refuses others with an error.

3. Connect a credential through the app. In the console, enter a **Name**, choose **OAuth** and the **App**, optionally list fewer **Scopes (OAuth)** than the app allows, and choose **Connect**, then **Sign in**. With the CLI, pass `--scope` once for each scope to ask for fewer than all of the app's:

   ```sh
   caas credentials connect GOOGLE_DRIVE --oauth google
   ```

4. Sign in at the provider within 15 minutes. The provider sends you back to the console's **Credentials** page, which finishes connecting the credential. You must be signed in to the console as the owner who started the sign-in (for a sign-in started with an API key, the key's creator), even if you started it from the CLI. The CLI waits until the credential is ready, unless you pass `--detach`.

caas.sh refreshes the access token for you. The Google preset asks for offline access, so Google returns a refresh token.

### Install a GitHub App

1. On GitHub, create a GitHub App with the permissions it needs, and set its **Callback URL** and **Setup URL** to the [callback URL](#register-the-callback-url). Generate a private key, and note the app's Client ID and its slug: the last part of `github.com/apps/<slug>`.
2. Add the app. In the console, choose **GitHub App** as the **Type**, enter the **Client ID** and the **Slug (GitHub App)**, paste the private key's PEM under **Secret**, and choose **Add app**. With the CLI:

   ```sh
   caas credentials apps add acme-bot --type github-app \
     --client-id Iv23liAbCdEf012345 --slug acme-bot < acme-bot.private-key.pem
   ```

3. Connect a credential through the app. In the console, choose **GitHub App** and the **App**, choose **Connect**, then **Install app**. With the CLI:

   ```sh
   caas credentials connect ACME_BOT_TOKEN --github-app acme-bot
   ```

4. Install the app on the account and repositories it should act for. GitHub sends you back to the console, which finishes connecting the credential.

caas.sh mints short-lived installation tokens for you.

### Sign in as a GitHub user

You need no app of your own: you sign in through caas.sh's GitHub App. In the console, choose **GitHub user**, choose **Connect**, then **Sign in**. With the CLI:

```sh
caas credentials connect GITHUB_TOKEN --github
```

caas.sh refreshes your token for you.

### Connect Codex

```sh
caas credentials connect CODEX --codex
```

The CLI prints a page at OpenAI and a code to enter there; the console shows the code with an **Open sign-in page** button. caas.sh keeps the ChatGPT sign-in, but no egress rule can add a Codex credential to requests, so sandboxes can't use it.

### Check a credential's status

```sh
caas credentials list
```

Each line shows a credential's name, method and app, status, scopes, and how many rules use it. The console's list shows the same.

| Console | CLI | Meaning |
| --- | --- | --- |
| Waiting to connect | `pending` | The sign-in, install or device code isn't finished yet. |
| Ready | `ready` | Sandboxes can use the credential. |
| Renewing | `renewing` | caas.sh is fetching a new token. Requests that need it may be blocked until it's done. |
| Needs reconnecting | `reauthentication required` | A refresh failed for good. [Reconnect](#reconnect-replace-or-remove) it. |
| Expired | `expired` | The sign-in ended before it finished. Reconnect it. |
| Unavailable | `unavailable` | The credential can't be used, or its status can't be read right now. |

## Add credentials to requests

A credential is added to no request until an egress credential rule names it. A rule says: on HTTPS requests to this host, put this credential in this header.

- **The policy still decides.** A rule doesn't allow a host: the sandbox's egress policy must allow it too.
- **HTTPS only.** A plain `http://` request to a host that has a rule is blocked.
- **Every path.** A rule covers every path on its host.
- **The real value wins.** The credential replaces whatever the sandbox sent in that header, and caas.sh redacts it as `[REDACTED]` from the response's headers and body.
- **Wildcards include the domain.** In a rule, `*.example.com` covers example.com itself and every name under it, unlike in the egress policy. A rule can't name `*`.
- **First match.** When several rules match a host, the first one you added applies.
- **Scope.** A rule applies to every sandbox in the workspace, or to one sandbox. A sandbox gets at most 16 rules, the workspace's and its own together.

MCP servers get credentials the same way as other sandboxes. Workflow steps work a little differently: see [Use secrets in workflow steps](#use-secrets-in-workflow-steps). caas.sh never adds a credential to requests to `registry.caas.sh`.

### Use a preset

| Preset | Host | Header |
| --- | --- | --- |
| `github` | `api.github.com` | `Authorization: Bearer <token>` |
| `github` | `github.com` | `Authorization: Basic`, with user `x-access-token`, for Git over HTTPS |
| `anthropic` | `api.anthropic.com` | `x-api-key: <key>` |
| `openai` | `api.openai.com` | `Authorization: Bearer <key>` |
| `google` | `generativelanguage.googleapis.com` | `x-goog-api-key: <key>`, for the Gemini API |

The `github` preset suits a GitHub user or GitHub App credential, and a GitHub token pasted as an API key.

In the console's **Egress credential rules** card, choose the **Credential** and a **Preset**, and choose **Add rule**. Leave **Sandbox ID** empty for every sandbox. With the CLI:

```sh
caas egress rules add --preset github --credential GITHUB_TOKEN
caas egress rules add --preset anthropic --credential ANTHROPIC_API_KEY
```

A new sandbox that may reach `api.github.com` now gets the token on its requests there, though it never sends one:

```sh
caas run --egress-host api.github.com -- node -e "fetch('https://api.github.com/user').then(r => r.json()).then(console.log)"
```

### Write your own rule

Give the host, the header, and how to write the value in it:

- `bearer`: `Bearer <value>`.
- `basic`: `Basic` and the base64 of `<username>:<value>`. It needs a username.
- `raw`: the value as it is.

```sh
caas egress rules add --credential NPM_TOKEN --host registry.npmjs.org --header authorization --scheme bearer
caas egress rules add --credential JIRA_TOKEN --host acme.atlassian.net --header authorization --scheme basic --username you@example.com
caas egress rules add --credential INTERNAL_KEY --host '*.example.com' --header x-api-key --scheme raw --sandbox <sandbox-id>
```

In the console, keep **Preset** at **Custom** and fill in **Host**, **Header** (`authorization` when left empty), **Scheme** and, for basic, **Username (basic)**. A rule can't use a header that routes or frames a request, such as `host`, `content-length` or `connection`, or one that caas.sh sets itself.

To list and remove rules:

```sh
caas egress rules list
caas egress rules remove <rule-id>
```

In the console, choose **Remove** on the rule's row.

### What the sandbox sees

Every process in a sandbox starts with an environment variable for each credential its rules add, named after the credential and holding the placeholder `caas-egress-placeholder-key`. A rule for `api.anthropic.com`, `api.openai.com` or `generativelanguage.googleapis.com` also sets `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY` and `GOOGLE_API_KEY`, to the placeholder. An SDK that won't start without a key starts and sends the placeholder, and caas.sh puts the real key in its place on the way out. Variables a sandbox needs as they are, such as `PATH` and `HOME`, and names that start with `CAAS_` are never replaced.

The sandbox's page lists the credentials added to its requests, and for which hosts, under **Credentials added at egress** on the **Network policy** card.

### When changes take effect

| Change | Effect on a running sandbox |
| --- | --- |
| Egress policy saved | At once. |
| API key replaced, sign-in reconnected, token refreshed | At the next request. |
| Credential removed | At once: requests to its hosts are blocked until the sandbox's next start. |
| Rule added or removed | At the sandbox's next start, with its environment variables. |

To apply rule changes now, stop the sandbox and start it again.

## Use secrets in workflow steps

A workflow step lists the credentials it uses under `secrets:`, up to 16. Before a run starts, caas.sh checks that each one is connected, has a usable value, and is added by a rule for every sandbox in the workspace: a rule limited to one sandbox doesn't count. A step's sandbox gets only the rules for the secrets its steps list, and for an agent step's model key. A rule doesn't allow a host, so list the hosts a step needs under `egress:` as well.

A step with `agent: claude-code` needs a rule for every sandbox that adds a usable credential to `api.anthropic.com`, such as the `anthropic` preset. Its sandbox reaches `api.anthropic.com` without listing it, and sees `ANTHROPIC_API_KEY` as the placeholder.

```yaml
workflow: weekly-report
image: registry.caas.sh/tools:1
egress: [api.github.com]
steps:
  - id: issues
    name: Export open issues
    run: gh issue list --repo acme/app --json number,title,body > issues.json
    outputs: [issues.json]
    secrets: [GITHUB_TOKEN]

  - id: report
    name: Write the report
    agent: claude-code
    task: Summarize the open issues in issues.json in report.md.
    inputs: [issues]
    outputs: [report.md]
```

`registry.caas.sh/tools:1` stands for an image of your own with `gh` and the `claude` command installed (see [Agent steps](/docs/workflows#agent-steps)). Here `gh` finds `GITHUB_TOKEN` holding the placeholder, and caas.sh adds the real token on its way to `api.github.com`. A one-command workflow puts `secrets:` at the top, beside `run:`.

If a credential or rule is missing, the run doesn't start, and the error names the step and what to fix:

```text
step issues names secret GITHUB_TOKEN, which no workspace egress rule adds; add one under Credentials
```

Resuming a run checks the same. See [Workflows](/docs/workflows) for the rest of the file.

## Reconnect, replace or remove

### Replace an API key

In the console, use the **Replace key** form: choose the key, paste the new value, and choose **Replace key**. With the CLI, the new key comes from standard input:

```sh
caas credentials reconnect OPENAI_API_KEY < new-openai-key.txt
```

The next request that leaves a sandbox gets the new key, and the old one is retired.

### Reconnect a sign-in

When a credential shows **Needs reconnecting** or **Expired**, choose **Reconnect** on its row, or run:

```sh
caas credentials reconnect GOOGLE_DRIVE
```

This starts a new sign-in, install or device code. The value the credential has keeps working until the new one replaces it. `caas credentials list` prints this command for every credential that needs it.

### Remove a credential

Choose **Remove** on its row, or run:

```sh
caas credentials remove GOOGLE_DRIVE
```

This erases the credential's value and every rule that names it, and sandboxes stop getting it at once. Removing a GitHub App credential doesn't uninstall the app from GitHub: uninstall it there if you no longer need it. If a removal fails partway, the credential stays listed; remove it again to finish.

### Change or remove an app

To change an app's settings or rotate its secret, add it again with the same name and type. Its settings and secret are replaced, and its credentials stay connected.

To remove an app, choose **Remove** on its row, or run `caas credentials apps remove acme-bot`. This erases its secret. You can remove an app only once no credential connects through it, so remove those credentials first.

## Who can make changes

| What | Who |
| --- | --- |
| See credentials, apps, rules, egress policies and egress logs | Everyone in the workspace |
| Connect, reconnect and remove credentials; add and remove apps and rules | Owners, in the console or with an owner's API key |
| Change a sandbox's egress policy | Owners and operators |

API keys need the `sandbox:write` scope to make changes. While a workspace is suspended, changes are refused, but you can still read everything in the console. Suspension also revokes the workspace's API keys.

The audit log records each change, with names, methods, apps and hosts, and never a value: `secret.set` for connecting, reconnecting and adding an app, `secret.delete` for removing either, and `egress.rules.update` for rule and policy changes. To list them:

```sh
caas audit --action secret
caas audit --action egress
```

## Limits

| Limit | Value |
| --- | --- |
| Credentials per workspace | 32 |
| OAuth and GitHub apps per workspace | 16 |
| Egress policy rules per sandbox | 64 |
| Egress credential rules per sandbox, the workspace's and its own | 16 |
| Hosts listed when you create a sandbox | 32 |
| A key, client secret or private key | 16 KiB |
| Time to finish a sign-in, install or device code | 15 minutes |
| Secrets per workflow step | 16 |
| Egress log | Newest 1,000 allowed and 1,000 blocked requests, up to seven days |

## Use the API

Every path below is under `https://api.caas.sh/v1/tenants/<tenant-id>`, with your API key sent as `Authorization: Bearer <api-key>`. See [The API](/docs/api) for keys and errors.

| Request | Does |
| --- | --- |
| `GET /credentials` | Lists credentials with their status, never a value. |
| `GET /credentials/NAME` | Reads one credential. |
| `POST /credentials` | Connects a credential. |
| `POST /credentials/NAME/reconnect` | Replaces an API key's value (`secret`), or starts a new sign-in (an OAuth one may ask for new `scopes`). |
| `DELETE /credentials/NAME` | Removes a credential and its rules. |
| `GET /credential-providers` | Lists apps. |
| `POST /credential-providers` | Adds an app, or replaces one with the same name and type. |
| `DELETE /credential-providers/APP` | Removes an app. |
| `GET /egress-rules` | Lists egress credential rules. |
| `POST /egress-rules` | Adds a rule, or a preset's rules. |
| `DELETE /egress-rules/RULE_ID` | Removes a rule. |
| `GET /sandboxes/SANDBOX_ID/egress-policy` | Reads a sandbox's egress policy. |
| `PUT /sandboxes/SANDBOX_ID/egress-policy` | Replaces it: `{"rules": [{"host": "pypi.org", "action": "allow"}]}`. |
| `GET /sandboxes/SANDBOX_ID/egress` | Lists the egress log, newest first; pass `?cursor=N` for the next page. |

To connect an API key, send the key beside the credential:

```json
{ "credential": { "name": "OPENAI_API_KEY", "method": "api_key" }, "secret": "sk-..." }
```

For the other methods, name the app (`provider`) where one is needed. The answer's `credential.action` holds the `url` to open, and a device `code` for Codex. Read the credential every few seconds until its `status` is `ready`:

```json
{ "credential": { "name": "GOOGLE_DRIVE", "method": "oauth", "provider": "google" } }
```

To add rules:

```json
{ "rule": { "preset": "github", "credential": "GITHUB_TOKEN" } }
```

```json
{ "rule": { "credential": "NPM_TOKEN", "host": "registry.npmjs.org", "header": "authorization", "scheme": "bearer" } }
```

Add `"sandboxId"` to a rule to limit it to one sandbox, and `"username"` to a `basic` rule.
