v0.18.0: MCP entity-graph CRUD, lifecycle validation, curl -o /dev/null fix
- 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:
104
archive/knowledge/infrastructure/oikos-check-lifecycle.md
Normal file
104
archive/knowledge/infrastructure/oikos-check-lifecycle.md
Normal 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
34
internal/db/checks.go
Normal 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
146
internal/db/lifecycle.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
251
internal/mcp/create_entity_test.go
Normal file
251
internal/mcp/create_entity_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ¤tState); 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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 1–4 genuinely done; **step 5 falsely marked "done"** after both its tool calls errored `entity not found`; steps 6–8 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:45–19: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:38–20: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:42–20: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 F1–F8 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).
|
||||||
Reference in New Issue
Block a user