22 KiB
Supply Chain Gate
Package-manager authorization with signed capability tokens; full closure custody, network isolation, and kernel enforcement remain design work.
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.
Current enforced scope: APT and DNF mutation through RedFlag requires a signed authorization token. Docker, Winget, and Windows Update still use the default-strict signed-command path, and kernel enforcement against out-of-band root mutation is not wired.
The decision: capability tokens, not a decision daemon
The gate authorizes APT/DNF installs with signed capability tokens, not with a runtime decision daemon. The server is the authority: it evaluates policy and mints an Ed25519-signed token describing exactly one operation over the artifact entries the agent resolved and reported. The top-level hash is required. Dependency hashes are best-effort; an unresolved dependency is logged and omitted today rather than making the report fail.
A small, privileged executor (helper/, Rust) validates token version and time, host
binding, signature, and replay state, then runs a fixed argv plan without a shell or inherited
environment. It rehashes local artifact files and requires a readable matching file for a
mirror entry. A normal registry entry without a local path is not rehashed helper-side, and
the current transient unit retains host network access.
The intended boundary is stronger: resolve and stage the complete transitive closure, rehash every byte at the privileged edge, then run the executor in a network-isolated unit. That remains explicit design work, not a present guarantee.
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, short-lived, Rust) │
│ - Invoked via `sudo systemd-run --wait` with token/result │
│ files; escapes the agent's ProtectSystem sandbox │
│ - Verifies version/time/host/signature/replay state │
│ - Rehashes local files; registry entries may have no path │
│ - Executes fixed argv: no shell, env stripped │
│ - Current unit retains host network access │
└─────────────────────────────────────────────────────────────┘
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": [ // signed reported set; target is the full closure
{
"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). For dnf/apt, persist the agent-reported resolved entries, run OSV over that set, and mint a host-bound token after the approval boundary. The top-level hash is required, but the current agent may omit a dependency whose hash did not resolve. Full transitive resolution, the mirror tier, and signer process isolation remain target work.
- Agent consumer (unprivileged). Run discovery and best-effort hash resolution, receive
the token, confirm
agent_idis this host, and hand it to the executor. It holds no signing key and has no direct APT/DNF mutation method. - Executor (
helper/, privileged, Rust). Verify validity window → resolve trusted key bykey_idfrom a local pinned keyring → reconstructsigned_message→ Ed25519 verify → rehash each locally available artifact (and require mirror paths) → build a fixed argv plan → replay-guard ontoken_id→ exec without a shell or inherited environment → structured result + exit code. Registry entries without local paths are not rehashed. The current unit is not network-isolated. - Kernel layer (where applicable). Linux eBPF / Windows WDAC / macOS ESF deny package-manager execution except via the trusted executor. This is defense-in-depth design; the present eBPF scaffold is not wired to the capability model.
Load-bearing constraints (target invariants)
Current deviations are named above and below. These constraints describe the boundary the system is meant to reach; they must not be presented as deployed enforcement until code and runtime evidence support them.
- 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 today: the exact version is resolved and its expected SHA256 is signed into the capability. The helper enforces that hash when it receives a local artifact path. For a normal registry entry without a local path, it fixes the package name/version in argv but does not compare the fetched bytes with the signed hash; APT/DNF still relies on its signed repository metadata. Making the installed artifact itself match the capability hash in every case is the mirror-backed target.
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)
Every name, version, and hash carried in a token is covered by its Ed25519 signature. That is not the same as rehashing every installed byte. The helper's current verification boundary is:
source=mirror:artifact_pathis required; missing or mismatched bytes deny.- Any entry with an existing local
artifact_path: the helper rehashes it; mismatch denies. - Normal
source=registrywith no local file: the hash remains signed into the token, but the helper does not rehash the bytes APT/DNF later fetches.
The top-level APT/DNF hash is mandatory before the server stores a closure. Dependency hash resolution is best-effort and unresolved entries can be omitted. Complete closure staging and helper-side verification of every byte remain the intended mirror-backed end state.
Storage (as built):
current_package_state.expected_sha256(migration 040) — the pinned top-level hash per update. On normal registry-backed dnf/apt helper execution it is signed into authority but not rehashed against fetched bytes.capability_tokens(migration 042) — the minted, signed token carries the reported resolved set (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 capability-signature validation and required local-artifact checks, which
have no skip setting.
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
Top-level vulnerability scanning begins at detection time (moved in v0.2.6.2). After
the dnf/apt dry-run report, checkClosureAndAdvance queries OSV for the resolved entries the
agent reported; verdicts persist to package metadata (supply_chain_vulns,
supply_chain_checked_at) and gate auto-confirm/minting. An unresolved dependency omitted
from the report is not checked by this path.
Approval reads the persisted verdict — it does not re-scan. Any known vulnerability among the reported entries checked 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 in any reported entry
that was checked — top-level or transitive — is a hard stop: ApproveUpdate returns 409 and
mints nothing. For capability-gated ecosystems, a reported closure that OSV could not check
(service unreachable) is also a stop. This does not claim coverage for a dependency omitted
because its artifact hash did not resolve.
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 the reported artifact
entries, and the executor still validates authority plus any local artifact paths. 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.
Current: The privileged executor has a narrow argv-only API, no shell, and a stripped environment; the agent that hands it tokens is unprivileged and holds no signing key. The transient unit still has host network access.
Target: Stage and re-verify the full artifact closure, then enforce network isolation on the helper unit. Neither property should be claimed until the invocation and artifact path make it true.
Connection: security/01-trust-boundaries (kernel enforcement as trust boundary)
Connection: security/04-machine-binding (agent identity verification)
Last reviewed: 2026-08-25