feat: assent window + compound read-only classification + continue-after-approval
Agent stopped after every approval step, forcing operator to type 'continue' 7× per deploy session. Root causes and fixes: 1. Compound read-only commands (e.g. 'systemctl status; journalctl') defaulted to config_mutation — now splits on ;/&&/||/| and classifies as read_only if all segments are inspection verbs. Added grep, wc, sort, uniq, cut, tr, dpkg -l, apt list, docker stats to allowlist. 2. curl|sh was classified destructive, forcing typed confirmation for legitimate installs (get.docker.com). Demoted to config_mutation — loose assent grants it, no typed phrase needed. 3. SOUL.md said 'STOP after queuing' — replaced with 'continue working on non-blocked steps'. Added assent window section instructing agent to carry out the full plan after approval. 4. Assent window: when operator approves a plan via chat assent, a 30-minute window opens where config_mutation commands auto-run without re-approval. Agent writes expiry to autonomy_settings; MCP run tool checks it before gating. Destructive never auto-runs. 5. System note after approval now says 'CONTINUE executing the full plan — do not stop and wait for continue.'
This commit is contained in:
@@ -483,6 +483,28 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
return textResult(fmt.Sprintf("run on %s (read_only, auto): %s", targetSlug, out)), nil
|
||||
}
|
||||
|
||||
// Assent window: if the operator recently approved a plan in this
|
||||
// agent's chat session, config_mutation commands auto-run without
|
||||
// re-approval. This is the "approve the plan, carry it out" path —
|
||||
// the 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) {
|
||||
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 assent window", "target", targetSlug, "execution_id", id)
|
||||
return textResult(fmt.Sprintf("run on %s (config_mutation, auto via assent 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 := ""
|
||||
@@ -1263,6 +1285,29 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
|
||||
return "", "", nil, fmt.Errorf("unsupported target %q: must be host:<slug> or lxc:<slug>", targetSlug)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
var expiresStr string
|
||||
err := pool.QueryRow(ctx,
|
||||
"SELECT value FROM autonomy_settings WHERE key = $1",
|
||||
"assent_window.agent:"+agentID.String()).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)
|
||||
|
||||
Reference in New Issue
Block a user