fix(concurrency): scope assent/destructive windows to session, not just agent
Fix 1 of plans/2026-07-11-concurrent-task-execution.md — the safety-critical
one. The assent window (and destructive window) were keyed purely by agent
id ("assent_window.agent:<uuid>"). With one agent:nomos entity serving every
concurrent task, this meant approving Task A's plan opened a window that ANY
concurrently-running task's config-mutation/destructive actions could also
ride, auto-executing without their own approval.
- store.go / agent.go: assentWindowActive/openAssentWindow and
destructiveWindowActive/openDestructiveWindow/destructiveWindowKey all gain
a sessionID parameter; keys become
"assent_window.agent:<id>.session:<sessionID>" and
"destructive_window.agent:<id>.target:<slug>.session:<sessionID>". Missing
session id fails closed (no window) rather than falling back to the old
agent-wide key.
- continue.go: the auto-continuation worker's window check moved from once-
per-batch to once-per-pending-item, scoped to that item's own session —
it was previously checking ONE agent-wide window for a batch that can span
multiple tasks.
- agent.go tool-dispatch: injects `_session_id` into a COPY of the wire args
sent to the MCP server (never into the args used for the emitted/logged/
persisted tool call, and never part of any tool's declared InputSchema —
invisible to the model) so the gating checks on the OTHER side of the
process boundary know which task is asking.
- internal/mcp/server.go: assentWindowActive/destructiveWindowActive/
classifyAndGate gain the same sessionID parameter, read from
args["_session_id"] at the three call sites (request_execution's
apt_upgrade/pct_create branches, and the shared classifyAndGate used by
restart/pct_exec/systemctl/run).
Verified against the live stack with the exact scenario from the plan: opened
an assent window for session A only, then called `run` with an identical
config-mutation command for session A (window open) and session B (same
agent, no window). A auto-ran (execution status completed); B correctly
queued for approval (pending_approval) instead of bleeding through — proven
at both the MCP response text and the executions table.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -356,6 +356,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
targetSlug, _ := args["target"].(string)
|
||||
action, _ := args["action"].(string)
|
||||
params, _ := args["params"].(string)
|
||||
sessionID, _ := args["_session_id"].(string)
|
||||
if targetSlug == "" || action == "" {
|
||||
return textResult("error: target and action required"), nil
|
||||
}
|
||||
@@ -388,7 +389,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
cmd = fmt.Sprintf("systemctl %s %s; sleep 1; systemctl is-active %s", params, svc, svc)
|
||||
purpose = "systemctl " + params + " " + svc
|
||||
}
|
||||
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, cmd, purpose, ""), nil
|
||||
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, cmd, purpose, "", sessionID), nil
|
||||
}
|
||||
|
||||
// Deduplicate: if a pending execution already exists for the same
|
||||
@@ -451,7 +452,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
return textResult("apt audit:\n" + out), nil
|
||||
}
|
||||
// During an active assent window, auto-approve.
|
||||
if assentWindowActive(ctx, pool, agentID) {
|
||||
if assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||
createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation")
|
||||
// Do NOT pre-flip approvals/executions status here (that was
|
||||
@@ -485,7 +486,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
case "pct_create":
|
||||
// During an active assent window, auto-approve and execute
|
||||
// instead of queuing — the operator already approved the plan.
|
||||
if assentWindowActive(ctx, pool, agentID) {
|
||||
if assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||
createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation")
|
||||
// See the apt_upgrade case above for why there's no
|
||||
@@ -517,6 +518,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
command, _ := args["command"].(string)
|
||||
purpose, _ := args["purpose"].(string)
|
||||
declaredRisk, _ := args["declared_risk"].(string)
|
||||
sessionID, _ := args["_session_id"].(string)
|
||||
if targetSlug == "" || command == "" {
|
||||
return textResult("error: target and command are required"), nil
|
||||
}
|
||||
@@ -526,7 +528,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
||||
}
|
||||
|
||||
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk), nil
|
||||
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
|
||||
@@ -1308,7 +1310,7 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
|
||||
// fleet's reverse proxy) executed instantly with no approval at all. Routing
|
||||
// every mutating path through the same classifier + approval-queue logic
|
||||
// closes that gap without special-casing each caller.
|
||||
func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk string) *mcp.CallToolResult {
|
||||
func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk, sessionID string) *mcp.CallToolResult {
|
||||
riskClass := policy.ClassifyCommand(command, declaredRisk)
|
||||
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
|
||||
actionCol := "run:" + string(runParams)
|
||||
@@ -1360,7 +1362,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
// operator approved the overall direction; individual config steps
|
||||
// within the window don't each need a separate yes. Destructive
|
||||
// commands never auto-run, regardless of window.
|
||||
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID) {
|
||||
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||
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()))
|
||||
@@ -1382,7 +1384,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
||||
// 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) {
|
||||
if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug, sessionID) {
|
||||
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()))
|
||||
@@ -1450,18 +1452,25 @@ func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, ac
|
||||
}
|
||||
|
||||
// assentWindowActive checks whether the operator has recently approved a plan
|
||||
// in this agent's chat session. The agent sets an assent_window.agent:<uuid>
|
||||
// key in autonomy_settings with an expiry timestamp when chat-assent grants
|
||||
// a pending execution. While active, config_mutation commands auto-run
|
||||
// without re-approval — the operator approved the overall plan, not each step.
|
||||
func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID) bool {
|
||||
if agentID == uuid.Nil {
|
||||
return false
|
||||
// in THIS TASK's chat session. The agent sets an
|
||||
// assent_window.agent:<uuid>.session:<id> key in autonomy_settings with an
|
||||
// expiry timestamp when chat-assent grants a pending execution. While
|
||||
// active, config_mutation commands auto-run without re-approval — the
|
||||
// operator approved the overall plan, not each step. Scoped by session, not
|
||||
// just agent: with one agent:nomos entity serving every concurrent task, an
|
||||
// agent-only key would let approving Task A's plan silently auto-run
|
||||
// unapproved actions from a concurrently-running Task B. sessionID comes
|
||||
// from the `_session_id` nomos injects into every tool call's wire args
|
||||
// (never part of any tool's declared InputSchema, so the model never
|
||||
// supplies or sees it) — see cmd/nomos/agent.go's tool dispatch loop.
|
||||
func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, sessionID string) bool {
|
||||
if agentID == uuid.Nil || sessionID == "" {
|
||||
return false // fail closed: no session to scope to means no window
|
||||
}
|
||||
var expiresStr string
|
||||
err := pool.QueryRow(ctx,
|
||||
"SELECT value FROM autonomy_settings WHERE key = $1",
|
||||
"assent_window.agent:"+agentID.String()).Scan(&expiresStr)
|
||||
"assent_window.agent:"+agentID.String()+".session:"+sessionID).Scan(&expiresStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -1473,20 +1482,21 @@ func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID) b
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
// confirmed destructive grant for this agent WITHIN THIS SESSION/TASK. Key
|
||||
// format ("destructive_window.agent:<id>.target:<slug>.session:<id>") must
|
||||
// match cmd/nomos/store.go's openDestructiveWindow — both processes
|
||||
// read/write the same autonomy_settings row. Scoped to one target AND one
|
||||
// session so a typed confirmation for destroying container A in task X can
|
||||
// never be read as authorizing anything against container A from a
|
||||
// different, concurrently-running task Y.
|
||||
func destructiveWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, targetSlug, sessionID string) bool {
|
||||
if agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
|
||||
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)
|
||||
"destructive_window.agent:"+agentID.String()+".target:"+targetSlug+".session:"+sessionID).Scan(&expiresStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user