Compare commits
3 Commits
a126cfa710
...
0920c4cb6d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0920c4cb6d | |||
| 2254a07baf | |||
| 1b9c761274 |
@@ -33,6 +33,7 @@ const (
|
|||||||
KindBackup = "backup-freshness"
|
KindBackup = "backup-freshness"
|
||||||
KindCertExpiry = "cert-expiry"
|
KindCertExpiry = "cert-expiry"
|
||||||
KindVMStatus = "vm-status"
|
KindVMStatus = "vm-status"
|
||||||
|
KindDNS = "dns"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultBackupMaxAge is how long a backup target may go without a new
|
// defaultBackupMaxAge is how long a backup target may go without a new
|
||||||
@@ -306,6 +307,23 @@ func buildKind(kind string, t Target, attrs map[string]any, host, user string, p
|
|||||||
interval: 60,
|
interval: 60,
|
||||||
}}, ""
|
}}, ""
|
||||||
|
|
||||||
|
case KindDNS:
|
||||||
|
// Resolve the entity's name via DNS to verify the zone is reachable.
|
||||||
|
// Uses the entity name (zone apex) or falls back to the slug.
|
||||||
|
name := t.Name
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimPrefix(t.Slug, "zone:")
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return nil, "no name to resolve"
|
||||||
|
}
|
||||||
|
return []checkDef{{
|
||||||
|
kind: "dns",
|
||||||
|
config: map[string]any{"name": name},
|
||||||
|
interval: 300, // 5 min — DNS changes are rare; the cost of a miss
|
||||||
|
// is a stale IP, not a service outage.
|
||||||
|
}}, ""
|
||||||
|
|
||||||
case KindCertExpiry:
|
case KindCertExpiry:
|
||||||
// The host whose cert to read (SNI / cert CN). Prefer an explicit
|
// The host whose cert to read (SNI / cert CN). Prefer an explicit
|
||||||
// `hostname` attribute, then `cn`, then a dotted name. Hourly: expiry
|
// `hostname` attribute, then `cn`, then a dotted name. Hourly: expiry
|
||||||
|
|||||||
@@ -872,6 +872,28 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VM transport pre-flight: qm guest exec requires the QEMU guest agent
|
||||||
|
// to be running inside the VM. If it's not, the execution would queue
|
||||||
|
// for approval and never execute — the agent has no way to learn it's
|
||||||
|
// stuck (spotted live 2026-08-05: vm:zimaos had qemu_guest_agent=not_running,
|
||||||
|
// the run queued forever, and the agent fell back to unsafe raw SSH).
|
||||||
|
if strings.HasPrefix(targetSlug, "vm:") {
|
||||||
|
var rawAttrs []byte
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT attributes FROM entities WHERE id = $1`, targetID).Scan(&rawAttrs); err == nil {
|
||||||
|
var attrs map[string]any
|
||||||
|
if json.Unmarshal(rawAttrs, &attrs) == nil {
|
||||||
|
if qga, ok := attrs["qemu_guest_agent"]; ok {
|
||||||
|
qgaStr, _ := qga.(string)
|
||||||
|
if qgaStr == "not_running" || qgaStr == "" {
|
||||||
|
return textResult(fmt.Sprintf(
|
||||||
|
"run on %s blocked: QEMU guest agent is not running (%s). qm guest exec cannot reach this VM. Start the agent inside the guest first (e.g. via SSH/systemctl start qemu-guest-agent), then re-run. If the agent is running but the entity attribute is stale, update it with update_entity_attributes(slug=%s, attributes={\"qemu_guest_agent\":\"running\"}).",
|
||||||
|
targetSlug, qgaStr, targetSlug))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dedup: an identical pending command (same target, command, and
|
// Dedup: an identical pending command (same target, command, and
|
||||||
// purpose) blocks a re-request — stops a tool-calling loop from queuing
|
// purpose) blocks a re-request — stops a tool-calling loop from queuing
|
||||||
// the same approval repeatedly.
|
// the same approval repeatedly.
|
||||||
|
|||||||
@@ -324,6 +324,26 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
|
|||||||
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
|
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip scheduler-owned keys: health is computed by the scheduler
|
||||||
|
// from probe results (spotted live 2026-08-05: an agent set
|
||||||
|
// health:"healthy" on lxc:nfs-export, which derived 4 spurious checks).
|
||||||
|
// Agents can observe health via get_health_summary / list_checks.
|
||||||
|
var blocked []string
|
||||||
|
for _, key := range []string{"health", "last_check_at", "last_check"} {
|
||||||
|
if _, ok := attrs[key]; ok {
|
||||||
|
delete(attrs, key)
|
||||||
|
blocked = append(blocked, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(blocked) > 0 {
|
||||||
|
// Re-marshal the filtered attrs
|
||||||
|
filtered, _ := json.Marshal(attrs)
|
||||||
|
attrsStr = string(filtered)
|
||||||
|
if len(attrs) == 0 {
|
||||||
|
return textResult(fmt.Sprintf("Updated %s: no allowed attributes provided. The following keys are scheduler-owned and ignored: %s. Use get_health_summary or list_checks to observe entity health.", slug, strings.Join(blocked, ", "))), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
attrsJSON, _ := json.Marshal(attrs)
|
attrsJSON, _ := json.Marshal(attrs)
|
||||||
|
|
||||||
// Run the merge + check regeneration in one transaction so the
|
// Run the merge + check regeneration in one transaction so the
|
||||||
@@ -554,7 +574,7 @@ func allTools(pool *db.Pool, agentID uuid.UUID) []toolReg {
|
|||||||
// for future runbook extraction — especially pct_create DNS/VMID logic.
|
// for future runbook extraction — especially pct_create DNS/VMID logic.
|
||||||
// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
|
// DO NOT re-register this tool. See plans/2026-07-10-general-gated-execution.md.
|
||||||
|
|
||||||
{tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host, LXC, or VM. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.",
|
{tool: &mcp.Tool{Name: "run", Description: "Run ANY shell command against any host, LXC, or VM. This is the general execution primitive — prefer it over asking the operator to run something manually, and don't wait for a matching fixed action to exist. Every command is automatically risk-classified: read-only inspection (cat, systemctl status, docker ps, journalctl, df, git status, ...) runs immediately; anything that changes state requires operator approval (granted by the operator replying \"go ahead\"/\"yes\" in chat, or via the Approve button); commands matching a destructive pattern (rm -rf, dd, mkfs, pct/qm destroy, DROP TABLE, reboot, piping curl into a shell, ...) always require approval regardless of what you declare. You cannot talk your way past the destructive check by declaring a lower risk.\n\nHost-level mutations (apt-get install, dpkg, systemctl enable) always classify as config_mutation — operator approval required.\n\nVM targets: the QEMU guest agent must be running inside the VM. If the entity's qemu_guest_agent attribute is not_running, the run is blocked immediately with a clear error.",
|
||||||
InputSchema: objSchema(
|
InputSchema: objSchema(
|
||||||
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong), lxc:<slug> (e.g. lxc:caddy), or vm:<slug> (e.g. vm:zimaos). LXC commands run via pct exec on their Proxmox host automatically. VM commands run via qm guest exec on their Proxmox host (requires the QEMU guest agent inside the VM — standard for Proxmox VMs)."},
|
prop{"target", "string", "Target entity slug: host:<slug> (e.g. host:strong), lxc:<slug> (e.g. lxc:caddy), or vm:<slug> (e.g. vm:zimaos). LXC commands run via pct exec on their Proxmox host automatically. VM commands run via qm guest exec on their Proxmox host (requires the QEMU guest agent inside the VM — standard for Proxmox VMs)."},
|
||||||
prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."},
|
prop{"command", "string", "The shell command to run. Can be a full script (multi-line, &&-chained). Runs as root."},
|
||||||
|
|||||||
@@ -299,6 +299,8 @@ func executeCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledChec
|
|||||||
return checkSSHScript(ctx, pool, cd)
|
return checkSSHScript(ctx, pool, cd)
|
||||||
case "backup-freshness":
|
case "backup-freshness":
|
||||||
return checkBackupFreshness(ctx, cd)
|
return checkBackupFreshness(ctx, cd)
|
||||||
|
case "dns":
|
||||||
|
return checkDNS(ctx, cd)
|
||||||
default:
|
default:
|
||||||
return checkResult{health: "unknown"}
|
return checkResult{health: "unknown"}
|
||||||
}
|
}
|
||||||
@@ -484,6 +486,53 @@ func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResu
|
|||||||
return checkResult{health: "healthy"}
|
return checkResult{health: "healthy"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkDNS verifies a DNS name resolves, catching a stale or unreachable
|
||||||
|
// zone. It looks up NS records first (a zone always has NS), falling back to
|
||||||
|
// an A/AAAA lookup for hostnames. Uses the system resolver; for split-horizon
|
||||||
|
// correctness reserve an explicit `server` in the config.
|
||||||
|
func checkDNS(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||||
|
cfg := struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Server string `json:"server"`
|
||||||
|
}{}
|
||||||
|
if len(cd.Config) > 0 {
|
||||||
|
_ = json.Unmarshal(cd.Config, &cfg)
|
||||||
|
}
|
||||||
|
if cfg.Name == "" {
|
||||||
|
return checkResult{health: "healthy"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve via an explicit server when supplied (split-horizon), else the
|
||||||
|
// system default resolver.
|
||||||
|
lookup := func(q string) (int, error) {
|
||||||
|
r := &net.Resolver{}
|
||||||
|
if cfg.Server != "" {
|
||||||
|
r = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
d := net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
return d.DialContext(ctx, network, net.JoinHostPort(cfg.Server, "53"))
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ns, err := r.LookupNS(ctx, q)
|
||||||
|
if err == nil && len(ns) > 0 {
|
||||||
|
return len(ns), nil
|
||||||
|
}
|
||||||
|
addrs, err2 := r.LookupHost(ctx, q)
|
||||||
|
return len(addrs), err2
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := lookup(cfg.Name)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return checkResult{
|
||||||
|
health: "down", signalKind: "dns",
|
||||||
|
evidence: fmt.Sprintf("DNS resolution failed for %q: %v", cfg.Name, err),
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return checkResult{health: "healthy"}
|
||||||
|
}
|
||||||
|
|
||||||
// checkDisk performs a disk usage check.
|
// checkDisk performs a disk usage check.
|
||||||
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||||
cfg := struct {
|
cfg := struct {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ went sideways, open an investigation.
|
|||||||
| 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0–P2 implemented; P3 ("cool stuff") ideas open |
|
| 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0–P2 implemented; P3 ("cool stuff") ideas open |
|
||||||
| 2026-07-21 | [Frontend as OS + Apps — architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned — Phase 1 ready |
|
| 2026-07-21 | [Frontend as OS + Apps — architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned — Phase 1 ready |
|
||||||
| 2026-08-04 | [Hermes MCP client integration](done/2026-08-04-hermes-mcp-client-integration.md) | Done — deployed |
|
| 2026-08-04 | [Hermes MCP client integration](done/2026-08-04-hermes-mcp-client-integration.md) | Done — deployed |
|
||||||
| 2026-08-05 | [Agent execution safety: QEMU guest agent gate + host-mutation guard](2026-08-05-agent-execution-safety-qemu-guest-agent-gate.md) | Planned |
|
| 2026-08-05 | [Agent execution safety: QEMU guest agent gate + host-mutation guard](done/2026-08-05-agent-execution-safety-qemu-guest-agent-gate.md) | Done — implemented (1b9c761) |
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
|||||||
@@ -87,10 +87,12 @@ entities:
|
|||||||
mesh: {netbird: {ip: 100.122.165.149, fqdn: netbird-ionos.netbird.selfhosted}}
|
mesh: {netbird: {ip: 100.122.165.149, fqdn: netbird-ionos.netbird.selfhosted}}
|
||||||
ssh: {user: root}
|
ssh: {user: root}
|
||||||
note: netbird mgmt+signal+relay+dashboard + coturn; sshd locked to hubris pubkey
|
note: netbird mgmt+signal+relay+dashboard + coturn; sshd locked to hubris pubkey
|
||||||
monitoring: none # host unreachable from the lab (no ICMP, port 22
|
monitoring: [http] # public HTTPS probe via https://mcp.hubris.network
|
||||||
# times out even via hubris); liveness is covered
|
# (2026-08-05: was `none` — the VPS went silent for
|
||||||
# by its services — authentik/matrix http checks
|
# 7 days because nothing probed it. The standalone-server
|
||||||
# and the matrix cert-expiry dial it on :443
|
# type inherits [ping,resource,updates] from machine, but
|
||||||
|
# SSH/ICMP don't reach it from the lab; an HTTP probe on
|
||||||
|
# the public endpoint is the reachable liveness signal).
|
||||||
- slug: "ws:mac-mini"
|
- slug: "ws:mac-mini"
|
||||||
type: workstation
|
type: workstation
|
||||||
name: mac-mini
|
name: mac-mini
|
||||||
|
|||||||
@@ -381,11 +381,13 @@ entity_types:
|
|||||||
layer: infrastructure
|
layer: infrastructure
|
||||||
lifecycle: infrastructure
|
lifecycle: infrastructure
|
||||||
description: DNS zone (e.g. split-horizon hubris.network).
|
description: DNS zone (e.g. split-horizon hubris.network).
|
||||||
monitoring: none # no `dns` checker exists yet; declaring [dns]
|
monitoring: [dns] # resolves the zone's apex via the configured
|
||||||
# made every zone an unresolvable `unmonitored`
|
# resolver, verifying the zone is authoritatively
|
||||||
# signal. Flip back to [dns] when a checker lands.
|
# reachable. (2026-08-05: was `none` — the DNS layer
|
||||||
# Requires ontology re-ingest to take effect;
|
# had zero checks, so a stale record like
|
||||||
# coverageSweep then auto-clears the stale signals.
|
# matrix→82.165.190.79 went unnoticed. Requires
|
||||||
|
# ontology re-ingest; coverageSweep clears stale
|
||||||
|
# signals after.)
|
||||||
attributes: {type: object, properties: {zone: {type: string}, authority: {type: string}}}
|
attributes: {type: object, properties: {zone: {type: string}, authority: {type: string}}}
|
||||||
dns-record:
|
dns-record:
|
||||||
parent: entity
|
parent: entity
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ approval_rules:
|
|||||||
- {entity_type: docker-container, action: restart, risk_class: reversible_low, autonomy_level: auto}
|
- {entity_type: docker-container, action: restart, risk_class: reversible_low, autonomy_level: auto}
|
||||||
- {entity_type: machine, action: apt-upgrade, risk_class: config_mutation, autonomy_level: escalate}
|
- {entity_type: machine, action: apt-upgrade, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
- {entity_type: machine, action: reboot, risk_class: config_mutation, autonomy_level: escalate}
|
- {entity_type: machine, action: reboot, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
# host-level package/kernel install (apt-get install, dpkg, modprobe, systemctl enable)
|
||||||
|
# always classifies as config_mutation — the classifier defaults to config_mutation
|
||||||
|
# for any command not in the read-only allowlist, so apt-get install reaches this
|
||||||
|
# tier naturally. Documented explicitly here so agents stop second-guessing:
|
||||||
|
# host mutations always need operator approval.
|
||||||
- {entity_type: machine, action: format-disk, risk_class: destructive, autonomy_level: never}
|
- {entity_type: machine, action: format-disk, risk_class: destructive, autonomy_level: never}
|
||||||
- {entity_type: config-repo, action: edit, risk_class: config_mutation, autonomy_level: escalate}
|
- {entity_type: config-repo, action: edit, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
- {entity_type: deploy-pipeline, action: trigger, risk_class: config_mutation, autonomy_level: escalate}
|
- {entity_type: deploy-pipeline, action: trigger, risk_class: config_mutation, autonomy_level: escalate}
|
||||||
|
|||||||
Reference in New Issue
Block a user