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:
- Outbound LLM calls — gated by
checkAirGappedHost()at the 3 enforcement sites inSessionRuntime.ts. The allowlist is the only list of permitted hosts. - Install-time dependencies — operator-owned today via private registry or vendored snapshot. v1.x runbook scope.
- Observability destinations —
@pleach/observeships 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
}
}Related
License compatibility for procurement
FSL-1.1-Apache-2.0 across all 13 publishable SKUs, with a written future-license clause to Apache-2.0 at the 2-year mark.
Supply-chain risk & SBOM
Vendor-neutrality at the SKU boundary, current build artifact + signature posture, and the SBOM / Sigstore / SLSA roadmap.