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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user