Watch
1
0
Fork
You've already forked RedFlag
0

docs: bind the supply-chain claims to the helper

This commit is contained in:
Fimeg 2026-08-25 08:26:05 -04:00
commit c3037655cd
10 changed files with 194 additions and 128 deletions

View file

@ -39,14 +39,22 @@ lifecycle orchestrator drives auto-advance and recovers stuck states.
The differentiator. The server is the signing authority — it evaluates policy (OSV
vulnerability checks, package age, human approval) and mints an Ed25519-signed
capability token describing exactly one operation over a fully-resolved dependency
closure. A privileged, network-less Rust executor (`helper/`) verifies the signature
and every artifact hash before performing that one operation.
capability token describing exactly one operation over the resolved artifact set the
agent reported. On dnf/apt, the top-level hash is mandatory; dependency hashes that
resolve are included, while unresolved dependencies can currently be omitted. A
privileged, short-lived Rust executor (`helper/`) validates the token version and time,
host binding, signature, and replay state before running one fixed argv plan with no
shell and a cleared environment.
The approval gate is fail-closed: a known vulnerability anywhere in the resolved
closure — top-level or transitive — blocks the token from being minted. The operator
must override with a documented reason. The override waives the vulnerability judgment
only; signing and hash verification have no skip path.
The helper rehashes a closure entry when it points to a readable local file and refuses a
missing mirror artifact. Normal registry entries without local paths are not rehashed
helper-side, and the current transient unit retains host network access. Full closure
pinning, complete local byte custody, and network isolation remain the target boundary.
The approval gate is fail-closed over the set it checked: a known vulnerability in a
reported resolved entry — top-level or transitive — blocks the token from being minted.
The operator must override with a documented reason. The override waives the vulnerability
judgment only; it does not waive capability validation or local artifact verification.
Auto-confirm shares the same `ClosureCleared` predicate as manual approval — the two
paths cannot drift on what counts as a clean closure.
@ -106,9 +114,9 @@ decisions are: verify this signature, check this nonce, reject this replay.
### Mutation Only Through the Helper
On capability-gated ecosystems (dnf, apt), the agent cannot run install commands
directly. All mutation flows through `consumer.go``sudo systemd-run --pipe` →
`redflag-helper`. The agent holds zero sudo for installs. Discovery (scan, dry-run,
hash-resolve) runs unprivileged through `DiscoveryRunner`.
directly. All mutation flows through `consumer.go``sudo systemd-run --wait` with
token/result files → `redflag-helper`. The agent holds zero sudo for installs.
Discovery (scan, dry-run, hash-resolve) runs unprivileged through `DiscoveryRunner`.
### Two Execution Paths
@ -150,7 +158,9 @@ From `security/05-supply-chain-gate.md` — do not regress these:
## Honest Gaps
- **Gate policy visibility**: the soak and age gates are live policies as of v0.2.6.2 (`supply_chain.*` settings — see [security/05-supply-chain-gate](security/05-supply-chain-gate.md) §4), but the dashboard doesn't yet surface their configuration; operators tune them blind. The live install-through-helper path completed e2e on 2026-06-05
- **Closure transitivity split by platform**: dnf/apt resolve via dry-run (full closure); npm/pypi still single-entry
- **Closure completeness**: dnf/apt require the top-level hash, but dependency hashes are best-effort and unresolved entries can be omitted; npm/pypi registry pinning remains single-entry
- **Registry artifact verification**: the helper rehashes local paths, but normal registry artifacts without a local path are not rehashed helper-side
- **Helper network isolation**: the current `systemd-run` unit retains host network access; complete local artifact custody and a private network boundary are not built
- **Signer in-process**: key encapsulated in SigningService, minter is the only caller — but true process isolation not built
- **Legacy ecosystems ungated**: docker, winget, windows_update still direct-mutation
- **Kernel enforcement inert**: eBPF scaffold exists, not wired to the capability model
@ -160,4 +170,4 @@ boundary currently ends. Task tracking for closing them lives in `docs/tasks/`.
---
*Last reviewed: 2026-06-10*
*Last reviewed: 2026-08-25*

View file

