feat(agentapi): surface agent disk-op refusal reasons; v0.101.0 + CHANGELOG/REPORT

EjectDisk/Decommission switched from c.post (drops non-2xx body) to
postWithStatus + shared refusalError, so the agent's informative 403 body
("…decommission refused (role: X)") reaches the operator instead of a bare
"HTTP 403" (campaign F2 evidence gap). Generic post + other callers untouched.
Tests T-D1/T-D2/T-D3 + ok:false case; T-D1 red-proof shows the pre-fix bare
"HTTP 403". Bundles the v0.101.0 CHANGELOG entry (this + the F3 sync deadline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-06 14:11:44 +02:00
parent fc28033fe8
commit c997d79246
4 changed files with 211 additions and 47 deletions
+39 -4
View File
@@ -461,13 +461,18 @@ type EjectResult struct {
}
// EjectDisk safe-unmounts a host mount (data preserved) and returns the dependent guests.
// Status-aware POST (campaign F2 evidence gap): the agent's refusal body carries the reason
// (e.g. "…eject refused (role: system)") — surface it instead of a bare "HTTP 403".
func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, error) {
var out EjectResult
body, err := c.post(ctx, "/disks/eject", map[string]string{"where": where})
env, status, err := c.postWithStatus(ctx, "/disks/eject", map[string]string{"where": where})
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
if err := refusalError("/disks/eject", status, env); err != nil {
return out, err
}
if err := json.Unmarshal(env.Data, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /disks/eject: %w", err)
}
return out, nil
@@ -483,18 +488,48 @@ type DecommissionResult struct {
// Decommission permanently removes a user-data drive (self-serve, non-destructive — the agent records
// IntentDecommissioned, prunes the bind record, and unmounts; it NEVER formats). Data stays on the
// drive. The agent role-gates to user-data and refuses a system/backup mount regardless.
// Status-aware POST (campaign F2 evidence gap): the agent's refusal body carries the reason
// (e.g. "…decommission refused (role: system)") — surface it instead of a bare "HTTP 403".
func (c *Client) Decommission(ctx context.Context, where string) (DecommissionResult, error) {
var out DecommissionResult
body, err := c.post(ctx, "/disks/decommission", map[string]string{"where": where})
env, status, err := c.postWithStatus(ctx, "/disks/decommission", map[string]string{"where": where})
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
if err := refusalError("/disks/decommission", status, env); err != nil {
return out, err
}
if err := json.Unmarshal(env.Data, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /disks/decommission: %w", err)
}
return out, nil
}
// refusalError converts a non-2xx status or an ok:false envelope into an error that CARRIES the
// agent's reason (truncated; never request bodies or secrets). nil on an accepted 200/202+ok=true.
func refusalError(path string, status int, env apiResponse) error {
accepted := status == http.StatusOK || status == http.StatusAccepted
if accepted && env.OK {
return nil
}
reason := truncateErr(env.Error, 300)
if reason == "" {
reason = "(no reason in agent response)"
}
if accepted { // 2xx but ok:false — business refusal without an HTTP error code
return fmt.Errorf("agentapi: POST %s: %s", path, reason)
}
return fmt.Errorf("agentapi: POST %s: HTTP %d: %s", path, status, reason)
}
// truncateErr mirrors stacks.truncateStr for agent refusal reasons.
func truncateErr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// 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.