v0.18.0: MCP entity-graph CRUD, lifecycle validation, curl -o /dev/null fix
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

- create_entity, set_entity_state, end_relationship MCP tools
- update_entity_attributes now triggers check derivation via EnsureEntityChecks
- shared db.EnsureEntityChecks + db.ValidateTransition hooks (HTTP + MCP parity)
- curl -o /dev/null now classified read_only (was config_mutation)
- db.ErrTransitionInvalid sentinel for HTTP error-type accuracy
- SOUL.md: capability escalation, self-grounding, exploration budget rules
- Runbook: oikos check lifecycle for agent self-knowledge
This commit is contained in:
2026-08-04 08:52:08 +02:00
parent 058f1afcdc
commit 20adb89650
14 changed files with 1098 additions and 157 deletions

View File

@@ -1 +1 @@
0.17.3 0.18.0

View File

@@ -0,0 +1,104 @@
# Oikos check lifecycle — how monitoring works
This runbook covers how Oikos health checks are derived, created, and wired so
an agent (Nomos) doesn't reverse-engineer source when asked to add monitoring to
an entity — the problem that stranded session `23da10db` (2026-08-03).
## Concepts
- **`check_defs`** (scheduler config, table `check_defs`): the row the scheduler
reads to know *what* to probe and *when*. One per check instance.
- **`check` entity** (type `check`, slug `check:<kind>:<target>:<n>`): the
knowledge-graph entity for that check. It carries attributes
(`check_type`, `target`, `port`, …) and `checks` edges to the probed target.
- **`monitoring` spec** on an entity type (`entity_types.monitoring_spec`): the
default list of check kinds (e.g. `[http, process]` for `service`).
- Per-entity override: set `monitoring` in the entity's attributes —
`"none"` for zero checks, `["http"]` to replace the type defaults.
- **`checkdefaults.Ensure`** (`internal/checkdefaults/defaults.go`): the
function that reads the monitoring spec, resolves host/port/URL from
attributes + relationships, and writes `check_defs` rows. Idempotent.
## When checks are derived
`checkdefaults.Ensure` runs in three situations (as of v0.17.1+):
1. **Seed/deploy ingest**`internal/db/seed.go:231`. Every entity gets its
default checks once on initial ingest.
2. **HTTP `POST /api/v1/entities` (create)**`ensureDefaultChecks` at
`internal/httpapi/impl.go:1012`. Creating an entity via the REST API derives
its checks in the same transaction.
3. **HTTP `PATCH /api/v1/entities` (patch)**`ensureDefaultChecks` at
`internal/httpapi/impl.go:1280`. Changing an entity's attributes (especially
`monitoring`) via the REST API regenerates its checks.
4. **MCP `create_entity`** — SAME hook. Creating an entity via the MCP tool
derives checks. (Added 2026-08-03; previously MCP had no create.)
5. **MCP `update_entity_attributes`** — SAME hook. Changing an entity's
`monitoring` attribute via MCP now regenerates checks. (Added 2026-08-03;
previously MCP updates silently skipped check derivation — the exact bug
that stranded the haos session.)
## Check slug grammar
```
check:<kind>:<target-type>:<target-name>:<n>
```
Examples: `check:http:service:jellyfin:0`, `check:vm-status:vm:haos:0`,
`check:cert-expiry:cert:house.hubris.network:0`.
## Adding monitoring to an entity
**If the entity already exists:**
```
update_entity_attributes(slug="service:haos", attributes={"monitoring":["http"]})
```
This regenerates checks via `checkdefaults.Ensure`. The result message tells you
how many checks were derived and whether any kinds were skipped (and why).
**If the entity does not exist yet (a new check, ingress, cert, etc.):**
```
create_entity(type="check", name="HAOS http check",
slug="check:http:service:haos:0",
attributes={"check_type":"http:service","target":"service:haos","port":"8123"})
```
This creates the entity AND derives its `check_defs`. Same for a new `ingress`
(`type=ingress`, monitoring `[http]`) or `cert` (`type=cert`,
monitoring `[cert-expiry]`).
**To remove monitoring:** set `monitoring:["none"]` or transition the entity
to a terminal lifecycle state (`set_entity_state``deprecated`/`destroyed`).
## Caveats
- **A service without a `url` attribute AND without a `probe_unit` gets no
process check** (the http check covers liveness; the process check would
be redundant without an opt-in `probe_unit`). The skip is logged.
- **A service whose address comes from a `hosts` edge** may produce no checks on
initial create because the edge doesn't exist yet — the next inventory ingest
(or a later `update_entity_attributes` after the edge is created) fills it in.
- **A `not found` error from `update_entity_attributes`** means the entity
doesn't exist — use `create_entity` instead.
- **`check_defs` has target columns** (`target_id`, `target_type`). A check
entity needs a `checks` relationship (`create_relationship(source=check:…,
target=service:…, type="checks")`) so the scheduler can resolve what to
probe. `create_entity` derives the check_def; `create_relationship` links
the check entity to its target in the graph.
## Related files
- `internal/checkdefaults/defaults.go``Ensure`, `Target`, `LogResult`
- `internal/httpapi/default_checks.go``ensureDefaultChecks` (HTTP hook)
- `internal/db/checks.go``db.EnsureEntityChecks` (shared hook)
- `internal/db/seed.go` — seed-time check derivation
- `internal/mcp/tools.go``create_entity`, `update_entity_attributes`
## Revision history
- **2026-08-03:** Created after session `23da10db` stranded for lack of entity-
creation tool and unawareness of check-derivation triggers. Covers the MCP
create_entity + update_entity_attributes regen paths added same day.

34
internal/db/checks.go Normal file
View File

@@ -0,0 +1,34 @@
package db
import (
"context"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// EnsureEntityChecks derives an entity's default check_defs from the
// monitoring spec of its type (resolving per-entity `monitoring` overrides).
//
// This is the single shared hook that keeps the check graph in sync with
// entity mutations. Both the HTTP create/patch handlers and the MCP
// entity-mutation tools (create_entity, update_entity_attributes) call it so
// that flipping an entity's `monitoring` attribute regenerates checks
// regardless of which surface made the change — previously only the HTTP
// path ran check derivation, so entities mutated via MCP silently produced no
// checks (see plans/2026-08-03-session-review-haos-monitoring-capability-gaps.md, A2).
func EnsureEntityChecks(ctx context.Context, tx pgx.Tx, id uuid.UUID, slug, entityType, name string, attrs []byte) (checkdefaults.Result, error) {
tree, err := LoadTypeTree(ctx, tx)
if err != nil {
return checkdefaults.Result{}, err
}
res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{
ID: id, Slug: slug, Type: entityType, Name: name, Attrs: attrs,
})
if err != nil {
return res, err
}
checkdefaults.LogResult(slug, entityType, res)
return res, nil
}

146
internal/db/lifecycle.go Normal file
View File