@ -67,7 +67,7 @@ agent/
| Path | Ecosystems | Mechanism |
|------|-----------|-----------|
| Capability gate | dnf, apt | Token fetched in loop → `consumer.ProcessToken``sudo systemd-run --pipe``redflag-helper` verifies + executes. Agent never runs the install command. |
| Capability gate | dnf, apt | Token fetched in loop → `consumer.ProcessToken``sudo systemd-run --wait` with token/result files`redflag-helper` verifies + executes. Agent never runs the install command. |
| Legacy command | docker, winget, windows_update | Signed command → handler → installer mutation method (type-asserted). |
Discovery (scan, dry-run, hash-resolve) always runs unprivileged through `DiscoveryRunner`. Sudoers grants only discovery commands plus the single helper invocation line — zero sudo otherwise.
@ -101,4 +101,4 @@ Discovery (scan, dry-run, hash-resolve) always runs unprivileged through `Discov
---
*Last reviewed: 2026-06-14*
*Last reviewed: 2026-08-25*

View file

@ -1,12 +1,14 @@
# Helper Component
**A privileged, network-less Rust executor that trusts nothing it didn't verify itself — the last gate before mutation.**
**A privileged, short-lived Rust executor that independently validates authority before APT/DNF mutation.**
---
## Doctrine
The helper (`helper/src/main.rs`, a single ~2,000-line binary) is the only thing on a gated host allowed to mutate packages, and it earns that privilege by being structurally incapable of being talked into anything. It has no network stack in play, reads its trust inputs from root-owned pinned files, and performs exactly one operation per invocation — the one described by a validly signed capability token. Everything else is a typed denial.
The helper (`helper/src/main.rs`, a single ~2,000-line binary) is the only RedFlag path allowed to mutate packages on gated ecosystems. It reads trust inputs from root-owned pinned files and performs exactly one operation per invocation — the one described by a validly signed capability token. It accepts no shell text and inherits no environment. Everything else is a typed denial.
The current Linux transient unit is **not network-isolated**. APT and DNF can reach their configured registries while the helper runs. Networkless execution remains the design target after RedFlag can stage and re-verify every required artifact locally.
Deny-by-default is the architecture, not a configuration: every failure path returns a `Denial` with a distinct exit code and a `log_security` entry. There is no flag that weakens verification. See [security/05-supply-chain-gate](../security/05-supply-chain-gate.md) for the token contract this enforces.
@ -14,7 +16,7 @@ Deny-by-default is the architecture, not a configuration: every failure path ret
## Invocation
The agent invokes it via `sudo systemd-run --pipe --property=ProtectSystem=no` — a transient unit with full filesystem access, separate from the agent's own locked-down unit. The token arrives on stdin (or a file path for self-update flows). The agent holds zero install sudo; the sudoers file grants discovery commands plus this one invocation line. See [components/02-agent](02-agent.md).
On Linux the agent invokes it through `sudo systemd-run --wait --property=ProtectSystem=no`, passing root-readable token and result file paths. This avoids fd passing through dbus while escaping the agent service's own `ProtectSystem=strict` mount sandbox. No `PrivateNetwork` or equivalent property is set. The agent holds zero direct install sudo; its privileged route is this helper invocation. See [components/02-agent](02-agent.md).
### Windows Invocation (SEC-030, decided 2026-07-01)
@ -32,16 +34,17 @@ The ACL lockdown described here is the v1 cut, not the final word — Casey's ca
## The Verification Pipeline
`run()` executes, in order — any failure stops the world:
`run()` executes, in order — any failure stops the operation:
1. **Trust-input self-validation (SEC-021)**`validate_trusted_path`: every file the helper relies on (keyring dir, agent-id file, state) must be root-owned, not a symlink, and not group/other-writable. The helper defends its own inputs instead of trusting that the installer set permissions correctly.
2. **Keyring load** — pinned Ed25519 public keys from `/etc/redflag/trusted-keys`. Verify keys, not servers (load-bearing constraint #4).
3. **Closure hash**`closure_hash()` recomputes the canonical hash over the token's resolved closure. This must be **byte-identical** to the Go implementation; cross-language tests pin the contract ([testing/01-test-pyramid](../testing/01-test-pyramid.md)).
4. **Signature**`verify_signature()` checks the token's Ed25519 signature over the signed message (which embeds the closure hash) against the keyring, by `key_id`.
5. **Artifact hashes**`verify_artifacts()` SHA-256s every artifact the token authorizes. A mismatch anywhere is a denial.
6. **Replay check**`replay_check_and_record()`: token IDs are recorded in local state; a token executes once.
7. **Plan + execute**`build_plan()` translates the token into the exact package-manager commands; `execute_plan()` runs them. No interpretation, no substitution. A POSIX `--` (end-of-options) separator precedes all user-derived values (package names, versions) to prevent option injection (GATE-005).
8. **Receipt**`emit_result()` writes a `PolicyResult` the agent reports back; the server reconciles it into lifecycle state.
1. **Token shape and time** — reject an unsupported token version, a not-yet-valid token, or an expired token.
2. **Host binding** — compare the token's `agent_id` with an independently read local identity.
3. **Trust-input validation and keyring load (SEC-021)** — root-owned, non-symlinked, non-writable trust paths; pinned Ed25519 public keys from `/etc/redflag/trusted-keys`.
4. **Closure hash and signature** — recompute the canonical closure hash and verify the signed message against the selected pinned key. Go and Rust tests pin the byte contract.
5. **Local artifact hashes**`verify_artifacts()` rehashes entries that name a local file. A mirror entry must name a readable matching file. A normal registry entry with no local file remains signed but is not rehashed here.
6. **Fixed plan**`build_plan()` maps the signed package type and operation to fixed package-manager argv. APT/DNF insert POSIX `--` before package values; unsupported pairs deny.
7. **Replay record**`replay_check_and_record()` records the token ID before execution; a token runs at most once even across a crash.
8. **Execute**`execute_plan()` invokes the fixed argv directly, without a shell, after `env_clear()` and a fixed `PATH`.
9. **Receipt** — the helper writes a structured `PolicyResult`; the agent reports it and the server reconciles lifecycle state.
---
@ -59,10 +62,11 @@ The helper also carries a local-authority minting path (`MintRequest` / `MintedT
## Why a Separate Binary, Why Rust
- **Privilege separation:** the long-running, network-facing agent stays unprivileged; the privileged thing is short-lived, single-purpose, and offline.
- **Can't be redirected:** no outbound capability means a compromised server can lie in a token — and the signature/hash checks catch that — but nothing can make the helper fetch from somewhere else.
- **Privilege separation:** the long-running, network-facing agent stays unprivileged; the privileged thing is short-lived and single-purpose.
- **Narrow execution surface:** token fields select from fixed argv templates. They are not interpreted as shell input, and the child gets a cleared environment.
- **Isolation still to land:** registry-backed APT/DNF operations currently retain network access. The intended end state stages every authorized artifact locally, verifies it, and runs the helper without a network namespace.
- **Small audit surface:** one file, explicit pipeline, typed denials. The binary is meant to be read.
---
*Last reviewed: 2026-06-14*
*Last reviewed: 2026-08-25*

