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.jsonprovides the service→host map (action targets).pending_changesprovides the approval lifecycle to mirror.- Little Blue today = the read-only health band UI +
littleblue_avatar.js. Nolittle-blueagent seeded yet.
Decisions (locked)
- Tiered actions:
safe→ execute directly + audit;risky→ queue for owner approval, then execute. - 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.
- One brick: action framework + manual UI + conversational Little Blue together.
- Whitelist =
config/actions.json(version-controlled, immutable at runtime) — the single source of truth. - Dedicated
agent_actionsqueue (notpending_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_restart → safe; guest start → safe; guest stop/shutdown/reboot → risky. 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}) withAuthorization: PVEAPIToken=.... Env:PROXMOX_API_URL,PROXMOX_API_TOKEN. Token scoped server-side toVM.PowerMgmton whitelisted guests only.ssh.js:restart(host, actionId)→ spawnsssh -i <key> -o BatchMode=yes voidact@<host-ip> <actionId>. The host'sauthorized_keyspinscommand="/usr/local/bin/void-act"(forced). The wrapper holds its OWN whitelist copy, readsSSH_ORIGINAL_COMMAND(the action id), maps it tosystemctl 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 }.risky→agentActions.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, orstatus:'failed'+error; audit.rejectAction(rowId, owner):status:'rejected'; audit.execute(action): dispatch onkind→proxmox.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/run→runAction(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-blueagent (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)→ callsservice.runAction(safe runs + reports; risky queues + tells the owner it needs approval). She only passes ids; never commands. companion-stdio.jsalready selects registry byVOID_TOOL_REGISTRY; addblue→blueRegistry.- Persona (
personas/index.jskeylittle-blue): the small blue water-creature caretaker who keeps the lab alive — warm, protective, practical; callslist_actions/service_statusbefore proposing; explains what she'll do and that risky fixes wait for the owner's nod. - Chat surface:
#/little-blueview (reusesagent_chat,toolLabelsfor 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/validatesactions.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_actionsrepo: create/listPending/resolve claim-once.- routes: run (safe/risky), pending list, approve/reject (owner-gated).
- Little Blue route:
propose_actionsafe 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.