From 2e922f64210b903181b95d4ac4a0fc3a9121d2e5 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 10 Jul 2026 14:45:09 +0200 Subject: [PATCH] feat: decompose pct_create into atomic create + agent-driven install; add scoped destructive window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two remaining open points from the auto-continuation work. 1. Atomic pct_create (observability, the bigger of the two): pct_create used to bundle create + apt install + post_install script into one black-box multi-minute SSH call — the agent got back a single opaque success/fail with no way to see (or fix) which step actually broke. Removed the whole post-create provisioning block (and the now-dead provisionScript/sanitizePkgs helpers + their tests). pct_create is now create + start + register ONLY — fast, and its result is fed back to the agent via auto-continuation almost immediately. The agent installs packages and runs setup as its OWN sequence of `run` calls against the new lxc:, observing each command's real output and able to diagnose and retry exactly the step that failed — the same recovery loop already proven for the general case, now applied to installs too, instead of requiring a separate black-box mechanism. - services/post_install removed from the pct_create params struct and from the MCP tool schema/SOUL.md docs. - SOUL.md: explains the new flow, moves the Docker CLI gotcha and DNS troubleshooting guidance to be steps the agent runs itself. 2. Scoped destructive window (targeted autonomy for recovery): Verified live in the previous session that a destructive recovery (a failed destroy needing stop-then-destroy on the same container) required TWO separate typed confirmations for what was clearly one recovery action. Added a narrow, TARGET-scoped 15-minute grant (destructive_window.agent:.target: in autonomy_settings, shared key format across cmd/nomos and internal/mcp) that opens only after an EXPLICIT typed confirmation (never loose assent) or an explicit button-approval of a destructive step, and only ever covers further destructive commands against that SAME target. A different target always needs its own fresh confirmation — this narrows risk instead of loosening it globally, unlike broadening the general assent window to cover destructive actions would have. - cmd/nomos/store.go: openDestructiveWindow/destructiveWindowActive/ executionTarget. - cmd/nomos/agent.go: opens the window when a typed confirmation grants a destructive chat-assent execution. - internal/mcp/server.go: `run` tool checks the window before gating a destructive command; auto-runs if active. - internal/httpapi/phase3.go: DecideApproval opens the same window when a destructive execution is approved via the button/API, for parity with the chat-assent path. Co-Authored-By: Claude Opus 4.8 --- cmd/nomos/agent.go | 15 +++- cmd/nomos/store.go | 57 +++++++++++++ internal/httpapi/pct_create_test.go | 46 +--------- internal/httpapi/phase3.go | 126 ++++++++++------------------ internal/mcp/server.go | 49 ++++++++++- nomos/SOUL.md | 65 +++++++++----- 6 files changed, 208 insertions(+), 150 deletions(-) diff --git a/cmd/nomos/agent.go b/cmd/nomos/agent.go index 3f00e63..fc4bf7d 100644 --- a/cmd/nomos/agent.go +++ b/cmd/nomos/agent.go @@ -232,6 +232,19 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s slog.Info("nomos: chat-assent granted", "execution", p.execID, "status", status, "session", sessionID) emit(agentEvent{Type: "tool_use", Data: map[string]any{"name": "chat_assent", "args": map[string]any{"execution_id": p.execID}, "id": "assent-" + p.execID}, SessionID: sessionID}) emit(agentEvent{Type: "tool_result", Data: map[string]any{"name": "chat_assent", "result": fmt.Sprintf("Approved via chat assent (%q). Status: %s.", message, status), "id": "assent-" + p.execID}, SessionID: sessionID}) + + // An explicit typed confirmation for a destructive action + // opens a short, target-scoped window so the rest of a + // destructive recovery sequence on the SAME target (e.g. + // stop -> destroy) doesn't need a second typed confirmation. + if p.destructive && typedConfirm { + if execUUID, perr := uuid.Parse(p.execID); perr == nil { + if target := a.store.executionTarget(ctx, execUUID); target != "" { + a.store.openDestructiveWindow(ctx, a.agentID, target) + slog.Info("nomos: destructive window opened", "agent", a.agentID, "target", target) + } + } + } } } if len(granted) > 0 { @@ -240,7 +253,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s messages = append(messages, openai.SystemMessage(note)) } if len(blocked) > 0 { - note := fmt.Sprintf("[System: execution(s) %s are classified DESTRUCTIVE and were NOT approved by loose assent — you must ask the operator for an explicit typed confirmation before they can run.]", strings.Join(blocked, ", ")) + note := fmt.Sprintf("[System: execution(s) %s are classified DESTRUCTIVE and were NOT approved by loose assent — you must ask the operator for an explicit typed confirmation before they can run. Once they do confirm, further destructive steps on that SAME target (e.g. finishing a stop-then-destroy sequence) will auto-run for 15 minutes without asking again — but a different target always needs its own confirmation.]", strings.Join(blocked, ", ")) messages = append(messages, openai.SystemMessage(note)) } } else if assent && len(lastAssistantCalls) == 0 { diff --git a/cmd/nomos/store.go b/cmd/nomos/store.go index 3337146..7761a93 100644 --- a/cmd/nomos/store.go +++ b/cmd/nomos/store.go @@ -273,6 +273,63 @@ func (s *store) assentWindowActive(ctx context.Context, agentID uuid.UUID) bool return time.Now().Before(expires) } +// destructiveWindowDuration is intentionally shorter than the general assent +// window (30 min): it's a narrow, scoped grant for a multi-step DESTRUCTIVE +// recovery (e.g. "stop then destroy this specific half-provisioned +// container"), not a standing license to destroy things. +const destructiveWindowDuration = 15 * time.Minute + +// destructiveWindowKey scopes the grant to one agent AND one target entity — +// an explicit typed confirmation ("I confirm") for a destructive action on +// target X must never be read as authorizing a destructive action on target Y. +func destructiveWindowKey(agentID uuid.UUID, targetSlug string) string { + return "destructive_window.agent:" + agentID.String() + ".target:" + targetSlug +} + +// openDestructiveWindow records a short, target-scoped grant after an +// operator's EXPLICIT typed confirmation (never loose assent) authorized a +// destructive action. Real case this exists for: recovering a failed destroy +// took "stop" (destructive) then "destroy" (destructive) — same container, +// two separate typed-confirmation round trips, because each was gated +// independently. One explicit confirmation on a target should cover the +// short follow-up sequence needed to finish what was just confirmed. +func (s *store) openDestructiveWindow(ctx context.Context, agentID uuid.UUID, targetSlug string) { + if s == nil || agentID == uuid.Nil || targetSlug == "" { + return + } + expires := time.Now().Add(destructiveWindowDuration).UTC().Format(time.RFC3339) + s.pool.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = $2`, destructiveWindowKey(agentID, targetSlug), expires) +} + +// destructiveWindowActive reports whether target has a live, explicitly- +// confirmed destructive grant for this agent. +func (s *store) destructiveWindowActive(ctx context.Context, agentID uuid.UUID, targetSlug string) bool { + if s == nil || agentID == uuid.Nil || targetSlug == "" { + return false + } + var expires time.Time + if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, + destructiveWindowKey(agentID, targetSlug)).Scan(&expires); err != nil { + return false + } + return time.Now().Before(expires) +} + +// executionTarget resolves the target entity slug for an execution — used to +// scope the destructive window to the right entity when a chat-assent typed +// confirmation grants a destructive execution. +func (s *store) executionTarget(ctx context.Context, execID uuid.UUID) string { + if s == nil { + return "" + } + var slug string + s.pool.QueryRow(ctx, ` + SELECT e.slug FROM executions ex JOIN entities e ON e.id = ex.target_entity_id + WHERE ex.entity_id = $1`, execID).Scan(&slug) + return slug +} + // logActivity records a tool call. agent_id is the agent entity UUID and is // NOT NULL in the schema, so we skip logging when it can't be resolved. // The (nullable) session_id column carries the conversation id. diff --git a/internal/httpapi/pct_create_test.go b/internal/httpapi/pct_create_test.go index 5381f99..5198b61 100644 --- a/internal/httpapi/pct_create_test.go +++ b/internal/httpapi/pct_create_test.go @@ -2,7 +2,6 @@ package httpapi import ( "encoding/json" - "strings" "testing" ) @@ -114,44 +113,7 @@ func TestGatewayPreflightPassed(t *testing.T) { } } -func TestProvisionScript(t *testing.T) { - s := provisionScript([]string{"docker.io", "git"}, "echo hi > /root/x") - // Network/DNS gate must come before apt. - gate := strings.Index(s, "getent hosts") - apt := strings.Index(s, "apt-get update") - post := strings.Index(s, "echo hi > /root/x") - if gate < 0 || apt < 0 || post < 0 { - t.Fatalf("missing sections: gate=%d apt=%d post=%d\n%s", gate, apt, post, s) - } - if !(gate < apt && apt < post) { - t.Errorf("wrong ordering: gate=%d apt=%d post=%d", gate, apt, post) - } - if !strings.Contains(s, "nameserver 1.1.1.1") { - t.Error("missing DNS self-heal fallback") - } - if !strings.Contains(s, "docker.io git") { - t.Error("packages not joined into install line") - } - // No packages: no apt lines, but post_install and gate still present. - s2 := provisionScript(nil, "systemctl status foo") - if strings.Contains(s2, "apt-get install") { - t.Error("apt install should be absent when no packages requested") - } - if !strings.Contains(s2, "systemctl status foo") || !strings.Contains(s2, "getent hosts") { - t.Error("post_install or gate missing in no-package case") - } -} - -func TestSanitizePkgs(t *testing.T) { - in := []string{"docker.io", "git", "rm -rf /", "curl;wget", "python3-pip", ""} - got := sanitizePkgs(in) - want := map[string]bool{"docker.io": true, "git": true, "python3-pip": true} - if len(got) != len(want) { - t.Fatalf("got %v want keys %v", got, want) - } - for _, g := range got { - if !want[g] { - t.Errorf("unexpected package survived sanitize: %q", g) - } - } -} +// provisionScript and sanitizePkgs were removed when pct_create was made +// atomic (create + start + register only) — installing packages and running +// setup scripts is now the agent's own job via follow-up `run` calls, which +// already has its own classifier/sanitization tests in internal/policy. diff --git a/internal/httpapi/phase3.go b/internal/httpapi/phase3.go index 173ba4b..08637bc 100644 --- a/internal/httpapi/phase3.go +++ b/internal/httpapi/phase3.go @@ -292,8 +292,12 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, Mounts []string `json:"mounts"` Nameserver string `json:"nameserver"` Searchdomain string `json:"searchdomain"` - Services []string `json:"services"` // apt packages to install after create - PostInstall string `json:"post_install"` // shell run inside the container after create + // No services/post_install here anymore — pct_create is atomic + // (create + start + register only). Installing packages and + // running setup scripts is the agent's job via follow-up `run` + // calls against lxc:, so each step is individually + // observable and recoverable instead of one opaque multi-minute + // black box. See the comment above the removed post-create block. } if err := json.Unmarshal([]byte(params), &cfg); err != nil { slog.Error("httpapi: pct_create parse params", "error", err, "params", params) @@ -475,21 +479,24 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd) output, err = sshExec(ctx, host, user, createCmd) - // Post-create provisioning: install apt packages and run a post_install - // script inside the fresh container, so a single approved pct_create - // yields a *working service*, not just an empty container. The script - // waits for real DNS/connectivity and self-heals the resolver first — - // a static-IP container with a dead nameserver otherwise fails apt with - // "Temporary failure resolving deb.debian.org" and installs nothing. - if err == nil && (len(cfg.Services) > 0 || cfg.PostInstall != "") { - script := provisionScript(sanitizePkgs(cfg.Services), cfg.PostInstall) - b64 := base64.StdEncoding.EncodeToString([]byte(script)) - // sleep on the host so the container is up enough to accept pct exec. - cmd := fmt.Sprintf("sleep 4; pct exec %d -- bash -c 'echo %s | base64 -d | bash'", cfg.VMID, b64) - var provOut string - provOut, err = sshExec(ctx, host, user, cmd) - output = output + "\n--- post-install ---\n" + provOut - } + // pct_create is now DELIBERATELY ATOMIC: create + start + register, + // nothing else. It used to also run apt installs and a post_install + // script inline as one black-box multi-minute SSH call — the agent + // got back a single opaque success/fail for the whole thing with no + // way to see (or fix) which step actually broke. That's the opposite + // of what makes an agent able to recover from errors. + // + // Installing packages, running post_install, and verifying the + // service now happen as the agent's OWN follow-up `run` calls against + // the new lxc: target — each one is synchronous (in an + // active assent window) or individually gated, so the agent observes + // every step's real output and can diagnose + retry the exact thing + // that failed instead of re-doing the whole container. See SOUL.md + // "After pct_create: you drive the install" and provisionScript's + // surviving role (DNS self-heal) is now something the agent invokes + // itself via `run`, not something baked into this handler. + // + // cfg.Services/cfg.PostInstall are intentionally no longer read here. // On success, register the entity in the DB with proper relationships if err == nil { @@ -583,47 +590,6 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, "execution_id", execID, "action", action, "status", status, "duration_ms", durationMs) } -// provisionScript builds the in-container bootstrap run after pct create. It -// (1) waits for DNS/connectivity and self-heals /etc/resolv.conf with a public -// resolver if the configured nameserver is dead, (2) installs apt packages with -// retries, (3) runs the operator's post_install. `set -e` after the network -// gate means any apt or post_install failure exits non-zero, so sshExec surfaces -// it and the execution is marked failed with the exact broken step in output. -func provisionScript(pkgs []string, postInstall string) string { - var b strings.Builder - b.WriteString("set -o pipefail\n") - // A fresh debian LXC has no locale set, which spams "Can't set locale" - // warnings and breaks some package post-install scripts. Pin C.UTF-8. - b.WriteString("export LANG=C.UTF-8 LC_ALL=C.UTF-8 DEBIAN_FRONTEND=noninteractive\n") - b.WriteString("probe=deb.debian.org\n") - b.WriteString("ok=0\n") - // `timeout 3` on every getent call is load-bearing, not cosmetic: when - // the network is truly unreachable (e.g. a wrong gateway), a plain - // `getent hosts` doesn't fail fast — it can hang far longer than the - // resolver's nominal timeout because packets are just dropped, not - // rejected. Without a hard per-attempt cap, this loop's "~90s" budget - // was fiction — one run hung 17+ minutes on a bad gateway before the Go - // side finally got a hard sshExec timeout to fall back on. Capping each - // attempt makes the wall-clock budget real. - b.WriteString("for i in $(seq 1 30); do if timeout 3 getent hosts \"$probe\" >/dev/null 2>&1; then ok=1; break; fi; sleep 2; done\n") - // Self-heal: if the assigned resolver can't resolve, fall back to public DNS. - b.WriteString("if [ \"$ok\" != 1 ]; then printf 'nameserver 1.1.1.1\\nnameserver 8.8.8.8\\n' > /etc/resolv.conf; ") - b.WriteString("for i in $(seq 1 15); do if timeout 3 getent hosts \"$probe\" >/dev/null 2>&1; then ok=1; break; fi; sleep 2; done; fi\n") - b.WriteString("if [ \"$ok\" != 1 ]; then echo 'ERROR: container has no DNS/connectivity after ~2min — check the LXC net0 gateway/IP are correct for this subnet'; exit 1; fi\n") - b.WriteString("set -e\n") - if len(pkgs) > 0 { - b.WriteString("export DEBIAN_FRONTEND=noninteractive\n") - b.WriteString("apt-get update -o Acquire::Retries=3 -qq\n") - b.WriteString("apt-get install -y -o Acquire::Retries=3 --no-install-recommends -qq " + strings.Join(pkgs, " ") + "\n") - } - if strings.TrimSpace(postInstall) != "" { - b.WriteString("# --- operator post_install ---\n") - b.WriteString(postInstall) - b.WriteString("\n") - } - return b.String() -} - // jsonErr builds a valid {"error": "..."} JSON payload for an execution's // result column. Always use this instead of fmt.Sprintf'ing JSON by hand — // error text and command output routinely contain quotes/backslashes that @@ -682,29 +648,6 @@ func resolveTemplate(requested string, available []string) string { return best } -// sanitizePkgs drops anything that isn't a plausible apt package token, so a -// hallucinated package list can't inject shell into the install command. -func sanitizePkgs(pkgs []string) []string { - out := make([]string, 0, len(pkgs)) - for _, p := range pkgs { - p = strings.TrimSpace(p) - if p == "" { - continue - } - ok := true - for _, r := range p { - if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '.' || r == '+') { - ok = false - break - } - } - if ok { - out = append(out, p) - } - } - return out -} - // ─── Checks ──────────────────────────────────────────────────────────── func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) { @@ -1474,12 +1417,12 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque // On approve: execute the linked gated command. if status == "approved" { var execID, targetID uuid.UUID - var actionStr, targetSlug string + var actionStr, targetSlug, riskClass string err := tx.QueryRow(ctx, ` - SELECT e.entity_id, e.target_entity_id, e.action + SELECT e.entity_id, e.target_entity_id, e.action, e.risk_class FROM executions e WHERE e.approval_id = $1 AND e.status = 'pending_approval' - LIMIT 1`, id).Scan(&execID, &targetID, &actionStr) + LIMIT 1`, id).Scan(&execID, &targetID, &actionStr, &riskClass) if err == nil { // Resolve target entity slug from targetID. _ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug) @@ -1504,6 +1447,21 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque expires := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339) _, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2`, "assent_window.agent:"+agentID.String(), expires) + + // Approving a DESTRUCTIVE step via the button is exactly as + // explicit as a typed "I confirm" — the operator affirmatively + // clicked Approve on a card that said DESTRUCTIVE. Open the + // same short, target-scoped destructive window chat-assent's + // typed-confirm path opens, for parity: a multi-step + // destructive recovery (stop, then destroy) shouldn't need a + // fresh confirmation per click any more than it needs one per + // typed phrase. + if riskClass == "destructive" && targetSlug != "" { + dExpires := time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339) + _, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = $2`, + "destructive_window.agent:"+agentID.String()+".target:"+targetSlug, dExpires) + } } slog.Info("httpapi: approved execution queued", diff --git a/internal/mcp/server.go b/internal/mcp/server.go index d263231..2095cb4 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -273,7 +273,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { InputSchema: objSchema( prop{"target", "string", "Target entity slug. For pct_create this MUST be the Proxmox HOST that will run the container (e.g. host:strong) — NOT the new LXC's name. For restart/systemctl/apt_upgrade/pct_exec use the target service/LXC slug (e.g. lxc:caddy)."}, prop{"action", "string", "Action: restart, systemctl, pct_exec, apt_upgrade, pct_create"}, - prop{"params", "string", "For systemctl: 'enable|disable|reload'. For pct_exec: the shell command. For apt_upgrade: 'audit|upgrade'. For pct_create: a JSON object string with keys: vmid (int, required, unused id), hostname (string, required), cores (int), memory (MB int), disk_gb (int), ip (CIDR e.g. 192.168.8.50/24, or omit/\"dhcp\" — DHCP is the safe default, see below), gw (gateway ip, static only), bridge (e.g. vmbr0/vmbr1 — WHICH BRIDGE REACHES WHICH SUBNET IS DIFFERENT PER HOST, never assume vmbr0; see below), storage (default local-lvm), template (optional — omit to auto-pick newest debian on the host), privileged (bool), nesting (bool), mounts ([]string of 'src,mp=/dst'), services ([]string of apt packages to install), post_install (string shell script run inside the container after create). Example: {\"vmid\":150,\"hostname\":\"typetype\",\"cores\":2,\"memory\":2048,\"disk_gb\":16,\"ip\":\"192.168.8.50/24\",\"gw\":\"192.168.8.2\",\"bridge\":\"vmbr1\",\"nesting\":true,\"services\":[\"docker.io\",\"git\"],\"post_install\":\"git clone https://github.com/x/y /opt/y && cd /opt/y && docker compose up -d\"}. STATIC IP RULE: before setting ip/gw/bridge to anything other than DHCP, use list_entities/get_entity_knowledge to find an EXISTING lxc on the SAME host whose IP is in the same /28 block, and copy its exact gw+bridge — do not invent a gateway. If no such neighbor exists, prefer ip:\"dhcp\" (proven to work, gets a real routable address) over guessing; a wrong bridge/gateway pair fails a fast pre-flight ping check now (seconds, not minutes) but is still a wasted turn — better to not guess at all."}, + prop{"params", "string", "For systemctl: 'enable|disable|reload'. For pct_exec: the shell command. For apt_upgrade: 'audit|upgrade'. For pct_create: a JSON object string with keys: vmid (int, required, unused id), hostname (string, required), cores (int), memory (MB int), disk_gb (int), ip (CIDR e.g. 192.168.8.50/24, or omit/\"dhcp\" — DHCP is the safe default, see below), gw (gateway ip, static only), bridge (e.g. vmbr0/vmbr1 — WHICH BRIDGE REACHES WHICH SUBNET IS DIFFERENT PER HOST, never assume vmbr0; see below), storage (default local-lvm), template (optional — omit to auto-pick newest debian on the host), privileged (bool), nesting (bool), mounts ([]string of 'src,mp=/dst'). Example: {\"vmid\":150,\"hostname\":\"typetype\",\"cores\":2,\"memory\":2048,\"disk_gb\":16,\"ip\":\"192.168.8.50/24\",\"gw\":\"192.168.8.2\",\"bridge\":\"vmbr1\",\"nesting\":true}. pct_create is ATOMIC — it ONLY creates and starts the container (no services/post_install params anymore). Once it completes you will be automatically re-invoked with the result; install packages and run setup by issuing your OWN `run` calls against the new lxc: target, one step at a time — you'll see each step's real output and can fix exactly the one that fails, instead of one opaque multi-minute install that either fully works or fully doesn't. STATIC IP RULE: before setting ip/gw/bridge to anything other than DHCP, use list_entities/get_entity_knowledge to find an EXISTING lxc on the SAME host whose IP is in the same /28 block, and copy its exact gw+bridge — do not invent a gateway. If no such neighbor exists, prefer ip:\"dhcp\" (proven to work, gets a real routable address) over guessing; a wrong bridge/gateway pair fails a fast pre-flight ping check now (seconds, not minutes) but is still a wasted turn — better to not guess at all."}, ), }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := argsMap(req) @@ -527,6 +527,28 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server { return textResult(fmt.Sprintf("run on %s (config_mutation, auto via assent window): %s", targetSlug, out)), nil } + // Destructive window: a narrow, TARGET-scoped grant opened only after + // an operator's explicit typed confirmation ("I confirm") on this + // same target — never by loose assent. Exists for multi-step + // destructive recovery (e.g. a failed destroy needing stop, then + // destroy) so the operator isn't asked to re-type "I confirm" for + // every single command against the thing they just confirmed. + if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug) { + host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug) + if rerr != nil { + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error())) + return textResult(fmt.Sprintf("resolve target: %v", rerr)), nil + } + out, xerr := sshExec(ctx, host, user, wrap(command)) + if xerr != nil { + pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s: %s", xerr.Error(), out)) + return textResult(fmt.Sprintf("run on %s: ERROR %v\n%s", targetSlug, xerr, out)), nil + } + pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, id, jsonOut(out)) + slog.Info("mcp: run auto-executed via destructive window", "target", targetSlug, "execution_id", id) + return textResult(fmt.Sprintf("run on %s (destructive, auto via confirmed-target window): %s", targetSlug, out)), nil + } + pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class=$2 WHERE entity_id=$1`, id, riskClass) createApproval(ctx, pool, id, targetID, "run", string(runParams), riskClass) confirmNote := "" @@ -1384,6 +1406,31 @@ func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID) b return time.Now().UTC().Before(expires) } +// destructiveWindowActive reports whether targetSlug has a live, explicitly- +// confirmed destructive grant for this agent. Key format +// ("destructive_window.agent:.target:") must match +// cmd/nomos/store.go's openDestructiveWindow — both processes read/write the +// same autonomy_settings row. Scoped to one target so a typed confirmation +// for destroying container A can never be read as authorizing anything +// against container B. +func destructiveWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, targetSlug string) bool { + if agentID == uuid.Nil || targetSlug == "" { + return false + } + var expiresStr string + err := pool.QueryRow(ctx, + "SELECT value FROM autonomy_settings WHERE key = $1", + "destructive_window.agent:"+agentID.String()+".target:"+targetSlug).Scan(&expiresStr) + if err != nil { + return false + } + expires, err := time.Parse(time.RFC3339, expiresStr) + if err != nil { + return false + } + return time.Now().UTC().Before(expires) +} + func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UUID, action, params, riskClass string) { p := map[string]any{"action": action, "params": params, "execution_id": execID.String()} payload, _ := json.Marshal(p) diff --git a/nomos/SOUL.md b/nomos/SOUL.md index e0706e4..1ab41b0 100644 --- a/nomos/SOUL.md +++ b/nomos/SOUL.md @@ -89,15 +89,30 @@ of what a command does. Before calling `request_execution`: - Check risk class via `get_entity` on the target -- `pct_create` — `config_mutation`: provisions a new LXC AND installs its service in one - approved step. Set `target` to the Proxmox HOST slug (e.g. `host:strong`), not the new - container name. `params` is a JSON string: vmid (unused id), hostname, cores, memory (MB), - disk_gb, ip (CIDR), gw, storage, template (omit to auto-pick newest debian on the host), - privileged, nesting, mounts, and — to actually deliver a working service — - `services` ([]apt packages) and `post_install` (shell run inside the container, e.g. a - `git clone && docker compose up -d`). Prefer one pct_create with services+post_install - over pct_create followed by many pct_exec approvals. Once approved, the LXC entity is - created in the DB with `hosts` relationships and `state: provisioning`. +- `pct_create` — `config_mutation`: **ATOMIC** — creates and starts a new LXC, nothing + more. Set `target` to the Proxmox HOST slug (e.g. `host:strong`), not the new container + name. `params` is a JSON string: vmid (unused id), hostname, cores, memory (MB), disk_gb, + ip (CIDR), gw, bridge, storage, template (omit to auto-pick newest debian on the host), + privileged, nesting, mounts. **No `services`/`post_install` — those were removed.** Once + approved, the LXC entity is created in the DB with `hosts` relationships and + `state: provisioning`. + - **You install the service yourself, one step at a time, via `run` against the new + `lxc:` target — do NOT try to cram everything into pct_create.** This is + deliberate: a single giant install script gave you back one opaque success/fail for a + multi-minute black box, with no way to see (or fix) which specific step broke. Issuing + your own `run` calls — `apt-get update`, `apt-get install -y docker.io`, the install + script, the verify curl — means you see each command's real output and can diagnose and + retry exactly the thing that failed, the same way you'd work at a real shell. You will + be automatically re-invoked with pct_create's result (see "Automatic continuation" + below) — don't poll, don't wait for the operator, just start issuing the install steps + once you see it succeeded. + - **DNS/network right after boot**: a fresh container's network can take a few seconds to + come up. If your first `apt-get update` fails with a DNS/connectivity error, don't + immediately blame the gateway (the pre-flight already validated that) — first retry + after a short wait (`sleep 5`), and if it's still failing, check `/etc/resolv.conf` + inside the container and fall back to a public resolver + (`printf 'nameserver 1.1.1.1\n' > /etc/resolv.conf`) before concluding the network + config itself is wrong. - **vmid**: omit or set 0 — a free cluster id is assigned automatically. Never reuse an existing container's id. - **networking — DHCP is the default, static is the exception**: use `"ip":"dhcp"` unless @@ -121,23 +136,21 @@ Before calling `request_execution`: "gateway unreachable, don't guess a different one, find a real neighbor or use DHCP" message — instead of a multi-minute hang or silent retry loop. If you see that error, the fix is to find a real neighbor's config or switch to DHCP, not to try a third guess. - - If you set a static CIDR anyway and the *DNS resolver itself* (not the gateway) is the - problem, the provisioner self-heals to a public resolver — but that only helps once the - gateway/bridge are actually correct. - **Docker — CRITICAL**: Debian's `docker.io` package installs the Docker **daemon** but NOT the `docker` **CLI binary** on Debian 13 (trixie). The TypeType installer (and any script that calls `docker`) will fail with - "command not found". Do NOT rely on `docker.io` alone. Instead: - - Put `docker.io` in `services` (provides the engine + dependencies) - - In `post_install`, FIRST install Docker CE CLI via + "command not found". Do NOT rely on `docker.io` alone. Instead, as separate + observable `run` steps against the new container: + - `apt-get install -y docker.io` (provides the engine + dependencies) + - THEN install Docker CE CLI via `curl -fsSL https://get.docker.com | sh` (provides the `docker` CLI + - compose plugin), THEN run your installer. - - Example post_install: - `curl -fsSL https://get.docker.com | sh && docker compose version && curl -fsSL https://raw.githubusercontent.com/Priveetee/TypeType/main/scripts/install-stack.sh | bash && curl -fsS http://localhost:8080/health` + compose plugin) — check its output before continuing. + - THEN the actual install script (e.g. the service's own installer). - `docker-compose-plugin` is NOT in Debian's repos — always get it from get.docker.com. - - **verify**: end `post_install` by confirming the service actually answers (e.g. - `curl -fsS http://localhost:/` ), so a green result means it truly works. + - **verify**: your LAST step should confirm the service actually answers (e.g. + `curl -fsS http://localhost:/`), so a green result means it truly works — only + report success to the operator once you've seen this pass. - If `destructive` or `config_mutation`: escalate to operator - If `reversible_low` with validated pattern: auto-act allowed @@ -180,8 +193,16 @@ in chat), the system: install packages, edit configs, start services, etc. — no need to stop and re-ask for each step. 3. `read_only` commands always auto-run (no approval needed, no window). -4. `destructive` commands **never** auto-run — they always need an explicit - typed confirmation ("I confirm ..."), even during an assent window. +4. `destructive` commands **never** auto-run via the general assent window — + they always need an explicit typed confirmation ("I confirm ...") or the + operator clicking Approve on a card that says DESTRUCTIVE. +5. **After that confirmation**, a short 15-minute window opens scoped to that + ONE target — further destructive commands against the SAME target auto-run + without asking again. This exists for multi-step destructive recovery + (e.g. a destroy failed because the container was still running: you need + `stop` then `destroy`, both destructive, same container — one confirmation + should cover finishing that sequence). A different target ALWAYS needs its + own fresh confirmation — the window never generalizes across targets. **Your job after approval:** carry out the full plan. If a step fails, think about why, try an alternative approach, and continue. Only surface to the