View file

@ -15,7 +15,7 @@ The existing architecture has three artifacts with clean boundaries. Tier 4 adds
| Component | Network | Lifetime | Privilege | Purpose |
|-----------|---------|----------|-----------|---------|
| Agent (Go) | None (pull-only) | Persistent daemon | Unprivileged | Poll, scan, process tokens |
| Helper (Rust) | None | One-shot | Privileged (systemd-run) | Verify + execute one mutation |
| Helper (Rust) | Host network currently reachable; isolation intended | One-shot | Privileged (systemd-run) | Verify authority + execute one mutation |
| Desktop (Rust/Tauri) | None (local socket only) | Persistent | Unprivileged | Local tray status |
| **Broker (Rust)** | WebSocket to server | Per-session | Privileged (systemd-run) | Grant verification, session lifecycle, audit trail |
| **Streamer (Rust)** | WebRTC/WS to browser, ENet to Sunshine | Per-session | Privileged (systemd-run) | Moonlight protocol, video decode, input injection |
@ -30,7 +30,7 @@ The broker and streamer are **separate binaries** by design:
Tier 4 needs a live interactive channel — streaming I/O, return data, a WebSocket or gRPC connection to the server. None of the existing components can carry this without breaking their design constraints:
- **Agent:** pull-only doctrine forbids server-initiated connections; grafting a push channel onto the agent would destroy the posture that makes it safe.
- **Helper:** network-less by design; one-shot by design. A persistent session is the opposite of both.
- **Helper:** one-shot by design. Network isolation is also the target, but the current helper unit does not enforce it; a persistent interactive channel would still violate the intended boundary.
- **Desktop:** local-only by design; no server connectivity.
The session broker is the fifth artifact. It reuses the agent's spawn pattern (`sudo systemd-run --pipe`) but has its own trust boundary, its own network connection, and its own audit trail.
@ -154,7 +154,7 @@ Same pattern as the helper, same narrowed sudoers philosophy: the agent can spaw
### `ProtectSystem=no` blast radius
The broker unit is the most-privileged transient thing on the host: root, full filesystem access, network connection. This is a wider blast radius than the helper (which is also root + `ProtectSystem=no` but has no network). The tighter gating compensates:
The broker unit is the most-privileged transient thing on the host: root, full filesystem access, a network connection, and an interactive lifetime. The current helper unit is also root with `ProtectSystem=no` and retains host network access; that is a helper gap, not a safety property the broker design may rely on. The broker's interactive protocol surface and longer lifetime still make its blast radius wider. The tighter gating compensates:
- Grant is short-lived (15 min max recommended TTL)
- Grant is scoped to one operator, one agent, one scope

View file

