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

# How it works

> The sandbox, the egress proxy, and the identity-attributed audit trail.

Airlock has two planes. The **capture plane** is every surface that turns
access into identity-attached events: the agent runtime described on this page,
plus the push-ingestion API and SaaS audit connectors covered in
[capture surfaces](/capture). The **review plane** is a self-hosted backend
(Postgres plus `airlockd`, deployed from the [bundle](/deploy)) that runs
detection and turns it all into a per-person record: see
[the review plane](#the-review-plane) below.

Per session, the agent runtime runs two containers: a **sandbox** where the
agent works, and an **egress proxy** that is the sandbox's only way out.

```
airlock CLI  (host)
     │ docker run
     ▼
┌──────────────────────────────────────┐
│ sandbox container   (airlock/base)   │
│ /<reponame> = your repo (rw)         │
│ egress forced through the proxy      │
└──────────────────────────────────────┘
     │ airlock-net  (internal: no route out)
     ▼
┌──────────────────────────────────────┐
│ proxy container     (airlock/proxy)  │
│ CONNECT allowlist + JSONL audit log  │
└──────────────────────────────────────┘
     │ bridge network
     ▼
api.anthropic.com, openrouter.ai, ...
```

## Scoped isolation

The sandbox is attached to a single Docker network, `airlock-net`, created with
`--internal`. An internal network has no route to the internet and none to the
host, so the only peer the sandbox can reach is the proxy.

The proxy container is the only one attached to both `airlock-net` and the
default bridge network, which is what gives it egress.

## The sandbox container

Started fresh for every run with `--rm`, so nothing persists in it:

* Your repo is mounted read-write at `/<reponame>`, which is also the working
  directory and the container hostname. The name is lowercased, and any
  character outside `a-z 0-9 _ . -` becomes `-`.
* `HTTP_PROXY`, `HTTPS_PROXY` (and lowercase variants) point at the proxy on
  port `8888`. `NO_PROXY` covers `localhost,127.0.0.1`.
* `AIRLOCK_REPO` holds the in-container repo path. `AIRLOCK_USER_ID` and
  `AIRLOCK_USER_EMAIL` (from [`airlock login`](/cli#airlock-login)) identify
  who is running this session.
* Allowlisted host env vars and any MCP credentials are injected.
* A TTY is attached only when your terminal has one, so piping into `airlock`
  works.

### The agent's home is not yours

So the agent isn't re-authenticating and re-picking a theme on every launch,
two paths persist across runs:

| Host path                      | In sandbox                 |
| ------------------------------ | -------------------------- |
| `~/.airlock/agent/claude/`     | `/home/agent/.claude`      |
| `~/.airlock/agent/claude.json` | `/home/agent/.claude.json` |

This is an Airlock-owned home, deliberately **not** your `~/.claude`. The
sandbox keeps its own credentials, isolated from the ones on your host.

## The egress proxy

**One proxy container per repo**, named `airlock-proxy-<repo>`, restarted on
each `airlock` run. This is also what makes allowlist changes take effect
immediately: edit `.airlock.toml`, start a sandbox, and the new policy is
live.

Identity is threaded in when the container starts: `AIRLOCK_USER_ID` and
`AIRLOCK_REPO` ride in as environment, and every audit line carries them. On
a shared machine, restart the sandbox after switching accounts. Attribution
follows whoever was signed in when the proxy started.

For each request, the proxy takes the hostname, checks it against policy
(exact match or a subdomain of a listed domain), and writes an audit entry
before acting on the verdict.

* **Allowed `CONNECT`** — dials upstream and splices bytes. TLS stays end-to-end
  between the agent and the provider.
* **Allowed plain HTTP** — forwarded upstream.
* **Refused** — `403` naming the host and the policy that refused it.

Policy is one of two modes, per repo. **Allowlist** (the default) refuses
anything unlisted and refuses to start on an empty list rather than silently
allowing nothing. **Blocklist** permits everything except the hosts you name. See
[choosing a mode](/configuration#choosing-a-mode).

The mode decides what is refused; it does not decide what is recorded. Both
modes log every request identically, so the audit trail and everything built
on it are unchanged whichever you run.

## The audit log

Every attempt, allowed or denied, is appended as one JSON object per line to:

```
~/.airlock/logs/<repo>/audit-<YYYY-MM-DD>.jsonl
```

```json theme={null}
{"ts":"2026-07-24T18:03:11.482Z","user_id":"41f0c2…","repo":"some-repo","host":"api.anthropic.com","method":"CONNECT","allowed":true,"bytes_sent":18204,"bytes_recv":91544,"duration_ms":2311}
{"ts":"2026-07-24T18:03:12.905Z","user_id":"41f0c2…","repo":"some-repo","host":"pastebin.com","method":"CONNECT","allowed":false,"duration_ms":0}
```

`user_id` comes from the identity threaded into the proxy's environment at
start; for an unattributed run (no `airlock login`) it is simply omitted.
Denials are written **immediately**; allowed tunnels log on close, with
byte counts in both directions and duration.

The same events stream to the proxy container's stdout as `ALLOW` / `DENY`
lines, so `docker logs -f airlock-proxy-<repo>` gives you a live view.

To see what a repo's agents have been reaching:

```sh theme={null}
jq -r 'select(.allowed == false) | .host' ~/.airlock/logs/some-repo/*.jsonl | sort -u
```

## The review plane

The egress log answers *where did it connect*. It cannot answer *what did it
read* — by design, since TLS stays end-to-end and the proxy never sees
plaintext. That answer comes from the agent's own transcripts, which survive the
container because the [agent's home is persisted](#the-agents-home-is-not-yours).

```
~/.airlock/agent/claude/projects/*.jsonl     ~/.airlock/logs/<repo>/*.jsonl
     └────────────────┬─────────────────────────────────┘
                      │ airlock publish     (host, API token)      external
                      ▼                                            agents
┌───────────────────────────────────────────┐                        │
│ airlockd + Postgres      (self-hosted)    │ ◀── POST /api/v1/events┘
│ insert-only, deduped · user attributed    │     (decision events)
│ decision flags raised at ingest           │
└───────────────────────────────────────────┘
                      │ serves the console (login, RBAC)
                      ▼
┌───────────────────────────────────────────┐
│ timeline: access + chat + decision events │
│ session drill-down · flags review queue   │
└───────────────────────────────────────────┘
```

Publishing runs **on the host, outside the sandbox**. That placement is the
security property: the backend is not on the egress allowlist, so the agent
being audited cannot reach its own audit trail, and the API token that
authenticates publishing never enters a container.

The store is the [deploy bundle](/deploy)'s Postgres — in your VPC or on your
laptop, operated by you. `airlockd` attributes every ingested event to its
token's user (a token cannot write anyone else's history), records a
server-side `received_at` beside the client timestamp so backdating is
visible, and raises [decision flags](/exposure) at ingest. The
[console](/dashboard) is served by the same daemon, login-gated with
role-scoped reads. And where an agent workflow crosses an irreversible
boundary, the same machinery backs
[action gates](/governance#action-gates): verification before the action.

<Warning>
  Published content — including returned tool results, so internal document
  text — lands in that Postgres and stays inside your deployment. The backend
  makes **no** outbound connections.
</Warning>
