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

# Security model

> What Bellows isolates, what an agent can reach, how secrets and tokens are handled, and where the limits are.

This page is for a security reviewer. It states what each control does and what it does not. Names of values refer to the Kubernetes chart. See [Kubernetes and Helm](https://docs.bellows.cloud/self-host/kubernetes.md) to set them.

## The short version

* An agent runs code you did not write, next to credentials. Bellows confines it with hardened containers, separate user ids, a network policy, and an admission policy. It holds no long-lived credential.
* The agent gets a short-lived GitHub token for one repository, fetched on demand. The token is not in its environment.
* By default this is **not** kernel or VM isolation: every runner shares the node's kernel with other pods. Set `runner.runtimeClassName` (for example `gvisor` on GKE Sandbox) to run every runner pod in a sandbox kernel.
* Membership is not a sandbox. Anyone in an organization can queue a command that an agent runs on any tracked repository.

## Trust boundaries

| Component | Holds | Does not hold |
| - | - | - |
| Dashboard | GitHub App private key, secret-store keys, artifact store credentials, session secret | Agent-written code (it runs in runner pods, never here) |
| Driver | The shared board token, a namespaced Role that creates Jobs | No `get` or `list` on Secrets. It never reads a Secret back |
| Runner pod | Its per-attempt credentials, model credentials, a memory-only GitHub token cache | No ServiceAccount token, no App key |
| Agent tool commands | Nothing | No GitHub token, no model credential, no control-channel token |

Keep the runner node group separate from the dashboard's nodes (`runner.nodeSelector`, `runner.tolerations`). Then agent code never shares a node with the process that holds the App key.

## Runner isolation

### Hardened containers

On Kubernetes, the admission policy requires these on every pod the driver creates. The cluster refuses a pod that does not meet them.

| Control | Setting |
| - | - |
| No capabilities | `capabilities.drop: [ALL]` on every container |
| No escalation | `allowPrivilegeEscalation: false` |
| Seccomp | `RuntimeDefault` |
| No cluster credential | `automountServiceAccountToken: false`, default ServiceAccount |
| No host access | No host ports, no host namespaces, no host path mounts, no `nodeName` pinning |
| Limited mounts | Only the task volume, `emptyDir` and the pod's own per-attempt Secrets |
| Limited Secret reads | Only the pod's own per-attempt Secrets and the runner credentials |
| Sandbox runtime | `runtimeClassName` equal to `runner.runtimeClassName`, when it is set |

One exception exists. The `agent` container starts as root with only `SETUID` and `SETGID` added, so it can start commands as another user. A declared service or a gate image has no such exception. If a service needs root at boot, it declares a numeric `user: "uid:gid"` that is not 0.

Pod Security `restricted` cannot admit the agent container. `baseline` can.

Under gVisor (GKE Sandbox) the policy keeps every rule above. GKE admits and starts these pods, but seccomp and No New Privileges have no effect inside the sandbox: a runner reads `Seccomp: 0`. The gVisor kernel is the boundary there. The rules stay so that one policy fits installs with and without a RuntimeClass.

### Separate users

| Role | User id |
| - | - |
| CLI (the agent program) | 1000 |
| Tool commands the agent runs | 1001, started through a root broker |
| Reviewer sidecar | 1002 |

Commands the agent runs execute as the tool user. The CLI's configuration, credentials and mailboxes stay closed to that user. The control channel to the supervisor is reached only through a proxy in the CLI's user. No tool environment holds a control token.

> [!WARNING]
>
> The tool user protects the CLI against accidents, not against a hostile tool. A tool can still write files the CLI later reads (`.git/config`, `.claude`, `.mcp.json`) and can exhaust memory, CPU or process slots. Hooks and MCP servers run as the CLI's user. Treat what an agent can do inside its container as unconstrained.

Member-set environment variables that start with `LD_` are refused.

### Gates and services

Gates run in a separate `gate` sidecar, and declared services run as `svc-<name>` sidecars. They share the task's workspace and reach each other on the pod's loopback. Only these sidecars and the reviewer may restart. Admission refuses anything else.

## What the agent can reach

| Target | Reachable | Notes |
| - | - | - |
| The task's repository on GitHub | Yes | Through a token limited to that one repository and to Contents, Pull requests, Issues (write) and Checks, Actions (read) |
| Other repositories | No, unless public | The token names one repository |
| The internet | Yes, except `isolation.blockedCidrs` | Defaults block private ranges, the pod and service networks, and `169.254.0.0/16` (cloud metadata) |
| Your VPC or private hosts | No | Add a host as a `/32` in `isolation.allowedCidrs` if a run needs it |
| DNS | Only the resolvers | `kube-dns`, `node-local-dns` and `isolation.dnsCidrs` |
| The dashboard, collector and driver | Yes | Dashboard on its serving port, collector on 4317 and 4318. The driver on any port it listens on, because its gate endpoint uses an ephemeral port |
| Other runner pods | No | Runner pods accept no ingress |
| The Kubernetes API | No | No ServiceAccount token |
| Other tasks' files | No | Each thread has its own volume |
| Connection tokens (Jira) | By brokered call | A task you allowed can use the connection. Tokens are not printed by any command |

Declared services resolve by name to `127.0.0.1` inside the pod, not through DNS.

## GitHub access and the credential broker

The agent never holds a standing GitHub credential.

1. The runner's `git` and `gh` helpers ask the dashboard for a token, proving themselves with the attempt's own credentials.
2. The dashboard mints a short-lived installation token narrowed to the task's repository and a fixed permission set.
3. The helper caches it in memory only (`emptyDir` with `medium: Memory`) and renews it before expiry.
4. At the end of the attempt the token is revoked.

The token is not in the runner's environment, not in a Secret, and not in git config. Connection credentials such as Jira use the same broker.

Publishing a pull request is a supervised step: the supervisor runs it in its own container with its own copy of the token, over an authenticated channel. The supervisor's three per-attempt tokens each open a different door, and any other route answers 403.

The GitHub App's private key is the worst secret to leak. It mints installation tokens indefinitely, and the only fix is a new key. It reaches the dashboard only. Set `chmod 600` on key files, and keep the runner node group apart from the dashboard.

## Secrets handling

| Topic | Behavior |
| - | - |
| Secrets members save | Write-only through the API. No one, an admin included, can read a value back. See [Environment variables and secrets](https://docs.bellows.cloud/guides/env-and-secrets.md). |
| Where they are stored | Your choice of `secretStore.backend`: AES-256-GCM envelopes in PostgreSQL, AWS Secrets Manager, HashiCorp Vault KV v2, or Google Cloud Secret Manager. External backends keep only a reference and a MAC in the database. |
| How a runner gets them | A per-attempt Secret, mounted as a file with mode `0600` at the container start. Never in argv, a log or the board. |
| Secret-store keys | Reach the dashboard container alone. The driver strips every `SECRETS_*` variable it receives. |
| Model credentials | Referenced by name (`runner.env`) from a Secret. The value does not appear in a pod spec, so `kubectl get pods -o yaml` shows only the reference. |
| Key rotation | A data key leaves the ring only after `rotate-cli --verify-retired <keyId>` reports 0. The operation and fingerprint keys cannot be rotated. |
| Logs | An MCP token is never logged. Keep `OTEL_LOG_USER_PROMPTS`, `OTEL_LOG_ASSISTANT_RESPONSES` and `OTEL_LOG_TOOL_DETAILS` at `0`. |

A secret a task can read is a secret the agent can use, and a prompt could ask it to print it. Give tasks only the secrets they need, and scope them to a repository.

## Authentication

| Mode | Where | Behavior |
| - | - | - |
| GitHub sign-in | The chart always, and `docker-compose.yml` | Every route needs a credential |
| `AUTH_MODE=none` | Local development only | Refuses a non-loopback `HOST` unless `AUTH_ALLOW_PUBLIC_BIND=1` |

The chart has no open mode. An unauthenticated `POST /api/jobs` would be remote code execution, so the Ingress is off by default and you enable it on purpose.

Sign-in uses the OAuth App with the `read:org` scope and reads no repository data. Access follows installation membership, re-checked on every request. A removed membership ends sessions and tokens on the next request. With the webhook configured (see [Create the GitHub App](https://docs.bellows.cloud/self-host/github-app.md)), a GitHub-side removal takes effect as soon as GitHub reports it. Without it, it waits for that person's next sign-in.

The first member of an organization is its admin. Demoting the last admin is refused.

## Tokens

| Credential | Prefix | Reach |
| - | - | - |
| Session cookie | | Everything the member can do. `HttpOnly`, `SameSite=Lax`, `Secure` when `auth.cookieSecure` is on. Only a SHA-256 is stored. |
| Personal access token | `fat_` | Everything its owner can do |
| Organization access token | `oat_` | Read only, on its allowed routes |
| MCP access and refresh tokens | `mat_`, `mrt_` | Only `/api/mcp`, within the granted scopes |
| Worker credential | `JOB_BOARD_TOKEN` | The driver's routes. One deployment-wide secret of at least 32 characters |

Only hashes of tokens are stored. Each credential kind works on its own routes and never falls through to another. A worker token is refused on human routes, and the reverse.

## MCP scope

MCP is off unless you set `mcp.enabled`. It requires an `https` public URL, since the endpoint carries bearer tokens. See [Connect an MCP client](https://docs.bellows.cloud/mcp/connect.md).

* The authorization server accepts public clients only, with PKCE `S256`. A missing or `plain` challenge is refused.
* The user approves each client on a consent screen that only a signed-in browser session can confirm. The screen cannot be framed (`frame-ancestors 'none'`, `X-Frame-Options: DENY`).
* Two scopes exist: `factory:read` and `factory:write`. A refresh can only narrow them.
* Some routes are closed to every scoped token: minting or revoking credentials, writing secrets or connections, authorizing a provider, and managing members, notification destinations and executor profiles. An MCP grant cannot be turned into a longer-lived credential or a wider reach.
* Members see and end their own grants in the connected-apps list. Tokens die when the membership does.
* Client registration is open and rate limited by caller address in each process only. Behind a proxy the address is the proxy's.

## Network

* Terminate TLS at your Ingress. Set `auth.cookieSecure: 'true'`.
* Runner egress is controlled by a NetworkPolicy. It works only if your CNI enforces it. Check from inside a runner pod that traffic to `169.254.169.254` and to private ranges fails.
* IPv6 egress is not covered. Every rule is IPv4.
* A NetworkPolicy does not restrict HTTP routes. A runner can call any public URL.
* The driver's metrics port (`driver.metricsPort`, default `9464`) answers only with `Authorization: Bearer <JOB_BOARD_TOKEN>`. It has no Service. The driver refuses a metrics port with no token off loopback.
* The OTLP collector accepts unauthenticated telemetry unless you set `telemetry.ingestToken`.
* On EKS, also set the node's metadata hop limit to 1 so a pod cannot reach node credentials. See [Kubernetes and Helm](https://docs.bellows.cloud/self-host/kubernetes.md).

## Uploads

Attachment type is decided from the file's bytes, not its declared type. SVG, HTML and macro documents are refused. Downloads are sandboxed attachments, and only three image types may display inline. A draft upload is readable by its uploader only. Text extraction runs in the runner with no board credentials, under a timeout and an output cap. Artifact store credentials live in the dashboard only. With the gcs backend there is no stored credential at all: the dashboard's ServiceAccount is bound by Workload Identity, and runner pods have no binding.

## Known limits

* Kernel isolation needs a RuntimeClass. Without `runner.runtimeClassName`, runners share the node kernel. With it, the admission policy refuses any pod the driver creates outside that RuntimeClass, even from a compromised driver. The chart does not provision the runtime: install gVisor, Kata Containers or Firecracker on the runner nodes, or use a GKE Sandbox node pool.
* The network controls do nothing without an enforcing CNI.
* A taken-over run's gate requests keep the lost driver's endpoint, and a node that goes NotReady is noticed only when the cluster acts on it (about 300 seconds by default).
* A task volume is a thread boundary. A volume's size is a request, and a cap only where the provisioner enforces it.
* `attrs.series` in telemetry is an unsalted one-way digest, so a guess can confirm a value.
* Membership gives access to every tracked repository. GitHub repository permissions are not mirrored.


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