feat: general gated run primitive + chat-assent approval (Layer 0)
Implements the first slice of plans/2026-07-10-general-gated-execution.md:
Nomos gets one general execution tool instead of only a fixed action enum,
gated by an automatic risk classifier, and approval can be granted by the
operator just replying in chat instead of clicking a button.
- internal/policy/command.go: ClassifyCommand(cmd, declaredRisk) — rule-based
read-only allowlist + destructive denylist, default-escalate to
config_mutation for anything else. Classification can only ESCALATE the
caller's declared risk, never de-escalate it (destructive always wins even
if declared read_only). Compound commands (&&, ;, |, $()) never qualify for
the read-only fast path. Full test corpus.
- internal/mcp/server.go: new `run` MCP tool — target (host:/lxc:), command,
purpose, optional declared_risk. Read-only commands execute immediately;
everything else queues an approval exactly like pct_create today, executed
via httpapi's existing executeApprovedAction. Also fixes a real latent bug:
pct_exec resolved an LXC's host attribute without the "host:" prefix, so it
could never find the Proxmox host — new resolveExecTarget/resolveRunTarget
helpers (mcp + httpapi) fix this for both the new `run` action and existing
actions that route through the same execution path.
- internal/httpapi/phase3.go: "run" case in executeApprovedAction; fixes two
bugs found while wiring this up — (1) DecideApproval hardcoded risk_class to
'config_mutation' on every approve, silently corrupting the audit ledger for
every other risk class; (2) denying/revoking an approval never updated the
linked execution's status, so it stayed 'pending_approval' forever instead
of reflecting the decision.
- cmd/nomos/assent.go: deterministic (not LLM-judged) chat-assent detection.
Scoped to the immediately-preceding assistant turn's pending approvals only
— an old "yes" can't retroactively approve something new. Destructive-risk
actions are excluded from loose assent. Approves via the same HTTP decision
endpoint the UI button calls, so both paths share one audit trail.
- web/.../InlineApproval.svelte: self-healing poll — a pending approval card
now picks up being decided via ANY path (chat assent, Ops page, Matrix), not
just its own button. Previously the banner stayed stuck showing
Approve/Deny even after the action had already run elsewhere.
- nomos/SOUL.md: `run` is now the general capability ("no fixed menu, only a
risk gate"); documents chat-assent behavior and the destructive exception.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -30,13 +31,15 @@ var refusalDenylist = []string{
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
client *mcpClient
|
||||
system string
|
||||
provider *openai.Client
|
||||
model string
|
||||
store *store
|
||||
agentID uuid.UUID
|
||||
reqOpts []option.RequestOption
|
||||
client *mcpClient
|
||||
system string
|
||||
provider *openai.Client
|
||||
model string
|
||||
store *store
|
||||
agentID uuid.UUID
|
||||
reqOpts []option.RequestOption
|
||||
apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug string) (*agent, error) {
|
||||
@@ -76,14 +79,26 @@ func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug st
|
||||
}
|
||||
reqOpts := []option.RequestOption{option.WithJSONSet("provider", providerRouting)}
|
||||
|
||||
// Derive the oikos HTTP API base from the MCP URL (e.g.
|
||||
// "http://api:8090/mcp?session_id=..." -> "http://api:8090"). Used for
|
||||
// chat-assent approvals, which call the same decision endpoint the UI's
|
||||
// Approve button calls.
|
||||
mcpURL := os.Getenv("NOMOS_MCP_URL")
|
||||
apiBase := ""
|
||||
if idx := strings.Index(mcpURL, "/mcp"); idx > 0 {
|
||||
apiBase = mcpURL[:idx]
|
||||
}
|
||||
|
||||
return &agent{
|
||||
client: mcpClient,
|
||||
system: system,
|
||||
provider: &provider,
|
||||
model: model,
|
||||
store: st,
|
||||
agentID: agentID,
|
||||
reqOpts: reqOpts,
|
||||
client: mcpClient,
|
||||
system: system,
|
||||
provider: &provider,
|
||||
model: model,
|
||||
store: st,
|
||||
agentID: agentID,
|
||||
reqOpts: reqOpts,
|
||||
apiBase: apiBase,
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -127,6 +142,7 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
}
|
||||
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
|
||||
history, _ := a.store.getMessages(ctx, sessionID)
|
||||
var lastAssistantCalls []persistedCall
|
||||
for _, m := range history {
|
||||
text := extractText(m.Content)
|
||||
switch m.Role {
|
||||
@@ -138,6 +154,7 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
for _, c := range calls {
|
||||
messages = append(messages, openai.ToolMessage(c.resultText(), c.id))
|
||||
}
|
||||
lastAssistantCalls = calls
|
||||
}
|
||||
if text != "" {
|
||||
messages = append(messages, openai.AssistantMessage(text))
|
||||
@@ -148,6 +165,41 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
messages = append(messages, openai.UserMessage(message))
|
||||
}
|
||||
|
||||
// Chat-assent approval: if the immediately-preceding assistant turn
|
||||
// proposed gated action(s) and the operator's new message reads as
|
||||
// authorization ("go ahead", "yes", ...), grant them now — this is the
|
||||
// primary approval path; the Approve button in the UI is a fallback for
|
||||
// when the operator wants to click instead of type. Destructive-risk
|
||||
// actions are never granted by loose assent.
|
||||
if pending := extractPendingApprovals(lastAssistantCalls); len(pending) > 0 && isAssent(message) {
|
||||
var granted, blocked []string
|
||||
for _, p := range pending {
|
||||
if p.destructive {
|
||||
blocked = append(blocked, p.execID)
|
||||
continue
|
||||
}
|
||||
ok, status, aerr := a.approveExecution(ctx, p.execID)
|
||||
if aerr != nil {
|
||||
slog.Error("nomos: chat-assent approve", "execution", p.execID, "error", aerr)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
granted = append(granted, p.execID)
|
||||
slog.Info("nomos: chat-assent granted", "execution", p.execID, "status", status, "session", sessionID)
|
||||
emit(agentEvent{Type: "tool_use", Data: map[string]any{"name": "chat_assent", "args": map[string]any{"execution_id": p.execID}, "id": "assent-" + p.execID}, SessionID: sessionID})
|
||||
emit(agentEvent{Type: "tool_result", Data: map[string]any{"name": "chat_assent", "result": fmt.Sprintf("Approved via chat assent (%q). Status: %s.", message, status), "id": "assent-" + p.execID}, SessionID: sessionID})
|
||||
}
|
||||
}
|
||||
if len(granted) > 0 {
|
||||
note := fmt.Sprintf("[System: the operator's message approved pending execution(s) %s via chat assent — they are now running. Do not call request_execution/run again for these; check get_execution_status if you need the outcome before replying.]", strings.Join(granted, ", "))
|
||||
messages = append(messages, openai.SystemMessage(note))
|
||||
}
|
||||
if len(blocked) > 0 {
|
||||
note := fmt.Sprintf("[System: execution(s) %s are classified DESTRUCTIVE and were NOT approved by loose assent — you must ask the operator for an explicit typed confirmation before they can run.]", strings.Join(blocked, ", "))
|
||||
messages = append(messages, openai.SystemMessage(note))
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < maxIterations; i++ {
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Model: openai.ChatModel(a.model),
|
||||
|
||||
118
cmd/nomos/assent.go
Normal file
118
cmd/nomos/assent.go
Normal file
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
76
cmd/nomos/assent_test.go
Normal file
76
cmd/nomos/assent_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsAssent_Positive(t *testing.T) {
|
||||
cases := []string{
|
||||
"go ahead", "Go ahead.", "yes", "Yes!", "yeah", "yep", "do it",
|
||||
"proceed", "approve", "ship it", "sounds good", "lgtm", "please do",
|
||||
"ok go ahead and run it",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if !isAssent(c) {
|
||||
t.Errorf("isAssent(%q) = false, want true", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAssent_Negative(t *testing.T) {
|
||||
cases := []string{
|
||||
"no", "no, don't", "wait", "hold on", "not yet", "cancel that",
|
||||
"nevermind", "what's the plan for tomorrow?", "how many CPUs does strong have?",
|
||||
"maybe later", "",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if isAssent(c) {
|
||||
t.Errorf("isAssent(%q) = true, want false", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAssent_NegationBeatsAssentWord(t *testing.T) {
|
||||
// Contains "yes" as a substring pattern risk word but is clearly not
|
||||
// assent — negation must win.
|
||||
cases := []string{
|
||||
"no, don't do it yet",
|
||||
"wait, not yet please",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if isAssent(c) {
|
||||
t.Errorf("isAssent(%q) = true, want false (negation should block)", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPendingApprovals(t *testing.T) {
|
||||
mkCall := func(text string) persistedCall {
|
||||
b, _ := json.Marshal(text)
|
||||
return persistedCall{id: "x", name: "run", result: json.RawMessage(b)}
|
||||
}
|
||||
calls := []persistedCall{
|
||||
mkCall("run on host:strong requires approval (risk: config_mutation) — execution 019f4930-e22b-7c47-8c6e-715dcd59df19 queued. Present the command..."),
|
||||
mkCall("some unrelated read-only result, no approval here"),
|
||||
mkCall("run on lxc:caddy requires approval (risk: destructive) — execution 019f4931-aaaa-7c47-8c6e-715dcd59df20 queued. This is classified DESTRUCTIVE — flag that clearly."),
|
||||
}
|
||||
got := extractPendingApprovals(calls)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 pending approvals, got %d: %+v", len(got), got)
|
||||
}
|
||||
if got[0].execID != "019f4930-e22b-7c47-8c6e-715dcd59df19" || got[0].destructive {
|
||||
t.Errorf("first approval wrong: %+v", got[0])
|
||||
}
|
||||
if got[1].execID != "019f4931-aaaa-7c47-8c6e-715dcd59df20" || !got[1].destructive {
|
||||
t.Errorf("second approval should be flagged destructive: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPendingApprovals_NoneWhenNoneQueued(t *testing.T) {
|
||||
b, _ := json.Marshal("fleet is healthy, nothing to report")
|
||||
calls := []persistedCall{{id: "x", result: json.RawMessage(b)}}
|
||||
if got := extractPendingApprovals(calls); len(got) != 0 {
|
||||
t.Errorf("expected no pending approvals, got %+v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user