slice 8C Phase A: agent disk endpoints + data-bearing classifier gate + mkfs (v0.12.0)

internal/storage: mkfs executor (Format, device-pinned, narrow FELHOM_FORMAT
sudoers) + data-bearing device inspection (InspectDevice/DeviceProbe via
blkid+lsblk; conservative — ambiguous=data-bearing). internal/localapi: /disks
(+ data-bearing flag), /disks/assign (EnsureMount), /disks/eject (Unmount +
dependent guests), /disks/format. SECURITY CENTERPIECE: the agent inspects the
device itself; data-bearing format -> ClassStorageWipe gate -> pending_signature
refused; the caller's claim is never trusted. Additive (no controller change yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 12:52:22 +02:00
parent 4d9e76e66a
commit c17cfde236
10 changed files with 1074 additions and 10 deletions
+197
View File
@@ -2,6 +2,7 @@ package storage
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
@@ -34,6 +35,55 @@ type HostOps interface {
// ThinPoolMetadata returns the lvmthin pool's metadata-used fraction (0..1) via lvs.
// ok=false when it cannot be read (the field stays null in the report).
ThinPoolMetadata(ctx context.Context, vg, pool string) (fraction float64, ok bool)
// InspectDevice probes a block device for data-bearing evidence (filesystem signature,
// partition table, partitions, mounted) — the AGENT-INTERNAL evidence the 8C classifier
// uses, NEVER the caller's claim. Conservative: a failed/ambiguous probe → DataBearing()
// true (fail-safe). This is the read that decides whether a format is benign or destructive.
InspectDevice(ctx context.Context, device string) (DeviceProbe, error)
// Format runs mkfs.<fstype> on a (validated) device. DESTRUCTIVE to whatever is on the
// device — the caller MUST have classified it non-data-bearing AND/OR routed it through the
// gate first; HostOps only performs an already-authorized format.
Format(ctx context.Context, device, fstype string) error
}
// DeviceProbe is the result of inspecting a block device for data-bearing evidence (8C). The
// agent decides data-bearing-ness from THIS (its own device read), never from the caller's claim.
type DeviceProbe struct {
Device string `json:"device"`
Probed bool `json:"probed"` // false = the probe failed/was ambiguous → treat as data-bearing
HasFilesystem bool `json:"has_filesystem"` // a filesystem signature (blkid TYPE / USAGE)
HasPartitionTable bool `json:"has_partition_table"` // a partition table (blkid PTTYPE)
HasPartitions bool `json:"has_partitions"` // child partitions present (lsblk)
Mounted bool `json:"mounted"` // currently mounted somewhere
FSType string `json:"fstype,omitempty"`
}
// DataBearing is the conservative verdict: any signature / partition table / partition / mount —
// OR a probe that did not complete cleanly — makes the device data-bearing. Only a device that
// probed cleanly AND shows none of those is considered blank (benign to format).
func (p DeviceProbe) DataBearing() bool {
if !p.Probed {
return true // fail-safe: never call an unprobed device blank
}
return p.HasFilesystem || p.HasPartitionTable || p.HasPartitions || p.Mounted
}
// Reason returns a short human string for why the device is data-bearing (for the UI/audit).
func (p DeviceProbe) Reason() string {
switch {
case !p.Probed:
return "device could not be reliably inspected"
case p.Mounted:
return "device is mounted"
case p.HasFilesystem:
return "device has a " + p.FSType + " filesystem"
case p.HasPartitionTable:
return "device has a partition table"
case p.HasPartitions:
return "device has partitions"
default:
return "device is blank"
}
}
// MountSpec describes a persistent by-UUID mount.
@@ -52,6 +102,10 @@ type Binaries struct {
Install string
Smartctl string
Lvs string
Blkid string // device signature probe (8C data-bearing detection)
Lsblk string // partition/mount topology (8C)
MkfsExt4 string // 8C format executor (ext4)
MkfsXfs string // 8C format executor (xfs)
}
func (b Binaries) withDefaults() Binaries {
@@ -67,6 +121,18 @@ func (b Binaries) withDefaults() Binaries {
if b.Lvs == "" {
b.Lvs = "/usr/sbin/lvs"
}
if b.Blkid == "" {
b.Blkid = "/usr/sbin/blkid"
}
if b.Lsblk == "" {
b.Lsblk = "/usr/bin/lsblk"
}
if b.MkfsExt4 == "" {
b.MkfsExt4 = "/usr/sbin/mkfs.ext4"
}
if b.MkfsXfs == "" {
b.MkfsXfs = "/usr/sbin/mkfs.xfs"
}
return b
}
@@ -210,6 +276,87 @@ func (h *SudoHostOps) ThinPoolMetadata(ctx context.Context, vg, pool string) (fl
return parseThinPoolMetadata(out)
}
// InspectDevice probes a device for data-bearing evidence (8C). It runs `blkid -p -o export`
// (the reliable signature probe) for filesystem/partition-table signatures and `lsblk -J` for
// child partitions + mount state. The verdict defaults to data-bearing on ANY read failure
// (Probed=false), so a compromised caller cannot get a data-bearing device declared blank.
func (h *SudoHostOps) InspectDevice(ctx context.Context, device string) (DeviceProbe, error) {
if err := ValidateBlockDevice(device); err != nil {
return DeviceProbe{Device: device}, err // Probed=false → DataBearing()=true
}
probe := DeviceProbe{Device: device}
// blkid -p -o export is the authoritative on-disk SIGNATURE probe. Its OUTPUT is the signal:
// any TYPE/PTTYPE/USAGE line is positive data-bearing evidence. Its exit code is NOT relied
// on (blkid exits 2 on a blank device) — output presence is what matters. A broken/empty
// blkid simply adds no positive evidence; lsblk (below) is the read-success authority.
bout, _, _ := h.runner.Run(ctx, h.bins.Blkid, "-p", "-o", "export", device)
for k, v := range parseBlkidExport(bout) {
switch k {
case "TYPE":
probe.HasFilesystem = true
probe.FSType = v
case "PTTYPE":
probe.HasPartitionTable = true
case "USAGE":
if v != "" {
probe.HasFilesystem = true // filesystem/raid/crypto member = data-bearing
}
}
}
// lsblk -J is the READ-SUCCESS authority + the partition/mount view. It exits 0 on any valid
// device (blank or not), so a clean parse means the agent reliably read the device. If lsblk
// fails, Probed stays false → DataBearing()=true (fail-safe — never call a device blank on a
// failed read).
lout, _, lerr := h.runner.Run(ctx, h.bins.Lsblk, "-J", "-o", "NAME,FSTYPE,PTTYPE,MOUNTPOINT", device)
if lerr == nil {
probe.Probed = true
hasChildren, mounted, fstype, pttype := parseLsblkDevice(lout)
if hasChildren {
probe.HasPartitions = true
}
if mounted {
probe.Mounted = true
}
if fstype != "" {
probe.HasFilesystem = true
if probe.FSType == "" {
probe.FSType = fstype
}
}
if pttype != "" {
probe.HasPartitionTable = true
}
}
return probe, nil
}
// Format runs mkfs.<fstype> on a validated device. The caller is responsible for authorization
// (8C: only after classifying the device non-data-bearing, or via a slice-10 operator signature).
func (h *SudoHostOps) Format(ctx context.Context, device, fstype string) error {
if err := ValidateBlockDevice(device); err != nil {
return err
}
if err := ValidateFSType(fstype); err != nil {
return err
}
switch fstype {
case "ext4":
if err := h.run(ctx, h.bins.MkfsExt4, "-F", device); err != nil {
return fmt.Errorf("storage: mkfs.ext4 %s: %w", device, err)
}
case "xfs":
if err := h.run(ctx, h.bins.MkfsXfs, "-f", device); err != nil {
return fmt.Errorf("storage: mkfs.xfs %s: %w", device, err)
}
default:
return fmt.Errorf("storage: unsupported fstype %q", fstype) // unreachable after Validate
}
h.logger.Info("storage: formatted device", "device", device, "fstype", fstype)
return nil
}
// run execs an allow-listed command with a fixed arg vector and wraps a nonzero exit.
func (h *SudoHostOps) run(ctx context.Context, name string, args ...string) error {
_, stderr, err := h.runner.Run(ctx, name, args...)
@@ -239,6 +386,48 @@ func trim(b []byte) string {
return s
}
// parseBlkidExport parses `blkid -p -o export` output (KEY=value lines) into a map.
func parseBlkidExport(out []byte) map[string]string {
m := map[string]string{}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if i := strings.IndexByte(line, '='); i > 0 {
m[line[:i]] = line[i+1:]
}
}
return m
}
// lsblkDevice mirrors the `lsblk -J` device shape (only the fields we read).
type lsblkDevice struct {
Name string `json:"name"`
FSType string `json:"fstype"`
PTType string `json:"pttype"`
MountPoint string `json:"mountpoint"`
Children []lsblkDevice `json:"children"`
}
// parseLsblkDevice parses `lsblk -J -o NAME,FSTYPE,PTTYPE,MOUNTPOINT <device>` for the top device:
// whether it has child partitions, is mounted (itself or any child), and its fstype/pttype.
func parseLsblkDevice(out []byte) (hasChildren, mounted bool, fstype, pttype string) {
var doc struct {
BlockDevices []lsblkDevice `json:"blockdevices"`
}
if json.Unmarshal(out, &doc) != nil || len(doc.BlockDevices) == 0 {
return false, false, "", ""
}
d := doc.BlockDevices[0]
fstype, pttype = d.FSType, d.PTType
hasChildren = len(d.Children) > 0
mounted = d.MountPoint != ""
for _, c := range d.Children {
if c.MountPoint != "" {
mounted = true
}
}
return hasChildren, mounted, fstype, pttype
}
// NoopHostOps is the safe fallback when the privileged surface is unavailable or declined
// (a missing sudoers entry must degrade with a clear warning, not crash — slice notes). It
// reports SMART as UNKNOWN, no thin-pool metadata, and errors on any write (so a benign
@@ -257,3 +446,11 @@ func (n NoopHostOps) SMART(context.Context, string) (hub.SmartSummary, error) {
func (n NoopHostOps) ThinPoolMetadata(context.Context, string, string) (float64, bool) {
return 0, false
}
func (n NoopHostOps) InspectDevice(_ context.Context, device string) (DeviceProbe, error) {
// Probed=false → DataBearing()=true: with no privileged surface we MUST NOT call any device
// blank (fail-safe — a format would then be refused as destructive).
return DeviceProbe{Device: device}, nil
}
func (n NoopHostOps) Format(context.Context, string, string) error {
return fmt.Errorf("storage: privileged HostOps not configured; cannot format")
}