> ## 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.

# Prevent Cloud Agents from Auto-Merging PRs

> Run Formal Endpoint on Cursor and Devin cloud agents and block GitHub auto-merge with policy

export const G = ({term, anchor, children}) => {
  const href = anchor ? `/docs/glossary/index#${anchor}` : `/docs/glossary/index`;
  return <a href={href} className="glossary-link" style={{
    textDecoration: "underline",
    textDecorationLine: "underline",
    textDecorationColor: "#6b7280",
    textDecorationThickness: "1px",
    textUnderlineOffset: "2px",
    color: "inherit",
    transition: "text-decoration-color 0.2s ease",
    borderBottom: "none"
  }} onMouseEnter={e => e.target.style.textDecorationColor = "#fff"} onMouseLeave={e => e.target.style.textDecorationColor = "#6b7280"}>
  {children || term}
</a>;
};

## Overview

Teams may require human review before pull requests updated by cloud agents are
merged. Run the Formal Endpoint inside each agent to enforce this workflow by
preventing auto-merge from being enabled.

Cloud agent platforms mint short-lived OIDC tokens for their agents. The
Endpoint exchanges these tokens for a Formal machine session and refreshes them
automatically. Its transparent proxy evaluates each GitHub request against
Formal policy.

This guide covers Cursor Cloud Agents and Devin. The general setup is the same
for both. Setup steps that differ are split into provider tabs.

```mermaid theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
sequenceDiagram
  participant Agent as Cloud agent
  participant Provider as Agent OIDC provider
  participant Endpoint as Formal Endpoint
  participant Formal as Formal Control Plane
  participant GitHub

  Endpoint->>Provider: Mint JWT for Formal audience
  Provider-->>Endpoint: Short-lived OIDC JWT
  Endpoint->>Formal: Exchange JWT
  Formal-->>Endpoint: Short-lived machine session
  Agent->>Endpoint: HTTPS request
  Endpoint->>Endpoint: Evaluate Formal policy
  Endpoint->>GitHub: Forward allowed request
```

## Prerequisites

* A Cursor Cloud Agent environment or a Devin environment
* A Formal machine user for your cloud agents
* Permission to create OIDC integrations, Permissions, and policies in Formal
* Permission to edit the cloud agent environment

## Configure the OIDC integration

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

