# Install air-gapped

Install and run with no route off the host, and verify what you carried across. The offline path is structural, not a setting you enable.

Source: https://tokentriage.com/docs/operate/air-gap

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

An air-gapped install of most gateways is a fight against the installer: a chart that pulls
subcharts, a base image that wants a registry, a binary that fetches a price table on boot.
TokenTriage has none of those, which makes the offline path short.

Two properties do the work. The chart declares **zero dependencies**, so `helm install` fetches
nothing a mirror cannot serve. The runtime image is **`FROM scratch`**, so there is no
third-party base layer to copy across the gap or to trust once it is there. The pricing table
and the dashboard are compiled into the binary.

**Prerequisites:** a connected host that can reach both the internet and your internal
registry, a way to carry files across, and a private registry on the far side. On Compose you
need neither, because the offline profile is self-contained.

## Prove it on Compose first

Before planning a transfer, run the shape end to end on one host. The air-gap Compose profile
puts the proxy on a network Docker attaches **no gateway** to, so there is no route off the
host at all, so egress is impossible by construction rather than denied by policy, and points
it at an in-network mock upstream:

```console
$ docker compose -f docker-compose.yml -f deploy/compose/compose.airgap.yaml \
    --profile test up --exit-code-from tests --abort-on-container-exit
[airgap-tests] waiting for /health ...
[airgap-tests] health OK after 0s; driving one completion ...
[airgap-tests] reading the costed row from the admin API ...
[airgap-tests] AIRGAP COSTED-ROW GATE: PASS
```

That gate drives one completion through the proxy and asserts a **costed row** comes back —
`cost.usd` present, priced from the compiled-in table, with no packet able to leave the host and
no API key anywhere in the stack. It is the shortest proof that pricing is not a network call.

## What crosses the gap

Three things, and no more:

1. **The chart.** `helm pull` the OCI artifact or the release archive, untarred on the
   connected side so the whole tree comes across.
2. **The images.** Every line of the release's `images.txt`, by digest.
3. **The verification material.** The signing public key.

Copy images with `cosign copy` rather than `crane cp`, `skopeo copy` or `docker save`. Those
three transport the image and drop the signature and attestation sidecar tags, which live as
separate tags in the source repository rather than inside the manifest. An image copied that way
installs fine and cannot be verified afterwards, which is the worst of both outcomes.

<Callout type="warning">
The `images.txt` committed in the repository is a **shape example**. Its product digest resolves
in no registry, so a mirror script fed with it fails on the first line. Download the real list
from the release you are mirroring.
</Callout>

## Install against your registry

`global.imageRegistry` rewrites every image reference the chart emits, the product image and
the helm-test client alike, to your mirror prefix. Install from the tree you carried across:

```console
$ helm install tt ./chart/tokentriage \
    --namespace tokentriage --create-namespace \
    --set global.imageRegistry=<PRIV> \
    --set image.digest=sha256:<the digest from images.txt> \
    -f ./chart/tokentriage/examples/airgap-values.yaml \
    -f my-values.yaml
```

Three details in that command decide whether it works.

**The `-f` order matters.** Later files win, so the preset goes first and your values last.

**Pin the digest.** A digest is the only reference a registry cannot serve different content
for, and it is what carries a signature check through to what actually runs. The values schema
enforces the `sha256:` shape, so a typo is refused at render rather than becoming an image-pull
failure behind the gap.

**The preset lives inside the chart archive.** `examples/` is kept in the packaged
archive, so reference it at `./chart/tokentriage/examples/airgap-values.yaml`. A repository
path is no use to someone who only has the archive.

The preset itself sets no addresses: it selects the `airgap` NetworkPolicy preset and states
how to pull. It pins no digest and no registry, because the chart cannot know either.

<Callout type="warning">
`image.pullPolicy` has two opposite correct values. The preset ships `IfNotPresent`, which is
right for the **mirror-registry** case above, where the kubelet must be able to pull from your
registry. Use `Never` only for the other case, where images are side-loaded onto every node and
no registry is reachable at all. Setting `Never` while pointing at a mirror produces an
image-pull failure that reads like a network problem and is a configuration one.
</Callout>

Then prove it, without a credential:

```console
$ helm test tt -n tokentriage
TEST SUITE:     tt-tokentriage-test-connection
Phase:          Succeeded
```

## What the air-gap posture actually guarantees

The guarantee is about the *install*, and it is structural:

- **Zero chart dependencies.** Every optional backend, whether Redis, Postgres or an L3 cache, is an
  external reference through a typed values block rather than a bundled subchart, so nothing
  reaches out mid-install.
