Files
Void-Homelab/docs/superpowers/specs/2026-06-04-little-blue-design.md
2026-06-04 21:33:01 +10:00

7.6 KiB

Little Blue — Design

Date: 2026-06-04 · Component: Void 2.0 · Phase: Plan 7 (Agent Layer), brick 2 · Status: Approved (design)

Goal

Give Little Blue, the homelab caretaker agent, the ability to fix things — restart lab services and power-manage Proxmox guests — through a least-privilege, tiered-approval, fully-audited action framework. The LLM is the weakest link by design: it can only name an action id from a fixed, version-controlled whitelist; it never constructs commands.

Background

  • Void app runs on CT 311 (.216) and today has no execution access to any host (SSH to Z = Permission denied; no Proxmox token).
  • Shared agent-chat foundation exists (run_turn.js, agent_chat.js, personas/) — Little Blue reuses it like Yerin did.
  • config/services.json provides the service→host map (action targets). pending_changes provides the approval lifecycle to mirror.
  • Little Blue today = the read-only health band UI + littleblue_avatar.js. No little-blue agent seeded yet.

Decisions (locked)

  1. Tiered actions: safe → execute directly + audit; risky → queue for owner approval, then execute.
  2. Two execution channels: scoped Proxmox API token for guest power; SSH forced-command wrapper for in-guest service restarts. Both enforce the whitelist server-side.
  3. One brick: action framework + manual UI + conversational Little Blue together.
  4. Whitelist = config/actions.json (version-controlled, immutable at runtime) — the single source of truth.
  5. Dedicated agent_actions queue (not pending_changes) — isolate command execution from entity-CRUD apply.

Architecture

1. Action whitelist — config/actions.json

Array of action defs. Nothing outside this file is runnable.

{ "id": "restart-caddy-ct100", "label": "Restart Caddy on mediastack", "kind": "service_restart", "host": "ct100", "service": "caddy", "tier": "safe" }
{ "id": "stop-ct107",          "label": "Stop iVentoy (CT 107)",       "kind": "guest_power",    "node": "z", "vmid": 107, "op": "stop", "tier": "risky" }

Tier convention: service_restartsafe; guest startsafe; guest stop/shutdown/rebootrisky. The tier is explicit per action (config wins).

lib/actions/registry.js: loadActions() (read + validate config), getAction(id), listActions(). Validation rejects unknown kind/op/tier at load.

2. Execution channels — lib/actions/channels/

  • proxmox.js: power(node, vmid, op) → Proxmox REST (POST /nodes/{node}/lxc|qemu/{vmid}/status/{op}) with Authorization: PVEAPIToken=.... Env: PROXMOX_API_URL, PROXMOX_API_TOKEN. Token scoped server-side to VM.PowerMgmt on whitelisted guests only.
  • ssh.js: restart(host, actionId) → spawns ssh -i <key> -o BatchMode=yes voidact@<host-ip> <actionId>. The host's authorized_keys pins command="/usr/local/bin/void-act" (forced). The wrapper holds its OWN whitelist copy, reads SSH_ORIGINAL_COMMAND (the action id), maps it to systemctl restart <service>, and refuses anything else. The Void can send only an id; the host decides what runs.
  • Both adapters take an injectable transport (fetch / spawn) for tests; they NEVER interpolate a raw shell command from caller input.

3. Action service — lib/actions/service.js

  • runAction(id, actor): getAction(id) (404 if absent). safe → execute via channel now, audit, return { executed:true, result }. riskyagentActions.create({...status:'pending'}), return { queued:true, action_row_id }. No execution for risky.
  • approveAction(rowId, owner): claim pending row → execute via channel → status:'executed'+result, or status:'failed'+error; audit.
  • rejectAction(rowId, owner): status:'rejected'; audit.
  • execute(action): dispatch on kindproxmox.power / ssh.restart. Single choke point; audits every call.

4. agent_actions table — new migration 016_agent_actions.sql

id uuid pk, action_id text, params jsonb, agent_id uuid null, tier text, status text default 'pending' (pending|executed|failed|rejected), result jsonb, requested_by jsonb, resolved_by jsonb, created_at, resolved_at. Repo lib/db/repos/agent_actions.js: create, listPending, getById, resolve(id,status,result,by) (claims WHERE status='pending'), recent(limit).

5. API — lib/api/routes/actions.js (owner-gated, mounted at /api/actions)

  • GET / → whitelist (+ optional live guest/service status later).
  • POST /:id/runrunAction (safe executes, risky queues).
  • GET /pending → queued risky actions.
  • POST /pending/:rowId/approve | /pending/:rowId/reject.
  • GET /recent → recent executed/failed (audit view).

6. Little Blue — agent + tools + chat

  • Migration seeds little-blue agent (kind:'claude', capabilities { read:true, act:true }).
  • MCP blueRegistry (lib/ai/agent/tools/blue/): service_status (read health/registry), list_actions (the whitelist she may use), propose_action(action_id) → calls service.runAction (safe runs + reports; risky queues + tells the owner it needs approval). She only passes ids; never commands.
  • companion-stdio.js already selects registry by VOID_TOOL_REGISTRY; add blueblueRegistry.
  • Persona (personas/index.js key little-blue): the small blue water-creature caretaker who keeps the lab alive — warm, protective, practical; calls list_actions/service_status before proposing; explains what she'll do and that risky fixes wait for the owner's nod.
  • Chat surface: #/little-blue view (reuses agent_chat, toolLabels for blue tools) + a manual Actions panel: whitelisted actions with Run buttons (risky → creates an approval card), and a pending-actions queue with Approve/Reject. Sidebar nav entry.

Safety model

Least-privilege server-side on both channels (SSH forced-command wrapper + scoped PVE token) → even a fully compromised Void only triggers whitelisted actions. Tiered approval gates destructive ops. Full audit of request→approve→execute→result. The whitelist lives in version control, not a runtime-editable store. No code path constructs a shell command from agent/user free text.

Provisioning (deploy-time, owner-authorized)

Create the scoped Proxmox API token (VM.PowerMgmt on chosen guests); generate the Void's SSH keypair; deploy /usr/local/bin/void-act wrapper + restricted authorized_keys (command=, no-port-forwarding,no-pty) on each target host; populate config/actions.json with the real action set; set PROXMOX_API_URL/PROXMOX_API_TOKEN + SSH key path in the app .env. Done interactively at the deploy task.

Testing (vitest + supertest, serial)

  • registry: loads/validates actions.json; rejects bad defs.
  • channels/proxmox: mocked fetch → asserts correct URL/op/token header; never called for non-whitelisted.
  • channels/ssh: mocked spawn → asserts argv is [..., 'voidact@<ip>', '<action-id>'] with no shell string; rejects ids with shell metachars.
  • service: safe → executes via stub channel; risky → queues (no channel call); approve → executes; reject → no execution; all audited.
  • agent_actions repo: create/listPending/resolve claim-once.
  • routes: run (safe/risky), pending list, approve/reject (owner-gated).
  • Little Blue route: propose_action safe runs + reports; risky queues; uses fake-claude fixture. Frontend: manual.

Out of scope (YAGNI / later)

Live guest/service status polling in the actions list; runtime-editable whitelist; arbitrary-command tool; multi-step remediation playbooks; Little Blue acting on a schedule/cron (that's the scheduled-agent track). SSH wrapper supports only service_restart; Proxmox token only VM.PowerMgmt.