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 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"). Includes contracted negatives // ("haven't", "isn't", ...) alongside "don't"/"do not" — found live: "I // haven't confirmed anything yet" was reading as an explicit confirmation // because none of the contracted forms were covered, only "don't"/"do not". // Deliberately does NOT include a bare "not": that's broad enough to false- // negative ordinary assent ("go ahead, this is not risky") — the specific // contracted-verb forms below are unambiguous negation on their own. 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", "haven't", "hasn't", "isn't", "wasn't", "aren't", "can't", "cannot", "won't", "wouldn't", "shouldn't", "didn't", "doesn't", } // 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", } // wordTokenRe splits a message into lowercase word tokens. Apostrophes // (straight ' and curly ’) stay attached to their word so "don't"/"haven't" // tokenize as one token, not two. var wordTokenRe = regexp.MustCompile(`[a-z0-9'’]+`) func tokenize(msg string) []string { return wordTokenRe.FindAllString(strings.ToLower(strings.ReplaceAll(msg, "’", "'")), -1) } // containsPhrase reports whether phrase (one or more words) appears as a // consecutive run of WHOLE tokens in tokens — never a mid-word substring // match. This is the fix for a real false positive found live: the old // substring check (`strings.Contains(m, "yes")`) matched "yes" inside // "yesterday", and "confirm" inside "confirmed"/"unconfirmed" without regard // for word boundaries. Negation already used a word-boundary check // (space-padded); assent/confirm words didn't — this brings both onto the // same, more robust tokenized comparison instead of ad-hoc string padding. func containsPhrase(tokens []string, phrase string) bool { words := strings.Fields(phrase) if len(words) == 0 || len(words) > len(tokens) { return false } for i := 0; i+len(words) <= len(tokens); i++ { match := true for j, w := range words { if tokens[i+j] != w { match = false break } } if match { return true } } return false } // 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 { tokens := tokenize(msg) for _, w := range negationWords { if containsPhrase(tokens, w) { return false } } for _, w := range assentWords { if containsPhrase(tokens, 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 { tokens := tokenize(msg) for _, w := range negationWords { if containsPhrase(tokens, w) { return false } } return containsPhrase(tokens, "confirm") || containsPhrase(tokens, "confirmed") } // 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") if a.apiToken != "" { req.Header.Set("Authorization", "Bearer "+a.apiToken) } 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 }