Files
oikos/cmd/nomos/assent.go
dtoro 0c0f35a3a9
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat(web): split SPA from oikos binary, require auth on every route
Phase 0 of plans/2026-07-12-wails-desktop-app.md. The control-room SPA
is no longer embedded (web/embed.go deleted); it's a standalone static
build served separately (make ui / make deploy-ui). The api process
adds CORS and drops the dev-open auth bypass — every route now needs a
real bearer token, including SSE (?token= query param, EventSource
can't set headers) and api's own /agent proxy to nomos (previously
unauthenticated by omission).

nomos was an unauthenticated client of api's /mcp and approval-decision
endpoints; closing dev-open would have broken it, so it now sends
Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN on every call back to api.

SPA gets a runtime config module (config.ts) and a Config.svelte
first-launch/reconfigure page, reachable afterwards via a "Connection"
entry in the sidebar footer. Every fetch() in api.ts routes through
fetchWithAuth so the same build works same-origin (browser prod, Vite
dev proxy) or cross-origin (future Wails webview, remote access).

Six gaps found against the plan and the live Caddy topology while
implementing — documented in the plan's "Plan review" section, most
notably: api's own /agent mount was never behind combinedAuth (fixed),
and production's Authentik forward-auth needs a bearer-token bypass for
API routes that this repo's Caddyfile.oikos reference copy now has, but
the real dtoro/caddy-conf deploy does not yet.

Verified live: cross-origin static SPA + API, CORS, bearer auth, SSE
query-token auth, and localStorage persistence all confirmed working
in-browser. Full Go test suite and npm run build pass with no
regressions against the pre-change baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 15:49:42 +02:00

184 lines
7.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"). 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
}