fix: make Nomos actually provision LXCs from chat (pct_create + web fetch)
Root cause of "asks permission but never acts": the approved pct_create execution failed to parse because the LLM emitted `"privileged":0` / `"nesting":1` (numbers) into strict `bool` fields, so the container was never created. Compounded by a hardcoded template name (debian-13.0-1) that no longer exists on the host, and no way for the agent to read the web. - flexBool: accept 0/1, "true", bool for privileged/nesting (the exact prod failure) - pct_create template pre-flight: list host cache, validate/auto-pick newest debian - pct_create services[] + post_install: one approval provisions a working service - new http_get MCP tool (sanitized, size-capped, SSRF-guarded) — agent can read repos/sites - request_execution description: target=host, full JSON schema + example - SOUL.md: agent CAN fetch the web; prefer one-step provisioning - default model deepseek-v4-flash -> v4-pro; maxIterations 15 -> 25 - unit tests for flexBool, template resolve, pkg sanitize, HTML sanitize + SSRF block Verified live on host:strong with a throwaway VMID 999: template auto-resolved, container created + booted, services installed, post_install ran, then destroyed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,10 @@ import (
|
||||
"github.com/openai/openai-go/shared"
|
||||
)
|
||||
|
||||
const maxIterations = 15
|
||||
// maxIterations bounds one chat turn's tool-calling loop. Provisioning a
|
||||
// service is a long chain (research → plan → request_execution → status), so
|
||||
// 15 was too tight and turns died with "max iterations reached" mid-deploy.
|
||||
const maxIterations = 25
|
||||
const maxLLMRetries = 1
|
||||
|
||||
var refusalDenylist = []string{
|
||||
@@ -41,7 +44,10 @@ func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug st
|
||||
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
||||
model := os.Getenv("NOMOS_MODEL")
|
||||
if model == "" {
|
||||
model = "deepseek/deepseek-v4-flash"
|
||||
// v4-pro over v4-flash: the flash tier over-narrates, occasionally
|
||||
// emits canned refusals, and is unreliable at multi-step tool use —
|
||||
// exactly the agentic provisioning path the operator needs to work.
|
||||
model = "deepseek/deepseek-v4-pro"
|
||||
}
|
||||
|
||||
provider := openai.NewClient(
|
||||
|
||||
@@ -19,7 +19,7 @@ COPY nomos/ /app/nomos/
|
||||
ENV NOMOS_MCP_URL=http://api:8090/mcp
|
||||
ENV NOMOS_AGENT_SLUG=agent:nomos
|
||||
ENV NOMOS_LISTEN=:8092
|
||||
ENV NOMOS_MODEL=deepseek/deepseek-v4-flash
|
||||
ENV NOMOS_MODEL=deepseek/deepseek-v4-pro
|
||||
|
||||
EXPOSE 8092
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ services:
|
||||
NOMOS_MCP_URL: http://api:8090/mcp
|
||||
NOMOS_AGENT_SLUG: agent:nomos
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-flash}
|
||||
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro}
|
||||
DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
||||
ports:
|
||||
- "8092:8092"
|
||||
|
||||
83
internal/httpapi/pct_create_test.go
Normal file
83
internal/httpapi/pct_create_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFlexBoolUnmarshal covers the exact production failure: the LLM emitted
|
||||
// `"privileged":0` / `"nesting":1` (numbers) and the strict bool field made the
|
||||
// already-approved pct_create execution fail to parse, so the LXC was never
|
||||
// created.
|
||||
func TestFlexBoolUnmarshal(t *testing.T) {
|
||||
type cfg struct {
|
||||
Privileged flexBool `json:"privileged"`
|
||||
Nesting flexBool `json:"nesting"`
|
||||
}
|
||||
cases := []struct {
|
||||
in string
|
||||
privileged bool
|
||||
nesting bool
|
||||
wantErr bool
|
||||
}{
|
||||
{`{"privileged":0,"nesting":1}`, false, true, false}, // the prod payload
|
||||
{`{"privileged":false,"nesting":true}`, false, true, false}, // canonical
|
||||
{`{"privileged":"1","nesting":"0"}`, true, false, false}, // stringified
|
||||
{`{"privileged":"true","nesting":"no"}`, true, false, false},
|
||||
{`{}`, false, false, false}, // absent → zero
|
||||
{`{"privileged":"maybe"}`, false, false, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
var out cfg
|
||||
err := json.Unmarshal([]byte(c.in), &out)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Fatalf("%s: err=%v wantErr=%v", c.in, err, c.wantErr)
|
||||
}
|
||||
if c.wantErr {
|
||||
continue
|
||||
}
|
||||
if bool(out.Privileged) != c.privileged || bool(out.Nesting) != c.nesting {
|
||||
t.Errorf("%s: got priv=%v nest=%v want priv=%v nest=%v",
|
||||
c.in, bool(out.Privileged), bool(out.Nesting), c.privileged, c.nesting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTemplate(t *testing.T) {
|
||||
avail := []string{
|
||||
"debian-12-standard_12.7-1_amd64.tar.zst",
|
||||
"debian-13-standard_13.0-1_amd64.tar.zst",
|
||||
"ubuntu-24.04-standard_24.04-2_amd64.tar.zst",
|
||||
}
|
||||
cases := []struct {
|
||||
requested string
|
||||
want string
|
||||
}{
|
||||
{"debian-13-standard_13.0-1_amd64.tar.zst", "debian-13-standard_13.0-1_amd64.tar.zst"}, // exact
|
||||
{"debian-13", "debian-13-standard_13.0-1_amd64.tar.zst"}, // prefix
|
||||
{"", "debian-13-standard_13.0-1_amd64.tar.zst"}, // auto newest debian
|
||||
{"debian-99", "debian-13-standard_13.0-1_amd64.tar.zst"}, // miss prefix → auto debian
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := resolveTemplate(c.requested, avail); got != c.want {
|
||||
t.Errorf("resolveTemplate(%q): got %q want %q", c.requested, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := resolveTemplate("debian-13", nil); got != "" {
|
||||
t.Errorf("empty cache should yield empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePkgs(t *testing.T) {
|
||||
in := []string{"docker.io", "git", "rm -rf /", "curl;wget", "python3-pip", ""}
|
||||
got := sanitizePkgs(in)
|
||||
want := map[string]bool{"docker.io": true, "git": true, "python3-pip": true}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v want keys %v", got, want)
|
||||
}
|
||||
for _, g := range got {
|
||||
if !want[g] {
|
||||
t.Errorf("unexpected package survived sanitize: %q", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -27,6 +28,26 @@ var (
|
||||
_sshKey []byte
|
||||
)
|
||||
|
||||
// flexBool accepts a JSON bool, number (0/1), or string ("true"/"1"/"yes").
|
||||
// LLMs routinely emit `"privileged": 0` instead of `false`; a strict `bool`
|
||||
// field made the approved pct_create execution fail to parse *after* the
|
||||
// operator had already approved it — the container was never created and the
|
||||
// operator saw "queued" with no result. This type tolerates the common shapes.
|
||||
type flexBool bool
|
||||
|
||||
func (b *flexBool) UnmarshalJSON(data []byte) error {
|
||||
s := strings.TrimSpace(strings.Trim(string(data), `"`))
|
||||
switch strings.ToLower(s) {
|
||||
case "true", "1", "yes", "on":
|
||||
*b = true
|
||||
case "false", "0", "no", "off", "", "null":
|
||||
*b = false
|
||||
default:
|
||||
return fmt.Errorf("cannot parse %q as bool", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initSSH() {
|
||||
if _sshUser == "" {
|
||||
_sshUser = os.Getenv("OIKOS_SSH_USER")
|
||||
@@ -185,11 +206,13 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
|
||||
GW string `json:"gw"`
|
||||
Storage string `json:"storage"`
|
||||
Template string `json:"template"`
|
||||
Privileged bool `json:"privileged"`
|
||||
Nesting bool `json:"nesting"`
|
||||
Privileged flexBool `json:"privileged"`
|
||||
Nesting flexBool `json:"nesting"`
|
||||
Mounts []string `json:"mounts"`
|
||||
Nameserver string `json:"nameserver"`
|
||||
Searchdomain string `json:"searchdomain"`
|
||||
Services []string `json:"services"` // apt packages to install after create
|
||||
PostInstall string `json:"post_install"` // shell run inside the container after create
|
||||
}
|
||||
if err := json.Unmarshal([]byte(params), &cfg); err != nil {
|
||||
slog.Error("httpapi: pct_create parse params", "error", err, "params", params)
|
||||
@@ -225,9 +248,32 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
|
||||
if cfg.Searchdomain == "" {
|
||||
cfg.Searchdomain = "hubris.network"
|
||||
}
|
||||
// Template pre-flight: resolve against what the host actually has
|
||||
// cached. A hardcoded name (e.g. debian-13) fails opaquely with a raw
|
||||
// `pct` error when that exact file isn't present. List the cache, then
|
||||
// either validate the requested template or auto-pick the newest
|
||||
// debian one; on miss, fail early with the available list so the
|
||||
// operator/agent can retry with a real name.
|
||||
cacheList, tplErr := sshExec(ctx, host, user, "ls -1 /var/lib/vz/template/cache/ 2>/dev/null | grep -E '\\.tar\\.(zst|gz|xz)$' || true")
|
||||
available := []string{}
|
||||
for _, l := range strings.Split(strings.TrimSpace(cacheList), "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
available = append(available, l)
|
||||
}
|
||||
}
|
||||
if tplErr != nil {
|
||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
||||
execID, fmt.Sprintf(`{"error":"list templates on %s: %s"}`, targetSlug, strings.ReplaceAll(tplErr.Error(), `"`, `'`)))
|
||||
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": tplErr.Error()})
|
||||
return
|
||||
}
|
||||
cfg.Template = resolveTemplate(cfg.Template, available)
|
||||
if cfg.Template == "" {
|
||||
// Try to find the latest debian template
|
||||
cfg.Template = "debian-13-standard_13.0-1_amd64.tar.zst"
|
||||
msg := fmt.Sprintf("no usable LXC template on %s. Available: %v", targetSlug, available)
|
||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
||||
execID, fmt.Sprintf(`{"error":%q}`, msg))
|
||||
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
|
||||
return
|
||||
}
|
||||
|
||||
privFlag := "--unprivileged 1"
|
||||
@@ -270,6 +316,27 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
|
||||
slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd)
|
||||
output, err = sshExec(ctx, host, user, createCmd)
|
||||
|
||||
// Post-create provisioning: install apt packages and run a post_install
|
||||
// script inside the fresh container, so a single approved pct_create
|
||||
// yields a *working service*, not just an empty container. Best-effort
|
||||
// with a boot settle; failures are appended to output and mark the
|
||||
// execution failed so the operator sees exactly which step broke.
|
||||
if err == nil && (len(cfg.Services) > 0 || cfg.PostInstall != "") {
|
||||
// Give the container a moment to finish booting before exec.
|
||||
steps := []string{fmt.Sprintf("sleep 5")}
|
||||
if len(cfg.Services) > 0 {
|
||||
pkgs := strings.Join(sanitizePkgs(cfg.Services), " ")
|
||||
steps = append(steps, fmt.Sprintf("pct exec %d -- bash -lc 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s'", cfg.VMID, pkgs))
|
||||
}
|
||||
if cfg.PostInstall != "" {
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(cfg.PostInstall))
|
||||
steps = append(steps, fmt.Sprintf("pct exec %d -- bash -lc 'echo %s | base64 -d | bash'", cfg.VMID, b64))
|
||||
}
|
||||
var provOut string
|
||||
provOut, err = sshExec(ctx, host, user, strings.Join(steps, " && "))
|
||||
output = output + "\n--- post-install ---\n" + provOut
|
||||
}
|
||||
|
||||
// On success, register the entity in the DB with proper relationships
|
||||
if err == nil {
|
||||
slug := "lxc:" + cfg.Hostname
|
||||
@@ -336,6 +403,67 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
|
||||
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
|
||||
}
|
||||
|
||||
// resolveTemplate maps a requested template name to one actually present in
|
||||
// the host's template cache. Exact match wins; a bare distro hint (e.g.
|
||||
// "debian-13" or "debian") matches by prefix; empty picks the newest debian
|
||||
// (falling back to any) template available. Returns "" when nothing fits.
|
||||
func resolveTemplate(requested string, available []string) string {
|
||||
if len(available) == 0 {
|
||||
return ""
|
||||
}
|
||||
if requested != "" {
|
||||
for _, a := range available {
|
||||
if a == requested {
|
||||
return a
|
||||
}
|
||||
}
|
||||
for _, a := range available {
|
||||
if strings.HasPrefix(a, requested) {
|
||||
return a
|
||||
}
|
||||
}
|
||||
}
|
||||
// Auto-pick: prefer debian, then the lexically-greatest (newest version).
|
||||
best := ""
|
||||
for _, a := range available {
|
||||
if strings.Contains(a, "debian") && a > best {
|
||||
best = a
|
||||
}
|
||||
}
|
||||
if best != "" {
|
||||
return best
|
||||
}
|
||||
for _, a := range available {
|
||||
if a > best {
|
||||
best = a
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// sanitizePkgs drops anything that isn't a plausible apt package token, so a
|
||||
// hallucinated package list can't inject shell into the install command.
|
||||
func sanitizePkgs(pkgs []string) []string {
|
||||
out := make([]string, 0, len(pkgs))
|
||||
for _, p := range pkgs {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
ok := true
|
||||
for _, r := range p {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '.' || r == '+') {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── Checks ────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {
|
||||
|
||||
56
internal/mcp/httpget_test.go
Normal file
56
internal/mcp/httpget_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// sprintResult extracts the concatenated text content from a tool result.
|
||||
func sprintResult(r *mcp.CallToolResult) string {
|
||||
var b strings.Builder
|
||||
for _, c := range r.Content {
|
||||
if tc, ok := c.(*mcp.TextContent); ok {
|
||||
b.WriteString(tc.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestSanitizeBodyStripsHTML(t *testing.T) {
|
||||
raw := `<html><head><style>.x{color:red}</style><script>alert(1)</script></head>` +
|
||||
`<body><h1>Hello & Welcome</h1><p>Deploy with docker compose up -d</p></body></html>`
|
||||
out := sanitizeBody("text/html; charset=utf-8", raw)
|
||||
if strings.Contains(out, "<script") || strings.Contains(out, "alert(1)") {
|
||||
t.Errorf("script not stripped: %q", out)
|
||||
}
|
||||
if strings.Contains(out, ".x{color:red}") {
|
||||
t.Errorf("style not stripped: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "Hello & Welcome") {
|
||||
t.Errorf("expected unescaped heading text, got: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "docker compose up -d") {
|
||||
t.Errorf("expected body text preserved, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPGetBlocksPrivateAndBadScheme(t *testing.T) {
|
||||
cases := []string{
|
||||
"http://127.0.0.1:8080/",
|
||||
"http://localhost/admin",
|
||||
"http://192.168.8.77/",
|
||||
"http://10.0.0.5/",
|
||||
"file:///etc/passwd",
|
||||
"ftp://example.com/x",
|
||||
"",
|
||||
}
|
||||
for _, c := range cases {
|
||||
out := sprintResult(httpGet(context.Background(), c))
|
||||
if !strings.Contains(strings.ToLower(out), "error") && !strings.Contains(strings.ToLower(out), "refus") {
|
||||
t.Errorf("%q: expected rejection, got %q", c, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -261,11 +266,11 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
nStr(args["status"])), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (agent-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade, pct_create.",
|
||||
register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (agent-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade, pct_create. pct_create provisions a NEW LXC and installs its service in one approved step — you do NOT need follow-up pct_exec calls for package installs.",
|
||||
InputSchema: objSchema(
|
||||
prop{"target", "string", "Target entity slug (e.g. lxc:caddy, host:strong)"},
|
||||
prop{"target", "string", "Target entity slug. For pct_create this MUST be the Proxmox HOST that will run the container (e.g. host:strong) — NOT the new LXC's name. For restart/systemctl/apt_upgrade/pct_exec use the target service/LXC slug (e.g. lxc:caddy)."},
|
||||
prop{"action", "string", "Action: restart, systemctl, pct_exec, apt_upgrade, pct_create"},
|
||||
prop{"params", "string", "Extra params: for systemctl use 'enable|disable|reload', for pct_exec use the shell command, for apt_upgrade use 'audit|upgrade', for pct_create use JSON config (see docs)"},
|
||||
prop{"params", "string", "For systemctl: 'enable|disable|reload'. For pct_exec: the shell command. For apt_upgrade: 'audit|upgrade'. For pct_create: a JSON object string with keys: vmid (int, required, unused id), hostname (string, required), cores (int), memory (MB int), disk_gb (int), ip (CIDR e.g. 192.168.8.50/24), gw (gateway ip), storage (default local-lvm), template (optional — omit to auto-pick newest debian on the host), privileged (bool), nesting (bool), mounts ([]string of 'src,mp=/dst'), services ([]string of apt packages to install), post_install (string shell script run inside the container after create). Example: {\"vmid\":150,\"hostname\":\"typetype\",\"cores\":2,\"memory\":2048,\"disk_gb\":16,\"ip\":\"192.168.8.50/24\",\"gw\":\"192.168.8.2\",\"nesting\":true,\"services\":[\"docker.io\",\"git\"],\"post_install\":\"git clone https://github.com/x/y /opt/y && cd /opt/y && docker compose up -d\"}"},
|
||||
),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
@@ -402,6 +407,16 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
}
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
|
||||
InputSchema: objSchema(
|
||||
prop{"url", "string", "Absolute http(s) URL to fetch"},
|
||||
),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := argsMap(req)
|
||||
rawURL, _ := args["url"].(string)
|
||||
return httpGet(ctx, rawURL), nil
|
||||
})
|
||||
|
||||
register(&mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
|
||||
InputSchema: objSchema(
|
||||
prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
|
||||
@@ -999,6 +1014,76 @@ func resolveHost(ctx context.Context, pool *db.Pool, entitySlug string) (hostIP
|
||||
return "", "", fmt.Errorf("no IP found for %s", entitySlug)
|
||||
}
|
||||
|
||||
// htmlTagRe strips HTML tags for the naive text extraction in httpGet.
|
||||
var htmlTagRe = regexp.MustCompile(`(?s)<(script|style)[^>]*>.*?</(script|style)>|<[^>]+>`)
|
||||
|
||||
// httpGet fetches a public URL and returns sanitized, size-capped text so the
|
||||
// agent can read a service's README/site before provisioning. Guards: scheme
|
||||
// allow-list, request timeout, 16KB body cap, and blocking of RFC1918/loopback
|
||||
// hosts to avoid using the tool as an SSRF pivot into the private mesh.
|
||||
func httpGet(ctx context.Context, rawURL string) *mcp.CallToolResult {
|
||||
if rawURL == "" {
|
||||
return textResult("error: url required")
|
||||
}
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return textResult("error: url must be an absolute http(s) URL")
|
||||
}
|
||||
if isPrivateHost(u.Hostname()) {
|
||||
return textResult("error: refusing to fetch private/loopback address")
|
||||
}
|
||||
|
||||
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
hreq, err := http.NewRequestWithContext(cctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: %v", err))
|
||||
}
|
||||
hreq.Header.Set("User-Agent", "oikos-nomos/1.0 (+homelab agent)")
|
||||
hreq.Header.Set("Accept", "text/plain, text/html, application/json;q=0.9, */*;q=0.5")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(hreq)
|
||||
if err != nil {
|
||||
return textResult(fmt.Sprintf("error: fetch failed: %v", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
const cap = 256 * 1024 // read a bit extra pre-strip; final output capped below
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, cap))
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
text := sanitizeBody(ct, string(body))
|
||||
return textResult(fmt.Sprintf("GET %s → %d %s\n\n%s", u.String(), resp.StatusCode, ct, text))
|
||||
}
|
||||
|
||||
// sanitizeBody strips scripts/styles/tags from HTML, unescapes entities,
|
||||
// collapses whitespace, and caps the result to ~16KB of readable text.
|
||||
func sanitizeBody(contentType, raw string) string {
|
||||
text := raw
|
||||
if strings.Contains(contentType, "html") {
|
||||
text = htmlTagRe.ReplaceAllString(text, " ")
|
||||
text = html.UnescapeString(text)
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
if len(text) > 16*1024 {
|
||||
text = text[:16*1024] + "\n…[truncated]"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// isPrivateHost reports whether host is loopback, link-local, or RFC1918.
|
||||
func isPrivateHost(host string) bool {
|
||||
host = strings.ToLower(host)
|
||||
if host == "localhost" || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false // hostname; DNS may still resolve private — acceptable for a homelab tool
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
|
||||
}
|
||||
|
||||
func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UUID, action, params, riskClass string) {
|
||||
p := map[string]any{"action": action, "params": params, "execution_id": execID.String()}
|
||||
payload, _ := json.Marshal(p)
|
||||
|
||||
@@ -35,6 +35,11 @@ the actuator (a separate container with restricted SSH key) picks up.
|
||||
- `get_trend` — metric trends for a specific entity (single-entity only)
|
||||
- `request_execution` — the ONLY mutation path. Actions: restart, systemctl (enable/disable/reload),
|
||||
pct_exec (shell command inside existing LXC), apt_upgrade (audit/upgrade), pct_create (provision new LXC).
|
||||
- `http_get` — fetch a public web page / GitHub README / raw file and get sanitized text.
|
||||
You CAN read the internet with this. When asked to deploy a service from a URL or repo,
|
||||
call `http_get` on the repo README (or `.../raw/main/docker-compose.yml`) to learn its
|
||||
stack, ports, and install steps BEFORE proposing a plan. Never tell the operator you
|
||||
cannot access the web — use this tool.
|
||||
- `get_agent_activity` — your own behavior log
|
||||
|
||||
### Tool selection rules
|
||||
@@ -52,10 +57,15 @@ the actuator (a separate container with restricted SSH key) picks up.
|
||||
|
||||
Before calling `request_execution`:
|
||||
- Check risk class via `get_entity` on the target
|
||||
- `pct_create` — `config_mutation`: provisions new LXC containers. Requires operator approval.
|
||||
Once approved, the new LXC entity is created in the DB with `hosts` relationships and
|
||||
`state: provisioning`. Accepts JSON params with vmid, hostname, cores, memory, disk_gb,
|
||||
ip, gw, storage, template, privileged, nesting, mounts, nameserver, searchdomain.
|
||||
- `pct_create` — `config_mutation`: provisions a new LXC AND installs its service in one
|
||||
approved step. Set `target` to the Proxmox HOST slug (e.g. `host:strong`), not the new
|
||||
container name. `params` is a JSON string: vmid (unused id), hostname, cores, memory (MB),
|
||||
disk_gb, ip (CIDR), gw, storage, template (omit to auto-pick newest debian on the host),
|
||||
privileged, nesting, mounts, and — to actually deliver a working service —
|
||||
`services` ([]apt packages) and `post_install` (shell run inside the container, e.g. a
|
||||
`git clone && docker compose up -d`). Prefer one pct_create with services+post_install
|
||||
over pct_create followed by many pct_exec approvals. Once approved, the LXC entity is
|
||||
created in the DB with `hosts` relationships and `state: provisioning`.
|
||||
- If `destructive` or `config_mutation`: escalate to operator
|
||||
- If `reversible_low` with validated pattern: auto-act allowed
|
||||
|
||||
|
||||
Reference in New Issue
Block a user