@@ -0,0 +1,146 @@
package db
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ErrTransitionInvalid is a sentinel returned by ValidateTransition when the
// from→to pair is not a declared lifecycle transition or a precondition fails.
// Callers test with errors.Is to distinguish semantic validation failures
// (→ HTTP 409) from infrastructure errors (→ HTTP 500).
var ErrTransitionInvalid = errors.New("invalid lifecycle transition")
// ValidateTransition enforces an entity type's lifecycle: fromState → toState
// must be a declared transition, and every precondition it lists must hold. A
// type with no lifecycle defined allows any state. A no-op (fromState ==
// toState) passes immediately.
//
// Shared by the HTTP PATCH path and the MCP set_entity_state tool so both
// surfaces apply identical lifecycle rules — previously only the HTTP path
// validated transitions, so an agent changing state via MCP could skip the
// graph's retire/deprecate guardrails entirely.
func ValidateTransition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, fromState, toState string) error {
if toState == fromState {
return nil
}
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, entityType)
if err != nil {
if err == pgx.ErrNoRows {
return nil // no lifecycle defined → any state allowed
}
return err
}
var transitions map[string]map[string]json.RawMessage
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
return fmt.Errorf("parse lifecycle transitions: %w", err)
}
tos, ok := transitions[fromState]
if !ok {
return fmt.Errorf("%w: no transitions defined from %q", ErrTransitionInvalid, fromState)
}
trans, ok := tos[toState]
if !ok {
return fmt.Errorf("%w: %s → %s is not a declared lifecycle transition", ErrTransitionInvalid, fromState, toState)
}
var gate struct {
Requires []string `json:"requires"`
}
if err := json.Unmarshal(trans, &gate); err == nil {
for _, check := range gate.Requires {
if err := checkPrecondition(ctx, tx, entityID, entityType, check); err != nil {
return fmt.Errorf("%w: precondition %q not met: %w", ErrTransitionInvalid, check, err)
}
}
}
return nil
}
// checkPrecondition evaluates one mechanical precondition named by a lifecycle
// transition's `requires` list. Soft/operator-confirmed checks pass; unknown
// checks are skipped (operator intent overrides). Moved here from httpapi so
// both surfaces share one implementation.
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
switch check {
case "no-inbound-edges":
var count int
if err := tx.QueryRow(ctx,
"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count); err != nil {
return err
}
if count > 0 {
return fmt.Errorf("%d inbound relationship edges remaining", count)
}
case "backups-verified", "secrets-revoked", "ingress-dns-removed":
var attrs string
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
return err
}
want := map[string]string{
"backups-verified": "backups_verified",
"secrets-revoked": "secrets_revoked",
"ingress-dns-removed": "ingress_dns_removed",
}[check]
if !strings.Contains(attrs, want) {
return fmt.Errorf("%s not recorded in entity attributes", want)
}
case "age-key-enrolled-if-needed":
if entityType == "workstation" {
var attrs string
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
return err
}
if !strings.Contains(attrs, "age_pubkey") {
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
}
}
case "mesh-joined-if-needed":
if entityType == "workstation" {
var attrs string
if err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs); err != nil {
return err
}
if !strings.Contains(attrs, "mesh_ip") {
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
}
}
case "health-check-answering":
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
if err != nil || st.Health == "unknown" || st.Health == "down" {
h := "unknown"
if err == nil {
h = st.Health
}
return fmt.Errorf("health check not answering (status: %s)", h)
}
case "doc-page-complete":
var count int
if err := tx.QueryRow(ctx, `
SELECT count(*) FROM relationships r
JOIN entities ke ON ke.id = r.source_id
WHERE r.target_id = $1 AND r.valid_to IS NULL
AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
entityID).Scan(&count); err != nil {
return err
}
if count == 0 {
return fmt.Errorf("no documentation linked to entity")
}
case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
"preflight-passed", "error-summary", "replacement-live-or-role-retired",
"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
"ingress-live-if-public", "doc-page-stub", "un-deprecate-note", "write-off-note":
// Soft checks — always pass. Operator-confirmed via the transition
// request itself, or not mechanically enforceable.
default:
// Unknown preconditions are skipped (operator intent overrides).
}
return nil
}

View File

@@ -380,6 +380,8 @@ type RelationshipType struct {
Cardinality string Cardinality string
Description *string Description *string
CreatedAt time.Time CreatedAt time.Time
// Which end of this edge depends on the other. forward = target depends on source. backward = source depends on target. none = no runtime dependency. Drives blast_radius().
BlastDirection string
} }
type RiskClass struct { type RiskClass struct {

View File

@@ -99,7 +99,7 @@ func (q *Queries) ListLifecycleDefs(ctx context.Context) ([]LifecycleDef, error)
} }
const listRelationshipTypes = `-- name: ListRelationshipTypes :many const listRelationshipTypes = `-- name: ListRelationshipTypes :many
SELECT name, inverse, source_type, target_type, cardinality, description, created_at FROM relationship_types ORDER BY name SELECT name, inverse, source_type, target_type, cardinality, description, created_at, blast_direction FROM relationship_types ORDER BY name
` `
func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType, error) { func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType, error) {
@@ -119,6 +119,7 @@ func (q *Queries) ListRelationshipTypes(ctx context.Context) ([]RelationshipType
&i.Cardinality, &i.Cardinality,
&i.Description, &i.Description,
&i.CreatedAt, &i.CreatedAt,
&i.BlastDirection,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@@ -3,31 +3,22 @@ package httpapi
import ( import (
"context" "context"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
// ensureDefaultChecks derives an entity's default checks from the monitoring // ensureDefaultChecks derives an entity's default checks from the monitoring
// kinds its type declares. // kinds its type declares. Thin wrapper over the shared db.EnsureEntityChecks
// hook so the HTTP create/patch paths and the MCP entity-mutation tools stay
// in lockstep.
// //
// Note the ordering caveat: an entity created through the API usually has no // Note the ordering caveat (carried from db.LoadTypeTree / checkdefaults.Ensure):
// edges yet, so a type whose address comes from its host (a service) will // an entity created through the API usually has no edges yet, so a type whose
// produce no checks on this pass. That gap is real and deliberately visible — // address comes from its host (a service) will produce no checks on this pass.
// coverageSweep reports it, and the next inventory ingest fills it in once // That gap is real and deliberately visible — coverageSweep reports it, and
// the hosting edge exists. // the next inventory ingest fills it in once the hosting edge exists.
func ensureDefaultChecks(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType, name string, attrsJSON []byte) error { func ensureDefaultChecks(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType, name string, attrsJSON []byte) error {
tree, err := db.LoadTypeTree(ctx, tx) _, err := db.EnsureEntityChecks(ctx, tx, entityID, slug, entityType, name, attrsJSON)
if err != nil {
return err return err
}
res, err := checkdefaults.Ensure(ctx, tx, tree, checkdefaults.Target{
ID: entityID, Slug: slug, Type: entityType, Name: name, Attrs: attrsJSON,
})
if err != nil {
return err
}
checkdefaults.LogResult(slug, entityType, res)
return nil
} }

View File

@@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"math/big" "math/big"
"strconv" "strconv"
@@ -1062,49 +1063,15 @@ func (s *Server) PatchEntity(ctx context.Context, req gen.PatchEntityRequestObje
// Validate lifecycle transition if state is being changed. // Validate lifecycle transition if state is being changed.
if req.Body.State != nil && *req.Body.State != "" { if req.Body.State != nil && *req.Body.State != "" {
// Get lifecycle def for the entity's type.
lc, err := sqlcgen.New(tx).GetLifecycleForType(ctx, current.Type)
if err != nil {
if err == pgx.ErrNoRows {
// No lifecycle defined — any state is allowed.
} else {
return nil, err
}
} else {
var transitions map[string]map[string]json.RawMessage
if err := json.Unmarshal(lc.Transitions, &transitions); err != nil {
return nil, fmt.Errorf("parse lifecycle transitions: %w", err)
}
fromState := "" fromState := ""
if current.State != nil { if current.State != nil {
fromState = *current.State fromState = *current.State
} }
toState := *req.Body.State if err := db.ValidateTransition(ctx, tx, id, current.Type, fromState, *req.Body.State); err != nil {
if errors.Is(err, db.ErrTransitionInvalid) {
if toState != fromState { return nil, fmt.Errorf("%w: %v", domain.ErrInvalidTransition, err)
tos, ok := transitions[fromState]
if !ok {
return nil, fmt.Errorf("%w: no transitions from %q", domain.ErrInvalidTransition, fromState)
}
trans, ok := tos[toState]
if !ok {
return nil, fmt.Errorf("%w: %s → %s", domain.ErrInvalidTransition, fromState, toState)
}
// Parse preconditions: {"requires": ["check-name", ...]}
var gate struct {
Requires []string `json:"requires"`
}
if err := json.Unmarshal(trans, &gate); err == nil && len(gate.Requires) > 0 {
for _, check := range gate.Requires {
if err := checkPrecondition(ctx, tx, id, current.Type, check); err != nil {
return nil, fmt.Errorf("%w: precondition %q not met: %v",
domain.ErrInvalidTransition, check, err)
}
}
}
} }
return nil, err
} }
} }
@@ -1556,97 +1523,5 @@ func generateAgeKeypair() (pubKey, privKey string, err error) {
return pub, priv, nil return pub, priv, nil
} }
// checkPrecondition validates a named lifecycle transition precondition.
func checkPrecondition(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, entityType, check string) error {
switch check {
case "no-inbound-edges":
var count int
err := tx.QueryRow(ctx,
"SELECT count(*) FROM relationships WHERE target_id = $1 AND valid_to IS NULL", entityID).Scan(&count)
if err != nil {
return err
}
if count > 0 {
return fmt.Errorf("%d inbound relationship edges remaining", count)
}
case "backups-verified":
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "backups_verified") {
return fmt.Errorf("backup verification not recorded in entity attributes")
}
case "secrets-revoked":
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "secrets_revoked") {
return fmt.Errorf("secret revocation not recorded in entity attributes")
}
case "ingress-dns-removed":
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "ingress_dns_removed") {
return fmt.Errorf("ingress/DNS removal not recorded in entity attributes")
}
case "age-key-enrolled-if-needed":
if entityType == "workstation" {
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "age_pubkey") {
return fmt.Errorf("age key not enrolled (no age_pubkey in attributes)")
}
}
case "mesh-joined-if-needed":
if entityType == "workstation" {
var attrs string
err := tx.QueryRow(ctx, "SELECT coalesce(attributes::text,'{}') FROM entities WHERE id = $1", entityID).Scan(&attrs)
if err != nil {
return err
}
if !strings.Contains(attrs, "mesh_ip") {
return fmt.Errorf("mesh not joined (no mesh_ip in attributes)")
}
}
case "health-check-answering":
st, err := sqlcgen.New(tx).GetEntityStatus(ctx, entityID)
if err != nil || st.Health == "unknown" || st.Health == "down" {
return fmt.Errorf("health check not answering (status: %s)", st.Health)
}
case "doc-page-complete":
var count int
err := tx.QueryRow(ctx, `
SELECT count(*) FROM relationships r
JOIN entities ke ON ke.id = r.source_id
WHERE r.target_id = $1 AND r.valid_to IS NULL
AND r.type = 'documents' AND ke.type IN ('document','runbook','investigation')`,
entityID).Scan(&count)
if err != nil {
return err
}
if count == 0 {
return fmt.Errorf("no documentation linked to entity")
}
case "inventory-entry", "ip-reserved", "storage-pool-chosen", "cancelled-note",
"preflight-passed", "error-summary", "replacement-live-or-role-retired",
"replacement-failed", "post-verify-passed", "recovery-verified", "written-off",
"ingress-live-if-public", "doc-page-stub":
// Soft checks — always pass. These are operator-confirmed via the
// transition request itself, or are not mechanically enforceable.
default:
// Unknown preconditions are skipped (operator intent overrides).
}
return nil
}
// ─── Helpers ─────────────────────────────────────────────────────────── // ─── Helpers ───────────────────────────────────────────────────────────

View File

@@ -0,0 +1,251 @@
package mcp
// Integration tests for the entity-mutation MCP tools (create_entity,
// update_entity_attributes), focused on the capability gap that stranded
// session 23da10db: entities mutated via MCP must derive/regenerate checks the
// same way the HTTP create/patch paths do. Guarded by OIKOS_TEST_DATABASE_URL
// (see internal/db/integration_test.go); run via `make test-db`.
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"os"
"strings"
"testing"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/dtoro/oikos/internal/db"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// newTestPool mirrors internal/httpapi/api_test.go: a throwaway database,
// migrated and seeded with ontology/inventory/policy so create_entity's type
// validation and checkdefaults derivation have a real type tree to work
// against.
func newTestPool(t *testing.T) *db.Pool {
t.Helper()
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
if baseURL == "" {
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
}
ctx := context.Background()
admin, err := pgx.Connect(ctx, baseURL)
if err != nil {
t.Fatalf("connect admin: %v", err)
}
dbName := fmt.Sprintf("oikos_mcp_test_%08x", rand.Int63())
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
admin.Close(ctx)
t.Fatalf("create test db: %v", err)
}
admin.Close(ctx)
qi := strings.Index(baseURL, "?")
base, params := baseURL, ""
if qi >= 0 {
base, params = baseURL[:qi], baseURL[qi:]
}
testURL := base[:strings.LastIndex(base, "/")+1] + dbName + params
pool, err := db.New(ctx, testURL)
if err != nil {
t.Fatalf("connect test db: %v", err)
}
t.Cleanup(func() {
pool.Close()
if admin, e := pgx.Connect(ctx, baseURL); e == nil {
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
admin.Close(ctx)
}
})
if err := pool.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
for _, f := range []string{"ontology.yaml", "inventory.yaml", "policy.yaml"} {
content, err := os.ReadFile("../../seeds/" + f)
if err != nil {
t.Fatalf("read seed %s: %v", f, err)
}
name := f
if err := pool.SeedIngest(ctx, name, content,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
var err error
switch name {
case "ontology.yaml":
_, err = db.IngestOntologySeed(ctx, tx, data)
case "inventory.yaml":
_, err = db.IngestInventorySeed(ctx, tx, data)
case "policy.yaml":
_, err = db.IngestPolicySeed(ctx, tx, data)
}
return err
}); err != nil {
t.Fatalf("ingest %s: %v", f, err)
}
}
return pool
}
// callTool invokes a registered tool's handler in-process and returns its
// concatenated text result.
func callTool(t *testing.T, pool *db.Pool, name string, args map[string]any) string {
t.Helper()
var handler toolHandler
for _, r := range allTools(pool, uuid.Nil) {
if r.tool.Name == name {
handler = r.handler
break
}
}
if handler == nil {
t.Fatalf("tool %q not registered", name)
}
argsJSON, _ := json.Marshal(args)
res, err := handler(context.Background(), &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{
Name: name,
Arguments: argsJSON,
}})
if err != nil {
t.Fatalf("tool %s returned error: %v", name, err)
}
var sb strings.Builder
for _, c := range res.Content {
if tc, ok := c.(*mcp.TextContent); ok {
sb.WriteString(tc.Text)
}
}
return sb.String()
}
// checkCountFor returns the number of derived check_defs targeting slug.
func checkCountFor(t *testing.T, pool *db.Pool, slug string) int {
t.Helper()
var n int
err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM check_defs cd
JOIN entities e ON e.id = cd.target_id
WHERE e.slug = $1`, slug).Scan(&n)
if err != nil {
t.Fatalf("count check_defs for %s: %v", slug, err)
}
return n
}
// TestCreateEntity_DerivesChecks proves create_entity inserts an entity AND
// derives its default checks in one call (the HTTP create path did this; the
// MCP path previously could not create at all).
func TestCreateEntity_DerivesChecks(t *testing.T) {
pool := newTestPool(t)
slug := "service:mcp-create-test"
out := callTool(t, pool, "create_entity", map[string]any{
"type": "service",
"slug": slug,
"name": "mcp-create-test",
"attributes": `{"url":"https://mcp-create-test.example"}`,
})
if !strings.Contains(out, "Created "+slug) {
t.Fatalf("create_entity result = %q, want Created %s", out, slug)
}
if !strings.Contains(out, "Derived") {
t.Errorf("create_entity result = %q, want a Derived check summary", out)
}
if got := checkCountFor(t, pool, slug); got < 1 {
t.Errorf("check_defs targeting %s = %d, want >=1 (create did not derive checks)", slug, got)
}
}
// TestCreateEntity_DuplicateAndInvalid covers the guard rails: a repeat create
// is reported as "already exists" (not an error), and an unknown type is
// rejected with a clear message.
func TestCreateEntity_DuplicateAndInvalid(t *testing.T) {
pool := newTestPool(t)
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "service", "slug": "service:mcp-dup", "name": "mcp-dup",
}); !strings.Contains(out, "Created service:mcp-dup") {
t.Fatalf("first create = %q", out)
}
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "service", "slug": "service:mcp-dup", "name": "mcp-dup",
}); !strings.Contains(out, "already exists") {
t.Errorf("duplicate create = %q, want 'already exists'", out)
}
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "no-such-type", "slug": "no-such-type:x", "name": "x",
}); !strings.Contains(out, "not found in ontology") {
t.Errorf("unknown type = %q, want 'not found in ontology'", out)
}
}
// TestUpdateEntityAttributes_RegeneratesChecks is the regression guard for the
// haos session: setting an entity's `monitoring` attribute via MCP must
// regenerate checks. Before this fix the MCP update path skipped
// ensureDefaultChecks, so flipping monitoring produced nothing.
func TestUpdateEntityAttributes_RegeneratesChecks(t *testing.T) {
pool := newTestPool(t)
slug := "service:mcp-regen-test"
// Create with monitoring:none — no checks derived.
if out := callTool(t, pool, "create_entity", map[string]any{
"type": "service", "slug": slug, "name": "mcp-regen-test",
"attributes": `{"monitoring":"none","url":"https://mcp-regen.example"}`,
}); !strings.Contains(out, "Created "+slug) {
t.Fatalf("create = %q", out)
}
if got := checkCountFor(t, pool, slug); got != 0 {
t.Fatalf("check_defs with monitoring:none = %d, want 0", got)
}
// Flip monitoring to [http] via update_entity_attributes — checks must
// regenerate. This is exactly what failed for service:haos.
out := callTool(t, pool, "update_entity_attributes", map[string]any{
"slug": slug,
"attributes": `{"monitoring":["http"]}`,
})
if !strings.Contains(out, "Updated "+slug) {
t.Fatalf("update result = %q, want Updated %s", out, slug)
}
if !strings.Contains(out, "Derived") {
t.Errorf("update result = %q, want a Derived check summary (regeneration)", out)
}
if got := checkCountFor(t, pool, slug); got < 1 {
t.Errorf("check_defs after monitoring:[http] = %d, want >=1 (MCP update did not regenerate checks)", got)
}
}
// TestUpdateEntityAttributes_NotFound keeps the existing error contract.
func TestUpdateEntityAttributes_NotFound(t *testing.T) {
pool := newTestPool(t)
out := callTool(t, pool, "update_entity_attributes", map[string]any{
"slug": "service:does-not-exist",
"attributes": `{"x":1}`,
})
if !strings.Contains(out, "not found") {
t.Errorf("update missing entity = %q, want 'not found'", out)
}
}
// TestFormatCheckResult is a pure unit test for the result-message helper, so
// the formatting contract holds even when the DB is unavailable.
func TestFormatCheckResult(t *testing.T) {
if got := formatCheckResult(checkdefaults.Result{Created: 2}); !strings.Contains(got, "Derived 2 check") {
t.Errorf("created-only = %q, want Derived 2", got)
}
got := formatCheckResult(checkdefaults.Result{Created: 1, Skipped: []checkdefaults.Skip{{Kind: "process", Reason: "no host"}}})
if !strings.Contains(got, "Derived 1 check") || !strings.Contains(got, "Skipped process") || !strings.Contains(got, "no host") {
t.Errorf("created+skipped = %q", got)
}
if got := formatCheckResult(checkdefaults.Result{Undeclared: true}); !strings.Contains(got, "no monitoring") {
t.Errorf("undeclared = %q, want no-monitoring hint", got)
}
if formatCreateResult("a", "b", checkdefaults.Result{Created: 0}) != "Created a (b)." {
t.Error("create result with no checks should have no suffix")
}
}

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/dtoro/oikos/internal/audit" "github.com/dtoro/oikos/internal/audit"
"github.com/dtoro/oikos/internal/checkdefaults"
"github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/policy" "github.com/dtoro/oikos/internal/policy"
"github.com/google/uuid" "github.com/google/uuid"
@@ -173,6 +174,109 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
return upsertKnowledge(ctx, pool, args) return upsertKnowledge(ctx, pool, args)
}}, }},
{tool: &mcp.Tool{Name: "create_entity", Description: "Create a new entity in the knowledge graph — the creation half alongside update_entity_attributes (which only updates EXISTING entities). Use it when a task needs an entity that does not exist yet: a new check (check:<kind>:<target>:<n>), an ingress (ingress:<host>), a cert (cert:<host>), a service, a host/LXC/VM, etc. After inserting, it derives default checks from the entity type's monitoring spec (same as a seed ingest), so creating a checkable entity wires its monitoring in one call. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). If the slug already exists it returns 'already exists' — then use update_entity_attributes to change it.",
InputSchema: objSchema(
prop{"type", "string", "Entity type — must already exist in the ontology and not be abstract (e.g. service, lxc, host, vm, check, ingress, cert, dns)."},
prop{"name", "string", "Human-readable name (e.g. 'HAOS http service check')."},
prop{"slug", "string", "Entity slug (e.g. check:http:service:haos:0, ingress:home.hubris.network). If omitted, defaults to <type>:<name>."},
prop{"attributes", "string", "JSON object string of attributes, e.g. {\"check_type\":\"http:service\",\"target\":\"service:haos\",\"port\":\"8123\"}. Optional."},
prop{"state", "string", "Lifecycle state. Optional; defaults to the type's lifecycle default_state."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
entityType, _ := args["type"].(string)
name, _ := args["name"].(string)
slug, _ := args["slug"].(string)
if slug == "" && entityType != "" && name != "" {
slug = entityType + ":" + name
}
if entityType == "" || name == "" || slug == "" {
return textResult("error: type and name are required (slug defaults to <type>:<name>)"), nil
}
attrsStr, _ := args["attributes"].(string)
attrs := map[string]any{}
if attrsStr != "" {
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
}
}
attrsJSON, _ := json.Marshal(attrs)
stateStr, _ := args["state"].(string)
tx, err := pool.Begin(ctx)
if err != nil {
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
}
defer tx.Rollback(ctx)
// Validate the type exists and is concrete (mirror httpapi.CreateEntity).
var isAbstract bool
if err := tx.QueryRow(ctx, `SELECT is_abstract FROM entity_types WHERE name = $1`, entityType).Scan(&isAbstract); err != nil {
return textResult(fmt.Sprintf("error: entity type %q not found in ontology", entityType)), nil
}
if isAbstract {
return textResult(fmt.Sprintf("error: type %q is abstract — pick a concrete subtype", entityType)), nil
}
// Default state from the type's lifecycle unless the caller
// supplied one. Caller-supplied states are validated against
// the lifecycle's declared states — a create_entity bypass of
// lifecycle guardrails would let an agent create in a terminal
// state (destroyed) without satisfying the preconditions that
// set_entity_state enforces for the same transition.
var state *string
var lsDefault, statesRaw string
if err := tx.QueryRow(ctx, `SELECT coalesce(ld.default_state,''), coalesce(ld.states::text,'')
FROM lifecycle_defs ld
JOIN entity_types et ON et.lifecycle_id = ld.id
WHERE et.name = $1`, entityType).Scan(&lsDefault, &statesRaw); err == nil {
var validStates []string
json.Unmarshal([]byte(statesRaw), &validStates)
if stateStr != "" {
found := false
for _, s := range validStates {
if s == stateStr {
found = true
break
}
}
if !found && len(validStates) > 0 {
return textResult(fmt.Sprintf("error: state %q not declared in %s lifecycle (states: %s). Use the default (%s) or omit state.", stateStr, entityType, strings.Join(validStates, ","), lsDefault)), nil
}
state = &stateStr
} else if lsDefault != "" {
state = &lsDefault
}
}
id, err := uuid.NewV7()
if err != nil {
return textResult(fmt.Sprintf("error: gen id: %v", err)), nil
}
var createdName string
if err := tx.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, state, attributes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING name`,
id, slug, entityType, name, state, attrsJSON).Scan(&createdName); err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return textResult(fmt.Sprintf("Entity %q already exists — use update_entity_attributes to change it.", slug)), nil
}
return textResult(fmt.Sprintf("error creating %s: %v", slug, err)), nil
}
res, derr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, createdName, attrsJSON)
if derr != nil {
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, derr)), nil
}
if cerr := tx.Commit(ctx); cerr != nil {
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
}
return textResult(formatCreateResult(slug, entityType, res)), nil
}},
{tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.", {tool: &mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
InputSchema: objSchema( InputSchema: objSchema(
prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."}, prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
@@ -190,7 +294,18 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
} }
attrsJSON, _ := json.Marshal(attrs) attrsJSON, _ := json.Marshal(attrs)
ct, err := pool.Exec(ctx, `
// Run the merge + check regeneration in one transaction so the
// derived checks always see the post-merge attributes. Mirrors
// httpapi.PatchEntity; without this, setting an entity's
// `monitoring` attribute via MCP silently produced no checks.
tx, err := pool.Begin(ctx)
if err != nil {
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
}
defer tx.Rollback(ctx)
ct, err := tx.Exec(ctx, `
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now() UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
WHERE slug = $1`, slug, string(attrsJSON)) WHERE slug = $1`, slug, string(attrsJSON))
if err != nil { if err != nil {
@@ -199,7 +314,65 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
if ct.RowsAffected() == 0 { if ct.RowsAffected() == 0 {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
} }
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil
var id uuid.UUID
var entityType, name string
var mergedAttrs []byte
if err := tx.QueryRow(ctx, `SELECT id, type, name, attributes FROM entities WHERE slug = $1`, slug).
Scan(&id, &entityType, &name, &mergedAttrs); err != nil {
return textResult(fmt.Sprintf("error reloading %s: %v", slug, err)), nil
}
res, cerr := db.EnsureEntityChecks(ctx, tx, id, slug, entityType, name, mergedAttrs)
if cerr != nil {
return textResult(fmt.Sprintf("error deriving checks for %s: %v", slug, cerr)), nil
}
if cerr := tx.Commit(ctx); cerr != nil {
return textResult(fmt.Sprintf("error committing %s: %v", slug, cerr)), nil
}
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).%s", slug, len(attrs), formatCheckResult(res))), nil
}},
{tool: &mcp.Tool{Name: "set_entity_state", Description: "Transition an entity to a new lifecycle state — the entity-graph \"delete\" surface, since this system never hard-deletes entities. Use retire/deprecate to take an entity out of service, destroy for terminal removal, or active to revive. The target state must be a declared transition in the entity type's lifecycle (e.g. active→deprecated, deprecated→active); preconditions (no inbound edges, backups verified, etc.) are enforced — an error tells you what's blocking. Does NOT require approval (knowledge-graph mutation, not live infrastructure).",
InputSchema: objSchema(
prop{"slug", "string", "Entity slug."},
prop{"state", "string", "Target lifecycle state."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
slug, _ := args["slug"].(string)
targetState, _ := args["state"].(string)
if slug == "" || targetState == "" {
return textResult("error: slug and state are required"), nil
}
tx, err := pool.Begin(ctx)
if err != nil {
return textResult(fmt.Sprintf("error: begin tx: %v", err)), nil
}
defer tx.Rollback(ctx)
var id uuid.UUID
var entityType, currentState string
if err := tx.QueryRow(ctx, `SELECT id, type, coalesce(state,'') FROM entities WHERE slug = $1`, slug).
Scan(&id, &entityType, &currentState); err != nil {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
}
if err := db.ValidateTransition(ctx, tx, id, entityType, currentState, targetState); err != nil {
return textResult(fmt.Sprintf("error: %v", err)), nil
}
ct, err := tx.Exec(ctx, `UPDATE entities SET state = $2, updated_at = now() WHERE id = $1`, id, targetState)
if err != nil {
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
}
if ct.RowsAffected() == 0 {
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
}
if err := tx.Commit(ctx); err != nil {
return textResult(fmt.Sprintf("error: commit: %v", err)), nil
}
return textResult(fmt.Sprintf("Transitioned %s: %s → %s.", slug, currentState, targetState)), nil
}}, }},
{tool: &mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.", {tool: &mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
@@ -236,6 +409,40 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
}}, }},
{tool: &mcp.Tool{Name: "end_relationship", Description: "End an existing relationship (soft-delete by setting valid_to) — the graph-structure \"delete\" surface. Use it when you discover an edge is no longer true (a service moved hosts, a route was removed, a dependency dissolved). The edge is kept for history; only the currently-active edge is ended. Idempotent — ending an already-ended or non-existent edge is a no-op. Does NOT require approval.",
InputSchema: objSchema(
prop{"source", "string", "Source entity slug."},
prop{"target", "string", "Target entity slug."},
prop{"type", "string", "Relationship type name."},
),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
source, _ := args["source"].(string)
target, _ := args["target"].(string)
relType, _ := args["type"].(string)
if source == "" || target == "" || relType == "" {
return textResult("error: source, target, and type are required"), nil
}
var sourceID, targetID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil {
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
}
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil {
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
}
ct, err := pool.Exec(ctx, `
UPDATE relationships SET valid_to = now()
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL`,
sourceID, targetID, relType)
if err != nil {
return textResult(fmt.Sprintf("error ending relationship: %v", err)), nil
}
if ct.RowsAffected() == 0 {
return textResult(fmt.Sprintf("No active relationship %s —%s→ %s found.", source, relType, target)), nil
}
return textResult(fmt.Sprintf("Ended: %s —%s→ %s.", source, relType, target)), nil
}},
{tool: &mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics", {tool: &mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}), InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
}, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, handler: func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -819,3 +1026,25 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
}}, }},
} }
} }
// formatCheckResult renders a human-readable summary of what check derivation
// did, appended to a base status message. Shared by create_entity and
// update_entity_attributes so both surface the same check-regeneration signal
// (created / undeclared / skipped) to the agent.
func formatCheckResult(res checkdefaults.Result) string {
var b strings.Builder
if res.Created > 0 {
fmt.Fprintf(&b, " Derived %d check(s).", res.Created)
}
if res.Undeclared {
b.WriteString(" Type declares no monitoring — no checks derived (set the entity's `monitoring` attribute and call update_entity_attributes to regenerate).")
}
for _, s := range res.Skipped {
fmt.Fprintf(&b, " Skipped %s (%s).", s.Kind, s.Reason)
}
return b.String()
}
func formatCreateResult(slug, entityType string, res checkdefaults.Result) string {
return fmt.Sprintf("Created %s (%s).%s", slug, entityType, formatCheckResult(res))
}

View File

@@ -105,6 +105,15 @@ var curlLeadRe = regexp.MustCompile(`(?i)^curl\b`)
// When any of these appears, the curl command is no longer read-only. // 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`) 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`)
// curlDevNullOutRe matches curl output redirected to /dev/null in any of curl's
// argument forms (space, =, or attached). /dev/null is a no-op sink, so a GET
// that discards its body — the canonical reachability idiom
// `curl -o /dev/null -w '%{http_code}' URL` — is read-only. Output to any real
// path (-o /tmp/x) stays a potential mutation. Stripped before curlMutateRe so
// the remaining flags (-X, -d, ...) still classify correctly: a
// `curl -o /dev/null -X POST` stays config_mutation.
var curlDevNullOutRe = regexp.MustCompile(`(?i)(^|\s)-o\s*/dev/null(\s|$)|(^|\s)--output[=\s]\s*/dev/null(\s|$)`)
// redirectOutRe matches shell output redirection to a file (> or >> followed // redirectOutRe matches shell output redirection to a file (> or >> followed
// by a path), but excludes the file-descriptor merge form `>&<digit>` (e.g. // 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 // `2>&1`) which only rearranges streams and writes nothing to disk. RE2 has
@@ -308,6 +317,10 @@ func curlIsReadOnly(curlCmd string) bool {
if !curlLeadRe.MatchString(curlCmd) { if !curlLeadRe.MatchString(curlCmd) {
return false return false
} }
// -o /dev/null is a no-op sink: strip it before flag detection so the
// canonical GET-and-discard reachability probe stays read-only.
// A `curl -o /dev/null -X POST` still fails curlMutateRe after stripping.
curlCmd = curlDevNullOutRe.ReplaceAllString(curlCmd, " ")
if curlMutateRe.MatchString(curlCmd) { if curlMutateRe.MatchString(curlCmd) {
return false return false
} }

View File

@@ -102,6 +102,33 @@ func TestClassifyCommand_CurlPipeSh_ConfigMutation(t *testing.T) {
} }
} }
func TestClassifyCommand_CurlDevNull_ReadOnly(t *testing.T) {
// -o /dev/null is a no-op sink — the canonical GET-and-discard
// reachability idiom must stay read_only. Output to real paths stays
// config_mutation. POST/data flags after stripping still gate.
cases := []struct {
cmd string
cls string
}{
// read_only: GET with body discarded to /dev/null
{`curl -o /dev/null -w '%{http_code}' --connect-timeout 10 http://192.168.8.101:8123`, RiskReadOnly},
{`curl -sS -o /dev/null https://home.hubris.network`, RiskReadOnly},
{`curl --output /dev/null https://example.com`, RiskReadOnly},
{`curl -o /dev/null https://example.com`, RiskReadOnly},
{`curl -o/dev/null -w '%{http_code}' https://example.com`, RiskReadOnly},
// config_mutation: POST/data still caught after stripping devnull
{`curl -o /dev/null -X POST https://example.com`, RiskConfigMutation},
{`curl -o /dev/null -d '{"x":1}' https://example.com`, RiskConfigMutation},
// config_mutation: -o to real path stays config_mutation
{`curl -o /etc/caddy/Caddyfile http://example.com`, RiskConfigMutation},
}
for _, c := range cases {
if got := ClassifyCommand(c.cmd, ""); got != c.cls {
t.Errorf("ClassifyCommand(%q) = %q, want %q", c.cmd, got, c.cls)
}
}
}
func TestClassifyCommand_DefaultEscalatesToConfigMutation(t *testing.T) { func TestClassifyCommand_DefaultEscalatesToConfigMutation(t *testing.T) {
cases := []string{ cases := []string{
"apt-get install -y nginx", "apt-get install -y nginx",

View File

@@ -47,6 +47,15 @@ Read-only commands auto-run (no approval). Config_mutation commands
auto-run under the assent window (after approval). Destructive commands auto-run under the assent window (after approval). Destructive commands
always need explicit typed confirmation. always need explicit typed confirmation.
**Never mark a step `done` if its tool calls errored.** If `run` timed out,
`update_entity_attributes` returned "not found", `create_relationship` returned
"source entity not found", or any tool returned an error — the step is NOT done.
Diagnose the error, try an alternative (e.g. use `create_entity` when
`update_entity_attributes` reports the entity doesn't exist), and only advance
to `done` when the step's intended work actually completed. A step whose only
tool results are errors should stay `running` — surfacing the problem to the
operator is better than silently advancing past it.
### 6. WRITE BACK + COMPLETE — `complete_task` ### 6. WRITE BACK + COMPLETE — `complete_task`
Call `update_entity_attributes` for every entity you ran `run` against Call `update_entity_attributes` for every entity you ran `run` against
(versions, states, counts, timestamps). Call `create_relationship` for any (versions, states, counts, timestamps). Call `create_relationship` for any
@@ -205,6 +214,21 @@ disappear.
`list_lxcs` answers the same question in one call. Use it. `list_lxcs` answers the same question in one call. Use it.
- When a bulk tool's summary isn't enough for a specific entity, call the - When a bulk tool's summary isn't enough for a specific entity, call the
per-entity tool for that one entity — not for every entity in the fleet. per-entity tool for that one entity — not for every entity in the fleet.
- **Cap pre-plan exploration:** prefer `list_entities(limit)` +
`get_entity_knowledge` (context for one entity, one call) over N+1
`get_entity`/`get_relations` chains. If you've already called
`get_entity_knowledge(slug)` and need more, call `get_entity(slug)` +
`get_relations(slug)` — not `list_entities` without a limit scanning the
whole entity table.
- **Group parallel reads:** `get_entity_knowledge`, `search_knowledge`,
`get_entity`, and `get_relations` are all read-only DB calls that can
be batched in a single tool-call block. Do not sequentialize them one
per turn when they are independent.
- **Source-reading on prod (`run cat/grep/find /opt/…`) is NOT the way to
learn how the platform works.** The MCP tools ARE the interface. If you
need to understand a check lifecycle or a scheduler behavior, search
`search_knowledge("oikos check lifecycle")` or ask the operator — do
not treat the prod host as a code repository you grep.
## Policy awareness ## Policy awareness
@@ -355,6 +379,26 @@ port is busy, find a free one. Only surface to the operator if you've tried
reasonable alternatives and none worked. An error in one step is not a reason reasonable alternatives and none worked. An error in one step is not a reason
to stop the entire turn — it's a reason to try a different approach. to stop the entire turn — it's a reason to try a different approach.
**When you hit a genuine missing capability — STOP and ask, don't bypass:**
If a tool returns `entity … not found` when you're trying to create something
(a check, an ingress, a cert, a new service), the entity doesn't exist yet —
use `create_entity`. If you need to retire/delete an entity, use
`set_entity_state`. If you need to remove a relationship, use
`end_relationship`. If NONE of these fit and you truly lack a tool, **tell the
operator directly: "I need to X, but no MCP tool does that — can you create it
via the API?"** Do NOT pivot to `run find/grep/cat` on `/opt/homelab-context`
to reverse-engineer how the platform works — MCP tools are the interface, not
the prod source tree.
**Self-grounding — use the DB, don't invent:**
- `run` targets must be `host:<slug>`, `lxc:<slug>`, or `vm:<slug>` — never
`ws:`, raw container names, or Docker Compose service aliases.
- Never invent an IP address or subnet. Query `get_entity("service:oikos")` for
the real API address, `get_entity("host:<name>")` for a host's real LAN IP,
`list_lxcs` for container addresses. The DB is authoritative; your guess is
wrong (the homelab has multiple subnets — `192.168.8.0/24`, `192.168.178.0/24`,
etc. — and guessing the wrong one wastes turns).
**A hung command is not a failed command — investigate before retrying.** **A hung command is not a failed command — investigate before retrying.**
If a `run` call times out or returns "ERROR" (e.g. SSH killed, signal, If a `run` call times out or returns "ERROR" (e.g. SSH killed, signal,
gateway timeout), DO NOT immediately retry the same command with different gateway timeout), DO NOT immediately retry the same command with different

View File

@@ -0,0 +1,224 @@
# 2026-08-03 — Session review: `service:haos` monitoring + agent capability gaps
**Status:** Plan (audit complete; ready to implement).
**Reviewed session:** `23da10db-46a9-444c-bbde-ca9457bd9087` — *"Work out what
monitoring checks service:haos should have and configure them."*
**Method:** Direct Postgres read of `agent_sessions`/`agent_messages`/
`agent_activity`/`session_plan_steps` on the prod mac-mini (oikos prod runs here
in docker compose project `oikos`; gateway `:8092`), cross-referenced with the
code paths in `internal/mcp`, `internal/httpapi`, `internal/policy`,
`internal/checkdefaults`, `internal/db/seed.go`.
---
## 1. Session audit (objective vs outcome)
| Dimension | Finding |
|---|---|
| Objective | Determine + configure monitoring checks for `service:haos` (HAOS VM 108, `home.hubris.network`, `192.168.8.101:8123`). |
| Outcome | ❌ **Failed/stuck.** `status=executing`, `outcome=null` ~4 min after last activity (UTC); never reached a terminal state. Only the *existing* `check:vm-status:vm:haos:0` stub got populated; the three **new** checks (`http:service`, `http:ingress`, `cert-expiry`) and their `ingress:`/`cert:` entities were never created. |
| Tool calls | **116** (vs the >30 N+1 failure signature). ~45 redundant `list_entities`/`get_entity`/`get_relations`, then a ~15-min storm of `run` doing `find`/`grep`/`cat` on prod source. |
| Plan | 8 steps proposed; steps 14 genuinely done; **step 5 falsely marked "done"** after both its tool calls errored `entity not found`; steps 68 never started. |
| Operator friction | 3 manual interventions: `status`, `proceed`, *"why dony you use the mcp?"*; plus a **44-minute approval stall** (19:52→20:36) on two trivial reachability curls. |
| Severity | **blocker** (capability gap) + **friction** (classifier, plan-state, reaping). |
### Timeline (UTC)
- **19:4519:50** — read-only exploration; `run` correctly blocked ("No plan… call set_goal then propose_plan"). Good guard.
- **19:50** — `propose_plan` (8 steps).
- **19:52** — two `curl … -o /dev/null -w '%{http_code}'` reachability probes → both classified `config_mutation` → one queued for approval (`019fc92e…`), second blocked ("approval already pending").
- **19:52 → 20:36 (44 min)** — idle, waiting on operator approval.
- **20:36** — approval granted ("auto via assent window"); both curls → 200/200.
- **20:37** — step 4 ✅: populated `check:vm-status:vm:haos:0` + `checks` edge.
- **20:37:58** — step 5 ❌: `update_entity_attributes("check:http:service:haos:0")`**`entity not found`**; `create_relationship`**`source entity not found`**. *(There is no create tool.)*
- **20:3820:47** — spiral: `search_knowledge` (empty), then `run find/grep/cat` across `/opt/homelab-context/**/*.go` to reverse-engineer check creation. Reads `checkdefaults.go`, `monitoring.go`, `default_checks.go`, `checks.go`, `coverage.go`.
- **20:42** — sets `service:haos` `monitoring: ["http"]` via `update_entity_attributes`, hoping `checkdefaults.Ensure()` auto-generates. **It does not** (see A2).
- **20:4220:50** — tries to reach the REST API directly: `psql` on hubris (cmd 127), `docker exec` on hubris (docker absent), `curl http://192.168.178.25:8090` (wrong subnet; real net is `192.168.8.x`; exit 7), `curl http://oikos-api:8090` (MCP routes to hubris which can't resolve the mac-mini docker alias; 30s timeouts ×2), `ssh root@192.168.178.25` (no route). Final `update_entity_attributes` on `ingress:`/`cert:``not found`.
- **20:50:38** — last activity: a failed 30s `run`. Session goes silent, never terminates.
---
## 2. Root-cause findings (with code evidence)
### A1 — No entity-creation capability in the MCP toolset *(the blocker)*
`internal/mcp/tools.go` registers **37 tools**; the only entity-mutation surface is
`update_entity_attributes` (merge into an **existing** entity) and
`create_relationship` (needs **existing** source+target). Neither can create a new
entity. The capability **does** exist at the HTTP layer — `CreateEntity`
(`internal/httpapi/impl.go:865`, `POST /api/v1/entities`) — it is simply not exposed
to the agent. Every "set up / onboard / configure entity X" task that needs a new
check/ingress/cert/service hits this wall.
### A2 — MCP `update_entity_attributes` bypasses `ensureDefaultChecks`
`ensureDefaultChecks` (`internal/httpapi/default_checks.go:20`) is invoked **only**
from the HTTP handlers: `CreateEntity` (`impl.go:1012`) and `PatchEntity`
(`impl.go:1280`). `grep ensureDefaultChecks internal/mcp/`**no matches**: the MCP
tool writes attributes straight to the store, so flipping `service:haos`
`monitoring:["http"]` never regenerated its checks. The agent's fallback strategy
was structurally doomed via MCP.
### A3 — `-o /dev/null` curl idiom misclassified as `config_mutation`
`internal/policy/command.go:106` `curlMutateRe` matches `(?:^|\s)-(?:d|F|T|o)\b` — so
`-o` (output-file) is treated as mutation. The canonical read-only reachability probe
`curl -sS -o /dev/null -w '%{http_code}' …` therefore escalates to approval. This is
the entire 44-minute stall. (`curlIsReadOnly` at `command.go:307` only passes for GET
with no `-o`/`-d`/`-X`/`>`.) A pure GET that discards the body is the single most
common health probe and shouldn't need approval.
### A4 — No platform self-knowledge doc for the check lifecycle
`search_knowledge("create check entity how to add new check monitoring")` → empty.
The agent re-derived the whole mechanism from source on prod (~15 min, dozens of
`run`). There is no agent/operator runbook explaining: check slugs are
`check:<kind>:<target>:<n>`; `check_defs` are derived from the type's `monitoring`
spec by `checkdefaults.Ensure`; Ensure runs at **seed/deploy** and on **HTTP
create/patch**, not via MCP.
### A5 — False plan progress (step marked done on failure)
At 20:37:58 both tool calls for step 5 returned `error: entity not found`, yet the
agent advanced step 5→`done`. Plan-state integrity hole: a step whose actions error
should not transition to `done`. (`session_plan_steps` confirms seq 5 = `done`.)
### A6 — No "missing-capability" escalation; self-grounding failures
On detecting the dead-end (no create tool) the agent never told the operator *"I lack
a tool to create entities — please create them"*; instead it tried to bypass its own
platform. Grounding errors: invented IP `192.168.178.25` (real LAN is `192.168.8.x`),
ran `run` against `ws:mac-mini` ("unsupported target — must be host:/lxc:/vm:"),
assumed `docker` exists on hubris, assumed the docker-alias `oikos-api` resolves from
hubris. The agent didn't query `get_entity("service:oikos")` for the real address.
### A7 — Sessions never reap from `executing`
Last activity 20:50; status still `executing` with no turn running. There is no
idle-timeout / abandoned transition when a turn ends without resolution. (Fleet-wide:
176 done / 9 failed / 1 executing; the 9 prior failures are pre-v0.15.0, mostly
approval-stalls and entity-not-found — same families.)
### A8 — N+1 tool fan-out (116 calls)
Dozens of redundant `list_entities`/`get_entity`/`get_relations` before proposing a
plan, plus the source-reading `run` storm. Above the >30-per-turn signature; indicates
weak bulk-tool use and under-constrained exploration before planning.
---
## 3. Improvement plan (ordered)
**Scope decision (confirmed with operator):** general `create_entity` MCP tool **+
wire regen** — solves this case and the 67-entity blast radius (§4).
### Task 1 — `create_entity` MCP tool *(fixes A1; the centerpiece)*
- Register a new tool `create_entity(slug, type, name, attributes?)` in
`internal/mcp/tools.go` that **reuses** `httpapi.CreateEntity`
(`impl.go:865`) / the same store path — do not hand-roll. It must run
`ensureDefaultChecks` (free, since it goes through the create path).
- **Approval policy:** no approval required for the entity itself — it mutates the
knowledge graph, matching the existing no-approval stance of
`update_entity_attributes`/`create_relationship`/`upsert_knowledge`. (Derived checks
are safe/read-side; if a check kind is ever deemed mutating, gate *that* in the
scheduler, not here.)
- Validate `type` against `entity_types`; reject unknown slugs/types with a clear
error. Idempotent on existing slug (return the existing entity, mirroring the HTTP
`ETag`/conflict behavior).
- Expose to the agent via the tool-list build path used by `cmd/nomos/agent.go`.
### Task 2 — MCP `update_entity_attributes` triggers `ensureDefaultChecks` *(fixes A2)*
- After the attribute merge in the MCP handler, call `ensureDefaultChecks` with the
post-merge entity (same args as `impl.go:1280`). This makes "set monitoring → checks
regenerate" work via MCP, matching HTTP semantics.
- Mind the `default_checks.go:14-19` caveat: a service whose address comes from its
host edge may still produce no checks until the hosting edge exists — log/return
that as an explicit result so the agent knows to create the edge next.
### Task 3 — Classifier: read-only `curl` with `-o /dev/null` *(fixes A3)*
- In `internal/policy/command.go` `curlIsReadOnly`, treat `-o /dev/null` (and
`--output /dev/null`) as read-only — it's a no-op sink. Keep `-o <realpath>` as
mutation. Add `TestClassifyCommand_CurlDevNull_ReadOnly` next to the existing
`TestClassifyCommand_CurlPipeSh_ConfigMutation`.
- Coach complement: in `nomos/SOUL.md`, note that reachability probes should use
`curl -I` or `-o /dev/null` GETs (now read-only) rather than POSTs.
### Task 4 — Plan-state integrity: don't mark `done` on errored actions *(fixes A5)*
- In `cmd/nomos` (`agent.go`/`tasks.go` where `update_plan_step` is emitted), a step
whose turn ended with only error/`not-found` tool results must **not** auto-advance
to `done`; leave it `running`/`blocked` and surface the failure to the operator.
Minimal: if every tool call in the step returned an `error:*` result, hold the step.
### Task 5 — Stuck-session reaping *(fixes A7)*
- Add an idle sweep (extend the existing continuation/idle worker in `cmd/nomos`) that
transitions a session from `executing``failed` (or a new `stuck`) when no turn has
run for N minutes and no approval is pending. Emit an event so the UI (F3 terminal
handling) clears the spinner. Pick N (recommend 30 min) — confirm in review.
### Task 6 — Missing-capability escalation + grounding *(fixes A6)*
- `nomos/SOUL.md`: when a mutation tool returns `entity … not found` on a create
intent, the agent must **stop and ask the operator** (or now use `create_entity`)
rather than pivot to `run`/SSH/API-bypass. Forbidden: inventing IPs/subnets; instead
`get_entity("service:oikos")` for the real API address. `run` targets must be
`host:/lxc:/vm:` slugs (state the contract explicitly).
### Task 7 — Runbook: "how checks work / how to add monitoring" *(fixes A4)*
- Upsert a knowledge doc (via `upsert_knowledge`, linked to the `agent:nomos` and
`document:infrastructure/monitoring` entities) covering: check slug grammar,
`checkdefaults.Ensure` triggers (seed + HTTP create/patch, now also MCP), the
`monitoring` per-entity override, the host-edge caveat, and the canonical way to add
monitoring to an entity (create/patch entity → checks derive).
### Task 8 — (Lower priority) exploration budget / bulk-tool use *(A8)*
- `nomos/SOUL.md`: prefer `list_entities(limit)` + `get_entity_knowledge` bulk calls
over N+1 `get_entity`/`get_relations` fans; cap pre-plan exploration. Optional
guardrail in `agent.go` (warn at >N same-tool calls per turn).
### Recommended sequence
1 → 2 → 3 → 4 → 7 → 5 → 6 → 8. (1+2 unblock the whole task class; 3 kills the
approval stall; 4+5 fix state integrity; 7 is cheap leverage; 6+8 are persona
hardening.)
---
## 4. Uncovered cases — the capability-gap blast radius
The existing F1F8 plans (`2026-08-03-nomos-chat-reliability-and-ux-audit.md`,
shipped v0.15.0) and the turn-scheduler review cover **only** UI / streaming / turn
serialization / connection UX. **None** addresses agent *capability* or
MCP↔HTTP integration. This session exposes the uncovered class:
- **67 entities currently have no `check:` relationship** (DB query): 40 `lxc`, 26
`service`, 1 `vm`. Any "add monitoring to X" task fails identically until Tasks 1+2.
- **Whole task families blocked by the no-create gap:** onboarding a new host/LXC/VM,
declaring a new service/ingress/cert/dns, adding any check that doesn't already
exist, registering a relationship target that doesn't exist yet. All currently
require an operator to hand-edit `seeds/inventory.yaml` and re-seed.
- **MCP↔HTTP semantic drift (generalize A2):** audit other MCP mutation tools for
side-effects that the HTTP handlers perform but the MCP path skips (check regen,
drift-flagging, audit fields, idempotency). Each is a latent "agent did the right
thing but nothing happened" bug.
- **Classifier read-only false-positives (generalize A3):** beyond `-o /dev/null`,
review other common read-only idioms that escalate (`curl` with benign flags,
compound read-only commands) — friction compounds into approval stalls and stuck
sessions.
- **No terminal/`stuck` reaping (generalize A7):** any turn that ends unresolved
leaves the session `executing` forever; the UI never shows "done/failed".
---
## 5. Validation
- **Task 1/2:** `go test ./internal/mcp/... ./internal/httpapi/...` — new test creates
`check:http:service:haos:0` via `create_entity`, asserts the entity exists **and**
that a `check_def` row was derived; then `update_entity_attributes(service:haos,
monitoring:["http"])` via MCP and assert checks regenerate (currently absent).
- **Task 3:** `go test ./internal/policy/` — `curl -sS -o /dev/null -w '%{http_code}'
URL` ⇒ `read_only`; `curl -o /tmp/x URL` ⇒ `config_mutation`.
- **Task 4:** `cmd/nomos` test — a step whose only tool result is `error:*` stays
non-`done`.
- **Task 5:** idle-sweep test — session with no turn for N min and no pending approval
⇒ `failed` (+ event emitted).
- **End-to-end re-run:** replay the haos goal against a local nomos; expect the three
checks + `ingress:`/`cert:` entities created in <15 tool calls with **zero**
approvals and a `done` outcome.
## 6. Out of scope / open questions
- Whether `create_entity` for sensitive types (e.g. `secret`, `key`) should require
approval even though it's graph-only — recommend: same no-approval stance now, add
type-specific gating later if abused.
- The exact stuck-reap window N (recommend 30 min) and whether to introduce a distinct
`stuck` status vs reuse `failed`.
- Whether to also expose a `delete_entity`/`retire_entity` MCP tool (not needed for
this case; lifecycle retirement is a separate flow).