# Air-gapped architecture (/docs/use-cases/government/air-gapped-architecture)



This page describes the air-gapped enforcement that `@pleach/core`
ships in v1, the three call-sites where it lands, and the v2+ gaps
(dependency vendoring, SBOM, signed artifacts) that operators should
plan around.

The general air-gapped story for any Pleach deployment lives at
[Air-gapped deployment](/docs/air-gapped-deployment). This page is the
government-procurement-facing detail: what enforcement lives inside
`@pleach/core` (so hosts cannot accidentally bypass it), what the
allowlist semantics are, and how the `governmentAgent` recipe surfaces
misconfiguration eagerly.

## v1 enforcement substrate [#v1-enforcement-substrate]

The air-gapped option is a two-field mixin on `SessionRuntimeConfig`,
declared at `packages/core/src/runtime/AirGappedRuntimeOption.ts`:

```ts
export interface AirGappedRuntimeOption {
  airGapped: boolean
  airGappedAllowedHosts?: string[]
}
```

Default is `airGapped: false` (legacy / non-air-gapped). Operators
opt in by setting both fields explicitly.

### Allowlist semantics [#allowlist-semantics]

`checkAirGappedHost(url, opts)` runs the check. A URL is permitted
when its `URL(url).host`:

* matches an allowlist entry **exactly**, OR
* ends with `.<entry>` (subdomain match).

Host comparison is case-insensitive (DNS names are case-insensitive).
Allowlist entries with a leading `.` are normalized — operators can
write either `corp.local` or `.corp.local` and the match is the same.

```ts
import { checkAirGappedHost } from "@pleach/core/runtime/AirGappedRuntimeOption"

// No-op in legacy mode.
checkAirGappedHost("https://api.openai.com/v1/...", { airGapped: false })

// Throws AirGappedHostRejectedError — empty allowlist rejects everything.
checkAirGappedHost("https://api.openai.com/v1/...", { airGapped: true })

// Permitted — host matches an allowlist entry.
checkAirGappedHost("https://llm.corp.local/v1/...", {
  airGapped: true,
  airGappedAllowedHosts: ["llm.corp.local"],
})

// Permitted — subdomain match on `.corp.local`.
checkAirGappedHost("https://gateway.corp.local/v1/...", {
  airGapped: true,
  airGappedAllowedHosts: ["corp.local"],
})
```

### Fail-closed by design [#fail-closed-by-design]

The default `airGappedAllowedHosts: []` rejects **every** URL when
`airGapped: true`. This is intentional. An operator that turns on
air-gapped mode without enumerating the on-prem proxy / private gateway
/ mirror they intend to reach has misconfigured the deployment, and the
runtime surfaces the misconfiguration at first call rather than
allowing a silent leak through a forgotten entry.

