feat: decompose pct_create into atomic create + agent-driven install; add scoped destructive window
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

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:
2026-07-10 14:45:09 +02:00
parent 6f9998fa29
commit 2e922f6421
6 changed files with 208 additions and 150 deletions

View File

@@ -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.