Handler/migration counts, calculateBackoff -> calculateDelay, machine-id binding (cross-platform machineid + fallbacks, not hostname), last-reviewed dates, and made [[cross-references]] relative so they resolve.
20 KiB
Supply Chain Gate
Package-manager authorization with signed capability tokens, kernel-enforced where the platform allows.
This is the design of record for the gate — the capability model, the wire contract all
three components agree on, the load-bearing constraints, and component responsibilities.
Build status and per-step implementation tracking live in docs/tasks/GATE-000-supply-chain-gate-plan.md.
Overview
RedFlag's supply chain gate inverts the traditional defense model: instead of allow all installs and detect bad ones, it denies all state changes and requires explicit human authorization.
Core principle: Nothing installs, updates, or changes without a signed authorization token from the RedFlag server.
The decision: capability tokens, not a decision daemon
The gate authorizes package installs with signed capability tokens, not with a runtime
decision daemon. The server is the authority: it evaluates policy (OSV, age, pinning, human
approval) and mints an Ed25519-signed token describing exactly one operation over a
fully-resolved dependency closure. A small, privileged, network-less executor
(helper/, Rust) verifies the signature against a trusted authority key, verifies every
artifact hash, and performs that one operation. Nothing else can change package state.
This replaced the earlier rs-helper socket-decision daemon. rs-helper's reusable parts
(the eBPF InterceptEvent struct, the package-manager allowlist, the hash cache) moved into
the unprivileged agent-side consumer. Its role as a runtime allow/deny RPC is retired.
Why this model (two tests it has to pass)
Cross-platform. Linux eBPF and macOS ESF can pause an exec and ask a daemon "may this proceed?" Windows WDAC cannot — it is signature-based, with no runtime callback. A decision-daemon model therefore has no Windows mapping. A capability token maps onto all three identically: in every case the enforcement layer only needs to answer "is this execution authorized," and a verified token + a privileged executor that the OS trusts is platform-agnostic. We build for the platform with the tightest constraint.
Protects people. Protection comes down to where the trust root lives. The signing key lives at the server (the human-approval authority), off the host. An attacker who fully owns the agent process — a prompt-injected coding agent, the literal threat — can request a token but cannot forge the server's signature, so the executor never runs. The trust root is outside the blast radius. A socket-RPC daemon degrades to "can the attacker reach the socket or influence what's pinned," which a compromised agent tier often can.
The two tests converge on the same answer, which is the signal it's right.
Architecture
Enforcement Layers (Platform-Specific)
| Platform | Enforcement Mechanism | What It Blocks |
|---|---|---|
| Linux | eBPF (syscalls/sys_enter_execve) or AppArmor | apt, dnf, yum, pip, npm, bun, docker (CLI) |
| Windows | WDAC (Windows Defender Application Control) | winget, npm.cmd, pip.exe, choco, scoop |
| macOS | Endpoint Security Framework (ESF) | brew, pip, npm, bun, cargo |
Crucial distinction: Enforcement sits below userspace wrappers. A prompt-injected Claude Code session cannot bypass it with absolute paths or environment manipulation.
Trust Chain
┌─────────────────────────────────────────────────────────────┐
│ Human Operator │
│ - Reviews UI prompt with OSV findings, age checks, etc. │
│ - Clicks "Approve" or "Reject" │
└─────────────────────┬───────────────────────────────────────┘
│
┌─────────────────────▼───────────────────────────────────────┐
│ RedFlag Server (authority, unprivileged) │
│ - Resolves the closure, records/fetches per-artifact hash │
│ - Runs OSV.dev check + package-age gates │
│ - Mints an Ed25519-signed capability token (signer off the │
│ request path) │
└─────────────────────┬───────────────────────────────────────┘
│
┌─────────────────────▼───────────────────────────────────────┐
│ RedFlag Agent (consumer, unprivileged) │
│ - Polls for the token, confirms agent_id is this host │
│ - Holds no signing key; cannot run installs directly │
│ - Hands the token to the executor │
└─────────────────────┬───────────────────────────────────────┘
│
┌─────────────────────▼───────────────────────────────────────┐
│ Helper / Executor (privileged, network-less, Rust) │
│ - Invoked via `sudo systemd-run --pipe` as its own │
│ transient service (escapes the agent's ProtectSystem │
│ sandbox); not setuid │
│ - Verifies token signature (ed25519) + every artifact hash │
│ - Replay-guards on token_id, then execs exactly one op │
│ via argv: no shell, env stripped, forward-only │
│ - Entire codebase auditable in one sitting │
└─────────────────────────────────────────────────────────────┘
The token (the contract all three sides agree on)
This is the canonical wire contract. The Go (server/, agent/internal/capability/token.go)
and Rust (helper/src/main.rs) implementations reconstruct identical bytes from it.
{
"version": 1,
"token_id": "<uuid>", // unique; replay guard + receipt/audit
"agent_id": "<uuid>", // bound to exactly one host
"key_id": "<hex, 32 chars>", // authority key fingerprint (rotation)
"package_type": "apt|dnf|npm|bun|pip|docker|winget|agent-self",
"operation": "install|upgrade", // forward-only; no downgrade
"closure": [ // FULL resolved set, sorted by (name,version)
{
"name": "<pkg>",
"version": "<exact>",
"sha256": "<hex>", // expected artifact hash
"source": "mirror|registry",
"artifact_path": "<local path or url, optional>"
}
],
"issued_at": <unix>,
"not_before": <unix>,
"expires_at": <unix>, // short TTL
"signature": "<hex ed25519>" // over the canonical message below
}
Canonical signed message (deterministic, language-agnostic — mirrors the existing v3 command format so Go and Rust reconstruct identical bytes):
closure_hash = hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) ))
signed_message = "{agent_id}:{token_id}:{operation}:{package_type}:{closure_hash}:{expires_at}"
signature = ed25519_sign(authority_priv, signed_message)
The closure is sorted before hashing so ordering can't change the digest. Tampering with any
artifact, version, or hash changes closure_hash and breaks verification.
Reuse, don't reinvent
The token extends the existing Ed25519 infrastructure rather than introducing new crypto:
server/internal/services/signing.go—SigningService(Ed25519,GetPublicKeyHex,GetCurrentKeyID= SHA-256(pubkey)[:16] hex). The token is a new payload it signs.server/internal/database/queries/signing_keys.go— key storage + rotation/version.agent/internal/crypto/verification.go— verifies against active keys; v3 message format{agent_id}:{id}:{type}:{sha256(params)}:{ts}. The token mirrors this.agent/internal/client/client.go::GetActivePublicKeys— already "verify keys not servers."
Component responsibilities
- Server (authority). At approval/unpin: run OSV + age + attestation checks, resolve the full closure, pull+hash artifacts (mirror tier) or record expected hashes (no-mirror tier), mint and sign the token. Refuse to sign until every check clears. Signer runs off the web process.
- Agent consumer (unprivileged). Receives the token, confirms
agent_idis this host, hands it to the executor. Holds no signing key. On Linux/macOS, the eBPF/ESF event path feeds the request for a token; the consumer never decides allow/deny itself. Salvagesrs-helper'sInterceptEvent, allowlist, and cache. - Executor (
helper/, privileged, Rust). Verify validity window → resolve trusted key bykey_idfrom a local pinned keyring → reconstructsigned_message→ Ed25519 verify → verify each artifact's sha256 → replay-guard ontoken_id→ exec exactly one operation via argv (no shell, env stripped) → structured result + exit code. Fail-closed on every error path. Auditable in one sitting. - Kernel layer (where applicable). Linux eBPF / Windows WDAC / macOS ESF deny package-manager execution except via the trusted executor. Defense-in-depth.
Load-bearing constraints (do not regress these)
- Sign the resolved closure, not the top-level package. Aggregate updates and the scheduler chaining dependencies mean the token must cover every transitive artifact and its hash. Authorizing only the top-level reopens the gap where the modern attacks live. Resolve-and-hash-the-closure is also the mirror's real security job; air-gapping from the registry is the bonus.
- The signer lives off the web process. Server-as-authority plus server-as-mirror concentrates blast radius. The signing key must not be reachable from the request path (separate signer service / key material not loaded in the API process), so a web compromise cannot both mint tokens and serve artifacts.
- Verified-cache fallback, fail-closed only on change. When installs route through the mirror, an already-approved-and-hashed artifact must still install from local cache if the server blinks. Fail closed on new change, not on a brief outage of an already-authorized operation.
- Verify keys, not servers. The agent and helper trust a public key (set) identified by
key_idfingerprint — never a server URL. Today the key lives on your server; nothing changes operationally. But the contract ("trust this key") lets the authority later be rotated, replicated, or held by a federation/guild node without touching the agent↔helper interface. This is the long-term cross-platform answer hidden in a one-line design choice. - Kernel stops are defense-in-depth, not a prerequisite. The capability model protects on a host where eBPF/WDAC/ESF cannot be deployed (locked-down managed box, constrained container). Kernel enforcement raises the cost of bypass; it does not gate whether the model means anything. Partial deployment still moves a host out of the soft-target category.
- No doctrinal knobs. Signing required and forward-only (no downgrade) are ETHOS doctrine, not configurable. The token has no "skip verification" path.
Gate Features
1. Version Pinning (Security Primitive)
Normal model: npm install express → resolves to latest → fetches from registry → installs
RedFlag model: the exact version is resolved and its SHA256 recorded at pin time; the artifact that installs must match that hash.
The pin's hash source depends on who can reach the artifact:
- npm / PyPI — one canonical public registry exists, so the server fetches the
artifact and computes the hash directly at approval (
computeAndStorePackageHash). - dnf / apt — artifacts come from each agent's own GPG-signed repos, which the server
cannot reach. The agent resolves the canonical hash from its signed repo metadata at the
dry-run step and reports it (
installer.ResolveArtifactSHA256: dnf viadnf download+SHA256, apt via theSHA256:field of the signed index). The server pins what the agent reports. Trust is anchored in the repo signature; the pin is set before the install-time compromise the gate defends against.
Historical note: an earlier draft of this doc specified a
ResolvePin/fetchAndHashfunction and asecurity_packagestable. Neither was built. The shipped registry is the two stores below. This section documents what exists.
2. Hash Registry (Layer 1)
All artifacts are verified by SHA256 before installation.
Storage (as built):
current_package_state.expected_sha256(migration 040) — the pinned top-level hash per update, consumed by the agent's install-timeVerifyHash.capability_tokens(migration 042) — the minted, signed token carries the full resolved closure (per-artifact name/version/sha256/source) as JSONB. The token row is the closure record; there is no separatesecurity_packagestable.
OSV findings, package age, and published-at are recorded on the update's own metadata JSONB
at approval, not in a dedicated table.
3. Local Mirror (Optional)
Purpose: Decouple fleet from upstream availability after approval.
Flow:
- Operator approves update → server fetches artifact → stores in local mirror
- Server issues approval token with artifact path in mirror
- Agent fetches from mirror (not upstream) → verifies SHA256 → installs
When to enable:
- Large fleets (>100 agents) where redundant fetches are noisy
- Upstream availability is a concern
- Maximum isolation desired
Configuration: security.package_mirror.enabled (boolean)
4. Time Gates: Package Age + Version Soak (live policies, v0.2.6.2)
Two distinct time-based gates, both under the supply_chain.* settings category, both
resolving env → config → DB → default. These are policies (configurable, with enforcement
modes) — unlike signing and hash verification, which are doctrine.
Approval-time age gate (package_age.go) — the Shai-Hulud defense. Packages younger
than the threshold draw a warning or a block at approval:
| Setting | Default | Meaning |
|---|---|---|
min_package_age_hours |
24 | Minimum publish age before approval is clean |
gate_enforcement |
warn |
warn or block |
block_unknown_age |
false |
Opt-in: under block, registry-backed ecosystems (npm, PyPI) fail closed when the publish date can't be determined — a dark recency source is itself a Shai-Hulud-class signal. Default off, by sovereignty: the operator chooses to fail closed on unknowns. |
Install-time version-soak gate (soak_gate.go — this is GATE-005, not the age gate):
| Setting | Default | Meaning |
|---|---|---|
soak_window_days |
14 | A version must soak this long before the install path will take it |
soak_enforcement |
block |
warn or block |
5. OSV.dev Integration
Vulnerability scanning runs at detection time, not approval time (moved in v0.2.6.2).
The scan-report path enqueues OSV checks (batch endpoint, 100 per POST, 4 concurrent)
across the full dependency closure; verdicts persist to package metadata
(supply_chain_vulns, supply_chain_checked_at) and are visible in the dashboard before
anyone approves anything.
Approval reads the persisted verdict — it does not re-scan. Any known vulnerability anywhere in the closure is a full stop (see Enforcement Posture below); there is no severity threshold below which approval proceeds quietly.
Standalone path resilience. In standalone mode there is no server, so the agent
queries OSV.dev directly before requesting a mint (agent/internal/supplychain/osv.go).
That client retries transient failures (transport error, 5xx, 429) with exponential
backoff and trips a process-wide circuit breaker after a run of failures, fast-failing
to unreachable instead of hammering the endpoint (ETHOS #3, SEC-029). The verdict
semantics are unchanged and remain fail-closed: an exhausted retry or an open breaker
surfaces as unreachable — never a silent clear — and unreachable still gates the
mint behind an explicit operator override. The resilience only avoids turning a transient
OSV blip into a forced operator action.
6. SLSA/Sigstore Attestation (Visibility Signal)
Not a hard block — surfaced as a visibility indicator.
UI: When unpinning a package without attestation:
This package lacks SLSA provenance or Sigstore signature.
Consider waiting for an attested release before proceeding.
Enforcement Posture (v0.2.3.1, extended v0.2.6.2)
Approval is an enforcement point, not advisory. A known vulnerability — top-level or anywhere
in the resolved dependency closure — is a hard stop: ApproveUpdate returns 409 and mints
nothing. For capability-gated ecosystems, a closure that OSV could not check (service
unreachable) is also a stop.
The only path through is an explicit operator override with a documented reason. The override
waives the vulnerability judgment only — the signed token still binds real artifact hashes and
the executor still verifies signature + hash. Every override writes a supply_chain_override
system event. Bulk approve carries no blanket override: flagged updates come back in
blocked[] and must be approved individually.
Auto-confirm shares the ClosureCleared predicate with manual approval — the two paths cannot
drift on what counts as a clean closure.
Build sequence (design intent)
The order the gate is built in. Per-step status is tracked in
docs/tasks/GATE-000-supply-chain-gate-plan.md; this records the intended dependency order.
helper/executor + token contract (the keystone; defines the schema in code).- Go
capabilitytoken type + canonical encoder + Ed25519 sign/verify (server & agent share the definition; mirrored in both modules — no shared module exists). - Server: closure resolver + mint/sign at approval; signer off web process; endpoint to deliver tokens to the agent.
- Migration: store resolved closure + per-artifact hashes alongside the pinned state.
- Agent consumer: accept token, bind-check, pass to executor; fold in
rs-helperparts. - Mirror tier (optional): pull+hash closure at approval; verified-cache fallback.
- Kernel adapters wire the executor as the only permitted caller.
Steps 1–2 are the contract. Everything else hangs off them.
Cross-References
- Build status / implementation tracking →
docs/tasks/GATE-000-supply-chain-gate-plan.md - Command signing →
verification/01-signing-pipeline.md - Agent verification →
verification/02-agent-verification.md - Replay protection →
verification/04-replay-protection.md - Trust boundaries →
security/01-trust-boundaries.md
Footer: Assumptions & Connections
Assumption: The capability model is the floor; kernel-level primitives (eBPF, WDAC, ESF) are defense-in-depth on top, not a prerequisite. Userspace wrappers alone are bypassable.
Assumption: The privileged executor runs network-less, with a narrow argv-only API, no shell, and a stripped environment; the agent that hands it tokens is unprivileged and holds no signing key.
Connection: security/01-trust-boundaries (kernel enforcement as trust boundary)
Connection: security/04-machine-binding (agent identity verification)
Last reviewed: 2026-06-14