- **One image, `FROM scratch`.** No base layer to mirror, pin or trust. The timezone database is
  knowingly absent; budgets and windows are UTC by contract, so nothing in the runtime reads
  local time. Do not add a base image to "fix" that.
- **Credentials by reference only.** The chart holds no key; you create the `Secret`.

At runtime, every outbound tier is gated on the binary's own consent string. Shared state, the
L3 cache tier, an external embeddings API, OTLP export, a guardrail judge, the guardrail
sidecar and the evals-plane LLM judge that grades quality are each refused unless their config
block carries `external: "allow"`, a string you type, that no deployment layer types for you. A
default install dials nothing. The judge gate is unconditional in one way worth knowing before
you plan around it: it does not read the endpoint, so pointing the grader at a listener on your
own box still needs the opt-in typed.
[Air-gap judging, and the known limits](/docs/judges/air-gap-and-limits) is that tier in full.

<Callout type="key">
The NetworkPolicy `airgap` preset is not what makes the install offline-safe, and it is not a
containment boundary. Its DNS rule opens port 53 outbound to any destination, its ingress rule
admits both ports from any source, and a NetworkPolicy is only enforced where the cluster's CNI
implements one. [Production hardening](/docs/operate/hardening) shows the rendered rules.
</Callout>

## Verify what you downloaded

Every release is signed twice on the same image, for two different audiences.

**The key-pair signature** is the air-gap path. The pipeline signs with a static key and no
transparency-log upload, so verification behind the gap needs nothing but the public key:

```console
$ cosign verify --key cosign.pub --offline --insecure-ignore-tlog=true \
    <PRIV>/tokentriage/tokentriage@sha256:<digest>
```

**The keyless signature** is for connected consumers. It is an online operation on both sides,
which is precisely why it is not the offline path, and it exists so anyone can verify without a
pre-shared key.

Each release also carries an SPDX software bill of materials and build provenance as
attestations on the image, and `images.txt` as the machine-readable mirror list.

The third-party images in that list, the busybox helper and the development backends, are not
signed by this project. Do not run `cosign verify` against them; it would correctly fail for
want of a signature. Their integrity *is* the digest pin: a registry cannot serve content whose
digest differs.

One limitation of the whole approach is worth planning around. **A public key carried across a
gap has no revocation feed.** Nothing behind the gap can learn that a key was rotated or a root
revoked, so the key you carried is a snapshot with an expiry rather than a standing authority.
Schedule a re-transfer on a fixed cadence, and record that cadence in your change management,
because the artifact cannot enforce it for you.

<Details summary="What this project has not executed, and what that means for the two sections above">

The transfer and verification procedures above are documented from the release pipeline and the
supply-chain design. They have **not** been executed against a published release, because no
release has been published yet: there is no tagged version, no OCI chart in a registry, and no
signed image to pull. The pipeline exists and has never run.

What *has* been executed and is quoted verbatim on this page: the Compose air-gap gate, the
rendered NetworkPolicy, and a chart install against a locally mirrored image with a pinned
reference. The offline verification loop is reproducible today with a throwaway key pair and a
local registry: sign an image with `--tlog-upload=false` and verify it with `--offline`. That
is how the design was proven, and which is not the same claim as verifying a real release.

Treat the two sections above as the procedure to follow at the first release rather than as a
report of one.

</Details>

Guardrail consent is necessary but does not enable unsupported capabilities. Stock sidecar
activation remains rejected pending runtime/model qualification; positive judge hard caps require
a proven reservation bound. See the [current activation table](/docs/guardrails/air-gap-and-limits).

## What this does not cover

The guardrails tier documents its own offline story, including which detectors are embedded and
which leave the process, on [air-gap & known limits](/docs/guardrails/air-gap-and-limits). The
consent gate's effect on caching and governance belongs to those sections. Hardening the install
you have made offline is [production hardening](/docs/operate/hardening).

<NextSteps items={[
  {title: "Production hardening", href: "/docs/operate/hardening", description: "The rendered NetworkPolicy, the admin plane, and the rest of the exposure decisions."},
  {title: "Guardrails air-gap & known limits", href: "/docs/guardrails/air-gap-and-limits", description: "The detector tier's own containment story, including what leaves the process."},
  {title: "Deployment topologies", href: "/docs/operate/deployment", description: "The Compose and Kubernetes shapes the offline paths above are built on."},
  {title: "Security & secrets", href: "/docs/operate/security", description: "Why credentials are references, and what a leaked config file would actually contain."}
]} />
