feat: decompose pct_create into atomic create + agent-driven install; add scoped destructive window
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:<hostname>, 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:<id>.target:<slug> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
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_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})
|
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 {
|
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))
|
messages = append(messages, openai.SystemMessage(note))
|
||||||
}
|
}
|
||||||
if len(blocked) > 0 {
|
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))
|
messages = append(messages, openai.SystemMessage(note))
|
||||||
}
|
}
|
||||||
} else if assent && len(lastAssistantCalls) == 0 {
|
} else if assent && len(lastAssistantCalls) == 0 {
|
||||||
|
|||||||
@@ -273,6 +273,63 @@ func (s *store) assentWindowActive(ctx context.Context, agentID uuid.UUID) bool
|
|||||||
return time.Now().Before(expires)
|
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
|
// 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.
|
// NOT NULL in the schema, so we skip logging when it can't be resolved.
|
||||||
// The (nullable) session_id column carries the conversation id.
|
// The (nullable) session_id column carries the conversation id.
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,44 +113,7 @@ func TestGatewayPreflightPassed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvisionScript(t *testing.T) {
|
// provisionScript and sanitizePkgs were removed when pct_create was made
|
||||||
s := provisionScript([]string{"docker.io", "git"}, "echo hi > /root/x")
|
// atomic (create + start + register only) — installing packages and running
|
||||||
// Network/DNS gate must come before apt.
|
// setup scripts is now the agent's own job via follow-up `run` calls, which
|
||||||
gate := strings.Index(s, "getent hosts")
|
// already has its own classifier/sanitization tests in internal/policy.
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -292,8 +292,12 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
|
|||||||
Mounts []string `json:"mounts"`
|
Mounts []string `json:"mounts"`
|
||||||
Nameserver string `json:"nameserver"`
|
Nameserver string `json:"nameserver"`
|
||||||
Searchdomain string `json:"searchdomain"`
|
Searchdomain string `json:"searchdomain"`
|
||||||
Services []string `json:"services"` // apt packages to install after create
|
// No services/post_install here anymore — pct_create is atomic
|
||||||
PostInstall string `json:"post_install"` // shell run inside the container after create
|
// (create + start + register only). Installing packages and
|
||||||
|
// running setup scripts is the agent's job via follow-up `run`
|
||||||
|
// calls against lxc:<hostname>, 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 {
|
if err := json.Unmarshal([]byte(params), &cfg); err != nil {
|
||||||
slog.Error("httpapi: pct_create parse params", "error", err, "params", params)
|
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)
|
slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd)
|
||||||
output, err = sshExec(ctx, host, user, createCmd)
|
output, err = sshExec(ctx, host, user, createCmd)
|
||||||
|
|
||||||
// Post-create provisioning: install apt packages and run a post_install
|
// pct_create is now DELIBERATELY ATOMIC: create + start + register,
|
||||||
// script inside the fresh container, so a single approved pct_create
|
// nothing else. It used to also run apt installs and a post_install
|
||||||
// yields a *working service*, not just an empty container. The script
|
// script inline as one black-box multi-minute SSH call — the agent
|
||||||
// waits for real DNS/connectivity and self-heals the resolver first —
|
// got back a single opaque success/fail for the whole thing with no
|
||||||
// a static-IP container with a dead nameserver otherwise fails apt with
|
// way to see (or fix) which step actually broke. That's the opposite
|
||||||
// "Temporary failure resolving deb.debian.org" and installs nothing.
|
// of what makes an agent able to recover from errors.
|
||||||
if err == nil && (len(cfg.Services) > 0 || cfg.PostInstall != "") {
|
//
|
||||||
script := provisionScript(sanitizePkgs(cfg.Services), cfg.PostInstall)
|
// Installing packages, running post_install, and verifying the
|
||||||
b64 := base64.StdEncoding.EncodeToString([]byte(script))
|
// service now happen as the agent's OWN follow-up `run` calls against
|
||||||
// sleep on the host so the container is up enough to accept pct exec.
|
// the new lxc:<hostname> target — each one is synchronous (in an
|
||||||
cmd := fmt.Sprintf("sleep 4; pct exec %d -- bash -c 'echo %s | base64 -d | bash'", cfg.VMID, b64)
|
// active assent window) or individually gated, so the agent observes
|
||||||
var provOut string
|
// every step's real output and can diagnose + retry the exact thing
|
||||||
provOut, err = sshExec(ctx, host, user, cmd)
|
// that failed instead of re-doing the whole container. See SOUL.md
|
||||||
output = output + "\n--- post-install ---\n" + provOut
|
// "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
|
// On success, register the entity in the DB with proper relationships
|
||||||
if err == nil {
|
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)
|
"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
|
// jsonErr builds a valid {"error": "..."} JSON payload for an execution's
|
||||||
// result column. Always use this instead of fmt.Sprintf'ing JSON by hand —
|
// result column. Always use this instead of fmt.Sprintf'ing JSON by hand —
|
||||||
// error text and command output routinely contain quotes/backslashes that
|
// error text and command output routinely contain quotes/backslashes that
|
||||||
@@ -682,29 +648,6 @@ func resolveTemplate(requested string, available []string) string {
|
|||||||
return best
|
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 ────────────────────────────────────────────────────────────
|
// ─── Checks ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {
|
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.
|
// On approve: execute the linked gated command.
|
||||||
if status == "approved" {
|
if status == "approved" {
|
||||||
var execID, targetID uuid.UUID
|
var execID, targetID uuid.UUID
|
||||||
var actionStr, targetSlug string
|
var actionStr, targetSlug, riskClass string
|
||||||
err := tx.QueryRow(ctx, `
|
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
|
FROM executions e
|
||||||
WHERE e.approval_id = $1 AND e.status = 'pending_approval'
|
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 {
|
if err == nil {
|
||||||
// Resolve target entity slug from targetID.
|
// Resolve target entity slug from targetID.
|
||||||
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
|
_ = 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)
|
expires := time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)
|
||||||
_, _ = tx.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
|
_, _ = 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)
|
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",
|
slog.Info("httpapi: approved execution queued",
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
InputSchema: objSchema(
|
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{"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{"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:<hostname> 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) {
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
args := argsMap(req)
|
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
|
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)
|
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)
|
createApproval(ctx, pool, id, targetID, "run", string(runParams), riskClass)
|
||||||
confirmNote := ""
|
confirmNote := ""
|
||||||
@@ -1384,6 +1406,31 @@ func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID) b
|
|||||||
return time.Now().UTC().Before(expires)
|
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:<id>.target:<slug>") 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) {
|
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()}
|
p := map[string]any{"action": action, "params": params, "execution_id": execID.String()}
|
||||||
payload, _ := json.Marshal(p)
|
payload, _ := json.Marshal(p)
|
||||||
|
|||||||
@@ -89,15 +89,30 @@ of what a command does.
|
|||||||
|
|
||||||
Before calling `request_execution`:
|
Before calling `request_execution`:
|
||||||
- Check risk class via `get_entity` on the target
|
- Check risk class via `get_entity` on the target
|
||||||
- `pct_create` — `config_mutation`: provisions a new LXC AND installs its service in one
|
- `pct_create` — `config_mutation`: **ATOMIC** — creates and starts a new LXC, nothing
|
||||||
approved step. Set `target` to the Proxmox HOST slug (e.g. `host:strong`), not the new
|
more. Set `target` to the Proxmox HOST slug (e.g. `host:strong`), not the new container
|
||||||
container name. `params` is a JSON string: vmid (unused id), hostname, cores, memory (MB),
|
name. `params` is a JSON string: vmid (unused id), hostname, cores, memory (MB), disk_gb,
|
||||||
disk_gb, ip (CIDR), gw, storage, template (omit to auto-pick newest debian on the host),
|
ip (CIDR), gw, bridge, storage, template (omit to auto-pick newest debian on the host),
|
||||||
privileged, nesting, mounts, and — to actually deliver a working service —
|
privileged, nesting, mounts. **No `services`/`post_install` — those were removed.** Once
|
||||||
`services` ([]apt packages) and `post_install` (shell run inside the container, e.g. a
|
approved, the LXC entity is created in the DB with `hosts` relationships and
|
||||||
`git clone && docker compose up -d`). Prefer one pct_create with services+post_install
|
`state: provisioning`.
|
||||||
over pct_create followed by many pct_exec approvals. Once approved, the LXC entity is
|
- **You install the service yourself, one step at a time, via `run` against the new
|
||||||
created in the DB with `hosts` relationships and `state: provisioning`.
|
`lxc:<hostname>` 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
|
- **vmid**: omit or set 0 — a free cluster id is assigned automatically. Never reuse an
|
||||||
existing container's id.
|
existing container's id.
|
||||||
- **networking — DHCP is the default, static is the exception**: use `"ip":"dhcp"` unless
|
- **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"
|
"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,
|
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.
|
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
|
- **Docker — CRITICAL**: Debian's `docker.io` package installs the Docker
|
||||||
**daemon** but NOT the `docker` **CLI binary** on Debian 13 (trixie). The
|
**daemon** but NOT the `docker` **CLI binary** on Debian 13 (trixie). The
|
||||||
TypeType installer (and any script that calls `docker`) will fail with
|
TypeType installer (and any script that calls `docker`) will fail with
|
||||||
"command not found". Do NOT rely on `docker.io` alone. Instead:
|
"command not found". Do NOT rely on `docker.io` alone. Instead, as separate
|
||||||
- Put `docker.io` in `services` (provides the engine + dependencies)
|
observable `run` steps against the new container:
|
||||||
- In `post_install`, FIRST install Docker CE CLI via
|
- `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 +
|
`curl -fsSL https://get.docker.com | sh` (provides the `docker` CLI +
|
||||||
compose plugin), THEN run your installer.
|
compose plugin) — check its output before continuing.
|
||||||
- Example post_install:
|
- THEN the actual install script (e.g. the service's own installer).
|
||||||
`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`
|
|
||||||
- `docker-compose-plugin` is NOT in Debian's repos — always get it from
|
- `docker-compose-plugin` is NOT in Debian's repos — always get it from
|
||||||
get.docker.com.
|
get.docker.com.
|
||||||
- **verify**: end `post_install` by confirming the service actually answers (e.g.
|
- **verify**: your LAST step should confirm the service actually answers (e.g.
|
||||||
`curl -fsS http://localhost:<port>/` ), so a green result means it truly works.
|
`curl -fsS http://localhost:<port>/`), 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 `destructive` or `config_mutation`: escalate to operator
|
||||||
- If `reversible_low` with validated pattern: auto-act allowed
|
- 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
|
install packages, edit configs, start services, etc. — no need to stop and
|
||||||
re-ask for each step.
|
re-ask for each step.
|
||||||
3. `read_only` commands always auto-run (no approval needed, no window).
|
3. `read_only` commands always auto-run (no approval needed, no window).
|
||||||
4. `destructive` commands **never** auto-run — they always need an explicit
|
4. `destructive` commands **never** auto-run via the general assent window —
|
||||||
typed confirmation ("I confirm ..."), even during an 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
|
**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
|
about why, try an alternative approach, and continue. Only surface to the
|
||||||
|
|||||||
Reference in New Issue
Block a user