<Tabs>
  <Tab title="Cursor">
    <Steps>
      <Step title="Restrict the claim condition">
        Replace `your-team-id` in the **Cursor** preset claim condition. You can
        also restrict the integration 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>"
        ```

        Use `claims.repo_urls` when the environment checks out multiple
        repositories. See [Cursor's OIDC claim reference](https://cursor.com/docs/cloud-agent/identity)
        for all available claims.
      </Step>

      <Step title="Map the Cursor owner">
        Keep the preset **End-User Email Expression**:

        ```cel theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
        claims.owner_email
        ```

        This maps each agent action to the Formal human with the same email.
        Confirm every Cursor owner has a matching Formal user before activation.
      </Step>

      <Step title="Copy the integration ID">
        Copy the generated `integrationoidc_...` value. The Endpoint requests
        the audience `oidc.formal.ai/<INTEGRATION_ID>`.
      </Step>
    </Steps>
  </Tab>

  <Tab title="Devin">
    <Steps>
      <Step title="Restrict the claim condition">
        Replace `your-org-id` in the **Devin** preset claim condition with your
        Devin organization ID:

        ```cel theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
        claims.org_id == "<DEVIN_ORG_ID>"
        ```

        See [Devin's OIDC documentation](https://docs.devin.ai/product-guides/oidc)
        for all available claims, including `devin_id`.
      </Step>

      <Step title="Map the session requester">
        Keep the preset **End-User Email Expression**:

        ```cel theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
        claims.requesting_user_email
        ```

        This maps each agent action to the Formal human who ran the session.
        Confirm every Devin user has a matching Formal user before activation.
      </Step>

      <Step title="Copy the integration ID">
        Copy the generated `integrationoidc_...` value. The Endpoint requests
        the audience `oidc.formal.ai/<INTEGRATION_ID>`.
      </Step>
    </Steps>
  </Tab>
</Tabs>

### Restrict Control Plane access

The cloud agent machine user only needs the Endpoint authentication and logging
flows. Create a Control Plane Permission that denies every other Formal API and
console operation for that identity.

<Steps>
  <Step title="Copy the machine user ID">
    Open the machine user selected by the OIDC integration and copy its
    `user_...` ID.
  </Step>

  <Step title="Create the Permission">
    Go to [Permissions](https://app.formal.ai/permissions) and click **Create
    Permission**. Name it `block-cloud-agent-control-plane` and paste:

    ```rego theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
    package formal.app

    import future.keywords.if

    default allow = true

    allow = false if {
      input.user.id == "<CLOUD_AGENT_MACHINE_USER_ID>"
    }
    ```

    Replace `<CLOUD_AGENT_MACHINE_USER_ID>` with the copied ID.
  </Step>

  <Step title="Activate and verify">
    Set the Permission to **Active**. An API request authenticated as the cloud
    agent machine user should return HTTP `403` for Control Plane operations.
    The Formal Endpoint can still authenticate and send logs through its
    dedicated APIs.
  </Step>
</Steps>

## Create the network rule

The Endpoint passes a connection through untouched unless a network rule
matches it. Create a rule for GitHub so the Endpoint terminates TLS and
evaluates policy. Restrict the rule to your agent machine users.

<Tabs>
  <Tab title="Web Console">
    1. Navigate to [Network Rules](https://app.formal.ai/network-rules)
    2. Create a rule named `github-cloud-agents`
    3. Match hostname `github.com` and `api.github.com` on the pre-TLS condition
    4. Restrict the rule to your cloud agent machine users
    5. Leave **Forward to Connector** unset
    6. Save the rule and set it to **Active**
  </Tab>

  <Tab title="Terraform">
    ```hcl theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
    resource "formal_network_rule" "github_cloud_agents" {
      name        = "github-cloud-agents"
      description = "Intercept GitHub traffic from cloud agents"
      status      = "active"

      cel_expression = <<-EOT
        {
          "condition": {
            "pre_tls": (hostname == "github.com" || hostname == "api.github.com") &&
              user.id in ["<MACHINE_USER_ID>"],
            "post_tls": true
          },
          "outputs": {}
        }
      EOT
    }
    ```
  </Tab>
</Tabs>

<Note>
  Interception happens on the Endpoint. This rule does not need a Formal
  <G anchor="resource">resource</G>, Connector listener, or **Forward to
  Connector**.
</Note>

## Configure the agent environment

Install the Endpoint while the environment image is built. If the environment
restricts egress, allow `api.joinformal.com`,
`static-assets.formalcloud.net`, and each governed destination.

<Tabs>
  <Tab title="Cursor">
    Cursor environment builds preserve installed files, but not running
    processes. Start the Endpoint again for every Cloud Agent run. The Cursor
    OIDC socket is local and does not require outbound access.

    <AccordionGroup>
      <Accordion title="Cursor dashboard">
        <Steps>
          <Step title="Open the environment">
            Open **Cloud Agents** → **Environments** in Cursor. Create an
            environment or edit the environment used by your repository.
          </Step>

          <Step title="Start guided setup">
            Start Cursor's environment setup flow. Give the setup agent these
            instructions:

            ```text theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
            Install the latest Formal Endpoint headless Debian package from:
            https://static-assets.formalcloud.net/desktop-app/linux/formal-desktop-headless_latest_amd64.deb

            Write ~/.formal/config.toml with:
            - auth_mode = "oidc"
            - oidc.provider = "cursor"
            - oidc.integration_id = "integrationoidc_<INTEGRATION_ID>"
            - transparent_proxy.enable = true
            - transparent_proxy.enforce = true
            - transparent_proxy.fail = "closed"

            Configure the environment runtime to wait for CURSOR_AGENT_SOCKET and
            then run: formal agent --headless
            ```

            Replace `<INTEGRATION_ID>` before submitting the instructions. Use
            the `arm64` package on an ARM environment.
          </Step>

          <Step title="Activate the build">
            Review the generated install and start configuration. Create a
            successful environment build, then make it active.
          </Step>
        </Steps>
      </Accordion>

      <Accordion title="environment.json">
        Use the following repository-managed configuration when you want setup
        changes reviewed and versioned with the repository.

        <Steps>
          <Step title="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

            arch="$(dpkg --print-architecture)"
            case "${arch}" in
              amd64|arm64) ;;
              *) echo "Unsupported architecture: ${arch}" >&2; exit 1 ;;
            esac

            package="/tmp/formal-desktop-headless.deb"
            curl --fail --silent --show-error --location \
              "https://static-assets.formalcloud.net/desktop-app/linux/formal-desktop-headless_latest_${arch}.deb" \
              --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
            ```

            Replace `<INTEGRATION_ID>` with the copied integration ID. Pin a
            versioned Endpoint package URL when your rollout process requires
            deterministic versions.
          </Step>

          <Step title="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` to `/run/cursor/api.sock` on
            managed Cloud Agent VMs. Do not hard-code this path because
            self-hosted workers use a different path. The retry handles the
            brief period before Cursor creates the socket during boot.
          </Step>

          <Step title="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
            ```
          </Step>

          <Step title="Update the environment configuration">
            Add the install command and named terminal to
            `.cursor/environment.json`. Merge these fields into any existing
            environment configuration:

            ```json theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
            {
              "name": "Cursor Cloud with Formal Endpoint",
              "install": "./.cursor/install-formal-endpoint.sh",
              "terminals": [
                {
                  "name": "formal-endpoint",
                  "command": "./.cursor/start-formal-endpoint.sh",
                  "description": "Runs the Formal Endpoint and transparent proxy"
                }
              ]
            }
            ```

            Commit all three files. New Cloud Agents use the
            repository-managed environment configuration.
          </Step>
        </Steps>
      </Accordion>
    </AccordionGroup>
  </Tab>

  <Tab title="Devin">
    Devin snapshots preserve installed files, the systemd user service, and
    lingering. The Endpoint package ships a user service, so you only configure
    the environment once. Every later session starts the Endpoint
    automatically.

    <Steps>
      <Step title="Install the Devin OIDC helper">
        Add Cognition's OIDC action to the `initialize` section of your
        [environment blueprint](https://docs.devin.ai/onboard-devin/environment-yaml):

        ```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, which exchanges the session
        token for an audience-scoped token. The Endpoint runs this CLI to
        authenticate.

        **Verify:**

        ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
        command -v devin-oidc
        # Expected: /usr/local/bin/devin-oidc
        ```
      </Step>

      <Step title="Install and configure the Endpoint">
        Add a step that installs the headless Debian package and writes the
        Endpoint configuration:

        ```yaml theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
          - run: |
              set -euo pipefail

              arch="$(dpkg --print-architecture)"
              package="/tmp/formal-desktop-headless.deb"
              curl --fail --silent --show-error --location \
                "https://static-assets.formalcloud.net/desktop-app/linux/formal-desktop-headless_latest_${arch}.deb" \
                --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
        ```

        Replace `<INTEGRATION_ID>` with the copied integration ID. Pin a
        versioned Endpoint package URL when your rollout process requires
        deterministic versions.
      </Step>

      <Step title="Enable the systemd user service">
        The package installs a `formal.service` user unit that runs
        `formal agent --headless`. Enable lingering so systemd starts the unit
        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
        ```
      </Step>
    </Steps>

    <Tip>
      Read service logs with `journalctl --user -u formal.service`. Set
      `XDG_RUNTIME_DIR` first when your shell has no session bus.
    </Tip>
  </Tab>
