> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formal.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Keep API Keys Out of Cloud Agent Sandboxes

> Forward Cursor and Devin cloud agent API calls through the Formal Endpoint to a Connector that injects the credential

## Overview

Cloud agents such as Cursor Cloud Agents and Devin often need API keys to run
and test your application. Teams usually store those keys as environment
secrets. Every agent session can then read the raw key, print it, or send it
anywhere.

With Formal, the key never enters the sandbox:

1. The [Formal Endpoint](https://docs.formal.ai/docs/glossary/index.md#formal-endpoint) runs inside the agent
   sandbox and authenticates with the agent's short-lived OIDC token.
2. A [network rule](https://docs.formal.ai/docs/guides/network-rules.md) intercepts requests to the API
   and forwards them to a [Connector](https://docs.formal.ai/docs/glossary/index.md#connector).
3. A [Native User](https://docs.formal.ai/docs/glossary/index.md#native-user) on the Connector injects the key.
4. [Policies](https://docs.formal.ai/docs/glossary/index.md#policy) restrict which requests the agent can make.
   Every request is logged with the agent and the human who started it.

The agent keeps calling the real API hostname. You do not change base URLs or
client code.

This guide uses the Stripe API with a bearer key. The same setup works for any
HTTP API and for every [Native User type](https://docs.formal.ai/docs/guides/core-concepts/resources/native-users.md#supported-credential-types),
including Basic credentials, header and query API keys, and keys fetched from
a vault by a hook.

```mermaid theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
sequenceDiagram
  participant Agent as Cloud agent
  participant Endpoint as Formal Endpoint
  participant Formal as Formal Control Plane
  participant Connector as Connector
  participant Stripe as api.stripe.com

  Endpoint->>Formal: Exchange agent OIDC token
  Formal-->>Endpoint: Machine session
  Agent->>Endpoint: GET /v1/balance (no key)
  Endpoint->>Connector: Forward with agent identity
  Connector->>Connector: Evaluate policy, inject Native User key
  Connector->>Stripe: Authorization: Bearer <STRIPE_API_KEY>
  Stripe-->>Agent: Response
```

## Prerequisites

* A deployed [Connector](https://docs.formal.ai/docs/glossary/index.md#connector) with a
  [hostname](https://docs.formal.ai/docs/guides/core-concepts/connectors/tls.md) that the agent sandbox
  can reach
* A Cursor Cloud Agent environment or a Devin environment you can edit
* A Formal machine user for your cloud agents
* Permission to create OIDC integrations, resources, network rules, and
  policies in Formal
* A Stripe API key

## 1. Store the key on the Connector

Set the key as the `STRIPE_API_KEY` environment variable on the Connector.
Remove it from your Cursor and Devin secrets.

## 2. Create the Stripe resource and Native User

> [tab] Control Plane
>
> 1. Navigate to [Resources](https://app.formal.ai/resources) and click **Create Resource**
> 2. Set **Technology** to **HTTP**, **Name** to `stripe`, **Hostname** to `api.stripe.com`, and **Port** to `443`
> 3. Save the resource, then add a TLS configuration and select **Verify Full**
> 4. Select **Authentication**, click **Add User**, and select **HTTP Bearer**
> 5. Set **Label** to `stripe-api-key`
> 6. Choose **Static**, set the header to `Authorization`, and choose **Environment Variable**
> 7. Enter `STRIPE_API_KEY` and click **Create**
> 8. Under **Default Native User**, enter this CEL expression and click **Save**
>
> ```cel theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> native_users["stripe-api-key"]
> ```

> [tab] Terraform
>
> ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> resource "formal_resource" "stripe" {
>   name       = "stripe"
>   technology = "http"
>   hostname   = "api.stripe.com"
>   port       = 443
> }
>
> resource "formal_resource_tls_configuration" "stripe" {
>   resource_id = formal_resource.stripe.id
>   tls_config  = "verify-full"
> }
>
> resource "formal_native_user_v3" "stripe" {
>   resource_id = formal_resource.stripe.id
>   label       = "stripe-api-key"
>
>   http_bearer {
>     header = "Authorization"
>     token {
>       environment_variable = "STRIPE_API_KEY"
>     }
>   }
> }
>
> resource "formal_resource_native_user_selection" "stripe" {
>   resource_id = formal_resource.stripe.id
>   cel         = "\"${formal_native_user_v3.stripe.id}\""
> }
> ```

The Native User replaces any `Authorization` header the agent sends. Without a
default Native User, the Connector forwards requests without a key, and Stripe
returns `401`.

**Verify:** The `stripe-api-key` Native User appears under **Native Users**
with type **HTTP Bearer** and source `STRIPE_API_KEY`.

## 3. Make Stripe reachable through the Connector

> [tab] Control Plane
>
> 1. Open your Connector and select **Listeners**
> 2. Create a listener on an available port, such as `443`
> 3. Open the listener, click **New Rule**, set the type to **Resource**, and select `stripe`

> [tab] Terraform
>
> ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> resource "formal_connector_listener" "stripe" {
>   connector_id = formal_connector.example.id
>   name         = "stripe-listener"
>   port         = 443
> }
>
> resource "formal_connector_listener_rule" "stripe" {
>   connector_listener_id = formal_connector_listener.stripe.id
>   type                  = "resource"
>   rule                  = formal_resource.stripe.id
> }
> ```

The Endpoint forwards to the Connector's hostname on this listener port.

**Verify:** `stripe` appears under the Connector's **Reachable Resources**.

## 4. Configure the OIDC integration

The Endpoint in the sandbox exchanges the agent's OIDC token for a Formal
machine session. No Formal API key is stored in the sandbox.

Go to [OIDC Integrations](https://app.formal.ai/oidc-integrations) and click
**Create Integration**. Select your provider's preset, then select the machine
user for your cloud agents.

> [tab] Cursor
>
> Replace `your-team-id` in the **Cursor** preset claim condition. You can
> also restrict it to one repository:
>
> ```cel theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> claims.team_id == "<CURSOR_TEAM_ID>" &&
> claims.repo_url == "github.com/<ORGANIZATION>/<REPOSITORY>"
> ```
>
> Keep the preset **End-User Email Expression**, `claims.owner_email`. Formal
> then logs each request with the human who started the agent.

> [tab] Devin
>
> Replace `your-org-id` in the **Devin** preset claim condition:
>
> ```cel theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> claims.org_id == "<DEVIN_ORG_ID>"
> ```
>
> Keep the preset **End-User Email Expression**,
> `claims.requesting_user_email`. Formal then logs each request with the
> human who started the session.

Copy the generated `integrationoidc_...` ID and the machine user's `user_...`
ID. To block the machine user from every other Control Plane operation, follow
[Restrict Control Plane access](https://docs.formal.ai/docs/guides/example-use-cases/formal-endpoint-cloud-agents.md#restrict-control-plane-access).

## 5. Forward Stripe traffic to the Connector

Create a network rule that intercepts requests to `api.stripe.com` from the
cloud agent machine user and forwards them to the Connector. On macOS,
`pre_tcp` checks the Endpoint identity, so Endpoints logged in as anyone else
leave their traffic alone. Linux ignores `pre_tcp`, so `pre_tls` checks the
identity again along with the hostname.

> [tab] Control Plane
>
> 1. Navigate to [Network Rules](https://app.formal.ai/network-rules)
> 2. Create a rule named `stripe-cloud-agents`
> 3. Paste the CEL below and replace `<CLOUD_AGENT_MACHINE_USER_ID>`
> 4. Save the rule and set it to **Active**
>
> ```cel theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> {
>   "condition": {
>     "pre_tcp": user.id == "<CLOUD_AGENT_MACHINE_USER_ID>",
>     "pre_tls": hostname == "api.stripe.com" &&
>       user.id == "<CLOUD_AGENT_MACHINE_USER_ID>",
>     "post_tls": true
>   },
>   "outputs": {
>     "resource_name": "stripe",
>     "forward_to_connector": true
>   }
> }
> ```

> [tab] Terraform
>
> ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> resource "formal_network_rule" "stripe_cloud_agents" {
>   name        = "stripe-cloud-agents"
>   description = "Forward cloud agent Stripe requests to the Connector for key injection"
>   status      = "active"
>
>   cel_expression = <<-EOT
>     {
>       "condition": {
>         "pre_tcp": user.id == "<CLOUD_AGENT_MACHINE_USER_ID>",
>         "pre_tls": hostname == "api.stripe.com" &&
>           user.id == "<CLOUD_AGENT_MACHINE_USER_ID>",
>         "post_tls": true
>       },
>       "outputs": {
>         "resource_name": "stripe",
>         "forward_to_connector": true
>       }
>     }
>   EOT
> }
> ```

> [!WARNING]
>
> **Forward to Connector** must be `true`. Without it, the Endpoint evaluates
> the request locally, and no Native User injects the key.

## 6. Bake the Endpoint into the agent environment

Install the Endpoint when the environment image is built. The sandbox needs
outbound access to:

* `api.joinformal.com`, for authentication, network rules, and logs
* Your Connector hostname, on the listener port from step 3
* The Endpoint package download link, while the image is built

The Connector, not the sandbox, opens the connection to `api.stripe.com`.

Each setup below downloads the headless Debian package from a link you copy
from the [Control Plane](https://app.formal.ai). Click **Download Formal
Endpoint** at the bottom of the sidebar and open **Linux**. Right-click the
headless package for your architecture and copy the link:

* **Ubuntu/Debian (x86, headless)** for `amd64`
* **Ubuntu/Debian (ARM, headless)** for `arm64`

Replace `<FORMAL_ENDPOINT_PACKAGE_URL>` below with the copied link and
`<INTEGRATION_ID>` with the integration ID from step 4. To upgrade the
Endpoint, copy the link again and update your configuration.

> [!NOTE]
>
> Node.js and most Python HTTP clients ignore the system trust store. The
> setups below point them at `/etc/ssl/certs/ca-certificates.crt`, which
> includes the Formal local CA once the Endpoint trusts it. See
> [Runtime TLS trust](https://docs.formal.ai/docs/guides/client-apps/desktop-app.md#runtime-tls-trust)
> for the variables other clients read.

> [tab] Cursor
>
> Cursor environment builds keep installed files, but not running processes.
> The install script runs once per build. A named terminal starts the
> Endpoint again in every Cloud Agent run. The Cursor OIDC socket is local
> and does not need outbound access.
>
> **1. Create the install script**
>
> Add `.cursor/install-formal-endpoint.sh`:
>
> ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> #!/usr/bin/env bash
> set -euo pipefail
>
> package="/tmp/formal-desktop-headless.deb"
> curl --fail --silent --show-error --location \
>   "<FORMAL_ENDPOINT_PACKAGE_URL>" \
>   --output "${package}"
> sudo apt-get update
> sudo apt-get install --yes "${package}"
>
> mkdir -p "${HOME}/.formal"
> cat >"${HOME}/.formal/config.toml" <<'EOF'
> auth_mode = "oidc"
>
> [oidc]
> provider = "cursor"
> integration_id = "integrationoidc_<INTEGRATION_ID>"
>
> [transparent_proxy]
> enable = true
> enforce = true
> fail = "closed"
> EOF
>
> if ! grep -q "formal-runtime-ca" "${HOME}/.bashrc" 2>/dev/null; then
>   cat >>"${HOME}/.bashrc" <<'EOF'
> # formal-runtime-ca
> export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
> export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
> export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
> EOF
> fi
> ```
>
> **2. Create the runtime script**
>
> Add `.cursor/start-formal-endpoint.sh`:
>
> ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> #!/usr/bin/env bash
> set -euo pipefail
>
> : "${CURSOR_AGENT_SOCKET:?Cursor OIDC socket is unavailable}"
>
> for attempt in $(seq 1 30); do
>   if [[ -S "${CURSOR_AGENT_SOCKET}" ]]; then
>     exec formal agent --headless
>   fi
>   sleep 1
> done
>
> echo "Cursor OIDC socket did not become available" >&2
> exit 1
> ```
>
> Cursor sets `CURSOR_AGENT_SOCKET` on Cloud Agent VMs. Do not hard-code
> its path, because self-hosted workers use a different one. The retry
> covers the moment before Cursor creates the socket during boot.
>
> **3. Make the scripts executable**
>
> ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> chmod +x \
>   .cursor/install-formal-endpoint.sh \
>   .cursor/start-formal-endpoint.sh
> ```
>
> **4. Update the environment configuration**
>
> Merge these fields into `.cursor/environment.json`:
>
> ```json theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> {
>   "install": "./.cursor/install-formal-endpoint.sh",
>   "terminals": [
>     {
>       "name": "formal-endpoint",
>       "command": "./.cursor/start-formal-endpoint.sh",
>       "description": "Runs the Formal Endpoint and Transparent Mode"
>     }
>   ]
> }
> ```
>
> Commit all three files. New Cloud Agents build from the
> repository-managed configuration.
>
> You can instead give these steps to Cursor's guided environment setup
> under **Cloud Agents** → **Environments**. Review the generated
> configuration before you activate the build.

> [tab] Devin
>
> The Endpoint runs as a systemd user service. Devin saves the installed
> package and the enabled service in its environment snapshot. Every later
> session then starts the Endpoint automatically.
>
> Add these steps to the `initialize` section of your
> [environment blueprint](https://docs.devin.ai/onboard-devin/environment-yaml).
>
> **1. Install the Devin OIDC helper**
>
> ```yaml theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> initialize:
>   - uses: github.com/CognitionAI/actions/setup-devin-oidc@main
> ```
>
> The action installs the `devin-oidc` CLI. The Endpoint runs it to mint
> a token for the Formal audience.
>
> **2. Install and configure the Endpoint**
>
> ```yaml theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
>   - run: |
>       set -euo pipefail
>
>       package="/tmp/formal-desktop-headless.deb"
>       curl --fail --silent --show-error --location \
>         "<FORMAL_ENDPOINT_PACKAGE_URL>" \
>         --output "${package}"
>       sudo apt-get update
>       sudo apt-get install --yes "${package}"
>
>       mkdir -p "${HOME}/.formal"
>       cat >"${HOME}/.formal/config.toml" <<'EOF'
>       auth_mode = "oidc"
>
>       [oidc]
>       provider = "devin"
>       integration_id = "integrationoidc_<INTEGRATION_ID>"
>
>       [transparent_proxy]
>       enable = true
>       enforce = true
>       fail = "closed"
>       EOF
>
>       if ! grep -q "formal-runtime-ca" "${HOME}/.bashrc" 2>/dev/null; then
>         cat >>"${HOME}/.bashrc" <<'EOF'
>       # formal-runtime-ca
>       export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
>       export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
>       export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
>       EOF
>       fi
> ```
>
> **3. Enable the systemd user service**
>
> The package installs a `formal.service` user unit that runs
> `formal agent --headless`. Enable lingering so systemd starts it
> without an interactive login:
>
> ```yaml theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
>   - run: |
>       set -euo pipefail
>
>       sudo loginctl enable-linger "$(id -un)"
>       export XDG_RUNTIME_DIR="/run/user/$(id -u)"
>       systemctl --user enable --now formal.service
> ```
>
> **Verify:**
>
> ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> XDG_RUNTIME_DIR="/run/user/$(id -u)" \
>   systemctl --user is-active formal.service
> # Expected: active
> ```

## 7. Call Stripe without a key

Start a new Cursor Cloud Agent or Devin session. Confirm the Endpoint is
signed in and intercepting:

```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
formal auth whoami
formal transparent-proxy status
```

`formal auth whoami` should show the cloud agent machine user, and Transparent
Mode should be enabled. Then call Stripe with no key:

```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
curl https://api.stripe.com/v1/balance
```

The response should be the account balance.

**Verify:**

1. Open [Logs](https://app.formal.ai/logs) and filter on the `stripe` resource
2. Confirm the request shows the cloud agent machine user and the human who
   started the session
3. Confirm the response status is `200`
4. Run `env | grep -i stripe` in the sandbox and confirm no key is set

## 8. (Optional) Restrict Stripe requests with a policy

The Native User injects the key into every request that reaches the `stripe`
resource. To limit what agents can do with it, add a policy. This one only
allows reads of balances, charges, and customers:

> [tab] Control Plane
>
> 1. Navigate to [Policies](https://app.formal.ai/policies) and click **Create Policy**
> 2. Set name to `stripe-cloud-agents-read-only`
> 3. Paste the Rego below and click **Create Policy**
>
> ```rego theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> package formal.v2
>
> import future.keywords.if
> import future.keywords.in
>
> allowed_paths := {"/v1/balance", "/v1/charges", "/v1/customers"}
>
> allowed if {
>   input.http.method == "GET"
>   some path in allowed_paths
>   startswith(concat("", [input.http.path, "/"]), concat("", [path, "/"]))
> }
>
> request := {
>   "action": "block",
>   "type": "block_with_formal_message",
>   "reason": "This Stripe request is not allowed for cloud agents"
> } if {
>   input.resource.hostname == "api.stripe.com"
>   not allowed
> }
> ```

> [tab] Terraform
>
> ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> resource "formal_policy" "stripe_cloud_agents_read_only" {
>   name        = "stripe-cloud-agents-read-only"
>   description = "Allow cloud agents to read Stripe balances, charges, and customers"
>   status      = "active"
>
>   module = <<-EOT
>     package formal.v2
>
>     import future.keywords.if
>     import future.keywords.in
>
>     allowed_paths := {"/v1/balance", "/v1/charges", "/v1/customers"}
>
>     allowed if {
>       input.http.method == "GET"
>       some path in allowed_paths
>       startswith(concat("", [input.http.path, "/"]), concat("", [path, "/"]))
>     }
>
>     request := {
>       "action": "block",
>       "type": "block_with_formal_message",
>       "reason": "This Stripe request is not allowed for cloud agents"
>     } if {
>       input.resource.hostname == "api.stripe.com"
>       not allowed
>     }
>   EOT
> }
> ```

Create the policy in **Dry-run** first. Set it to **Active** after reviewing
the matching requests. See [HTTP object](https://docs.formal.ai/docs/guides/policies/evaluation.md#http-object)
for all request fields.

**Verify:** With the policy active, `curl -X POST https://api.stripe.com/v1/refunds`
returns the Formal block message.

## Troubleshooting

> [accordion] Stripe returns 401
>
> **Possible causes:**
>
> * The network rule is in **Draft** or does not match the machine user
> * **Forward to Connector** is not set, so the request never reaches the Connector
> * The resource has no default Native User, or `STRIPE_API_KEY` is not set on the Connector
>
> **Fix:** Confirm the request appears in [Logs](https://app.formal.ai/logs)
> under the `stripe` resource. If it does not, check the network rule. If
> it does, check the Native User and the Connector environment.

> [accordion] TLS verification fails
>
> Run:
>
> ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
> sudo env HOME="${HOME}" formal ca trust
> ```
>
> Open a new shell so it loads `~/.bashrc`, then retry. Do not disable TLS
> verification.

> [accordion] Requests time out
>
> Confirm the sandbox can reach the Connector hostname on the listener port.
> With `fail = "closed"`, a matched request fails when the Endpoint cannot
> reach the Connector.

> [accordion] The Endpoint is not signed in
>
> For Cursor, start the Endpoint from `terminals`, not `install`, because
> install processes do not survive the build. For Devin, confirm lingering
> with `loginctl show-user "$(id -un)"` and read
> `journalctl --user -u formal.service`. In both cases, confirm the
> integration ID has no `oidc.formal.ai/` prefix.

## Next Steps

> [card] [Native Users](https://docs.formal.ai/docs/guides/core-concepts/resources/native-users.md)
>
> Inject other credential types or fetch them with hooks

> [card] [Block Agent Auto-Merge](https://docs.formal.ai/docs/guides/example-use-cases/formal-endpoint-cloud-agents.md)
>
> Enforce GitHub policy inside cloud agents

> [card] [Devin Database Access](https://docs.formal.ai/docs/guides/example-use-cases/devin-database-access-oidc.md)
>
> Approve Devin database access in Slack

> [card] [Network Rules](https://docs.formal.ai/docs/guides/network-rules.md)
>
> Choose which traffic the Endpoint intercepts


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.