fix(agent+ui): whatsapp session audit — approvals, stuck indicator, stale execs
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

P1: add docker compose (logs|ps|top|config|images|port|cp) to read-only
allowlist. docker compose logs was classified as config_mutation, causing
individual approval cards for read-only inspection commands.

P2: remove approval entries from activityLog. They were always status=running
and never transitioned to done (the derived store builds from tool-call
text, not execution status), causing AgentIndicator to latch onto a stale
'Approval: ...' entry and never clear — even after the session completed.

P3: remove InlineApproval from Chat.svelte. The green 'Completed in 1s on
lxc:...' boxes were noise in the chat stream. Approval UX belongs in the
Operations page (already has it via Ops.svelte), not inline in the chat.

P4: stale execution cleanup. Startup sweep (mark >1hr non-terminal as
cancelled) + 5-min periodic sweep (mark >10min non-terminal as cancelled).
98 orphaned executions accumulated from eval testing (39 running from
apt_upgrade:audit timeouts, 19 pending_approval, 3 approved).

P5: refuse second config_mutation run when an approval is already pending
for the session. Without this, the agent queues N individual approvals
before the operator can respond — confirmed in session 20757eb9 (two
approval cards for what should have been one plan-level approval).

VERSION 0.7.0 → 0.7.1
This commit is contained in:
2026-07-15 22:19:30 +02:00
parent a9b3f844b2
commit 7ef8446825
9 changed files with 320 additions and 28 deletions

View File

@@ -1295,6 +1295,26 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
return textResult(fmt.Sprintf("An identical command is already queued for approval on %s — execution %s. Wait for the operator, don't re-request.", targetSlug, existingID))
}
// P5: if this is a config_mutation command, no assent window is active,
// and there's already a pending_approval for this session, refuse —
// don't queue a second approval. The operator should see ONE approval
// (the plan), approve it (which opens the assent window), and then all
// subsequent config_mutation commands auto-run. Without this gate, the
// agent queues N individual approvals before the operator can respond,
// flooding the chat with approval cards — confirmed in session 20757eb9
// (WhatsApp bridge: two approvals for what should have been one plan).
if riskClass == policy.RiskConfigMutation && sessionID != "" && !assentWindowActive(ctx, pool, agentID, sessionID) {
var anyPending int
pool.QueryRow(ctx, `
SELECT COUNT(*) FROM nomos_plan_executions pe
JOIN executions ex ON ex.entity_id = pe.execution_id
WHERE pe.session_id = $1 AND ex.status = 'pending_approval'`,
sessionID).Scan(&anyPending)
if anyPending > 0 {
return textResult("An approval is already pending for this plan. Present the plan and its steps to the operator, then STOP and wait for their approval (\"approved\", \"yes\", \"go ahead\"). Do not call run again until the operator responds — after approval, all config_mutation commands will auto-run.")
}
}
id, _ := uuid.NewV7()
correlationID := uuid.New().String()
execName := "run on " + targetSlug + " (" + id.String() + ")"

View File

@@ -74,6 +74,7 @@ var readOnlyLeadPattern = regexp.MustCompile(
`systemctl\s+(status|is-active|is-enabled|is-failed|list-units|list-unit-files|list-timers|show)\b|` +
`timedatectl|hostnamectl|systemd-analyze|` +
`docker\s+(ps|images|inspect|logs|version|info|stats)|` +
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b|` +
`pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` +
`rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` +
`git\s+(status|log|diff|show|branch|remote)|` +

View File

@@ -27,6 +27,11 @@ func TestClassifyCommand_ReadOnly(t *testing.T) {
"hostnamectl",
"systemd-analyze blame",
"rclone lsl proton:library-backup",
// docker compose read-only subcommands (F1 fix).
"docker compose logs --tail=100",
"docker compose ps",
"docker compose top",
"docker compose config",
}
for _, c := range cases {
if got := ClassifyCommand(c, ""); got != RiskReadOnly {