@ -12,7 +12,7 @@ The flow has two execution paths that diverge at install time:
- **Capability gate** (dnf, apt): server mints an Ed25519-signed token → agent's Rust helper verifies + executes → agent reports receipt
- **Legacy command** (docker, winget, windows_update): server creates a `confirm_dependencies` command → agent executes → agent reports via `ReportLog`
**Implementation status:** State machine enforced with typed `PackageStatus` and `ValidateTransition` guards (LIFECYCLE-001, v0.2.1.3). Lifecycle orchestrator running with stuck-state recovery and auto-advance (LIFECYCLE-003, v0.2.2.0). Approval-time supply chain enforcement: vuln in closure = full stop with audited override (v0.2.3.1).
**Implementation status:** State machine enforced with typed `PackageStatus` and `ValidateTransition` guards (LIFECYCLE-001, v0.2.1.3). Lifecycle orchestrator running with stuck-state recovery and auto-advance (LIFECYCLE-003, v0.2.2.0). Approval-time supply chain enforcement: a vulnerability in any reported entry checked is a full stop with an audited override path (v0.2.3.1).
**Cross-references:**
- `flows/02-command-execution.md` — agent polling, command dispatch, at-least-once delivery
@ -89,8 +89,8 @@ ReportDependencies handler (updates.go:1232)
installing
│ Agent polls: GET /api/v1/capability-tokens/pending/:agent_id
│ Agent: consumer.ProcessToken → helper invoked via systemd-run --pipe
│ Helper: verify Ed25519 sig → verify artifact hashes → dnf install / apt install
│ Agent: consumer.ProcessToken → systemd-run --wait with token/result files
│ Helper: verify token authority/replay → rehash local paths → fixed dnf/apt argv
│ Agent reports: POST /api/v1/capability-tokens/:token_id/result
@ -99,7 +99,7 @@ ReportCapabilityResult handler (updates.go:1936)
│ UpdatePackageStatus → installed | failed
```
The agent never receives an install command on this path. The capability token IS the install authorization — the helper enforces that only the exact artifact closure signed by the server can be installed.
The agent never receives an install command on this path. The capability token is the install authorization and fixes the package entries passed to APT/DNF. The top-level hash is mandatory; unresolved dependency hashes can be omitted from the reported set, and normal registry artifacts without local paths are not rehashed helper-side. APT/DNF may use the network and their signed repository metadata during execution.
**Implementation:**
- Token mint: `server/internal/services/capability_minter.go`
@ -175,9 +175,10 @@ The agent has no lifecycle state awareness. It is a stateless executor — it re
`checking_dependencies` and `installing`. Auto-approval policy support. Packages stuck
in active states no longer require manual operator intervention. (LIFECYCLE-003, v0.2.2.0)
- **Supply chain enforcement at approval.** `ApproveUpdate` checks the full resolved closure
against OSV. A vuln anywhere in the closure returns 409 and mints nothing. Override requires
an operator reason and is journaled. `ClosureCleared` predicate shared with auto-confirm.
- **Supply chain enforcement at approval.** `ApproveUpdate` checks the reported resolved
entries against OSV. A vuln anywhere in that checked set returns 409 and mints nothing.
Override requires an operator reason and is journaled. `ClosureCleared` is shared with
auto-confirm. Unresolved dependency hashes can currently be omitted before this check.
(v0.2.3.1)
### Remaining Visibility Gaps
@ -271,4 +272,4 @@ asserted in the footer below). Model precedent: TacticalRMM `WinUpdatePolicy`
---
*Last reviewed: 2026-06-01 — updated for LIFECYCLE-001/003 completion and supply chain enforcement*
*Last reviewed: 2026-08-25 — implementation boundary reconciled for helper execution and closure coverage*

View file

@ -7,21 +7,21 @@
| Term | Meaning |
|------|---------|
| **Agent** | Stateless Go executor on each managed host. Polls, verifies, executes, reports. Never decides. [components/02-agent](components/02-agent.md) |
| **Capability token** | Ed25519-signed grant describing exactly one operation over a resolved closure, with every artifact hash pinned. Minted at approval, executed once. [security/05-supply-chain-gate](security/05-supply-chain-gate.md) |
| **Closure (dependency closure)** | The full set of artifacts an operation will touch — the named package *and* every transitive dependency resolved at dry-run time. |
| **Capability token** | Ed25519-signed grant describing exactly one operation over the artifact entries it carries. Every carried name/version/hash is signed; the current dnf/apt set can omit a dependency whose hash did not resolve. Minted at approval, executed once. [security/05-supply-chain-gate](security/05-supply-chain-gate.md) |
| **Closure (dependency closure)** | The artifact set reported from a package-manager dry-run. The target is the full named package plus every transitive dependency; today the top-level hash is mandatory while unresolved dependency hashes can be omitted. |
| **Closure hash** | Canonical hash over the closure, embedded in the token's signed message. Computed byte-identically in Go (server) and Rust (helper) — the cross-language contract. |
| **Discovery vs. mutation** | Discovery (scan, dry-run, hash-resolve) runs unprivileged through `DiscoveryRunner`. Mutation happens only through the helper on gated ecosystems. The agent cannot install. |
| **Doctrine / doctrinal** | A guarantee with no configuration knob: signing-required, forward-only versioning, no verification skip path. If it's doctrine, there is nothing to misconfigure. [core/01-ethos](core/01-ethos.md) |
| **Drift detection** | Knowing what *should* be installed vs. what *is*, and bridging the gap into update packages. |
| **ETHOS** | The five principles every change is held to: errors are history, no unauthenticated endpoints, assume failure, idempotency, no marketing fluff in logs. [core/01-ethos](core/01-ethos.md) |
| **Fail-closed** | When verification can't succeed, the operation doesn't happen. A registered hash that can't be checked blocks; an unknown vulnerability state blocks under `block` enforcement. The opposite of "warn and continue." |
| **Fail-closed** | When a required check fails, the operation doesn't happen: bad token version/time/host/signature/replay state denies, a missing or mismatched mirror artifact denies, and an unknown vulnerability state blocks under configured policy. A normal registry entry without a local path is not currently a required helper-side rehash. |
| **Family revocation** | Refresh tokens form a lineage (`family_id`); replaying a stale token burns the entire family loudly. Theft is detected, not coexisted with. [security/03-refresh-tokens](security/03-refresh-tokens.md) |
| **Forward-only** | No downgrades. Versions move forward; the release gate enforces it; there is no override. |
| **Helper** | The privileged, network-less Rust executor — the only mutation path on gated ecosystems. [components/04-helper](components/04-helper.md) |
| **Helper** | The privileged, short-lived Rust executor — the only RedFlag mutation path on gated ecosystems. It uses fixed argv and a cleared environment; its current transient unit is not network-isolated. [components/04-helper](components/04-helper.md) |
| **Legacy command path** | Direct signed-command execution for docker / winget / windows_update — ecosystems the capability gate doesn't cover yet. A documented gap, not a feature. [OVERVIEW](OVERVIEW.md) |
| **Machine binding** | Hardware fingerprint registered at enrollment and checked on every authenticated request, including token renewal. A stolen `config.json` is inert elsewhere. [security/04-machine-binding](security/04-machine-binding.md) |
| **Nonce** | Per-command signed value with a 10-minute window; agents track executed nonces and reject replays. [verification/04-replay-protection](verification/04-replay-protection.md) |
| **OSV** | OSV.dev, the open vulnerability database. Queried in batches across full closures at detection time; verdicts persist and gate approval. |
| **OSV** | OSV.dev, the open vulnerability database. Queried for discovered packages and for the resolved entries reported after dry-run; verdicts persist and gate approval. An unresolved dependency omitted from that report is not checked by this path. |
| **RAF** | This document tree — the RedFlag Architecture Framework, the design of record. What the system is, not what's currently on the task list. |
| **Soak gate / age gate** | Time-based supply-chain policies: minimum package age before approval (Shai-Hulud defense) and a version soak window before install. Policies, not doctrine — configurable, with enforcement modes. [security/05-supply-chain-gate](security/05-supply-chain-gate.md) |
| **TOFU** | Trust-on-first-use: the agent caches the server's public key at first connect and verifies everything after against the cached roster, by `key_id`. [verification/03-key-rotation](verification/03-key-rotation.md) |
@ -29,4 +29,4 @@
---
*Last reviewed: 2026-06-11*
*Last reviewed: 2026-08-25*

View file

@ -1,6 +1,6 @@
# Supply Chain Gate
**Package-manager authorization with signed capability tokens, kernel-enforced where the platform allows.**
**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.
@ -12,18 +12,29 @@ Build status and per-step implementation tracking live in `docs/tasks/GATE-000-s
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.
**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 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.
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
@ -86,14 +97,13 @@ The two tests converge on the same answer, which is the signal it's right.
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────────┐
│ 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 │
│ 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 │
└─────────────────────────────────────────────────────────────┘
```
@ -112,7 +122,7 @@ and Rust (`helper/src/main.rs`) implementations reconstruct identical bytes from
"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)
"closure": [ // signed reported set; target is the full closure
{
"name": "<pkg>",
"version": "<exact>",
@ -154,26 +164,30 @@ The token extends the existing Ed25519 infrastructure rather than introducing ne
## 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_id` is 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. Salvages
`rs-helper`'s `InterceptEvent`, allowlist, and cache.
- **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_id` is 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 by
`key_id` from a local pinned keyring → reconstruct `signed_message` → Ed25519 verify →
verify each artifact's sha256 → replay-guard on `token_id` → exec exactly one operation via
argv (no shell, env stripped, POSIX `--` separator before user values) → structured result
+ exit code. Fail-closed on every error path. `request_id` validated as canonical UUID v4
at intake (GATE-005). Auditable in one sitting.
rehash each locally available artifact (and require mirror paths) → build a fixed argv plan
→ replay-guard on `token_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. Defense-in-depth.
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 (do not regress these)
## 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.
1. **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
@ -208,8 +222,12 @@ The token extends the existing Ed25519 infrastructure rather than introducing ne
**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.
**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:
@ -228,13 +246,24 @@ The pin's hash source depends on who can reach the artifact:
### 2. Hash Registry (Layer 1)
All artifacts are verified by SHA256 before installation.
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_path` is required; missing or mismatched bytes deny.
- Any entry with an existing local `artifact_path`: the helper rehashes it; mismatch denies.
- Normal `source=registry` with 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, consumed by the agent's install-time `VerifyHash`.
- `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
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 separate `security_packages` table.
OSV findings, package age, and published-at are recorded on the update's own `metadata` JSONB
@ -260,7 +289,8 @@ at approval, not in a dedicated table.
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.
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:
@ -280,15 +310,15 @@ than the threshold draw a warning or a block at approval:
### 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.
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
anywhere in the closure is a full stop (see Enforcement Posture below); there is no
severity threshold below which approval proceeds quietly.
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`).
@ -314,15 +344,17 @@ 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.
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 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
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
@ -364,9 +396,13 @@ Steps 12 are the contract. Everything else hangs off them.
**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.
**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](01-trust-boundaries.md) (kernel enforcement as trust boundary)
@ -374,4 +410,4 @@ signing key.
---
*Last reviewed: 2026-06-14*
*Last reviewed: 2026-08-25*

View file

@ -29,9 +29,10 @@ The boundary that *is* preservable locally is the OS privilege boundary:
| Defended against | Fleet mode | Standalone |
|---|---|---|
| Tampered artifact (hash mismatch) | yes | **yes** — helper verification unchanged |
| Known-vuln package (OSV) | yes | **yes** — gates run before mint |
| Too-new package (age/soak gates) | yes | **yes** |
| Tampered local/mirror artifact (hash mismatch) | yes | **yes** — helper rehashes a supplied local path |
| Registry artifact with no local path | not helper-rehashed today | **not helper-rehashed today** |
| Known-vuln package (OSV) | yes, over reported resolved entries | **yes, over reported resolved entries** |
| Too-new package (age/soak gates) | fleet policy | **not yet applied** — local evidence records `not_applicable` |
| Unprivileged local malware minting installs | yes | **yes** — mint key is root-owned |
| Compromised agent process | yes (server refuses) | **yes** — agent user cannot read mint key |
| Local root attacker | yes (authority off-host) | **no — out of scope, say so in docs** |
@ -50,7 +51,7 @@ Standalone mint runs as a **separate privileged invocation of the helper**
uses. Rationale:
- Keeps the artifact set at three (`agent`, `server`, `helper`) — no fourth binary.
- The helper is already the audited, privileged, network-less Rust component
- The helper is already the audited, privileged, short-lived Rust component
("auditable in one sitting"). Mint is ~the token struct it already parses, signed
instead of verified.
- Minter-equals-verifier is acceptable *here only* because both already run as root on
@ -72,8 +73,8 @@ Keys:
tray (redflag-local member)
→ POST /v1/updates/:id/approve (agent local API, group ACL boundary)
→ agent: resolve closure via DiscoveryRunner (dry-run, hash-resolve — read-only)
→ agent: run gate predicates locally (OSV.dev query, age gate, soak gate)
any vuln in closure = full stop, same as ApproveUpdate's 409 — no silent waiver;
→ agent: run the current local predicate (OSV.dev query over resolved entries)
any vuln in the checked set = full stop — no silent waiver;
override requires explicit reason, journaled locally
→ agent: write mint request file (closure + gate evidence + operator + reason)
→ sudo systemd-run redflag-helper --mint <request> (narrowed sudoers entry)
@ -82,11 +83,15 @@ tray (redflag-local member)
→ token → normal consumer path → helper verify + execute (unchanged)
```
The mint step **re-validates rather than trusts** the agent's gate verdicts where it
can do so without network (closure hash shape, evidence timestamps, forward-only
version check). It cannot re-run OSV (network-less) — the OSV verdict is part of the
journaled evidence, so a lying agent user leaves a tamper-evident trail and still
cannot bypass artifact-hash verification at execute time.
The mint step **re-validates rather than trusts** the agent's evidence where the current
contract permits: closure entry shape, evidence timestamps, host binding, operation, and
the presence of an explicit override reason when OSV was vulnerable or unreachable. It
does not re-run OSV; the unprivileged agent performs that query and the privileged mint
path journals the supplied verdict. Network reachability is not the enforcement boundary
here: the current helper unit retains host network access.
Age and soak evidence fields exist, but the local approval handler currently records both
as `not_applicable`. Bringing the fleet age/soak policies into standalone mode remains work.
Doctrine carried over unchanged: signing required, forward-only, no skip-verification
path, no doctrinal knobs.
@ -113,11 +118,10 @@ as install/upgrade: idempotent, re-runnable, verified by the post-join healthche
rather than assumed (`docs/tasks/INSTALL-001` is the enforcement pattern). Two
standing rules keep it from rotting:
1. **Gate logic stays single-source.** Standalone and fleet share the same gate
code (vuln full-stop, soak, age, hash verification). When a gate gains a
fleet-side capability (e.g. DB-backed policy config), the standalone resolution
path must be extended in the same change — a gate that behaves differently per
mode is drift, not configuration.
1. **Gate logic should converge.** Fleet and standalone share capability and helper
verification primitives, while standalone currently omits the age/soak policies.
Closing that drift is required; future gate changes must extend both resolution
paths in the same change.
2. **The join flow is exercised, not trusted.** Keyring replacement, key
destruction, and journal upload need test coverage that runs both directions of
the matrix (fresh-fleet install vs standalone-then-join must converge on
@ -138,10 +142,11 @@ standing rules keep it from rotting:
1. **Mint placement: `redflag-helper --mint`.** Artifact set stays at three. Two keys
keep roles distinct (mint key root-owned `0600`, verify key in pinned keyring);
SEC-022 attests both invocation modes.
2. **OSV in standalone: best-effort with honest verdict.** Vuln found = full stop.
2. **OSV in standalone: best-effort with honest verdict.** Vuln found in a reported
resolved entry = full stop.
OSV unreachable = explicit operator acceptance of "closure unverified" with reason,
journaled — mirrors the fleet `unverified` hold from v0.2.3.1. Age/soak gates and
execute-time hash verification never relax.
complete execute-time registry artifact rehashing are not current standalone guarantees.
3. **Gate-evidence freshness window: 15 minutes, hard-coded.** Not configurable
(no doctrinal knobs); expired evidence means the agent re-resolves and re-checks.
@ -152,3 +157,5 @@ standing rules keep it from rotting:
- `docs/tasks/SEC-023` — package-mutation boundary (mint path must not weaken it).
- `docs/tasks/SEC-022` — binary mutual attestation (helper trust file).
- `docs/tasks/THREAT-001` — must document the standalone trust model table above.
*Last reviewed: 2026-08-25*

View file

@ -16,7 +16,7 @@
One dashboard for updates across Linux, Windows, and the Docker containers running on those hosts. Agents check in, scan their package managers, and queue what they find. Nothing installs until a human approves it.
What makes RedFlag different: the software that patches your fleet runs as root on every box, which makes it part of your attack surface — XZ Utils came through a build pipeline, SolarWinds came through an update. So every command here is Ed25519-signed, agents reject anything forged or replayed, and approved packages are hash-pinned down to their dependency closure before anything touches a machine. A known vulnerability anywhere in that chain stops the install cold. The full trust model is in [SECURITY.md](SECURITY.md).
What makes RedFlag different: the software that patches your fleet runs as root on every box, which makes it part of your attack surface — XZ Utils came through a build pipeline, SolarWinds came through an update. So every command here is Ed25519-signed and agents reject anything forged or replayed. On APT and DNF, direct package mutation must cross a privileged Rust helper: a short-lived capability binds the host, operation, and artifact entries whose hashes resolved, and the helper validates that authority before executing a fixed argv plan with a cleared environment. Docker, Winget, and Windows Update still use the default-strict signed-command path. The full trust model is in [SECURITY.md](SECURITY.md).
ConnectWise charges $50/agent/month. RedFlag doesn't.
@ -87,7 +87,9 @@ Agents run at the OS level and query the Docker socket directly — there's no s
## Security
The update manager *is* attack surface, so it gets treated like one: Ed25519-signed commands with replay protection, hardware-bound agent identity, rotating refresh tokens that burn loudly when stolen, and a supply-chain gate that hash-pins entire dependency closures behind a network-less executor. Signing and hash verification have no skip path — that's doctrine, not a setting.
The update manager *is* attack surface, so it gets treated like one: Ed25519-signed commands with replay protection, hardware-bound agent identity, rotating refresh tokens that burn loudly when stolen, and a separate privileged executor for APT/DNF mutation. The helper checks token version and time, host binding, signature, and replay state, then executes fixed package-manager argv without a shell or inherited environment.
The APT/DNF dry-run must resolve the top-level artifact hash before a capability can be minted. Successfully resolved dependency hashes are included, but unresolved dependency hashes can currently be omitted. The helper rehashes artifacts supplied by local path and refuses a missing or mismatched mirror artifact; normal registry entries without local paths are not rehashed helper-side. Its current `systemd-run` unit is short-lived but **not network-isolated**. Complete transitive closure pinning, local custody of every byte, and network isolation remain design work rather than implied guarantees.
The full trust model lives in [SECURITY.md](SECURITY.md), including how to report a vulnerability. The architecture and its honest gaps are documented in the RedFlag Architecture Framework (RAF).
@ -138,8 +140,8 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
- Failed state recovery: reopen, resolve, and transition out of failed
- Lifecycle history with status badges, version transitions, and failure reasons
- Scan-set closure reconciler (close-by-absence) — fixes out-of-band false positives
- Dry-run dependency checking with full closure resolution
- Supply chain gate: OSV batch checks across transitive closures, vuln-is-a-full-stop enforcement, audited override path
- Dry-run dependency checking with a mandatory top-level hash and best-effort transitive hash resolution
- Supply chain gate: OSV batch checks across reported resolved entries, vuln-is-a-full-stop enforcement for the checked set, audited override path
- Version soak-gating and package age gate as configurable policies
- Capability-token minting for dnf/apt with Ed25519-signed token verification
- Ed25519 key rotation and replay protection
@ -159,6 +161,10 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
- macOS agent binaries not signed
- Mobile dashboard usable, not optimized
- Cert pinning and enforced TLS verification
- Complete transitive closure hashing for APT/DNF; unresolved dependency hashes can currently be omitted
- Helper-side rehashing of normal registry artifacts before mutation
- Network isolation for the privileged helper invocation
- Capability-helper execution for Docker, Winget, and Windows Update
---

View file

@ -44,11 +44,13 @@ The failure mode is detection, not silent coexistence: a leaked token is only us
## The Supply-Chain Gate
When an update is approved, the server resolves the full dependency closure, checks every transitive artifact against OSV.dev, and mints a signed capability token binding the exact artifact hashes. A network-less privileged executor verifies the signature and every hash before anything installs — it can't reach out and can't be redirected.
For DNF and APT, the agent runs the package-manager dry-run and resolves artifact hashes from that host's signed repository metadata. The top-level artifact hash is mandatory. Successfully resolved dependency hashes are reported too, but an unresolved dependency is currently logged and omitted rather than blocking the whole report. The server checks the reported entries against OSV.dev and can mint an Ed25519-signed capability binding that exact resolved set to one host and operation.
A known vulnerability anywhere in the closure is a full stop: the operator must override with a documented reason, or the token is never minted. The override waives the vulnerability judgment only — the signing and hash verification have no skip path.
A known vulnerability among the entries checked is a full stop: the operator must override with a documented reason, or the token is never minted. The override waives that vulnerability judgment only; it does not bypass capability validation or local artifact verification where a local artifact is present.
Before any package install: the agent fetches the expected SHA-256 from the server, downloads the artifact, verifies the hash. Mismatch blocks the install. OSV.dev is queried at discovery time (async, deduped) for npm, PyPI, apt, and dnf packages — results are visible in the dashboard before approval, and the resolved closure is checked again before the token is minted, not just the package you named.
The privileged Rust helper independently validates the token version and validity window, host `agent_id`, pinned-key Ed25519 signature, and replay state. It constructs a fixed package-manager argv plan, invokes no shell, and clears the inherited environment. If a closure entry points to an existing local file, the helper rehashes that file and denies a mismatch; a `source=mirror` entry without a readable matching file also denies. A normal `source=registry` entry with no readable local file is bound into the signed capability but is **not rehashed helper-side** before APT or DNF fetches and installs it.
The Linux invocation currently uses a transient `systemd-run --wait` unit with `ProtectSystem=no`. It does not set a private network namespace or otherwise enforce network isolation. Complete transitive hash resolution, local custody and re-verification of every installed artifact, then a network-isolated helper are the intended boundary and remain unfinished.
**Current boundary, honestly:** the capability-token gate covers dnf and apt today. Docker, winget, and Windows Update still execute through the signed-command path without the helper — gating them is designed but not yet built. The gaps are documented in the RAF, not hidden.