The `governmentAgent` recipe passes `airGappedAllowedHosts` straight
through to `@pleach/core` — it adds no validation layer of its own. The
core-level fail-closed check is what enforces the allowlist: with an
empty list, the first outbound URL resolution throws
`AirGappedHostRejectedError` (see [Error shape](#error-shape) below), so
a misconfigured deployment fails on its first call — during smoke test —
rather than leaking silently in production.

### Malformed URLs [#malformed-urls]

Anything `new URL(...)` rejects throws `AirGappedHostRejectedError`
unconditionally. An unparseable URL in air-gapped mode is treated as a
leak candidate — there is no host to check, so the runtime refuses to
make the call.

### Error shape [#error-shape]

```ts
export class AirGappedHostRejectedError extends Error {
  public readonly url: string
  public readonly allowedHosts: readonly string[]
  // .cause carries the rejected URL string
}
```

Hosts can catch and inspect the structured fields without re-parsing
the error message. The `.cause` field carries the rejected URL through
the standard Node `Error.cause` channel.

## Enforcement sites in `@pleach/core` [#enforcement-sites-in-pleachcore]

The check is enforced at **3 URL-resolution sites** inside
`SessionRuntime.ts`. The check lives next to the `fetch()` it guards,
so a host cannot legitimately bypass it by forgetting to wire something
at the edge.

In addition to the runtime check, the env-var override of the
OpenRouter base URL is honored at the same 3 sites:

* `PLEACH_OPENROUTER_BASE_URL` (process env)
* `SessionRuntimeConfig.openrouterBaseURL` (per-runtime config)

Operators in air-gapped deployments typically set
`PLEACH_OPENROUTER_BASE_URL` to point at the on-prem proxy that
satisfies the OpenRouter protocol (an in-perimeter inference endpoint
behind an OpenAI-compatible gateway, for example), and add that proxy's
host to `airGappedAllowedHosts`.

## v2+ gaps [#v2-gaps]

The v1 enforcement substrate addresses the runtime call boundary. It
does **not** address install-time supply chain. Three honest gaps:

### 1. Dependency vendoring [#1-dependency-vendoring]

`npm install` today requires reaching a registry. Operators can point
npm at a **private** registry inside the perimeter, or vendor a
snapshot of `node_modules` and install from that snapshot. Neither path
is documented as a runbook today.

v1.x scope: a documented, reproducible recipe for vendoring
`node_modules` from a publish-time tarball into an offline mirror, and
a recipe for installing from a private npm registry behind a perimeter
proxy.

### 2. SBOM generation [#2-sbom-generation]

No SBOM is emitted with the npm publish today. The `governmentAgent`
recipe accepts `sbomFormat: "cyclonedx-1.5" | "spdx-2.3"` and stamps it
on the runtime as procurement-visible metadata, but the artifact itself
is not produced.

v1.x scope: CycloneDX 1.5 / SPDX 2.3 SBOM attached to each npm publish
under a known artifact name, with the dependency graph derived from
`package.json` + the resolved lockfile.

### 3. Signed artifacts [#3-signed-artifacts]

Tarballs published to npm carry the npm-default signature today. No
Sigstore signing is wired into the publish pipeline.

v1.x scope: Sigstore keyless signing of every published tarball under
the project's trusted-publisher identity, plus SLSA Build Level 3
provenance attestation. See
[Supply-chain risk & SBOM](/docs/use-cases/government/scrm-and-sbom) for
the SCRM-facing detail.

## Reference architecture [#reference-architecture]

A typical air-gapped deployment looks like:

<Mermaid
  chart="flowchart TB
    subgraph Perimeter
        direction TB
        H[&#x22;Pleach host (Node.js)<br/>airGapped: true<br/>allowedHosts: [proxy]&#x22;]
        L[&#x22;On-prem LLM proxy / gateway<br/>vLLM / TGI / Ollama / Bedrock GCv&#x22;]
        R[&#x22;Private npm registry /<br/>vendored snap&#x22;]
        SIEM[&#x22;Private SIEM<br/>(observe SDK destination)&#x22;]
        H --- L
    end"
/>

Three perimeter boundaries the runtime relies on:

1. **Outbound LLM calls** — gated by `checkAirGappedHost()` at the 3
   enforcement sites in `SessionRuntime.ts`. The allowlist is the only
   list of permitted hosts.
2. **Install-time dependencies** — operator-owned today via private
   registry or vendored snapshot. v1.x runbook scope.
3. **Observability destinations** — `@pleach/observe` ships data
   where the host configures it to ship. For air-gapped deployments,
   point it at a private SIEM or log aggregator inside the perimeter.
   See [Observability](/docs/observability).

## Verifying enforcement [#verifying-enforcement]

The recommended verification is a negative test:

```ts
import { governmentAgent } from "@pleach/recipes"
import { AirGappedHostRejectedError } from "@pleach/core/runtime/AirGappedRuntimeOption"

const bot = governmentAgent({
  agencyId: "test",
  airGapped: true,
  airGappedAllowedHosts: ["llm.internal.example.gov"],
  onPremProvider: {
    kind: "ollama",
    endpoint: "https://llm.internal.example.gov",
  },
})

// In a smoke test, point the provider at a public URL and verify
// the runtime rejects it:
try {
  await bot.ask("hello") // routes through a public endpoint
  throw new Error("air-gapped enforcement bypassed")
} catch (err) {
  if (err instanceof AirGappedHostRejectedError) {
    // Expected — the allowlist is doing its job.
  } else {
    throw err
  }
}
```

## Related [#related]

* [Air-gapped deployment (general)](/docs/air-gapped-deployment)
* [Government & public sector landing](/docs/use-cases/government)
* [Supply-chain risk & SBOM](/docs/use-cases/government/scrm-and-sbom)
* [License compatibility](/docs/use-cases/government/license-compatibility)
* [Session runtime](/docs/session-runtime)
* [Gateway](/docs/gateway)
