Replaces the flat battery x display matrix (which misclassified laptops
as phones) with layered detection: container -> vm -> SMBIOS chassis ->
ARM fallback. Adds laptop, vm, container device types across agent,
migration 062, server validation, web icons/types.
DEVICE-002: ARM machine-ID fallback — device-tree model + /etc/machine-id
combo, then /proc/cpuinfo Serial (all-zero rejected), before the weak
hostname fallback. Hardware-bound IDs on DMI-less devices.
DEVICE-001: agent detects device_type (server/desktop/phone/tablet) from
/sys signals — system battery (scope=Device peripherals excluded, UPS
excluded), DRM connector state, framebuffer min-dimension for phone/tablet
split. Reports device_type/device_model/os_distro in registration and
system-info paths.
SERVER-001: migration 061 — device_type, device_type_manual (operator
override, never agent-written), device_model, os_distro on agents.
effective_device_type computed into every serialized agent.
SERVER-002: PUT /admin/agents/:id/device-type — set/clear override,
enum-validated, journaled.
WEB-001: device-type icons + fleet filter, device model in list, detail
header badge with reclassify dropdown, os_distro surfaced.
INSTALL-003: arm64 install path unblocked — helper (required manifest
component) now cross-built aarch64-unknown-linux-musl via rust-lld in the
server image, signed at boot (helperArches += arm64), listed in the release
manifest. Install template already handled uname -m and pacman.
Plus in-flight: desktop tray wiring, enrollment page polish, CI workflow
updates, RAF session-broker/pacman-scanner docs, native installer scaffold.
helpers.go was gitignored as "stale WIP" but defines BufferSystemEvent
called by committed code in install.go and loop.go — CI broke because
the file never shipped.
buffer.go GetBufferedEvents: re-atomic the read+clear under one lock
hold; the refactor split into ReadBufferedEvents+Clear introduced a
window where BufferEvent could write events that Clear then deletes.
Desktop:
- Windows tray cross-compiled (cargo-xwin), installed with per-user
autostart Run key; tray actions trigger_scan/approve_update wired to
the local API
- Linux tray off the service child-spawn path — XDG autostart only, kills
the double-launch
- signalDesktopRestart no longer no-ops on Windows (taskkill /F /IM)
- server serves /desktop/:platform/:arch
Web:
- TokenManagement + AgentManagement folded into one Agents & Enrollment
settings page (useRegistrationTokens hook)
Agent/server:
- platform-aware self-update staging (constants/paths.go), no more
hardcoded /var/lib/redflag
- consumer helper gated: sudo systemd-run on Linux, child proc elsewhere
- migration 060 drops the never-used token_seats table
- droppage of dead constructors and orphaned windows.go service methods
Desktop self-updates ran in the agent process with their own replay
file. They now go through the privileged helper like agent and helper
self-updates, so the agent performs no binary mutation and keeps no
replay state of its own. The desktop app is a status surface only.
SEC-028 -- a rotated-out server signing key must stop being trusted even when the agent cannot phone home. pubkey.go: bounded stale-cache window on public-key fetch failure; past the window (or when cache age is unknown) it fails closed instead of trusting the cached key indefinitely. Window length is operator policy (command_signing.stale_key_max_age_hours, default 168h/7d) delivered fleet-wide via GET /agents/:id/config; the [1h, 30d] clamp and the existence of the ceiling are doctrine, not knobs. verification.go: CheckKeyRotation refuses when the named key_id is not in the server active set (no primary fallback), and applies the same bounded-stale ceiling to the active-set fetch-failure path so key_id'd commands are no weaker than keyless ones. Server carries the default + 1-720h validation; web surfaces it in Security Settings.
SEC-029 -- the standalone OSV.dev client retries transient transport/5xx/429 with exponential backoff and trips a process-wide circuit breaker after a run of failures, fast-failing to 'unreachable'. Verdict semantics unchanged and still fail-closed; the resilience only stops a transient scanner blip from forcing an operator override.
GATE-004 #4 -- Consumer.ProcessToken holds a mutex so the replay-state guards are never raced by a concurrent caller. Today's single caller (the poll loop) never overlaps; this enforces the one-token-at-a-time invariant for future callers (local-API trigger, retry worker).
RAF/verification/03 and RAF/security/05 document the key-path and OSV changes. ETHOS #3, #4; forward-only doctrine.
dep-scan.sh gates go/npm/cargo on push and bakes an attested posture into the
release — embedded in the server, signed into the manifest. Reasoning and the
two Moby exceptions are in SECURITY.md.
(posture-builder runs rustup; bookworm's cargo is too old for cargo-audit.)
Delivery-tracker persistence failures (ack/receipt/confirmed Save) were
local-only log lines; a tracker that cannot persist risks double-delivery
or replay-rejection after a crash, so they now tee to the server event
buffer via TeeLogger. The untagged 'Command rejected' line gains ETHOS
tags. install.sh restorecon calls lose their 2>/dev/null — SELinux
relabel failures now print a tagged warning instead of vanishing.
classifyFailure is now the single source of truth for which polling
failures are terminal (ErrRefreshTokenInvalid, ErrMachineMismatch —
wrapped or bare) vs transient. delayForFailure picks the curve: flat
10-minute delay for terminal states awaiting operator intervention,
jittered exponential (calculateBackoff) for everything else. The
terminalBackoff bool is gone from the polling loop. Task file said
to delete itself when this landed — done.
Both previously logged hash_verification_skipped and returned nil when the
server had registered an expected hash the installer cannot verify. Now:
empty expected hash errors (consistent with dnf/apt), and a registered hash
without an implemented verifier blocks the install instead of pretending.
No behavior change today — the server only registers hashes for npm/pypi,
and handlers skip VerifyHash on empty hash — this closes the latent path.
- NewOrchestrator initialises a log-only TeeLogger (nil buffer) so the
Windows service path never carries a nil logger into executeScan
- rpmEVRAhead returns false when all epoch/version/release components
compare equal; adds test cases for "0:2.0-1" vs "2.0-1" normalisation
- processDesktopSelfToken splits replayCheckAndRecordAgent into
replayCheckAgent (before install) + recordAgentTokenConsumed (after
successful install) so a transient install failure does not permanently
consume the token
- Comments on helperSelfStagingPath (Go) and DEFAULT_HELPER_SELF_SOURCE
(Rust) name each other as the cross-language counterpart
Add AllowedSelfUpdatePackageTypes and allowedCapabilityPackageType() so the
consumer accepts redflag_agent_self / redflag_helper_self / redflag_desktop_self
tokens without putting them in the package-manager allowlist. ProcessToken
dispatches self-update types to dedicated processAgentSelfToken,
processHelperSelfToken, processDesktopSelfToken handlers. ArtifactDownloader
interface extracted; inferred from reporter when available. Tests added for
allowlist invariants, stageClosureArtifact (local file path), and
installDesktopBinary (backup-and-replace).
Replace fmt.Printf / log.Printf calls in loop, config, crypto, and security
logger with TeeLogger.Info/Warning/Error. Buffer is persisted to events_buffer.json
and flushed to the server after each check-in. CRITICAL security events still
write to disk immediately then also enter the buffer for server delivery.
SecurityLogger.GetBatch() no longer falls through on CRITICAL before buffering.
Agent-side: reads /proc for all PIDs with 25+ fields (identity, resources,
state, disk I/O, elevation) plus related data on drill-down (open files,
sockets, pipes, env keys, memory map, namespaces, listening ports). Pure
/proc reads, no subprocess spawns.
Server-side: dedicated tables (agent_process_snapshots, agent_processes,
agent_process_related) with JSONB for related data. On-demand scan via
scan_processes command, last-10-snapshot retention. Four endpoints:
report, get latest, get detail, trigger scan.
UI: new Processes tab in agent detail with sortable/filterable table,
search by name/cmdline, state/user filters, and ProcessDetailModal with
tabs for Overview, Network, Files, Environment, Memory, Namespaces.
OSV fail-closed paths, safeTokenFilename traversal, bind check,
disallowed package types covered in agent/internal/supplychain.
Artifact hash resolution and dnf scanner test fixes. Server update
handlers extended for the local-approve flow with OSV version test
coverage.
ci.yml: vet, race tests, clippy, full web build, AI-attribution and
action-pin enforcement. release.yml: gate job verifies tag against
versions.go/docker-compose/Cargo/CHANGELOG, forward-only and on public,
before anything builds; web UI staged into the embed path (gitignored
dist made a bare go build ship an empty dashboard); binaries and docker
image must self-report the tag; release created via Gitea's own API.
scripts/release.sh is the operator path: checks runner, secret, branch,
versions, changelog — asks before every mutation, watches the run after.
bump-version.sh gains current-version display, dirty-tree warning,
duplicate check, changelog check, confirmation. build-secure-agent.sh
retired (bare go build, no version injection, single Makefile caller).
Old binary drops a marker (command_id, from/to) once the swap is
committed, on both the helper path and the legacy path. New binary
checks it at startup: running >= target just clears the marker
(check-in confirm still owns success); short of target means the swap
failed or rolled back, so it files a failed update_agent report under
the original command_id and the server clears is_updating right away
instead of sitting out the stuck-update timeout. Marker survives
failed reports for retry, drops on 409 or after 24h.
GetTopProcesses(limit) reads /proc/[pid]/stat and /proc/[pid]/status
directly on Linux — no subprocess spawn, ~30ms for 5 processes.
Windows uses tasklist CSV, macOS uses ps aux.
Wired into reportSystemInfo via metadata[top_processes] — flows
through the existing merge path, no server schema change needed.
UI already reads agent.metadata.top_processes and renders the table.
Added tickCount to the polling loop for future N-tick throttling
(heartbeat-level reporting cadence).
Test output confirms real data:
1. Isolated Web Co (pid=10319) cpu=4.5% mem=3.4%
2. firefox (pid=4090) cpu=3.2% mem=4.6%
3. qs (pid=3611) cpu=1.6% mem=1.7%
FEAT-003 core (design: RAF/security/06-standalone-authority.md, approved
2026-06-10). On a host with no fleet server, the trust boundary preserved is
root-vs-unprivileged: a root-owned 0600 Ed25519 key signs capability tokens
via a new privileged helper invocation; redflag-local membership lets you
request a mint, never perform one.
Helper gains the mint subcommand: validates forward-only ops, mintable-type
allowlist (no agent-self), host agent-id bind, closure shape (64-hex sha256
fail-closed), hard-coded 15-minute gate-evidence freshness with future-dating
rejection, override-reason requirement for vulnerable/unreachable/overridden
verdicts, duplicate request_id dedupe, journal-before-emission. --init-key /
--retire-key manage the authority lifecycle (retire = the fleet-join swap).
Deny taxonomy 22-25. Round-trip test proves a minted token passes the execute
path's own verification and parses as the wire CapabilityToken.
Agent gains POST /v1/actions/approve-update (single-flight, 409/503 mapping):
fleet-mode refusal, dnf/apt dry-run closure resolve + hash pin (no pin, no
mint), best-effort OSV.dev closure check with honest verdicts (unreachable is
never silent-clear), mint via sudo systemd-run mirroring the execute grant,
then the unchanged verify+execute path. Provisioning script sets up the
journal dir (root:redflag-local 2750 setgid), mint request dir, key init, and
the pinned mint sudoers line.
Server becomes self-contained: web/dist embedded via go:embed
(server/internal/webui), SPA served from the binary with JSON-404 guard on
/api paths, nginx web container removed from compose (31336 now maps to the
server). Clean checkouts without the UI copy build API-only.
Agent local API gains its first write endpoint, POST /v1/actions/trigger-scan
(FEAT-002 write path): group-ACL authorized, single-flight, 202/409/503
semantics. Registered agents run the same HandleScanUpdates path as a signed
scan command (empty command_id, no ack tracking); standalone agents scan
through the orchestrator into the local read model only. Also repairs
localapi tests left uncompilable by the desktop-provider parameter.
UI: move screenshot/Sunshine square into the System Information card
header (top-right, w-48 aspect-video) instead of a standalone block
above the card. Same click logic, smaller size to fit the header row.
Agent: captureScreenLinux now discovers DISPLAY/WAYLAND_DISPLAY/
XDG_RUNTIME_DIR from /proc environ entries so the service (which
doesn't inherit display vars from systemd) can reach the session.
Tool priority: scrot → grim → magick import → import.
Adds bytes/strconv/strings imports for discoverSessionDisplayEnv.
Three layered causes:
1. config_v5_migration had no executor phase — structurally impossible
to mark complete.
2. StateManager.loadConfig unmarshaled into typed config.Config, but
install template writes version as JSON number while Config types
it as string — migration completion never persisted to disk.
3. readConfigVersion only parsed float64, so normalized string read 0
and re-triggered.
Fix: StateManager is map-based (immune to field-type drift). Executor
has real config_v5 phase (bump + mark complete). parseConfigVersion
accepts both number and string. validateMigration uses MkdirAll then
stat (fixes false 'state dir not found' on Windows).
Service:
- Build complete LoopContext in Windows service runAgent (was missing
ReceiptTracker, ConfirmedTracker, scanners, circuit breakers,
kernel enforcer — nil ReceiptTracker caused immediate panic).
- Wire process logger so log.Printf writes to agent.log on Windows.
- Parse CPU info JSON from PowerShell fallback (wmic absent on
modern Windows; old code tried CSV parse on JSON output).
Local state cache:
- Agent writes status, scan results, capability token state to
local disk cache. Enables local observability without a server
round-trip. Handlers expose /local/state, /local/status endpoints.
- Record agent status (online/backoff) and capability token metrics
to local cache during polling loop.
observe-only: the agent folds detected integrations into its system-info
report under metadata.integrations; the dashboard renders what it reports.
nothing reaches into the host — an integration can be watched, not commanded.
three more off the scale list:
- rate-limit map now gets swept on a cadence (taskrunner.Every) instead of
growing forever — nobody was calling the cleanup. first old ticker moved
onto the runner
- subsystem load was one db query per agent at startup; now it's a single
ANY($1) for all the online ones. 100 agents, 1 query
- outbound http clients (osv, registries, upstream, agent) were inheriting
the stock transport that keeps 2 idle conns per host — so every scan burst
re-dialed. shared tuned transport now, 10 per host, 90s idle
builds clean both modules.
Treats each ecosystem scan as the authoritative full set for that
(agent, ecosystem) pair. Packages absent from a successful scan that
are still in a waiting state (pending/approved) are closed to installed
with out-of-band provenance — no operator action required.
State machine:
- Added pending/approved → installed edges (out-of-band resolution path)
- Added installed → pending edge (reactivation when a new version reappears)
- ReconcileFromScan updated to match: installed now reopens, ignored/failed preserved
Server (ReportUpdates):
- closeScanAbsentRows goroutine: diff waiting rows against reported set,
transition absent rows via transitionStatus (guarded UPDATE, idempotent)
- Provenance stamping: redflag_receipt if a consumed capability token exists,
out_of_band otherwise
- System event emitted per closure for audit trail
- scanEcosystemSupported gate: dnf/apt only; failed/partial scans never close rows
Agent:
- UpdateReport extended with Ecosystem + ScanSucceeded fields
- APT/DNF scan handlers now always report on successful scan (even 0 updates)
- HandleScanAPT/DNF/Updates: report failure is non-fatal (transport problem,
scan succeeded locally)
Queries:
- GetTrackedNonResting: scoped to pending/approved only — in-flight states
(checking_dependencies, pending_dependencies, installing) are orchestrator-owned
- TransitionByID: routes closure through the state machine
- HasConsumedTokenForUpdate: provenance check for the reconciler
- UpdateCurrentStateInTx SQL CASE: installed now reopens to pending on re-scan
Tests: reconcile_test.go (5 unit tests including load-bearing
TestWaitingStatesResolveOutOfBand), reconcile_test.go handler tests (7 sub-tests).
Bump: v0.2.6.1
Server: mintAgentSelfToken includes helper in closure, sends
helper_download_url + helper_checksum in command params.
Agent: downloads and stages both binaries, passes --helper-file to helper.
Helper: parses --helper-file, separates closure into agent+helper entries,
self-updates helper binary first, then installs agent. Falls back to
agent-only if closure has 1 entry (backward compatible).
- consumer.go: safeTokenFilename() blocks path traversal via token ID
- consumer.go: TOCTOU sanity check on result token_id
- main.rs: result file written 0640 (was 0644)
- linux.sh.tmpl: sudoers wildcards restricted to tokens/* and results/*
- linux.sh.tmpl: polkit scoped to manage-transient-units
- agent_update.go: clean up pending-upgrade.bin on failure
- updates.go: clear is_updating flag on failed update_agent
- bump 0.2.3.7
We kept claiming self-update worked. On a clean box it didn't.
- linux.sh.tmpl: install a polkit rule so the service user can invoke the
helper via systemd-run. Without it every gated install and self-update
hit auth_admin and died on a TTY-less service.
- self-update: drop the post-update .bak sweep. It ran unprivileged against
a root-owned backup and could only ever log permission-denied. The helper
already keeps .bak as the single rollback slot.
- metrics/docker reports: stop finalizing the command at ingest. It raced
ReportLog and 409'd the history-bearing log, silently dropping system and
docker scans from History. ReportLog is the sole finalize point now, same
as dnf/storage.
- google/uuid -> gofrs/uuid/v5 across server + agent
- windows.go: cross-platform binding cleanup
- linux install template: disable sudo lecture for TTY-less service user
- README: XZ/SolarWinds lede, stable-release note, single attack-surface block
delivery plumbing that got us there:
- acks clear on result-recorded, not command lifecycle status (no more 34-deep recycling)
- timeouts, cancels, dropped acks/receipts, failed actions all land in history instead of dying on stdout
- one shared closure-cleared predicate so auto-confirm and manual approve can't drift
override waives the vuln call only and gets journaled; signing and hash verification stay non-negotiable.
dnf DryRun: --assumeno cancels the transaction, so DNF exits non-zero
even on a dry run that resolved cleanly — the old check failed those
(curl-style single-package upgrades with no extra deps showed FAILED).
But non-empty stdout is not success either: 'No match for argument',
'Nothing to do', and 'Error:' all print output and exit non-zero, and
treating them as success would mint a capability token for a transaction
that never installs (fail-open). Gate on an actually-resolved
transaction (a 'Transaction Summary' block, which never coexists with
'Nothing to do') instead.
web logout: clear the zustand persist key (auth-storage) alongside
auth_token and user, so a JWT from a prior server reinstall (JWT_SECRET
rotation) does not survive a logout + re-login cycle. Drop the redundant
localStorage removal in Layout — the store owns logout cleanup.
When ErrRefreshTokenInvalid or ErrMachineMismatch fires, the agent now
waits 10 minutes between poll attempts instead of exponential backoff.
These are permanent states — no amount of retrying fixes a dead
refresh token or a machine_id mismatch. The agent stays alive and
visible, waiting for operator intervention through the dashboard.