</Tabs>

## Verify the setup

Start a new cloud agent session, then run:

```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
formal auth whoami
formal transparent-proxy status
sudo env HOME="${HOME}" formal ca trust
curl -v https://api.github.com -o /dev/null
```

`formal auth whoami` should show the configured machine user. The transparent
proxy status should be enabled. The verbose `curl` output should show:

```text theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
subject: O=Formal; CN=Formal Desktop Certificate
issuer: C=US; ST=New York; L=New York; O=Formal; CN=Formal Desktop Local CA
SSL certificate verify ok.
```

GitHub's own certificate means the network rule is not matching.

The package watches for regenerated Endpoint certificates and updates the
system trust store. Run `formal ca trust` again if a client reports an
untrusted Formal CA.

## Create the merge policy

GitHub merges pull requests through a GraphQL mutation or a REST endpoint.
This policy parses the GraphQL request and blocks both paths:

```rego theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
package formal.v2

import future.keywords.contains
import future.keywords.if
import future.keywords.in

default request := {"action": "allow"}

blocked_mutations := {
  "enablePullRequestAutoMerge",
  "enqueuePullRequest",
  "mergePullRequest",
}

block := {
  "action": "block",
  "type": "block_with_formal_message",
  "reason": "Cloud agents cannot merge pull requests",
}

document := graphql.parse_query(input.http.json.query)

is_mutation if {
  some operation in document.Operations
  operation.Operation == "mutation"
}

# Selections also live in named and inline fragments, so walk the whole
# document instead of only the operation's top-level selection set.
selected_names contains name if {
  walk(document, [_, node])
  name := node.Name
}

request := block if {
  input.http.hostname == "api.github.com"
  input.http.path == "/graphql"

  is_mutation

  some name in selected_names
  name in blocked_mutations
}

request := block if {
  input.http.hostname == "api.github.com"
  input.http.method == "PUT"
  regex.match(`^/repos/[^/]+/[^/]+/pulls/[0-9]+/merge$`, input.http.path)
}
```

