pleach
Use cases / Government

Air-gapped architecture

How `@pleach/core` enforces an outbound URL allowlist at the runtime boundary, and the v2+ gaps for fully offline install.

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

The air-gapped option is a two-field mixin on SessionRuntimeConfig, declared at packages/core/src/runtime/AirGappedRuntimeOption.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

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.

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

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 below), so a misconfigured deployment fails on its first call — during smoke test — rather than leaking silently in production.

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

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

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

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

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

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

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 for the SCRM-facing detail.

Reference architecture

A typical air-gapped deployment looks like:

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.

Verifying enforcement

The recommended verification is a negative test:

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
  }
}

On this page