v0.44.0: role-aware drive management — protected lockout + customer type-to-confirm wipe + drive-list restyle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:44:50 +02:00
parent 2c32c821fe
commit 12064dcd88
13 changed files with 696 additions and 182 deletions
+53 -20
View File
@@ -184,8 +184,15 @@ type DiskInfo struct {
BackingDevice string `json:"backing_device"`
MountPath string `json:"mount_path"`
Class string `json:"class"`
DataBearing bool `json:"data_bearing"`
DataReason string `json:"data_reason"`
// Role is the agent's AUTHORITATIVE protection tier: "system" | "backup" | "user-data". The UI
// is driven from it — system/backup get a lock badge and NO destructive controls; user-data is
// customer-manageable (eject/wipe with type-to-confirm).
Role string `json:"role"`
DataBearing bool `json:"data_bearing"`
DataReason string `json:"data_reason"`
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
UsedFraction float64 `json:"used_fraction"`
// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The
// fs UUID (strip the "uuid:" prefix) is the key the controller passes to AssignDisk — it's the
// only way the de-privileged controller learns a mount key it cannot read off the device itself.
@@ -209,12 +216,19 @@ type DisksResponse struct {
// FormatResult mirrors POST /disks/format (the success/refusal payload).
type FormatResult struct {
VMID int `json:"vmid"`
Device string `json:"device"`
Formatted bool `json:"formatted"`
DataBearing bool `json:"data_bearing"`
Reason string `json:"reason"`
PendingOp *PendingOp `json:"pending_op,omitempty"`
VMID int `json:"vmid"`
Device string `json:"device"`
Formatted bool `json:"formatted"`
DataBearing bool `json:"data_bearing"`
Reason string `json:"reason"`
// Role is the agent's tier for this device (system | backup | user-data).
Role string `json:"role,omitempty"`
// NeedsConfirmation is set on a USER-DATA data-bearing refusal: re-submit with confirmed=true +
// DurableID after the type-to-confirm UI (NOT an operator signature).
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
DurableID string `json:"durable_id,omitempty"`
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
PendingOp *PendingOp `json:"pending_op,omitempty"`
}
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
@@ -231,9 +245,14 @@ func (p PendingOp) OpsignCommand() string {
return fmt.Sprintf("felhom-opsign -op %s -host %s -durable-id %s", p.Op, p.HostScope, p.DurableID)
}
// ErrFormatRefused is returned by FormatDisk when the agent refuses a data-bearing format
// (pending operator authorization — the 8C invariant). The UI surfaces this distinctly.
var ErrFormatRefused = fmt.Errorf("agentapi: format refused — device is data-bearing (operator authorization required)")
// ErrFormatRefused is returned by FormatDisk when the agent refuses a data-bearing format on a
// SYSTEM/BACKUP device (operator signature required). The UI surfaces the pending opsign command.
var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup device (operator authorization required)")
// ErrNeedsConfirmation is returned by FormatDisk when the agent refuses a data-bearing format on a
// USER-DATA device pending the CUSTOMER's informed-confirmation (bound to FormatResult.DurableID).
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
// Disks lists the host drives the agent manages, with a data-bearing flag per drive.
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
@@ -276,14 +295,22 @@ func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, erro
return out, nil
}
// FormatDisk asks the agent to format a device. The AGENT inspects the device and decides
// data-bearing-ness — a data-bearing device is refused (ErrFormatRefused), the controller's claim
// is irrelevant. Only a device the agent reads as blank is formatted.
func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatResult, error) {
// FormatDisk asks the agent to format a device. The AGENT inspects the device and tiers it by ROLE
// (its own classification, never the controller's claim):
// - blank device → formatted.
// - user-data, data-bearing, NOT confirmed → ErrNeedsConfirmation (out.DurableID = the id to confirm).
// - user-data, data-bearing, confirmed + matching durable id → formatted.
// - system/backup, data-bearing → ErrFormatRefused (out.PendingOp = the operator opsign command).
//
// confirmed + durableID authorize a user-data wipe (the durable id the agent gave on the prior
// ErrNeedsConfirmation); they are inert for system/backup.
func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (FormatResult, error) {
var out FormatResult
// Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op) even on the
// 403 refusal, so we must read the body on non-2xx rather than discarding it.
data, status, err := c.postWithStatus(ctx, "/disks/format", map[string]string{"device": device, "fstype": fstype})
// Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op / durable_id)
// even on the 403 refusal, so we must read the body on non-2xx rather than discarding it.
data, status, err := c.postWithStatus(ctx, "/disks/format", map[string]any{
"device": device, "fstype": fstype, "confirmed": confirmed, "durable_id": durableID,
})
if err != nil {
return out, err
}
@@ -291,10 +318,16 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatR
if len(data) > 0 {
_ = json.Unmarshal(data, &out) // best-effort; fields default on a missing/partial body
}
if out.Formatted {
return out, nil
}
if out.NeedsConfirmation {
out.DataBearing = true
return out, ErrNeedsConfirmation // user-data: surface the type-to-confirm flow
}
if status == http.StatusForbidden || (out.DataBearing && !out.Formatted) {
out.DataBearing = true
out.Formatted = false
return out, ErrFormatRefused // carries PendingOp for the caller to surface the opsign command
return out, ErrFormatRefused // system/backup: surface the opsign command
}
return out, nil
}