Create the policy in **Draft** or **Dry-run** first. Set it to **Active** after
reviewing the matching requests.

## Verify merging is blocked

Use a disposable pull request that does not already have auto-merge enabled:

```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
pull_request_id="$(gh pr view <PULL_REQUEST_NUMBER> --json id --jq .id)"

gh api graphql \
  -f query='
    mutation($pullRequestId: ID!) {
      enablePullRequestAutoMerge(
        input: {
          pullRequestId: $pullRequestId
          mergeMethod: SQUASH
        }
      ) {
        pullRequest {
          number
        }
      }
    }
  ' \
  -F pullRequestId="${pull_request_id}"
```

The command should return HTTP `403` with a Formal policy, session, and request
ID. Open [Logs](https://app.formal.ai/logs) and confirm the blocked request
contains `enablePullRequestAutoMerge`.

The REST merge endpoint should be blocked as well:

```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
gh api --method PUT \
  repos/<OWNER>/<REPO>/pulls/<PULL_REQUEST_NUMBER>/merge
```

## Troubleshooting

<AccordionGroup>
  <Accordion title="The Cursor OIDC socket is unavailable">
    Start the Endpoint from the environment's runtime command or `terminals`,
    not `install`. Install processes do not survive an environment build.
    Retry with a new Cloud Agent if the socket remains unavailable.
  </Accordion>

  <Accordion title="The Devin user service is not running">
    Confirm lingering is enabled with `loginctl show-user "$(id -un)"`. Confirm
    `devin-oidc` is on the service's `PATH`. Read failures with
    `journalctl --user -u formal.service`.
  </Accordion>

  <Accordion title="OIDC authentication fails">
    Confirm the integration is active. Its issuer should be
    `https://api.cursor.com` for Cursor and `https://app.devin.ai` for Devin.
    Verify every claim in the claim condition exists on the token. Confirm the
    configured integration ID has no `oidc.formal.ai/` prefix.
  </Accordion>

  <Accordion title="TLS verification fails">
    Run:

    ```bash theme={"languages":{"custom":["/languages/cel.json","/languages/rego.json"]}}
    sudo env HOME="${HOME}" formal ca trust
    ```

    Then retry the request. Do not disable TLS verification.
  </Accordion>

  <Accordion title="The policy does not evaluate">
    Confirm the policy is **Active** and the transparent proxy is enabled.
    Confirm the GitHub network rule is **Active** and matches the agent's
    machine user. Without a matching rule the Endpoint forwards the connection
    without inspecting it.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Formal Endpoint" icon="desktop" href="/docs/guides/client-apps/desktop-app">
    Review Endpoint authentication and connection options
  </Card>

  <Card title="OIDC Integrations" icon="id-card" href="/docs/guides/integrations/oidc">
    Configure claims and federated access
  </Card>

  <Card title="Policy Evaluation" icon="gavel" href="/docs/guides/policies/evaluation">
    Explore HTTP and agent policy inputs
  </Card>

  <Card title="Endpoint Logs" icon="chart-line" href="/docs/guides/observability/logs">
    Review requests and policy decisions
  </Card>
</CardGroup>
