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>
77 lines
2.4 KiB
Go
77 lines
2.4 KiB
Go
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)
|
|
}
|
|
}
|