Root cause of "chat gave me no further feedback — had to go to Ops": two compounding bugs, found by reading the actual production session transcript. 1. InlineApproval.svelte — all of last session's live-status/self-heal work — was never imported or rendered anywhere. Chat.svelte had its own separate, much dumber approval bar (no status tracking, no destructive handling, just silently disappears after clicking) that WAS the one users actually saw. Deleted the dead bar and its state; InlineApproval now renders per-message. 2. chat.ts's extractApprovals hardcoded `tool.name === 'request_execution'`, so any approval raised by the newer `run` tool was invisible — no card, no feedback, nothing to self-heal, forcing the operator to the Ops page with zero acknowledgement in the conversation. This was the actual proximate cause of last night's destroy-135 session. Fixed to match on response shape, not tool name, so it doesn't silently break again for the next new gated tool. 3. Nomos was telling operators "type something like 'I confirm destroy 135'" for destructive actions (SOUL.md) but no backend path ever consumed that phrase — chat-assent explicitly (and correctly) excludes destructive from loose assent, but I never built the alternative. Added isTypedConfirmation() (cmd/nomos/assent.go): stricter than loose assent, requires an explicit "confirm" statement, only applies to destructive- flagged pending approvals. 4. InlineApproval's completed-state hardcoded "Provisioned successfully" — wrong/confusing for a destroy or arbitrary `run` command. Now says "Completed on <target>" and shows the actual command output, verified live against the real destroy-135 execution. Verified live in a real browser against the production API/DB (dev server proxying to :8090): the historical stuck session now retroactively renders both executions as resolved with correct wording and real output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
5.3 KiB
Go
136 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Chat-assent approval: the operator authorizes a proposed action by
|
|
// replying normally in chat ("go ahead", "yes", "do it") instead of clicking
|
|
// a separate Approve button. This is deterministic (not LLM-judged) so it
|
|
// can't be talked around by a model that misreads intent, and it only ever
|
|
// looks at the assistant turn immediately preceding the operator's reply —
|
|
// an old "yes" from three messages ago can never retroactively approve
|
|
// something new. Destructive-risk actions are excluded: they always need the
|
|
// explicit typed-confirmation flow, never loose assent.
|
|
|
|
// pendingApproval is one gated action proposed in the immediately-preceding
|
|
// assistant turn, extracted from its tool_result text.
|
|
type pendingApproval struct {
|
|
execID string
|
|
destructive bool
|
|
}
|
|
|
|
// executionQueuedRE matches the "execution <uuid> queued" phrasing shared by
|
|
// the run and request_execution/pct_create tool result messages.
|
|
var executionQueuedRE = regexp.MustCompile(`(?i)execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\s+queued`)
|
|
|
|
// extractPendingApprovals scans the tool results of one assistant turn for
|
|
// gated actions that are still awaiting a decision.
|
|
func extractPendingApprovals(calls []persistedCall) []pendingApproval {
|
|
var out []pendingApproval
|
|
for _, c := range calls {
|
|
text := c.resultText()
|
|
m := executionQueuedRE.FindStringSubmatch(text)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
out = append(out, pendingApproval{
|
|
execID: m[1],
|
|
destructive: strings.Contains(strings.ToUpper(text), "DESTRUCTIVE"),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// negationWords, checked first: any of these anywhere in the message means
|
|
// the reply is NOT assent, even if a positive word also appears (e.g. "no,
|
|
// don't restart it yet" contains neither "yes" nor "go ahead", but "wait"
|
|
// alone should also block a stray "yes" a sentence later — checking negation
|
|
// first and returning false errs toward re-confirming rather than assuming
|
|
// consent, per "when in doubt, escalate").
|
|
var negationWords = []string{
|
|
"no", "nope", "don't", "do not", "stop", "wait", "hold on", "hold off",
|
|
"not yet", "cancel", "nevermind", "never mind", "actually don't", "skip that",
|
|
}
|
|
|
|
// assentWords, checked only if no negation matched.
|
|
var assentWords = []string{
|
|
"go ahead", "goahead", "yes", "yep", "yeah", "yup", "do it", "proceed",
|
|
"approve", "approved", "confirm", "confirmed", "ship it", "sounds good",
|
|
"lgtm", "run it", "execute", "ok go", "okay go", "please do",
|
|
}
|
|
|
|
// isAssent reports whether msg is a plain-language authorization of a
|
|
// pending proposal. Deliberately simple and auditable: a fixed word list,
|
|
// not a model judgment call, so behavior is predictable and can't be
|
|
// prompt-injected via the pending action's own content.
|
|
func isAssent(msg string) bool {
|
|
m := " " + strings.ToLower(strings.TrimSpace(msg)) + " "
|
|
for _, w := range negationWords {
|
|
if strings.Contains(m, " "+w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+",") {
|
|
return false
|
|
}
|
|
}
|
|
for _, w := range assentWords {
|
|
if strings.Contains(m, w) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isTypedConfirmation reports whether msg is an explicit confirmation strong
|
|
// enough to grant a DESTRUCTIVE pending action. Deliberately a separate,
|
|
// stricter check from isAssent: a bare "yes"/"go ahead"/"proceed" must never
|
|
// grant something destructive, only an explicit "confirm" statement does —
|
|
// this is the typed-confirmation phrase SOUL.md tells the operator to use
|
|
// ("I confirm destroy 135"). Still negation-aware for the same reason as
|
|
// isAssent: "don't confirm yet" must not accidentally match.
|
|
func isTypedConfirmation(msg string) bool {
|
|
m := " " + strings.ToLower(strings.TrimSpace(msg)) + " "
|
|
for _, w := range negationWords {
|
|
if strings.Contains(m, " "+w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+",") {
|
|
return false
|
|
}
|
|
}
|
|
return strings.Contains(m, "confirm")
|
|
}
|
|
|
|
// approveExecution grants (or denies) a pending execution via the same HTTP
|
|
// endpoint the chat UI's Approve button calls, so both paths share one code
|
|
// path server-side (executeApprovedAction) and one audit trail. Returns the
|
|
// decided status, or an error if the request failed outright (a 4xx for an
|
|
// already-decided/expired approval is reported via ok=false, not a hard err,
|
|
// since that's an expected race, not a bug).
|
|
func (a *agent) approveExecution(ctx context.Context, execID string) (ok bool, status string, err error) {
|
|
if a.apiBase == "" {
|
|
return false, "", fmt.Errorf("no API base configured")
|
|
}
|
|
body, _ := json.Marshal(map[string]string{"decision": "approve"})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
a.apiBase+"/api/v1/approvals/"+execID+"/decision", bytes.NewReader(body))
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := a.httpClient.Do(req)
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return false, "", nil // already decided / expired / not found — not a hard failure
|
|
}
|
|
var out struct {
|
|
Status string `json:"status"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&out)
|
|
return true, out.Status, nil
|
|
}
|