package assent import ( "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(resultTexts []string) []PendingApproval { var out []PendingApproval for _, text := range resultTexts { 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") }