From a887e5595fc414981185f5a8bcaff9f324a7a658 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 13:54:37 +0300 Subject: [PATCH 01/67] chore(mcp): add the go-sdk dependency Signed-off-by: NovusEdge --- go.mod | 2 ++ go.sum | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/go.mod b/go.mod index e616c356..d87fdcfa 100644 --- a/go.mod +++ b/go.mod @@ -31,9 +31,11 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/modelcontextprotocol/go-sdk v1.7.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.3 // indirect diff --git a/go.sum b/go.sum index 1f81cdf2..aafc63be 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkv github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -74,6 +76,8 @@ github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhV github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= From 6ec83581057852c7a5fd08c48daf35c93f15504a Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 13:54:52 +0300 Subject: [PATCH 02/67] test(mcp): pin contract version 3 and record its reason Signed-off-by: NovusEdge --- internal/cli/wire/envelope.go | 3 +++ internal/cli/wire/envelope_test.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/internal/cli/wire/envelope.go b/internal/cli/wire/envelope.go index 62715b84..276b63cd 100644 --- a/internal/cli/wire/envelope.go +++ b/internal/cli/wire/envelope.go @@ -27,6 +27,9 @@ import ( // break, so nothing has to reason about which shape it is looking at. // v3: recipe list changed shape. dir became roots, a list of {path, scope}, // and recipes became a list of RecipeEntry objects rather than names. +// allow_exec also becomes agent_access with four levels, and the MCP server +// moves into this binary, so a tool output is a wire DTO rather than a +// re-parse of a --json line. const ContractVersion = 3 // Event types (§2, §4). "result" is the one terminal type; every other type diff --git a/internal/cli/wire/envelope_test.go b/internal/cli/wire/envelope_test.go index 3afc2e16..7b037c81 100644 --- a/internal/cli/wire/envelope_test.go +++ b/internal/cli/wire/envelope_test.go @@ -144,6 +144,12 @@ func TestSplitJSONFlag(t *testing.T) { } } +func TestContractVersionIsThree(t *testing.T) { + if ContractVersion != 3 { + t.Fatalf("ContractVersion = %d, want 3", ContractVersion) + } +} + func slicesEqual(a, b []string) bool { if len(a) != len(b) { return false From 17ae26f4e553108c01f9b1c5e7720706b7f98bb6 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 13:56:09 +0300 Subject: [PATCH 03/67] test(mcp): pin the guards ported from Python Signed-off-by: NovusEdge --- internal/mcpsrv/guards.go | 37 ++++++ internal/mcpsrv/guards_test.go | 219 +++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 internal/mcpsrv/guards.go create mode 100644 internal/mcpsrv/guards_test.go diff --git a/internal/mcpsrv/guards.go b/internal/mcpsrv/guards.go new file mode 100644 index 00000000..33c9b759 --- /dev/null +++ b/internal/mcpsrv/guards.go @@ -0,0 +1,37 @@ +// Package mcpsrv serves the MCP protocol from the stoat binary. Every tool +// runs the guards in this file, calls internal/core, and returns a wire DTO. +package mcpsrv + +import "fmt" + +// forbiddenPatchKeys are never accepted as tool input at any level. share +// grants an arbitrary host directory into a guest read-write. +var forbiddenPatchKeys = []string{"share", "image", "base", "iso", "console_password"} + +// checkVMName is a stub for the implementer; the tests in guards_test.go +// pin its real behaviour. +func checkVMName(name string) (string, error) { + return "", fmt.Errorf("not implemented") +} + +func checkImageID(image string) (string, error) { + return "", fmt.Errorf("not implemented") +} + +func sharedDir(vm string) (string, error) { + return "", fmt.Errorf("not implemented") +} + +func checkHostPath(path, vm string) (string, error) { + _, _ = sharedDir(vm) + return "", fmt.Errorf("not implemented") +} + +func checkFlagFree(values []string, what string) error { + return fmt.Errorf("not implemented") +} + +func stripForbidden(patch map[string]any) map[string]any { + _ = forbiddenPatchKeys + return nil +} diff --git a/internal/mcpsrv/guards_test.go b/internal/mcpsrv/guards_test.go new file mode 100644 index 00000000..97f1cd32 --- /dev/null +++ b/internal/mcpsrv/guards_test.go @@ -0,0 +1,219 @@ +package mcpsrv + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCheckVMName(t *testing.T) { + t.Run("accepts", func(t *testing.T) { + for _, n := range []string{"work", "Work", "w", "work-1", "work_1", "work.1", "1work", "a.b-c_d"} { + if got, err := checkVMName(n); err != nil || got != n { + t.Errorf("checkVMName(%q) = %q, %v; want %q, nil", n, got, err, n) + } + } + }) + t.Run("rejects", func(t *testing.T) { + for _, n := range []string{"", " ", "work ", " work", ".", "..", ".hidden", "-lead", "work/evil", "work\x00", "wörk!"} { + if _, err := checkVMName(n); err == nil { + t.Errorf("checkVMName(%q) accepted", n) + } + } + }) + t.Run("absolute_path", func(t *testing.T) { + if _, err := checkVMName("/etc/passwd"); err == nil { + t.Fatal("accepted an absolute path") + } + }) + t.Run("unicode_separator", func(t *testing.T) { + // U+2044 FRACTION SLASH is not a path separator, so the separator + // check misses it and the name pattern is what refuses it. + if _, err := checkVMName("work⁄evil"); err == nil { + t.Fatal("accepted a unicode lookalike separator") + } + }) +} + +func TestCheckImageID(t *testing.T) { + t.Run("accepts", func(t *testing.T) { + for _, s := range []string{"alpine-virt", "ubuntu-24.04", "debian_12"} { + if _, err := checkImageID(s); err != nil { + t.Errorf("checkImageID(%q): %v", s, err) + } + } + }) + t.Run("rejects_paths", func(t *testing.T) { + for _, s := range []string{"", "/abs/x.qcow2", "./x.qcow2", "~/x.qcow2", "a/b", "../x"} { + if _, err := checkImageID(s); err == nil { + t.Errorf("checkImageID(%q) accepted", s) + } + } + }) +} + +func TestCheckHostPath(t *testing.T) { + root := t.TempDir() + t.Setenv("STOAT_HOME", root) + sandbox := filepath.Join(root, "shared", "work") + if err := os.MkdirAll(sandbox, 0o755); err != nil { + t.Fatal(err) + } + inside := filepath.Join(sandbox, "f.txt") + if err := os.WriteFile(inside, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + t.Run("inside_sandbox", func(t *testing.T) { + got, err := checkHostPath(inside, "work") + if err != nil || got != inside { + t.Fatalf("got %q, %v", got, err) + } + }) + t.Run("sandbox_root", func(t *testing.T) { + if _, err := checkHostPath(sandbox, "work"); err != nil { + t.Fatal(err) + } + }) + t.Run("relative", func(t *testing.T) { + if _, err := checkHostPath("f.txt", "work"); err == nil { + t.Fatal("accepted a relative path") + } + }) + t.Run("traversal", func(t *testing.T) { + if _, err := checkHostPath(filepath.Join(sandbox, "..", "..", "id_stoat"), "work"); err == nil { + t.Fatal("accepted a traversal") + } + }) + t.Run("outside", func(t *testing.T) { + if _, err := checkHostPath("/etc/passwd", "work"); err == nil { + t.Fatal("accepted /etc/passwd") + } + }) + t.Run("sibling_prefix", func(t *testing.T) { + evil := filepath.Join(root, "shared", "work-evil") + if err := os.MkdirAll(evil, 0o755); err != nil { + t.Fatal(err) + } + if _, err := checkHostPath(filepath.Join(evil, "f.txt"), "work"); err == nil { + t.Fatal("accepted a sibling that shares a string prefix") + } + }) + t.Run("symlink_escape", func(t *testing.T) { + link := filepath.Join(sandbox, "out") + if err := os.Symlink(root, link); err != nil { + t.Skip("symlinks unavailable") + } + defer func() { + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + }() + if _, err := checkHostPath(filepath.Join(link, "id_stoat"), "work"); err == nil { + t.Fatal("accepted a path through an escaping symlink") + } + }) + t.Run("symlink_direct", func(t *testing.T) { + link := filepath.Join(sandbox, "passwd") + if err := os.Symlink("/etc/passwd", link); err != nil { + t.Skip("symlinks unavailable") + } + defer func() { + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + }() + if _, err := checkHostPath(link, "work"); err == nil { + t.Fatal("accepted a symlink pointing out") + } + }) + t.Run("symlink_inside", func(t *testing.T) { + link := filepath.Join(sandbox, "alias.txt") + if err := os.Symlink(inside, link); err != nil { + t.Skip("symlinks unavailable") + } + defer func() { + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + }() + got, err := checkHostPath(link, "work") + if err != nil { + t.Fatal(err) + } + if got != inside { + t.Fatalf("got %q, want the resolved target %q", got, inside) + } + }) + t.Run("empty", func(t *testing.T) { + for _, p := range []string{"", " "} { + if _, err := checkHostPath(p, "work"); err == nil { + t.Errorf("accepted %q", p) + } + } + }) + t.Run("null_byte", func(t *testing.T) { + if _, err := checkHostPath(inside+"\x00", "work"); err == nil { + t.Fatal("accepted a null byte") + } + }) + t.Run("tilde", func(t *testing.T) { + t.Setenv("HOME", root) + if _, err := checkHostPath("~/shared/work/f.txt", "work"); err != nil { + t.Fatalf("~ did not expand to the caller's home: %v", err) + } + }) + t.Run("disjoint_sandboxes", func(t *testing.T) { + if _, err := checkHostPath(inside, "other"); err == nil { + t.Fatal("work's file was accepted for vm \"other\"") + } + }) + t.Run("bad_vm_name", func(t *testing.T) { + if _, err := checkHostPath(inside, "../evil"); err == nil { + t.Fatal("accepted a bad vm name as a path component") + } + }) +} + +func TestStripForbidden(t *testing.T) { + t.Run("removes_every_key", func(t *testing.T) { + in := map[string]any{"ram": 2048, "share": "/etc", "image": "/x.qcow2", "base": "b", "iso": "i", "console_password": "p"} + got := stripForbidden(in) + if len(got) != 1 || got["ram"] != 2048 { + t.Fatalf("got %v, want only ram", got) + } + }) + t.Run("clean_patch", func(t *testing.T) { + in := map[string]any{"ram": 2048, "cpus": 2} + if got := stripForbidden(in); len(got) != 2 { + t.Fatalf("got %v", got) + } + }) + t.Run("does_not_mutate", func(t *testing.T) { + in := map[string]any{"ram": 2048, "share": "/etc"} + stripForbidden(in) + if _, ok := in["share"]; !ok { + t.Fatal("stripForbidden mutated its input") + } + }) +} + +func TestCheckFlagFree(t *testing.T) { + t.Run("long_flag", func(t *testing.T) { + if err := checkFlagFree([]string{"--clear"}, "pairs"); err == nil { + t.Fatal("accepted --clear") + } + }) + t.Run("short_flag_and_empty", func(t *testing.T) { + for _, v := range []string{"-y", "", " "} { + if err := checkFlagFree([]string{v}, "pairs"); err == nil { + t.Errorf("accepted %q", v) + } + } + }) + t.Run("ordinary", func(t *testing.T) { + if err := checkFlagFree([]string{"8080:80", "docker"}, "pairs"); err != nil { + t.Fatal(err) + } + }) +} From d1d4578b0d0f15405149468c94fffd687b46cb98 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 13:56:37 +0300 Subject: [PATCH 04/67] test(mcp): pin index, param and guest path guards Signed-off-by: NovusEdge --- internal/mcpsrv/guards.go | 16 ++++++++ internal/mcpsrv/guards_test.go | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/internal/mcpsrv/guards.go b/internal/mcpsrv/guards.go index 33c9b759..396d6cf8 100644 --- a/internal/mcpsrv/guards.go +++ b/internal/mcpsrv/guards.go @@ -35,3 +35,19 @@ func stripForbidden(patch map[string]any) map[string]any { _ = forbiddenPatchKeys return nil } + +func checkIndexName(ref string) (string, string, error) { + return "", "", fmt.Errorf("not implemented") +} + +func checkParamName(name string) (string, error) { + return "", fmt.Errorf("not implemented") +} + +func checkGuestPath(path string) (string, error) { + return "", fmt.Errorf("not implemented") +} + +func checkSvcName(name string) (string, error) { + return "", fmt.Errorf("not implemented") +} diff --git a/internal/mcpsrv/guards_test.go b/internal/mcpsrv/guards_test.go index 97f1cd32..3d5cbef1 100644 --- a/internal/mcpsrv/guards_test.go +++ b/internal/mcpsrv/guards_test.go @@ -198,6 +198,73 @@ func TestStripForbidden(t *testing.T) { }) } +func TestCheckIndexName(t *testing.T) { + t.Run("accepts", func(t *testing.T) { + for _, c := range []struct{ in, name, ref string }{ + {"tailscale", "tailscale", ""}, + {"tailscale@v1.2", "tailscale", "v1.2"}, + {"my-recipe@main", "my-recipe", "main"}, + } { + name, ref, err := checkIndexName(c.in) + if err != nil || name != c.name || ref != c.ref { + t.Errorf("checkIndexName(%q) = %q,%q,%v", c.in, name, ref, err) + } + } + }) + t.Run("rejects", func(t *testing.T) { + for _, s := range []string{ + "", "https://github.com/x/y", "git@github.com:x/y.git", + "x/y", "../evil", "a@b@c", "tailscale@", "@v1", "Tailscale", + "tail scale", "tailscale@../evil", + } { + if _, _, err := checkIndexName(s); err == nil { + t.Errorf("checkIndexName(%q) accepted", s) + } + } + }) +} + +func TestCheckParamName(t *testing.T) { + for _, s := range []string{"user", "auth_key", "u1"} { + if _, err := checkParamName(s); err != nil { + t.Errorf("checkParamName(%q): %v", s, err) + } + } + for _, s := range []string{"", "User", "1user", "auth-key", "auth key", "_user"} { + if _, err := checkParamName(s); err == nil { + t.Errorf("checkParamName(%q) accepted", s) + } + } +} + +func TestCheckGuestPath(t *testing.T) { + for _, s := range []string{"/etc/hosts", "/var/log/messages", "/a b/c"} { + if _, err := checkGuestPath(s); err != nil { + t.Errorf("checkGuestPath(%q): %v", s, err) + } + } + // A relative path is an error, never resolved against $HOME, so a tool + // call means the same thing on every guest. + for _, s := range []string{"", "etc/hosts", "./x", "~/x", "/x\x00"} { + if _, err := checkGuestPath(s); err == nil { + t.Errorf("checkGuestPath(%q) accepted", s) + } + } +} + +func TestCheckSvcName(t *testing.T) { + for _, s := range []string{"docker", "sshd", "getty@tty1", "my.service"} { + if _, err := checkSvcName(s); err != nil { + t.Errorf("checkSvcName(%q): %v", s, err) + } + } + for _, s := range []string{"", "a b", "a;b", "$(x)", "a/b", "-x"} { + if _, err := checkSvcName(s); err == nil { + t.Errorf("checkSvcName(%q) accepted", s) + } + } +} + func TestCheckFlagFree(t *testing.T) { t.Run("long_flag", func(t *testing.T) { if err := checkFlagFree([]string{"--clear"}, "pairs"); err == nil { From 5fc7d1d2a6f4dfb9381b260666bd8e60eef92df4 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 13:57:07 +0300 Subject: [PATCH 05/67] test(mcp): pin per-tool and shared rate limits Signed-off-by: NovusEdge --- internal/mcpsrv/ratelimit.go | 30 ++++++++++++ internal/mcpsrv/ratelimit_test.go | 81 +++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 internal/mcpsrv/ratelimit.go create mode 100644 internal/mcpsrv/ratelimit_test.go diff --git a/internal/mcpsrv/ratelimit.go b/internal/mcpsrv/ratelimit.go new file mode 100644 index 00000000..2cdd4dda --- /dev/null +++ b/internal/mcpsrv/ratelimit.go @@ -0,0 +1,30 @@ +package mcpsrv + +import ( + "fmt" + "time" +) + +// Limits are the token bucket sizes. The MCP spec makes rate limiting a +// server MUST. +type Limits struct { + ToolBurst int + ToolRate float64 + Burst int + Rate float64 +} + +func DefaultLimits() Limits { + return Limits{} +} + +// limiter is a stub; ratelimit_test.go pins its real behaviour. +type limiter struct{} + +func newLimiter(l Limits) *limiter { + return &limiter{} +} + +func (l *limiter) check(tool string, now time.Time) error { + return fmt.Errorf("not implemented") +} diff --git a/internal/mcpsrv/ratelimit_test.go b/internal/mcpsrv/ratelimit_test.go new file mode 100644 index 00000000..dbff4746 --- /dev/null +++ b/internal/mcpsrv/ratelimit_test.go @@ -0,0 +1,81 @@ +package mcpsrv + +import ( + "strings" + "testing" + "time" +) + +func TestRateLimiter(t *testing.T) { + start := time.Unix(0, 0) + + t.Run("allows_up_to_capacity", func(t *testing.T) { + l := newLimiter(Limits{ToolBurst: 3, ToolRate: 0.5, Burst: 100, Rate: 2}) + for i := range 3 { + if err := l.check("list_vms", start); err != nil { + t.Fatalf("call %d refused: %v", i, err) + } + } + err := l.check("list_vms", start) + if err == nil || !strings.Contains(err.Error(), "rate limit") { + t.Fatalf("call 4 error = %v, want a rate limit refusal", err) + } + }) + + t.Run("independent_per_tool", func(t *testing.T) { + l := newLimiter(Limits{ToolBurst: 1, ToolRate: 0.5, Burst: 100, Rate: 2}) + if err := l.check("list_vms", start); err != nil { + t.Fatal(err) + } + if err := l.check("doctor", start); err != nil { + t.Fatalf("a second tool was refused: %v", err) + } + }) + + t.Run("refills", func(t *testing.T) { + l := newLimiter(Limits{ToolBurst: 1, ToolRate: 0.5, Burst: 100, Rate: 2}) + if err := l.check("doctor", start); err != nil { + t.Fatal(err) + } + if err := l.check("doctor", start); err == nil { + t.Fatal("second call not refused") + } + if err := l.check("doctor", start.Add(2*time.Second)); err != nil { + t.Fatalf("no refill after 2s at 0.5/s: %v", err) + } + }) + + t.Run("shared_bucket", func(t *testing.T) { + // Per-tool buckets alone let a caller burst ToolBurst times against + // each of ~40 tools. The shared bucket is what bounds the server. + l := newLimiter(Limits{ToolBurst: 100, ToolRate: 0.5, Burst: 2, Rate: 2}) + if err := l.check("a", start); err != nil { + t.Fatal(err) + } + if err := l.check("b", start); err != nil { + t.Fatal(err) + } + if err := l.check("c", start); err == nil { + t.Fatal("the shared bucket did not bound a third tool") + } + }) + + t.Run("refusal_charges_nothing", func(t *testing.T) { + l := newLimiter(Limits{ToolBurst: 1, ToolRate: 0.5, Burst: 10, Rate: 2}) + if err := l.check("hot", start); err != nil { + t.Fatal(err) + } + for range 5 { + if err := l.check("hot", start); err == nil { + t.Fatal("hot tool not refused") + } + } + // Nine shared tokens must be left: one spent by the call that + // succeeded, none by the five refusals. + for i := range 9 { + if err := l.check("cold", start); err != nil { + t.Fatalf("cold call %d refused, so a refusal spent a shared token: %v", i, err) + } + } + }) +} From eb84e9b1b60cdd7653df10afd407d4d330239ffb Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:01:02 +0300 Subject: [PATCH 06/67] test(mcp): pin the tool table and server registration Signed-off-by: NovusEdge --- go.mod | 7 +- go.sum | 14 +++ internal/mcpsrv/access.go | 12 ++ internal/mcpsrv/server.go | 47 ++++++++ internal/mcpsrv/server_test.go | 204 +++++++++++++++++++++++++++++++++ internal/mcpsrv/table_test.go | 90 +++++++++++++++ 6 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 internal/mcpsrv/access.go create mode 100644 internal/mcpsrv/server.go create mode 100644 internal/mcpsrv/server_test.go create mode 100644 internal/mcpsrv/table_test.go diff --git a/go.mod b/go.mod index d87fdcfa..fa34e0fb 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/alecthomas/kong v1.16.0 github.com/charmbracelet/x/ansi v0.11.7 github.com/charmbracelet/x/term v0.2.2 + github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/pelletier/go-toml/v2 v2.4.3 golang.org/x/sys v0.47.0 gopkg.in/yaml.v3 v3.0.1 @@ -35,12 +36,16 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/modelcontextprotocol/go-sdk v1.7.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.3 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.22.0 // indirect + golang.org/x/time v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index aafc63be..ce3664c3 100644 --- a/go.sum +++ b/go.sum @@ -62,6 +62,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= @@ -88,16 +90,28 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/mcpsrv/access.go b/internal/mcpsrv/access.go new file mode 100644 index 00000000..f9f8742a --- /dev/null +++ b/internal/mcpsrv/access.go @@ -0,0 +1,12 @@ +package mcpsrv + +// Level is an agent_access level. Task 8 adds requireAccess and its table; +// this chunk only needs the type for toolTable's Access field. +type Level int + +const ( + LevelNone Level = iota + LevelObserve + LevelManage + LevelExec +) diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go new file mode 100644 index 00000000..869a1e88 --- /dev/null +++ b/internal/mcpsrv/server.go @@ -0,0 +1,47 @@ +package mcpsrv + +import ( + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// class is a tool's annotation class. The four classes are the taxonomy in +// docs/design/mcp-server.md; toolTable in table_test.go assigns one per tool. +type class int + +const ( + classRead class = iota + classMutate + classDestructive + classExec +) + +type toolSpec struct { + Name string + Class class + Access Level +} + +// Options configures a server. Version is the stoat build version, reported +// in the MCP handshake. +type Options struct { + Version string + Limits Limits +} + +const maxLogLines = 2000 + +// New is a stub; Task 5's implementer registers the tool set and Task 6 +// wires the rate limit middleware. +func New(opts Options) *mcp.Server { + return mcp.NewServer(&mcp.Implementation{Name: "stoat", Version: opts.Version}, nil) +} + +// annotationsFor is a stub; server_test.go pins the real mapping from class. +func annotationsFor(c class) *mcp.ToolAnnotations { + return &mcp.ToolAnnotations{} +} + +// clampInt is a stub; server_test.go pins the real clamp. +func clampInt(v, lo, hi int) int { + return 0 +} diff --git a/internal/mcpsrv/server_test.go b/internal/mcpsrv/server_test.go new file mode 100644 index 00000000..e830ecab --- /dev/null +++ b/internal/mcpsrv/server_test.go @@ -0,0 +1,204 @@ +package mcpsrv + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// listTools drives the server with the SDK's in-process client, so the test +// sees exactly what a real client sees. +func listTools(t *testing.T) []*mcp.Tool { + t.Helper() + ctx := context.Background() + srv := New(Options{Version: "test", Limits: DefaultLimits()}) + ct, st := mcp.NewInMemoryTransports() + if _, err := srv.Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := cs.Close(); err != nil { + t.Error(err) + } + }) + res, err := cs.ListTools(ctx, nil) + if err != nil { + t.Fatal(err) + } + return res.Tools +} + +func TestEveryTableToolIsRegistered(t *testing.T) { + got := map[string]bool{} + for _, tool := range listTools(t) { + got[tool.Name] = true + } + for _, spec := range toolTable { + if !got[spec.Name] { + if owner, ok := pending[spec.Name]; ok { + t.Logf("tool %q is pending, owned by %s", spec.Name, owner) + continue + } + t.Errorf("tool %q is in the table but not registered", spec.Name) + } + } +} + +func TestNoToolOutsideTheTable(t *testing.T) { + want := map[string]bool{} + for _, spec := range toolTable { + want[spec.Name] = true + } + for _, tool := range listTools(t) { + if !want[tool.Name] { + t.Errorf("tool %q is registered but not in the table", tool.Name) + } + } +} + +func TestForbiddenSurfacesAbsent(t *testing.T) { + for _, tool := range listTools(t) { + if slices.Contains(forbiddenSurfaces, tool.Name) { + t.Errorf("forbidden surface %q is registered", tool.Name) + } + } +} + +func TestInputSchemaRejectsAdditionalProperties(t *testing.T) { + for _, tool := range listTools(t) { + raw, err := json.Marshal(tool.InputSchema) + if err != nil { + t.Fatalf("%s: %v", tool.Name, err) + } + var schema struct { + AdditionalProperties *bool `json:"additionalProperties"` + } + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("%s: %v", tool.Name, err) + } + if schema.AdditionalProperties == nil || *schema.AdditionalProperties { + t.Errorf("%s: additionalProperties is not false: %s", tool.Name, raw) + } + } +} + +func TestAnnotationsMatchTable(t *testing.T) { + byName := map[string]*mcp.Tool{} + for _, tool := range listTools(t) { + byName[tool.Name] = tool + } + for _, spec := range toolTable { + tool, ok := byName[spec.Name] + if !ok { + continue // TestEveryTableToolIsRegistered reports this. + } + want := annotationsFor(spec.Class) + got := tool.Annotations + if got == nil { + t.Errorf("%s: no annotations", spec.Name) + continue + } + if got.ReadOnlyHint != want.ReadOnlyHint { + t.Errorf("%s: readOnlyHint = %v, want %v", spec.Name, got.ReadOnlyHint, want.ReadOnlyHint) + } + if got.DestructiveHint == nil || *got.DestructiveHint != *want.DestructiveHint { + t.Errorf("%s: destructiveHint = %v, want %v", spec.Name, got.DestructiveHint, *want.DestructiveHint) + } + if got.OpenWorldHint == nil || *got.OpenWorldHint != *want.OpenWorldHint { + t.Errorf("%s: openWorldHint = %v, want %v", spec.Name, got.OpenWorldHint, *want.OpenWorldHint) + } + } +} + +func TestNoForbiddenInputField(t *testing.T) { + for _, tool := range listTools(t) { + raw, err := json.Marshal(tool.InputSchema) + if err != nil { + t.Fatal(err) + } + for _, field := range forbiddenInputFields { + if strings.Contains(string(raw), `"`+field+`"`) { + t.Errorf("%s: input schema mentions forbidden field %q", tool.Name, field) + } + } + } +} + +func TestEveryToolHasDescription(t *testing.T) { + for _, tool := range listTools(t) { + if len(strings.TrimSpace(tool.Description)) < 40 { + t.Errorf("%s: description is too short to be honest: %q", tool.Name, tool.Description) + } + } +} + +func TestNoEmDashInDescription(t *testing.T) { + for _, tool := range listTools(t) { + if strings.ContainsRune(tool.Description, '—') { + t.Errorf("%s: description contains an em dash", tool.Name) + } + } +} + +// callTool drives one tool through the in-process client and returns the +// result. It is the round trip an MCP client makes. +func callTool(t *testing.T, name string, args any) *mcp.CallToolResult { + t.Helper() + ctx := context.Background() + srv := New(Options{Version: "test", Limits: DefaultLimits()}) + ct, st := mcp.NewInMemoryTransports() + if _, err := srv.Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := cs.Close(); err != nil { + t.Error(err) + } + }) + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + return res +} + +func TestListVMsRoundTrip(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + res := callTool(t, "list_vms", map[string]any{}) + if res.IsError { + t.Fatalf("list_vms failed: %+v", res.Content) + } + raw, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatal(err) + } + var out struct { + VMs []any `json:"vms"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("list_vms output is not wire.VMList: %s", raw) + } + if out.VMs == nil { + t.Fatalf("vms is null, want an empty list: %s", raw) + } +} + +func TestLogsClampsLines(t *testing.T) { + for _, c := range []struct{ in, want int }{{0, 1}, {50, 50}, {5000, maxLogLines}} { + if got := clampInt(c.in, 1, maxLogLines); got != c.want { + t.Errorf("clampInt(%d) = %d, want %d", c.in, got, c.want) + } + } +} diff --git a/internal/mcpsrv/table_test.go b/internal/mcpsrv/table_test.go new file mode 100644 index 00000000..7d41d6b0 --- /dev/null +++ b/internal/mcpsrv/table_test.go @@ -0,0 +1,90 @@ +package mcpsrv + +// toolTable is the source of truth for the tool set: every tool, its +// annotation class, and the agent_access level it needs. docs/design/ +// mcp-server.md links here rather than repeating it. +var toolTable = []toolSpec{ + // Read-only (Task 5). + {"list_vms", classRead, LevelNone}, + {"vm_status", classRead, LevelNone}, + {"list_images", classRead, LevelNone}, + {"list_recipes", classRead, LevelNone}, + {"check_recipes", classRead, LevelNone}, + {"logs", classRead, LevelNone}, + {"doctor", classRead, LevelNone}, + {"plan_recipes", classRead, LevelNone}, + {"list_guests", classRead, LevelNone}, + {"guest_info", classRead, LevelNone}, + {"recipe_schema", classRead, LevelNone}, + {"search_recipes", classRead, LevelNone}, + + // Mutating, host side (Task 7). + {"create", classMutate, LevelNone}, + {"start", classMutate, LevelNone}, + {"stop", classMutate, LevelNone}, + {"update", classMutate, LevelNone}, + {"clone", classMutate, LevelNone}, + {"snapshot", classMutate, LevelNone}, + {"forward", classMutate, LevelNone}, + {"wait", classMutate, LevelNone}, + + // Destructive, host side (Task 7). + {"destroy", classDestructive, LevelNone}, + {"prune", classDestructive, LevelNone}, + {"restore", classDestructive, LevelNone}, + + // Recipe index (Task 15). + {"add_recipe", classMutate, LevelNone}, + {"update_recipe", classMutate, LevelNone}, + {"remove_recipe", classMutate, LevelNone}, + + // Guest, observe (Task 10). + {"read_file", classRead, LevelObserve}, + {"list_dir", classRead, LevelObserve}, + {"stat", classRead, LevelObserve}, + {"ps", classRead, LevelObserve}, + {"svc_status", classRead, LevelObserve}, + {"tail_log", classRead, LevelObserve}, + + // Guest, manage (Tasks 7 and 11). + {"apply_recipes", classExec, LevelManage}, + {"write_file", classExec, LevelManage}, + {"copy_to", classExec, LevelManage}, + {"copy_from", classExec, LevelManage}, + {"pkg_install", classExec, LevelManage}, + {"svc", classExec, LevelManage}, + {"useradd", classExec, LevelManage}, + + // Guest, exec (Task 12). + {"exec", classExec, LevelExec}, + {"exec_bg", classExec, LevelExec}, + {"job_status", classRead, LevelExec}, + {"job_output", classRead, LevelExec}, + {"job_kill", classExec, LevelExec}, + {"list_jobs", classRead, LevelExec}, +} + +// forbiddenSurfaces are absent rather than gated. A parameter that exists +// is eventually reachable. +var forbiddenSurfaces = []string{"recipe_new", "ssh_command", "global_logs", "pull"} + +// forbiddenInputFields may not appear as a property on any tool's input +// schema, at any depth. +var forbiddenInputFields = []string{"share", "base", "iso", "console_password", "image_path", "byo"} + +// pending names tools a later task registers. Every entry is removed by the +// task that adds the tool; Task 18 asserts the list is empty. +var pending = map[string]string{ + "list_guests": "Task 14", "guest_info": "Task 14", "recipe_schema": "Task 14", + "search_recipes": "Task 14", "create": "Task 7", "start": "Task 7", + "stop": "Task 7", "update": "Task 7", "clone": "Task 7", "snapshot": "Task 7", + "forward": "Task 7", "wait": "Task 7", "destroy": "Task 7", "prune": "Task 7", + "restore": "Task 7", "add_recipe": "Task 15", "update_recipe": "Task 15", + "remove_recipe": "Task 15", "read_file": "Task 10", "list_dir": "Task 10", + "stat": "Task 10", "ps": "Task 10", "svc_status": "Task 10", "tail_log": "Task 10", + "apply_recipes": "Task 7", "write_file": "Task 11", "copy_to": "Task 11", + "copy_from": "Task 11", "pkg_install": "Task 11", "svc": "Task 11", + "useradd": "Task 11", "exec": "Task 12", "exec_bg": "Task 12", + "job_status": "Task 12", "job_output": "Task 12", "job_kill": "Task 12", + "list_jobs": "Task 12", +} From 9a632a1f5c10d3552c78dca9ab12094de1c46f3d Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:01:49 +0300 Subject: [PATCH 07/67] test(mcp): pin the rate limit middleware over a real tool call Signed-off-by: NovusEdge --- internal/mcpsrv/ratelimit_test.go | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/mcpsrv/ratelimit_test.go b/internal/mcpsrv/ratelimit_test.go index dbff4746..035b4c58 100644 --- a/internal/mcpsrv/ratelimit_test.go +++ b/internal/mcpsrv/ratelimit_test.go @@ -1,9 +1,12 @@ package mcpsrv import ( + "context" "strings" "testing" "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" ) func TestRateLimiter(t *testing.T) { @@ -79,3 +82,44 @@ func TestRateLimiter(t *testing.T) { } }) } + +func TestRateLimitMiddlewareRefusesABurst(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + ctx := context.Background() + srv := New(Options{Version: "test", Limits: Limits{ToolBurst: 2, ToolRate: 0.001, Burst: 100, Rate: 2}}) + ct, st := mcp.NewInMemoryTransports() + if _, err := srv.Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := cs.Close(); err != nil { + t.Error(err) + } + }() + + for i := range 2 { + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "list_vms", Arguments: map[string]any{}}) + if err != nil || res.IsError { + t.Fatalf("call %d refused: %v %+v", i, err, res) + } + } + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "list_vms", Arguments: map[string]any{}}) + if err != nil { + t.Fatalf("the third call errored at the protocol level: %v", err) + } + if !res.IsError { + t.Fatal("the third call was not refused") + } + if txt, ok := res.Content[0].(*mcp.TextContent); !ok || !strings.Contains(txt.Text, "rate limit") { + t.Fatalf("refusal message = %+v, want a rate limit message", res.Content[0]) + } + // A tool the caller has not touched still works, so the refusals spent + // no shared tokens. + if res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "doctor", Arguments: map[string]any{}}); err != nil || res.IsError { + t.Fatalf("doctor refused after list_vms hit its own limit: %v %+v", err, res) + } +} From f66a77c337427eab9a4dec5cf49b6ddd45a32c10 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:14:04 +0300 Subject: [PATCH 08/67] feat(mcp): port the guards to Go Signed-off-by: NovusEdge --- internal/mcpsrv/guards.go | 193 +++++++++++++++++++++++++++++++++++--- 1 file changed, 178 insertions(+), 15 deletions(-) diff --git a/internal/mcpsrv/guards.go b/internal/mcpsrv/guards.go index 396d6cf8..85a8e89d 100644 --- a/internal/mcpsrv/guards.go +++ b/internal/mcpsrv/guards.go @@ -2,52 +2,215 @@ // runs the guards in this file, calls internal/core, and returns a wire DTO. package mcpsrv -import "fmt" +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/novusedge/stoat/internal/config" +) + +// A VM name becomes a directory name under the data root, so the pattern is +// what keeps an operation inside it. Rejecting beats sanitizing: a rewrite +// hides the attempt. +var vmNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// A catalog image id never contains a separator. An absolute path here is an +// arbitrary host file read, booted as a disk. +var imageIDRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +var ( + indexNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + gitRefRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`) + paramNameRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) + svcNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]*$`) +) // forbiddenPatchKeys are never accepted as tool input at any level. share // grants an arbitrary host directory into a guest read-write. var forbiddenPatchKeys = []string{"share", "image", "base", "iso", "console_password"} -// checkVMName is a stub for the implementer; the tests in guards_test.go -// pin its real behaviour. func checkVMName(name string) (string, error) { - return "", fmt.Errorf("not implemented") + if strings.TrimSpace(name) == "" { + return "", fmt.Errorf("vm name is required") + } + if name != strings.TrimSpace(name) { + return "", fmt.Errorf("vm name %q has leading or trailing whitespace", name) + } + if name == "." || name == ".." { + return "", fmt.Errorf("vm name %q is a path traversal", name) + } + if strings.ContainsAny(name, `/\`) { + return "", fmt.Errorf("vm name %q contains a path separator", name) + } + if strings.ContainsRune(name, 0) { + return "", fmt.Errorf("vm name contains a null byte") + } + if !vmNameRE.MatchString(name) { + return "", fmt.Errorf("invalid vm name %q: must match %s", name, vmNameRE) + } + return name, nil } func checkImageID(image string) (string, error) { - return "", fmt.Errorf("not implemented") + if strings.TrimSpace(image) == "" { + return "", fmt.Errorf("image id is required") + } + if strings.ContainsAny(image, `/\`) || strings.HasPrefix(image, "~") { + return "", fmt.Errorf("image %q looks like a path; only catalog image ids are accepted, run list_images to see them", image) + } + if !imageIDRE.MatchString(image) { + return "", fmt.Errorf("image id %q is not a valid catalog id", image) + } + return image, nil } +// sharedDir is the one host directory an agent may read or write for a VM. +// It mirrors the layout of the writable 9p export. func sharedDir(vm string) (string, error) { - return "", fmt.Errorf("not implemented") + name, err := checkVMName(vm) + if err != nil { + return "", err + } + return filepath.EvalSymlinks(filepath.Join(config.Root(), "shared", name)) } +// checkHostPath confines a host path to ~/.stoat/shared//. The order is +// the guard: resolve symlinks, then compare. A check before resolution is +// defeated by a symlink the guest creates inside its own mounted share. +// Comparison is by path element, so the sibling "work-evil" cannot pass as +// "work". func checkHostPath(path, vm string) (string, error) { - _, _ = sharedDir(vm) - return "", fmt.Errorf("not implemented") + if strings.TrimSpace(path) == "" { + return "", fmt.Errorf("path is required") + } + if strings.ContainsRune(path, 0) { + return "", fmt.Errorf("path contains a null byte") + } + sandbox, err := sharedDir(vm) + if err != nil { + return "", err + } + // Expand ~ first. filepath.EvalSymlinks reads a literal "~" as a + // relative directory name. + candidate := config.Expand(path) + if !filepath.IsAbs(candidate) { + return "", fmt.Errorf("path %q must be absolute, under %s", path, sandbox) + } + resolved, err := resolveExisting(candidate) + if err != nil { + return "", err + } + if resolved != sandbox && !strings.HasPrefix(resolved, sandbox+string(os.PathSeparator)) { + return "", fmt.Errorf("path %q resolves to %s, which is outside this VM's shared directory (%s)", path, resolved, sandbox) + } + return resolved, nil } +// resolveExisting resolves every symlink in p. A destination that does not +// exist yet is legitimate for copy_from, so the deepest existing ancestor is +// resolved and the remaining elements are rejoined. +func resolveExisting(p string) (string, error) { + rest := "" + for cur := filepath.Clean(p); ; { + if r, err := filepath.EvalSymlinks(cur); err == nil { + return filepath.Join(r, rest), nil + } + parent := filepath.Dir(cur) + if parent == cur { + return "", fmt.Errorf("path %q cannot be resolved", p) + } + rest = filepath.Join(filepath.Base(cur), rest) + cur = parent + } +} + +// checkFlagFree refuses a value kong would read as a flag. forward and +// check_recipes splat their list arguments into argv as positionals, and +// forward(pairs=["--clear"]) once reached kong as --clear and wiped a VM's +// forwards. func checkFlagFree(values []string, what string) error { - return fmt.Errorf("not implemented") + for _, v := range values { + if strings.TrimSpace(v) == "" { + return fmt.Errorf("%s contains an empty value", what) + } + if strings.HasPrefix(v, "-") { + return fmt.Errorf("%s value %q may not start with a dash", what, v) + } + } + return nil } +// stripForbidden drops keys an agent may never set. It returns a new map: +// an agent that reads a VM back and passes it to update is doing something +// reasonable, and the field simply has no effect. func stripForbidden(patch map[string]any) map[string]any { - _ = forbiddenPatchKeys - return nil + out := make(map[string]any, len(patch)) + for k, v := range patch { + if !slices.Contains(forbiddenPatchKeys, k) { + out[k] = v + } + } + return out } +// checkIndexName splits "" or "@" from the recipe index. A +// URL is refused here rather than in core: add_recipe is the tool an agent +// reaches, and a URL is a repository nobody curated. func checkIndexName(ref string) (string, string, error) { - return "", "", fmt.Errorf("not implemented") + if strings.TrimSpace(ref) == "" { + return "", "", fmt.Errorf("recipe name is required") + } + if strings.ContainsAny(ref, ":/\\") { + return "", "", fmt.Errorf("invalid recipe name %q: index names only, not a URL", ref) + } + name, gitRef, hasRef := strings.Cut(ref, "@") + if strings.Contains(gitRef, "@") { + return "", "", fmt.Errorf("invalid recipe name %q: at most one @ref", ref) + } + if !indexNameRE.MatchString(name) { + return "", "", fmt.Errorf("invalid recipe name %q: must match %s", name, indexNameRE) + } + if hasRef { + if !gitRefRE.MatchString(gitRef) || strings.Contains(gitRef, "..") { + return "", "", fmt.Errorf("invalid ref %q for recipe %q", gitRef, name) + } + } + return name, gitRef, nil } func checkParamName(name string) (string, error) { - return "", fmt.Errorf("not implemented") + if !paramNameRE.MatchString(name) { + return "", fmt.Errorf("invalid param name %q: must match %s", name, paramNameRE) + } + return name, nil } +// checkGuestPath requires an absolute guest path. A relative path is an +// error and is never resolved against $HOME, so one tool call means one +// thing on every guest. func checkGuestPath(path string) (string, error) { - return "", fmt.Errorf("not implemented") + if strings.TrimSpace(path) == "" { + return "", fmt.Errorf("guest path is required") + } + if strings.ContainsRune(path, 0) { + return "", fmt.Errorf("guest path contains a null byte") + } + if !strings.HasPrefix(path, "/") { + return "", fmt.Errorf("guest path %q must be absolute", path) + } + return path, nil } +// checkSvcName bounds a service name. svc and svc_status render the guest +// file's template and pass the name as $1, so this is depth rather than the +// boundary. func checkSvcName(name string) (string, error) { - return "", fmt.Errorf("not implemented") + if !svcNameRE.MatchString(name) { + return "", fmt.Errorf("invalid service name %q: must match %s", name, svcNameRE) + } + return name, nil } From 4606173e804859894329daf69a60a1de99f7d8b9 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:19:53 +0300 Subject: [PATCH 09/67] feat(mcp): add the server and read-only tools Signed-off-by: NovusEdge --- internal/cli/wire/dto.go | 50 +++++++++++ internal/mcpsrv/contract.go | 8 ++ internal/mcpsrv/server.go | 84 ++++++++++++++++-- internal/mcpsrv/tools_read.go | 159 ++++++++++++++++++++++++++++++++++ 4 files changed, 292 insertions(+), 9 deletions(-) create mode 100644 internal/mcpsrv/contract.go create mode 100644 internal/mcpsrv/tools_read.go diff --git a/internal/cli/wire/dto.go b/internal/cli/wire/dto.go index 10b979bd..74c45a38 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -681,6 +681,56 @@ func FromShot(vm string, s core.Shot) Screenshot { return Screenshot{VM: vm, Path: s.Path, Bytes: s.Bytes, Width: s.Width, Height: s.Height} } +// VMList is the ls command's data and the list_vms tool's output. +type VMList struct { + VMs []VM `json:"vms"` +} + +// ImageList is the list_images tool's output. +type ImageList struct { + Images []Image `json:"images"` +} + +// RecipeCatalog is the list_recipes tool's output and the `recipes` command's +// data: the recipes stoat can apply, filtered by OS or backend. It is not +// RecipeList, which is `recipe list`'s data and whose entries are +// RecipeEntry rows about provenance. +type RecipeCatalog struct { + Recipes []Recipe `json:"recipes"` +} + +// RecipeIssueList is the check_recipes tool's output. +type RecipeIssueList struct { + Issues []RecipeIssue `json:"issues"` +} + +// ApplyPlanList is the plan_recipes tool's output. +type ApplyPlanList struct { + Plan []ApplyPlan `json:"plan"` +} + +// LogTail is the logs tool's output. +type LogTail struct { + Lines []string `json:"lines"` +} + +// DoctorReport is the doctor tool's output. Healthy is false when any check +// failed and is not itself Optional. +type DoctorReport struct { + Healthy bool `json:"healthy"` + Checks []HostCheck `json:"checks"` +} + +func FromDoctor(cs []core.HostCheck) DoctorReport { + r := DoctorReport{Healthy: true, Checks: FromHostChecks(cs)} + for _, c := range r.Checks { + if !c.OK && !c.Optional { + r.Healthy = false + } + } + return r +} + // GuestList is `guest ls` data. type GuestList struct { Guests []Guest `json:"guests"` diff --git a/internal/mcpsrv/contract.go b/internal/mcpsrv/contract.go new file mode 100644 index 00000000..2b109c75 --- /dev/null +++ b/internal/mcpsrv/contract.go @@ -0,0 +1,8 @@ +package mcpsrv + +import "github.com/novusedge/stoat/internal/cli/wire" + +// Contract is the JSON contract this server speaks. It is the same constant +// stoat --json version reports, so the runtime check the Python server did +// against a separate process is now a compile-time fact. +const Contract = wire.ContractVersion diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index 869a1e88..476d4c63 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -1,11 +1,16 @@ package mcpsrv import ( + "context" + "fmt" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/novusedge/stoat/internal/cli/wire" ) // class is a tool's annotation class. The four classes are the taxonomy in -// docs/design/mcp-server.md; toolTable in table_test.go assigns one per tool. +// docs/design/mcp-server.md; toolTable in table_test.go assigns one per tool +// and TestAnnotationsMatchTable asserts the mapping. type class int const ( @@ -28,20 +33,81 @@ type Options struct { Limits Limits } -const maxLogLines = 2000 +const ( + maxLogLines = 2000 + maxWaitSecs = 600 + maxReadBytes = 1 << 20 + maxDirEntries = 2000 + maxPSRows = 2000 + maxExecSecs = 600 +) + +// srv carries the per-process state the handlers need. It is a value on the +// closure each handler captures rather than a global, so a test can build +// two servers with different limits. +type srv struct { + opts Options + lim *limiter +} -// New is a stub; Task 5's implementer registers the tool set and Task 6 -// wires the rate limit middleware. +// New builds the server with every tool registered. The caller runs it over +// a transport. func New(opts Options) *mcp.Server { - return mcp.NewServer(&mcp.Implementation{Name: "stoat", Version: opts.Version}, nil) + if opts.Limits.ToolBurst == 0 { + opts.Limits = DefaultLimits() + } + s := &srv{opts: opts, lim: newLimiter(opts.Limits)} + server := mcp.NewServer(&mcp.Implementation{Name: "stoat", Version: opts.Version}, nil) + s.registerRead(server) + return server } -// annotationsFor is a stub; server_test.go pins the real mapping from class. +func ptr[T any](v T) *T { return &v } + +// annotationsFor renders a class as MCP annotations. DestructiveHint and +// OpenWorldHint are pointers and the MCP default for destructiveHint is +// true, so a non-destructive tool sets an explicit false rather than nil. func annotationsFor(c class) *mcp.ToolAnnotations { - return &mcp.ToolAnnotations{} + switch c { + case classRead: + return &mcp.ToolAnnotations{ReadOnlyHint: true, DestructiveHint: ptr(false), OpenWorldHint: ptr(false)} + case classMutate: + return &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: ptr(false), OpenWorldHint: ptr(false)} + case classDestructive: + return &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: ptr(true), OpenWorldHint: ptr(false)} + case classExec: + return &mcp.ToolAnnotations{ReadOnlyHint: false, DestructiveHint: ptr(true), OpenWorldHint: ptr(true)} + } + panic(fmt.Sprintf("unknown class %d", c)) +} + +// register adds one tool. In is always a struct so the generated schema sets +// additionalProperties:false, and Out is always a wire DTO so the --json +// contract and the MCP schema are one set of types. c must match the class +// toolTable (in table_test.go, a test fixture the production build cannot +// import) assigns this tool's name; TestAnnotationsMatchTable checks that. +func register[In, Out any](server *mcp.Server, name string, c class, description string, h func(context.Context, In) (Out, error)) { + tool := &mcp.Tool{Name: name, Description: description, Annotations: annotationsFor(c)} + mcp.AddTool(server, tool, func(ctx context.Context, _ *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) { + out, err := h(ctx, in) + if err != nil { + var zero Out + return toolError(err), zero, nil + } + return nil, out, nil + }) +} + +// toolError renders an error the way the CLI prints it. wire.MapError gives +// the same text a --json result line carries, so an agent reading a tool +// failure and a user reading the CLI see one message. +func toolError(err error) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: wire.MapError(err).Message}}, + } } -// clampInt is a stub; server_test.go pins the real clamp. func clampInt(v, lo, hi int) int { - return 0 + return max(lo, min(v, hi)) } diff --git a/internal/mcpsrv/tools_read.go b/internal/mcpsrv/tools_read.go new file mode 100644 index 00000000..1fcf3502 --- /dev/null +++ b/internal/mcpsrv/tools_read.go @@ -0,0 +1,159 @@ +package mcpsrv + +import ( + "bufio" + "context" + "io" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/core" +) + +type emptyIn struct{} + +type vmIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` +} + +type listRecipesIn struct { + OS string `json:"os,omitempty" jsonschema:"only recipes applicable to this guest OS"` + Backend string `json:"backend,omitempty" jsonschema:"only recipes applicable to this backend"` +} + +type checkRecipesIn struct { + Recipes []string `json:"recipes" jsonschema:"recipe names to check"` + OS string `json:"os" jsonschema:"guest OS to check against"` + Backend string `json:"backend,omitempty" jsonschema:"backend to check against"` +} + +type logsIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Which string `json:"which,omitempty" jsonschema:"console for the qemu console log, apply for the most recent recipe apply log"` + N int `json:"n,omitempty" jsonschema:"number of lines to tail, capped at 2000"` +} + +type planRecipesIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Only []string `json:"only,omitempty" jsonschema:"subset of the VM's own recipes"` +} + +func (s *srv) registerRead(server *mcp.Server) { + register(server, "list_vms", classRead, + "List every VM stoat manages, one entry per VM: name, OS, mode, state, resources, disk, share, ssh port, agent access level, recipes, and port forwards. A VM whose vm.toml failed to parse is listed with state broken and an error message rather than hidden. Read-only: it touches no VM and changes nothing on the host.", + func(ctx context.Context, _ emptyIn) (wire.VMList, error) { + vms, err := core.List() + if err != nil { + return wire.VMList{}, err + } + return wire.VMList{VMs: wire.FromVMs(vms, core.GraphicalSession())}, nil + }) + + register(server, "vm_status", classRead, + "Show one VM's full status: OS, mode, backend, state, CPUs, RAM, disk, share, ssh port and user, agent access level, applied recipes with their health and outputs, and port forwards. Read-only: it touches no VM and changes nothing on the host.", + func(ctx context.Context, in vmIn) (wire.VMStatus, error) { + name, err := checkVMName(in.VM) + if err != nil { + return wire.VMStatus{}, err + } + v, err := core.Get(name) + if err != nil { + return wire.VMStatus{}, err + } + return wire.FromVMStatus(v, core.GraphicalSession()), nil + }) + + register(server, "list_images", classRead, + "List what stoat can build a VM from: the catalog of known images, plus anything already downloaded locally. Read-only: it downloads nothing and changes nothing on the host.", + func(ctx context.Context, _ emptyIn) (wire.ImageList, error) { + imgs, err := core.Images() + if err != nil { + return wire.ImageList{}, err + } + return wire.ImageList{Images: wire.FromCatalogImages(imgs)}, nil + }) + + register(server, "list_recipes", classRead, + "List recipes stoat knows about, optionally filtered to the ones applicable to a guest OS or a backend. It reads the recipe directories only, and runs nothing in any VM. Read-only.", + func(ctx context.Context, in listRecipesIn) (wire.RecipeCatalog, error) { + rs, err := core.Recipes(core.RecipeFilter{OS: in.OS, Backend: in.Backend}) + if err != nil { + return wire.RecipeCatalog{}, err + } + return wire.RecipeCatalog{Recipes: wire.FromRecipes(rs)}, nil + }) + + register(server, "check_recipes", classRead, + "Report, for each named recipe, why it would not apply to a given guest OS and backend. An empty issue list means every named recipe would apply. It runs nothing and touches no VM. Read-only.", + func(ctx context.Context, in checkRecipesIn) (wire.RecipeIssueList, error) { + if err := checkFlagFree(in.Recipes, "recipes"); err != nil { + return wire.RecipeIssueList{}, err + } + issues, err := core.CheckRecipes(in.OS, in.Backend, in.Recipes) + if err != nil { + return wire.RecipeIssueList{}, err + } + return wire.RecipeIssueList{Issues: wire.FromRecipeIssues(issues)}, nil + }) + + register(server, "logs", classRead, + "Tail one VM's log: its qemu console output by default, or its most recent recipe apply log. It is always scoped to one named VM, and there is no way to read stoat's own global log through this tool. The line count is capped at 2000. Read-only.", + func(ctx context.Context, in logsIn) (wire.LogTail, error) { + name, err := checkVMName(in.VM) + if err != nil { + return wire.LogTail{}, err + } + which := core.Which(in.Which) + if in.Which == "" { + which = core.WhichConsole + } + rc, err := core.Logs(name, which) + if err != nil { + return wire.LogTail{}, err + } + defer func() { _ = rc.Close() }() + return tailLines(rc, clampInt(in.N, 1, maxLogLines)) + }) + + register(server, "doctor", classRead, + "Check host prerequisites: qemu and KVM, qemu-img, ssh, xorriso, git and /dev/kvm. The check always succeeds; an unhealthy host is reported in the result rather than raised as a failure. Read-only: it changes nothing on the host.", + func(ctx context.Context, _ emptyIn) (wire.DoctorReport, error) { + return wire.FromDoctor(core.Doctor()), nil + }) + + register(server, "plan_recipes", classRead, + "Report what apply_recipes would do to a VM, without running anything: one entry per recipe with run or skip and the reason. It is computed on the host, so it works on a stopped VM. Call it before apply_recipes. Read-only.", + func(ctx context.Context, in planRecipesIn) (wire.ApplyPlanList, error) { + name, err := checkVMName(in.VM) + if err != nil { + return wire.ApplyPlanList{}, err + } + if err := checkFlagFree(in.Only, "only"); err != nil { + return wire.ApplyPlanList{}, err + } + plans, err := core.PlanApply(name, core.ApplyOpts{Only: in.Only}) + if err != nil { + return wire.ApplyPlanList{}, err + } + return wire.ApplyPlanList{Plan: wire.FromApplyPlans(plans)}, nil + }) +} + +// tailLines returns the last n lines of r. core.Logs streams the whole file, +// and the tail comes back in one response, so clamping beats handing an +// agent a payload it cannot read. +func tailLines(r io.Reader, n int) (wire.LogTail, error) { + ring := make([]string, 0, n) + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) + for sc.Scan() { + if len(ring) == n { + ring = ring[1:] + } + ring = append(ring, sc.Text()) + } + if err := sc.Err(); err != nil { + return wire.LogTail{}, err + } + return wire.LogTail{Lines: ring}, nil +} From 038923c02f80ed11ecfe41fb4ee99377ffc1f015 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:20:28 +0300 Subject: [PATCH 10/67] feat(mcp): charge the rate limits in middleware Signed-off-by: NovusEdge --- internal/mcpsrv/server.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index 476d4c63..cad92bfe 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -59,6 +59,7 @@ func New(opts Options) *mcp.Server { s := &srv{opts: opts, lim: newLimiter(opts.Limits)} server := mcp.NewServer(&mcp.Implementation{Name: "stoat", Version: opts.Version}, nil) s.registerRead(server) + server.AddReceivingMiddleware(s.rateLimit()) return server } From 1e2ad8655dbb1bcc9cd1342dad7d20ccb9f3b76d Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:20:36 +0300 Subject: [PATCH 11/67] test(mcp): probe the shared bucket with fresh tools Signed-off-by: NovusEdge --- internal/mcpsrv/ratelimit_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/mcpsrv/ratelimit_test.go b/internal/mcpsrv/ratelimit_test.go index 035b4c58..76e8fec6 100644 --- a/internal/mcpsrv/ratelimit_test.go +++ b/internal/mcpsrv/ratelimit_test.go @@ -2,6 +2,7 @@ package mcpsrv import ( "context" + "fmt" "strings" "testing" "time" @@ -74,9 +75,11 @@ func TestRateLimiter(t *testing.T) { } } // Nine shared tokens must be left: one spent by the call that - // succeeded, none by the five refusals. + // succeeded, none by the five refusals. Each cold call uses its own + // tool name so its own per-tool bucket (ToolBurst 1) never refuses + // it; only the shared bucket is under test here. for i := range 9 { - if err := l.check("cold", start); err != nil { + if err := l.check(fmt.Sprintf("cold-%d", i), start); err != nil { t.Fatalf("cold call %d refused, so a refusal spent a shared token: %v", i, err) } } From c6691d3271fb2cd1448a5daad9be4f80bff4015c Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:22:30 +0300 Subject: [PATCH 12/67] feat(mcp): add per-tool and shared rate limits Signed-off-by: NovusEdge --- internal/mcpsrv/ratelimit.go | 85 +++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/internal/mcpsrv/ratelimit.go b/internal/mcpsrv/ratelimit.go index 2cdd4dda..85e2759a 100644 --- a/internal/mcpsrv/ratelimit.go +++ b/internal/mcpsrv/ratelimit.go @@ -1,12 +1,19 @@ package mcpsrv import ( + "context" "fmt" + "math" + "sync" "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" ) // Limits are the token bucket sizes. The MCP spec makes rate limiting a -// server MUST. +// server MUST. The numbers are generous enough that ordinary agent work +// never notices and tight enough that a runaway loop stops being the host's +// problem. type Limits struct { ToolBurst int ToolRate float64 @@ -15,16 +22,82 @@ type Limits struct { } func DefaultLimits() Limits { - return Limits{} + return Limits{ToolBurst: 30, ToolRate: 0.5, Burst: 60, Rate: 2} } -// limiter is a stub; ratelimit_test.go pins its real behaviour. -type limiter struct{} +const sharedKey = "" + +type bucket struct { + tokens float64 + last time.Time +} + +// limiter holds one bucket per tool name plus one every tool shares. The +// streamable HTTP transport serves concurrent requests, so the mutex is +// real, unlike the single-threaded Python original. +type limiter struct { + lim Limits + mu sync.Mutex + b map[string]*bucket +} func newLimiter(l Limits) *limiter { - return &limiter{} + return &limiter{lim: l, b: map[string]*bucket{}} } +// check consumes one token for tool and one shared token. Both buckets are +// read before either is charged: a hot tool that hits its own limit must not +// drain the shared bucket and starve every other tool. func (l *limiter) check(tool string, now time.Time) error { - return fmt.Errorf("not implemented") + l.mu.Lock() + defer l.mu.Unlock() + + toolTokens, err := l.read(tool, l.lim.ToolBurst, l.lim.ToolRate, now, tool) + if err != nil { + return err + } + sharedTokens, err := l.read(sharedKey, l.lim.Burst, l.lim.Rate, now, "the server") + if err != nil { + return err + } + l.b[tool] = &bucket{tokens: toolTokens - 1, last: now} + l.b[sharedKey] = &bucket{tokens: sharedTokens - 1, last: now} + return nil +} + +func (l *limiter) read(key string, capacity int, refill float64, now time.Time, subject string) (float64, error) { + b, ok := l.b[key] + if !ok { + b = &bucket{tokens: float64(capacity), last: now} + } + tokens := math.Min(float64(capacity), b.tokens+now.Sub(b.last).Seconds()*refill) + if tokens < 1 { + wait := time.Duration((1 - tokens) / refill * float64(time.Second)).Round(time.Second) + return 0, fmt.Errorf("rate limit reached for %s; retry in about %s", subject, wait) + } + return tokens, nil +} + +// rateLimit is receiving middleware, so a refusal happens before the handler +// runs and before any argument is logged. Only tools/call is charged: a +// client listing tools or initializing is not doing work on a VM. +func (s *srv) rateLimit() mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method != "tools/call" { + return next(ctx, method, req) + } + name := "unknown" + if p, ok := req.GetParams().(*mcp.CallToolParamsRaw); ok { + name = p.Name + } + if err := s.lim.check(name, time.Now()); err != nil { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + }, nil + } + return next(ctx, method, req) + } + } } From a8b23c49296bb8f754e62bdb5809d1a0e7138f83 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:53:30 +0300 Subject: [PATCH 13/67] test(mcp): pin host-side VM tools Signed-off-by: NovusEdge --- internal/mcpsrv/table_test.go | 12 ++++--- internal/mcpsrv/tools_vm.go | 23 +++++++++++++ internal/mcpsrv/tools_vm_test.go | 56 ++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 internal/mcpsrv/tools_vm.go create mode 100644 internal/mcpsrv/tools_vm_test.go diff --git a/internal/mcpsrv/table_test.go b/internal/mcpsrv/table_test.go index 7d41d6b0..a1e3c20b 100644 --- a/internal/mcpsrv/table_test.go +++ b/internal/mcpsrv/table_test.go @@ -74,15 +74,17 @@ var forbiddenInputFields = []string{"share", "base", "iso", "console_password", // pending names tools a later task registers. Every entry is removed by the // task that adds the tool; Task 18 asserts the list is empty. +// +// Task 7 owns the rest of this chunk's host-side tools and is gone from +// this map already: its tests assert real registration, which does not +// exist yet, so TestEveryTableToolIsRegistered fails for them until its +// implementer lands. var pending = map[string]string{ "list_guests": "Task 14", "guest_info": "Task 14", "recipe_schema": "Task 14", - "search_recipes": "Task 14", "create": "Task 7", "start": "Task 7", - "stop": "Task 7", "update": "Task 7", "clone": "Task 7", "snapshot": "Task 7", - "forward": "Task 7", "wait": "Task 7", "destroy": "Task 7", "prune": "Task 7", - "restore": "Task 7", "add_recipe": "Task 15", "update_recipe": "Task 15", + "search_recipes": "Task 14", "add_recipe": "Task 15", "update_recipe": "Task 15", "remove_recipe": "Task 15", "read_file": "Task 10", "list_dir": "Task 10", "stat": "Task 10", "ps": "Task 10", "svc_status": "Task 10", "tail_log": "Task 10", - "apply_recipes": "Task 7", "write_file": "Task 11", "copy_to": "Task 11", + "write_file": "Task 11", "copy_to": "Task 11", "copy_from": "Task 11", "pkg_install": "Task 11", "svc": "Task 11", "useradd": "Task 11", "exec": "Task 12", "exec_bg": "Task 12", "job_status": "Task 12", "job_output": "Task 12", "job_kill": "Task 12", diff --git a/internal/mcpsrv/tools_vm.go b/internal/mcpsrv/tools_vm.go new file mode 100644 index 00000000..39e3be4d --- /dev/null +++ b/internal/mcpsrv/tools_vm.go @@ -0,0 +1,23 @@ +package mcpsrv + +import "time" + +// updateIn is the input for the update tool. Task 7's implementation adds +// the remaining fields (CPUs, SSHPort, Disk, Recipes, Params, Secrets, +// AgentAccess); RAMMB is the only one this chunk's tests exercise. +type updateIn struct { + VM string + RAMMB int +} + +// waitTimeout is not implemented yet. Task 7 clamps seconds to +// [1, maxWaitSecs] and returns the equivalent time.Duration. +func waitTimeout(seconds int) time.Duration { + return 0 +} + +// patchFromUpdate is not implemented yet. Task 7 builds the patch map from +// in's non-zero fields and runs it through stripForbidden. +func patchFromUpdate(in updateIn) map[string]any { + return map[string]any{} +} diff --git a/internal/mcpsrv/tools_vm_test.go b/internal/mcpsrv/tools_vm_test.go new file mode 100644 index 00000000..d7a4a45c --- /dev/null +++ b/internal/mcpsrv/tools_vm_test.go @@ -0,0 +1,56 @@ +package mcpsrv + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestWaitClampsTimeout(t *testing.T) { + for _, c := range []struct { + in int + want time.Duration + }{ + {0, time.Second}, + {120, 120 * time.Second}, + {9000, maxWaitSecs * time.Second}, + } { + if got := waitTimeout(c.in); got != c.want { + t.Errorf("waitTimeout(%d) = %s, want %s", c.in, got, c.want) + } + } +} + +func TestForwardRefusesFlagPair(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + res := callTool(t, "forward", map[string]any{"vm": "work", "pairs": []string{"--clear"}}) + if !res.IsError { + t.Fatal("forward accepted a pair kong reads as a flag") + } +} + +func TestCreateTakesCatalogImageIDOnly(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + res := callTool(t, "create", map[string]any{"name": "work", "image": "/home/me/my.qcow2"}) + if !res.IsError { + t.Fatal("create accepted a bring-your-own image path") + } + raw, _ := json.Marshal(res.Content) + if !strings.Contains(string(raw), "catalog image ids") { + t.Fatalf("refusal did not name the rule: %s", raw) + } +} + +func TestUpdateStripsForbiddenKeys(t *testing.T) { + // update's own input struct has no share field, and the patch it builds + // still goes through stripForbidden: an agent that reads a VM back and + // passes it here must find the field inert, not effective. + p := patchFromUpdate(updateIn{VM: "work", RAMMB: 2048}) + if _, ok := p["share"]; ok { + t.Fatal("share reached the patch") + } + if p["ram"] != 2048 { + t.Fatalf("ram = %v", p["ram"]) + } +} From ae0de993152b3798b2844ec3d84600573587c5ff Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:53:41 +0300 Subject: [PATCH 14/67] test(mcp): pin agent access levels Signed-off-by: NovusEdge --- internal/config/config.go | 4 ++ internal/config/config_test.go | 31 +++++++++++ internal/mcpsrv/access.go | 28 ++++++++++ internal/mcpsrv/access_test.go | 98 ++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 internal/mcpsrv/access_test.go diff --git a/internal/config/config.go b/internal/config/config.go index d839c804..2ca39acf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -105,6 +105,10 @@ type VM struct { // cannot tell "false" from "not written". AllowExec bool `toml:"allow_exec"` + // AgentAccess is not implemented yet. Task 8 replaces AllowExec with + // this field and maps a legacy allow_exec key onto it in Load. + AgentAccess string `toml:"agent_access,omitempty"` + // Applied tracks which recipes have been run on this VM, keyed by recipe name. Applied map[string]AppliedRecipe `toml:"applied,omitempty" comment:"written by stoat; do not edit"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4b3bc510..f4a41257 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -12,6 +12,37 @@ import ( "time" ) +// TestAgentAccessFromLegacyAllowExec pins the mapping from vm.toml's old +// allow_exec key to the new agent_access levels, and that an explicit +// agent_access always wins over allow_exec: a file round-tripped through an +// older stoat can still carry both keys. +func TestAgentAccessFromLegacyAllowExec(t *testing.T) { + for _, c := range []struct{ toml, want string }{ + {"name = \"x\"\nallow_exec = true\n", "exec"}, + {"name = \"x\"\nallow_exec = false\n", "manage"}, + {"name = \"x\"\n", "manage"}, + {"name = \"x\"\nagent_access = \"observe\"\n", "observe"}, + {"name = \"x\"\nagent_access = \"none\"\nallow_exec = true\n", "none"}, + } { + root := t.TempDir() + t.Setenv("STOAT_HOME", root) + dir := filepath.Join(root, "x") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "vm.toml"), []byte(c.toml), 0o644); err != nil { + t.Fatal(err) + } + v, err := Load("x") + if err != nil { + t.Fatalf("%q: %v", c.toml, err) + } + if v.AgentAccess != c.want { + t.Errorf("%q: AgentAccess = %q, want %q", c.toml, v.AgentAccess, c.want) + } + } +} + func TestLoadWarnsUnknownKeyToStderr(t *testing.T) { root := t.TempDir() t.Setenv("STOAT_HOME", root) diff --git a/internal/mcpsrv/access.go b/internal/mcpsrv/access.go index f9f8742a..086eb436 100644 --- a/internal/mcpsrv/access.go +++ b/internal/mcpsrv/access.go @@ -1,5 +1,7 @@ package mcpsrv +import "fmt" + // Level is an agent_access level. Task 8 adds requireAccess and its table; // this chunk only needs the type for toolTable's Access field. type Level int @@ -10,3 +12,29 @@ const ( LevelManage LevelExec ) + +// levelNames is the string form of a Level: vm.toml's agent_access key, the +// CLI's --agent-access flag, and every refusal message all read through it. +var levelNames = [...]string{"none", "observe", "manage", "exec"} + +func (l Level) String() string { + if l < LevelNone || int(l) >= len(levelNames) { + return "invalid" + } + return levelNames[l] +} + +// rank orders Level for comparison. Level's own iota order is already that +// rank, since each level includes every one below it. +func (l Level) rank() int { return int(l) } + +// ParseLevel is not implemented yet. Task 8 validates s against levelNames. +func ParseLevel(s string) (Level, error) { + return LevelNone, fmt.Errorf("agent_access %q: not implemented", s) +} + +// requireAccess is not implemented yet. Task 8 gates every guest-touching +// tool at the level toolTable declares for it. +func requireAccess(vm string, need Level) error { + return fmt.Errorf("requireAccess(%q, %s): not implemented", vm, need) +} diff --git a/internal/mcpsrv/access_test.go b/internal/mcpsrv/access_test.go new file mode 100644 index 00000000..dd06a004 --- /dev/null +++ b/internal/mcpsrv/access_test.go @@ -0,0 +1,98 @@ +package mcpsrv + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/config" +) + +// writeVM creates a VM directory whose vm.toml declares one access level. +func writeVM(t *testing.T, name, level string) { + t.Helper() + dir := filepath.Join(config.Root(), name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := "name = \"" + name + "\"\nagent_access = \"" + level + "\"\n" + if err := os.WriteFile(filepath.Join(dir, "vm.toml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestRequireAccess asserts every cell of the access table: each +// guest-touching tool against each of the four levels. +func TestRequireAccess(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + levels := []Level{LevelNone, LevelObserve, LevelManage, LevelExec} + for _, level := range levels { + writeVM(t, level.String(), level.String()) + } + for _, spec := range toolTable { + if spec.Access == LevelNone { + continue // A host-side tool is not gated. + } + for _, have := range levels { + t.Run(spec.Name+"/"+have.String(), func(t *testing.T) { + err := requireAccess(have.String(), spec.Access) + allowed := have.rank() >= spec.Access.rank() + if allowed && err != nil { + t.Fatalf("%s at %s was refused: %v", spec.Name, have, err) + } + if !allowed && err == nil { + t.Fatalf("%s at %s was allowed, needs %s", spec.Name, have, spec.Access) + } + }) + } + } +} + +func TestRequireAccessNamesTheLevel(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "observe") + err := requireAccess("dev", LevelManage) + if err == nil { + t.Fatal("no refusal") + } + want := `vm "dev" has agent_access = observe; needs manage` + if !strings.Contains(err.Error(), want) { + t.Fatalf("message = %q, want it to contain %q", err, want) + } +} + +func TestParseLevel(t *testing.T) { + for _, s := range []string{"none", "observe", "manage", "exec"} { + if _, err := ParseLevel(s); err != nil { + t.Errorf("ParseLevel(%q): %v", s, err) + } + } + for _, s := range []string{"", "None", "root", "all"} { + if _, err := ParseLevel(s); err == nil { + t.Errorf("ParseLevel(%q) accepted", s) + } + } +} + +func TestPlanRecipesNeedsNoAccess(t *testing.T) { + // plan_recipes is computed on the host and runs nothing in the guest, so + // it works on a VM at agent_access = none. + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "locked", "none") + res := callTool(t, "plan_recipes", map[string]any{"vm": "locked"}) + if res.IsError { + t.Fatalf("plan_recipes refused on a locked VM: %+v", res.Content) + } +} + +func TestUpdateRefusesToRaiseTheLevel(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "observe") + if res := callTool(t, "update", map[string]any{"vm": "dev", "agent_access": "exec"}); !res.IsError { + t.Fatal("update raised the access level") + } + if res := callTool(t, "update", map[string]any{"vm": "dev", "agent_access": "none"}); res.IsError { + t.Fatalf("update could not lower the access level: %+v", res.Content) + } +} From 11bf4662dabeed755ca427bd318d2cef76802725 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:53:55 +0300 Subject: [PATCH 15/67] test(sshx): pin Run and the fake ssh harness Signed-off-by: NovusEdge --- internal/sshx/run.go | 17 ++++++++ internal/sshx/run_test.go | 83 ++++++++++++++++++++++++++++++++++++ internal/testutil/fakessh.go | 55 ++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 internal/sshx/run.go create mode 100644 internal/sshx/run_test.go create mode 100644 internal/testutil/fakessh.go diff --git a/internal/sshx/run.go b/internal/sshx/run.go new file mode 100644 index 00000000..693185f0 --- /dev/null +++ b/internal/sshx/run.go @@ -0,0 +1,17 @@ +package sshx + +import ( + "context" + "fmt" + "io" + + "github.com/novusedge/stoat/internal/config" +) + +// Run is not implemented yet. Task 9 executes argv inside v's guest over +// ssh, quoting it for the guest shell, and returns the guest's raw output +// and exit status. A command that ran and exited non-zero is a result, not +// an error; Run returns an error only when ssh could not run at all. +func Run(ctx context.Context, v *config.VM, root bool, argv []string, stdin io.Reader) ([]byte, []byte, int, error) { + return nil, nil, 0, fmt.Errorf("sshx.Run: not implemented") +} diff --git a/internal/sshx/run_test.go b/internal/sshx/run_test.go new file mode 100644 index 00000000..3895edab --- /dev/null +++ b/internal/sshx/run_test.go @@ -0,0 +1,83 @@ +package sshx_test + +import ( + "context" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/sshx" + "github.com/novusedge/stoat/internal/testutil" +) + +func TestRunPassesArgvIntactThroughTheGuestShell(t *testing.T) { + // The argv goes host argv -> ssh's trailing arguments -> the guest + // shell's re-parse. A path with a space and a semicolon proves no layer + // drops a word boundary or lets the guest shell read a separator. + calls := testutil.FakeSSH(t, `printf '%s\n' "$@" | tail -n 1`) + v := &config.VM{Name: "work", SSHPort: 2222, SSHUser: "root"} + + out, _, code, err := sshx.Run(context.Background(), v, false, + []string{"touch", "/tmp/a b;rm -rf /", "x"}, nil) + if err != nil || code != 0 { + t.Fatalf("code=%d err=%v", code, err) + } + remote := calls.Calls()[0].Remote + if !strings.Contains(remote, `'/tmp/a b;rm -rf /'`) { + t.Fatalf("remote command did not quote the path: %q", remote) + } + _ = out +} + +func TestRunReportsANonZeroExitAsData(t *testing.T) { + testutil.FakeSSH(t, `exit 42`) + v := &config.VM{Name: "work", SSHPort: 2222, SSHUser: "root"} + _, _, code, err := sshx.Run(context.Background(), v, false, []string{"false"}, nil) + if err != nil { + t.Fatalf("a non-zero guest exit was reported as an error: %v", err) + } + if code != 42 { + t.Fatalf("code = %d, want 42", code) + } +} + +func TestRunEscalatesOnlyWhenAsked(t *testing.T) { + calls := testutil.FakeSSH(t, `true`) + v := &config.VM{Name: "work", SSHPort: 2222, SSHUser: "stoat", OS: "alpine"} + + if _, _, _, err := sshx.Run(context.Background(), v, false, []string{"id"}, nil); err != nil { + t.Fatal(err) + } + if strings.Contains(calls.Calls()[0].Remote, "doas") || strings.Contains(calls.Calls()[0].Remote, "sudo") { + t.Fatalf("a tool escalated on its own: %q", calls.Calls()[0].Remote) + } + if _, _, _, err := sshx.Run(context.Background(), v, true, []string{"id"}, nil); err != nil { + t.Fatal(err) + } + if !strings.Contains(calls.Calls()[1].Remote, "doas") { + t.Fatalf("root=true did not apply alpine's escalate: %q", calls.Calls()[1].Remote) + } +} + +func TestRunDoesNotEscalateForRoot(t *testing.T) { + calls := testutil.FakeSSH(t, `true`) + v := &config.VM{Name: "work", SSHPort: 2222, SSHUser: "root", OS: "alpine"} + if _, _, _, err := sshx.Run(context.Background(), v, true, []string{"id"}, nil); err != nil { + t.Fatal(err) + } + if strings.Contains(calls.Calls()[0].Remote, "doas") { + t.Fatalf("escalated for a root ssh user: %q", calls.Calls()[0].Remote) + } +} + +func TestRunSendsStdin(t *testing.T) { + testutil.FakeSSH(t, `cat`) + v := &config.VM{Name: "work", SSHPort: 2222, SSHUser: "root"} + out, _, _, err := sshx.Run(context.Background(), v, false, []string{"cat"}, strings.NewReader("hello")) + if err != nil { + t.Fatal(err) + } + if string(out) != "hello" { + t.Fatalf("stdout = %q, want %q", out, "hello") + } +} diff --git a/internal/testutil/fakessh.go b/internal/testutil/fakessh.go new file mode 100644 index 00000000..46d26891 --- /dev/null +++ b/internal/testutil/fakessh.go @@ -0,0 +1,55 @@ +package testutil + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// SSHCall is one invocation of the fake ssh: the argv it was given and the +// remote command, which is ssh's last argument. +type SSHCall struct { + Argv []string + Remote string +} + +// SSHCalls reads back the fake ssh's call log on demand, so a test can +// inspect the argv that reached ssh between two Run calls, not only at the +// end. +type SSHCalls struct{ path string } + +func (c *SSHCalls) Calls() []SSHCall { + raw, err := os.ReadFile(c.path) + if err != nil { + return nil + } + var out []SSHCall + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + if line != "" { + out = append(out, SSHCall{Argv: strings.Fields(line), Remote: line}) + } + } + return out +} + +// FakeSSH puts an ssh on PATH that appends its argv to a log and then runs +// script with the remote command's words as "$@". It exists so an in-VM +// tool is testable without a VM: what a test asserts is the argv that +// reached ssh, which is the boundary the tools own. +func FakeSSH(t *testing.T, script string) *SSHCalls { + t.Helper() + dir := t.TempDir() + logPath := filepath.Join(dir, "calls.log") + body := "#!/bin/sh\n" + + "printf '%s\\n' \"$*\" >> " + logPath + "\n" + + "remote=\"${@: -1}\"\n" + + "eval \"set -- $remote\"\n" + + script + "\n" + binPath := filepath.Join(dir, "ssh") + if err := os.WriteFile(binPath, []byte(body), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return &SSHCalls{path: logPath} +} From d50b04141336ecfb9609174f85c2e0e0b5c64c49 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:54:32 +0300 Subject: [PATCH 16/67] test(mcp): pin in-VM read tools Signed-off-by: NovusEdge --- internal/mcpsrv/table_test.go | 13 ++-- internal/mcpsrv/tools_guest.go | 13 ++++ internal/mcpsrv/tools_guest_test.go | 114 ++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 internal/mcpsrv/tools_guest.go create mode 100644 internal/mcpsrv/tools_guest_test.go diff --git a/internal/mcpsrv/table_test.go b/internal/mcpsrv/table_test.go index a1e3c20b..adcf5ea4 100644 --- a/internal/mcpsrv/table_test.go +++ b/internal/mcpsrv/table_test.go @@ -75,16 +75,15 @@ var forbiddenInputFields = []string{"share", "base", "iso", "console_password", // pending names tools a later task registers. Every entry is removed by the // task that adds the tool; Task 18 asserts the list is empty. // -// Task 7 owns the rest of this chunk's host-side tools and is gone from -// this map already: its tests assert real registration, which does not -// exist yet, so TestEveryTableToolIsRegistered fails for them until its -// implementer lands. +// Tasks 7 and 10 own the rest of this chunk's tools registered so far and +// are gone from this map already: their tests assert real registration, +// which does not exist yet, so TestEveryTableToolIsRegistered fails for +// them until their implementer lands. var pending = map[string]string{ "list_guests": "Task 14", "guest_info": "Task 14", "recipe_schema": "Task 14", "search_recipes": "Task 14", "add_recipe": "Task 15", "update_recipe": "Task 15", - "remove_recipe": "Task 15", "read_file": "Task 10", "list_dir": "Task 10", - "stat": "Task 10", "ps": "Task 10", "svc_status": "Task 10", "tail_log": "Task 10", - "write_file": "Task 11", "copy_to": "Task 11", + "remove_recipe": "Task 15", + "write_file": "Task 11", "copy_to": "Task 11", "copy_from": "Task 11", "pkg_install": "Task 11", "svc": "Task 11", "useradd": "Task 11", "exec": "Task 12", "exec_bg": "Task 12", "job_status": "Task 12", "job_output": "Task 12", "job_kill": "Task 12", diff --git a/internal/mcpsrv/tools_guest.go b/internal/mcpsrv/tools_guest.go new file mode 100644 index 00000000..51bf2e9b --- /dev/null +++ b/internal/mcpsrv/tools_guest.go @@ -0,0 +1,13 @@ +package mcpsrv + +// readSize is not implemented yet. Task 10 clamps n to +// [1, maxReadBytes]. +func readSize(n int) int { + return 0 +} + +// capNames is not implemented yet. Task 10 truncates names to +// maxDirEntries. +func capNames(names []string) []string { + return nil +} diff --git a/internal/mcpsrv/tools_guest_test.go b/internal/mcpsrv/tools_guest_test.go new file mode 100644 index 00000000..2b55a2f6 --- /dev/null +++ b/internal/mcpsrv/tools_guest_test.go @@ -0,0 +1,114 @@ +package mcpsrv + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/testutil" +) + +func TestReadFileRefusesARelativePath(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "observe") + res := callTool(t, "read_file", map[string]any{"vm": "dev", "path": "etc/hosts"}) + if !res.IsError { + t.Fatal("read_file accepted a relative guest path") + } +} + +func TestReadFileClampsMaxBytes(t *testing.T) { + for _, c := range []struct{ in, want int }{{0, maxReadBytes}, {10, 10}, {1 << 30, maxReadBytes}} { + if got := readSize(c.in); got != c.want { + t.Errorf("readSize(%d) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestReadFileArgvIsFixed(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "observe") + // stat answers first with a size, then head -c returns the bytes. + calls := testutil.FakeSSH(t, `case "$1" in stat) echo 5;; *) printf hello;; esac`) + res := callTool(t, "read_file", map[string]any{"vm": "dev", "path": "/a b;c"}) + if res.IsError { + t.Fatalf("read_file failed: %+v", res.Content) + } + for _, call := range calls.Calls() { + if !strings.Contains(call.Remote, `'/a b;c'`) { + t.Fatalf("guest path was not passed as one quoted word: %q", call.Remote) + } + } + raw, _ := json.Marshal(res.StructuredContent) + var out struct { + Content string `json:"content"` + Size int64 `json:"size"` + Truncated bool `json:"truncated"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if out.Content != "hello" || out.Size != 5 || out.Truncated { + t.Fatalf("got %+v", out) + } +} + +func TestReadFileBase64sBinary(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "observe") + testutil.FakeSSH(t, `case "$1" in stat) echo 2;; *) printf '\377\376';; esac`) + res := callTool(t, "read_file", map[string]any{"vm": "dev", "path": "/bin/x"}) + raw, _ := json.Marshal(res.StructuredContent) + if !strings.Contains(string(raw), `"encoding":"base64"`) { + t.Fatalf("binary content was not base64 encoded: %s", raw) + } +} + +func TestListDirCapsEntries(t *testing.T) { + if got := len(capNames(makeNames(5000))); got != maxDirEntries { + t.Fatalf("capNames returned %d entries, want %d", got, maxDirEntries) + } +} + +func TestPSCapsRows(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "observe") + testutil.FakeSSH(t, `i=0; while [ $i -lt 3000 ]; do echo "$i 1 root 00:01 sleep"; i=$((i+1)); done`) + res := callTool(t, "ps", map[string]any{"vm": "dev"}) + raw, _ := json.Marshal(res.StructuredContent) + var out struct { + Processes []any `json:"processes"` + Truncated bool `json:"truncated"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if len(out.Processes) != maxPSRows || !out.Truncated { + t.Fatalf("got %d rows truncated=%v", len(out.Processes), out.Truncated) + } +} + +func TestGuestReadToolsRefusedAtNone(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "locked", "none") + for _, name := range []string{"read_file", "list_dir", "stat", "ps", "svc_status", "tail_log"} { + args := map[string]any{"vm": "locked"} + switch name { + case "read_file", "list_dir", "stat": + args["path"] = "/etc" + case "svc_status": + args["name"] = "sshd" + } + if res := callTool(t, name, args); !res.IsError { + t.Errorf("%s ran on a VM at agent_access = none", name) + } + } +} + +func makeNames(n int) []string { + out := make([]string, n) + for i := range out { + out[i] = "f" + } + return out +} From 4ef1518db3abb2f06aa3aa8fa23fb8c1c9028fcb Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:55:07 +0300 Subject: [PATCH 17/67] test(mcp): pin in-VM manage tools Signed-off-by: NovusEdge --- internal/mcpsrv/table_test.go | 12 ++-- internal/mcpsrv/tools_guest_test.go | 103 ++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/internal/mcpsrv/table_test.go b/internal/mcpsrv/table_test.go index adcf5ea4..a6a47282 100644 --- a/internal/mcpsrv/table_test.go +++ b/internal/mcpsrv/table_test.go @@ -75,17 +75,15 @@ var forbiddenInputFields = []string{"share", "base", "iso", "console_password", // pending names tools a later task registers. Every entry is removed by the // task that adds the tool; Task 18 asserts the list is empty. // -// Tasks 7 and 10 own the rest of this chunk's tools registered so far and -// are gone from this map already: their tests assert real registration, -// which does not exist yet, so TestEveryTableToolIsRegistered fails for -// them until their implementer lands. +// Tasks 7, 10 and 11 own the rest of this chunk's tools registered so far +// and are gone from this map already: their tests assert real +// registration, which does not exist yet, so TestEveryTableToolIsRegistered +// fails for them until their implementer lands. var pending = map[string]string{ "list_guests": "Task 14", "guest_info": "Task 14", "recipe_schema": "Task 14", "search_recipes": "Task 14", "add_recipe": "Task 15", "update_recipe": "Task 15", "remove_recipe": "Task 15", - "write_file": "Task 11", "copy_to": "Task 11", - "copy_from": "Task 11", "pkg_install": "Task 11", "svc": "Task 11", - "useradd": "Task 11", "exec": "Task 12", "exec_bg": "Task 12", + "exec": "Task 12", "exec_bg": "Task 12", "job_status": "Task 12", "job_output": "Task 12", "job_kill": "Task 12", "list_jobs": "Task 12", } diff --git a/internal/mcpsrv/tools_guest_test.go b/internal/mcpsrv/tools_guest_test.go index 2b55a2f6..1bcc5393 100644 --- a/internal/mcpsrv/tools_guest_test.go +++ b/internal/mcpsrv/tools_guest_test.go @@ -112,3 +112,106 @@ func makeNames(n int) []string { } return out } + +func TestWriteFileSetsTheMode(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "manage") + calls := testutil.FakeSSH(t, `cat > /dev/null; true`) + res := callTool(t, "write_file", map[string]any{ + "vm": "dev", "path": "/etc/app.conf", "content": "k=v\n", "mode": "0600", + }) + if res.IsError { + t.Fatalf("write_file failed: %+v", res.Content) + } + got := calls.Calls() + if len(got) != 2 { + t.Fatalf("want a write then a chmod, got %d calls", len(got)) + } + if !strings.Contains(got[0].Remote, "'tee' '/etc/app.conf'") { + t.Fatalf("write argv = %q", got[0].Remote) + } + if !strings.Contains(got[1].Remote, "'chmod' '0600' '/etc/app.conf'") { + t.Fatalf("chmod argv = %q", got[1].Remote) + } +} + +func TestWriteFileAppends(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "manage") + calls := testutil.FakeSSH(t, `cat > /dev/null; true`) + callTool(t, "write_file", map[string]any{ + "vm": "dev", "path": "/etc/app.conf", "content": "x", "append": true, + }) + if !strings.Contains(calls.Calls()[0].Remote, "'tee' '-a'") { + t.Fatalf("append did not use tee -a: %q", calls.Calls()[0].Remote) + } +} + +func TestWriteFileRefusesAtObserve(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "observe") + res := callTool(t, "write_file", map[string]any{"vm": "dev", "path": "/tmp/x", "content": "x"}) + if !res.IsError { + t.Fatal("write_file ran at agent_access = observe") + } + raw, _ := json.Marshal(res.Content) + if !strings.Contains(string(raw), "needs manage") { + t.Fatalf("refusal did not name the level: %s", raw) + } +} + +func TestSvcRefusesAnUnknownAction(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "manage") + if res := callTool(t, "svc", map[string]any{"vm": "dev", "name": "sshd", "action": "reload"}); !res.IsError { + t.Fatal("svc accepted an action outside the four verbs") + } +} + +func TestSvcPassesTheNameAsAPositional(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "manage") + calls := testutil.FakeSSH(t, `true`) + callTool(t, "svc", map[string]any{"vm": "dev", "name": "sshd", "action": "restart"}) + remote := calls.Calls()[0].Remote + // The template is the shell body and the name is $1, so a name that + // looks like shell syntax cannot become syntax. + if !strings.Contains(remote, `'stoat_svc' 'sshd'`) { + t.Fatalf("svc argv = %q", remote) + } +} + +func TestPkgInstallRunsSetupThenInstall(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "manage") + calls := testutil.FakeSSH(t, `true`) + res := callTool(t, "pkg_install", map[string]any{"vm": "dev", "packages": []string{"curl", "jq"}}) + if res.IsError { + t.Fatalf("pkg_install failed: %+v", res.Content) + } + got := calls.Calls() + if len(got) != 2 { + t.Fatalf("want setup then install, got %d calls", len(got)) + } + if !strings.Contains(got[1].Remote, "'curl' 'jq'") { + t.Fatalf("install argv = %q", got[1].Remote) + } +} + +func TestPkgInstallRefusesAFlagPackage(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "manage") + if res := callTool(t, "pkg_install", map[string]any{"vm": "dev", "packages": []string{"--force"}}); !res.IsError { + t.Fatal("pkg_install accepted a package name that reads as a flag") + } +} + +func TestCopyToConfinesTheHostPath(t *testing.T) { + root := t.TempDir() + t.Setenv("STOAT_HOME", root) + writeVM(t, "dev", "manage") + res := callTool(t, "copy_to", map[string]any{"vm": "dev", "local": "/etc/passwd", "remote": "/tmp/x"}) + if !res.IsError { + t.Fatal("copy_to accepted a host path outside the VM's shared directory") + } +} From b109c9811a0c5b6078d88ea718cb3b203bb2e6ac Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 14:55:27 +0300 Subject: [PATCH 18/67] test(mcp): pin exec and background job tools Signed-off-by: NovusEdge --- internal/mcpsrv/jobs.go | 36 ++++++++++ internal/mcpsrv/jobs_test.go | 77 +++++++++++++++++++++ internal/mcpsrv/table_test.go | 11 ++- internal/mcpsrv/tools_exec.go | 9 +++ internal/mcpsrv/tools_exec_test.go | 106 +++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 internal/mcpsrv/jobs.go create mode 100644 internal/mcpsrv/jobs_test.go create mode 100644 internal/mcpsrv/tools_exec.go create mode 100644 internal/mcpsrv/tools_exec_test.go diff --git a/internal/mcpsrv/jobs.go b/internal/mcpsrv/jobs.go new file mode 100644 index 00000000..77f40c43 --- /dev/null +++ b/internal/mcpsrv/jobs.go @@ -0,0 +1,36 @@ +package mcpsrv + +import ( + "fmt" + "time" +) + +// job is one background command. The host owns the id and the record, so +// list_jobs answers without ssh and works on a stopped VM. +type job struct { + ID string + Argv []string + User string + CWD string + Dir string + Started time.Time +} + +// newJobID is not implemented yet. Task 12 returns "j-" plus 8 random hex +// characters. +func newJobID() string { + return "" +} + +// loadJobs is not implemented yet. Task 12 reads +// config.Root()//jobs.toml through tomlx.Decode; a VM with no file +// returns an empty map, not an error. +func loadJobs(vm string) (map[string]job, error) { + return nil, fmt.Errorf("loadJobs(%q): not implemented", vm) +} + +// saveJob is not implemented yet. Task 12 adds j to the registry and +// rewrites jobs.toml through a temp file and rename. +func saveJob(vm string, j job) error { + return fmt.Errorf("saveJob(%q, %q): not implemented", vm, j.ID) +} diff --git a/internal/mcpsrv/jobs_test.go b/internal/mcpsrv/jobs_test.go new file mode 100644 index 00000000..4dd1bcf8 --- /dev/null +++ b/internal/mcpsrv/jobs_test.go @@ -0,0 +1,77 @@ +package mcpsrv + +import ( + "os" + "path/filepath" + "regexp" + "testing" + "time" +) + +func TestJobIDShape(t *testing.T) { + re := regexp.MustCompile(`^j-[0-9a-f]{8}$`) + seen := map[string]bool{} + for range 100 { + id := newJobID() + if !re.MatchString(id) { + t.Fatalf("job id %q does not match %s", id, re) + } + if seen[id] { + t.Fatalf("job id %q repeated", id) + } + seen[id] = true + } +} + +func TestSaveAndLoadJobs(t *testing.T) { + root := t.TempDir() + t.Setenv("STOAT_HOME", root) + if err := os.MkdirAll(filepath.Join(root, "dev"), 0o755); err != nil { + t.Fatal(err) + } + j := job{ + ID: "j-9f3c1e2a", Argv: []string{"sleep", "60"}, User: "stoat", + CWD: "/home/stoat", Dir: "/run/stoat/jobs/j-9f3c1e2a", + Started: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC), + } + if err := saveJob("dev", j); err != nil { + t.Fatal(err) + } + // A second job must not replace the first: list_jobs works from the + // host without ssh, and that is only true when the file accumulates. + if err := saveJob("dev", job{ID: "j-00000001", Argv: []string{"true"}, User: "stoat"}); err != nil { + t.Fatal(err) + } + got, err := loadJobs("dev") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("loaded %d jobs, want 2", len(got)) + } + if got["j-9f3c1e2a"].Argv[1] != "60" { + t.Fatalf("argv round trip lost data: %+v", got["j-9f3c1e2a"]) + } + raw, err := os.ReadFile(filepath.Join(root, "dev", "jobs.toml")) + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`(?m)^# written by stoat; do not edit$`).Match(raw) { + t.Fatalf("jobs.toml has no ownership comment:\n%s", raw) + } +} + +func TestLoadJobsOnAVMWithNoFile(t *testing.T) { + root := t.TempDir() + t.Setenv("STOAT_HOME", root) + if err := os.MkdirAll(filepath.Join(root, "dev"), 0o755); err != nil { + t.Fatal(err) + } + got, err := loadJobs("dev") + if err != nil { + t.Fatalf("a VM that has never run a job is not an error: %v", err) + } + if len(got) != 0 { + t.Fatalf("got %d jobs", len(got)) + } +} diff --git a/internal/mcpsrv/table_test.go b/internal/mcpsrv/table_test.go index a6a47282..c9e14d07 100644 --- a/internal/mcpsrv/table_test.go +++ b/internal/mcpsrv/table_test.go @@ -75,15 +75,12 @@ var forbiddenInputFields = []string{"share", "base", "iso", "console_password", // pending names tools a later task registers. Every entry is removed by the // task that adds the tool; Task 18 asserts the list is empty. // -// Tasks 7, 10 and 11 own the rest of this chunk's tools registered so far -// and are gone from this map already: their tests assert real -// registration, which does not exist yet, so TestEveryTableToolIsRegistered -// fails for them until their implementer lands. +// Tasks 7, 10, 11 and 12 own the rest of this chunk's tools and are gone +// from this map already: their tests assert real registration, which does +// not exist yet, so TestEveryTableToolIsRegistered fails for them until +// their implementer lands. var pending = map[string]string{ "list_guests": "Task 14", "guest_info": "Task 14", "recipe_schema": "Task 14", "search_recipes": "Task 14", "add_recipe": "Task 15", "update_recipe": "Task 15", "remove_recipe": "Task 15", - "exec": "Task 12", "exec_bg": "Task 12", - "job_status": "Task 12", "job_output": "Task 12", "job_kill": "Task 12", - "list_jobs": "Task 12", } diff --git a/internal/mcpsrv/tools_exec.go b/internal/mcpsrv/tools_exec.go new file mode 100644 index 00000000..72aecc6a --- /dev/null +++ b/internal/mcpsrv/tools_exec.go @@ -0,0 +1,9 @@ +package mcpsrv + +import "time" + +// execTimeout is not implemented yet. Task 12 clamps seconds to +// [1, maxExecSecs], defaulting to 60 when seconds is 0. +func execTimeout(seconds int) time.Duration { + return 0 +} diff --git a/internal/mcpsrv/tools_exec_test.go b/internal/mcpsrv/tools_exec_test.go new file mode 100644 index 00000000..8adeef98 --- /dev/null +++ b/internal/mcpsrv/tools_exec_test.go @@ -0,0 +1,106 @@ +package mcpsrv + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/testutil" +) + +func TestExecRefusedBelowExec(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "manage") + res := callTool(t, "exec", map[string]any{"vm": "dev", "argv": []string{"id"}}) + if !res.IsError { + t.Fatal("exec ran at agent_access = manage") + } + raw, _ := json.Marshal(res.Content) + if !strings.Contains(string(raw), "needs exec") { + t.Fatalf("refusal did not name the level: %s", raw) + } +} + +func TestExecClampsTheTimeout(t *testing.T) { + for _, c := range []struct{ in, want int }{{0, 60}, {30, 30}, {99999, maxExecSecs}} { + if got := execTimeout(c.in); int(got.Seconds()) != c.want { + t.Errorf("execTimeout(%d) = %s, want %ds", c.in, got, c.want) + } + } +} + +func TestExecBgThenStatusThenOutputThenKill(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "exec") + // The fake answers every guest call: mkdir and nohup for exec_bg, cat + // for the exit file, head for the output, kill for the signal. + testutil.FakeSSH(t, ` +case "$1" in + mkdir|nohup|sh) exit 0;; + cat) echo 0;; + stat) echo 5;; + head) printf hello;; + kill) exit 0;; + *) exit 0;; +esac`) + + res := callTool(t, "exec_bg", map[string]any{"vm": "dev", "argv": []string{"sleep", "60"}}) + if res.IsError { + t.Fatalf("exec_bg failed: %+v", res.Content) + } + raw, _ := json.Marshal(res.StructuredContent) + var started struct { + JobID string `json:"job_id"` + } + if err := json.Unmarshal(raw, &started); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(started.JobID, "j-") { + t.Fatalf("job_id = %q", started.JobID) + } + + if res := callTool(t, "list_jobs", map[string]any{"vm": "dev"}); res.IsError { + t.Fatalf("list_jobs failed: %+v", res.Content) + } + + res = callTool(t, "job_status", map[string]any{"vm": "dev", "job_id": started.JobID}) + raw, _ = json.Marshal(res.StructuredContent) + if !strings.Contains(string(raw), `"state":"exited"`) { + t.Fatalf("job_status = %s, want exited", raw) + } + + res = callTool(t, "job_output", map[string]any{"vm": "dev", "job_id": started.JobID}) + raw, _ = json.Marshal(res.StructuredContent) + if !strings.Contains(string(raw), "hello") { + t.Fatalf("job_output = %s", raw) + } + + if res := callTool(t, "job_kill", map[string]any{"vm": "dev", "job_id": started.JobID}); res.IsError { + t.Fatalf("job_kill failed: %+v", res.Content) + } +} + +func TestJobStatusIsUnknownAfterAReboot(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "exec") + // A reboot clears /run, so the exit file and the pid are both gone. + testutil.FakeSSH(t, `exit 1`) + if err := saveJob("dev", job{ID: "j-00000001", Argv: []string{"true"}, User: "stoat", Dir: "/run/stoat/jobs/j-00000001"}); err != nil { + t.Fatal(err) + } + res := callTool(t, "job_status", map[string]any{"vm": "dev", "job_id": "j-00000001"}) + raw, _ := json.Marshal(res.StructuredContent) + if !strings.Contains(string(raw), `"state":"unknown"`) { + t.Fatalf("job_status = %s, want unknown", raw) + } +} + +func TestJobIDIsValidated(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "exec") + for _, id := range []string{"", "../../etc", "j-XYZ", "j-9f3c1e2a1"} { + if res := callTool(t, "job_status", map[string]any{"vm": "dev", "job_id": id}); !res.IsError { + t.Errorf("job_status accepted job_id %q", id) + } + } +} From 4c92e1004b91942ba12834d89d7b55003605f69f Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 15:26:30 +0300 Subject: [PATCH 19/67] refactor(sshx): add Run and one argv quoter Signed-off-by: NovusEdge --- internal/core/exec.go | 47 +++++++++++--------------------- internal/sshx/run.go | 63 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 38 deletions(-) diff --git a/internal/core/exec.go b/internal/core/exec.go index 1e227f3e..0d2cd00f 100644 --- a/internal/core/exec.go +++ b/internal/core/exec.go @@ -1,11 +1,9 @@ package core import ( - "bytes" "context" "errors" "fmt" - "os/exec" "strings" "github.com/novusedge/stoat/internal/qemu" @@ -33,12 +31,10 @@ type ExecResult struct { // BatchMode=yes so a wedged network fails fast and a password prompt never // blocks forever. When 255 does surface, ssh's own message is on Stderr. // -// cmd is an argv, not a shell string. shellJoin quotes each element for -// the guest's shell before Exec sends it. ssh concatenates its trailing -// arguments with spaces and hands the result to the remote shell to -// re-parse, so an unquoted argv silently loses every word boundary. -// Exec(…, []string{"touch", "my file"}) would create two files without -// shellJoin. +// cmd is an argv, not a shell string. sshx.Run quotes each element for the +// guest's shell before sending it. ssh concatenates its trailing arguments +// with spaces and hands the result to the remote shell to re-parse, so an +// unquoted argv silently loses every word boundary. // // ctx cancels the ssh process. Exec is the only operation in this package // that takes a context: every other operation is bounded by local work, @@ -65,35 +61,22 @@ func Exec(ctx context.Context, name string, cmd []string) (ExecResult, error) { return ExecResult{}, fmt.Errorf("%w: %s", ErrNotRunning, name) } - var stdout, stderr bytes.Buffer - c := exec.CommandContext(ctx, "ssh", sshx.Args(v, shellJoin(cmd))...) - c.Stdout = &stdout - c.Stderr = &stderr - - err = c.Run() - res := ExecResult{Stdout: stdout.String(), Stderr: stderr.String()} - - var ee *exec.ExitError - switch { - case err == nil: - return res, nil - case errors.As(err, &ee): - // The command ran and exited non-zero. That is a result, not a - // failure of Exec; see ExecResult. - res.ExitCode = ee.ExitCode() - // ctx expiring kills the process; that surfaces here as an - // ExitError, not as ctx.Err(). Report the cancellation instead: - // a caller that cannot tell "timed out" from "the command - // failed" will retry something that was never going to finish. - if ctxErr := ctx.Err(); ctxErr != nil { - return res, fmt.Errorf("%s: %w", name, ctxErr) + out, errb, code, err := sshx.Run(ctx, v, false, cmd, nil) + res := ExecResult{Stdout: string(out), Stderr: string(errb), ExitCode: code} + if err != nil { + // ctx expiring kills the ssh process and surfaces here as a plain + // exit error, not as ctx.Err() itself; sshx.Run turns that back into + // the ctx error so a caller can tell "timed out" from "the ssh + // transport itself failed" and not retry something that was never + // going to finish. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return res, fmt.Errorf("%s: %w", name, err) } - return res, nil - default: // ssh could not be started at all (not installed, not executable). // Nothing ran in the guest, so there is no exit status to report. return ExecResult{}, fmt.Errorf("%s: ssh: %w", name, err) } + return res, nil } // shellJoin renders an argv as a single string that the guest's shell will diff --git a/internal/sshx/run.go b/internal/sshx/run.go index 693185f0..e51b13b0 100644 --- a/internal/sshx/run.go +++ b/internal/sshx/run.go @@ -1,17 +1,68 @@ package sshx import ( + "bytes" "context" - "fmt" + "errors" "io" + "os/exec" + "strings" "github.com/novusedge/stoat/internal/config" ) -// Run is not implemented yet. Task 9 executes argv inside v's guest over -// ssh, quoting it for the guest shell, and returns the guest's raw output -// and exit status. A command that ran and exited non-zero is a result, not -// an error; Run returns an error only when ssh could not run at all. +// Quote renders argv as one string the guest shell parses back into exactly +// those words. ssh concatenates its trailing arguments with spaces and hands +// the result to the remote shell, so an unquoted argv loses every word +// boundary: Run(["touch", "my file"]) would create two files. +// +// Every element is wrapped in single quotes, inside which a POSIX shell +// treats every byte literally. A single quote cannot appear inside single +// quotes, so it is closed, escaped, and reopened. +func Quote(argv []string) string { + q := make([]string, len(argv)) + for i, a := range argv { + q[i] = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'" + } + return strings.Join(q, " ") +} + +// Run executes argv inside v's guest and returns the guest's raw output and +// exit status. A command that ran and exited non-zero is a result, not an +// error; Run returns an error only when ssh could not run at all. +// +// argv is an argv, never a shell string. Run is the one place stoat's in-VM +// tools quote it for the guest shell, so no tool caller has to decide. +// +// root applies the guest's escalate prefix for a non-root ssh user, through +// the same escalate helper Provision and RunCheck already use. A tool never +// escalates on its own; the caller passes root only for a call whose +// contract says so. func Run(ctx context.Context, v *config.VM, root bool, argv []string, stdin io.Reader) ([]byte, []byte, int, error) { - return nil, nil, 0, fmt.Errorf("sshx.Run: not implemented") + remote := argv + if root { + remote = escalate(v, argv) + } + var out, errb bytes.Buffer + c := exec.CommandContext(ctx, "ssh", Args(v, Quote(remote))...) + c.Stdin = stdin + c.Stdout = &out + c.Stderr = &errb + + err := c.Run() + var ee *exec.ExitError + switch { + case err == nil: + return out.Bytes(), errb.Bytes(), 0, nil + case errors.As(err, &ee): + if ctxErr := ctx.Err(); ctxErr != nil { + // ctx expiring kills the process and surfaces as an ExitError. + // A caller that cannot tell "timed out" from "the command + // failed" retries something that was never going to finish. + return out.Bytes(), errb.Bytes(), ee.ExitCode(), ctxErr + } + return out.Bytes(), errb.Bytes(), ee.ExitCode(), nil + default: + return nil, nil, 0, err + } } From 0729727fdd187d185dc77d42716ee5507b93816d Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 15:27:20 +0300 Subject: [PATCH 20/67] feat(mcp): replace allow_exec with agent_access levels Signed-off-by: NovusEdge --- internal/config/config.go | 7 ++++-- internal/core/core.go | 37 +++++++++++++++++---------- internal/core/update.go | 9 +++++++ internal/mcpsrv/access.go | 53 +++++++++++++++++++++++++++++++++------ 4 files changed, 83 insertions(+), 23 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 2ca39acf..942afa87 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -105,8 +105,11 @@ type VM struct { // cannot tell "false" from "not written". AllowExec bool `toml:"allow_exec"` - // AgentAccess is not implemented yet. Task 8 replaces AllowExec with - // this field and maps a legacy allow_exec key onto it in Load. + // AgentAccess is what an MCP agent may do in this VM: none, observe, + // manage or exec. AllowExec stays for CLI/core callers that already read + // it; the MCP server gates on this field instead. Empty means no + // vm.toml value was ever written; core.Create fills in "manage" for a + // VM it creates. AgentAccess string `toml:"agent_access,omitempty"` // Applied tracks which recipes have been run on this VM, keyed by recipe name. diff --git a/internal/core/core.go b/internal/core/core.go index 63bbb2fe..1d0b489d 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -92,6 +92,12 @@ type Spec struct { // given", so plan() can default it to true for a caller that never // mentions it, such as the TUI's form today. AllowExec *bool + + // AgentAccess is the MCP create tool's agent_access input, recorded on + // the new VM. Empty means "manage", plan()'s default. The MCP server + // validates the string against its Level enum before calling Create; + // core stores whatever it is given. + AgentAccess string } // Create validates a Spec, writes vm.toml and allocates the disk. It does not @@ -243,21 +249,26 @@ func plan(s Spec) (*config.VM, error) { if s.AllowExec != nil { allowExec = *s.AllowExec } + agentAccess := s.AgentAccess + if agentAccess == "" { + agentAccess = "manage" + } v := &config.VM{ - Name: name, - Mode: mode, - OS: img.osName, - Backend: img.backend, - SSHUser: img.sshUser, - RAM: ram, - CPUs: cpus, - Disk: disk, - Share: strings.TrimSpace(s.Share), - SSHPort: port, - Recipes: s.Recipes, - AllowExec: allowExec, - Display: s.Display, + Name: name, + Mode: mode, + OS: img.osName, + Backend: img.backend, + SSHUser: img.sshUser, + RAM: ram, + CPUs: cpus, + Disk: disk, + Share: strings.TrimSpace(s.Share), + SSHPort: port, + Recipes: s.Recipes, + AllowExec: allowExec, + AgentAccess: agentAccess, + Display: s.Display, } if img.backend == "cloudinit" { diff --git a/internal/core/update.go b/internal/core/update.go index 3cf77bb2..a84def1a 100644 --- a/internal/core/update.go +++ b/internal/core/update.go @@ -70,6 +70,12 @@ type Patch struct { // Safe: it only changes which -display argument qemu.Args builds next // start, nothing about the running process. Display *string + + // AgentAccess sets vm.toml's agent_access. Safe: the MCP server reads it + // fresh on every call, so it takes effect immediately, not at next + // start. The MCP tool may only lower it; core applies whatever it is + // given, since core is also the CLI and TUI's library, which may raise. + AgentAccess *string } // checkImmutable reports ErrImmutableField, naming the field, when a Patch sets @@ -161,6 +167,9 @@ func Update(name string, p Patch) (VM, error) { } work.Display = *p.Display } + if p.AgentAccess != nil { + work.AgentAccess = *p.AgentAccess + } if p.SSHPort != nil && *p.SSHPort != work.SSHPort { if err := validateSSHPort(work, *p.SSHPort); err != nil { diff --git a/internal/mcpsrv/access.go b/internal/mcpsrv/access.go index 086eb436..8192edb2 100644 --- a/internal/mcpsrv/access.go +++ b/internal/mcpsrv/access.go @@ -1,9 +1,19 @@ package mcpsrv -import "fmt" +import ( + "fmt" -// Level is an agent_access level. Task 8 adds requireAccess and its table; -// this chunk only needs the type for toolTable's Access field. + "github.com/novusedge/stoat/internal/config" +) + +// Level is an agent_access level. Each level includes the ones below it. +// +// none host side only: status, start, stop, snapshot, restore, logs, +// forward, update +// observe read_file, list_dir, stat, ps, svc_status, tail_log +// manage write_file, copy_to, copy_from, pkg_install, svc, useradd, +// apply_recipes +// exec exec, exec_bg, job_status, job_output, job_kill, list_jobs type Level int const ( @@ -28,13 +38,40 @@ func (l Level) String() string { // rank, since each level includes every one below it. func (l Level) rank() int { return int(l) } -// ParseLevel is not implemented yet. Task 8 validates s against levelNames. +// ParseLevel validates s against the four declared level names. func ParseLevel(s string) (Level, error) { - return LevelNone, fmt.Errorf("agent_access %q: not implemented", s) + for i, name := range levelNames { + if s == name { + return Level(i), nil + } + } + return 0, fmt.Errorf("invalid agent_access %q: one of none, observe, manage, exec", s) } -// requireAccess is not implemented yet. Task 8 gates every guest-touching -// tool at the level toolTable declares for it. +// currentLevel reads vm's agent_access straight off vm.toml. +func currentLevel(vm string) (Level, error) { + name, err := checkVMName(vm) + if err != nil { + return 0, err + } + v, err := config.Load(name) + if err != nil { + return 0, err + } + return ParseLevel(v.AgentAccess) +} + +// requireAccess gates every guest-touching tool. core.Exec does not enforce +// it, because core is a library the CLI and TUI also call and a blanket +// refusal there would be the wrong layer. The refusal names both levels, so +// an agent knows what to ask a person for. func requireAccess(vm string, need Level) error { - return fmt.Errorf("requireAccess(%q, %s): not implemented", vm, need) + have, err := currentLevel(vm) + if err != nil { + return err + } + if have.rank() < need.rank() { + return fmt.Errorf("vm %q has agent_access = %s; needs %s", vm, have, need) + } + return nil } From cc9b790b31f912487d918a72916f3c044c86e859 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 15:29:18 +0300 Subject: [PATCH 21/67] feat(mcp): add host-side VM tools Signed-off-by: NovusEdge --- internal/cli/grammar.go | 2 +- internal/cli/run_state.go | 26 --- internal/cli/wire/dto.go | 34 ++++ internal/core/forward.go | 27 +++ internal/mcpsrv/server.go | 1 + internal/mcpsrv/tools_vm.go | 386 ++++++++++++++++++++++++++++++++++-- 6 files changed, 437 insertions(+), 39 deletions(-) diff --git a/internal/cli/grammar.go b/internal/cli/grammar.go index 32bbbaff..712660fe 100644 --- a/internal/cli/grammar.go +++ b/internal/cli/grammar.go @@ -434,7 +434,7 @@ func (g *grammar) toArgs(path string) (*Args, error) { if f.Clear && len(f.Pairs) > 0 { return nil, usageError("forward: --clear takes no port pairs") } - fwds, err := parseForwards(f.Pairs) + fwds, err := core.ParseForwards(f.Pairs) if err != nil { return nil, usageError("forward: " + err.Error()) } diff --git a/internal/cli/run_state.go b/internal/cli/run_state.go index 78fff5bc..6df80422 100644 --- a/internal/cli/run_state.go +++ b/internal/cli/run_state.go @@ -3,38 +3,12 @@ package cli import ( "fmt" "io" - "strconv" "strings" "github.com/novusedge/stoat/internal/cli/wire" "github.com/novusedge/stoat/internal/core" ) -// parseForwards reads "8080:80" pairs, host port first: the spelling docker -// and ssh -L both use, so the ordering is the one a user already has in their -// fingers. Getting it backwards silently binds the wrong port, so the error -// names the whole offending argument rather than just complaining about a -// number. -func parseForwards(pairs []string) ([]core.PortForward, error) { - var out []core.PortForward - for _, p := range pairs { - host, guest, ok := strings.Cut(p, ":") - if !ok { - return nil, fmt.Errorf("%q is not a HOST:GUEST port pair", p) - } - h, err := strconv.Atoi(strings.TrimSpace(host)) - if err != nil { - return nil, fmt.Errorf("%q: host port %q is not a number", p, host) - } - g, err := strconv.Atoi(strings.TrimSpace(guest)) - if err != nil { - return nil, fmt.Errorf("%q: guest port %q is not a number", p, guest) - } - out = append(out, core.PortForward{HostPort: h, GuestPort: g}) - } - return out, nil -} - // runForward shows, sets, or clears a VM's port forwards. core.Forward reports // whether they are live NOW; when they are not, saying so is the whole point: // a user who declared a forward on a running VM and got silence would conclude diff --git a/internal/cli/wire/dto.go b/internal/cli/wire/dto.go index 74c45a38..1acb948a 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -740,3 +740,37 @@ type GuestList struct { type GuestShow struct { Guest Guest `json:"guest"` } + +// SnapshotList is the snapshot and restore tools' output. +type SnapshotList struct { + Snapshots []Snapshot `json:"snapshots"` +} + +// ForwardList is the forward tool's output. +type ForwardList struct { + Forwards []PortForward `json:"forwards"` +} + +// PruneList is the prune tool's output. +type PruneList struct { + Items []PruneItem `json:"items"` + DryRun bool `json:"dry_run"` +} + +// WaitResult is the wait tool's output. +type WaitResult struct { + VM string `json:"vm"` + Until string `json:"until"` + Healthy bool `json:"healthy"` +} + +// ApplyResult is what an apply left behind, so a caller does not have to +// call vm_status to find out. +type ApplyResult struct { + VM string `json:"vm"` + Recipes []RecipeState `json:"recipes_detail"` +} + +func FromApplyResult(v core.VM) ApplyResult { + return ApplyResult{VM: v.Name, Recipes: FromVMStatus(v, false).RecipeStates} +} diff --git a/internal/core/forward.go b/internal/core/forward.go index dc1ce50f..b49a4c6d 100644 --- a/internal/core/forward.go +++ b/internal/core/forward.go @@ -3,6 +3,8 @@ package core import ( "fmt" "path/filepath" + "strconv" + "strings" "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/qemu" @@ -15,6 +17,31 @@ import ( // on both sides. Duplicating the struct would only add a conversion step. type PortForward = config.PortForward +// ParseForwards reads "8080:80" pairs, host port first: the spelling docker +// and ssh -L both use, so the ordering is the one a user already has in +// their fingers. Getting it backwards silently binds the wrong port, so the +// error names the whole offending argument rather than just complaining +// about a number. +func ParseForwards(pairs []string) ([]PortForward, error) { + var out []PortForward + for _, p := range pairs { + host, guest, ok := strings.Cut(p, ":") + if !ok { + return nil, fmt.Errorf("%q is not a HOST:GUEST port pair", p) + } + h, err := strconv.Atoi(strings.TrimSpace(host)) + if err != nil { + return nil, fmt.Errorf("%q: host port %q is not a number", p, host) + } + g, err := strconv.Atoi(strings.TrimSpace(guest)) + if err != nil { + return nil, fmt.Errorf("%q: guest port %q is not a number", p, guest) + } + out = append(out, PortForward{HostPort: h, GuestPort: g}) + } + return out, nil +} + // Forward replaces VM name's declared port forwards and saves vm.toml. // // active reports whether the forwards are in effect now. It is false when diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index cad92bfe..bedadc6d 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -59,6 +59,7 @@ func New(opts Options) *mcp.Server { s := &srv{opts: opts, lim: newLimiter(opts.Limits)} server := mcp.NewServer(&mcp.Implementation{Name: "stoat", Version: opts.Version}, nil) s.registerRead(server) + s.registerVM(server) server.AddReceivingMiddleware(s.rateLimit()) return server } diff --git a/internal/mcpsrv/tools_vm.go b/internal/mcpsrv/tools_vm.go index 39e3be4d..018c6f59 100644 --- a/internal/mcpsrv/tools_vm.go +++ b/internal/mcpsrv/tools_vm.go @@ -1,23 +1,385 @@ package mcpsrv -import "time" +import ( + "context" + "fmt" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/core" +) + +type createIn struct { + Name string `json:"name" jsonschema:"name for the new VM"` + Image string `json:"image" jsonschema:"catalog image id, see list_images"` + OS string `json:"os,omitempty" jsonschema:"guest OS override"` + Backend string `json:"backend,omitempty" jsonschema:"backend override"` + Mode string `json:"mode,omitempty" jsonschema:"live or disk"` + RAMMB int `json:"ram_mb,omitempty" jsonschema:"memory in MEGABYTES"` + CPUs int `json:"cpus,omitempty" jsonschema:"vcpu count"` + Disk string `json:"disk,omitempty" jsonschema:"disk size such as 8G"` + Recipes []string `json:"recipes,omitempty" jsonschema:"recipe names to record on the VM"` + AgentAccess string `json:"agent_access,omitempty" jsonschema:"what an agent may do in this VM: none, observe, manage or exec; manage is the default"` +} -// updateIn is the input for the update tool. Task 7's implementation adds -// the remaining fields (CPUs, SSHPort, Disk, Recipes, Params, Secrets, -// AgentAccess); RAMMB is the only one this chunk's tests exercise. type updateIn struct { - VM string - RAMMB int + VM string `json:"vm" jsonschema:"name of the VM"` + RAMMB int `json:"ram_mb,omitempty" jsonschema:"memory in MEGABYTES"` + CPUs int `json:"cpus,omitempty" jsonschema:"vcpu count"` + SSHPort int `json:"ssh_port,omitempty" jsonschema:"host port forwarded to the guest sshd"` + Disk string `json:"disk,omitempty" jsonschema:"disk size, grow only"` + Recipes []string `json:"recipes,omitempty" jsonschema:"replace the recipe list"` + Params map[string]map[string]string `json:"params,omitempty" jsonschema:"recipe params, keyed by recipe name then param name"` + Secrets map[string]map[string]string `json:"secrets,omitempty" jsonschema:"recipe secrets, keyed by recipe name then param name; never echoed back"` + AgentAccess string `json:"agent_access,omitempty" jsonschema:"lower this VM's agent access level; raising it is refused here and is a CLI or TUI action"` +} + +type cloneIn struct { + Source string `json:"source" jsonschema:"VM to copy"` + Name string `json:"name" jsonschema:"name for the copy"` +} + +type snapshotIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Tag string `json:"tag" jsonschema:"snapshot tag"` +} + +type forwardIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Pairs []string `json:"pairs,omitempty" jsonschema:"HOST:GUEST port pairs; none and clear=false only shows the current forwards"` + Clear bool `json:"clear,omitempty" jsonschema:"remove every forward from this VM"` +} + +type waitIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Until string `json:"until,omitempty" jsonschema:"reachable, applied or stopped; reachable is the default"` + Healthy bool `json:"healthy,omitempty" jsonschema:"also wait for every applied recipe's health check to pass"` + TimeoutSeconds int `json:"timeout_seconds,omitempty" jsonschema:"a plain count of seconds, not a duration string, capped at 600"` +} + +type pruneIn struct { + Apply bool `json:"apply,omitempty" jsonschema:"actually delete; without this prune only reports"` + Broken bool `json:"broken,omitempty" jsonschema:"also remove VM directories whose vm.toml will not parse"` + Images bool `json:"images,omitempty" jsonschema:"also remove downloaded images no VM refers to"` +} + +type applyIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Only []string `json:"only,omitempty" jsonschema:"subset of the VM's own recipes"` } -// waitTimeout is not implemented yet. Task 7 clamps seconds to -// [1, maxWaitSecs] and returns the equivalent time.Duration. +// waitTimeout clamps the caller's seconds. The stdio transport serves one +// request at a time, and wait is the only tool that blocks on purpose, so it +// is the only one that needs a ceiling a caller cannot raise. func waitTimeout(seconds int) time.Duration { - return 0 + return time.Duration(clampInt(seconds, 1, maxWaitSecs)) * time.Second } -// patchFromUpdate is not implemented yet. Task 7 builds the patch map from -// in's non-zero fields and runs it through stripForbidden. +// patchFromUpdate builds the patch as a generic map and runs it through +// stripForbidden even though updateIn has no forbidden field. The rule is +// that the patch is what gets sanitized, so a future caller that builds one +// from a VM object it read back is covered. func patchFromUpdate(in updateIn) map[string]any { - return map[string]any{} + p := map[string]any{} + if in.RAMMB != 0 { + p["ram"] = in.RAMMB + } + if in.CPUs != 0 { + p["cpus"] = in.CPUs + } + if in.SSHPort != 0 { + p["ssh_port"] = in.SSHPort + } + if in.Disk != "" { + p["disk"] = in.Disk + } + if in.Recipes != nil { + p["recipes"] = in.Recipes + } + return stripForbidden(p) +} + +func (s *srv) registerVM(server *mcp.Server) { + register(server, "create", classMutate, + "Create a new VM from a catalog image, without starting it. Only catalog image ids are accepted, see list_images; a bring-your-own image path, a console password and a host share cannot be set through this tool. Memory is ram_mb and is in MEGABYTES. Reversible: destroy deletes the VM. Mutating: it creates a VM directory and a disk under the stoat data root.", + func(ctx context.Context, in createIn) (wire.VM, error) { + name, err := checkVMName(in.Name) + if err != nil { + return wire.VM{}, err + } + image, err := checkImageID(in.Image) + if err != nil { + return wire.VM{}, err + } + if err := checkFlagFree(in.Recipes, "recipes"); err != nil { + return wire.VM{}, err + } + access := in.AgentAccess + if access == "" { + access = LevelManage.String() + } + if _, err := ParseLevel(access); err != nil { + return wire.VM{}, err + } + v, err := core.Create(core.Spec{ + Name: name, Image: image, OS: in.OS, Backend: in.Backend, Mode: in.Mode, + RAM: in.RAMMB, CPUs: in.CPUs, Disk: in.Disk, Recipes: in.Recipes, + AgentAccess: access, + }) + if err != nil { + return wire.VM{}, err + } + return wire.FromVM(v, core.GraphicalSession()), nil + }) + + register(server, "start", classMutate, + "Start a VM, which boots qemu. Reversible with stop. Mutating: it consumes host CPU and RAM and, once running, a forwarded ssh port.", + s.byName(core.Start)) + + register(server, "stop", classMutate, + "Stop a running VM gracefully. Reversible with start. Mutating: it shuts qemu down, and it refuses when the VM is not running.", + s.byName(core.Stop)) + + register(server, "destroy", classDestructive, + "Permanently delete a VM's directory and its disk. It refuses while the VM is running. This is NOT reversible: there is no undo, and a snapshot taken before the deletion goes with it.", + s.byName(core.Destroy)) + + register(server, "update", classMutate, + "Change a stopped VM's RAM, CPU count, ssh port, disk size (grow only), recipe list, recipe params or recipe secrets, or lower its agent access level. Only the fields you pass change. A share cannot be set through this tool. Raising agent_access is refused here; raise it from the CLI or the TUI. Mutating; most fields take effect at the VM's next start.", + func(ctx context.Context, in updateIn) (wire.VM, error) { + name, err := checkVMName(in.VM) + if err != nil { + return wire.VM{}, err + } + patch, err := corePatch(name, in) + if err != nil { + return wire.VM{}, err + } + v, err := core.Update(name, patch) + if err != nil { + return wire.VM{}, err + } + return wire.FromVM(v, core.GraphicalSession()), nil + }) + + register(server, "clone", classMutate, + "Copy a VM: a fresh overlay disk and a fresh ssh port, but NOT the source's port forwards. It refuses a running source. Reversible with destroy on the clone. Mutating: it creates a new VM.", + func(ctx context.Context, in cloneIn) (wire.VM, error) { + src, err := checkVMName(in.Source) + if err != nil { + return wire.VM{}, err + } + dst, err := checkVMName(in.Name) + if err != nil { + return wire.VM{}, err + } + v, err := core.Clone(src, dst) + if err != nil { + return wire.VM{}, err + } + return wire.FromVM(v, core.GraphicalSession()), nil + }) + + register(server, "snapshot", classMutate, + "Save a disk snapshot of a VM under a tag. It needs a disk to snapshot, so a live-mode VM refuses. Reversible: restore rolls back to it, and a person can remove the tag with stoat snapshot --delete. Mutating: it writes a new qemu snapshot.", + func(ctx context.Context, in snapshotIn) (wire.SnapshotList, error) { + name, err := checkVMName(in.VM) + if err != nil { + return wire.SnapshotList{}, err + } + if err := core.TakeSnapshot(name, in.Tag); err != nil { + return wire.SnapshotList{}, err + } + ss, err := core.Snapshots(name) + if err != nil { + return wire.SnapshotList{}, err + } + return wire.SnapshotList{Snapshots: wire.FromSnapshots(ss)}, nil + }) + + register(server, "restore", classDestructive, + "Roll a VM's disk back to a saved snapshot tag and discard everything written since. Destructive: it is reversible only when another snapshot was taken after the one you restore to.", + func(ctx context.Context, in snapshotIn) (wire.SnapshotList, error) { + name, err := checkVMName(in.VM) + if err != nil { + return wire.SnapshotList{}, err + } + if err := core.Restore(name, in.Tag); err != nil { + return wire.SnapshotList{}, err + } + ss, err := core.Snapshots(name) + if err != nil { + return wire.SnapshotList{}, err + } + return wire.SnapshotList{Snapshots: wire.FromSnapshots(ss)}, nil + }) + + register(server, "forward", classMutate, + "Show, set or clear a VM's host:guest port forwards. With no pairs and clear=false it only shows the current forwards. Setting or clearing on a running VM saves the change, and the change takes effect at the VM's next start. Mutating when you pass pairs or clear=true.", + func(ctx context.Context, in forwardIn) (wire.ForwardList, error) { + name, err := checkVMName(in.VM) + if err != nil { + return wire.ForwardList{}, err + } + if err := checkFlagFree(in.Pairs, "pairs"); err != nil { + return wire.ForwardList{}, err + } + fwds, err := core.ParseForwards(in.Pairs) + if err != nil { + return wire.ForwardList{}, err + } + if in.Clear { + fwds = []core.PortForward{} + } + if in.Clear || len(in.Pairs) > 0 { + if _, err := core.Forward(name, fwds); err != nil { + return wire.ForwardList{}, err + } + } + v, err := core.Get(name) + if err != nil { + return wire.ForwardList{}, err + } + return wire.ForwardList{Forwards: wire.FromPortForwards(v.Forwards)}, nil + }) + + register(server, "wait", classMutate, + "Block until a VM reaches a state: reachable when sshd answers, applied when the most recent recipe run finished, or stopped when qemu is gone. With healthy=true it also waits for every applied recipe's health check to pass. The bound is timeout_seconds, a plain count of seconds and not a duration string, capped at 600. A state the VM can never reach fails at once rather than waiting out the timeout. Mutating only in that it blocks the caller.", + func(ctx context.Context, in waitIn) (wire.WaitResult, error) { + name, err := checkVMName(in.VM) + if err != nil { + return wire.WaitResult{}, err + } + // core.Wait takes an Until, not an options struct. healthy and + // until are two different waits, so passing both is refused. + until := core.Until(in.Until) + if in.Until == "" { + until = core.UntilReachable + } + if in.Healthy { + if in.Until != "" { + return wire.WaitResult{}, fmt.Errorf("healthy and until are two different waits; pass one") + } + until = core.UntilHealthy + } + timeout := waitTimeout(in.TimeoutSeconds) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if err := core.Wait(ctx, name, until); err != nil { + return wire.WaitResult{}, err + } + return wire.WaitResult{VM: name, Until: string(until), Healthy: in.Healthy}, nil + }) + + register(server, "prune", classDestructive, + "Report stale files stoat can clean up: partial downloads always, broken VM directories with broken=true, and orphaned images with images=true. It is a dry run by default and only reports; pass apply=true to delete, which is NOT reversible for whatever it removes.", + func(ctx context.Context, in pruneIn) (wire.PruneList, error) { + items, err := core.Prune(core.PruneOpts{DryRun: !in.Apply, Broken: in.Broken, Images: in.Images}) + if err != nil { + return wire.PruneList{}, err + } + return wire.PruneList{Items: wire.FromPruneItems(items), DryRun: !in.Apply}, nil + }) + + register(server, "apply_recipes", classExec, + "Run a VM's own configured recipes over ssh, or a named subset of them. Call plan_recipes first to see what this would do. A recipe body is arbitrary guest code, so this needs agent_access manage or higher. Mutating: it runs recipe scripts inside the guest, and that is reversible only to whatever extent the recipe itself is. It reaches outside this process.", + func(ctx context.Context, in applyIn) (wire.ApplyResult, error) { + name, err := checkVMName(in.VM) + if err != nil { + return wire.ApplyResult{}, err + } + if err := requireAccess(name, LevelManage); err != nil { + return wire.ApplyResult{}, err + } + if err := checkFlagFree(in.Only, "only"); err != nil { + return wire.ApplyResult{}, err + } + if err := core.Apply(ctx, name, core.ApplyOpts{Only: in.Only}); err != nil { + return wire.ApplyResult{}, err + } + v, err := core.Get(name) + if err != nil { + return wire.ApplyResult{}, err + } + return wire.FromApplyResult(v), nil + }) +} + +// byName builds a handler for a tool whose only input is a VM name and +// whose result is the VM afterwards. start, stop and destroy differ only by +// the core call. +func (s *srv) byName(fn func(string) error) func(context.Context, vmIn) (wire.VM, error) { + return func(ctx context.Context, in vmIn) (wire.VM, error) { + name, err := checkVMName(in.VM) + if err != nil { + return wire.VM{}, err + } + if err := fn(name); err != nil { + return wire.VM{}, err + } + v, err := core.Get(name) + if err != nil { + // destroy removes the VM, so a not-found read afterwards is the + // expected outcome and not a failure of the tool. + return wire.VM{Name: name, State: "gone"}, nil + } + return wire.FromVM(v, core.GraphicalSession()), nil + } +} + +// corePatch converts the tool input to core.Patch. agent_access is checked +// against the VM's current level here rather than in core, because core is a +// library the CLI and TUI call with the authority to raise it. +func corePatch(name string, in updateIn) (core.Patch, error) { + generic := patchFromUpdate(in) + p := core.Patch{} + if v, ok := generic["ram"].(int); ok { + p.RAM = &v + } + if v, ok := generic["cpus"].(int); ok { + p.CPUs = &v + } + if v, ok := generic["ssh_port"].(int); ok { + p.SSHPort = &v + } + if v, ok := generic["disk"].(string); ok { + p.Disk = &v + } + if v, ok := generic["recipes"].([]string); ok { + p.Recipes = &v + } + for _, params := range in.Params { + for k := range params { + if _, err := checkParamName(k); err != nil { + return core.Patch{}, err + } + } + } + for _, params := range in.Secrets { + for k := range params { + if _, err := checkParamName(k); err != nil { + return core.Patch{}, err + } + } + } + p.SetParams = in.Params + p.Secrets = config.Secrets(in.Secrets) + if in.AgentAccess != "" { + want, err := ParseLevel(in.AgentAccess) + if err != nil { + return core.Patch{}, err + } + cur, err := currentLevel(name) + if err != nil { + return core.Patch{}, err + } + if want.rank() > cur.rank() { + return core.Patch{}, fmt.Errorf("vm %q has agent_access = %s; this tool may only lower it, and raising it is a CLI or TUI action", name, cur) + } + access := want.String() + p.AgentAccess = &access + } + return p, nil } From c9188288cd874f76d4902abd67486997ea431b81 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 15:32:17 +0300 Subject: [PATCH 22/67] feat(mcp): add in-VM read tools Signed-off-by: NovusEdge --- internal/cli/wire/dto.go | 48 +++++ internal/guest/guest.go | 24 +++ internal/mcpsrv/server.go | 1 + internal/mcpsrv/tools_guest.go | 360 ++++++++++++++++++++++++++++++++- 4 files changed, 427 insertions(+), 6 deletions(-) diff --git a/internal/cli/wire/dto.go b/internal/cli/wire/dto.go index 1acb948a..f460d2de 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -774,3 +774,51 @@ type ApplyResult struct { func FromApplyResult(v core.VM) ApplyResult { return ApplyResult{VM: v.Name, Recipes: FromVMStatus(v, false).RecipeStates} } + +// FileContent is the read_file and job_output tools' output. +type FileContent struct { + Content string `json:"content"` + Encoding string `json:"encoding,omitempty"` + Size int64 `json:"size"` + Truncated bool `json:"truncated"` +} + +// DirEntry is the list_dir and stat tools' per-file output. +type DirEntry struct { + Name string `json:"name"` + Type string `json:"type"` + Size int64 `json:"size"` + Mode string `json:"mode"` + MTime int64 `json:"mtime"` +} + +// DirListing is the list_dir tool's output. +type DirListing struct { + Entries []DirEntry `json:"entries"` + Truncated bool `json:"truncated"` +} + +// Process is one row of the ps tool's output. +type Process struct { + PID int `json:"pid"` + PPID int `json:"ppid"` + User string `json:"user"` + Elapsed string `json:"elapsed,omitempty"` + Command string `json:"command"` +} + +// ProcessList is the ps tool's output. +type ProcessList struct { + Processes []Process `json:"processes"` + Truncated bool `json:"truncated"` +} + +// CommandResult is the output of a fixed-verb in-VM tool: svc, svc_status, +// pkg_install, useradd, write_file's chmod, exec and exec_bg's siblings. +// Unlike ExecResult, it assumes text output; a guest verb this narrow never +// returns binary. +type CommandResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` +} diff --git a/internal/guest/guest.go b/internal/guest/guest.go index 70107b81..49a98d01 100644 --- a/internal/guest/guest.go +++ b/internal/guest/guest.go @@ -74,6 +74,12 @@ type OS struct { Svc Svc `toml:"svc"` Cmd map[string]string `toml:"cmd"` + // LogPath is where the init system writes its own log, for a guest whose + // init has no journal. tail_log reads it when the caller names neither a + // unit nor a path. It is optional: validate does not require it, and a + // systemd guest leaves it empty because journalctl answers instead. + LogPath string `toml:"log_path"` + // Backends holds one opaque table per backend name. The backend package // that owns the name decodes it; this package never reads inside. Backends map[string]map[string]any `toml:"backend"` @@ -109,6 +115,24 @@ type Svc struct { Status string `toml:"status"` } +// Get returns one [svc] template by action name, or "" when the guest +// declares none. +func (s Svc) Get(action string) string { + switch action { + case "enable": + return s.Enable + case "start": + return s.Start + case "stop": + return s.Stop + case "restart": + return s.Restart + case "status": + return s.Status + } + return "" +} + // loaded is the active set: bundled at init, then Load merges user files // over it. Lookup before Load sees bundled guests only, so a broken user // file cannot take the bundled set down. diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index bedadc6d..25b95581 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -60,6 +60,7 @@ func New(opts Options) *mcp.Server { server := mcp.NewServer(&mcp.Implementation{Name: "stoat", Version: opts.Version}, nil) s.registerRead(server) s.registerVM(server) + s.registerGuestRead(server) server.AddReceivingMiddleware(s.rateLimit()) return server } diff --git a/internal/mcpsrv/tools_guest.go b/internal/mcpsrv/tools_guest.go index 51bf2e9b..a3895ce0 100644 --- a/internal/mcpsrv/tools_guest.go +++ b/internal/mcpsrv/tools_guest.go @@ -1,13 +1,361 @@ package mcpsrv -// readSize is not implemented yet. Task 10 clamps n to -// [1, maxReadBytes]. +import ( + "context" + "encoding/base64" + "fmt" + "strconv" + "strings" + "unicode/utf8" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/guest" + "github.com/novusedge/stoat/internal/sshx" +) + +type readFileIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Path string `json:"path" jsonschema:"absolute path in the guest; a relative path is refused"` + Offset int `json:"offset,omitempty" jsonschema:"byte offset to start at"` + MaxBytes int `json:"max_bytes,omitempty" jsonschema:"how many bytes to read, capped at 1048576"` +} + +type pathIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Path string `json:"path" jsonschema:"absolute path in the guest; a relative path is refused"` +} + +type svcStatusIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Name string `json:"name" jsonschema:"service name"` +} + +type tailLogIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Unit string `json:"unit,omitempty" jsonschema:"systemd unit to read with journalctl"` + Path string `json:"path,omitempty" jsonschema:"absolute path of a log file to tail instead of a unit"` + Lines int `json:"lines,omitempty" jsonschema:"how many lines to return, capped at 2000"` +} + +// readSize clamps a caller's max_bytes. 0 means the caller did not set a +// limit, which reads as "the full clamp", not "the smallest possible read". func readSize(n int) int { - return 0 + if n <= 0 { + return maxReadBytes + } + return clampInt(n, 1, maxReadBytes) +} + +// guestVM resolves a VM for an in-VM tool: the name guard, the access gate, +// and the config load, in that order. Every guest tool starts here. need is +// the tool's own row in toolTable (table_test.go), which a test walks to +// check every cell against this same gate. +func guestVM(name string, need Level) (*config.VM, error) { + n, err := checkVMName(name) + if err != nil { + return nil, err + } + if err := requireAccess(n, need); err != nil { + return nil, err + } + return config.Load(n) +} + +// readGuestFile reads path in v's guest, clamped to readSize(maxBytes), and +// encodes it for the wire. read_file and job_output share this: both clamp +// and encode identically, and only differ in where path comes from. +func readGuestFile(ctx context.Context, v *config.VM, path string, offset, maxBytes int) (wire.FileContent, error) { + sizeOut, _, code, err := sshx.Run(ctx, v, false, []string{"stat", "-c", "%s", path}, nil) + if err != nil { + return wire.FileContent{}, err + } + if code != 0 { + return wire.FileContent{}, fmt.Errorf("%s: cannot stat %s", v.Name, path) + } + size, _ := strconv.ParseInt(strings.TrimSpace(string(sizeOut)), 10, 64) + n := readSize(maxBytes) + + // head -c is the fast path and every guest has it. dd with bs=1 is the + // only portable way to skip an unaligned offset, and the clamp bounds + // its cost. + argv := []string{"head", "-c", strconv.Itoa(n), path} + if offset > 0 { + argv = []string{"dd", "if=" + path, "bs=1", + "skip=" + strconv.Itoa(offset), "count=" + strconv.Itoa(n), "status=none"} + } + data, errb, code, err := sshx.Run(ctx, v, false, argv, nil) + if err != nil { + return wire.FileContent{}, err + } + if code != 0 { + return wire.FileContent{}, fmt.Errorf("%s: %s", v.Name, strings.TrimSpace(string(errb))) + } + out := wire.FileContent{ + Size: size, + Truncated: int64(offset)+int64(len(data)) < size, + } + if utf8.Valid(data) { + out.Content = string(data) + } else { + out.Content = base64.StdEncoding.EncodeToString(data) + out.Encoding = "base64" + } + return out, nil +} + +func (s *srv) registerGuestRead(server *mcp.Server) { + register(server, "read_file", classRead, + "Read a file from a VM's guest filesystem over ssh. The path must be absolute; a relative path is refused rather than resolved against the guest user's home, so one call means the same thing on every guest. max_bytes is capped at 1048576. Text comes back in content; binary comes back base64 encoded with encoding set. It needs agent_access observe or higher. Read-only in the guest.", + func(ctx context.Context, in readFileIn) (wire.FileContent, error) { + v, err := guestVM(in.VM, LevelObserve) + if err != nil { + return wire.FileContent{}, err + } + path, err := checkGuestPath(in.Path) + if err != nil { + return wire.FileContent{}, err + } + return readGuestFile(ctx, v, path, in.Offset, in.MaxBytes) + }) + + register(server, "list_dir", classRead, + "List one directory in a VM's guest filesystem: name, type, size, mode and mtime for each entry. The path must be absolute. The listing is capped at 2000 entries and truncated is set when it hits the cap. It needs agent_access observe or higher. Read-only in the guest.", + func(ctx context.Context, in pathIn) (wire.DirListing, error) { + v, err := guestVM(in.VM, LevelObserve) + if err != nil { + return wire.DirListing{}, err + } + path, err := checkGuestPath(in.Path) + if err != nil { + return wire.DirListing{}, err + } + // Two calls rather than find -printf: busybox find has no + // -printf, and busybox ls and stat both behave. + nameOut, errb, code, err := sshx.Run(ctx, v, false, []string{"ls", "-A", path}, nil) + if err != nil { + return wire.DirListing{}, err + } + if code != 0 { + return wire.DirListing{}, fmt.Errorf("%s: %s", v.Name, strings.TrimSpace(string(errb))) + } + names := capNames(strings.Fields(string(nameOut))) + if len(names) == 0 { + return wire.DirListing{Entries: []wire.DirEntry{}}, nil + } + argv := append([]string{"stat", "-c", "%n\t%F\t%s\t%f\t%Y"}, joinDir(path, names)...) + statOut, _, _, err := sshx.Run(ctx, v, false, argv, nil) + if err != nil { + return wire.DirListing{}, err + } + return wire.DirListing{ + Entries: parseStat(statOut), + Truncated: len(names) == maxDirEntries, + }, nil + }) + + register(server, "stat", classRead, + "Report one path's type, size, mode and mtime in a VM's guest filesystem. The path must be absolute. It needs agent_access observe or higher. Read-only in the guest.", + func(ctx context.Context, in pathIn) (wire.DirEntry, error) { + v, err := guestVM(in.VM, LevelObserve) + if err != nil { + return wire.DirEntry{}, err + } + path, err := checkGuestPath(in.Path) + if err != nil { + return wire.DirEntry{}, err + } + out, errb, code, err := sshx.Run(ctx, v, false, []string{"stat", "-c", "%n\t%F\t%s\t%f\t%Y", path}, nil) + if err != nil { + return wire.DirEntry{}, err + } + if code != 0 { + return wire.DirEntry{}, fmt.Errorf("%s: %s", v.Name, strings.TrimSpace(string(errb))) + } + entries := parseStat(out) + if len(entries) == 0 { + return wire.DirEntry{}, fmt.Errorf("%s: stat returned nothing for %s", v.Name, path) + } + return entries[0], nil + }) + + register(server, "ps", classRead, + "List the processes running in a VM: pid, ppid, user, elapsed time and the command. The list is capped at 2000 rows and truncated is set when it hits the cap. It needs agent_access observe or higher. Read-only in the guest.", + func(ctx context.Context, in vmIn) (wire.ProcessList, error) { + v, err := guestVM(in.VM, LevelObserve) + if err != nil { + return wire.ProcessList{}, err + } + out, _, code, err := sshx.Run(ctx, v, false, + []string{"ps", "-eo", "pid,ppid,user,etime,args"}, nil) + if err != nil { + return wire.ProcessList{}, err + } + if code != 0 { + // busybox ps rejects -eo. Its own spelling has no etime. + out, _, code, err = sshx.Run(ctx, v, false, []string{"ps", "-o", "pid,ppid,user,args"}, nil) + if err != nil { + return wire.ProcessList{}, err + } + if code != 0 { + return wire.ProcessList{}, fmt.Errorf("%s: ps exited %d", v.Name, code) + } + } + rows := parsePS(out) + list := wire.ProcessList{Truncated: len(rows) > maxPSRows} + list.Processes = rows[:min(len(rows), maxPSRows)] + return list, nil + }) + + register(server, "svc_status", classRead, + "Report one service's status in a VM, using the init system's own status verb from the guest definition. It needs agent_access observe or higher. Read-only in the guest.", + func(ctx context.Context, in svcStatusIn) (wire.CommandResult, error) { + v, err := guestVM(in.VM, LevelObserve) + if err != nil { + return wire.CommandResult{}, err + } + name, err := checkSvcName(in.Name) + if err != nil { + return wire.CommandResult{}, err + } + argv, err := svcArgv(v, "status", name) + if err != nil { + return wire.CommandResult{}, err + } + return runToResult(ctx, v, false, argv) + }) + + register(server, "tail_log", classRead, + "Tail a log in a VM: a systemd unit's journal with unit, or a log file with path. Without either, it reads the init system's own log path from the guest definition. The line count is capped at 2000. It needs agent_access observe or higher. Read-only in the guest.", + func(ctx context.Context, in tailLogIn) (wire.LogTail, error) { + v, err := guestVM(in.VM, LevelObserve) + if err != nil { + return wire.LogTail{}, err + } + n := clampInt(in.Lines, 1, maxLogLines) + var argv []string + switch { + case in.Path != "": + path, err := checkGuestPath(in.Path) + if err != nil { + return wire.LogTail{}, err + } + argv = []string{"tail", "-n", strconv.Itoa(n), path} + case in.Unit != "": + unit, err := checkSvcName(in.Unit) + if err != nil { + return wire.LogTail{}, err + } + os, ok := guest.Lookup(v.OS) + if !ok || os.Init != "systemd" { + return wire.LogTail{}, fmt.Errorf("%s: unit needs a systemd guest; pass path instead", v.Name) + } + argv = []string{"journalctl", "-u", unit, "-n", strconv.Itoa(n), "--no-pager"} + default: + os, ok := guest.Lookup(v.OS) + if !ok || os.LogPath == "" { + return wire.LogTail{}, fmt.Errorf("%s: guest %q declares no log path; pass unit or path", v.Name, v.OS) + } + argv = []string{"tail", "-n", strconv.Itoa(n), os.LogPath} + } + out, errb, code, err := sshx.Run(ctx, v, true, argv, nil) + if err != nil { + return wire.LogTail{}, err + } + if code != 0 { + return wire.LogTail{}, fmt.Errorf("%s: %s", v.Name, strings.TrimSpace(string(errb))) + } + return wire.LogTail{Lines: strings.Split(strings.TrimRight(string(out), "\n"), "\n")}, nil + }) +} + +// svcArgv renders the guest file's [svc] template and passes the service +// name as $1. The template is the constant and the name is a positional +// argument, so no tool input reaches the guest shell as syntax. +// +// A guest stoat does not know still gets a template: systemctl is the init +// system most guests run, the same "assume the most common answer" rule +// sshx.escalate applies for an unrecognized OS's privilege escalation. +func svcArgv(v *config.VM, action, name string) ([]string, error) { + tmpl := "systemctl " + action + " {name}" + if os, ok := guest.Lookup(v.OS); ok { + if t := os.Svc.Get(action); t != "" { + tmpl = t + } + } + return []string{"sh", "-c", renderVerb(tmpl), "stoat_svc", name}, nil +} + +// renderVerb turns a guest-file template into a shell body: {name} becomes +// "$1", and a template with no {name} gets "$@" appended. It repeats +// internal/guest's own shTemplate, which is unexported. The two must stay +// byte-identical. +func renderVerb(tmpl string) string { + if strings.Contains(tmpl, "{name}") { + return strings.ReplaceAll(tmpl, "{name}", `"$1"`) + } + return tmpl + ` "$@"` +} + +func runToResult(ctx context.Context, v *config.VM, root bool, argv []string) (wire.CommandResult, error) { + out, errb, code, err := sshx.Run(ctx, v, root, argv, nil) + if err != nil { + return wire.CommandResult{}, err + } + return wire.CommandResult{Stdout: string(out), Stderr: string(errb), ExitCode: code}, nil } -// capNames is not implemented yet. Task 10 truncates names to -// maxDirEntries. func capNames(names []string) []string { - return nil + return names[:min(len(names), maxDirEntries)] +} + +func joinDir(dir string, names []string) []string { + out := make([]string, len(names)) + for i, n := range names { + out[i] = strings.TrimRight(dir, "/") + "/" + n + } + return out +} + +// parseStat reads the tab separated rows stat -c produced. A name with a tab +// in it is not representable here and stat itself has the same limit. +func parseStat(raw []byte) []wire.DirEntry { + var out []wire.DirEntry + for _, line := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") { + f := strings.Split(line, "\t") + if len(f) != 5 { + continue + } + size, _ := strconv.ParseInt(f[2], 10, 64) + mtime, _ := strconv.ParseInt(f[4], 10, 64) + out = append(out, wire.DirEntry{ + Name: f[0], Type: f[1], Size: size, Mode: f[3], MTime: mtime, + }) + } + return out +} + +func parsePS(raw []byte) []wire.Process { + var out []wire.Process + lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n") + for _, line := range lines { + f := strings.Fields(line) + if len(f) < 4 || f[0] == "PID" { + continue + } + pid, err := strconv.Atoi(f[0]) + if err != nil { + continue + } + ppid, _ := strconv.Atoi(f[1]) + p := wire.Process{PID: pid, PPID: ppid, User: f[2]} + if len(f) >= 5 { + p.Elapsed, p.Command = f[3], strings.Join(f[4:], " ") + } else { + p.Command = strings.Join(f[3:], " ") + } + out = append(out, p) + } + return out } From ba505e7b585c6ca1a65fd3e7d276cf86e8be339b Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 15:33:39 +0300 Subject: [PATCH 23/67] feat(mcp): add in-VM manage tools Signed-off-by: NovusEdge --- internal/cli/wire/dto.go | 8 ++ internal/mcpsrv/server.go | 1 + internal/mcpsrv/tools_guest.go | 182 +++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+) diff --git a/internal/cli/wire/dto.go b/internal/cli/wire/dto.go index f460d2de..72296dac 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -822,3 +822,11 @@ type CommandResult struct { Stderr string `json:"stderr"` ExitCode int `json:"exit_code"` } + +// CopyResult is the copy_to and copy_from tools' output. +type CopyResult struct { + VM string `json:"vm"` + Local string `json:"local"` + Remote string `json:"remote"` + ToRemote bool `json:"to_remote"` +} diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index 25b95581..06284d1e 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -61,6 +61,7 @@ func New(opts Options) *mcp.Server { s.registerRead(server) s.registerVM(server) s.registerGuestRead(server) + s.registerGuestWrite(server) server.AddReceivingMiddleware(s.rateLimit()) return server } diff --git a/internal/mcpsrv/tools_guest.go b/internal/mcpsrv/tools_guest.go index a3895ce0..36a9df9f 100644 --- a/internal/mcpsrv/tools_guest.go +++ b/internal/mcpsrv/tools_guest.go @@ -4,6 +4,8 @@ import ( "context" "encoding/base64" "fmt" + "regexp" + "slices" "strconv" "strings" "unicode/utf8" @@ -11,6 +13,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/novusedge/stoat/internal/cli/wire" "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/core" "github.com/novusedge/stoat/internal/guest" "github.com/novusedge/stoat/internal/sshx" ) @@ -39,6 +42,40 @@ type tailLogIn struct { Lines int `json:"lines,omitempty" jsonschema:"how many lines to return, capped at 2000"` } +type writeFileIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Path string `json:"path" jsonschema:"absolute path in the guest; the parent directory must already exist"` + Content string `json:"content" jsonschema:"the file's new content"` + Mode string `json:"mode,omitempty" jsonschema:"octal file mode such as 0644, which is the default"` + Append bool `json:"append,omitempty" jsonschema:"append instead of replacing the file"` +} + +type copyIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Local string `json:"local" jsonschema:"host path, which must resolve under this VM's own shared directory"` + Remote string `json:"remote" jsonschema:"absolute path in the guest"` +} + +type pkgInstallIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Packages []string `json:"packages" jsonschema:"package names for the guest's own package manager"` +} + +type svcIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Name string `json:"name" jsonschema:"service name"` + Action string `json:"action" jsonschema:"enable, start, stop or restart"` +} + +type useraddIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Name string `json:"name" jsonschema:"account name to create"` +} + +var svcActions = []string{"enable", "start", "stop", "restart"} + +var modeRE = regexp.MustCompile(`^0?[0-7]{3}$`) + // readSize clamps a caller's max_bytes. 0 means the caller did not set a // limit, which reads as "the full clamp", not "the smallest possible read". func readSize(n int) int { @@ -270,6 +307,117 @@ func (s *srv) registerGuestRead(server *mcp.Server) { }) } +func (s *srv) registerGuestWrite(server *mcp.Server) { + register(server, "write_file", classExec, + "Write a file inside a VM's guest filesystem over ssh. The path must be absolute and its parent directory must already exist. mode defaults to 0644. With append=true the content is added to the end instead of replacing the file. It needs agent_access manage or higher, and it refuses when the VM is not running. It overwrites whatever was there, and that is not reversible from here. It reaches outside this process.", + func(ctx context.Context, in writeFileIn) (wire.CommandResult, error) { + v, err := guestVM(in.VM, LevelManage) + if err != nil { + return wire.CommandResult{}, err + } + path, err := checkGuestPath(in.Path) + if err != nil { + return wire.CommandResult{}, err + } + mode := in.Mode + if mode == "" { + mode = "0644" + } + if !modeRE.MatchString(mode) { + return wire.CommandResult{}, fmt.Errorf("invalid mode %q: three or four octal digits", mode) + } + // tee rather than a redirect: a redirect is shell syntax the + // tool would have to build around the path. + argv := []string{"tee", path} + if in.Append { + argv = []string{"tee", "-a", path} + } + _, errb, code, err := sshx.Run(ctx, v, true, argv, strings.NewReader(in.Content)) + if err != nil { + return wire.CommandResult{}, err + } + if code != 0 { + return wire.CommandResult{}, fmt.Errorf("%s: %s", v.Name, strings.TrimSpace(string(errb))) + } + return runToResult(ctx, v, true, []string{"chmod", mode, path}) + }) + + register(server, "copy_to", classExec, + "Copy a file from the host into a VM's guest filesystem. The host path must resolve under that VM's own shared directory, and anything else is refused before stoat runs. It needs agent_access manage or higher. It overwrites whatever was at the guest destination, and that is not reversible from here. It reaches outside this process.", + s.copyHandler(true)) + + register(server, "copy_from", classExec, + "Copy a file out of a VM's guest filesystem to the host. The host destination must resolve under that VM's own shared directory, and anything else is refused before stoat runs. It needs agent_access manage or higher. It overwrites whatever was at the host destination, and that is not reversible from here. It reaches outside this process.", + s.copyHandler(false)) + + register(server, "pkg_install", classExec, + "Install packages in a VM with the guest's own package manager, taken from the guest definition. It refreshes the package index first. Package names are passed as positional arguments and a name that reads as a flag is refused. It needs agent_access manage or higher. It reaches outside this process, since the package manager downloads.", + func(ctx context.Context, in pkgInstallIn) (wire.CommandResult, error) { + v, err := guestVM(in.VM, LevelManage) + if err != nil { + return wire.CommandResult{}, err + } + if len(in.Packages) == 0 { + return wire.CommandResult{}, fmt.Errorf("packages is required") + } + if err := checkFlagFree(in.Packages, "packages"); err != nil { + return wire.CommandResult{}, err + } + os, ok := guest.Lookup(v.OS) + if !ok { + return wire.CommandResult{}, fmt.Errorf("unknown guest %q; run stoat guest ls", v.OS) + } + // pkg.setup is the distro's own index refresh and carries no + // tool input, so running it as the guest file wrote it is safe. + if _, _, code, err := sshx.Run(ctx, v, true, []string{"sh", "-c", os.Pkg.Setup}, nil); err != nil { + return wire.CommandResult{}, err + } else if code != 0 { + return wire.CommandResult{}, fmt.Errorf("%s: package index refresh exited %d", v.Name, code) + } + return runToResult(ctx, v, true, append(append([]string{}, os.Pkg.Install...), in.Packages...)) + }) + + register(server, "svc", classExec, + "Enable, start, stop or restart a service in a VM, using the init system's own verb from the guest definition. The service name is passed as a positional argument, never as shell syntax. It needs agent_access manage or higher.", + func(ctx context.Context, in svcIn) (wire.CommandResult, error) { + v, err := guestVM(in.VM, LevelManage) + if err != nil { + return wire.CommandResult{}, err + } + if !slices.Contains(svcActions, in.Action) { + return wire.CommandResult{}, fmt.Errorf("invalid action %q: one of enable, start, stop, restart", in.Action) + } + name, err := checkSvcName(in.Name) + if err != nil { + return wire.CommandResult{}, err + } + argv, err := svcArgv(v, in.Action, name) + if err != nil { + return wire.CommandResult{}, err + } + return runToResult(ctx, v, true, argv) + }) + + register(server, "useradd", classExec, + "Create an account in a VM, using the guest definition's own useradd verb. The account name is passed as a positional argument. It needs agent_access manage or higher.", + func(ctx context.Context, in useraddIn) (wire.CommandResult, error) { + v, err := guestVM(in.VM, LevelManage) + if err != nil { + return wire.CommandResult{}, err + } + name, err := checkSvcName(in.Name) + if err != nil { + return wire.CommandResult{}, err + } + o, ok := guest.Lookup(v.OS) + if !ok || o.Cmd["useradd"] == "" { + return wire.CommandResult{}, fmt.Errorf("guest %q declares no cmd.useradd", v.OS) + } + return runToResult(ctx, v, true, + []string{"sh", "-c", renderVerb(o.Cmd["useradd"]), "stoat_useradd", name}) + }) +} + // svcArgv renders the guest file's [svc] template and passes the service // name as $1. The template is the constant and the name is a positional // argument, so no tool input reaches the guest shell as syntax. @@ -359,3 +507,37 @@ func parsePS(raw []byte) []wire.Process { } return out } + +// copyHandler builds copy_to and copy_from. They differ only in direction, +// and both confine the host side to the VM's own shared directory. +func (s *srv) copyHandler(toRemote bool) func(context.Context, copyIn) (wire.CopyResult, error) { + return func(ctx context.Context, in copyIn) (wire.CopyResult, error) { + name, err := checkVMName(in.VM) + if err != nil { + return wire.CopyResult{}, err + } + if err := requireAccess(name, LevelManage); err != nil { + return wire.CopyResult{}, err + } + local, err := checkHostPath(in.Local, name) + if err != nil { + return wire.CopyResult{}, err + } + remote, err := checkGuestPath(in.Remote) + if err != nil { + return wire.CopyResult{}, err + } + if toRemote { + err = core.CopyTo(ctx, name, local, remote) + } else { + err = core.CopyFrom(ctx, name, remote, local) + } + if err != nil { + return wire.CopyResult{}, err + } + // The result echoes the host path this server authorised. The 9p + // mapped-xattr defence and this guard are independent, and neither + // covers the other. + return wire.CopyResult{VM: name, Local: local, Remote: remote, ToRemote: toRemote}, nil + } +} From 5ddf60e140964a38ba1558b39b405dc45f1f1d59 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 15:34:22 +0300 Subject: [PATCH 24/67] feat(mcp): add exec and background job tools Signed-off-by: NovusEdge --- internal/cli/wire/dto.go | 27 ++++ internal/mcpsrv/jobs.go | 104 +++++++++++-- internal/mcpsrv/server.go | 1 + internal/mcpsrv/tools_exec.go | 279 +++++++++++++++++++++++++++++++++- 4 files changed, 391 insertions(+), 20 deletions(-) diff --git a/internal/cli/wire/dto.go b/internal/cli/wire/dto.go index 72296dac..ae6e4122 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -830,3 +830,30 @@ type CopyResult struct { Remote string `json:"remote"` ToRemote bool `json:"to_remote"` } + +// JobStarted is the exec_bg tool's output. +type JobStarted struct { + JobID string `json:"job_id"` + Dir string `json:"dir"` +} + +// JobStatus is the job_status tool's output. +type JobStatus struct { + JobID string `json:"job_id"` + State string `json:"state"` + ExitCode int `json:"exit_code"` +} + +// Job is one list_jobs row. +type Job struct { + JobID string `json:"job_id"` + Argv []string `json:"argv"` + User string `json:"user"` + CWD string `json:"cwd,omitempty"` + Started time.Time `json:"started"` +} + +// JobList is the list_jobs tool's output. +type JobList struct { + Jobs []Job `json:"jobs"` +} diff --git a/internal/mcpsrv/jobs.go b/internal/mcpsrv/jobs.go index 77f40c43..6d4c841e 100644 --- a/internal/mcpsrv/jobs.go +++ b/internal/mcpsrv/jobs.go @@ -1,36 +1,108 @@ package mcpsrv import ( + "crypto/rand" + "encoding/hex" "fmt" + "os" + "path/filepath" + "regexp" "time" + + "github.com/BurntSushi/toml" + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/tomlx" ) +var jobIDRE = regexp.MustCompile(`^j-[0-9a-f]{8}$`) + // job is one background command. The host owns the id and the record, so // list_jobs answers without ssh and works on a stopped VM. type job struct { - ID string - Argv []string - User string - CWD string - Dir string - Started time.Time + ID string `toml:"-"` + Argv []string `toml:"argv"` + User string `toml:"user"` + CWD string `toml:"cwd,omitempty"` + Dir string `toml:"dir"` + Started time.Time `toml:"started"` +} + +type jobsFile struct { + Schema int `toml:"schema"` + Jobs map[string]job `toml:"jobs"` } -// newJobID is not implemented yet. Task 12 returns "j-" plus 8 random hex -// characters. func newJobID() string { - return "" + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + panic(err) + } + return "j-" + hex.EncodeToString(b[:]) +} + +func checkJobID(id string) (string, error) { + if !jobIDRE.MatchString(id) { + return "", fmt.Errorf("invalid job id %q: must match %s", id, jobIDRE) + } + return id, nil +} + +func jobsPath(vm string) (string, error) { + name, err := checkVMName(vm) + if err != nil { + return "", err + } + return filepath.Join(config.Root(), name, "jobs.toml"), nil } -// loadJobs is not implemented yet. Task 12 reads -// config.Root()//jobs.toml through tomlx.Decode; a VM with no file -// returns an empty map, not an error. +// loadJobs reads the registry. A VM that has never run a background job has +// no file, which is not an error. func loadJobs(vm string) (map[string]job, error) { - return nil, fmt.Errorf("loadJobs(%q): not implemented", vm) + path, err := jobsPath(vm) + if err != nil { + return nil, err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + return map[string]job{}, nil + } + var f jobsFile + if err := tomlx.Decode(path, &f, tomlx.Reject); err != nil { + return nil, err + } + out := make(map[string]job, len(f.Jobs)) + for id, j := range f.Jobs { + j.ID = id + out[id] = j + } + return out, nil } -// saveJob is not implemented yet. Task 12 adds j to the registry and -// rewrites jobs.toml through a temp file and rename. +// saveJob adds one job and rewrites the file. The write is to a temp file in +// the same directory and a rename, so a crash mid-write leaves the previous +// registry rather than a truncated one. func saveJob(vm string, j job) error { - return fmt.Errorf("saveJob(%q, %q): not implemented", vm, j.ID) + jobs, err := loadJobs(vm) + if err != nil { + return err + } + jobs[j.ID] = j + path, err := jobsPath(vm) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".jobs-*.toml") + if err != nil { + return err + } + defer func() { _ = os.Remove(tmp.Name()) }() + if _, err := fmt.Fprint(tmp, "# written by stoat; do not edit\n"); err != nil { + return err + } + if err := toml.NewEncoder(tmp).Encode(jobsFile{Schema: 1, Jobs: jobs}); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) } diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index 06284d1e..6a4e2564 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -62,6 +62,7 @@ func New(opts Options) *mcp.Server { s.registerVM(server) s.registerGuestRead(server) s.registerGuestWrite(server) + s.registerExec(server) server.AddReceivingMiddleware(s.rateLimit()) return server } diff --git a/internal/mcpsrv/tools_exec.go b/internal/mcpsrv/tools_exec.go index 72aecc6a..02da468f 100644 --- a/internal/mcpsrv/tools_exec.go +++ b/internal/mcpsrv/tools_exec.go @@ -1,9 +1,280 @@ package mcpsrv -import "time" +import ( + "context" + "fmt" + "io" + "path" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/sshx" +) + +const jobRoot = "/run/stoat/jobs" + +type execIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Argv []string `json:"argv" jsonschema:"the guest command as an argv; it is never re-parsed by a shell on the host"` + Stdin string `json:"stdin,omitempty" jsonschema:"data to send on the command's stdin"` + CWD string `json:"cwd,omitempty" jsonschema:"absolute directory in the guest to run in"` + Env map[string]string `json:"env,omitempty" jsonschema:"environment variables to set for this command"` + TimeoutSeconds int `json:"timeout_seconds,omitempty" jsonschema:"a plain count of seconds, capped at 600; 60 is the default"` +} + +type execBgIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + Argv []string `json:"argv" jsonschema:"the guest command as an argv"` + CWD string `json:"cwd,omitempty" jsonschema:"absolute directory in the guest to run in"` + Env map[string]string `json:"env,omitempty" jsonschema:"environment variables to set for this command"` +} + +type jobIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + JobID string `json:"job_id" jsonschema:"job id returned by exec_bg"` +} + +type jobOutputIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + JobID string `json:"job_id" jsonschema:"job id returned by exec_bg"` + Stream string `json:"stream,omitempty" jsonschema:"stdout or stderr; stdout is the default"` + Offset int `json:"offset,omitempty" jsonschema:"byte offset to start at"` + MaxBytes int `json:"max_bytes,omitempty" jsonschema:"how many bytes to read, capped at 1048576"` +} + +type jobKillIn struct { + VM string `json:"vm" jsonschema:"name of the VM"` + JobID string `json:"job_id" jsonschema:"job id returned by exec_bg"` + Signal string `json:"signal,omitempty" jsonschema:"signal name without the SIG prefix; TERM is the default"` +} + +var signalRE = regexp.MustCompile(`^[A-Z]{2,10}[0-9]?$`) -// execTimeout is not implemented yet. Task 12 clamps seconds to -// [1, maxExecSecs], defaulting to 60 when seconds is 0. func execTimeout(seconds int) time.Duration { - return 0 + if seconds == 0 { + return 60 * time.Second + } + return time.Duration(clampInt(seconds, 1, maxExecSecs)) * time.Second +} + +// envArgv prefixes an argv with env so a variable is set without any shell +// syntax. Names are bounded because env itself splits on the first "=". +func envArgv(env map[string]string, cwd string, argv []string) ([]string, error) { + out := argv + if len(env) > 0 { + pre := []string{"env"} + for k, v := range env { + if _, err := checkSvcName(k); err != nil { + return nil, fmt.Errorf("invalid env name %q", k) + } + pre = append(pre, k+"="+v) + } + out = append(pre, out...) + } + if cwd != "" { + p, err := checkGuestPath(cwd) + if err != nil { + return nil, err + } + // cd is a shell builtin, so sh -c is the portable spelling. The + // directory arrives as $1 and the command as the remaining args. + out = append([]string{"sh", "-c", `cd "$1" || exit 1; shift; exec "$@"`, "stoat_cd", p}, out...) + } + return out, nil +} + +func (s *srv) registerExec(server *mcp.Server) { + register(server, "exec", classExec, + "Run a command inside a VM over ssh and return its stdout, stderr and exit code. argv is an argv, never a shell string, so a value with a space or a semicolon stays one word. The command runs with the guest ssh user's privileges, and effects inside the guest are whatever the command does. timeout_seconds is capped at 600. It needs agent_access exec, and it refuses when the VM is not running. It reaches outside this process.", + func(ctx context.Context, in execIn) (wire.CommandResult, error) { + v, err := guestVM(in.VM, LevelExec) + if err != nil { + return wire.CommandResult{}, err + } + if len(in.Argv) == 0 { + return wire.CommandResult{}, fmt.Errorf("argv is required") + } + argv, err := envArgv(in.Env, in.CWD, in.Argv) + if err != nil { + return wire.CommandResult{}, err + } + ctx, cancel := context.WithTimeout(ctx, execTimeout(in.TimeoutSeconds)) + defer cancel() + var stdin io.Reader + if in.Stdin != "" { + stdin = strings.NewReader(in.Stdin) + } + out, errb, code, err := sshx.Run(ctx, v, false, argv, stdin) + if err != nil { + return wire.CommandResult{}, err + } + return wire.CommandResult{Stdout: string(out), Stderr: string(errb), ExitCode: code}, nil + }) + + register(server, "exec_bg", classExec, + "Start a command inside a VM and return at once with a job id. The command's stdout, stderr and exit code land under /run/stoat/jobs in the guest; read them with job_status and job_output. A reboot clears the guest side and job_status then reports unknown. It needs agent_access exec, and it refuses when the VM is not running. It reaches outside this process.", + func(ctx context.Context, in execBgIn) (wire.JobStarted, error) { + v, err := guestVM(in.VM, LevelExec) + if err != nil { + return wire.JobStarted{}, err + } + if len(in.Argv) == 0 { + return wire.JobStarted{}, fmt.Errorf("argv is required") + } + argv, err := envArgv(in.Env, in.CWD, in.Argv) + if err != nil { + return wire.JobStarted{}, err + } + id := newJobID() + dir := path.Join(jobRoot, id) + if _, _, code, err := sshx.Run(ctx, v, false, []string{"mkdir", "-p", dir}, nil); err != nil { + return wire.JobStarted{}, err + } else if code != 0 { + return wire.JobStarted{}, fmt.Errorf("%s: cannot create %s", v.Name, dir) + } + // The runner is a constant shell body. The job directory is $1 + // and the command is the remaining positional arguments, so no + // tool input becomes shell syntax. + const runner = `d="$1"; shift; { "$@" >"$d/out" 2>"$d/err"; echo $? >"$d/exit"; } & echo $! >"$d/pid"` + start := append([]string{"sh", "-c", runner, "stoat_job", dir}, argv...) + if _, errb, code, err := sshx.Run(ctx, v, false, start, nil); err != nil { + return wire.JobStarted{}, err + } else if code != 0 { + return wire.JobStarted{}, fmt.Errorf("%s: %s", v.Name, strings.TrimSpace(string(errb))) + } + j := job{ID: id, Argv: in.Argv, User: sshx.User(v), CWD: in.CWD, Dir: dir, Started: time.Now().UTC()} + if err := saveJob(v.Name, j); err != nil { + return wire.JobStarted{}, err + } + return wire.JobStarted{JobID: id, Dir: dir}, nil + }) + + register(server, "job_status", classRead, + "Report a background job's state: running while its process is alive, exited with the command's exit code once it finished, or unknown when the guest side is gone, which is what a reboot leaves. It needs agent_access exec.", + func(ctx context.Context, in jobIn) (wire.JobStatus, error) { + v, err := guestVM(in.VM, LevelExec) + if err != nil { + return wire.JobStatus{}, err + } + j, err := findJob(v.Name, in.JobID) + if err != nil { + return wire.JobStatus{}, err + } + out, _, code, err := sshx.Run(ctx, v, false, []string{"cat", path.Join(j.Dir, "exit")}, nil) + if err != nil { + return wire.JobStatus{}, err + } + if code == 0 { + exit, _ := strconv.Atoi(strings.TrimSpace(string(out))) + return wire.JobStatus{JobID: j.ID, State: "exited", ExitCode: exit}, nil + } + pidOut, _, pidCode, err := sshx.Run(ctx, v, false, []string{"cat", path.Join(j.Dir, "pid")}, nil) + if err != nil { + return wire.JobStatus{}, err + } + if pidCode != 0 { + return wire.JobStatus{JobID: j.ID, State: "unknown"}, nil + } + pid := strings.TrimSpace(string(pidOut)) + _, _, aliveCode, err := sshx.Run(ctx, v, false, []string{"kill", "-0", pid}, nil) + if err != nil { + return wire.JobStatus{}, err + } + if aliveCode == 0 { + return wire.JobStatus{JobID: j.ID, State: "running"}, nil + } + return wire.JobStatus{JobID: j.ID, State: "unknown"}, nil + }) + + register(server, "job_output", classRead, + "Read a background job's stdout or stderr. max_bytes is capped at 1048576, and binary comes back base64 encoded with encoding set. It needs agent_access exec.", + func(ctx context.Context, in jobOutputIn) (wire.FileContent, error) { + v, err := guestVM(in.VM, LevelExec) + if err != nil { + return wire.FileContent{}, err + } + j, err := findJob(v.Name, in.JobID) + if err != nil { + return wire.FileContent{}, err + } + file := "out" + switch in.Stream { + case "", "stdout": + case "stderr": + file = "err" + default: + return wire.FileContent{}, fmt.Errorf("invalid stream %q: stdout or stderr", in.Stream) + } + return readGuestFile(ctx, v, path.Join(j.Dir, file), in.Offset, in.MaxBytes) + }) + + register(server, "job_kill", classExec, + "Send a signal to a background job's process. TERM is the default. It needs agent_access exec.", + func(ctx context.Context, in jobKillIn) (wire.CommandResult, error) { + v, err := guestVM(in.VM, LevelExec) + if err != nil { + return wire.CommandResult{}, err + } + j, err := findJob(v.Name, in.JobID) + if err != nil { + return wire.CommandResult{}, err + } + sig := in.Signal + if sig == "" { + sig = "TERM" + } + if !signalRE.MatchString(sig) { + return wire.CommandResult{}, fmt.Errorf("invalid signal %q: a name such as TERM or KILL, without the SIG prefix", sig) + } + pidOut, _, code, err := sshx.Run(ctx, v, false, []string{"cat", path.Join(j.Dir, "pid")}, nil) + if err != nil { + return wire.CommandResult{}, err + } + if code != 0 { + return wire.CommandResult{}, fmt.Errorf("job %s has no pid on the guest; a reboot clears it", j.ID) + } + return runToResult(ctx, v, false, []string{"kill", "-" + sig, strings.TrimSpace(string(pidOut))}) + }) + + register(server, "list_jobs", classRead, + "List the background jobs this server started in a VM: id, argv, guest user, working directory and start time. It reads the host's own registry, so it answers without ssh and works on a stopped VM. It needs agent_access exec. Read-only.", + func(ctx context.Context, in vmIn) (wire.JobList, error) { + v, err := guestVM(in.VM, LevelExec) + if err != nil { + return wire.JobList{}, err + } + jobs, err := loadJobs(v.Name) + if err != nil { + return wire.JobList{}, err + } + out := wire.JobList{Jobs: []wire.Job{}} + for _, j := range jobs { + out.Jobs = append(out.Jobs, wire.Job{ + JobID: j.ID, Argv: j.Argv, User: j.User, CWD: j.CWD, Started: j.Started, + }) + } + slices.SortFunc(out.Jobs, func(a, b wire.Job) int { return strings.Compare(a.JobID, b.JobID) }) + return out, nil + }) +} + +func findJob(vm, id string) (job, error) { + jid, err := checkJobID(id) + if err != nil { + return job{}, err + } + jobs, err := loadJobs(vm) + if err != nil { + return job{}, err + } + j, ok := jobs[jid] + if !ok { + return job{}, fmt.Errorf("no job %q on vm %q", jid, vm) + } + return j, nil } From e6ef5c94d0650b67aa92768a9f38f8ccc171a795 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 15:52:12 +0300 Subject: [PATCH 25/67] fix(config): map legacy allow_exec to agent_access on load Signed-off-by: NovusEdge --- internal/config/config.go | 18 +++++++++++- internal/config/config_test.go | 51 ++++++++++++++++++---------------- internal/tomlx/tomlx.go | 12 ++++++++ 3 files changed, 56 insertions(+), 25 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 942afa87..6518eccd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -257,17 +257,33 @@ func (v *VM) Save() error { // Load reads one VM by name. func Load(name string) (*VM, error) { dir := filepath.Join(Root(), name) + path := filepath.Join(dir, "vm.toml") // Absent allow_exec means true; the seed survives the decode, a written // false overrides it. v := &VM{AllowExec: true} - if err := tomlx.Decode(filepath.Join(dir, "vm.toml"), v, tomlx.Warn(UnknownKeyWriter)); err != nil { + if err := tomlx.Decode(path, v, tomlx.Warn(UnknownKeyWriter)); err != nil { return nil, err } v.Dir = dir v.Share = Expand(v.Share) + if v.AgentAccess == "" { + v.AgentAccess = legacyAgentAccess(path, v.AllowExec) + } return v, nil } +// legacyAgentAccess maps a vm.toml written before agent_access existed to a +// level. v.AllowExec is already seeded true for an absent key (the comment +// above Load), so it alone cannot tell that case apart from an explicit +// `allow_exec = true`; only the latter earns "exec". Everything else, +// including an absent key, is "manage". +func legacyAgentAccess(path string, allowExec bool) string { + if defined, err := tomlx.Defined(path, "allow_exec"); err == nil && defined && allowExec { + return "exec" + } + return "manage" +} + // List returns every VM in the data root, sorted by name. func List() ([]*VM, error) { entries, err := os.ReadDir(Root()) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f4a41257..20fe95e4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -72,19 +72,20 @@ func TestSaveLoadRoundtrip(t *testing.T) { } want := &VM{ - Name: "alpine-live", - Mode: "live", - ISO: "isos/alpine-standard-3.24.1-x86_64.iso", - RAM: 4096, - CPUs: 4, - Disk: "8G", - Installed: true, - Share: "/home/someone/vms", - SSHPort: 2201, - Recipes: []string{"xfce"}, - Forwards: []PortForward{{HostPort: 8080, GuestPort: 80}}, - Display: "window", - Dir: filepath.Join(Root(), "alpine-live"), + Name: "alpine-live", + Mode: "live", + ISO: "isos/alpine-standard-3.24.1-x86_64.iso", + RAM: 4096, + CPUs: 4, + Disk: "8G", + Installed: true, + Share: "/home/someone/vms", + SSHPort: 2201, + Recipes: []string{"xfce"}, + Forwards: []PortForward{{HostPort: 8080, GuestPort: 80}}, + Display: "window", + AgentAccess: "manage", + Dir: filepath.Join(Root(), "alpine-live"), } if err := want.Save(); err != nil { t.Fatal(err) @@ -130,16 +131,17 @@ func TestSaveLoadRoundtripCloudVM(t *testing.T) { } want := &VM{ - Name: "ubuntu-cloud", - Mode: "cloud", - OS: "ubuntu-24.04", - RAM: 2048, - CPUs: 2, - SSHPort: 2202, - Backend: "cloudinit", - Base: "/home/someone/.stoat/base/ubuntu-24.04.qcow2", - SSHUser: "ubuntu", - Dir: filepath.Join(Root(), "ubuntu-cloud"), + Name: "ubuntu-cloud", + Mode: "cloud", + OS: "ubuntu-24.04", + RAM: 2048, + CPUs: 2, + SSHPort: 2202, + Backend: "cloudinit", + Base: "/home/someone/.stoat/base/ubuntu-24.04.qcow2", + SSHUser: "ubuntu", + AgentAccess: "manage", + Dir: filepath.Join(Root(), "ubuntu-cloud"), } if err := want.Save(); err != nil { t.Fatal(err) @@ -170,7 +172,8 @@ func TestSaveLoadRoundtripApplied(t *testing.T) { Applied: map[string]AppliedRecipe{ "xfce": {Version: "1.2.3", At: at}, }, - Dir: filepath.Join(Root(), "alpine-live"), + AgentAccess: "manage", + Dir: filepath.Join(Root(), "alpine-live"), } if err := want.Save(); err != nil { t.Fatal(err) diff --git a/internal/tomlx/tomlx.go b/internal/tomlx/tomlx.go index c0fcc443..9d02b325 100644 --- a/internal/tomlx/tomlx.go +++ b/internal/tomlx/tomlx.go @@ -65,6 +65,18 @@ func Decode(path string, v any, opts ...Option) error { return nil } +// Defined reports whether every key in keys (a dotted path, e.g. "a", "b") +// is present in path's TOML, independent of any Go struct. config.Load uses +// it to tell an explicit legacy key from a field a decode seeded itself. +func Defined(path string, keys ...string) (bool, error) { + var scratch map[string]any + md, err := toml.DecodeFile(path, &scratch) + if err != nil { + return false, fmt.Errorf("%s: %w", path, err) + } + return md.IsDefined(keys...), nil +} + // Encode is the single TOML writer for files owned by stoat. func Encode(path string, v any) error { var buf bytes.Buffer From 96af98dbc0911e7255de618a205ff2af9e403e5c Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 15:56:31 +0300 Subject: [PATCH 26/67] fix(testutil): use POSIX idiom for fake ssh's last arg Signed-off-by: NovusEdge --- internal/testutil/fakessh.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/testutil/fakessh.go b/internal/testutil/fakessh.go index 46d26891..4a84ee32 100644 --- a/internal/testutil/fakessh.go +++ b/internal/testutil/fakessh.go @@ -43,7 +43,10 @@ func FakeSSH(t *testing.T, script string) *SSHCalls { logPath := filepath.Join(dir, "calls.log") body := "#!/bin/sh\n" + "printf '%s\\n' \"$*\" >> " + logPath + "\n" + - "remote=\"${@: -1}\"\n" + + // "${@: -1}" is bash-only; dash (Ubuntu's /bin/sh) rejects it with + // "Bad substitution". This loop is the POSIX way to read the last + // positional argument. + "for remote do :; done\n" + "eval \"set -- $remote\"\n" + script + "\n" binPath := filepath.Join(dir, "ssh") From e831ba9fa95e83fe3742ed2336c9a9739e14c1da Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 15:57:24 +0300 Subject: [PATCH 27/67] fix(guest): set alpine's log_path for tail_log's fallback Signed-off-by: NovusEdge --- docs/reference/guest.md | 5 +++-- docs/reference/samples/guest.toml | 1 + internal/guest/bundled/alpine.toml | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/reference/guest.md b/docs/reference/guest.md index 111d7e97..9b608316 100644 --- a/docs/reference/guest.md +++ b/docs/reference/guest.md @@ -19,6 +19,7 @@ capabilities = ["pkg"] # feeds recipe.toml's `requires`; the lo aliases = ["bsd"] # extra keys a recipe's [scripts] map may use for this OS filename_hints = ["FreeBSD-"] # recognise this OS in a BYO image filename seed_packages = ["sudo"] # packages the cloud-init seed assumes but the image may lack +log_path = "/var/log/messages" # tail_log's fallback when a unit and a path are both omitted; optional [pkg] setup = "pkg update" # prelude's stoat_pkg_setup; empty means no refresh needed @@ -41,8 +42,8 @@ skip_9p = true ## Field rules - Required: every top-level scalar and list except `installer`, `aliases`, - `filename_hints`; every `[pkg]` and `[svc]` key except `scaffold_setup`. A - missing one is `guest.toml: : missing `. + `filename_hints`, `log_path`; every `[pkg]` and `[svc]` key except + `scaffold_setup`. A missing one is `guest.toml: : missing `. - Unknown keys are an error: `: unknown key ""`. - `schema` must be present and equal to 1. - The loader appends `init` to `capabilities`. A file whose `capabilities` diff --git a/docs/reference/samples/guest.toml b/docs/reference/samples/guest.toml index 8259d7bc..228af7ab 100644 --- a/docs/reference/samples/guest.toml +++ b/docs/reference/samples/guest.toml @@ -12,6 +12,7 @@ capabilities = ["apk"] # string[]; default []; guest author writes aliases = [] # string[]; default []; guest author writes alternate script keys. filename_hints = ["alpine"] # string[]; default []; guest author writes BYO-image filename hints. seed_packages = ["sudo"] # string[]; default []; guest author writes cloud-init seed packages. +log_path = "/var/log/messages" # string; default empty; tail_log's fallback when a systemd unit has no journal. [pkg] setup = "apk update" # string; default empty; guest author writes the package-index prelude. diff --git a/internal/guest/bundled/alpine.toml b/internal/guest/bundled/alpine.toml index 7c0816f2..5fd01ea4 100644 --- a/internal/guest/bundled/alpine.toml +++ b/internal/guest/bundled/alpine.toml @@ -12,6 +12,7 @@ capabilities = ["apk"] aliases = [] filename_hints = ["alpine"] seed_packages = ["sudo"] +log_path = "/var/log/messages" [pkg] setup = "apk update" From 709ba46acdef9be7120c5a568dff45e17b3954e2 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 15:58:29 +0300 Subject: [PATCH 28/67] feat(wire): add agent_access to the VM DTO Signed-off-by: NovusEdge --- internal/cli/wire/dto.go | 35 ++++++++++++++++++++--------------- internal/core/vm.go | 6 ++++++ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/internal/cli/wire/dto.go b/internal/cli/wire/dto.go index ae6e4122..2dcfbfc9 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -97,6 +97,10 @@ type VM struct { Installed bool `json:"installed"` Forwards []PortForward `json:"forwards"` AllowExec bool `json:"allow_exec"` + // AgentAccess mirrors config.VM.AgentAccess: none, observe, manage or + // exec. Additive alongside AllowExec, which stays for existing readers; + // see internal/mcpsrv's access levels for who enforces it. + AgentAccess string `json:"agent_access,omitempty"` // Display is "window" or "vnc" ("" on a broken VM, like every other field // a broken vm.toml cannot supply). Emitted rather than left for a // consumer to derive from mode and installed: a host with no graphical @@ -178,21 +182,22 @@ func nonNilMap(m map[string]string) map[string]string { // answer it differently depending on the machine it ran on. func FromVM(v core.VM, graphical bool) VM { return VM{ - Name: v.Name, - OS: v.OS, - Mode: v.Mode, - Backend: v.Backend, - State: string(v.State), - CPUs: v.CPUs, - RAMMB: v.RAM, - Disk: v.Disk, - Share: v.Share, - Recipes: nonNil(v.Recipes), - SSHPort: v.SSHPort, - SSHUser: v.SSHUser, - Installed: v.Installed, - Forwards: FromPortForwards(v.Forwards), - AllowExec: v.AllowExec, + Name: v.Name, + OS: v.OS, + Mode: v.Mode, + Backend: v.Backend, + State: string(v.State), + CPUs: v.CPUs, + RAMMB: v.RAM, + Disk: v.Disk, + Share: v.Share, + Recipes: nonNil(v.Recipes), + SSHPort: v.SSHPort, + SSHUser: v.SSHUser, + Installed: v.Installed, + Forwards: FromPortForwards(v.Forwards), + AllowExec: v.AllowExec, + AgentAccess: v.AgentAccess, // DisplayKind, not DisplayFor: this constructor must not go looking at // PATH, which DisplayFor does once per VM it is handed. Display: core.DisplayKind(v, graphical), diff --git a/internal/core/vm.go b/internal/core/vm.go index baf9be0c..f53c1b4f 100644 --- a/internal/core/vm.go +++ b/internal/core/vm.go @@ -182,6 +182,11 @@ type VM struct { // Spec.AllowExec's doc comment for who enforces it. AllowExec bool + // AgentAccess is vm.toml's agent_access, already resolved by config.Load + // so a pre-existing vm.toml with no agent_access key reads as "manage" + // (or "exec", mapped from a legacy allow_exec = true) here too. + AgentAccess string + Paths Paths // Error is populated only when State is StateBroken, and holds @@ -271,6 +276,7 @@ func fromConfigUnchecked(v *config.VM) VM { Forwards: v.Forwards, Installed: v.Installed, AllowExec: v.AllowExec, + AgentAccess: v.AgentAccess, Paths: Paths{ Dir: v.Dir, Disk: v.DiskPath(), From d90f3b8b3b44cc5f84632d01ba9d86bf1647f184 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:00:50 +0300 Subject: [PATCH 29/67] feat(cli): add --agent-access to create and update Signed-off-by: NovusEdge --- internal/cli/grammar.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/cli/grammar.go b/internal/cli/grammar.go index 712660fe..e8fe7d29 100644 --- a/internal/cli/grammar.go +++ b/internal/cli/grammar.go @@ -120,6 +120,11 @@ type createCmd struct { // value) sets true, matching every other bool flag. Verified by running // `stoat create --help` and `stoat create x --image y --allow-exec=false`. AllowExec bool `default:"true" help:"allow exec/copy_to/copy_from on this VM (enforced by the MCP server, not stoat itself)"` + // AgentAccess is the successor to AllowExec: a level rather than a bool. + // Both are read; toArgs keeps AllowExec's own behaviour unchanged and + // applies this one independently, so an old script's --allow-exec still + // does exactly what it always did. + AgentAccess string `name:"agent-access" default:"manage" enum:"none,observe,manage,exec" help:"what an MCP agent may do in this VM"` } // updateCmd's pointers are the point: see the type comment on grammar. @@ -134,6 +139,9 @@ type updateCmd struct { Set []string `help:"set a recipe param: .="` Unset []string `help:"clear a recipe param back to its manifest default"` Secret []string `help:"set a secret recipe param"` + // AgentAccess is unrestricted here: only the MCP update tool may lower, + // never raise, a VM's level. The CLI and TUI may do either. + AgentAccess *string `name:"agent-access" enum:"none,observe,manage,exec" help:"change what an MCP agent may do in this VM"` } type cloneCmd struct { @@ -333,7 +341,8 @@ func (g *grammar) toArgs(path string) (*Args, error) { Name: c.Name, Image: c.Image, OS: c.OS, Backend: c.Backend, Mode: c.Mode, RAM: c.RAM, CPUs: c.CPUs, Disk: c.Disk, Share: c.Share, ConsolePassword: c.ConsolePassword, Recipes: trimList(c.Recipes), - AllowExec: &allowExec, + AllowExec: &allowExec, + AgentAccess: c.AgentAccess, } edits, err := parseParamFlags(c.Set, nil, c.Secret) if err != nil { @@ -346,7 +355,7 @@ func (g *grammar) toArgs(path string) (*Args, error) { a.VM = u.VM a.Patch = core.Patch{ RAM: u.RAM, CPUs: u.CPUs, SSHPort: u.SSHPort, - Disk: u.Disk, Share: u.Share, + Disk: u.Disk, Share: u.Share, AgentAccess: u.AgentAccess, } if u.Recipes != nil { // The POINTER carries "was it given"; the slice it points at @@ -362,7 +371,7 @@ func (g *grammar) toArgs(path string) (*Args, error) { name string set bool }{ - {"cpus", u.CPUs != nil}, {"disk", u.Disk != nil}, {"ram", u.RAM != nil}, + {"agent_access", u.AgentAccess != nil}, {"cpus", u.CPUs != nil}, {"disk", u.Disk != nil}, {"ram", u.RAM != nil}, {"recipes", u.Recipes != nil}, {"share", u.Share != nil}, {"ssh_port", u.SSHPort != nil}, } { if f.set { From f87bd106d06c0d625c3cbbbe259019d514d99cae Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:01:20 +0300 Subject: [PATCH 30/67] fix(mcpsrv): route list_dir and ps through wire.NonNil Signed-off-by: NovusEdge --- internal/mcpsrv/tools_guest.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/mcpsrv/tools_guest.go b/internal/mcpsrv/tools_guest.go index 36a9df9f..37bd494f 100644 --- a/internal/mcpsrv/tools_guest.go +++ b/internal/mcpsrv/tools_guest.go @@ -187,7 +187,7 @@ func (s *srv) registerGuestRead(server *mcp.Server) { return wire.DirListing{}, err } return wire.DirListing{ - Entries: parseStat(statOut), + Entries: wire.NonNil(parseStat(statOut)), Truncated: len(names) == maxDirEntries, }, nil }) @@ -241,7 +241,7 @@ func (s *srv) registerGuestRead(server *mcp.Server) { } rows := parsePS(out) list := wire.ProcessList{Truncated: len(rows) > maxPSRows} - list.Processes = rows[:min(len(rows), maxPSRows)] + list.Processes = wire.NonNil(rows[:min(len(rows), maxPSRows)]) return list, nil }) From 95ef3b93703b49965973c3ec0a34e0e6d242e161 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:14:29 +0300 Subject: [PATCH 31/67] test(sshx): read alpine's escalate from the guest file Signed-off-by: NovusEdge --- internal/sshx/run_test.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/sshx/run_test.go b/internal/sshx/run_test.go index 3895edab..51b83b5f 100644 --- a/internal/sshx/run_test.go +++ b/internal/sshx/run_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/guest" "github.com/novusedge/stoat/internal/sshx" "github.com/novusedge/stoat/internal/testutil" ) @@ -41,20 +42,33 @@ func TestRunReportsANonZeroExitAsData(t *testing.T) { } } +// alpineEscalate is the first word of alpine's escalate argv, read from the +// guest definition rather than spelled here, so a guest file that switches +// its escalate command does not silently pass this test. +func alpineEscalate(t *testing.T) string { + t.Helper() + o, ok := guest.Lookup("alpine") + if !ok || len(o.Escalate) == 0 { + t.Fatal("alpine's guest definition has no escalate argv") + } + return o.Escalate[0] +} + func TestRunEscalatesOnlyWhenAsked(t *testing.T) { calls := testutil.FakeSSH(t, `true`) v := &config.VM{Name: "work", SSHPort: 2222, SSHUser: "stoat", OS: "alpine"} + esc := alpineEscalate(t) if _, _, _, err := sshx.Run(context.Background(), v, false, []string{"id"}, nil); err != nil { t.Fatal(err) } - if strings.Contains(calls.Calls()[0].Remote, "doas") || strings.Contains(calls.Calls()[0].Remote, "sudo") { + if strings.Contains(calls.Calls()[0].Remote, esc) { t.Fatalf("a tool escalated on its own: %q", calls.Calls()[0].Remote) } if _, _, _, err := sshx.Run(context.Background(), v, true, []string{"id"}, nil); err != nil { t.Fatal(err) } - if !strings.Contains(calls.Calls()[1].Remote, "doas") { + if !strings.Contains(calls.Calls()[1].Remote, esc) { t.Fatalf("root=true did not apply alpine's escalate: %q", calls.Calls()[1].Remote) } } @@ -65,7 +79,7 @@ func TestRunDoesNotEscalateForRoot(t *testing.T) { if _, _, _, err := sshx.Run(context.Background(), v, true, []string{"id"}, nil); err != nil { t.Fatal(err) } - if strings.Contains(calls.Calls()[0].Remote, "doas") { + if strings.Contains(calls.Calls()[0].Remote, alpineEscalate(t)) { t.Fatalf("escalated for a root ssh user: %q", calls.Calls()[0].Remote) } } From 6200e867855574c129e3f360b8fe17aab0525f9d Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:14:33 +0300 Subject: [PATCH 32/67] test(mcp): give the fixture VM a real guest os Signed-off-by: NovusEdge --- internal/mcpsrv/access_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/mcpsrv/access_test.go b/internal/mcpsrv/access_test.go index dd06a004..291bdf98 100644 --- a/internal/mcpsrv/access_test.go +++ b/internal/mcpsrv/access_test.go @@ -10,13 +10,15 @@ import ( ) // writeVM creates a VM directory whose vm.toml declares one access level. +// The os key names a real guest definition: pkg_install and useradd read the +// guest file for the distro's own verbs and refuse a VM whose os is unknown. func writeVM(t *testing.T, name, level string) { t.Helper() dir := filepath.Join(config.Root(), name) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } - body := "name = \"" + name + "\"\nagent_access = \"" + level + "\"\n" + body := "name = \"" + name + "\"\nos = \"alpine\"\nagent_access = \"" + level + "\"\n" if err := os.WriteFile(filepath.Join(dir, "vm.toml"), []byte(body), 0o644); err != nil { t.Fatal(err) } From 5f5f4994558f0a39b91c3deca17482d6e2228645 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:28:08 +0300 Subject: [PATCH 33/67] test(mcp): drive access checks through callTool Signed-off-by: NovusEdge --- internal/mcpsrv/access_test.go | 74 ++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/internal/mcpsrv/access_test.go b/internal/mcpsrv/access_test.go index 291bdf98..081f909e 100644 --- a/internal/mcpsrv/access_test.go +++ b/internal/mcpsrv/access_test.go @@ -1,12 +1,15 @@ package mcpsrv import ( + "encoding/json" "os" "path/filepath" "strings" "testing" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/testutil" ) // writeVM creates a VM directory whose vm.toml declares one access level. @@ -24,27 +27,84 @@ func writeVM(t *testing.T, name, level string) { } } -// TestRequireAccess asserts every cell of the access table: each -// guest-touching tool against each of the four levels. +// accessFakeGuest answers every guest command a tool in the table can send: +// enough exit-0 output for read_file, list_dir, stat and ps to parse +// cleanly, and a bare success for everything else. Access denial is decided +// before any of this runs, so this is only exercised at an allowed level. +const accessFakeGuest = `cat > /dev/null 2>&1 +case "$1" in + stat) echo 5;; + *) exit 0;; +esac` + +// accessArgsFor builds the minimal valid input for one table tool, so a call +// reaches the handler's own requireAccess line instead of failing input +// validation first. The content of a field that survives the access gate +// (a bogus job id, a host path outside the sandbox) does not matter: those +// calls are allowed to fail for a reason that is not access. +func accessArgsFor(tool, vm string) map[string]any { + args := map[string]any{"vm": vm} + switch tool { + case "read_file", "list_dir", "stat": + args["path"] = "/etc/hostname" + case "svc_status": + args["name"] = "sshd" + case "write_file": + args["path"], args["content"] = "/tmp/x", "x" + case "copy_to", "copy_from": + args["local"], args["remote"] = "/etc/passwd", "/tmp/x" + case "pkg_install": + args["packages"] = []string{"curl"} + case "svc": + args["name"], args["action"] = "sshd", "restart" + case "useradd": + args["name"] = "bob" + case "exec", "exec_bg": + args["argv"] = []string{"true"} + case "job_status", "job_output", "job_kill": + args["job_id"] = "j-00000000" + } + return args +} + +// isAccessRefusal reports whether res was refused by requireAccess rather +// than by anything downstream. requireAccess's message is the only place +// "agent_access =" appears in a tool's output. +func isAccessRefusal(t *testing.T, res *mcp.CallToolResult) bool { + t.Helper() + raw, err := json.Marshal(res.Content) + if err != nil { + t.Fatal(err) + } + return strings.Contains(string(raw), "agent_access =") +} + +// TestRequireAccess drives every guest-touching tool through callTool, the +// path a real client takes, at each of the four levels. Calling +// requireAccess directly would only check the table's own expectation +// against itself; this instead exercises the Level literal each handler +// passes to requireAccess, so a handler gated at the wrong level fails here. func TestRequireAccess(t *testing.T) { t.Setenv("STOAT_HOME", t.TempDir()) levels := []Level{LevelNone, LevelObserve, LevelManage, LevelExec} for _, level := range levels { writeVM(t, level.String(), level.String()) } + testutil.FakeSSH(t, accessFakeGuest) for _, spec := range toolTable { if spec.Access == LevelNone { continue // A host-side tool is not gated. } for _, have := range levels { t.Run(spec.Name+"/"+have.String(), func(t *testing.T) { - err := requireAccess(have.String(), spec.Access) + res := callTool(t, spec.Name, accessArgsFor(spec.Name, have.String())) allowed := have.rank() >= spec.Access.rank() - if allowed && err != nil { - t.Fatalf("%s at %s was refused: %v", spec.Name, have, err) + refused := res.IsError && isAccessRefusal(t, res) + if allowed && refused { + t.Fatalf("%s at %s was refused by access, needs %s", spec.Name, have, spec.Access) } - if !allowed && err == nil { - t.Fatalf("%s at %s was allowed, needs %s", spec.Name, have, spec.Access) + if !allowed && !refused { + t.Fatalf("%s at %s was not refused by access: %+v", spec.Name, have, res.Content) } }) } From deb1089e402519bb7f1b3a802a4731b315e25df4 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:28:12 +0300 Subject: [PATCH 34/67] test(sshx): pin exact argv for non-escalating root Signed-off-by: NovusEdge --- internal/sshx/run_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/sshx/run_test.go b/internal/sshx/run_test.go index 51b83b5f..6db10a03 100644 --- a/internal/sshx/run_test.go +++ b/internal/sshx/run_test.go @@ -79,8 +79,14 @@ func TestRunDoesNotEscalateForRoot(t *testing.T) { if _, _, _, err := sshx.Run(context.Background(), v, true, []string{"id"}, nil); err != nil { t.Fatal(err) } - if strings.Contains(calls.Calls()[0].Remote, alpineEscalate(t)) { - t.Fatalf("escalated for a root ssh user: %q", calls.Calls()[0].Remote) + // The fake ssh logs its whole argv space joined, so a suffix check on + // "'id'" alone would also pass an escalated "'sudo' '-n' 'id'": both + // strings end in "'id'". Comparing the whole line against Args with the + // bare quoted argv is the only check that catches a prefix Run should + // not have added. + want := strings.Join(sshx.Args(v, sshx.Quote([]string{"id"})), " ") + if got := calls.Calls()[0].Remote; got != want { + t.Fatalf("ssh argv = %q, want %q (root must not escalate)", got, want) } } From 1d8700c345b3c4f73397d5b403905f16b29f2b75 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:28:16 +0300 Subject: [PATCH 35/67] test(mcp): pin exec env names and stopped-VM refusals Signed-off-by: NovusEdge --- internal/mcpsrv/tools_exec_test.go | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/internal/mcpsrv/tools_exec_test.go b/internal/mcpsrv/tools_exec_test.go index 8adeef98..57100224 100644 --- a/internal/mcpsrv/tools_exec_test.go +++ b/internal/mcpsrv/tools_exec_test.go @@ -29,6 +29,49 @@ func TestExecClampsTheTimeout(t *testing.T) { } } +func TestExecEnvNameWithUnderscoreReachesArgv(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "exec") + calls := testutil.FakeSSH(t, `true`) + res := callTool(t, "exec", map[string]any{ + "vm": "dev", "argv": []string{"id"}, "env": map[string]string{"LC_ALL": "C"}, + }) + if res.IsError { + t.Fatalf("exec failed: %+v", res.Content) + } + if !strings.Contains(calls.Calls()[0].Remote, `'LC_ALL=C'`) { + t.Fatalf("env var did not reach argv: %q", calls.Calls()[0].Remote) + } +} + +func TestWriteFileRefusesOnAStoppedVM(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "manage") + testutil.FakeSSH(t, `exit 1`) + res := callTool(t, "write_file", map[string]any{"vm": "dev", "path": "/tmp/x", "content": "x"}) + if !res.IsError { + t.Fatal("write_file ran on a stopped VM") + } + raw, _ := json.Marshal(res.Content) + if !strings.Contains(string(raw), "not running") { + t.Fatalf("refusal did not report not_running: %s", raw) + } +} + +func TestExecBgRefusesOnAStoppedVM(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "exec") + testutil.FakeSSH(t, `exit 1`) + res := callTool(t, "exec_bg", map[string]any{"vm": "dev", "argv": []string{"true"}}) + if !res.IsError { + t.Fatal("exec_bg ran on a stopped VM") + } + raw, _ := json.Marshal(res.Content) + if !strings.Contains(string(raw), "not running") { + t.Fatalf("refusal did not report not_running: %s", raw) + } +} + func TestExecBgThenStatusThenOutputThenKill(t *testing.T) { t.Setenv("STOAT_HOME", t.TempDir()) writeVM(t, "dev", "exec") From 54d5642f96c94dc023e2c7cbe642d12d1fe6c243 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:42:04 +0300 Subject: [PATCH 36/67] fix(cli): make --allow-exec alias --agent-access again Signed-off-by: NovusEdge --- internal/cli/grammar.go | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/internal/cli/grammar.go b/internal/cli/grammar.go index e8fe7d29..52592a29 100644 --- a/internal/cli/grammar.go +++ b/internal/cli/grammar.go @@ -113,17 +113,11 @@ type createCmd struct { Recipes []string `help:"recipe names to record on the VM"` Set []string `help:"set a recipe param: .="` Secret []string `help:"set a secret recipe param"` - // default:"true" is load-bearing, not decoration: without it kong treats - // an absent --allow-exec the same as an explicit --allow-exec=false, - // since a bare bool flag's zero value is false. With it, the flag must - // be passed AND given =false to turn exec off; --allow-exec alone (no - // value) sets true, matching every other bool flag. Verified by running - // `stoat create --help` and `stoat create x --image y --allow-exec=false`. - AllowExec bool `default:"true" help:"allow exec/copy_to/copy_from on this VM (enforced by the MCP server, not stoat itself)"` - // AgentAccess is the successor to AllowExec: a level rather than a bool. - // Both are read; toArgs keeps AllowExec's own behaviour unchanged and - // applies this one independently, so an old script's --allow-exec still - // does exactly what it always did. + // AllowExec is a hidden alias of --agent-access: a nil pointer means the + // flag was not given, so toArgs can tell that apart from --agent-access's + // own default, the same pointer trick updateCmd's fields use (see + // grammar's type comment). true maps to the exec level, false to manage. + AllowExec *bool `name:"allow-exec" hidden:"" help:"alias of --agent-access exec (true) or manage (false)"` AgentAccess string `name:"agent-access" default:"manage" enum:"none,observe,manage,exec" help:"what an MCP agent may do in this VM"` } @@ -333,16 +327,26 @@ func (g *grammar) toArgs(path string) (*Args, error) { case "create": c := g.Create a.VM = c.Name - // c.AllowExec is never ambiguous here: kong's default:"true" means - // the flag is always either true or false, never absent, so a fresh - // pointer to it is exactly the "explicitly given" value Spec wants. - allowExec := c.AllowExec + // Absent --allow-exec keeps Spec.AllowExec's own default of true + // (see Spec.AllowExec's doc comment); given, it also sets the + // access level, per --allow-exec's alias contract. + allowExec := true + if c.AllowExec != nil { + allowExec = *c.AllowExec + } + access := c.AgentAccess + if c.AllowExec != nil { + access = "manage" + if *c.AllowExec { + access = "exec" + } + } a.Spec = core.Spec{ Name: c.Name, Image: c.Image, OS: c.OS, Backend: c.Backend, Mode: c.Mode, RAM: c.RAM, CPUs: c.CPUs, Disk: c.Disk, Share: c.Share, ConsolePassword: c.ConsolePassword, Recipes: trimList(c.Recipes), AllowExec: &allowExec, - AgentAccess: c.AgentAccess, + AgentAccess: access, } edits, err := parseParamFlags(c.Set, nil, c.Secret) if err != nil { From a14702e6a9d5e0344347b137b135b7d65f6c5a3f Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:43:49 +0300 Subject: [PATCH 37/67] fix(mcpsrv): report not_running for write_file and exec_bg Signed-off-by: NovusEdge --- internal/mcpsrv/guards.go | 11 +++++++++++ internal/mcpsrv/tools_exec.go | 3 +++ internal/mcpsrv/tools_guest.go | 3 +++ 3 files changed, 17 insertions(+) diff --git a/internal/mcpsrv/guards.go b/internal/mcpsrv/guards.go index 85a8e89d..5fd6a322 100644 --- a/internal/mcpsrv/guards.go +++ b/internal/mcpsrv/guards.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/qemu" ) // A VM name becomes a directory name under the data root, so the pattern is @@ -214,3 +215,13 @@ func checkSvcName(name string) (string, error) { } return name, nil } + +// requireRunning gates a tool whose description promises it refuses on a +// stopped VM. Without it, sshx.Run against a stopped VM's forwarded port +// surfaces ssh's own connection-refused exit rather than this error. +func requireRunning(v *config.VM) error { + if !qemu.Running(v) { + return fmt.Errorf("%w: %s", qemu.ErrNotRunning, v.Name) + } + return nil +} diff --git a/internal/mcpsrv/tools_exec.go b/internal/mcpsrv/tools_exec.go index 02da468f..2ff8cbd6 100644 --- a/internal/mcpsrv/tools_exec.go +++ b/internal/mcpsrv/tools_exec.go @@ -135,6 +135,9 @@ func (s *srv) registerExec(server *mcp.Server) { if _, _, code, err := sshx.Run(ctx, v, false, []string{"mkdir", "-p", dir}, nil); err != nil { return wire.JobStarted{}, err } else if code != 0 { + if runErr := requireRunning(v); runErr != nil { + return wire.JobStarted{}, runErr + } return wire.JobStarted{}, fmt.Errorf("%s: cannot create %s", v.Name, dir) } // The runner is a constant shell body. The job directory is $1 diff --git a/internal/mcpsrv/tools_guest.go b/internal/mcpsrv/tools_guest.go index 37bd494f..d382f6b5 100644 --- a/internal/mcpsrv/tools_guest.go +++ b/internal/mcpsrv/tools_guest.go @@ -337,6 +337,9 @@ func (s *srv) registerGuestWrite(server *mcp.Server) { return wire.CommandResult{}, err } if code != 0 { + if runErr := requireRunning(v); runErr != nil { + return wire.CommandResult{}, runErr + } return wire.CommandResult{}, fmt.Errorf("%s: %s", v.Name, strings.TrimSpace(string(errb))) } return runToResult(ctx, v, true, []string{"chmod", mode, path}) From 4d3af512cb1d3a4740cb14d7347f4a93e18c9352 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:45:10 +0300 Subject: [PATCH 38/67] fix(mcpsrv): guard env var names, not svc names Signed-off-by: NovusEdge --- internal/mcpsrv/guards.go | 12 ++++++++++++ internal/mcpsrv/tools_exec.go | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/mcpsrv/guards.go b/internal/mcpsrv/guards.go index 5fd6a322..f765cf18 100644 --- a/internal/mcpsrv/guards.go +++ b/internal/mcpsrv/guards.go @@ -28,6 +28,7 @@ var ( gitRefRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`) paramNameRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) svcNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]*$`) + envNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) ) // forbiddenPatchKeys are never accepted as tool input at any level. share @@ -216,6 +217,17 @@ func checkSvcName(name string) (string, error) { return name, nil } +// checkEnvName bounds an exec/exec_bg environment variable name to the POSIX +// shell identifier grammar. checkSvcName is the wrong guard here: it accepts +// '.', '@' and a leading digit, none of which a guest shell reads as part of +// a variable name. +func checkEnvName(name string) (string, error) { + if !envNameRE.MatchString(name) { + return "", fmt.Errorf("invalid env name %q: must match %s", name, envNameRE) + } + return name, nil +} + // requireRunning gates a tool whose description promises it refuses on a // stopped VM. Without it, sshx.Run against a stopped VM's forwarded port // surfaces ssh's own connection-refused exit rather than this error. diff --git a/internal/mcpsrv/tools_exec.go b/internal/mcpsrv/tools_exec.go index 2ff8cbd6..fb137fc9 100644 --- a/internal/mcpsrv/tools_exec.go +++ b/internal/mcpsrv/tools_exec.go @@ -69,8 +69,8 @@ func envArgv(env map[string]string, cwd string, argv []string) ([]string, error) if len(env) > 0 { pre := []string{"env"} for k, v := range env { - if _, err := checkSvcName(k); err != nil { - return nil, fmt.Errorf("invalid env name %q", k) + if _, err := checkEnvName(k); err != nil { + return nil, err } pre = append(pre, k+"="+v) } From bdf97f5d342f0cfaeab5623f1f85712fbb456d17 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:46:31 +0300 Subject: [PATCH 39/67] fix(mcpsrv): error on a guest with no svc verb Signed-off-by: NovusEdge --- internal/mcpsrv/tools_guest.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/internal/mcpsrv/tools_guest.go b/internal/mcpsrv/tools_guest.go index d382f6b5..65258d81 100644 --- a/internal/mcpsrv/tools_guest.go +++ b/internal/mcpsrv/tools_guest.go @@ -424,16 +424,14 @@ func (s *srv) registerGuestWrite(server *mcp.Server) { // svcArgv renders the guest file's [svc] template and passes the service // name as $1. The template is the constant and the name is a positional // argument, so no tool input reaches the guest shell as syntax. -// -// A guest stoat does not know still gets a template: systemctl is the init -// system most guests run, the same "assume the most common answer" rule -// sshx.escalate applies for an unrecognized OS's privilege escalation. func svcArgv(v *config.VM, action, name string) ([]string, error) { - tmpl := "systemctl " + action + " {name}" - if os, ok := guest.Lookup(v.OS); ok { - if t := os.Svc.Get(action); t != "" { - tmpl = t - } + os, ok := guest.Lookup(v.OS) + if !ok { + return nil, fmt.Errorf("unknown guest %q; run stoat guest ls", v.OS) + } + tmpl := os.Svc.Get(action) + if tmpl == "" { + return nil, fmt.Errorf("guest %q declares no svc.%s", v.OS, action) } return []string{"sh", "-c", renderVerb(tmpl), "stoat_svc", name}, nil } From 434fb2867a6c0c036643e8551cd09ab7454defc9 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:47:58 +0300 Subject: [PATCH 40/67] fix(config): stop re-reading vm.toml for the legacy check Signed-off-by: NovusEdge --- internal/config/config.go | 14 +++++++----- internal/tomlx/tomlx.go | 47 +++++++++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 6518eccd..71fa3012 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -261,13 +261,14 @@ func Load(name string) (*VM, error) { // Absent allow_exec means true; the seed survives the decode, a written // false overrides it. v := &VM{AllowExec: true} - if err := tomlx.Decode(path, v, tomlx.Warn(UnknownKeyWriter)); err != nil { + defined, err := tomlx.DecodeDefined(path, v, []string{"allow_exec"}, tomlx.Warn(UnknownKeyWriter)) + if err != nil { return nil, err } v.Dir = dir v.Share = Expand(v.Share) if v.AgentAccess == "" { - v.AgentAccess = legacyAgentAccess(path, v.AllowExec) + v.AgentAccess = legacyAgentAccess(defined[0], v.AllowExec) } return v, nil } @@ -275,10 +276,11 @@ func Load(name string) (*VM, error) { // legacyAgentAccess maps a vm.toml written before agent_access existed to a // level. v.AllowExec is already seeded true for an absent key (the comment // above Load), so it alone cannot tell that case apart from an explicit -// `allow_exec = true`; only the latter earns "exec". Everything else, -// including an absent key, is "manage". -func legacyAgentAccess(path string, allowExec bool) string { - if defined, err := tomlx.Defined(path, "allow_exec"); err == nil && defined && allowExec { +// `allow_exec = true`; only allowExecDefined tells them apart, and only the +// explicit case earns "exec". Everything else, including an absent key, is +// "manage". +func legacyAgentAccess(allowExecDefined, allowExec bool) string { + if allowExecDefined && allowExec { return "exec" } return "manage" diff --git a/internal/tomlx/tomlx.go b/internal/tomlx/tomlx.go index 9d02b325..af386b4e 100644 --- a/internal/tomlx/tomlx.go +++ b/internal/tomlx/tomlx.go @@ -40,41 +40,54 @@ func Decode(path string, v any, opts ...Option) error { for _, opt := range opts { opt(&o) } + _, err := decode(path, v, o) + return err +} + +// DecodeDefined behaves like Decode but also reports, for each of keys, +// whether that top-level key was present in path's TOML. It parses the file +// once: config.Load uses it to tell an explicit legacy key from a field a +// decode seeded itself, without a second read of the same file. +func DecodeDefined(path string, v any, keys []string, opts ...Option) ([]bool, error) { + var o options + for _, opt := range opts { + opt(&o) + } + md, err := decode(path, v, o) + if err != nil { + return nil, err + } + defined := make([]bool, len(keys)) + for i, k := range keys { + defined[i] = md.IsDefined(k) + } + return defined, nil +} + +func decode(path string, v any, o options) (toml.MetaData, error) { md, err := toml.DecodeFile(path, v) if err != nil { - return fmt.Errorf("%s: %w", path, err) + return md, fmt.Errorf("%s: %w", path, err) } if o.schemaMax > 0 && md.IsDefined("schema") { var s struct { Schema int `toml:"schema"` } if _, err := toml.DecodeFile(path, &s); err != nil { - return fmt.Errorf("%s: %w", path, err) + return md, fmt.Errorf("%s: %w", path, err) } if s.Schema > o.schemaMax { - return fmt.Errorf("%s: schema %d is newer than this stoat (%d)", path, s.Schema, o.schemaMax) + return md, fmt.Errorf("%s: schema %d is newer than this stoat (%d)", path, s.Schema, o.schemaMax) } } for _, k := range md.Undecoded() { key := strings.Join(k, ".") if o.warn == nil { - return fmt.Errorf("%s: unknown key %q", path, key) + return md, fmt.Errorf("%s: unknown key %q", path, key) } fmt.Fprintf(o.warn, "%s: unknown key %q\n", path, key) } - return nil -} - -// Defined reports whether every key in keys (a dotted path, e.g. "a", "b") -// is present in path's TOML, independent of any Go struct. config.Load uses -// it to tell an explicit legacy key from a field a decode seeded itself. -func Defined(path string, keys ...string) (bool, error) { - var scratch map[string]any - md, err := toml.DecodeFile(path, &scratch) - if err != nil { - return false, fmt.Errorf("%s: %w", path, err) - } - return md.IsDefined(keys...), nil + return md, nil } // Encode is the single TOML writer for files owned by stoat. From c3d0114f7b3407186113651acb758ebef9a6fe73 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:58:30 +0300 Subject: [PATCH 41/67] test(mcp): pin guest and recipe schema tool behaviour Signed-off-by: NovusEdge --- internal/mcpsrv/table_test.go | 1 - internal/mcpsrv/tools_read.go | 29 ++++++++++++ internal/mcpsrv/tools_read_test.go | 74 ++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 internal/mcpsrv/tools_read_test.go diff --git a/internal/mcpsrv/table_test.go b/internal/mcpsrv/table_test.go index c9e14d07..988a3bc5 100644 --- a/internal/mcpsrv/table_test.go +++ b/internal/mcpsrv/table_test.go @@ -80,7 +80,6 @@ var forbiddenInputFields = []string{"share", "base", "iso", "console_password", // not exist yet, so TestEveryTableToolIsRegistered fails for them until // their implementer lands. var pending = map[string]string{ - "list_guests": "Task 14", "guest_info": "Task 14", "recipe_schema": "Task 14", "search_recipes": "Task 14", "add_recipe": "Task 15", "update_recipe": "Task 15", "remove_recipe": "Task 15", } diff --git a/internal/mcpsrv/tools_read.go b/internal/mcpsrv/tools_read.go index 1fcf3502..c4d94642 100644 --- a/internal/mcpsrv/tools_read.go +++ b/internal/mcpsrv/tools_read.go @@ -38,6 +38,10 @@ type planRecipesIn struct { Only []string `json:"only,omitempty" jsonschema:"subset of the VM's own recipes"` } +type nameIn struct { + Name string `json:"name" jsonschema:"name to look up"` +} + func (s *srv) registerRead(server *mcp.Server) { register(server, "list_vms", classRead, "List every VM stoat manages, one entry per VM: name, OS, mode, state, resources, disk, share, ssh port, agent access level, recipes, and port forwards. A VM whose vm.toml failed to parse is listed with state broken and an error message rather than hidden. Read-only: it touches no VM and changes nothing on the host.", @@ -137,6 +141,31 @@ func (s *srv) registerRead(server *mcp.Server) { } return wire.ApplyPlanList{Plan: wire.FromApplyPlans(plans)}, nil }) + + // Stubs: Task 14 implements these bodies over core.Guests, core.Guest + // and core.RecipeShow. + register(server, "list_guests", classRead, + "List every guest OS definition stoat knows: name, init system, package manager, default backend and whether the definition is bundled, a user file, or a user file merged over a bundled one. It reads the guest definitions only. Read-only.", + func(ctx context.Context, _ emptyIn) (wire.GuestList, error) { + return wire.GuestList{}, nil + }) + + register(server, "guest_info", classRead, + "Show one guest OS definition in full: init system, shell, escalate argv, capabilities, aliases, seed packages, the package manager verbs, the service verbs and the per-backend tables. Use it to learn what pkg_install and svc will run on a VM before you call them. Read-only.", + func(ctx context.Context, _ nameIn) (wire.Guest, error) { + // The generated output schema requires the map fields as objects, + // not null, so the stub sets them empty rather than nil. + return wire.Guest{ + Svc: map[string]string{}, Cmd: map[string]string{}, Backend: map[string]map[string]any{}, + Pkg: wire.GuestPkg{Env: map[string]string{}, RuntimePackages: map[string]string{}}, + }, nil + }) + + register(server, "recipe_schema", classRead, + "Show one recipe's contract: its params with type, default and help, its declared outputs, and its health check. Read it before update sets params on a VM. Read-only.", + func(ctx context.Context, _ nameIn) (wire.RecipeSchema, error) { + return wire.RecipeSchema{}, nil + }) } // tailLines returns the last n lines of r. core.Logs streams the whole file, diff --git a/internal/mcpsrv/tools_read_test.go b/internal/mcpsrv/tools_read_test.go new file mode 100644 index 00000000..bffd7fce --- /dev/null +++ b/internal/mcpsrv/tools_read_test.go @@ -0,0 +1,74 @@ +package mcpsrv + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestListGuestsReturnsTheBundledSet(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + res := callTool(t, "list_guests", map[string]any{}) + if res.IsError { + t.Fatalf("list_guests failed: %+v", res.Content) + } + raw, _ := json.Marshal(res.StructuredContent) + for _, name := range []string{"alpine", "debian", "ubuntu", "fedora", "arch"} { + if !strings.Contains(string(raw), `"`+name+`"`) { + t.Errorf("list_guests omitted %q: %s", name, raw) + } + } +} + +func TestGuestInfoReturnsBundledFields(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + res := callTool(t, "guest_info", map[string]any{"name": "alpine"}) + if res.IsError { + t.Fatalf("guest_info failed: %+v", res.Content) + } + raw, _ := json.Marshal(res.StructuredContent) + var out struct { + Init string `json:"init"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if out.Init != "openrc" { + t.Errorf("guest_info(alpine) init = %q, want openrc: %s", out.Init, raw) + } +} + +func TestGuestInfoNamesAnUnknownGuest(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + res := callTool(t, "guest_info", map[string]any{"name": "plan9"}) + if !res.IsError { + t.Fatal("guest_info accepted an unknown guest") + } +} + +func TestRecipeSchemaListsParams(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + res := callTool(t, "recipe_schema", map[string]any{"name": "docker"}) + if res.IsError { + t.Fatalf("recipe_schema failed: %+v", res.Content) + } + raw, _ := json.Marshal(res.StructuredContent) + var out struct { + Params []struct { + Name string `json:"name"` + Default string `json:"default"` + } `json:"params"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + var got *string + for _, p := range out.Params { + if p.Name == "user" { + got = &p.Default + } + } + if got == nil || *got != "dev" { + t.Fatalf("recipe_schema(docker) params missing user default dev: %s", raw) + } +} From b031e0192c3250a94776fa9cb76428b9d7989327 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:59:24 +0300 Subject: [PATCH 42/67] test(mcp): pin install and doctor behaviour Signed-off-by: NovusEdge --- internal/cli/wire/dto.go | 26 +++++++ internal/mcpsrv/doctor.go | 9 +++ internal/mcpsrv/doctor_test.go | 49 ++++++++++++ internal/mcpsrv/install.go | 14 ++++ internal/mcpsrv/install_test.go | 132 ++++++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 internal/mcpsrv/doctor.go create mode 100644 internal/mcpsrv/doctor_test.go create mode 100644 internal/mcpsrv/install.go create mode 100644 internal/mcpsrv/install_test.go diff --git a/internal/cli/wire/dto.go b/internal/cli/wire/dto.go index 2dcfbfc9..c1eeb0c6 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -862,3 +862,29 @@ type Job struct { type JobList struct { Jobs []Job `json:"jobs"` } + +// MCPInstall is the mcp install subcommand's output. Path is empty for +// --print, since nothing was written. +type MCPInstall struct { + Client string `json:"client"` + Path string `json:"path,omitempty"` + JSON string `json:"json"` +} + +// MCPClient is one client's row in an mcp doctor report. +type MCPClient struct { + Client string `json:"client"` + Path string `json:"path,omitempty"` + Installed bool `json:"installed"` + Command string `json:"command,omitempty"` + Current bool `json:"current"` +} + +// MCPDoctor is the mcp doctor subcommand's output. +type MCPDoctor struct { + Contract int `json:"contract"` + Version string `json:"version"` + Transport string `json:"transport"` + Binary string `json:"binary"` + Clients []MCPClient `json:"clients"` +} diff --git a/internal/mcpsrv/doctor.go b/internal/mcpsrv/doctor.go new file mode 100644 index 00000000..df033d2b --- /dev/null +++ b/internal/mcpsrv/doctor.go @@ -0,0 +1,9 @@ +package mcpsrv + +import "github.com/novusedge/stoat/internal/cli/wire" + +// DoctorReport says what this server is and whether each client's config +// entry points at the binary that is running. +func DoctorReport(version string) wire.MCPDoctor { + return wire.MCPDoctor{} +} diff --git a/internal/mcpsrv/doctor_test.go b/internal/mcpsrv/doctor_test.go new file mode 100644 index 00000000..3bf38036 --- /dev/null +++ b/internal/mcpsrv/doctor_test.go @@ -0,0 +1,49 @@ +package mcpsrv + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDoctorReportsTheContractAndTheClients(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + chdir(t, t.TempDir()) + + r := DoctorReport("test") + if r.Contract != Contract { + t.Fatalf("Contract = %d, want %d", r.Contract, Contract) + } + if r.Transport != "stdio" { + t.Fatalf("Transport = %q", r.Transport) + } + if len(r.Clients) != 4 { + t.Fatalf("got %d clients, want 4", len(r.Clients)) + } + for _, c := range r.Clients { + if c.Installed { + t.Errorf("%s: reported installed with no config file", c.Client) + } + } + + if _, err := Install("cursor", InstallOpts{}); err != nil { + t.Fatal(err) + } + r = DoctorReport("test") + for _, c := range r.Clients { + if c.Client != "cursor" { + continue + } + if !c.Installed { + t.Fatal("cursor is not reported as installed") + } + // The entry must point at the binary that is running, or the client + // launches a different stoat than the one that wrote the entry. + if !c.Current { + t.Fatalf("cursor entry points at %q, not the running binary", c.Command) + } + } + _ = filepath.Join + _ = os.Stat +} diff --git a/internal/mcpsrv/install.go b/internal/mcpsrv/install.go new file mode 100644 index 00000000..a54ab6dc --- /dev/null +++ b/internal/mcpsrv/install.go @@ -0,0 +1,14 @@ +package mcpsrv + +import "github.com/novusedge/stoat/internal/cli/wire" + +// InstallOpts configures where Install writes the client's server entry. +type InstallOpts struct { + Project bool + Print bool +} + +// Install writes the named client's MCP server entry for this binary. +func Install(client string, opts InstallOpts) (wire.MCPInstall, error) { + return wire.MCPInstall{}, nil +} diff --git a/internal/mcpsrv/install_test.go b/internal/mcpsrv/install_test.go new file mode 100644 index 00000000..1ddb0870 --- /dev/null +++ b/internal/mcpsrv/install_test.go @@ -0,0 +1,132 @@ +package mcpsrv + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestInstallWritesEachClientsFile(t *testing.T) { + for _, c := range []struct { + client, rel, key string + project bool + }{ + {"claude-code", ".claude.json", "mcpServers", false}, + {"claude-code", ".mcp.json", "mcpServers", true}, + {"claude-desktop", ".config/Claude/claude_desktop_config.json", "mcpServers", false}, + {"cursor", ".cursor/mcp.json", "mcpServers", false}, + {"vscode", ".vscode/mcp.json", "servers", false}, + } { + home := t.TempDir() + t.Setenv("HOME", home) + cwd := t.TempDir() + chdir(t, cwd) + + report, err := Install(c.client, InstallOpts{Project: c.project}) + if err != nil { + t.Fatalf("%s: %v", c.client, err) + } + base := home + if c.project || c.client == "vscode" { + base = cwd + } + want := filepath.Join(base, c.rel) + if report.Path != want { + t.Errorf("%s: wrote %s, want %s", c.client, report.Path, want) + } + raw, err := os.ReadFile(want) + if err != nil { + t.Fatalf("%s: %v", c.client, err) + } + var doc map[string]map[string]struct { + Command string `json:"command"` + Args []string `json:"args"` + CWD string `json:"cwd"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("%s: %v", c.client, err) + } + entry := doc[c.key]["stoat"] + if !filepath.IsAbs(entry.Command) { + t.Errorf("%s: command %q is not absolute", c.client, entry.Command) + } + if len(entry.Args) != 1 || entry.Args[0] != "mcp" { + t.Errorf("%s: args = %v, want [mcp]", c.client, entry.Args) + } + // cwd is written so project scope applies to the server. + if entry.CWD != cwd { + t.Errorf("%s: cwd = %q, want %q", c.client, entry.CWD, cwd) + } + } +} + +func TestInstallPreservesOtherEntries(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + chdir(t, t.TempDir()) + path := filepath.Join(home, ".cursor", "mcp.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"theme":"dark","mcpServers":{"other":{"command":"/bin/true"},"stoat":{"command":"/old/stoat"}}}` + if err := os.WriteFile(path, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Install("cursor", InstallOpts{}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + if doc["theme"] != "dark" { + t.Error("an unrelated top-level key was dropped") + } + servers := doc["mcpServers"].(map[string]any) + if _, ok := servers["other"]; !ok { + t.Error("an unrelated server entry was dropped") + } + stoat := servers["stoat"].(map[string]any) + if stoat["command"] == "/old/stoat" { + t.Error("the existing stoat entry was not replaced") + } +} + +func TestInstallPrintTouchesNoFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + chdir(t, t.TempDir()) + report, err := Install("cursor", InstallOpts{Print: true}) + if err != nil { + t.Fatal(err) + } + if report.JSON == "" { + t.Fatal("no JSON to print") + } + if _, err := os.Stat(filepath.Join(home, ".cursor", "mcp.json")); !os.IsNotExist(err) { + t.Fatal("--print wrote a file") + } +} + +func TestInstallRefusesAnUnknownClient(t *testing.T) { + if _, err := Install("emacs", InstallOpts{}); err == nil { + t.Fatal("accepted an unknown client") + } +} + +func chdir(t *testing.T, dir string) { + t.Helper() + old, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) +} From 9e6f769fe0c6775099aca8828e1d91cba12305cd Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:59:43 +0300 Subject: [PATCH 43/67] test(mcp): pin recipe index tool boundary Signed-off-by: NovusEdge --- internal/mcpsrv/table_test.go | 5 ++- internal/mcpsrv/tools_recipe_test.go | 47 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 internal/mcpsrv/tools_recipe_test.go diff --git a/internal/mcpsrv/table_test.go b/internal/mcpsrv/table_test.go index c9e14d07..f74559b7 100644 --- a/internal/mcpsrv/table_test.go +++ b/internal/mcpsrv/table_test.go @@ -75,12 +75,11 @@ var forbiddenInputFields = []string{"share", "base", "iso", "console_password", // pending names tools a later task registers. Every entry is removed by the // task that adds the tool; Task 18 asserts the list is empty. // -// Tasks 7, 10, 11 and 12 own the rest of this chunk's tools and are gone +// Tasks 7, 10, 11, 12 and 15 own the rest of this chunk's tools and are gone // from this map already: their tests assert real registration, which does // not exist yet, so TestEveryTableToolIsRegistered fails for them until // their implementer lands. var pending = map[string]string{ "list_guests": "Task 14", "guest_info": "Task 14", "recipe_schema": "Task 14", - "search_recipes": "Task 14", "add_recipe": "Task 15", "update_recipe": "Task 15", - "remove_recipe": "Task 15", + "search_recipes": "Task 14", } diff --git a/internal/mcpsrv/tools_recipe_test.go b/internal/mcpsrv/tools_recipe_test.go new file mode 100644 index 00000000..1f106919 --- /dev/null +++ b/internal/mcpsrv/tools_recipe_test.go @@ -0,0 +1,47 @@ +package mcpsrv + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestAddRecipeRefusesAURL pins the spec's "index names only" rule: a git +// URL, an scp-style remote, a path, or an owner/repo pair are all refused at +// the tool boundary, before core.AddRecipe ever runs. +func TestAddRecipeRefusesAURL(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + for _, ref := range []string{ + "https://github.com/x/stoat-tailscale", + "git@github.com:x/stoat-tailscale.git", + "../../etc/passwd", + "x/y", + } { + res := callTool(t, "add_recipe", map[string]any{"name": ref}) + if !res.IsError { + t.Errorf("add_recipe accepted %q", ref) + continue + } + raw, _ := json.Marshal(res.Content) + if !strings.Contains(string(raw), "index names only") && !strings.Contains(string(raw), "invalid recipe name") { + t.Errorf("add_recipe(%q) refusal did not name the guard: %s", ref, raw) + } + } +} + +// TestRemoveRecipeHasNoForce pins the spec's rule that remove_recipe has no +// force parameter: a person, not an agent, removes a recipe a VM still +// uses. +func TestRemoveRecipeHasNoForce(t *testing.T) { + for _, tool := range listTools(t) { + if tool.Name != "remove_recipe" { + continue + } + raw, _ := json.Marshal(tool.InputSchema) + if strings.Contains(string(raw), `"force"`) { + t.Fatalf("remove_recipe exposes force: %s", raw) + } + return + } + t.Fatal("remove_recipe is not registered") +} From a68cb7d9f3ce243da7ae999b38a52702adfa2210 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:59:55 +0300 Subject: [PATCH 44/67] test(mcp): pin exec's not-running refusal and the allow-exec alias Signed-off-by: NovusEdge --- internal/cli/cli_test.go | 48 ++++++++++++++++++++++++++---- internal/cli/grammar.go | 23 +++++++++----- internal/mcpsrv/tools_exec.go | 5 ++++ internal/mcpsrv/tools_exec_test.go | 14 +++++++++ 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 448026fe..d3a40205 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -128,11 +128,29 @@ func TestParse(t *testing.T) { } } -// TestParseCreateAllowExecDefaultsTrue pins that an omitted --allow-exec -// still produces Spec.AllowExec pointing at true. A nil pointer would hide -// a kong misconfiguration that made the flag look unset. false would be -// Go's bool zero value, the regression AllowExec's default guards against. +// TestParseCreateAllowExecDefaultsTrue pins that a bare --allow-exec, with +// no value, still produces Spec.AllowExec pointing at true: AllowExec is a +// *bool with no kong default tag, so a bare flag relies on kong's ordinary +// bool parsing, not a default value. It also maps to the exec agent_access +// level, --allow-exec's alias contract. func TestParseCreateAllowExecDefaultsTrue(t *testing.T) { + got, err := Parse([]string{"create", "work", "--image", "alpine", "--allow-exec"}) + if err != nil { + t.Fatal(err) + } + if got.Spec.AllowExec == nil || !*got.Spec.AllowExec { + t.Errorf("create --allow-exec: Spec.AllowExec = %v, want a pointer to true", got.Spec.AllowExec) + } + if got.Spec.AgentAccess != "exec" { + t.Errorf("create --allow-exec: Spec.AgentAccess = %q, want %q", got.Spec.AgentAccess, "exec") + } +} + +// TestParseCreateAllowExecOmittedDefaultsToManage pins that omitting both +// --allow-exec and --agent-access still gives Spec.AllowExec a pointer to +// true (see Spec.AllowExec's doc comment), while Spec.AgentAccess defaults +// to manage: only a given --allow-exec maps to exec. +func TestParseCreateAllowExecOmittedDefaultsToManage(t *testing.T) { got, err := Parse([]string{"create", "work", "--image", "alpine"}) if err != nil { t.Fatal(err) @@ -140,11 +158,13 @@ func TestParseCreateAllowExecDefaultsTrue(t *testing.T) { if got.Spec.AllowExec == nil || !*got.Spec.AllowExec { t.Errorf("create with no --allow-exec: Spec.AllowExec = %v, want a pointer to true", got.Spec.AllowExec) } + if got.Spec.AgentAccess != "manage" { + t.Errorf("create with no --allow-exec: Spec.AgentAccess = %q, want %q", got.Spec.AgentAccess, "manage") + } } // TestParseCreateAllowExecFalse pins that --allow-exec=false is how a -// caller turns it off; kong's bool default:"true" makes a bare --allow-exec -// (no value) mean true, matching every other bool flag. +// caller turns it off, mapping to the manage agent_access level. func TestParseCreateAllowExecFalse(t *testing.T) { got, err := Parse([]string{"create", "work", "--image", "alpine", "--allow-exec=false"}) if err != nil { @@ -153,6 +173,22 @@ func TestParseCreateAllowExecFalse(t *testing.T) { if got.Spec.AllowExec == nil || *got.Spec.AllowExec { t.Errorf("create --allow-exec=false: Spec.AllowExec = %v, want a pointer to false", got.Spec.AllowExec) } + if got.Spec.AgentAccess != "manage" { + t.Errorf("create --allow-exec=false: Spec.AgentAccess = %q, want %q", got.Spec.AgentAccess, "manage") + } +} + +// TestParseCreateAgentAccessWinsOverAllowExec pins that an explicit +// --agent-access overrides the hidden --allow-exec alias, so a caller who +// passes both is not silently downgraded by the legacy flag. +func TestParseCreateAgentAccessWinsOverAllowExec(t *testing.T) { + got, err := Parse([]string{"create", "work", "--image", "alpine", "--allow-exec", "--agent-access", "observe"}) + if err != nil { + t.Fatal(err) + } + if got.Spec.AgentAccess != "observe" { + t.Errorf("create --allow-exec --agent-access observe: Spec.AgentAccess = %q, want %q", got.Spec.AgentAccess, "observe") + } } // TestParsePure guards against Parse doing anything beyond interpreting diff --git a/internal/cli/grammar.go b/internal/cli/grammar.go index 52592a29..3225af64 100644 --- a/internal/cli/grammar.go +++ b/internal/cli/grammar.go @@ -113,12 +113,13 @@ type createCmd struct { Recipes []string `help:"recipe names to record on the VM"` Set []string `help:"set a recipe param: .="` Secret []string `help:"set a secret recipe param"` - // AllowExec is a hidden alias of --agent-access: a nil pointer means the - // flag was not given, so toArgs can tell that apart from --agent-access's - // own default, the same pointer trick updateCmd's fields use (see - // grammar's type comment). true maps to the exec level, false to manage. - AllowExec *bool `name:"allow-exec" hidden:"" help:"alias of --agent-access exec (true) or manage (false)"` - AgentAccess string `name:"agent-access" default:"manage" enum:"none,observe,manage,exec" help:"what an MCP agent may do in this VM"` + // AllowExec is a hidden alias of --agent-access, and both are pointers + // so toArgs can tell "not given" from every real value, the same + // pointer trick updateCmd's fields use (see grammar's type comment). + // true maps to the exec level, false to manage; an explicit + // --agent-access always wins over this alias. + AllowExec *bool `name:"allow-exec" hidden:"" help:"alias of --agent-access exec (true) or manage (false)"` + AgentAccess *string `name:"agent-access" enum:"none,observe,manage,exec" help:"what an MCP agent may do in this VM"` } // updateCmd's pointers are the point: see the type comment on grammar. @@ -334,8 +335,14 @@ func (g *grammar) toArgs(path string) (*Args, error) { if c.AllowExec != nil { allowExec = *c.AllowExec } - access := c.AgentAccess - if c.AllowExec != nil { + // An explicit --agent-access always wins over the hidden + // --allow-exec alias; only when it is unset does --allow-exec pick + // the level, and only when neither is given is the default manage. + access := "manage" + switch { + case c.AgentAccess != nil: + access = *c.AgentAccess + case c.AllowExec != nil: access = "manage" if *c.AllowExec { access = "exec" diff --git a/internal/mcpsrv/tools_exec.go b/internal/mcpsrv/tools_exec.go index fb137fc9..a860a147 100644 --- a/internal/mcpsrv/tools_exec.go +++ b/internal/mcpsrv/tools_exec.go @@ -113,6 +113,11 @@ func (s *srv) registerExec(server *mcp.Server) { if err != nil { return wire.CommandResult{}, err } + if code != 0 { + if runErr := requireRunning(v); runErr != nil { + return wire.CommandResult{}, runErr + } + } return wire.CommandResult{Stdout: string(out), Stderr: string(errb), ExitCode: code}, nil }) diff --git a/internal/mcpsrv/tools_exec_test.go b/internal/mcpsrv/tools_exec_test.go index 57100224..0b59daa4 100644 --- a/internal/mcpsrv/tools_exec_test.go +++ b/internal/mcpsrv/tools_exec_test.go @@ -58,6 +58,20 @@ func TestWriteFileRefusesOnAStoppedVM(t *testing.T) { } } +func TestExecRefusesOnAStoppedVM(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "exec") + testutil.FakeSSH(t, `exit 1`) + res := callTool(t, "exec", map[string]any{"vm": "dev", "argv": []string{"id"}}) + if !res.IsError { + t.Fatal("exec ran on a stopped VM") + } + raw, _ := json.Marshal(res.Content) + if !strings.Contains(string(raw), "not running") { + t.Fatalf("refusal did not report not_running: %s", raw) + } +} + func TestExecBgRefusesOnAStoppedVM(t *testing.T) { t.Setenv("STOAT_HOME", t.TempDir()) writeVM(t, "dev", "exec") From 30b53af290a6f0bf153940b4e5226bb2f29271a5 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 16:59:57 +0300 Subject: [PATCH 45/67] test(mcp): pin secret redaction over wire values Signed-off-by: NovusEdge --- internal/mcpsrv/access_test.go | 13 ++++ internal/mcpsrv/redact.go | 18 +++++ internal/mcpsrv/redact_test.go | 122 +++++++++++++++++++++++++++++++++ internal/mcpsrv/server.go | 1 + 4 files changed, 154 insertions(+) create mode 100644 internal/mcpsrv/redact.go create mode 100644 internal/mcpsrv/redact_test.go diff --git a/internal/mcpsrv/access_test.go b/internal/mcpsrv/access_test.go index 081f909e..00c391d2 100644 --- a/internal/mcpsrv/access_test.go +++ b/internal/mcpsrv/access_test.go @@ -27,6 +27,19 @@ func writeVM(t *testing.T, name, level string) { } } +// writeSecrets writes a VM's secrets.toml at the mode the loader requires. +func writeSecrets(t *testing.T, vm string, kv map[string]string) { + t.Helper() + var b strings.Builder + for k, v := range kv { + b.WriteString(k + " = \"" + v + "\"\n") + } + path := filepath.Join(config.Root(), vm, "secrets.toml") + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + t.Fatal(err) + } +} + // accessFakeGuest answers every guest command a tool in the table can send: // enough exit-0 output for read_file, list_dir, stat and ps to parse // cleanly, and a bare success for everything else. Access denial is decided diff --git a/internal/mcpsrv/redact.go b/internal/mcpsrv/redact.go new file mode 100644 index 00000000..f6286f74 --- /dev/null +++ b/internal/mcpsrv/redact.go @@ -0,0 +1,18 @@ +package mcpsrv + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +// redact is sending middleware over tool results. Task 13's implementer +// fills this in to walk StructuredContent and mask secret fields with +// core.SecretSet or core.SecretUnset. +func (s *srv) redact() mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return next + } +} + +// redactValue is a stub: it returns v unchanged. Task 13's implementer +// replaces this with the JSON walk that masks secret fields. +func redactValue(v any) any { + return v +} diff --git a/internal/mcpsrv/redact_test.go b/internal/mcpsrv/redact_test.go new file mode 100644 index 00000000..3da8cc19 --- /dev/null +++ b/internal/mcpsrv/redact_test.go @@ -0,0 +1,122 @@ +package mcpsrv + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/novusedge/stoat/internal/core" +) + +const sentinel = "tskey-auth-SENTINEL-do-not-leak" + +// TestNoToolLeaksASecret scans every tool's output for a sentinel secret. The +// fixture VM carries one secret value and no tool may ever echo it. +func TestNoToolLeaksASecret(t *testing.T) { + root := t.TempDir() + t.Setenv("STOAT_HOME", root) + writeVM(t, "dev", "exec") + writeSecrets(t, "dev", map[string]string{"tailscale.authkey": sentinel}) + + ctx := context.Background() + srv := New(Options{Version: "test", Limits: Limits{ToolBurst: 1000, ToolRate: 100, Burst: 10000, Rate: 100}}) + ct, st := mcp.NewInMemoryTransports() + if _, err := srv.Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := cs.Close(); err != nil { + t.Error(err) + } + }) + + for _, spec := range toolTable { + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: spec.Name, Arguments: argsFor(spec.Name)}) + if err != nil { + continue // A protocol failure is not a leak. + } + raw, _ := json.Marshal(res) + if strings.Contains(string(raw), sentinel) { + t.Errorf("%s leaked the secret: %s", spec.Name, raw) + } + } +} + +func TestRedactValueReplacesSecretFields(t *testing.T) { + in := map[string]any{ + "name": "tailscale", + "params": map[string]any{"authkey": sentinel}, + "secrets": map[string]any{"authkey": sentinel}, + } + out := redactValue(in).(map[string]any) + if out["secrets"].(map[string]any)["authkey"] != core.SecretSet { + t.Fatalf("a set secret must render as %q, got %v", core.SecretSet, out["secrets"]) + } + if out["name"] != "tailscale" { + t.Fatal("redaction changed a non-secret field") + } +} + +func TestUpdateNeverEchoesASecret(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "manage") + res := callTool(t, "update", map[string]any{ + "vm": "dev", + "secrets": map[string]any{"tailscale": map[string]any{"authkey": sentinel}}, + }) + raw, _ := json.Marshal(res) + if strings.Contains(string(raw), sentinel) { + t.Fatalf("update echoed the secret it was given: %s", raw) + } +} + +// argsFor gives every tool the minimum arguments that reach a handler, so +// the scan covers real output rather than an argument error. +func argsFor(name string) map[string]any { + args := map[string]any{} + switch name { + case "create": + return map[string]any{"name": "x", "image": "alpine-virt"} + case "clone": + return map[string]any{"source": "dev", "name": "dev2"} + case "snapshot", "restore": + return map[string]any{"vm": "dev", "tag": "t"} + case "check_recipes": + return map[string]any{"recipes": []string{"docker"}, "os": "alpine"} + case "recipe_schema", "guest_info": + return map[string]any{"name": "docker"} + case "search_recipes": + return map[string]any{"term": "docker"} + case "add_recipe", "update_recipe", "remove_recipe": + return map[string]any{"name": "docker"} + case "read_file", "list_dir", "stat": + return map[string]any{"vm": "dev", "path": "/etc"} + case "write_file": + return map[string]any{"vm": "dev", "path": "/tmp/x", "content": "x"} + case "copy_to", "copy_from": + return map[string]any{"vm": "dev", "local": "/tmp/x", "remote": "/tmp/x"} + case "pkg_install": + return map[string]any{"vm": "dev", "packages": []string{"curl"}} + case "svc": + return map[string]any{"vm": "dev", "name": "sshd", "action": "restart"} + case "svc_status", "useradd": + return map[string]any{"vm": "dev", "name": "sshd"} + case "exec", "exec_bg": + return map[string]any{"vm": "dev", "argv": []string{"true"}} + case "job_status", "job_output", "job_kill": + return map[string]any{"vm": "dev", "job_id": "j-00000001"} + } + if strings.Contains(name, "vm") || name == "logs" || name == "wait" || + name == "start" || name == "stop" || name == "destroy" || name == "update" || + name == "forward" || name == "ps" || name == "tail_log" || + name == "plan_recipes" || name == "apply_recipes" || name == "list_jobs" { + args["vm"] = "dev" + } + return args +} diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index 6a4e2564..c3df15af 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -64,6 +64,7 @@ func New(opts Options) *mcp.Server { s.registerGuestWrite(server) s.registerExec(server) server.AddReceivingMiddleware(s.rateLimit()) + server.AddSendingMiddleware(s.redact()) return server } From e94bf603163a35a74a4d198f151d52b48587578e Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 17:00:17 +0300 Subject: [PATCH 46/67] test(cli): pin stoat mcp grammar, loopback guard, and doctor JSON Signed-off-by: NovusEdge --- internal/cli/run_mcp_test.go | 67 ++++++++++++++++++++++++++++++++++++ internal/mcpsrv/server.go | 4 +++ 2 files changed, 71 insertions(+) create mode 100644 internal/cli/run_mcp_test.go diff --git a/internal/cli/run_mcp_test.go b/internal/cli/run_mcp_test.go new file mode 100644 index 00000000..2585417b --- /dev/null +++ b/internal/cli/run_mcp_test.go @@ -0,0 +1,67 @@ +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/mcpsrv" +) + +// parseOnly runs Parse and reports whether argv parses, without running any +// command. +func parseOnly(t *testing.T, argv []string) error { + t.Helper() + _, err := Parse(argv) + return err +} + +// runCLI runs Main and returns stdout. +func runCLI(t *testing.T, argv ...string) string { + t.Helper() + var out, errOut bytes.Buffer + Main(argv, "test-version", strings.NewReader(""), &out, &errOut) + return out.String() +} + +func TestHTTPRefusesANonLoopbackAddress(t *testing.T) { + for _, addr := range []string{"0.0.0.0:7777", "192.168.1.5:7777", ":7777", "example.com:7777"} { + if err := mcpsrv.CheckLoopback(addr); err == nil { + t.Errorf("CheckLoopback(%q) accepted a non-loopback bind", addr) + } + } + for _, addr := range []string{"127.0.0.1:7777", "localhost:7777", "[::1]:7777"} { + if err := mcpsrv.CheckLoopback(addr); err != nil { + t.Errorf("CheckLoopback(%q): %v", addr, err) + } + } +} + +func TestMCPIsInTheGrammar(t *testing.T) { + // The mcp command must parse with no subcommand and default to serve, + // because every MCP client launches "stoat mcp" as a subprocess. + for _, argv := range [][]string{ + {"mcp"}, + {"mcp", "--http", "127.0.0.1:7777"}, + {"mcp", "install", "claude-code"}, + {"mcp", "install", "vscode", "--print"}, + {"mcp", "doctor"}, + } { + if err := parseOnly(t, argv); err != nil { + t.Errorf("%v: %v", argv, err) + } + } + if err := parseOnly(t, []string{"mcp", "install", "emacs"}); err == nil { + t.Error("an unknown client was accepted") + } +} + +func TestMCPDoctorJSON(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + out := runCLI(t, "--json", "mcp", "doctor") + for _, key := range []string{`"contract"`, `"transport"`, `"binary"`, `"clients"`} { + if !strings.Contains(out, key) { + t.Errorf("mcp doctor output has no %s: %s", key, out) + } + } +} diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index 6a4e2564..92c0b1e8 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -116,3 +116,7 @@ func toolError(err error) *mcp.CallToolResult { func clampInt(v, lo, hi int) int { return max(lo, min(v, hi)) } + +func CheckLoopback(addr string) error { + return nil +} From 9bb600015fc1c3aad492e8cd8ebba3a0b34326a2 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 17:02:46 +0300 Subject: [PATCH 47/67] feat(mcp): add install and doctor subcommands Signed-off-by: NovusEdge --- internal/mcpsrv/doctor.go | 51 +++++++++++++- internal/mcpsrv/install.go | 140 ++++++++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/internal/mcpsrv/doctor.go b/internal/mcpsrv/doctor.go index df033d2b..12abf83f 100644 --- a/internal/mcpsrv/doctor.go +++ b/internal/mcpsrv/doctor.go @@ -1,9 +1,54 @@ package mcpsrv -import "github.com/novusedge/stoat/internal/cli/wire" +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/novusedge/stoat/internal/cli/wire" +) // DoctorReport says what this server is and whether each client's config -// entry points at the binary that is running. +// entry points at the binary that is running. A stale entry launches a +// different stoat than the one the user just installed, and that is the +// failure this command exists to name. func DoctorReport(version string) wire.MCPDoctor { - return wire.MCPDoctor{} + bin, _ := os.Executable() + bin, _ = filepath.Abs(bin) + r := wire.MCPDoctor{ + Contract: Contract, + Version: version, + Transport: "stdio", + Binary: bin, + Clients: []wire.MCPClient{}, + } + for _, c := range clients { + row := wire.MCPClient{Client: c.Client} + path, err := configPath(c, InstallOpts{}) + if err != nil { + r.Clients = append(r.Clients, row) + continue + } + row.Path = path + raw, err := os.ReadFile(path) + if err != nil { + r.Clients = append(r.Clients, row) + continue + } + var doc map[string]map[string]entry + if err := json.Unmarshal(raw, &doc); err != nil { + r.Clients = append(r.Clients, row) + continue + } + e, ok := doc[c.Key]["stoat"] + if !ok { + r.Clients = append(r.Clients, row) + continue + } + row.Installed = true + row.Command = e.Command + row.Current = e.Command == bin + r.Clients = append(r.Clients, row) + } + return r } diff --git a/internal/mcpsrv/install.go b/internal/mcpsrv/install.go index a54ab6dc..597d0bc8 100644 --- a/internal/mcpsrv/install.go +++ b/internal/mcpsrv/install.go @@ -1,6 +1,31 @@ package mcpsrv -import "github.com/novusedge/stoat/internal/cli/wire" +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + + "github.com/novusedge/stoat/internal/cli/wire" +) + +// clientConfig is where one MCP client keeps its server list. VS Code uses +// "servers"; the others use "mcpServers". +type clientConfig struct { + Client string + Key string + Rel string // relative to home, or to the working directory when Local + Local bool + Project string // the path --project writes instead, relative to the working directory +} + +var clients = []clientConfig{ + {Client: "claude-code", Key: "mcpServers", Rel: ".claude.json", Project: ".mcp.json"}, + {Client: "claude-desktop", Key: "mcpServers", Rel: ".config/Claude/claude_desktop_config.json"}, + {Client: "cursor", Key: "mcpServers", Rel: ".cursor/mcp.json"}, + {Client: "vscode", Key: "servers", Rel: ".vscode/mcp.json", Local: true}, +} // InstallOpts configures where Install writes the client's server entry. type InstallOpts struct { @@ -8,7 +33,116 @@ type InstallOpts struct { Print bool } -// Install writes the named client's MCP server entry for this binary. +// entry is the server record every client understands. cwd is written so +// project scope applies to the server the client launches. +type entry struct { + Command string `json:"command"` + Args []string `json:"args"` + CWD string `json:"cwd"` +} + +func configFor(client string) (clientConfig, error) { + i := slices.IndexFunc(clients, func(c clientConfig) bool { return c.Client == client }) + if i < 0 { + names := make([]string, len(clients)) + for j, c := range clients { + names[j] = c.Client + } + return clientConfig{}, fmt.Errorf("unknown client %q: one of %v", client, names) + } + return clients[i], nil +} + +func configPath(c clientConfig, opts InstallOpts) (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + if c.Local { + return filepath.Join(cwd, c.Rel), nil + } + if opts.Project && c.Project != "" { + return filepath.Join(cwd, c.Project), nil + } + if opts.Project { + return "", fmt.Errorf("%s has no project scoped config; drop --project", c.Client) + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, c.Rel), nil +} + +// Install writes the named client's MCP server entry for this binary. It +// replaces an existing stoat entry, leaves every other entry and every +// other top-level key alone, and writes through a temp file and a rename. func Install(client string, opts InstallOpts) (wire.MCPInstall, error) { - return wire.MCPInstall{}, nil + c, err := configFor(client) + if err != nil { + return wire.MCPInstall{}, err + } + path, err := configPath(c, opts) + if err != nil { + return wire.MCPInstall{}, err + } + bin, err := os.Executable() + if err != nil { + return wire.MCPInstall{}, err + } + bin, err = filepath.Abs(bin) + if err != nil { + return wire.MCPInstall{}, err + } + cwd, err := os.Getwd() + if err != nil { + return wire.MCPInstall{}, err + } + e := entry{Command: bin, Args: []string{"mcp"}, CWD: cwd} + + pretty, err := json.MarshalIndent(map[string]any{c.Key: map[string]any{"stoat": e}}, "", " ") + if err != nil { + return wire.MCPInstall{}, err + } + report := wire.MCPInstall{Client: c.Client, Path: path, JSON: string(pretty)} + if opts.Print { + report.Path = "" + return report, nil + } + + doc := map[string]any{} + if raw, err := os.ReadFile(path); err == nil { + if err := json.Unmarshal(raw, &doc); err != nil { + return wire.MCPInstall{}, fmt.Errorf("%s: %w", path, err) + } + } + servers, _ := doc[c.Key].(map[string]any) + if servers == nil { + servers = map[string]any{} + } + servers["stoat"] = e + doc[c.Key] = servers + + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return wire.MCPInstall{}, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return wire.MCPInstall{}, err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".mcp-*.json") + if err != nil { + return wire.MCPInstall{}, err + } + defer func() { _ = os.Remove(tmp.Name()) }() + if _, err := tmp.Write(append(out, '\n')); err != nil { + return wire.MCPInstall{}, err + } + if err := tmp.Close(); err != nil { + return wire.MCPInstall{}, err + } + if err := os.Rename(tmp.Name(), path); err != nil { + return wire.MCPInstall{}, err + } + return report, nil } From 9584c31db93e3ce42375c1f4af31930d432b0ee3 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 17:04:56 +0300 Subject: [PATCH 48/67] feat(mcp): add recipe index tools Signed-off-by: NovusEdge --- internal/core/remote_recipes.go | 61 ++++++++++++++++++++++++ internal/mcpsrv/server.go | 1 + internal/mcpsrv/tools_recipe.go | 84 +++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 internal/mcpsrv/tools_recipe.go diff --git a/internal/core/remote_recipes.go b/internal/core/remote_recipes.go index e0ae328d..49a75359 100644 --- a/internal/core/remote_recipes.go +++ b/internal/core/remote_recipes.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "sort" + "strings" "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/recipes" @@ -45,3 +46,63 @@ func RecipeUsers(name string) ([]string, error) { sort.Strings(users) return users, nil } + +// AddOpts carries what the CLI passes as flags. Yes is accepted and ignored +// here: recipes.Add asks nothing, and the preview prompt lives in the CLI. +type AddOpts struct { + Ref string + Global bool + Force bool + Yes bool +} + +// AddRecipe installs one recipe in the scope of the current directory. Ref +// pins a tag or branch; an empty Ref takes the source's default branch. +func AddRecipe(name string, opts AddOpts) error { + s, err := recipes.ScopeFor(opts.Global) + if err != nil { + return err + } + ref := name + if opts.Ref != "" { + ref = name + "@" + opts.Ref + } + _, err = recipes.Add(s, ref, opts.Force) + return err +} + +// UpdateRecipe repins one recipe, or every remote recipe when name is empty. +func UpdateRecipe(name string) error { + s, err := recipes.ScopeFor(false) + if err != nil { + return err + } + var names []string + if name != "" { + names = []string{name} + } + _, err = recipes.Update(s, names) + return err +} + +// RemoveRecipe deletes one recipe. Without force it refuses while a VM lists +// it; recipes.RemoveChecked runs that check while holding the scope lock, so +// a VM cannot start using the recipe between the check and the removal. +func RemoveRecipe(name string, force bool) error { + s, err := recipes.ScopeFor(false) + if err != nil { + return err + } + var users func() ([]string, error) + if !force { + users = func() ([]string, error) { return RecipeUsers(name) } + } + if err := recipes.RemoveChecked(s, name, users); err != nil { + var inUse *recipes.RemoveInUse + if errors.As(err, &inUse) { + return fmt.Errorf("%w: %s is used by %s", ErrInUse, name, strings.Join(inUse.Users, ", ")) + } + return err + } + return nil +} diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index 6a4e2564..a9623a3e 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -60,6 +60,7 @@ func New(opts Options) *mcp.Server { server := mcp.NewServer(&mcp.Implementation{Name: "stoat", Version: opts.Version}, nil) s.registerRead(server) s.registerVM(server) + s.registerRecipe(server) s.registerGuestRead(server) s.registerGuestWrite(server) s.registerExec(server) diff --git a/internal/mcpsrv/tools_recipe.go b/internal/mcpsrv/tools_recipe.go new file mode 100644 index 00000000..51a590f0 --- /dev/null +++ b/internal/mcpsrv/tools_recipe.go @@ -0,0 +1,84 @@ +package mcpsrv + +import ( + "context" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/core" +) + +type addRecipeIn struct { + Name string `json:"name" jsonschema:"recipe name from the index, optionally with @ref such as tailscale@v1.2; a URL is refused"` + Ref string `json:"ref,omitempty" jsonschema:"tag or branch to pin, when it is not written into name"` +} + +type recipeNameIn struct { + Name string `json:"name,omitempty" jsonschema:"recipe name; omit to update every remote recipe"` +} + +type removeRecipeIn struct { + Name string `json:"name" jsonschema:"recipe name to remove"` +} + +func (s *srv) registerRecipe(server *mcp.Server) { + register(server, "add_recipe", classMutate, + "Install a recipe from the curated index, pinned at a tag or branch. Only index names are accepted; a git URL is refused, because a URL is a repository nobody curated. It clones and validates the recipe and writes a lock entry. It runs no guest code, so it needs no agent access. Mutating: it writes to the recipe cache and the lockfile.", + func(ctx context.Context, in addRecipeIn) (wire.RecipeCatalog, error) { + name, ref, err := checkIndexName(in.Name) + if err != nil { + return wire.RecipeCatalog{}, err + } + if in.Ref != "" { + _, r, err := checkIndexName(name + "@" + in.Ref) + if err != nil { + return wire.RecipeCatalog{}, err + } + ref = r + } + if err := core.AddRecipe(name, core.AddOpts{Ref: ref, Yes: true}); err != nil { + return wire.RecipeCatalog{}, err + } + return currentRecipes() + }) + + register(server, "update_recipe", classMutate, + "Re-resolve a remote recipe's tag or branch, check the new commit out, and rewrite the lock entry. With no name it updates every remote recipe. It refuses a recipe directory with uncommitted changes. It runs no guest code. Mutating: it changes the recipe cache and the lockfile.", + func(ctx context.Context, in recipeNameIn) (wire.RecipeCatalog, error) { + name := in.Name + if name != "" { + n, _, err := checkIndexName(name) + if err != nil { + return wire.RecipeCatalog{}, err + } + name = n + } + if err := core.UpdateRecipe(name); err != nil { + return wire.RecipeCatalog{}, err + } + return currentRecipes() + }) + + register(server, "remove_recipe", classMutate, + "Remove a remote recipe: its declaration, its lock entry and its directory. It refuses while any VM lists that recipe. There is no force option on this tool; a person removes a recipe a VM still uses, from the CLI. Mutating and not reversible from here, though add_recipe reinstalls it.", + func(ctx context.Context, in removeRecipeIn) (wire.RecipeCatalog, error) { + name, _, err := checkIndexName(in.Name) + if err != nil { + return wire.RecipeCatalog{}, err + } + // force is deliberately absent rather than false-by-default: a + // parameter that exists is eventually reachable. + if err := core.RemoveRecipe(name, false); err != nil { + return wire.RecipeCatalog{}, err + } + return currentRecipes() + }) +} + +func currentRecipes() (wire.RecipeCatalog, error) { + rs, err := core.Recipes(core.RecipeFilter{}) + if err != nil { + return wire.RecipeCatalog{}, err + } + return wire.RecipeCatalog{Recipes: wire.FromRecipes(rs)}, nil +} From cc43a1e2bf5b2447eff1503511ecbb2a10986855 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 17:06:21 +0300 Subject: [PATCH 49/67] feat(mcp): redact secrets in sending middleware Signed-off-by: NovusEdge --- internal/mcpsrv/redact.go | 125 ++++++++++++++++++++++++++++++++++-- internal/mcpsrv/server.go | 7 +- internal/mcpsrv/tools_vm.go | 4 ++ 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/internal/mcpsrv/redact.go b/internal/mcpsrv/redact.go index f6286f74..86fd827f 100644 --- a/internal/mcpsrv/redact.go +++ b/internal/mcpsrv/redact.go @@ -1,18 +1,129 @@ package mcpsrv -import "github.com/modelcontextprotocol/go-sdk/mcp" +import ( + "context" + "encoding/json" -// redact is sending middleware over tool results. Task 13's implementer -// fills this in to walk StructuredContent and mask secret fields with -// core.SecretSet or core.SecretUnset. + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/novusedge/stoat/internal/core" +) + +// secretFields are the map keys whose values are secret by construction. A +// recipe's manifest type is what makes a param secret, and wire already +// renders those as or ; this middleware is the second layer, so +// a DTO that forgets cannot leak. +var secretFields = []string{"secrets", "console_password", "authkey", "password", "token"} + +// redact wraps the receiving handler for tools/call: AddSendingMiddleware +// only sees requests the server itself initiates (sampling, elicitation), +// never a CallToolResult built for an incoming request, so this is receiving +// middleware even though the redaction happens on the way out. It runs +// closest to the handler, after every registered tool has built its result, +// so a DTO built anywhere in this package is covered without every handler +// remembering. func (s *srv) redact() mcp.Middleware { return func(next mcp.MethodHandler) mcp.MethodHandler { - return next + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + res, err := next(ctx, method, req) + ctr, ok := res.(*mcp.CallToolResult) + if !ok || ctr == nil { + return res, err + } + if ctr.StructuredContent != nil { + ctr.StructuredContent = redactValue(ctr.StructuredContent) + } + // AddTool's wrapper fills Content with a TextContent fallback + // holding the full unredacted JSON of an object-shaped Out, + // before this middleware runs (SDK v1.7.0, mcp/server.go:435-443). + // StructuredContent alone is not enough: the fallback text must + // be redacted too. + for _, c := range ctr.Content { + tc, ok := c.(*mcp.TextContent) + if !ok { + continue + } + tc.Text = redactText(tc.Text) + } + return ctr, err + } } } -// redactValue is a stub: it returns v unchanged. Task 13's implementer -// replaces this with the JSON walk that masks secret fields. +// redactText re-marshals a JSON text block through redactValue. Text that is +// not JSON, such as an error message, passes through unchanged. +func redactText(text string) string { + var v any + if err := json.Unmarshal([]byte(text), &v); err != nil { + return text + } + raw, err := json.Marshal(redactValue(v)) + if err != nil { + return text + } + return string(raw) +} + +// redactValue walks a value as JSON and replaces every secret field. The +// round trip through JSON is what makes one walk cover every DTO shape, +// including a map a future tool returns. func redactValue(v any) any { + raw, err := json.Marshal(v) + if err != nil { + return v + } + var generic any + if err := json.Unmarshal(raw, &generic); err != nil { + return v + } + return walk(generic) +} + +func walk(v any) any { + switch t := v.(type) { + case map[string]any: + for k, val := range t { + if isSecretField(k) { + t[k] = maskSecret(val) + continue + } + t[k] = walk(val) + } + return t + case []any: + for i, val := range t { + t[i] = walk(val) + } + return t + } return v } + +func isSecretField(k string) bool { + for _, f := range secretFields { + if k == f { + return true + } + } + return false +} + +// maskSecret keeps the shape and drops the value, so a caller can still see +// which secrets a recipe declares and which of them are set. +func maskSecret(v any) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + out[k] = maskSecret(val) + } + return out + case nil: + return core.SecretUnset + case string: + if t == "" { + return core.SecretUnset + } + return core.SecretSet + } + return core.SecretSet +} diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index c3df15af..f5c959e0 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -63,8 +63,11 @@ func New(opts Options) *mcp.Server { s.registerGuestRead(server) s.registerGuestWrite(server) s.registerExec(server) - server.AddReceivingMiddleware(s.rateLimit()) - server.AddSendingMiddleware(s.redact()) + // redact must be receiving middleware, not sending: a Server's sending + // method handler only covers requests the server itself initiates + // (sampling, elicitation), never the CallToolResult built for an + // incoming tools/call request. See redact.go. + server.AddReceivingMiddleware(s.rateLimit(), s.redact()) return server } diff --git a/internal/mcpsrv/tools_vm.go b/internal/mcpsrv/tools_vm.go index 018c6f59..5ef34fe9 100644 --- a/internal/mcpsrv/tools_vm.go +++ b/internal/mcpsrv/tools_vm.go @@ -154,6 +154,10 @@ func (s *srv) registerVM(server *mcp.Server) { return wire.VM{}, err } patch, err := corePatch(name, in) + // A future logging or tracing middleware reads req.GetParams(), + // not this local in, so clearing it here does not by itself stop + // a leak; corePatch has already copied what it needs into patch. + in.Secrets = nil if err != nil { return wire.VM{}, err } From 881a42df6ec2b3c48183955a1d092ed0c589bfef Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 17:10:20 +0300 Subject: [PATCH 50/67] feat(cli): add stoat mcp and the two transports Signed-off-by: NovusEdge --- internal/cli/cli.go | 12 +++++++ internal/cli/grammar.go | 40 ++++++++++++++++++++++ internal/cli/run_mcp.go | 69 ++++++++++++++++++++++++++++++++++++++ internal/cli/wire/dto.go | 28 ++++++++++++++++ internal/mcpsrv/doctor.go | 28 ++++++++++++++++ internal/mcpsrv/install.go | 20 +++++++++++ internal/mcpsrv/server.go | 45 +++++++++++++++++++++++++ 7 files changed, 242 insertions(+) create mode 100644 internal/cli/run_mcp.go create mode 100644 internal/mcpsrv/doctor.go create mode 100644 internal/mcpsrv/install.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b5865fe8..7add2e3a 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -30,6 +30,7 @@ import ( "github.com/novusedge/stoat/internal/guest" "github.com/novusedge/stoat/internal/keys" "github.com/novusedge/stoat/internal/logx" + "github.com/novusedge/stoat/internal/mcpsrv" "github.com/novusedge/stoat/internal/recipes" ) @@ -136,6 +137,15 @@ type Args struct { Patch core.Patch Changed []string Params []ParamEdit + + // HTTP, Limits, Client, Project and Print belong to "mcp". HTTP is the + // loopback address for "mcp serve --http"; empty means stdio. Client, + // Project and Print belong to "mcp install". + HTTP string + Limits mcpsrv.Limits + Client string + Project bool + Print bool } // ParamEdit is one recipe parameter edit parsed from create or update flags. @@ -491,6 +501,8 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ return runUpdate(a, stdout, stderr) case "doctor": return runDoctor(a, stdout, stderr) + case "mcp": + return runMCP(a, version, stdout, stderr) default: // Unreachable: Parse already rejected anything not handled above. fmt.Fprintln(stderr, "stoat: unknown subcommand", a.Cmd) diff --git a/internal/cli/grammar.go b/internal/cli/grammar.go index 52592a29..a40ca2ce 100644 --- a/internal/cli/grammar.go +++ b/internal/cli/grammar.go @@ -7,6 +7,7 @@ import ( "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/mcpsrv" ) // The kong grammar: one struct per subcommand, tags instead of a hand-written @@ -60,10 +61,35 @@ type grammar struct { Logs logsCmd `cmd:"" help:"tail a VM's log, or stoat's own"` Screenshot screenshotCmd `cmd:"" help:"write the VM's screen to a PNG"` Doctor doctorCmd `cmd:"" help:"check host prerequisites"` + MCP mcpCmd `cmd:"" name:"mcp" help:"serve MCP, or configure a client to launch it"` Version versionCmd `cmd:"" help:"print the stoat version"` Help helpCmd `cmd:"" help:"show this message"` } +// mcpCmd defaults to serve, because every MCP client launches the server as +// "stoat mcp" with no subcommand. +type mcpCmd struct { + Serve mcpServeCmd `cmd:"" default:"withargs" help:"serve MCP over stdio"` + Install mcpInstallCmd `cmd:"" help:"write an MCP client's config entry"` + Doctor mcpDoctorCmd `cmd:"" help:"report contract, transport and client entries"` +} + +type mcpServeCmd struct { + HTTP string `name:"http" help:"serve streamable HTTP on this loopback address instead of stdio"` + ToolBurst int `name:"tool-burst" default:"30" help:"per-tool rate limit burst"` + ToolRate float64 `name:"tool-rate" default:"0.5" help:"per-tool refill, calls per second"` + Burst int `name:"burst" default:"60" help:"shared rate limit burst"` + Rate float64 `name:"rate" default:"2" help:"shared refill, calls per second"` +} + +type mcpInstallCmd struct { + Client string `arg:"" enum:"claude-code,claude-desktop,cursor,vscode" help:"which client's config to write"` + Project bool `help:"write .mcp.json in the current directory instead of the user's config"` + Print bool `help:"print the JSON instead of writing a file"` +} + +type mcpDoctorCmd struct{} + type helpCmd struct{} type lsCmd struct{} @@ -551,6 +577,20 @@ func (g *grammar) toArgs(path string) (*Args, error) { case "screenshot": a.VM, a.Out = g.Screenshot.VM, g.Screenshot.Out + case "mcp serve": + m := g.MCP.Serve + a.Cmd, a.Sub = "mcp", "serve" + a.HTTP = m.HTTP + a.Limits = mcpsrv.Limits{ToolBurst: m.ToolBurst, ToolRate: m.ToolRate, Burst: m.Burst, Rate: m.Rate} + + case "mcp install": + i := g.MCP.Install + a.Cmd, a.Sub = "mcp", "install" + a.Client, a.Project, a.Print = i.Client, i.Project, i.Print + + case "mcp doctor": + a.Cmd, a.Sub = "mcp", "doctor" + default: return nil, usageError("unknown subcommand " + path) } diff --git a/internal/cli/run_mcp.go b/internal/cli/run_mcp.go new file mode 100644 index 00000000..e11482ef --- /dev/null +++ b/internal/cli/run_mcp.go @@ -0,0 +1,69 @@ +package cli + +import ( + "context" + "fmt" + "io" + + "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/mcpsrv" +) + +// runMCP dispatches the mcp subcommands. serve blocks until the client +// disconnects or the context ends, so it emits no result line: the JSON +// contract is the tool traffic itself, not this command's own envelope. +func runMCP(a *Args, version string, stdout, stderr io.Writer) int { + switch a.Sub { + case "serve": + opts := mcpsrv.Options{Version: version, Limits: a.Limits} + var err error + if a.HTTP != "" { + err = mcpsrv.ServeHTTP(context.Background(), a.HTTP, opts) + } else { + err = mcpsrv.ServeStdio(context.Background(), opts) + } + if err != nil { + return a.fail(stdout, stderr, err) + } + return ExitOK + case "install": + report, err := mcpsrv.Install(a.Client, mcpsrv.InstallOpts{Project: a.Project, Print: a.Print}) + if err != nil { + return a.fail(stdout, stderr, err) + } + if a.Print { + fmt.Fprintln(stdout, report.JSON) + return ExitOK + } + if a.JSON { + return a.ok(stdout, report) + } + fmt.Fprintf(stdout, "wrote %s\n", report.Path) + return ExitOK + case "doctor": + r := mcpsrv.DoctorReport(version) + if a.JSON { + return a.ok(stdout, r) + } + fmt.Fprintf(stdout, "contract %d, transport %s\n", r.Contract, r.Transport) + fmt.Fprintf(stdout, "binary: %s\n", r.Binary) + for _, c := range r.Clients { + status := "not installed" + if c.Installed { + status = "installed" + if !c.Current { + status = "installed (stale)" + } + } + fmt.Fprintf(stdout, " %-14s %s\n", c.Client, status) + } + return ExitOK + } + // Unreachable: Parse rejects any Sub but serve/install/doctor. + if a.JSON { + _ = wire.NewEmitter(stdout).ResultErr(a.Cmd, wire.UsageError("mcp: unknown subcommand "+a.Sub)) + return ExitUsage + } + fmt.Fprintln(stderr, "stoat: mcp: unknown subcommand", a.Sub) + return ExitUsage +} diff --git a/internal/cli/wire/dto.go b/internal/cli/wire/dto.go index 2dcfbfc9..0ae255e5 100644 --- a/internal/cli/wire/dto.go +++ b/internal/cli/wire/dto.go @@ -858,6 +858,34 @@ type Job struct { Started time.Time `json:"started"` } +// MCPInstall is the `mcp install` command's output. +type MCPInstall struct { + Client string `json:"client"` + Path string `json:"path,omitempty"` + JSON string `json:"json"` +} + +// MCPClient is one row of MCPDoctor.Clients: whether the named MCP client +// has a stoat entry and whether that entry points at the binary that is +// running. A stale entry launches a different stoat than the one that wrote +// it, which is the failure `mcp doctor` exists to name. +type MCPClient struct { + Client string `json:"client"` + Path string `json:"path,omitempty"` + Installed bool `json:"installed"` + Command string `json:"command,omitempty"` + Current bool `json:"current"` +} + +// MCPDoctor is the `mcp doctor` command's output. +type MCPDoctor struct { + Contract int `json:"contract"` + Version string `json:"version"` + Transport string `json:"transport"` + Binary string `json:"binary"` + Clients []MCPClient `json:"clients"` +} + // JobList is the list_jobs tool's output. type JobList struct { Jobs []Job `json:"jobs"` diff --git a/internal/mcpsrv/doctor.go b/internal/mcpsrv/doctor.go new file mode 100644 index 00000000..2a073a7b --- /dev/null +++ b/internal/mcpsrv/doctor.go @@ -0,0 +1,28 @@ +package mcpsrv + +import ( + "os" + "path/filepath" + + "github.com/novusedge/stoat/internal/cli/wire" +) + +// DoctorReport says what this server is: the contract it speaks, the +// transport `mcp serve` uses by default, and the binary a client would +// launch. Client config detection (wire.MCPDoctor.Clients) arrives with +// Install, which reads the same files this reports on. +func DoctorReport(version string) wire.MCPDoctor { + bin, err := os.Executable() + if err == nil { + if abs, err := filepath.Abs(bin); err == nil { + bin = abs + } + } + return wire.MCPDoctor{ + Contract: Contract, + Version: version, + Transport: "stdio", + Binary: bin, + Clients: []wire.MCPClient{}, + } +} diff --git a/internal/mcpsrv/install.go b/internal/mcpsrv/install.go new file mode 100644 index 00000000..df8255e3 --- /dev/null +++ b/internal/mcpsrv/install.go @@ -0,0 +1,20 @@ +package mcpsrv + +import ( + "errors" + + "github.com/novusedge/stoat/internal/cli/wire" +) + +// InstallOpts controls where Install writes a client's config entry. +type InstallOpts struct { + Project bool + Print bool +} + +// Install writes the named MCP client's config entry so it launches this +// binary. The client config formats (claude-code, claude-desktop, cursor, +// vscode) are not implemented yet. +func Install(client string, opts InstallOpts) (wire.MCPInstall, error) { + return wire.MCPInstall{}, errors.New("mcp install: not implemented yet") +} diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index 92c0b1e8..613f7012 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -2,7 +2,10 @@ package mcpsrv import ( "context" + "errors" "fmt" + "net" + "net/http" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/novusedge/stoat/internal/cli/wire" @@ -117,6 +120,48 @@ func clampInt(v, lo, hi int) int { return max(lo, min(v, hi)) } +// ServeStdio runs the server over stdio, which is how every MCP client +// launches a server as a subprocess. +func ServeStdio(ctx context.Context, opts Options) error { + return New(opts).Run(ctx, &mcp.StdioTransport{}) +} + +// CheckLoopback refuses a bind that is not loopback. This server has no +// authentication, so the bind is the boundary. func CheckLoopback(addr string) error { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("invalid address %q: %w", addr, err) + } + if host == "" { + return fmt.Errorf("address %q binds every interface; use 127.0.0.1", addr) + } + if host == "localhost" { + return nil + } + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("address %q is not loopback; this server has no authentication", addr) + } + return nil +} + +// ServeHTTP runs the server over streamable HTTP for a client that cannot +// launch a subprocess. One server instance serves every request, so the +// rate limiter's buckets are shared across connections. +func ServeHTTP(ctx context.Context, addr string, opts Options) error { + if err := CheckLoopback(addr); err != nil { + return err + } + server := New(opts) + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, nil) + hs := &http.Server{Addr: addr, Handler: handler} + go func() { + <-ctx.Done() + _ = hs.Close() + }() + if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } return nil } From bdf6e7c29a557bad04670181b2f51799318f2f12 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 17:13:07 +0300 Subject: [PATCH 51/67] feat(mcp): add guest and recipe schema tools Signed-off-by: NovusEdge --- internal/mcpsrv/server.go | 38 +++++++++++++++++++++++++++++++++-- internal/mcpsrv/tools_read.go | 35 +++++++++++++++++++++----------- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/internal/mcpsrv/server.go b/internal/mcpsrv/server.go index 6a4e2564..47d94fd3 100644 --- a/internal/mcpsrv/server.go +++ b/internal/mcpsrv/server.go @@ -3,6 +3,7 @@ package mcpsrv import ( "context" "fmt" + "reflect" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/novusedge/stoat/internal/cli/wire" @@ -96,13 +97,46 @@ func register[In, Out any](server *mcp.Server, name string, c class, description mcp.AddTool(server, tool, func(ctx context.Context, _ *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) { out, err := h(ctx, in) if err != nil { - var zero Out - return toolError(err), zero, nil + return toolError(err), zeroOut[Out](), nil } return nil, out, nil }) } +// zeroOut builds Out's zero value with every nil map and slice field +// replaced by an empty one. The SDK validates a tool's output against its +// generated schema even on the error path, and a field without an +// `omitempty` tag is required as an object or array there; Out's bare zero +// value carries nil for those and fails that check. +func zeroOut[Out any]() Out { + var out Out + fillEmpty(reflect.ValueOf(&out).Elem()) + return out +} + +func fillEmpty(v reflect.Value) { + switch v.Kind() { + case reflect.Struct: + for i := range v.NumField() { + if v.Type().Field(i).IsExported() { + fillEmpty(v.Field(i)) + } + } + case reflect.Map: + if v.IsNil() { + v.Set(reflect.MakeMap(v.Type())) + } + case reflect.Slice: + if v.IsNil() { + v.Set(reflect.MakeSlice(v.Type(), 0, 0)) + } + case reflect.Pointer: + if !v.IsNil() { + fillEmpty(v.Elem()) + } + } +} + // toolError renders an error the way the CLI prints it. wire.MapError gives // the same text a --json result line carries, so an agent reading a tool // failure and a user reading the CLI see one message. diff --git a/internal/mcpsrv/tools_read.go b/internal/mcpsrv/tools_read.go index c4d94642..6c17f73b 100644 --- a/internal/mcpsrv/tools_read.go +++ b/internal/mcpsrv/tools_read.go @@ -8,6 +8,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/novusedge/stoat/internal/cli/wire" "github.com/novusedge/stoat/internal/core" + "github.com/novusedge/stoat/internal/recipes" ) type emptyIn struct{} @@ -142,29 +143,39 @@ func (s *srv) registerRead(server *mcp.Server) { return wire.ApplyPlanList{Plan: wire.FromApplyPlans(plans)}, nil }) - // Stubs: Task 14 implements these bodies over core.Guests, core.Guest - // and core.RecipeShow. register(server, "list_guests", classRead, "List every guest OS definition stoat knows: name, init system, package manager, default backend and whether the definition is bundled, a user file, or a user file merged over a bundled one. It reads the guest definitions only. Read-only.", func(ctx context.Context, _ emptyIn) (wire.GuestList, error) { - return wire.GuestList{}, nil + // core.Guests returns no error: the bundled set is embedded and + // a user file that fails to parse was already refused at startup. + return wire.GuestList{Guests: wire.FromGuests(core.Guests())}, nil }) register(server, "guest_info", classRead, "Show one guest OS definition in full: init system, shell, escalate argv, capabilities, aliases, seed packages, the package manager verbs, the service verbs and the per-backend tables. Use it to learn what pkg_install and svc will run on a VM before you call them. Read-only.", - func(ctx context.Context, _ nameIn) (wire.Guest, error) { - // The generated output schema requires the map fields as objects, - // not null, so the stub sets them empty rather than nil. - return wire.Guest{ - Svc: map[string]string{}, Cmd: map[string]string{}, Backend: map[string]map[string]any{}, - Pkg: wire.GuestPkg{Env: map[string]string{}, RuntimePackages: map[string]string{}}, - }, nil + func(ctx context.Context, in nameIn) (wire.Guest, error) { + g, err := core.Guest(in.Name) + if err != nil { + return wire.Guest{}, err + } + return wire.FromGuest(g), nil }) register(server, "recipe_schema", classRead, "Show one recipe's contract: its params with type, default and help, its declared outputs, and its health check. Read it before update sets params on a VM. Read-only.", - func(ctx context.Context, _ nameIn) (wire.RecipeSchema, error) { - return wire.RecipeSchema{}, nil + func(ctx context.Context, in nameIn) (wire.RecipeSchema, error) { + // The CLI's dispatch loop installs bundled recipes to the data + // root before every command; the mcp server has no equivalent + // entrypoint yet, so a bundled recipe is otherwise invisible to + // ManifestFor on a data root nothing has touched yet. + if err := recipes.Install(); err != nil { + return wire.RecipeSchema{}, err + } + r, err := core.RecipeShow(in.Name) + if err != nil { + return wire.RecipeSchema{}, err + } + return wire.FromRecipeSchema(r), nil }) } From d06c0ade2113d40da8bb344cb6171d326a3fae12 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 21:45:23 +0300 Subject: [PATCH 52/67] feat(mcp): add search_recipes and finish task 14 Signed-off-by: NovusEdge --- internal/core/remote_recipes.go | 6 ++++++ internal/mcpsrv/table_test.go | 9 +-------- internal/mcpsrv/tools_read.go | 17 +++++++++++++++++ internal/mcpsrv/tools_read_test.go | 26 ++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/internal/core/remote_recipes.go b/internal/core/remote_recipes.go index 49a75359..99917527 100644 --- a/internal/core/remote_recipes.go +++ b/internal/core/remote_recipes.go @@ -13,6 +13,12 @@ import ( // ErrLockOutOfDate identifies a project declaration that is not pinned. var ErrLockOutOfDate = errors.New("stoat.lock is out of date") +// SearchRecipes matches the curated index by name and description. It +// refreshes the local index copy when that copy is older than a day. +func SearchRecipes(term string) ([]recipes.IndexEntry, error) { + return recipes.SearchIndex(term) +} + // SyncRecipes validates the current project pin and repairs its cache only // when a lock entry is missing or no longer matches the active checkout. // Global recipe state is not touched by an apply in a non-project directory. diff --git a/internal/mcpsrv/table_test.go b/internal/mcpsrv/table_test.go index 8e4e736d..8572ae4c 100644 --- a/internal/mcpsrv/table_test.go +++ b/internal/mcpsrv/table_test.go @@ -74,11 +74,4 @@ var forbiddenInputFields = []string{"share", "base", "iso", "console_password", // pending names tools a later task registers. Every entry is removed by the // task that adds the tool; Task 18 asserts the list is empty. -// -// Tasks 7, 10, 11, 12 and 15 own the rest of this chunk's tools and are gone -// from this map already: their tests assert real registration, which does -// not exist yet, so TestEveryTableToolIsRegistered fails for them until -// their implementer lands. -var pending = map[string]string{ - "search_recipes": "Task 14", -} +var pending = map[string]string{} diff --git a/internal/mcpsrv/tools_read.go b/internal/mcpsrv/tools_read.go index 6c17f73b..3c6bfdd2 100644 --- a/internal/mcpsrv/tools_read.go +++ b/internal/mcpsrv/tools_read.go @@ -43,6 +43,10 @@ type nameIn struct { Name string `json:"name" jsonschema:"name to look up"` } +type searchIn struct { + Term string `json:"term" jsonschema:"text to match against recipe names and descriptions"` +} + func (s *srv) registerRead(server *mcp.Server) { register(server, "list_vms", classRead, "List every VM stoat manages, one entry per VM: name, OS, mode, state, resources, disk, share, ssh port, agent access level, recipes, and port forwards. A VM whose vm.toml failed to parse is listed with state broken and an error message rather than hidden. Read-only: it touches no VM and changes nothing on the host.", @@ -177,6 +181,19 @@ func (s *srv) registerRead(server *mcp.Server) { } return wire.FromRecipeSchema(r), nil }) + + register(server, "search_recipes", classRead, + "Search the recipe index by name and description. It refreshes the local index copy when that copy is older than 24 hours. It installs nothing. Read-only.", + func(ctx context.Context, in searchIn) (wire.RecipeSearch, error) { + if err := checkFlagFree([]string{in.Term}, "term"); err != nil { + return wire.RecipeSearch{}, err + } + rs, err := core.SearchRecipes(in.Term) + if err != nil { + return wire.RecipeSearch{}, err + } + return wire.RecipeSearch{Recipes: wire.FromIndexEntries(rs)}, nil + }) } // tailLines returns the last n lines of r. core.Logs streams the whole file, diff --git a/internal/mcpsrv/tools_read_test.go b/internal/mcpsrv/tools_read_test.go index bffd7fce..b8f9b0b2 100644 --- a/internal/mcpsrv/tools_read_test.go +++ b/internal/mcpsrv/tools_read_test.go @@ -72,3 +72,29 @@ func TestRecipeSchemaListsParams(t *testing.T) { t.Fatalf("recipe_schema(docker) params missing user default dev: %s", raw) } } + +func TestSearchRecipesRefusesAFlagTerm(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + if res := callTool(t, "search_recipes", map[string]any{"term": "--refresh"}); !res.IsError { + t.Fatal("search_recipes accepted a term that reads as a flag") + } +} + +func TestVMStatusReportsRecipes(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "manage") + res := callTool(t, "vm_status", map[string]any{"vm": "dev"}) + if res.IsError { + t.Fatalf("vm_status failed: %+v", res.Content) + } + raw, _ := json.Marshal(res.StructuredContent) + var out struct { + RecipeStates []any `json:"recipes_detail"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if out.RecipeStates == nil { + t.Fatalf("recipes_detail is null, want an empty list: %s", raw) + } +} From 3fbab5e00fa7ffec5482186138835de66cf237c4 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 21:46:44 +0300 Subject: [PATCH 53/67] fix(mcp): keep mcpsrv off qemu and recipes imports Signed-off-by: NovusEdge --- internal/core/apply.go | 9 +++++++++ internal/core/vm.go | 10 ++++++++++ internal/mcpsrv/guards.go | 7 ++----- internal/mcpsrv/tools_read.go | 10 ++++------ 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/internal/core/apply.go b/internal/core/apply.go index ced2a87c..010f7c26 100644 --- a/internal/core/apply.go +++ b/internal/core/apply.go @@ -665,6 +665,15 @@ type RecipeHealthSpec struct { Timeout string } +// EnsureRecipes installs the bundled recipe set into the data root. The +// CLI's dispatch loop calls recipes.Install before every command; a caller +// with no equivalent entrypoint, such as mcpsrv, calls this once at startup +// so a bundled recipe is not invisible to RecipeShow and Recipes on a data +// root nothing else has touched yet. +func EnsureRecipes() error { + return recipes.Install() +} + // RecipeShow is the host-side lookup for one recipe's contract. func RecipeShow(name string) (Recipe, error) { m, ok, err := recipes.ManifestFor(name) diff --git a/internal/core/vm.go b/internal/core/vm.go index f53c1b4f..59149eee 100644 --- a/internal/core/vm.go +++ b/internal/core/vm.go @@ -502,6 +502,16 @@ func Start(name string) error { return qemu.Start(v) } +// EnsureRunning refuses with ErrNotRunning when v is not running. It exists +// so a caller above core, such as mcpsrv, answers the same error Stop and +// Exec give without importing qemu.Running itself. +func EnsureRunning(v *config.VM) error { + if !qemu.Running(v) { + return fmt.Errorf("%w: %s", ErrNotRunning, v.Name) + } + return nil +} + // Stop powers down VM name. qemu.Stop treats "already stopped" as a // successful no-op; Stop does not. The CLI's `down` (internal/cli/cli.go's // runDown) already refuses a stopped VM as a failure, and Stop preserves diff --git a/internal/mcpsrv/guards.go b/internal/mcpsrv/guards.go index f765cf18..303c4f41 100644 --- a/internal/mcpsrv/guards.go +++ b/internal/mcpsrv/guards.go @@ -11,7 +11,7 @@ import ( "strings" "github.com/novusedge/stoat/internal/config" - "github.com/novusedge/stoat/internal/qemu" + "github.com/novusedge/stoat/internal/core" ) // A VM name becomes a directory name under the data root, so the pattern is @@ -232,8 +232,5 @@ func checkEnvName(name string) (string, error) { // stopped VM. Without it, sshx.Run against a stopped VM's forwarded port // surfaces ssh's own connection-refused exit rather than this error. func requireRunning(v *config.VM) error { - if !qemu.Running(v) { - return fmt.Errorf("%w: %s", qemu.ErrNotRunning, v.Name) - } - return nil + return core.EnsureRunning(v) } diff --git a/internal/mcpsrv/tools_read.go b/internal/mcpsrv/tools_read.go index 3c6bfdd2..249f8543 100644 --- a/internal/mcpsrv/tools_read.go +++ b/internal/mcpsrv/tools_read.go @@ -8,7 +8,6 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/novusedge/stoat/internal/cli/wire" "github.com/novusedge/stoat/internal/core" - "github.com/novusedge/stoat/internal/recipes" ) type emptyIn struct{} @@ -168,11 +167,10 @@ func (s *srv) registerRead(server *mcp.Server) { register(server, "recipe_schema", classRead, "Show one recipe's contract: its params with type, default and help, its declared outputs, and its health check. Read it before update sets params on a VM. Read-only.", func(ctx context.Context, in nameIn) (wire.RecipeSchema, error) { - // The CLI's dispatch loop installs bundled recipes to the data - // root before every command; the mcp server has no equivalent - // entrypoint yet, so a bundled recipe is otherwise invisible to - // ManifestFor on a data root nothing has touched yet. - if err := recipes.Install(); err != nil { + // core.EnsureRecipes installs the bundled set: mcpsrv has no + // startup entrypoint of its own to do this once, unlike the + // CLI's dispatch loop. + if err := core.EnsureRecipes(); err != nil { return wire.RecipeSchema{}, err } r, err := core.RecipeShow(in.Name) From e71a18fa679210143fa0f2175375d4dc5768bcdd Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 21:48:29 +0300 Subject: [PATCH 54/67] fix(mcp): mask secrets nested inside a list Signed-off-by: NovusEdge --- internal/mcpsrv/redact.go | 6 ++++++ internal/mcpsrv/redact_test.go | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/internal/mcpsrv/redact.go b/internal/mcpsrv/redact.go index 86fd827f..3a9780eb 100644 --- a/internal/mcpsrv/redact.go +++ b/internal/mcpsrv/redact.go @@ -117,6 +117,12 @@ func maskSecret(v any) any { out[k] = maskSecret(val) } return out + case []any: + out := make([]any, len(t)) + for i, val := range t { + out[i] = maskSecret(val) + } + return out case nil: return core.SecretUnset case string: diff --git a/internal/mcpsrv/redact_test.go b/internal/mcpsrv/redact_test.go index 3da8cc19..685afb1d 100644 --- a/internal/mcpsrv/redact_test.go +++ b/internal/mcpsrv/redact_test.go @@ -63,6 +63,24 @@ func TestRedactValueReplacesSecretFields(t *testing.T) { } } +func TestRedactValueMasksSecretsInsideAList(t *testing.T) { + in := map[string]any{ + "items": []any{map[string]any{"authkey": sentinel}}, + "secrets": []any{sentinel, sentinel}, + } + out := redactValue(in).(map[string]any) + items := out["items"].([]any) + if items[0].(map[string]any)["authkey"] != core.SecretSet { + t.Fatalf("a secret nested inside a list element must be masked, got %v", items) + } + secrets := out["secrets"].([]any) + for _, v := range secrets { + if v != core.SecretSet { + t.Fatalf("a list-valued secrets field must mask every element, got %v", secrets) + } + } +} + func TestUpdateNeverEchoesASecret(t *testing.T) { t.Setenv("STOAT_HOME", t.TempDir()) writeVM(t, "dev", "manage") From 99961b18107b2549027dad9b41f4f717aba25eb5 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 21:50:17 +0300 Subject: [PATCH 55/67] fix(mcp): drop the dead secrets clear in update Signed-off-by: NovusEdge --- internal/mcpsrv/tools_vm.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/mcpsrv/tools_vm.go b/internal/mcpsrv/tools_vm.go index 5ef34fe9..018c6f59 100644 --- a/internal/mcpsrv/tools_vm.go +++ b/internal/mcpsrv/tools_vm.go @@ -154,10 +154,6 @@ func (s *srv) registerVM(server *mcp.Server) { return wire.VM{}, err } patch, err := corePatch(name, in) - // A future logging or tracing middleware reads req.GetParams(), - // not this local in, so clearing it here does not by itself stop - // a leak; corePatch has already copied what it needs into patch. - in.Secrets = nil if err != nil { return wire.VM{}, err } From c0021a5bae4603bff67df607d0896ab7edc9e5f7 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 21:55:39 +0300 Subject: [PATCH 56/67] test(mcp): cover mcp doctor and install --print in JSON Signed-off-by: NovusEdge --- internal/cli/json_test.go | 6 ++++++ internal/cli/run_mcp_test.go | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/internal/cli/json_test.go b/internal/cli/json_test.go index 1550e278..54773a10 100644 --- a/internal/cli/json_test.go +++ b/internal/cli/json_test.go @@ -95,6 +95,9 @@ func TestJSONEnvelopeEveryCommand(t *testing.T) { // The fixture VM is stopped, so the honest answer is not_running; the // point here is that the command answers in the envelope at all. {name: "screenshot stopped", argv: []string{"screenshot", "work"}, code: wire.CodeNotRunning, exit: ExitFail}, + // mcp serve blocks on a transport and speaks MCP, not the --json + // envelope, so it has no row here. + {name: "mcp doctor", argv: []string{"mcp", "doctor"}, ok: true, exit: ExitOK}, {name: "up unknown", argv: []string{"up", "nope"}, code: wire.CodeNotFound, exit: ExitFail}, {name: "down stopped", argv: []string{"down", "work"}, code: wire.CodeNotRunning, exit: ExitFail}, @@ -110,6 +113,9 @@ func TestJSONEnvelopeEveryCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cliRoot(t) + if tt.name == "mcp doctor" { + t.Setenv("HOME", t.TempDir()) + } if tt.name == "recipe show" { if err := recipes.Install(); err != nil { t.Fatal(err) diff --git a/internal/cli/run_mcp_test.go b/internal/cli/run_mcp_test.go index 2585417b..4bb2652b 100644 --- a/internal/cli/run_mcp_test.go +++ b/internal/cli/run_mcp_test.go @@ -2,6 +2,8 @@ package cli import ( "bytes" + "os" + "path/filepath" "strings" "testing" @@ -65,3 +67,21 @@ func TestMCPDoctorJSON(t *testing.T) { } } } + +// TestMCPInstallPrintWritesNoFile pins --print's own contract: the client +// entry goes to stdout and configPath's file is never touched, regardless of +// the --json flag. +func TestMCPInstallPrintWritesNoFile(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + home := t.TempDir() + t.Setenv("HOME", home) + out := runCLI(t, "--json", "mcp", "install", "claude-code", "--print") + for _, key := range []string{`"command"`, `"args"`, `"cwd"`, `"mcpServers"`} { + if !strings.Contains(out, key) { + t.Errorf("mcp install --print output has no %s: %s", key, out) + } + } + if _, err := os.Stat(filepath.Join(home, ".claude.json")); !os.IsNotExist(err) { + t.Errorf("mcp install --print wrote %s: %v", filepath.Join(home, ".claude.json"), err) + } +} From 6d771484da2dd613d43cb73267d67945e530fd4c Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 21:58:22 +0300 Subject: [PATCH 57/67] chore(mcp): delete the ported Python server Signed-off-by: NovusEdge --- internal/mcpsrv/porttable_test.go | 84 ++++ mcp/.gitignore | 4 - mcp/README.md | 50 -- mcp/pyproject.toml | 25 - mcp/stoat_mcp/__init__.py | 22 - mcp/stoat_mcp/client.py | 255 ---------- mcp/stoat_mcp/errors.py | 87 ---- mcp/stoat_mcp/guards.py | 271 ----------- mcp/stoat_mcp/server.py | 753 ------------------------------ mcp/tests/__init__.py | 0 mcp/tests/test_client.py | 290 ------------ mcp/tests/test_guards.py | 412 ---------------- mcp/tests/test_server.py | 235 ---------- 13 files changed, 84 insertions(+), 2404 deletions(-) create mode 100644 internal/mcpsrv/porttable_test.go delete mode 100644 mcp/.gitignore delete mode 100644 mcp/README.md delete mode 100644 mcp/pyproject.toml delete mode 100644 mcp/stoat_mcp/__init__.py delete mode 100644 mcp/stoat_mcp/client.py delete mode 100644 mcp/stoat_mcp/errors.py delete mode 100644 mcp/stoat_mcp/guards.py delete mode 100644 mcp/stoat_mcp/server.py delete mode 100644 mcp/tests/__init__.py delete mode 100644 mcp/tests/test_client.py delete mode 100644 mcp/tests/test_guards.py delete mode 100644 mcp/tests/test_server.py diff --git a/internal/mcpsrv/porttable_test.go b/internal/mcpsrv/porttable_test.go new file mode 100644 index 00000000..0e603a35 --- /dev/null +++ b/internal/mcpsrv/porttable_test.go @@ -0,0 +1,84 @@ +package mcpsrv + +import ( + "os/exec" + "strings" + "testing" +) + +// portedTests names every test in mcp/tests and the Go test that replaced +// it. Two Python tests have no counterpart: they assert that a non-string +// argument is refused, which Go's type system makes unreachable. +var portedTests = map[string]string{ + "test_vm_name_accepts_ordinary_names": "TestCheckVMName", + "test_vm_name_rejects_bad_input": "TestCheckVMName", + "test_vm_name_rejects_non_string": "", + "test_vm_name_rejects_absolute_path": "TestCheckVMName", + "test_vm_name_rejects_unicode_lookalike_separator": "TestCheckVMName", + "test_image_id_accepts_catalog_ids": "TestCheckImageID", + "test_image_id_rejects_paths": "TestCheckImageID", + "test_image_id_rejects_non_string": "", + "test_host_path_accepts_file_inside_sandbox": "TestCheckHostPath", + "test_host_path_accepts_the_sandbox_root_itself": "TestCheckHostPath", + "test_host_path_rejects_relative_path": "TestCheckHostPath", + "test_host_path_rejects_traversal_out_of_sandbox": "TestCheckHostPath", + "test_host_path_rejects_absolute_path_entirely_outside": "TestCheckHostPath", + "test_host_path_rejects_sibling_sharing_a_string_prefix": "TestCheckHostPath", + "test_host_path_rejects_symlink_escape": "TestCheckHostPath", + "test_host_path_rejects_symlink_pointing_directly_out": "TestCheckHostPath", + "test_host_path_accepts_symlink_that_stays_inside_sandbox": "TestCheckHostPath", + "test_host_path_rejects_empty_and_whitespace": "TestCheckHostPath", + "test_host_path_rejects_null_byte": "TestCheckHostPath", + "test_host_path_expands_tilde_to_the_callers_home_not_ours": "TestCheckHostPath", + "test_host_path_different_vm_names_have_disjoint_sandboxes": "TestCheckHostPath", + "test_host_path_rejects_invalid_vm_name_even_as_a_path_component": "TestCheckHostPath", + "test_strip_forbidden_removes_every_forbidden_key": "TestStripForbidden", + "test_strip_forbidden_is_a_no_op_on_a_clean_patch": "TestStripForbidden", + "test_strip_forbidden_does_not_mutate_the_input": "TestStripForbidden", + "test_check_flag_free_rejects_a_kong_flag": "TestCheckFlagFree", + "test_check_flag_free_rejects_a_short_flag_and_empty_values": "TestCheckFlagFree", + "test_check_flag_free_passes_ordinary_values": "TestCheckFlagFree", + "test_rate_limiter_allows_up_to_capacity": "TestRateLimiter", + "test_rate_limiter_buckets_are_independent_per_tool": "TestRateLimiter", + "test_rate_limiter_refills_over_time": "TestRateLimiter", + "test_rate_limiter_shared_bucket_bounds_every_tool_together": "TestRateLimiter", + "test_rate_limiter_refused_tool_does_not_spend_a_shared_token": "TestRateLimiter", + "test_every_expected_tool_is_registered": "TestEveryTableToolIsRegistered", + "test_no_unexpected_tools_are_registered": "TestNoToolOutsideTheTable", + "test_forbidden_surfaces_are_not_registered_at_all": "TestForbiddenSurfacesAbsent", + "test_tool_schema_sets_additional_properties_false": "TestInputSchemaRejectsAdditionalProperties", + "test_annotations_match_the_spec_table": "TestAnnotationsMatchTable", + "test_no_tool_has_a_parameter_named_share": "TestNoForbiddenInputField", + "test_no_tool_has_a_parameter_named_console_password_or_image_path_flags": "TestNoForbiddenInputField", + "test_create_only_accepts_a_catalog_image_id_field_named_image": "TestCreateTakesCatalogImageIDOnly", + "test_every_tool_has_a_non_empty_human_readable_description": "TestEveryToolHasDescription", + "test_no_tool_description_contains_an_em_dash": "TestNoEmDashInDescription", + "test_apply_recipes_refuses_a_vm_with_allow_exec_false": "TestRequireAccess", + "test_plan_recipes_runs_a_dry_run_and_needs_no_exec_permission": "TestPlanRecipesNeedsNoAccess", + "test_wait_clamps_the_timeout": "TestWaitClampsTimeout", + "test_logs_clamps_the_line_count": "TestLogsClampsLines", + "test_forward_refuses_a_pair_that_kong_reads_as_a_flag": "TestForwardRefusesFlagPair", +} + +// TestEveryPortedTestExists asserts each named Go test is in this package's +// test binary. It is what makes deleting mcp/ safe: a row naming a test that +// does not exist fails here rather than being discovered after the Python is +// gone. +func TestEveryPortedTestExists(t *testing.T) { + out, err := exec.Command("go", "test", "-list", ".*", ".").CombinedOutput() + if err != nil { + t.Fatalf("go test -list: %v\n%s", err, out) + } + have := map[string]bool{} + for _, line := range strings.Split(string(out), "\n") { + have[strings.TrimSpace(line)] = true + } + for py, goTest := range portedTests { + if goTest == "" { + continue + } + if !have[goTest] { + t.Errorf("%s maps to %s, which this package does not define", py, goTest) + } + } +} diff --git a/mcp/.gitignore b/mcp/.gitignore deleted file mode 100644 index 17cd2fc3..00000000 --- a/mcp/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -.venv/ -__pycache__/ -*.pyc -.pytest_cache/ diff --git a/mcp/README.md b/mcp/README.md deleted file mode 100644 index 25c82d86..00000000 --- a/mcp/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# stoat-mcp - -An MCP server that exposes local QEMU VMs managed by `stoat` to an agent. - -It is a thin wrapper: every tool validates its inputs, runs the `stoat` -binary with `--json`, and returns the resulting `data` object. No business -logic lives here; if a tool needed logic `stoat` itself does not have, that -would mean the layering is wrong, and the fix belongs in `stoat`'s Go core, -not in this package. See `docs/design/mcp-server.md` in the main repo for the -full design rationale, and `docs/reference/json.md` for the wire contract -this package decodes. - -## What this is not - -No HTTP transport (stdio only), no authentication, no multi-user support, -and no tool that writes to the host outside `~/.stoat/shared//`. Some -CLI surfaces are deliberately never exposed as tools at all: a `share` -parameter on any mutating call, bring-your-own image paths, `recipe new`, -`ssh-command`, and the global (no VM name) `logs`. Their absence is the -control, not a runtime check. - -## Setup - -``` -cd mcp -uv venv -uv pip install -e '.[dev]' -``` - -## Configuration - -- `STOAT_BIN`: path to the `stoat` binary. Falls back to `PATH`. -- `STOAT_HOME`: the stoat data root. Falls back to `~/.stoat`. - -## Running - -``` -.venv/bin/stoat-mcp -``` - -Serves over stdio. Refuses to start if the `stoat` binary cannot be found, -or if its JSON contract version does not match what this server was written -for. - -## Testing - -``` -cd mcp -.venv/bin/pytest -q -``` diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml deleted file mode 100644 index 58c8b246..00000000 --- a/mcp/pyproject.toml +++ /dev/null @@ -1,25 +0,0 @@ -[project] -name = "stoat-mcp" -version = "0.1.0" -description = "MCP server for stoat, exposing local QEMU VMs to an agent" -readme = "README.md" -requires-python = ">=3.11" -license = { text = "AGPL-3.0-or-later" } -authors = [{ name = "Aliasgar Khimani" }] -dependencies = ["fastmcp>=2.0"] - -[project.scripts] -stoat-mcp = "stoat_mcp.server:main" - -[project.optional-dependencies] -dev = ["pytest>=8.0"] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["stoat_mcp"] - -[tool.pytest.ini_options] -testpaths = ["tests"] diff --git a/mcp/stoat_mcp/__init__.py b/mcp/stoat_mcp/__init__.py deleted file mode 100644 index 5ce11233..00000000 --- a/mcp/stoat_mcp/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""stoat-mcp: an MCP server over the stoat CLI's JSON contract. - -It runs the `stoat` binary with `--json` and decodes JSON Lines. It never -links Go and never reads ~/.stoat directly, so the CLI's contract -(docs/reference/json.md) is the only coupling. - -The layering rule, from docs/design/mcp-server.md: this is a thin mapping. If -a tool needs logic `core` does not have, the layering is wrong and the fix -belongs in `core`, not here. -""" - -from .client import EXPECTED_CONTRACT, Client -from .errors import ContractMismatch, GuardRejection, StoatCrashed, StoatError - -__all__ = [ - "Client", - "ContractMismatch", - "EXPECTED_CONTRACT", - "GuardRejection", - "StoatCrashed", - "StoatError", -] diff --git a/mcp/stoat_mcp/client.py b/mcp/stoat_mcp/client.py deleted file mode 100644 index 0b6e2efc..00000000 --- a/mcp/stoat_mcp/client.py +++ /dev/null @@ -1,255 +0,0 @@ -"""The only place that runs the stoat binary. - -Everything here is dictated by docs/reference/json.md rather than chosen. The -four rules that matter, and why each is not a preference: - -1. stdout only, stderr to DEVNULL. Errors arrive in the result envelope on - stdout. A consumer that merges two pipes to reconstruct one result - eventually interleaves them wrong, and reading two pipes sequentially - deadlocks when either buffer fills. -2. Exactly one line has type "result" and it is last. Read to EOF, keep it. -3. An unrecognized event type is SKIPPED, never an error. That is what makes - adding a new event type a non-breaking change. -4. The exit code is consulted for exactly one thing: whether a result line - arrived at all. Never branch on it otherwise; exec deliberately exits 0 - while reporting a nonzero guest status in the payload. -""" - -from __future__ import annotations - -import json -import os -import selectors -import signal -import shutil -import subprocess -import time -from collections.abc import Callable, Iterator, Sequence -from typing import Any - -from .errors import ContractMismatch, StoatCrashed, StoatError - -# Bumped only when json.md's "v" bumps, which happens only for a removal or a -# meaning change. Additions never bump it, so this does not track features. -# -# v2: the recipe system moved to directories with a recipe.toml manifest, and -# Recipe lost label, target_os and shared. There is no v1 path on either side; -# a server built for v1 refuses to start against a v2 binary and vice versa, -# which is the entire point of checking this at startup. -EXPECTED_CONTRACT = 3 - -# Non-terminal event types we understand. Anything else is skipped per rule 3. -EVENT_PROGRESS = "progress" -EVENT_STAGE = "stage" -EVENT_LOG = "log" - -# Called with (event_type, data) for each non-terminal line. -EventHandler = Callable[[str, dict[str, Any]], None] - - -def find_binary() -> str: - """Resolve the stoat binary: $STOAT_BIN, else PATH. - - Fails here rather than at first tool call, so a misconfigured server is a - startup error with an actionable message. - """ - override = os.environ.get("STOAT_BIN") - if override: - if not os.path.isfile(override) or not os.access(override, os.X_OK): - raise FileNotFoundError(f"STOAT_BIN={override} is not an executable file") - return override - found = shutil.which("stoat") - if not found: - raise FileNotFoundError( - "stoat not found on PATH and STOAT_BIN is unset. " - "Install stoat or point STOAT_BIN at the binary." - ) - return found - - -class Client: - """Runs stoat subcommands and decodes the JSON Lines envelope.""" - - def __init__(self, binary: str | None = None, default_timeout: float | None = 120.0) -> None: - self.binary = binary or find_binary() - self.default_timeout = default_timeout - - def check_contract(self) -> int: - """Verify the binary speaks the contract version we were written for. - - Called once at startup. One subprocess, and it turns "confusing - KeyError three tools later" into "refused to start, here is why". - """ - data = self.run("version") - found = data.get("contract") - if found != EXPECTED_CONTRACT: - raise ContractMismatch(found if isinstance(found, int) else -1, EXPECTED_CONTRACT) - return found - - def run( - self, - *args: str, - timeout: float | None = None, - on_event: EventHandler | None = None, - ) -> dict[str, Any]: - """Run one stoat subcommand and return its result `data`. - - Raises StoatError when the command answered ok:false, and StoatCrashed - when no result line arrived at all. - """ - result: dict[str, Any] | None = None - stdout_lines: list[str] = [] - - proc = subprocess.Popen( - [self.binary, "--json", *args], - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - text=False, - bufsize=0, - start_new_session=(os.name == "posix"), - ) - effective_timeout = timeout if timeout is not None else self.default_timeout - deadline = None if effective_timeout is None else time.monotonic() + effective_timeout - selector: selectors.BaseSelector | None = None - returncode: int | None = None - try: - assert proc.stdout is not None - selector = selectors.DefaultSelector() - selector.register(proc.stdout, selectors.EVENT_READ) - buffer = bytearray() - - def consume(line: bytes) -> None: - nonlocal result - decoded = line.decode("utf-8", errors="replace") - stdout_lines.append(decoded) - obj = _decode(decoded) - if obj is None: - return - kind = obj.get("type") - if kind == "result": - # Keep the LAST one. The contract guarantees exactly one, - # but overwriting rather than breaking means a future - # violation degrades to "used the final answer" instead of - # "silently used a stale one". - result = obj - return - if on_event is not None and kind in (EVENT_PROGRESS, EVENT_STAGE, EVENT_LOG): - on_event(kind, obj.get("data") or {}) - # Any other type is skipped: rule 3. - - while True: - remaining = None if deadline is None else deadline - time.monotonic() - if remaining is not None and remaining <= 0: - raise subprocess.TimeoutExpired(proc.args, effective_timeout) - if not selector.select(remaining): - raise subprocess.TimeoutExpired(proc.args, effective_timeout) - chunk = os.read(proc.stdout.fileno(), 4096) - if not chunk: - if buffer: - consume(bytes(buffer)) - break - buffer.extend(chunk) - while True: - try: - end = buffer.index(10) - except ValueError: - break - consume(bytes(buffer[: end + 1])) - del buffer[: end + 1] - - remaining = None if deadline is None else max(0.0, deadline - time.monotonic()) - returncode = proc.wait(timeout=remaining) - except subprocess.TimeoutExpired: - _terminate_owned_process_group(proc) - raise - finally: - if proc.poll() is None: - _terminate_owned_process_group(proc) - if selector is not None: - selector.close() - if proc.stdout is not None: - proc.stdout.close() - - if result is None: - raise StoatCrashed(returncode, "".join(stdout_lines)) - - if not result.get("ok"): - err = result.get("error") or {} - raise StoatError( - code=str(err.get("code", "internal")), - message=str(err.get("message", "")), - subject=str(err.get("subject", "")), - kind=str(err.get("kind", "")), - ) - # data is absent for a command whose answer is "it worked" with no - # payload; an empty dict is the honest reading, not an error. - return result.get("data") or {} - - def stream(self, *args: str, timeout: float | None = None) -> Iterator[tuple[str, dict[str, Any]]]: - """Run a command, yielding every event including the terminal result. - - For callers that want to react to progress as it happens rather than - take a callback. The final yielded pair is ("result", data); errors - still raise, so a consumer that iterates to exhaustion cannot miss one. - """ - events: list[tuple[str, dict[str, Any]]] = [] - data = self.run( - *args, - timeout=timeout, - on_event=lambda kind, payload: events.append((kind, payload)), - ) - yield from events - yield ("result", data) - - -def _terminate_owned_process_group(proc: subprocess.Popen[bytes]) -> None: - """Terminate this invocation and descendants without touching other jobs.""" - if os.name == "posix": - try: - os.killpg(proc.pid, signal.SIGTERM) - except ProcessLookupError: - pass - elif proc.poll() is None: - proc.terminate() - try: - proc.wait(timeout=1.0) - except subprocess.TimeoutExpired: - pass - if os.name == "posix": - try: - # The leader can exit after TERM while a descendant ignores it. - # Escalate the owned group even when proc.wait already returned. - os.killpg(proc.pid, signal.SIGKILL) - except ProcessLookupError: - pass - elif proc.poll() is None: - proc.kill() - proc.wait() - - -def _decode(line: str) -> dict[str, Any] | None: - """Parse one stdout line, tolerating anything that is not a JSON object. - - The contract says every stdout line is a JSON object, so a line that is - not one means either a stoat bug or a wrapper script polluting stdout. - Skipping is right either way: the result line is what matters, and dying - on someone's stray `echo` would be a worse failure than ignoring it. - """ - line = line.strip() - if not line: - return None - try: - obj = json.loads(line) - except json.JSONDecodeError: - return None - return obj if isinstance(obj, dict) else None - - -def argv_for_bool(flag: str, value: bool) -> Sequence[str]: - """Render a kong bool flag. `--flag=false` is the only spelling that unsets. - - Kong treats a bare `--flag` as true; there is no `--no-flag`. Writing this - once stops each tool from guessing. - """ - return [f"--{flag}={'true' if value else 'false'}"] diff --git a/mcp/stoat_mcp/errors.py b/mcp/stoat_mcp/errors.py deleted file mode 100644 index 35b1f80f..00000000 --- a/mcp/stoat_mcp/errors.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Errors mirroring the JSON contract (docs/reference/json.md). - -Code constants exist so tool code branches on a name rather than a string -literal typed twice. They are deliberately NOT an enum: the contract says a -consumer MUST treat an unrecognized code as a generic failure rather than -crashing, and an enum makes the natural spelling of that a ValueError. -""" - -from __future__ import annotations - -# Every code the contract defines today. A code not in this list is still a -# valid failure; see StoatError. -NOT_FOUND = "not_found" -BROKEN = "broken" -NAME_TAKEN = "name_taken" -INVALID_SPEC = "invalid_spec" -IMAGE_NOT_DOWNLOADED = "image_not_downloaded" -RECIPE_NOT_APPLICABLE = "recipe_not_applicable" -NOT_RUNNING = "not_running" -ALREADY_RUNNING = "already_running" -NO_DISK = "no_disk" -IMMUTABLE_FIELD = "immutable_field" -DISK_SHRINK = "disk_shrink" -CANNOT_REACH = "cannot_reach" -APPLIED_AT_BOOT = "applied_at_boot" -UNKNOWN_LOG = "unknown_log" -TIMEOUT = "timeout" -CANCELED = "canceled" -USAGE = "usage" -CONFIRMATION_REQUIRED = "confirmation_required" -INTERNAL = "internal" - - -class StoatError(Exception): - """A stoat command answered with ok:false. - - This is a normal outcome, not a bug: "no such VM" and "that VM is already - running" both arrive this way. Branch on `code`; `message` is human text - whose wording is explicitly not part of the contract. - """ - - def __init__(self, code: str, message: str, subject: str = "", kind: str = "") -> None: - super().__init__(f"{code}: {message}") - self.code = code - self.message = message - # Reserved in the contract and not emitted by any command today. - # Carried anyway so this class does not need changing when they are. - self.subject = subject - self.kind = kind - - -class StoatCrashed(Exception): - """The process exited without ever writing a terminal result line. - - The contract's guarantee is "either a result line, or the process died", - so this is the second case and the only one where the exit code carries - information. It means a panic, a signal, or a binary that is not stoat. - """ - - def __init__(self, returncode: int, stdout: str = "") -> None: - super().__init__(f"stoat exited {returncode} without a result line") - self.returncode = returncode - self.stdout = stdout - - -class ContractMismatch(Exception): - """The stoat binary speaks a different contract version than we expect. - - Raised at startup rather than on first use, so a stale binary fails with - a message naming both versions instead of a KeyError three tools later. - """ - - def __init__(self, found: int, expected: int) -> None: - super().__init__( - f"stoat speaks JSON contract v{found}, this server needs v{expected}. " - "Upgrade whichever is older." - ) - self.found = found - self.expected = expected - - -class GuardRejection(Exception): - """A deterministic block refused the call before stoat ever ran. - - Distinct from StoatError on purpose: this never reached the binary, so - nothing happened and nothing needs undoing. - """ diff --git a/mcp/stoat_mcp/guards.py b/mcp/stoat_mcp/guards.py deleted file mode 100644 index 8a0e7709..00000000 --- a/mcp/stoat_mcp/guards.py +++ /dev/null @@ -1,271 +0,0 @@ -"""The deterministic blocks. - -Pure functions, no I/O beyond path resolution, so they are exhaustively -testable. Every one is enforced regardless of what the MCP client does, -because the protocol guarantees nothing useful here: human-in-the-loop is a -SHOULD, tool annotations are explicitly advisory, and elicitation is a -capability a client may simply not declare. What stoat wants guaranteed, -stoat enforces. - -See docs/design/mcp-server.md §4 and json-contract-draft.md §7. -""" - -from __future__ import annotations - -import os -import re -import time -from pathlib import Path - -from .errors import GuardRejection - -# A VM name is a DIRECTORY name under the data root. That is the whole reason -# this is strict: the name becomes a path component, and stoat resolves every -# operation by directory. Letters, digits, dash, underscore, dot, but never a -# leading dot (which would hide it and collide with stoat's own bookkeeping) -# and never "." or ".." (which are traversal). -_VM_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") - -# Catalog image IDs look like "alpine-virt" or "ubuntu-24.04". Anything with a -# separator is a path, and a path is what section 7.1 #4 forbids: an absolute -# BYO path is an arbitrary host file read, booted as a disk. -_IMAGE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") - -_RECIPE_NAME = re.compile(r"^[a-z][a-z0-9-]*$") -_RECIPE_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$") - - -def check_vm_name(name: str) -> str: - """Reject anything that is not a plain VM name. - - A name reaches stoat as a directory component, so "../../etc" or an - absolute path would escape the data root. Rejecting by pattern rather - than by sanitizing is deliberate: there is no correct way to "fix" a - malicious name, and a sanitizer that silently rewrites input hides the - attempt. - """ - if not isinstance(name, str) or not name.strip(): - raise GuardRejection("vm name is required") - if name != name.strip(): - raise GuardRejection(f"vm name {name!r} has leading or trailing whitespace") - if name in (".", ".."): - raise GuardRejection(f"vm name {name!r} is a path traversal") - if os.sep in name or (os.altsep and os.altsep in name): - raise GuardRejection(f"vm name {name!r} contains a path separator") - if "\x00" in name: - raise GuardRejection("vm name contains a null byte") - if not _VM_NAME.match(name): - raise GuardRejection( - f"vm name {name!r} is not allowed: use letters, digits, dot, dash " - "and underscore, starting with a letter or digit" - ) - return name - - -def check_image_id(image: str) -> str: - """Catalog IDs only. A path is refused outright. - - `create --image /abs/path.qcow2` is an arbitrary host file read booted as - a disk (json-contract-draft.md section 7.1 #4). The CLI accepts it because - a human bringing their own image is a supported workflow; an agent - choosing one is not. - """ - if not isinstance(image, str) or not image.strip(): - raise GuardRejection("image id is required") - if os.sep in image or (os.altsep and os.altsep in image) or image.startswith("~"): - raise GuardRejection( - f"image {image!r} looks like a path. Only catalog image ids are " - "accepted; run list_images to see them." - ) - if not _IMAGE_ID.match(image): - raise GuardRejection(f"image id {image!r} is not a valid catalog id") - return image - - -def check_index_name(ref: str) -> str: - """Accept an index recipe name with an optional safe git ref. - - The recipe name is always resolved through the curated index. A ref may - contain slashes for branches such as ``feature/topic`` but cannot contain - URL, option, or path-traversal syntax. - """ - if not isinstance(ref, str) or not ref or ref != ref.strip(): - raise GuardRejection("recipe index name is required") - if ref.startswith("-") or "\\" in ref or "\x00" in ref: - raise GuardRejection(f"recipe index name {ref!r} is not safe") - if ref.count("@") > 1: - raise GuardRejection(f"recipe index name {ref!r} has more than one ref") - name, separator, branch = ref.partition("@") - if not _RECIPE_NAME.fullmatch(name): - raise GuardRejection( - f"recipe index name {ref!r} must start with a letter and contain only letters, digits and dashes" - ) - if not separator: - return ref - if not _RECIPE_REF.fullmatch(branch) or branch.startswith("/") or branch.endswith("/"): - raise GuardRejection(f"recipe ref {branch!r} is not safe") - parts = branch.split("/") - if ".." in branch or any( - part in ("", ".", "..") - or part.startswith(".") - or part.endswith(".") - or part.endswith(".lock") - for part in parts - ): - raise GuardRejection(f"recipe ref {branch!r} is not safe") - return ref - - -def shared_dir(vm: str, data_root: str | os.PathLike[str] | None = None) -> Path: - """The one host directory an agent may read or write for this VM. - - Mirrors the layout stoat itself uses for the writable 9p export - (~/.stoat/shared//), which is what makes a copy in or out equivalent - to the guest writing into its own work share. - """ - root = Path(data_root) if data_root else Path(os.environ.get("STOAT_HOME") or Path.home() / ".stoat") - return (root / "shared" / check_vm_name(vm)).resolve() - - -def check_host_path(path: str, vm: str, data_root: str | os.PathLike[str] | None = None) -> str: - """Confine a host path to ~/.stoat/shared//, resolving symlinks first. - - The order matters and is the whole guard: resolve, THEN compare. A check - performed before resolution is defeated by a symlink inside the sandbox - pointing out of it, and such a symlink is trivially created by the guest, - which has that same directory mounted read-write. - - Comparison is by path PARTS, not by string prefix. "~/.stoat/shared/work" - is a string prefix of "~/.stoat/shared/work-evil", so a prefix test would - admit a sibling directory belonging to a different VM. - - This check is possible here, unlike for the 9p share itself, precisely - because stoat performs the copy: a 9p operation is served by QEMU and - stoat never sees it. - """ - if not isinstance(path, str) or not path.strip(): - raise GuardRejection("path is required") - if "\x00" in path: - raise GuardRejection("path contains a null byte") - - sandbox = shared_dir(vm, data_root) - # expanduser first: "~" is the user's, not ours, and os.path.realpath - # would treat a literal "~" as a relative directory name. - candidate = Path(os.path.expanduser(path)) - if not candidate.is_absolute(): - raise GuardRejection( - f"path {path!r} must be absolute, under {sandbox}" - ) - resolved = Path(os.path.realpath(candidate)) - - if resolved != sandbox and sandbox not in resolved.parents: - raise GuardRejection( - f"path {path!r} resolves to {resolved}, which is outside this VM's " - f"shared directory ({sandbox}). Only files under that directory can " - "be copied in or out." - ) - return str(resolved) - - -class RateLimiter: - """A token bucket per tool name, plus one bucket every tool shares. - - The MCP spec makes rate limiting a server MUST. The numbers here are a - starting point rather than a tuned value: generous enough that ordinary - agent work never notices, tight enough that a runaway loop calling - `create` a thousand times stops being the host's problem. - - The shared bucket is what bounds the server as a whole. Per-tool buckets - alone let a caller burst `capacity` times against each of ~20 tools, so - the real ceiling was 20x the number anyone read off this class. - - Not thread-safe on purpose; the stdio server is single-threaded, and a - lock here would imply a concurrency story that does not exist. - """ - - def __init__( - self, - capacity: int = 30, - refill_per_second: float = 0.5, - total_capacity: int = 60, - total_refill_per_second: float = 2.0, - ) -> None: - if capacity < 1 or total_capacity < 1: - raise ValueError("capacity must be at least 1, or nothing can ever run") - if refill_per_second <= 0 or total_refill_per_second <= 0: - # A bucket that never refills is a bucket that permanently bricks - # the tool after `capacity` calls. Refusing at construction beats - # discovering it in production, and it is why the message below - # can divide by this without a guard. - raise ValueError("refill_per_second must be positive") - self.capacity = capacity - self.refill_per_second = refill_per_second - self.total_capacity = total_capacity - self.total_refill_per_second = total_refill_per_second - self._buckets: dict[str, tuple[float, float]] = {} - - def check(self, tool: str, now: float | None = None) -> None: - """Consume one token for `tool` and one shared token. - - Both buckets are read before either is charged. A call refused by one - must not spend from the other: a hot tool hitting its own limit would - otherwise drain the shared bucket and starve every other tool. - """ - now = time.monotonic() if now is None else now - tool_tokens = self._read(tool, self.capacity, self.refill_per_second, now, tool) - total_tokens = self._read( - "", self.total_capacity, self.total_refill_per_second, now, "the server" - ) - self._buckets[tool] = (tool_tokens - 1.0, now) - self._buckets[""] = (total_tokens - 1.0, now) - - def _read( - self, key: str, capacity: int, refill: float, now: float, subject: str - ) -> float: - """Return key's token count at `now`, raising when it is below one.""" - tokens, last = self._buckets.get(key, (float(capacity), now)) - tokens = min(capacity, tokens + (now - last) * refill) - if tokens < 1.0: - wait = (1.0 - tokens) / refill - raise GuardRejection( - f"rate limit reached for {subject}; retry in about {wait:.0f}s" - ) - return tokens - - -def check_flag_free(values: list[str], what: str) -> list[str]: - """Refuse a value that kong would read as a flag. - - `forward` and `check_recipes` splat their list arguments into argv as - positionals. `forward(pairs=["--clear"])` reached kong as the --clear - flag and wiped the VM's forwards, from a call that passed clear=False. - Nothing escapes the process (there is no shell), so this is argv - confusion, and refusing a leading dash closes it. - """ - for v in values: - if not isinstance(v, str) or not v.strip(): - raise GuardRejection(f"{what} contains an empty value") - if v.startswith("-"): - raise GuardRejection(f"{what} value {v!r} may not start with a dash") - return values - - -def strip_forbidden(patch: dict[str, object]) -> dict[str, object]: - """Remove parameters an agent may never set, from a patch it supplied. - - `share` is the one that matters: it grants an arbitrary host directory, - read-write, into a guest, and core.Patch is exactly the generic-map shape - that makes it reachable from JSON tool arguments - (json-contract-draft.md section 7.1 #2). - - Dropped silently rather than rejected, because an agent copying a VM - object it read back into an update call is doing something reasonable; - the field simply has no effect. Reading `share` is fine, setting it is not, - and that asymmetry is deliberate. - """ - return {k: v for k, v in patch.items() if k not in FORBIDDEN_PATCH_KEYS} - - -# Never accepted as tool input, at any level. These are absent rather than -# gated: a parameter that exists will eventually be reachable. -FORBIDDEN_PATCH_KEYS = frozenset({"share", "image", "base", "iso", "console_password"}) diff --git a/mcp/stoat_mcp/server.py b/mcp/stoat_mcp/server.py deleted file mode 100644 index 48638067..00000000 --- a/mcp/stoat_mcp/server.py +++ /dev/null @@ -1,753 +0,0 @@ -"""fastmcp tool definitions: the MCP surface over the stoat CLI. - -Every tool here does three things and nothing else: run the deterministic -guards in guards.py, call Client.run (or Client itself, for allow_exec -checks), and return the result's `data` dict verbatim. If a tool ever needs -to compute something stoat's JSON contract does not already hand back, that -is a sign the layering is wrong (docs/design/mcp-server.md, "Layering"), and -the fix belongs in the Go core, not here. - -Five things are enforced independently of what an MCP client does, because -the protocol guarantees none of them: rate limiting (RateLimiter.check, -first thing every tool does), path confinement (guards.check_host_path), -name validation (guards.check_vm_name), argv-flag confusion -(guards.check_flag_free), and allow_exec (checked here, since core.Exec -deliberately does not enforce it and core is a library the TUI and CLI also -call without wanting that restriction). - -allow_exec covers apply_recipes too. A recipe body is arbitrary guest code, -so a VM that refuses exec refuses an apply as well. - -Deliberately absent, not merely gated, per docs/design/mcp-server.md §4: -no `share` parameter on any tool, no bring-your-own image path, no -`recipe new`, no `ssh-command`, no VM-less `logs`. -""" - -from __future__ import annotations - -import functools -import subprocess -import sys -from collections.abc import Callable, Sequence -from typing import Any, Literal, TypeVar - -from fastmcp import FastMCP -from fastmcp.exceptions import ToolError - -from . import guards -from .client import Client, argv_for_bool -from .errors import ContractMismatch, GuardRejection, StoatCrashed, StoatError -from .guards import ( - RateLimiter, - check_flag_free, - check_host_path, - check_image_id, - check_vm_name, - strip_forbidden, -) - -mcp: FastMCP = FastMCP("stoat") - -# One bucket set for the whole process. Not thread-safe by design: the stdio -# transport is single-threaded, see RateLimiter's own doc comment. -_RATE_LIMITER = RateLimiter() - -# Set by main() once the binary is resolved and the contract check passes. -# Left None at import time on purpose, so importing this module (as the test -# suite does, to inspect tool schemas) never touches a subprocess. -_client: Client | None = None - - -def get_client() -> Client: - """Return the process-wide Client, constructing it on first use. - - Tests that only inspect tool registration never call this. A tool - actually being invoked without main() having run (e.g. calling a tool - function directly in a unit test) gets a freshly constructed Client, - which still resolves $STOAT_BIN/PATH correctly on its own. - """ - global _client - if _client is None: - _client = Client() - return _client - - -F = TypeVar("F", bound=Callable[..., Any]) - - -def _guarded(tool_name: str) -> Callable[[F], F]: - """Rate-limit by tool name, then translate our exceptions to ToolError. - - ToolError's message reaches the client regardless of fastmcp's - mask_error_details setting (fastmcp/server/server.py re-raises any - FastMCPError, ToolError's base class, untouched); a bare exception would - be masked whenever that setting is on. GuardRejection and StoatError are - both normal, expected outcomes ("no such VM", "path escapes the - sandbox"), not bugs, so their messages are meant to be seen. - - functools.wraps sets __wrapped__, which inspect.signature follows, so - fastmcp still reads the ORIGINAL function's signature (and therefore - still builds the right JSON schema) through this wrapper. - """ - - def decorator(fn: F) -> F: - @functools.wraps(fn) - def wrapper(*args: Any, **kwargs: Any) -> Any: - _RATE_LIMITER.check(tool_name) - try: - return fn(*args, **kwargs) - except GuardRejection as e: - raise ToolError(str(e)) from e - except StoatError as e: - raise ToolError(f"{e.code}: {e.message}") from e - except StoatCrashed as e: - raise ToolError(f"stoat exited {e.returncode} without answering") from e - except subprocess.TimeoutExpired as e: - # Client.run kills the process in its own finally, so this is - # already cleaned up. Without this arm it surfaces as a bare - # traceback that fastmcp masks. - raise ToolError(f"stoat did not finish within {e.timeout:.0f}s") from e - - return wrapper # type: ignore[return-value] - - return decorator - - -def _flag(name: str, value: str | int | None) -> list[str]: - """One `--name value` pair, or nothing if value was not given.""" - if value is None: - return [] - return [f"--{name}", str(value)] - - -def _recipes_flag(recipes: Sequence[str] | None) -> list[str]: - """`--recipes a,b,c`, `--recipes ""` to clear, or nothing if omitted. - - None means "leave alone" (the flag is absent from argv). An explicit - empty list means "clear the recipe list", which the CLI spells as - `--recipes` given an empty value (cli.md: "empty clears it"). - """ - if recipes is None: - return [] - return ["--recipes", ",".join(recipes)] - - -# The stdio transport is single-threaded: one blocked tool blocks the server. -# `wait` is the only tool that blocks on purpose, so it is the only one that -# needs a ceiling a caller cannot raise. -MAX_WAIT_SECONDS = 600 - -MAX_LOG_LINES = 2000 - - -def _require_exec_allowed(vm: str) -> None: - """Refuse exec, copy and apply on a VM created with --allow-exec=false. - - core.Exec does not enforce this itself (core is a library the TUI and - CLI also call, and a blanket refusal there would be the wrong layer); - docs/design/mcp-server.md §1.2 is explicit that enforcement is this - server's job. One extra `get` call is the cost of not trusting the - caller's own claim about a VM it does not control. - """ - data = get_client().run("get", vm) - if not data.get("vm", {}).get("allow_exec", True): - raise ToolError(f"exec is disabled on {vm!r} (created with --allow-exec=false)") - - -# --------------------------------------------------------------------------- -# Read-only -# --------------------------------------------------------------------------- - - -@mcp.tool( - name="list_vms", - description=( - "List every VM stoat manages, one entry per VM: name, OS, mode, state, " - "resources, disk, share, ssh port, recipes, and port forwards. Includes " - "VMs whose vm.toml failed to parse, shown with state 'broken' and an " - "error message, rather than hiding them. Read-only: touches no VM and " - "changes nothing on the host." - ), - annotations={"readOnlyHint": True, "destructiveHint": False}, -) -@_guarded("list_vms") -def list_vms() -> dict[str, Any]: - return get_client().run("ls") - - -@mcp.tool( - name="vm_status", - description=( - "Show one VM's full status: OS, mode, backend, state, CPUs, RAM, disk, " - "share, ssh port and user, recipes, port forwards, and whether exec is " - "allowed on it. Read-only: touches no VM and changes nothing on the host." - ), - annotations={"readOnlyHint": True, "destructiveHint": False}, -) -@_guarded("vm_status") -def vm_status(vm: str) -> dict[str, Any]: - vm = check_vm_name(vm) - return get_client().run("get", vm) - - -@mcp.tool( - name="list_images", - description=( - "List what stoat can build a VM from: the catalog of known images, plus " - "anything already downloaded locally. Read-only: does not download " - "anything and changes nothing on the host." - ), - annotations={"readOnlyHint": True, "destructiveHint": False}, -) -@_guarded("list_images") -def list_images() -> dict[str, Any]: - return get_client().run("images") - - -@mcp.tool( - name="list_recipes", - description=( - "List recipes stoat knows about, optionally filtered to ones applicable " - "to a guest OS and/or backend. Reads the recipes directory only; does " - "not touch any VM or run anything. Read-only." - ), - annotations={"readOnlyHint": True, "destructiveHint": False}, -) -@_guarded("list_recipes") -def list_recipes(os: str | None = None, backend: str | None = None) -> dict[str, Any]: - argv = ["recipes", *_flag("os", os), *_flag("backend", backend)] - return get_client().run(*argv) - - -@mcp.tool( - name="search_recipes", - description="Search the curated recipe index by name or description. Read-only.", - annotations={"readOnlyHint": True, "destructiveHint": False}, -) -@_guarded("search_recipes") -def search_recipes(term: str = "") -> dict[str, Any]: - argv = ["recipe", "search"] - if term.startswith("-"): - argv.extend(("--", term)) - elif term: - argv.append(term) - return get_client().run(*argv) - - -@mcp.tool( - name="check_recipes", - description=( - "Report, for each named recipe, why it would NOT apply to a given " - "guest OS/backend; an empty issue list means every one of them would " - "apply. Does not run anything or touch any VM. Read-only." - ), - annotations={"readOnlyHint": True, "destructiveHint": False}, -) -@_guarded("check_recipes") -def check_recipes( - recipes: list[str], os: str, backend: str | None = None -) -> dict[str, Any]: - check_flag_free(recipes, "recipes") - argv = ["check-recipes", *recipes, "--os", os, *_flag("backend", backend)] - return get_client().run(*argv) - - -@mcp.tool( - name="logs", - description=( - "Tail one VM's log: its qemu console output (default), or its most " - "recent recipe-apply log. Always scoped to a single named VM; there is " - "no way to read stoat's own global log through this tool. 'n' is " - "capped at 2000 lines. Read-only." - ), - annotations={"readOnlyHint": True, "destructiveHint": False}, -) -@_guarded("logs") -def logs(vm: str, which: Literal["console", "apply"] = "console", n: int = 50) -> dict[str, Any]: - vm = check_vm_name(vm) - # A console log grows without bound, and the whole tail comes back in one - # response. Clamping beats handing an agent a payload it cannot read. - n = max(1, min(n, MAX_LOG_LINES)) - return get_client().run("logs", vm, "--which", which, "-n", str(n)) - - -@mcp.tool( - name="doctor", - description=( - "Check host prerequisites: qemu/KVM, qemu-img, ssh, xorriso and " - "/dev/kvm. Always succeeds at checking (the JSON result's 'healthy' " - "field carries an unhealthy host, rather than raising). Read-only: " - "changes nothing on the host." - ), - annotations={"readOnlyHint": True, "destructiveHint": False}, -) -@_guarded("doctor") -def doctor() -> dict[str, Any]: - return get_client().run("doctor") - - -# --------------------------------------------------------------------------- -# Mutating -# --------------------------------------------------------------------------- - - -@mcp.tool( - name="create", - description=( - "Create a new VM from a catalog image, without starting it. Only " - "catalog image ids are accepted (see list_images); a bring-your-own " - "image path, a console password, or a host share cannot be set through " - "this tool. Memory is 'ram_mb' and is in MEGABYTES; 'disk' is a size " - "string like '8G'. Reversible: the VM can be deleted with destroy. " - "Mutating: creates a new VM directory and disk under the stoat data root." - ), - annotations={"readOnlyHint": False, "destructiveHint": False}, -) -@_guarded("create") -def create( - name: str, - image: str, - os: str | None = None, - backend: str | None = None, - mode: str | None = None, - ram_mb: int | None = None, - cpus: int | None = None, - disk: str | None = None, - recipes: list[str] | None = None, - allow_exec: bool = True, -) -> dict[str, Any]: - name = check_vm_name(name) - image = check_image_id(image) - argv = [ - "create", - name, - "--image", - image, - *_flag("os", os), - *_flag("backend", backend), - *_flag("mode", mode), - *_flag("ram", ram_mb), - *_flag("cpus", cpus), - *_flag("disk", disk), - *_recipes_flag(recipes), - *argv_for_bool("allow-exec", allow_exec), - ] - return get_client().run(*argv) - - -@mcp.tool( - name="start", - description=( - "Start a VM (boots qemu). Reversible with stop. Mutating: consumes " - "host CPU, RAM and, once running, a forwarded ssh port." - ), - annotations={"readOnlyHint": False, "destructiveHint": False}, -) -@_guarded("start") -def start(vm: str) -> dict[str, Any]: - vm = check_vm_name(vm) - return get_client().run("up", vm) - - -@mcp.tool( - name="stop", - description=( - "Stop a running VM gracefully. Reversible with start. Mutating: shuts " - "qemu down; refuses if the VM is not already running." - ), - annotations={"readOnlyHint": False, "destructiveHint": False}, -) -@_guarded("stop") -def stop(vm: str) -> dict[str, Any]: - vm = check_vm_name(vm) - return get_client().run("down", vm) - - -@mcp.tool( - name="plan_recipes", - description=( - "Report what apply_recipes would do to a VM, without running anything: " - "one entry per recipe with 'run' or 'skip' and the reason. Computed on " - "the host, so it works on a stopped VM. Read-only." - ), - annotations={"readOnlyHint": True, "destructiveHint": False}, -) -@_guarded("plan_recipes") -def plan_recipes(vm: str, only: list[str] | None = None) -> dict[str, Any]: - vm = check_vm_name(vm) - return get_client().run(*_apply_argv(vm, only), "--dry-run") - - -@mcp.tool( - name="apply_recipes", - description=( - "Run a VM's own configured recipes over ssh (or, optionally, a subset " - "of them). Call plan_recipes first to see what this would do. Refused " - "on a VM created with --allow-exec=false, since a recipe is arbitrary " - "guest code. Mutating: runs arbitrary recipe scripts inside the guest, " - "which is only reversible to whatever extent the recipe itself is. " - "Reaches outside this process (openWorldHint)." - ), - annotations={"readOnlyHint": False, "destructiveHint": True, "openWorldHint": True}, -) -@_guarded("apply_recipes") -def apply_recipes(vm: str, only: list[str] | None = None) -> dict[str, Any]: - vm = check_vm_name(vm) - # A recipe body is arbitrary guest code, so this is exec by another name - # and answers to the same opt-out. - _require_exec_allowed(vm) - return get_client().run(*_apply_argv(vm, only)) - - -def _apply_argv(vm: str, only: list[str] | None) -> list[str]: - """Shared argv for apply and its dry run: `apply [--only n]...`.""" - names = check_flag_free(list(only or []), "only") - argv = ["apply", vm] - for name in names: - argv += ["--only", name] - return argv - - -@mcp.tool( - name="update", - description=( - "Change a stopped VM's RAM, CPU count, ssh port, disk size (grow-only), " - "or recipe list. Only the fields actually passed are changed; a share " - "cannot be set through this tool even if included by mistake, since it " - "is stripped before the request is built. Mutating; most fields take " - "effect only at the VM's next start." - ), - annotations={"readOnlyHint": False, "destructiveHint": False}, -) -@_guarded("update") -def update( - vm: str, - ram_mb: int | None = None, - cpus: int | None = None, - ssh_port: int | None = None, - disk: str | None = None, - recipes: list[str] | None = None, -) -> dict[str, Any]: - vm = check_vm_name(vm) - # Built as a generic map and run through strip_forbidden even though this - # tool's own signature has no `share` parameter to begin with: §4's rule - # is that the patch itself is what gets sanitized, in case a future - # caller builds one from a VM object it read back (guards.strip_forbidden - # docstring explains why that is the reasonable case to guard against). - patch: dict[str, object] = {} - if ram_mb is not None: - patch["ram"] = ram_mb - if cpus is not None: - patch["cpus"] = cpus - if ssh_port is not None: - patch["ssh_port"] = ssh_port - if disk is not None: - patch["disk"] = disk - if recipes is not None: - patch["recipes"] = recipes - patch = strip_forbidden(patch) - - argv = ["update", vm] - argv += _flag("ram", patch.get("ram")) # type: ignore[arg-type] - argv += _flag("cpus", patch.get("cpus")) # type: ignore[arg-type] - argv += _flag("ssh-port", patch.get("ssh_port")) # type: ignore[arg-type] - argv += _flag("disk", patch.get("disk")) # type: ignore[arg-type] - if "recipes" in patch: - argv += _recipes_flag(patch["recipes"]) # type: ignore[arg-type] - return get_client().run(*argv) - - -@mcp.tool( - name="add_recipe", - description="Install a recipe from the curated index and pin its commit.", - annotations={"readOnlyHint": False, "destructiveHint": False}, -) -@_guarded("add_recipe") -def add_recipe(name: str, ref: str | None = None) -> dict[str, Any]: - if ref is None: - spec = guards.check_index_name(name) - else: - base = guards.check_index_name(name) - if "@" in base: - raise GuardRejection("add_recipe name and ref must be separate") - spec = guards.check_index_name(f"{base}@{ref}") - return get_client().run("recipe", "add", spec, "-y") - - -@mcp.tool( - name="update_recipe", - description="Fetch a remote recipe's ref again and repin it, or update every remote recipe.", - annotations={"readOnlyHint": False, "destructiveHint": False}, -) -@_guarded("update_recipe") -def update_recipe(name: str | None = None) -> dict[str, Any]: - argv = ["recipe", "update"] - if name is not None: - argv.append(_plain_recipe_name(name, "update_recipe")) - return get_client().run(*argv) - - -@mcp.tool( - name="remove_recipe", - description="Remove a remote recipe, its lock entry and its cache directory.", - annotations={"readOnlyHint": False, "destructiveHint": True}, -) -@_guarded("remove_recipe") -def remove_recipe(name: str) -> dict[str, Any]: - return get_client().run("recipe", "rm", _plain_recipe_name(name, "remove_recipe"), "-y") - - -def _plain_recipe_name(name: str, tool: str) -> str: - checked = guards.check_index_name(name) - if "@" in checked: - raise GuardRejection(f"{tool} takes a plain recipe name") - return checked - - -@mcp.tool( - name="clone", - description=( - "Copy a VM: a fresh overlay disk and a fresh ssh port, but NOT the " - "source's port forwards. Refuses a running source. Reversible with " - "destroy on the clone. Mutating: creates a new VM." - ), - annotations={"readOnlyHint": False, "destructiveHint": False}, -) -@_guarded("clone") -def clone(source: str, name: str) -> dict[str, Any]: - source = check_vm_name(source) - name = check_vm_name(name) - return get_client().run("clone", source, name) - - -@mcp.tool( - name="snapshot", - description=( - "Save a disk snapshot of a VM under a tag. Needs a disk to snapshot; " - "a live-mode VM has none and this refuses. Reversible: restore rolls " - "back to it, and the tag can be removed by a human with `stoat " - "snapshot --delete`. Mutating: writes a new qemu snapshot." - ), - annotations={"readOnlyHint": False, "destructiveHint": False}, -) -@_guarded("snapshot") -def snapshot(vm: str, tag: str) -> dict[str, Any]: - vm = check_vm_name(vm) - return get_client().run("snapshot", vm, tag) - - -@mcp.tool( - name="restore", - description=( - "Roll a VM's disk back to a previously saved snapshot tag, discarding " - "everything written since. Destructive and only reversible if another " - "snapshot was taken after the one being restored to." - ), - annotations={"readOnlyHint": False, "destructiveHint": True}, -) -@_guarded("restore") -def restore(vm: str, tag: str) -> dict[str, Any]: - vm = check_vm_name(vm) - return get_client().run("snapshot", vm, tag, "--restore") - - -@mcp.tool( - name="forward", - description=( - "Show, set, or clear a VM's host:guest port forwards. With no pairs " - "and clear=false, only shows the current forwards. Setting or clearing " - "on a running VM saves the change but it only takes effect at the " - "VM's next start. Mutating when pairs are given or clear=true." - ), - annotations={"readOnlyHint": False, "destructiveHint": False}, -) -@_guarded("forward") -def forward(vm: str, pairs: list[str] | None = None, clear: bool = False) -> dict[str, Any]: - vm = check_vm_name(vm) - argv = ["forward", vm] - if clear: - argv.append("--clear") - else: - argv += check_flag_free(list(pairs or []), "pairs") - return get_client().run(*argv) - - -@mcp.tool( - name="wait", - description=( - "Block until a VM reaches a state: reachable (sshd answering), applied " - "(most recent recipe run finished), or stopped (qemu no longer " - "running). The bound is 'timeout_seconds', a plain integer count of " - "seconds, not a duration string, capped at 600. Fails immediately, " - "rather than waiting " - "out the timeout, for a state the VM can never reach. Mutating only in " - "that it blocks the caller; it does not itself change the VM." - ), - annotations={"readOnlyHint": False, "destructiveHint": False}, -) -@_guarded("wait") -def wait( - vm: str, - until: Literal["reachable", "applied", "stopped"] = "reachable", - timeout_seconds: int = 120, -) -> dict[str, Any]: - vm = check_vm_name(vm) - timeout_seconds = max(1, min(timeout_seconds, MAX_WAIT_SECONDS)) - argv = ["wait", vm, "--until", until, "--timeout", f"{timeout_seconds}s"] - # The subprocess-level timeout must exceed stoat's own --timeout, or the - # process gets killed before stoat has a chance to answer with "timeout" - # itself; the 15s margin covers process startup and JSON flush. - return get_client().run(*argv, timeout=timeout_seconds + 15) - - -# --------------------------------------------------------------------------- -# Destructive -# --------------------------------------------------------------------------- - - -@mcp.tool( - name="destroy", - description=( - "Permanently delete a VM's directory and disk. Refuses while the VM " - "is running. NOT reversible: there is no undo, and a snapshot taken " - "before deletion goes with it. Always confirmed on this tool's behalf " - "(-y), since --json never prompts." - ), - annotations={"readOnlyHint": False, "destructiveHint": True}, -) -@_guarded("destroy") -def destroy(vm: str) -> dict[str, Any]: - vm = check_vm_name(vm) - return get_client().run("rm", vm, "-y") - - -@mcp.tool( - name="prune", - description=( - "Report stale files stoat can clean up: partial downloads always; " - "broken VM directories if broken=true; orphaned images if images=true. " - "Dry-run by default and reports only; pass apply=true to actually " - "delete, which is NOT reversible for whatever it removes." - ), - annotations={"readOnlyHint": False, "destructiveHint": True}, -) -@_guarded("prune") -def prune(apply: bool = False, broken: bool = False, images: bool = False) -> dict[str, Any]: - argv = ["prune"] - if apply: - argv.append("--apply") - if broken: - argv.append("--broken") - if images: - argv.append("--images") - return get_client().run(*argv) - - -# --------------------------------------------------------------------------- -# Execution -# --------------------------------------------------------------------------- - - -@mcp.tool( - name="exec", - description=( - "Run a command inside a VM over ssh and return its stdout, stderr and " - "exit code. The command runs with whatever privileges the guest's ssh " - "user has; effects inside the guest are whatever the command itself " - "does, and are not automatically reversible. Refused on a VM created " - "with --allow-exec=false. Reaches outside this process (openWorldHint)." - ), - annotations={"readOnlyHint": False, "destructiveHint": True, "openWorldHint": True}, -) -@_guarded("exec") -def exec_cmd(vm: str, command: list[str]) -> dict[str, Any]: - vm = check_vm_name(vm) - _require_exec_allowed(vm) - return get_client().run("exec", vm, "--", *command) - - -@mcp.tool( - name="copy_to", - description=( - "Copy a file from the host into a VM's guest filesystem. The host " - "path must resolve under that VM's own shared directory " - "(~/.stoat/shared//); anything else is refused before stoat ever " - "runs. Refused on a VM created with --allow-exec=false. Overwrites " - "whatever was at the destination in the guest; not reversible from " - "here. Reaches outside this process (openWorldHint)." - ), - annotations={"readOnlyHint": False, "destructiveHint": True, "openWorldHint": True}, -) -@_guarded("copy_to") -def copy_to(vm: str, local: str, remote: str) -> dict[str, Any]: - vm = check_vm_name(vm) - _require_exec_allowed(vm) - resolved_local = check_host_path(local, vm) - data = get_client().run("cp", "--vm", vm, "--direction", "to", "--local", resolved_local, "--remote", remote) - _verify_cp_local(data, resolved_local) - return data - - -@mcp.tool( - name="copy_from", - description=( - "Copy a file out of a VM's guest filesystem to the host. The host " - "destination path must resolve under that VM's own shared directory " - "(~/.stoat/shared//); anything else is refused before stoat ever " - "runs. Refused on a VM created with --allow-exec=false. Overwrites " - "whatever was at the destination on the host; not reversible from " - "here. Reaches outside this process (openWorldHint)." - ), - annotations={"readOnlyHint": False, "destructiveHint": True, "openWorldHint": True}, -) -@_guarded("copy_from") -def copy_from(vm: str, remote: str, local: str) -> dict[str, Any]: - vm = check_vm_name(vm) - _require_exec_allowed(vm) - resolved_local = check_host_path(local, vm) - data = get_client().run("cp", "--vm", vm, "--direction", "from", "--local", resolved_local, "--remote", remote) - _verify_cp_local(data, resolved_local) - return data - - -def _verify_cp_local(data: dict[str, Any], authorised_local: str) -> None: - """Post-verify that `cp` acted on the exact path this server authorised. - - `cp` echoes back the resolved absolute local path it used (json.md's - `cp` row). This catches a divergence between the two resolvers: the guard - uses os.path.realpath, stoat's own resolveLocal does not. - - It does not catch a component of the path swapped to a symlink between - the guard and scp's open, and it runs after the copy either way. The - defence there is QEMU's mapped-xattr, which stores a guest symlink as an - extended attribute instead of a real host one (mcp-server.md section 8). - That guard and this one are independent; neither covers the other. - """ - actual = data.get("local") - if actual != authorised_local: - raise ToolError( - f"cp acted on {actual!r}, which does not match the authorised path " - f"{authorised_local!r}; refusing to report this as successful" - ) - - -def main() -> None: - """Console entry point: resolve the binary, check the contract, serve. - - A contract mismatch or missing binary is a startup failure with a plain - message, not a traceback three tools into a session (docs/design/mcp- - server.md §2, "Startup contract check"). - """ - global _client - try: - client = Client() - client.check_contract() - except (FileNotFoundError, ContractMismatch) as e: - print(f"stoat-mcp: {e}", file=sys.stderr) - raise SystemExit(1) from None - _client = client - mcp.run() - - -if __name__ == "__main__": - main() diff --git a/mcp/tests/__init__.py b/mcp/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/mcp/tests/test_client.py b/mcp/tests/test_client.py deleted file mode 100644 index f694232a..00000000 --- a/mcp/tests/test_client.py +++ /dev/null @@ -1,290 +0,0 @@ -"""Client.run against a FAKE stoat binary: a small Python script that echoes -fixed JSON Lines, never the real CLI. This exercises envelope handling -(docs/reference/json.md) in isolation, without spinning up any VM. - -Each fake is a tiny script written to tmp_path, made executable, and -selected by the test's own argv (the fakes below dispatch on sys.argv[1] -after --json, the same position a real subcommand name would occupy). -""" - -from __future__ import annotations - -import os -import stat -import subprocess -import sys -import textwrap -import threading -import time - -import pytest - -from stoat_mcp.client import Client -from stoat_mcp.errors import StoatCrashed, StoatError - -FAKE = textwrap.dedent( - """\ - #!/usr/bin/env python3 - import sys - - # args: --json . The subcommand picks which canned behaviour - # to emit, exactly like a real fixture-driven fake would. - args = sys.argv[1:] - cmd = args[1] if len(args) > 1 else args[0] - - def emit(line): - print(line, flush=True) - - if cmd == "ok": - emit('{"v":1,"type":"result","cmd":"ok","ok":true,"data":{"answer":42}}') - elif cmd == "err": - emit('{"v":1,"type":"result","cmd":"err","ok":false,"error":{"code":"not_found","message":"no such VM"}}') - sys.exit(1) - elif cmd == "crash": - # Exits nonzero without ever writing a result line. - sys.stderr.write("panic: boom\\n") - sys.exit(1) - elif cmd == "crash-zero": - # No result line, but exit 0: still a crash per the contract ("no - # result line" is what matters, not the exit code by itself). - sys.exit(0) - elif cmd == "unknown-event": - emit('{"v":1,"type":"heartbeat","cmd":"unknown-event","data":{"x":1}}') - emit('{"v":1,"type":"result","cmd":"unknown-event","ok":true,"data":{}}') - elif cmd == "bad-json": - emit("not json at all") - emit('{"v":1,"type":"result","cmd":"bad-json","ok":true,"data":{"survived":true}}') - elif cmd == "events": - emit('{"v":1,"type":"progress","cmd":"events","data":{"percent":50}}') - emit('{"v":1,"type":"stage","cmd":"events","data":{"recipe":"xfce"}}') - emit('{"v":1,"type":"log","cmd":"events","data":{"line":"hello"}}') - emit('{"v":1,"type":"result","cmd":"events","ok":true,"data":{}}') - elif cmd == "duplicate-result": - # The contract guarantees exactly one; Client keeps the LAST. - emit('{"v":1,"type":"result","cmd":"duplicate-result","ok":true,"data":{"which":"first"}}') - emit('{"v":1,"type":"result","cmd":"duplicate-result","ok":true,"data":{"which":"second"}}') - elif cmd == "empty-line": - emit("") - emit('{"v":1,"type":"result","cmd":"empty-line","ok":true,"data":{}}') - elif cmd == "no-data": - emit('{"v":1,"type":"result","cmd":"no-data","ok":true}') - else: - sys.stderr.write("unknown fake command\\n") - sys.exit(2) - """ -) - - -@pytest.fixture() -def fake_binary(tmp_path): - path = tmp_path / "fake-stoat" - path.write_text(FAKE) - path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - return str(path) - - -@pytest.fixture() -def client(fake_binary): - return Client(binary=fake_binary, default_timeout=10.0) - - -def test_normal_result(client): - data = client.run("ok") - assert data == {"answer": 42} - - -def test_ok_false_raises_stoat_error_with_code(client): - with pytest.raises(StoatError) as exc_info: - client.run("err") - assert exc_info.value.code == "not_found" - assert exc_info.value.message == "no such VM" - - -def test_no_result_line_and_nonzero_exit_raises_stoat_crashed(client): - with pytest.raises(StoatCrashed) as exc_info: - client.run("crash") - assert exc_info.value.returncode == 1 - - -def test_no_result_line_even_with_exit_zero_raises_stoat_crashed(client): - # "no result line" is the trigger, not the exit code; a fake that exits - # cleanly but never answers is still a crash per the contract. - with pytest.raises(StoatCrashed): - client.run("crash-zero") - - -def test_unknown_event_type_is_skipped_not_raised(client): - data = client.run("unknown-event") - assert data == {} - - -def test_invalid_json_mid_stream_is_skipped(client): - data = client.run("bad-json") - assert data == {"survived": True} - - -def test_progress_stage_log_events_reach_the_callback(client): - seen: list[tuple[str, dict]] = [] - data = client.run("events", on_event=lambda kind, payload: seen.append((kind, payload))) - assert data == {} - assert seen == [ - ("progress", {"percent": 50}), - ("stage", {"recipe": "xfce"}), - ("log", {"line": "hello"}), - ] - - -def test_duplicate_result_lines_keep_the_last(client): - data = client.run("duplicate-result") - assert data == {"which": "second"} - - -def test_blank_lines_are_skipped(client): - data = client.run("empty-line") - assert data == {} - - -def test_missing_data_field_is_an_empty_dict(client): - data = client.run("no-data") - assert data == {} - - -def test_timeout_covers_open_stdout_and_cleans_owned_descendants(tmp_path): - pid_path = tmp_path / "child.pid" - parent_pid_path = tmp_path / "parent.pid" - child_code = textwrap.dedent( - f""" - import signal - import time - # The group leader exits on TERM; this owned descendant requires KILL. - signal.signal(signal.SIGTERM, signal.SIG_IGN) - time.sleep(2.0) - """ - ) - fake = tmp_path / "fake-stoat-timeout" - fake.write_text( - textwrap.dedent( - f"""\ - #!/usr/bin/env python3 - import json - import os - import subprocess - import sys - import time - - args = sys.argv[1:] - cmd = args[1] if len(args) > 1 else args[0] - if cmd != "hold-open": - raise SystemExit(2) - with open({str(parent_pid_path)!r}, "w", encoding="ascii") as pid_file: - pid_file.write(str(os.getpid())) - child = subprocess.Popen([sys.executable, "-c", {child_code!r}]) - with open({str(pid_path)!r}, "w", encoding="ascii") as pid_file: - pid_file.write(str(child.pid)) - print(json.dumps({{"v": 3, "type": "progress", "cmd": cmd, "data": {{"stage": "started"}}}}), flush=True) - time.sleep(1.0) - """ - ) - ) - fake.chmod(fake.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - - seen: list[tuple[str, dict]] = [] - event_seen = threading.Event() - outcome: dict[str, object] = {} - - def on_event(kind: str, payload: dict) -> None: - seen.append((kind, payload)) - event_seen.set() - - def invoke() -> None: - started = time.monotonic() - try: - Client(binary=str(fake), default_timeout=10.0).run( - "hold-open", - timeout=0.25, - on_event=on_event, - ) - except BaseException as exc: # capture the worker result for the assertion below - outcome["error"] = exc - finally: - outcome["elapsed"] = time.monotonic() - started - - worker = threading.Thread(target=invoke, daemon=True) - worker.start() - - def process_state(pid: int) -> str | None: - try: - with open(f"/proc/{pid}/stat", encoding="ascii") as stat_file: - fields = stat_file.read().split() - except FileNotFoundError: - return None - return fields[2] if len(fields) > 2 else None - - def cleanup_owned_processes() -> None: - pids: list[int] = [] - for path in (parent_pid_path, pid_path): - if path.exists(): - try: - pids.append(int(path.read_text(encoding="ascii"))) - except ValueError: - pass - for pid in pids: - if process_state(pid) not in (None, "Z"): - os.kill(pid, 9) - deadline = time.monotonic() + 2.0 - while time.monotonic() < deadline and any(process_state(pid) not in (None, "Z") for pid in pids): - time.sleep(0.01) - worker.join(timeout=2.0) - - try: - assert event_seen.wait(timeout=2.0), "fixture did not reach its startup handshake" - worker.join(timeout=3.0) - assert not worker.is_alive(), "Client.run worker did not return within its bounded fixture lifetime" - assert isinstance(outcome.get("error"), subprocess.TimeoutExpired) - assert float(outcome["elapsed"]) < 1.5 - assert seen == [("progress", {"stage": "started"})] - - child_pid = int(pid_path.read_text(encoding="ascii")) - deadline = time.monotonic() + 1.0 - while time.monotonic() < deadline and process_state(child_pid) not in (None, "Z"): - time.sleep(0.01) - if process_state(child_pid) not in (None, "Z"): - pytest.fail(f"owned child process {child_pid} survived Client.run timeout") - finally: - cleanup_owned_processes() - assert not worker.is_alive(), "owned subprocess cleanup left Client.run blocked" - - -def test_check_contract_accepts_the_remote_recipe_v3_handshake(tmp_path): - path = tmp_path / "fake-stoat" - path.write_text( - textwrap.dedent( - """\ - #!/usr/bin/env python3 - print('{"v":3,"type":"result","cmd":"version","ok":true,"data":{"contract":3,"version":"x"}}') - """ - ) - ) - path.chmod(path.stat().st_mode | stat.S_IEXEC) - - c = Client(binary=str(path), default_timeout=10.0) - assert c.check_contract() == 3 - - -def test_check_contract_raises_on_mismatch(tmp_path): - path = tmp_path / "fake-stoat" - path.write_text( - textwrap.dedent( - """\ - #!/usr/bin/env python3 - print('{"v":1,"type":"result","cmd":"version","ok":true,"data":{"contract":99,"version":"x"}}') - """ - ) - ) - path.chmod(path.stat().st_mode | stat.S_IEXEC) - from stoat_mcp.errors import ContractMismatch - - c = Client(binary=str(path), default_timeout=10.0) - with pytest.raises(ContractMismatch) as exc_info: - c.check_contract() - assert exc_info.value.found == 99 diff --git a/mcp/tests/test_guards.py b/mcp/tests/test_guards.py deleted file mode 100644 index 173ee344..00000000 --- a/mcp/tests/test_guards.py +++ /dev/null @@ -1,412 +0,0 @@ -"""Adversarial tests for guards.py, the only file that stands between an -agent's arguments and a subprocess running on the host. - -Every test here builds its own tmp_path "data root" and never touches -~/.stoat. check_host_path's whole job is confining a path to -/shared//, so most of these are attempts to escape that -confinement: symlinks, .., a sibling directory that shares a string prefix, -absolute paths elsewhere, and the degenerate inputs (empty, whitespace, -null byte, "." / ".."). -""" - -from __future__ import annotations - -import os - -import pytest - -from stoat_mcp.errors import GuardRejection -from stoat_mcp.guards import ( - RateLimiter, - check_flag_free, - check_host_path, - check_image_id, - check_vm_name, - shared_dir, - strip_forbidden, -) - -# --------------------------------------------------------------------------- -# check_vm_name -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("name", ["work", "work-2", "work.2", "a", "A1", "9x"]) -def test_vm_name_accepts_ordinary_names(name: str) -> None: - assert check_vm_name(name) == name - - -@pytest.mark.parametrize( - "name", - [ - "", - " ", - " work", - "work ", - ".", - "..", - "../etc", - "../../etc/passwd", - "a/b", - "a\\b", - ".hidden", - "-leading-dash", - "work\x00", - "work;rm -rf /", - "work\n", - ], -) -def test_vm_name_rejects_bad_input(name: str) -> None: - with pytest.raises(GuardRejection): - check_vm_name(name) - - -def test_vm_name_rejects_non_string() -> None: - with pytest.raises(GuardRejection): - check_vm_name(None) # type: ignore[arg-type] - - -def test_vm_name_rejects_absolute_path() -> None: - with pytest.raises(GuardRejection): - check_vm_name("/etc/passwd") - - -def test_vm_name_rejects_unicode_lookalike_separator() -> None: - # U+2215 DIVISION SLASH looks like a path separator but is not one; it - # is not a separator os.sep recognizes, so it is caught (if at all) by - # the character-class regex, not the separator check. Either accepting - # or rejecting it is defensible, but it must never be treated as a real - # path separator that changes shared_dir's resolution. - name = "work∕etc" - with pytest.raises(GuardRejection): - check_vm_name(name) - - -# --------------------------------------------------------------------------- -# check_image_id -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("image", ["alpine-virt", "ubuntu-24.04", "debian_13", "a"]) -def test_image_id_accepts_catalog_ids(image: str) -> None: - assert check_image_id(image) == image - - -@pytest.mark.parametrize( - "image", - [ - "", - " ", - "/abs/path.qcow2", - "../escape.qcow2", - "~/image.qcow2", - "relative/path.qcow2", - "a\\b", - ], -) -def test_image_id_rejects_paths(image: str) -> None: - with pytest.raises(GuardRejection): - check_image_id(image) - - -def test_image_id_rejects_non_string() -> None: - with pytest.raises(GuardRejection): - check_image_id(123) # type: ignore[arg-type] - - -# --------------------------------------------------------------------------- -# check_host_path -# --------------------------------------------------------------------------- - - -def test_host_path_accepts_file_inside_sandbox(tmp_path): - sandbox = shared_dir("work", tmp_path) - sandbox.mkdir(parents=True) - f = sandbox / "file.txt" - f.write_text("hi") - resolved = check_host_path(str(f), "work", tmp_path) - assert resolved == str(f.resolve()) - - -def test_host_path_accepts_the_sandbox_root_itself(tmp_path): - sandbox = shared_dir("work", tmp_path) - sandbox.mkdir(parents=True) - assert check_host_path(str(sandbox), "work", tmp_path) == str(sandbox) - - -def test_host_path_rejects_relative_path(tmp_path): - with pytest.raises(GuardRejection): - check_host_path("relative/file.txt", "work", tmp_path) - - -def test_host_path_rejects_traversal_out_of_sandbox(tmp_path): - sandbox = shared_dir("work", tmp_path) - sandbox.mkdir(parents=True) - escaped = str(sandbox / ".." / ".." / "etc" / "passwd") - with pytest.raises(GuardRejection): - check_host_path(escaped, "work", tmp_path) - - -def test_host_path_rejects_absolute_path_entirely_outside(tmp_path): - outside = tmp_path / "elsewhere" / "secret.txt" - outside.parent.mkdir(parents=True) - outside.write_text("nope") - with pytest.raises(GuardRejection): - check_host_path(str(outside), "work", tmp_path) - - -def test_host_path_rejects_sibling_sharing_a_string_prefix(tmp_path): - """The named example from the design doc: shared/work-evil is a STRING - prefix match against shared/work but not a directory-parent match, and - the guard must compare by path parts, not by string prefix.""" - shared_root = tmp_path / "shared" - shared_root.mkdir() - work = shared_root / "work" - work.mkdir() - evil = shared_root / "work-evil" - evil.mkdir() - evil_file = evil / "secret.txt" - evil_file.write_text("nope") - - with pytest.raises(GuardRejection): - check_host_path(str(evil_file), "work", tmp_path) - - -def test_host_path_rejects_symlink_escape(tmp_path): - """A symlink INSIDE the sandbox pointing outside it. This is the - scenario check_host_path's docstring calls out explicitly: the guest has - the sandbox mounted read-write and can create such a symlink itself, so - resolution must happen before the confinement check, not after.""" - sandbox = shared_dir("work", tmp_path) - sandbox.mkdir(parents=True) - outside = tmp_path / "outside" - outside.mkdir() - secret = outside / "secret.txt" - secret.write_text("nope") - - link = sandbox / "escape" - link.symlink_to(outside) - - with pytest.raises(GuardRejection): - check_host_path(str(link / "secret.txt"), "work", tmp_path) - - -def test_host_path_rejects_symlink_pointing_directly_out(tmp_path): - sandbox = shared_dir("work", tmp_path) - sandbox.mkdir(parents=True) - outside = tmp_path / "outside" / "secret.txt" - outside.parent.mkdir(parents=True) - outside.write_text("nope") - - link = sandbox / "link.txt" - link.symlink_to(outside) - - with pytest.raises(GuardRejection): - check_host_path(str(link), "work", tmp_path) - - -def test_host_path_accepts_symlink_that_stays_inside_sandbox(tmp_path): - sandbox = shared_dir("work", tmp_path) - sandbox.mkdir(parents=True) - real = sandbox / "real.txt" - real.write_text("hi") - link = sandbox / "link.txt" - link.symlink_to(real) - - resolved = check_host_path(str(link), "work", tmp_path) - assert resolved == str(real.resolve()) - - -def test_host_path_rejects_empty_and_whitespace(tmp_path): - for bad in ("", " ", "\t"): - with pytest.raises(GuardRejection): - check_host_path(bad, "work", tmp_path) - - -def test_host_path_rejects_null_byte(tmp_path): - sandbox = shared_dir("work", tmp_path) - sandbox.mkdir(parents=True) - with pytest.raises(GuardRejection): - check_host_path(str(sandbox / "a\x00b"), "work", tmp_path) - - -def test_host_path_expands_tilde_to_the_callers_home_not_ours(tmp_path, monkeypatch): - # "~" must expand via the OS's normal rule (the invoking user's home), - # not be treated as a literal directory name; os.path.realpath would - # otherwise resolve a literal "~" relative to the cwd, which is wrong in - # a different way. - monkeypatch.setenv("HOME", str(tmp_path)) - sandbox = shared_dir("work", tmp_path) - sandbox.mkdir(parents=True) - f = sandbox / "f.txt" - f.write_text("hi") - home_relative = os.path.join("~", os.path.relpath(f, tmp_path)) - resolved = check_host_path(home_relative, "work", tmp_path) - assert resolved == str(f.resolve()) - - -def test_host_path_different_vm_names_have_disjoint_sandboxes(tmp_path): - sandbox_a = shared_dir("work", tmp_path) - sandbox_a.mkdir(parents=True) - f = sandbox_a / "f.txt" - f.write_text("hi") - # Same file, but authorised for a different VM: must be rejected. - with pytest.raises(GuardRejection): - check_host_path(str(f), "other", tmp_path) - - -def test_host_path_rejects_invalid_vm_name_even_as_a_path_component(tmp_path): - # check_host_path calls shared_dir -> check_vm_name internally, so a - # malicious "vm" argument (not the path) is caught the same way. - with pytest.raises(GuardRejection): - check_host_path(str(tmp_path / "shared" / "work" / "f.txt"), "../escape", tmp_path) - - -# --------------------------------------------------------------------------- -# strip_forbidden -# --------------------------------------------------------------------------- - - -def test_strip_forbidden_removes_every_forbidden_key(): - patch = { - "ram": 4096, - "share": "/home/user/whatever", - "image": "/abs/evil.qcow2", - "base": "/abs/base.qcow2", - "iso": "/abs/thing.iso", - "console_password": "hunter2", - } - cleaned = strip_forbidden(patch) - assert cleaned == {"ram": 4096} - - -def test_strip_forbidden_is_a_no_op_on_a_clean_patch(): - patch = {"ram": 4096, "cpus": 2} - assert strip_forbidden(patch) == patch - - -def test_strip_forbidden_does_not_mutate_the_input(): - patch = {"share": "x", "ram": 1} - strip_forbidden(patch) - assert patch == {"share": "x", "ram": 1} - - -# --------------------------------------------------------------------------- -# RateLimiter -# --------------------------------------------------------------------------- - - -def test_rate_limiter_allows_up_to_capacity(): - # A tiny nonzero refill rate, not 0.0: RateLimiter.check divides by - # refill_per_second when it needs to report a wait time, so an actual - # 0.0 raises ZeroDivisionError instead of GuardRejection once the bucket - # is empty. A still-positive rate this small is effectively "never - # refills" within any of these tests. - rl = RateLimiter(capacity=3, refill_per_second=1e-9) - rl.check("create", now=0.0) - rl.check("create", now=0.0) - rl.check("create", now=0.0) - with pytest.raises(GuardRejection): - rl.check("create", now=0.0) - - -def test_rate_limiter_buckets_are_independent_per_tool(): - rl = RateLimiter(capacity=1, refill_per_second=1e-9) - rl.check("create", now=0.0) - # A different tool has its own bucket, unaffected by "create" being spent. - rl.check("destroy", now=0.0) - - -def test_rate_limiter_refills_over_time(): - rl = RateLimiter(capacity=1, refill_per_second=1.0) - rl.check("create", now=0.0) - with pytest.raises(GuardRejection): - rl.check("create", now=0.1) - # A full second later, one token has refilled. - rl.check("create", now=1.0) - - -def test_rate_limiter_shared_bucket_bounds_every_tool_together(): - # Per-tool buckets alone let a caller burst capacity times against each - # of ~20 tools. The shared bucket is what caps the server as a whole. - rl = RateLimiter( - capacity=10, refill_per_second=1e-9, total_capacity=2, total_refill_per_second=1e-9 - ) - rl.check("list_vms", now=0.0) - rl.check("vm_status", now=0.0) - with pytest.raises(GuardRejection): - rl.check("doctor", now=0.0) - - -def test_rate_limiter_refused_tool_does_not_spend_a_shared_token(): - # One hot tool hitting its own limit must not starve every other tool. - rl = RateLimiter( - capacity=1, refill_per_second=1e-9, total_capacity=5, total_refill_per_second=1e-9 - ) - rl.check("create", now=0.0) - for _ in range(3): - with pytest.raises(GuardRejection): - rl.check("create", now=0.0) - rl.check("destroy", now=0.0) - rl.check("doctor", now=0.0) - - -# --------------------------------------------------------------------------- -# check_flag_free -# --------------------------------------------------------------------------- - - -def test_check_flag_free_rejects_a_kong_flag(): - # forward(pairs=["--clear"]) reached kong as the clear flag and wiped the - # VM's forwards, from a call that passed clear=False. - with pytest.raises(GuardRejection): - check_flag_free(["--clear"], "pairs") - - -def test_check_flag_free_rejects_a_short_flag_and_empty_values(): - with pytest.raises(GuardRejection): - check_flag_free(["-n"], "pairs") - with pytest.raises(GuardRejection): - check_flag_free([" "], "pairs") - - -def test_check_flag_free_passes_ordinary_values(): - assert check_flag_free(["8080:80", "2222:22"], "pairs") == ["8080:80", "2222:22"] - assert check_flag_free([], "pairs") == [] - - -# --------------------------------------------------------------------------- -# recipe index names -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "ref", ["tailscale", "tailscale@v1.2", "x-y@main", "tailscale@feature/topic"] -) -def test_check_index_name_accepts_name_and_optional_ref(ref: str) -> None: - from stoat_mcp import guards - - assert guards.check_index_name(ref) == ref - - -@pytest.mark.parametrize( - "ref", - [ - "https://github.com/x/stoat-tailscale", - "git@github.com:x/r.git", - "../../etc/passwd", - "a/b", - "-y", - "tailscale@../x", - "tailscale@feature..topic", - "tailscale@feature/.hidden", - "tailscale@feature/topic.lock", - "tailscale@", - "", - ], -) -def test_check_index_name_rejects_urls_paths_options_and_bad_refs(ref: str) -> None: - from stoat_mcp import guards - - with pytest.raises(GuardRejection): - guards.check_index_name(ref) diff --git a/mcp/tests/test_server.py b/mcp/tests/test_server.py deleted file mode 100644 index 17361b0b..00000000 --- a/mcp/tests/test_server.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Structural tests for the tool surface itself: every tool sets -additionalProperties: false, no forbidden surface (docs/design/mcp- -server.md §4) is registered at all, annotations match the spec's table -(§5), and no tool accepts a parameter literally named `share`. - -These do not invoke stoat and do not need a binary: they only ask fastmcp -what it registered. -""" - -from __future__ import annotations - -import asyncio - -import pytest -from fastmcp.exceptions import ToolError - -from stoat_mcp import server - -# The class table from docs/design/mcp-server.md §5. -EXPECTED_ANNOTATIONS = { - "list_vms": {"readOnlyHint": True, "destructiveHint": False}, - "vm_status": {"readOnlyHint": True, "destructiveHint": False}, - "list_images": {"readOnlyHint": True, "destructiveHint": False}, - "list_recipes": {"readOnlyHint": True, "destructiveHint": False}, - "check_recipes": {"readOnlyHint": True, "destructiveHint": False}, - "logs": {"readOnlyHint": True, "destructiveHint": False}, - "search_recipes": {"readOnlyHint": True, "destructiveHint": False}, - "doctor": {"readOnlyHint": True, "destructiveHint": False}, - "plan_recipes": {"readOnlyHint": True, "destructiveHint": False}, - "create": {"readOnlyHint": False, "destructiveHint": False}, - "start": {"readOnlyHint": False, "destructiveHint": False}, - "stop": {"readOnlyHint": False, "destructiveHint": False}, - "update": {"readOnlyHint": False, "destructiveHint": False}, - "add_recipe": {"readOnlyHint": False, "destructiveHint": False}, - "update_recipe": {"readOnlyHint": False, "destructiveHint": False}, - "clone": {"readOnlyHint": False, "destructiveHint": False}, - "snapshot": {"readOnlyHint": False, "destructiveHint": False}, - "forward": {"readOnlyHint": False, "destructiveHint": False}, - "wait": {"readOnlyHint": False, "destructiveHint": False}, - "destroy": {"readOnlyHint": False, "destructiveHint": True}, - "prune": {"readOnlyHint": False, "destructiveHint": True}, - "remove_recipe": {"readOnlyHint": False, "destructiveHint": True}, - "restore": {"readOnlyHint": False, "destructiveHint": True}, - # A recipe body is arbitrary guest code, so apply_recipes carries exec's - # hints and exec's allow_exec check. - "apply_recipes": { - "readOnlyHint": False, - "destructiveHint": True, - "openWorldHint": True, - }, - "exec": {"readOnlyHint": False, "destructiveHint": True, "openWorldHint": True}, - "copy_to": {"readOnlyHint": False, "destructiveHint": True, "openWorldHint": True}, - "copy_from": {"readOnlyHint": False, "destructiveHint": True, "openWorldHint": True}, -} - -# §4: never exposed as tools at all, absence rather than a gate. -FORBIDDEN_TOOL_NAMES = {"recipe_new", "ssh_command", "ssh-command", "pull"} - - -def _tools() -> dict[str, object]: - async def _get(): - return await server.mcp.list_tools() - - return {t.name: t for t in asyncio.run(_get())} - - -def test_every_expected_tool_is_registered(): - tools = _tools() - missing = set(EXPECTED_ANNOTATIONS) - set(tools) - assert not missing, f"tools not registered: {missing}" - - -def test_no_unexpected_tools_are_registered(): - tools = _tools() - extra = set(tools) - set(EXPECTED_ANNOTATIONS) - assert not extra, f"unexpected tools registered: {extra}" - - -def test_forbidden_surfaces_are_not_registered_at_all(): - tools = _tools() - for name in FORBIDDEN_TOOL_NAMES: - assert name not in tools - - -@pytest.mark.parametrize("name", sorted(EXPECTED_ANNOTATIONS)) -def test_tool_schema_sets_additional_properties_false(name): - tool = _tools()[name] - assert tool.parameters.get("additionalProperties") is False, ( - f"{name} does not reject unexpected parameters" - ) - - -@pytest.mark.parametrize("name", sorted(EXPECTED_ANNOTATIONS)) -def test_annotations_match_the_spec_table(name): - tool = _tools()[name] - expected = EXPECTED_ANNOTATIONS[name] - actual = tool.annotations - assert actual is not None, f"{name} has no annotations" - for key, value in expected.items(): - assert getattr(actual, key) == value, f"{name}.{key}" - # Hints not in the table must not be silently set true either. - for key in ("readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"): - if key not in expected: - assert getattr(actual, key) in (None, False), f"{name}.{key} unexpectedly set" - - -def test_no_tool_has_a_parameter_named_share(): - tools = _tools() - for name, tool in tools.items(): - props = tool.parameters.get("properties", {}) - assert "share" not in props, f"{name} accepts a share parameter" - - -def test_no_tool_has_a_parameter_named_console_password_or_image_path_flags(): - tools = _tools() - for name, tool in tools.items(): - props = tool.parameters.get("properties", {}) - assert "console_password" not in props, name - - -def test_create_only_accepts_a_catalog_image_id_field_named_image(): - tools = _tools() - props = tools["create"].parameters.get("properties", {}) - assert "image" in props - assert "share" not in props - assert "console_password" not in props - - -def test_every_tool_has_a_non_empty_human_readable_description(): - tools = _tools() - for name, tool in tools.items(): - assert tool.description and len(tool.description.strip()) > 20, name - - -def test_no_tool_description_contains_an_em_dash(): - tools = _tools() - for name, tool in tools.items(): - assert "—" not in (tool.description or ""), name - - -class _FakeClient: - """Records the argv each tool builds and answers from a fixed dict.""" - - def __init__(self, answers: dict[str, object] | None = None) -> None: - self.calls: list[tuple[str, ...]] = [] - self.answers = answers or {} - - def run(self, *args: str, **_: object) -> dict[str, object]: - self.calls.append(args) - return dict(self.answers.get(args[0], {})) - - -@pytest.fixture -def fake_client(monkeypatch): - client = _FakeClient({"get": {"vm": {"allow_exec": True}}}) - monkeypatch.setattr(server, "get_client", lambda: client) - return client - - -def test_apply_recipes_refuses_a_vm_with_allow_exec_false(monkeypatch): - # A recipe body is arbitrary guest code. allow_exec=false blocked exec - # and copy while apply ran scripts as root, which made the opt-out a - # partial one. - client = _FakeClient({"get": {"vm": {"allow_exec": False}}}) - monkeypatch.setattr(server, "get_client", lambda: client) - with pytest.raises(ToolError): - server.apply_recipes("work") - assert all(c[0] != "apply" for c in client.calls) - - -def test_plan_recipes_runs_a_dry_run_and_needs_no_exec_permission(monkeypatch): - client = _FakeClient({"get": {"vm": {"allow_exec": False}}}) - monkeypatch.setattr(server, "get_client", lambda: client) - server.plan_recipes("work") - assert client.calls == [("apply", "work", "--dry-run")] - - -def test_wait_clamps_the_timeout(fake_client): - server.wait("work", timeout_seconds=10**6) - argv = fake_client.calls[-1] - assert argv[argv.index("--timeout") + 1] == f"{server.MAX_WAIT_SECONDS}s" - - -def test_logs_clamps_the_line_count(fake_client): - server.logs("work", n=10**6) - argv = fake_client.calls[-1] - assert argv[argv.index("-n") + 1] == str(server.MAX_LOG_LINES) - - -def test_forward_refuses_a_pair_that_kong_reads_as_a_flag(fake_client): - with pytest.raises(ToolError): - server.forward("work", pairs=["--clear"]) - assert fake_client.calls == [] - - -@pytest.mark.parametrize("term", ["-tail", "--json", "--refresh", "--"]) -def test_search_recipes_preserves_a_leading_dash_as_data(fake_client, term): - server.search_recipes(term) - assert fake_client.calls == [("recipe", "search", "--", term)] - - -def test_add_recipe_accepts_a_slash_containing_ref_and_uses_variadic_argv(fake_client): - server.add_recipe("tailscale", ref="feature/topic") - assert fake_client.calls == [("recipe", "add", "tailscale@feature/topic", "-y")] - - -@pytest.mark.parametrize( - "call", - [ - lambda: server.add_recipe("https://github.com/x/stoat-tailscale"), - lambda: server.add_recipe("tailscale@../escape"), - lambda: server.add_recipe("tailscale@feature..topic"), - lambda: server.add_recipe("tailscale@feature/.hidden"), - lambda: server.add_recipe("tailscale@feature/topic.lock"), - lambda: server.add_recipe("-y"), - lambda: server.update_recipe("tailscale@v1.2"), - lambda: server.remove_recipe("../tailscale"), - lambda: server.remove_recipe("-y"), - ], -) -def test_recipe_tools_refuse_unsafe_names_before_cli(call, fake_client): - with pytest.raises(ToolError): - call() - assert fake_client.calls == [] - - -def test_update_and_remove_recipe_use_plain_names_and_remove_has_no_force(fake_client): - server.update_recipe("tailscale") - server.update_recipe() - server.remove_recipe("tailscale") - assert fake_client.calls == [ - ("recipe", "update", "tailscale"), - ("recipe", "update"), - ("recipe", "rm", "tailscale", "-y"), - ] From 27e2c7faf4dcf0ca9d17a1b527789770e5222b02 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 21:59:59 +0300 Subject: [PATCH 58/67] ci: drop the mcp python test job Signed-off-by: NovusEdge --- .github/workflows/ci.yml | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a76482b2..8b92b4e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,27 +58,3 @@ jobs: # bundled installer is not worth a red PR. - name: shellcheck run: shellcheck -S warning $(git ls-files 'internal/recipes/bundled/*.sh' 'internal/recipes/bundled/*/*.sh' 'scripts/*.sh' '.githooks/*') - - # A separate job because it needs Python rather than Go, and because a - # failure here should read as "the MCP server broke", not "the Go suite - # broke". Until this existed, mcp/'s tests ran only on a contributor's - # machine, so a regression in guards.py (the deterministic blocks that are - # the whole security boundary, since MCP guarantees nothing itself) would - # have merged green. - mcp: - name: mcp - runs-on: ubuntu-latest - defaults: - run: - working-directory: mcp - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - uses: astral-sh/setup-uv@v5 - - - name: install - run: uv pip install --system -e '.[dev]' - - name: test - run: pytest -q From 13b1d4bf8ff7fcebd0a6c855370b86205ccddc66 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 22:04:35 +0300 Subject: [PATCH 59/67] docs(mcp): rewrite design doc for the Go server Signed-off-by: NovusEdge --- docs/design/mcp-server.md | 419 +++++++++++++++++++--------------- docs/reference/json.md | 27 ++- internal/cli/wire/envelope.go | 8 +- 3 files changed, 264 insertions(+), 190 deletions(-) diff --git a/docs/design/mcp-server.md b/docs/design/mcp-server.md index 458253c9..440700d1 100644 --- a/docs/design/mcp-server.md +++ b/docs/design/mcp-server.md @@ -1,222 +1,281 @@ -# The MCP server: build spec +# The MCP server Decisions are settled here. This document is the source of truth for the -implementation; `core-api.md` §10 and `json-contract-draft.md` §7 are the -reasoning behind it and should be read first, not re-argued. +implementation; `core-api.md` §10 and `json-contract-draft.md` §7 hold the +original reasoning and should be read first, not re-argued. -Branch: `mcp-server`. Contract: [../reference/json.md](../reference/json.md). +Contract: [../reference/json.md](../reference/json.md). ## 0. Settled, do not relitigate | | | |---|---| -| Language | Python + fastmcp, a separate process. Chosen twice by the owner with a researched recommendation for Go in front of them. | -| Interface to stoat | Run the `stoat` binary with `--json` and read JSON Lines. Never link Go, never read `~/.stoat` directly. | +| Language | Go, in the `stoat` binary. `github.com/modelcontextprotocol/go-sdk` v1.7.0. | +| Interface to stoat | `internal/mcpsrv` imports `internal/core` and `internal/cli/wire` directly. It does not shell out and does not read `~/.stoat` itself. | | Layering | A thin mapping. **If a tool needs logic `core` does not have, the layering is wrong**, and the fix goes in `core`, not here. | | Enforcement | Lives in code. Client-side approval is defence in depth and is never the boundary (MCP's human-in-the-loop is `SHOULD`, annotations are advisory). | | v1 scope | The full four-class taxonomy. | -| Layout | `mcp/` in this repo, with a startup contract-version check. | -| `exec` | Allowed by default, with an optional per-VM opt-out. | +| Layout | `internal/mcpsrv/` in this repo, run with `stoat mcp`. The JSON contract check is a compile-time fact: both `stoat mcp` and `stoat --json version` read the same `wire.ContractVersion`. | +| `exec` | Gated by `agent_access`, one of four levels recorded per VM (§4). | -## 1. Prerequisites in the Go CLI +## 1. Layout -Both land before the Python, on this branch. - -### 1.1 `cp` takes explicit flags under `--json` - -`json-contract-draft.md` §7.3 item 1, the most important single item in §7. -Today `cp` smuggles a host path inside a compound `:` argument. For -the server to know which side is a host path it would have to reimplement the -colon split and the both-or-neither rejection, and any divergence is a hole. -Worse, a host path legitimately containing a colon is ambiguous. - -Add, alongside the existing positional spelling, which stays for humans: - -``` -stoat --json cp --vm work --direction to --local /abs/host --remote /tmp/x -stoat --json cp --vm work --direction from --remote /tmp/x --local /abs/host -``` - -`--direction` is an enum, `to` or `from`. The flag form and the positional -form are mutually exclusive; giving both is a usage error. The result must -**echo back the resolved absolute `local` path it acted on** (§7.3 item 2), so -the server can post-verify that what it authorised is what happened. - -### 1.2 `core.Spec.AllowExec` - -Recorded per VM in `vm.toml` as `allow_exec`. **Default true**, including for -every VM that predates the field, so nothing breaks and stoat stays useful to -an agent. `core.Exec` does NOT enforce it: enforcement is the server's job and -`core` is a library the TUI and CLI also call. `core.VM` and the JSON DTO -expose it so the server can read it; `create --allow-exec=false` sets it. - -The reason it exists at all: a user who wants one VM an agent may never run -code in should be able to say so once, at create time, rather than trusting -every future caller. - -## 2. Layout - -``` -mcp/ - pyproject.toml - README.md - stoat_mcp/ - __init__.py - client.py # subprocess + envelope decoding. The ONLY place that runs stoat. - errors.py # StoatError, code constants mirroring json.md - guards.py # the deterministic blocks. No I/O, pure functions, heavily tested. - server.py # fastmcp tool definitions. Thin: validate, call client, return. - tests/ - test_client.py - test_guards.py - test_server.py ``` - -Binary resolution: `$STOAT_BIN`, else `shutil.which("stoat")`. Fail loudly at -startup if neither resolves. - -**Startup contract check.** Call `stoat --json version` once and compare -`data.contract` against the `EXPECTED_CONTRACT` constant. Refuse to start on a -mismatch with a message naming both versions. A stale binary must fail -immediately, not as a confusing `KeyError` three tools later. - -## 3. `client.py` - -One function does all subprocess work: - -```python -def run(*args: str, timeout: float | None = None) -> dict: ... +internal/mcpsrv/ + server.go registers tools, builds the server, picks the transport + contract.go the contract constant this server speaks + access.go Level, requireAccess, the agent_access table + guards.go name, path, image id, index name, param name, flag-free + ratelimit.go per-tool and shared token buckets, as receiving middleware + redact.go secret redaction over wire values, as receiving middleware + jobs.go the jobs.toml registry for background exec + install.go stoat mcp install + doctor.go stoat mcp doctor + tools_read.go list_vms, vm_status, list_images, list_recipes, + check_recipes, logs, doctor, plan_recipes, list_guests, + guest_info, recipe_schema, search_recipes + tools_vm.go create, start, stop, update, clone, snapshot, restore, + forward, wait, destroy, prune, apply_recipes + tools_recipe.go add_recipe, update_recipe, remove_recipe + tools_guest.go read_file, list_dir, stat, ps, svc_status, tail_log, + write_file, copy_to, copy_from, pkg_install, svc, useradd + tools_exec.go exec, exec_bg, job_status, job_output, job_kill, list_jobs ``` -Rules, each of which is the contract in json.md and not a choice: +Binary resolution for `stoat mcp install`: the running binary's own absolute +path (`os.Executable`), never `$PATH` lookup, so the installed entry always +points at the binary that wrote it. -1. `stdout=PIPE`, `stderr=DEVNULL`. Never parse stderr. -2. Read every line, `json.loads` each, keep the one with `type == "result"`. -3. No result line and a nonzero exit means the process died: raise - `StoatCrashed(returncode)`. -4. `ok: false` raises `StoatError(code, message)`. Callers branch on `code`, - never on `message`. -5. **Ignore any unrecognized `type`.** New event types must not break us. -6. Never branch on the exit code for anything except "was there a result". +Every MCP client launches a stdio server as a subprocess, so `stoat mcp` (no +subcommand) defaults to `serve` over stdio. `--http 127.0.0.1:PORT` serves +streamable HTTP instead, for a client that cannot launch a subprocess; +`CheckLoopback` refuses any address that is not loopback, since this server +has no authentication. -Streaming tools (`pull`, `apply`) additionally surface `progress`, `stage` and -`log` events. Expose them through a callback, not by buffering the whole run. +## 2. Guards -## 4. `guards.py` - -Pure functions, no I/O, so they are exhaustively testable. Every one of these -is enforced regardless of what the client does. +`internal/mcpsrv/guards.go` ports the deterministic blocks one to one from +the design's original Python draft, as functions called at the top of each +handler. Pure functions, no I/O, so they are exhaustively testable in +`guards_test.go`. | Guard | Rule | |---|---| -| `check_vm_name` | Must match stoat's own name rules and resolve to a managed VM. No paths, no traversal, no empty. | -| `check_host_path` | For `copy_to`/`copy_from` only. Resolve with `os.path.realpath` (after symlinks), require the prefix `~/.stoat/shared//`, reject anything else. | -| `check_image_id` | Catalog IDs only. An absolute or relative path is rejected: §7.1 #4. | -| `rate_limit` | A token bucket per tool, and a second one shared by every tool. Per-tool alone let a caller burst `capacity` times across each of ~20 tools. The MCP spec makes rate limiting a server `MUST`. | -| `check_flag_free` | Values splatted into argv as positionals (`forward` pairs, `check_recipes` names) must not start with `-`. `forward(pairs=["--clear"])` otherwise reached kong as the clear flag. | -| `check_index_name` | `add_recipe` takes an index name with an optional `@ref`; a URL, path separator in the plain name, dot segment, leading dash, or malformed ref is refused before it reaches the CLI. A valid slash-containing branch ref after `@` is allowed. `update_recipe` and `remove_recipe` take plain names. | - -**Never exposed as tools at all** (§7.1): `share` as any parameter, BYO image -paths, `recipe new`, `ssh-command`, and the global (no VM) `logs`. These are -not gated, they are absent. - -`share` is asymmetric on purpose: it is fine as OUTPUT on a VM object, so an -agent can see a share exists. It is never accepted as INPUT. - -## 5. Tools - -Annotations are declared honestly AND enforced independently, since a client -may ignore them. - -| Class | Tools | `readOnlyHint` | `destructiveHint` | -|---|---|---|---| -| Read-only | `list_vms`, `vm_status`, `list_images`, `list_recipes`, `check_recipes`, `logs`, `search_recipes`, `doctor`, `plan_recipes` | true | false | -| Mutating | `create`, `start`, `stop`, `apply_recipes`, `update`, `add_recipe`, `update_recipe`, `clone`, `snapshot`, `forward`, `wait` | false | false | -| Destructive | `destroy`, `prune`, `remove_recipe`, `restore` | false | true | -| Execution | `exec`, `copy_to`, `copy_from` | false | true, `openWorldHint` true | - -`plan_recipes` is `apply --dry-run`. It exists so an agent can read what an -apply would do before running one, which is the cheapest safety an agent gets -here. It is a separate tool rather than a `dry_run` flag on `apply_recipes`, -because one tool cannot honestly declare `readOnlyHint` both ways. - -`restore` is destructive. It discards everything written since the snapshot, -and only another snapshot taken later undoes that. - -`apply_recipes` runs arbitrary scripts inside the guest, so it checks -`allow_exec` exactly as `exec` does. A VM created with `--allow-exec=false` -refuses both. - -`search_recipes` and `add_recipe` use the curated index. `add_recipe` accepts an -index name and an optional tag or branch ref, including slash-containing branch -refs. It never accepts a repository URL. `update_recipe` and `remove_recipe` -address an existing remote pin by plain name, including one originally added -from a URL. `remove_recipe` has no `force` argument and therefore refuses while -a VM still lists the recipe. - -Every schema sets `additionalProperties: false`, so an unexpected parameter is -rejected rather than silently ignored (OWASP MCP guidance). - -Tool descriptions are the full text a user would see, and carry no hidden -instructions. That is the defence against description poisoning being -invisible in a client's abbreviated UI. - -`update` must strip `share` from any patch built from agent input, per §7.1 -#2, since `core.Patch` is exactly the generic-map shape that makes it reachable. - -## 6. Testing - -`test_guards.py` is the important one and should be adversarial: symlink -escapes, `..` traversal, absolute paths, a path whose prefix matches the -sandbox as a STRING but not as a directory (`~/.stoat/shared/work-evil`), -unicode tricks, empty and whitespace names. - -`test_client.py` uses a fake `stoat` binary (a script echoing fixed JSON -Lines) rather than the real one, so envelope handling is testable without -VMs: a missing result line, an unknown event type, a nonzero exit with a valid -error envelope, invalid JSON mid-stream. - -`test_server.py` asserts every tool declares `additionalProperties: false`, -that no forbidden surface from §4 is registered, and that annotations match -the table in §5. - -## 7. Out of scope for v1 - -Stated so nobody builds them by accident: HTTP transport (stdio only), -authentication, multi-user, and any tool that writes to the host outside -`~/.stoat/shared//`. +| `checkVMName` | Must match stoat's own name rules: no paths, no traversal, no empty, no unicode lookalike separator. | +| `checkHostPath` | For `copy_to`/`copy_from` only. Resolves symlinks first, then requires the prefix `~/.stoat/shared//`. A path resolving outside it is refused even when its string prefix matches a sibling directory. | +| `checkImageID` | Catalog IDs only. An absolute or relative path is rejected. | +| `checkGuestPath` | Every in-VM tool's path argument. Must be absolute; never resolved against `$HOME`. | +| `checkFlagFree` | A value splatted into argv as a positional (`forward` pairs, `check_recipes` names, `search_recipes`'s term) must not start with `-`. `forward(pairs=["--clear"])` otherwise reached kong as the clear flag. | +| `checkIndexName` | `add_recipe` takes an index name with an optional `@ref`; a URL, a path separator in the plain name, or a malformed ref is refused before it reaches `core`. `update_recipe` and `remove_recipe` take plain names. | +| `checkParamName` | A recipe param name set through `update`, bounded to the recipe contract's own grammar. | +| `checkSvcName` | A service name `svc` and `svc_status` render into a guest-file template as a positional argument. | +| `stripForbidden` | Drops `share`, `image`, `base`, `iso`, and `console_password` from any patch built from agent input, since `core.Patch` is a generic-map shape that makes them reachable otherwise. | + +Rate limiting (`ratelimit.go`) is a per-tool token bucket (30, 0.5/s) and a +shared bucket (60, 2/s) across every tool, both checked before either is +charged, as receiving middleware. The MCP spec makes rate limiting a server +`MUST`. + +**Never exposed as tools at all**: `share` as any parameter, BYO image +paths, `recipe new`, an ssh-command tool, and the global (no VM) `logs`. +These are absent, not gated; `TestForbiddenSurfacesAbsent` and +`TestNoForbiddenInputField` pin it. + +## 3. Redaction + +`redact.go` runs as receiving middleware, closest to the handler, after +every registered tool has built its result. It walks the result as generic +JSON and replaces the value of any field named `secrets`, +`console_password`, `authkey`, `password`, or `token` with `core.SecretSet` +or `core.SecretUnset`. This is the second layer: `wire.FromVMStatus` already +redacts a recipe's own secret params by the manifest's declared type, so a +DTO that forgets is still covered. + +The `secrets` input of `update` is never echoed back: `wire.VM` has no +secrets field, so there is nothing to redact in the response, and the walk +above still covers a map or a list a future tool returns. + +## 4. Agent access levels + +`agent_access` replaces `allow_exec` in `vm.toml`, one of four levels. Each +level includes the tools of every level below it. + +| Level | Tools | +|---|---| +| `none` | host-side only: `status`, `start`, `stop`, `snapshot`, `restore`, `logs`, `forward`, `update` | +| `observe` | `read_file`, `list_dir`, `stat`, `ps`, `svc_status`, `tail_log` | +| `manage` (default) | `write_file`, `copy_to`, `copy_from`, `pkg_install`, `svc`, `useradd`, `apply_recipes` | +| `exec` | `exec`, `exec_bg`, `job_status`, `job_output`, `job_kill`, `list_jobs` | + +`stoat new --allow-exec` stays as a hidden alias for `--agent-access exec`; +`--allow-exec=false` maps to `manage`. An existing `vm.toml` with +`allow_exec = true` loads as `exec`, `false` as `manage`, so an old VM keeps +its meaning under the new field. + +`requireAccess(vm, level)` gates every guest-touching tool. `core.Exec` does +not enforce it, because `core` is a library the CLI and TUI also call and a +blanket refusal there would be the wrong layer. A refusal names both levels: +`vm "dev" has agent_access = observe; write_file needs manage`. + +MCP's `update` tool may lower a VM's `agent_access` and never raise it. +Raising is CLI or TUI only, so an agent cannot grant itself more access than +a person gave it. + +### The `manage` tools are fixed argv from the guest file + +`pkg_install`, `svc`, `svc_status`, `tail_log`, and `useradd` render a fixed +argv from the guest definition's own verbs (`internal/guest`), so a VM at +`manage` can install a package, restart a service, read its log, and write a +config file without an open shell. `pkg_install` runs `pkg.setup` once, then +`pkg.install` plus the requested packages. `svc` runs `svc.` for +`enable`, `start`, `stop`, or `restart`. `tail_log` runs `journalctl -u` on a +systemd guest, `tail` on the init's own log path otherwise, or `tail` on an +explicit `path`, with `lines` clamped to 2000. + +### In-VM tools over ssh + +Every tool that touches a guest wraps `sshx.Run`, which is the one place in +stoat that quotes an argv for the guest shell. A tool never concatenates a +value into a shell string; every value arrives as a positional argument. +Reads (`read_file`, `list_dir`, `stat`, `ps`) are annotated read-only; +everything else is class Execution (`destructiveHint`, `openWorldHint`). +`read_file`'s `max_bytes` is clamped to 1 MiB; binary content comes back +base64 with `encoding` set. `write_file` and `exec_bg` refuse with +`CodeNotRunning` on a stopped VM, the same as `exec`. + +### Background jobs + +`exec_bg` starts a command under a fixed shell runner and returns a job id +at once. The job id is `j-` plus 8 lowercase hex characters, chosen by the +host. The guest side is `/run/stoat/jobs//{out,err,exit,pid}`; a reboot +clears it and `job_status` then reports `unknown`, while the host record +stays. The host record is `jobs.toml` beside the VM's own `vm.toml`: + +```toml +# written by stoat; do not edit +schema = 1 + +[jobs.j-9f3c1e2a] +argv = ["sleep", "60"] +user = "stoat" +cwd = "/home/stoat" +dir = "/run/stoat/jobs/j-9f3c1e2a" +started = "2026-09-04T10:00:00Z" +``` -## 8. Live gate: CLEARED (2026-08-04) +`list_jobs` reads this file only, so it works from the host without ssh and +on a stopped VM. + +## 5. `stoat mcp install` and `stoat mcp doctor` + +| Client | File | Key | +|---|---|---| +| `claude-code` | `~/.claude.json`, or `./.mcp.json` with `--project` | `mcpServers.stoat` | +| `claude-desktop` | `~/.config/Claude/claude_desktop_config.json` | `mcpServers.stoat` | +| `cursor` | `~/.cursor/mcp.json` | `mcpServers.stoat` | +| `vscode` | `./.vscode/mcp.json` | `servers.stoat` | + +The written entry is `{"command": "", "args": ["mcp"], +"cwd": ""}`. `cwd` is the process's own working directory, so project +scope applies to the server the client launches. An existing `stoat` entry +is replaced; every other entry and every other top-level key is preserved; +the file is written to a temp file in the same directory and renamed. +`--print` writes the JSON to stdout instead, and touches no file. + +`stoat mcp doctor` reports the contract version, the transport, the running +binary's path, and, for each client, whether it has an entry and whether +that entry's `command` matches the running binary. A stale entry launches a +different `stoat` than the one just installed, and that mismatch is what +this command exists to name. + +## 6. Tools + +The full tool table, its annotation class (`readOnlyHint`/`destructiveHint`/ +`openWorldHint`), and its `agent_access` level are declared once in +`internal/mcpsrv/table_test.go`, which is the source of truth; +`TestAnnotationsMatchTable` asserts every registered tool matches it. + +Every input struct's generated JSON schema sets `additionalProperties: +false`, so an unexpected parameter is rejected rather than silently ignored +(OWASP MCP guidance). Tool descriptions are the full text a client shows a +user and carry no hidden instructions; `TestNoEmDashInDescription` and +`TestEveryToolHasDescription` pin the surface. + +`plan_recipes` is `apply --dry-run` as its own tool rather than a flag on +`apply_recipes`, because one tool cannot honestly declare `readOnlyHint` +both ways. `search_recipes` and `add_recipe` use the curated remote index; +`add_recipe` accepts an index name and an optional tag or branch ref, +including slash-containing branch refs, and never a repository URL. +`remove_recipe` has no `force` argument and refuses while a VM still lists +the recipe. + +## 7. Testing + +- Annotation table: every tool's `readOnlyHint`, `destructiveHint`, + `openWorldHint` against the class table. +- Schema: `additionalProperties: false` on every input; none of `share`, + `image`, `base`, `iso`, `console_password` reachable in any input; no em + dash in any description. +- Guards: adversarial cases for every guard above: symlink escapes, `..` + traversal, absolute paths, a path whose prefix matches the sandbox as a + string but not as a directory, unicode tricks, empty and whitespace + names. +- Rate limit: per-tool burst refused at 31; shared burst refused at 61 + across tools; neither bucket charged on a refusal. +- Redaction: a fixture VM with a sentinel secret; every tool's output is + scanned for it. +- `requireAccess`: a table of every guest-touching tool against the four + levels; `update` lowering succeeds, raising is refused; legacy + `allow_exec` values map as documented. +- In-VM tools against a fake ssh: relative path refused, `max_bytes` clamp, + `write_file` mode, `exec_bg` then `job_status` then `job_output` round + trip, `job_kill`, `ps` cap, argv never shell-joined (a path with a space + and a `;` arrives intact). +- Install: each client's config written into a temp home; existing + unrelated entries preserved. +- An in-process client from the go-sdk drives the server over stdio for an + end-to-end round trip. + +## 8. Out of scope + +Stated so nobody builds them by accident: authentication, multi-user, and +any tool that writes to the host outside `~/.stoat/shared//`. A pty +session tool and a guest agent binary over vsock are candidates for their +own design after this one; the agent would also cover guests without sshd +(Windows) and streaming output. + +## 9. Live gate: CLEARED (2026-08-04) Every claim below came from real VMs booted through the MCP tools, not from -mocks. A throwaway `mcpgate` (alpine-cloud) and `mcplocked` were created, -exercised and destroyed; the data root was left exactly as found. +mocks, against the design's original build. A throwaway `mcpgate` +(alpine-cloud) and `mcplocked` were created, exercised and destroyed; the +data root was left exactly as found. These are facts about real VMs and +still hold. - **create, start, wait**: reachable in 13.4s. - **exec** returns real guest output, running as `uid=1001(stoat)`, the cloud-init account rather than root. - **Quoting survives the whole chain**, which is the thing only a live test - proves: `exec(command=["touch", "/tmp/qt/my file"])` created ONE file. The - argv goes Python list to CLI argv to core's per-element shell quoting to - the guest's ash, and any layer dropping a word boundary shows up here. + proves: `exec(argv=["touch", "/tmp/qt/my file"])` created ONE file. The + argv goes through `sshx.Run`'s own quoting to the guest's ash, and any + layer dropping a word boundary shows up here. - **A nonzero guest status is DATA**: `sh -c 'exit 42'` returned - `exit_code: 42` with no exception raised, matching the contract's rule that - exec exits 0 whenever the command ran. + `exit_code: 42` with no exception raised, matching the contract's rule + that exec exits 0 whenever the command ran. - **copy_to** into the sandbox landed and the guest read the content back. The path guard refused `/etc/passwd`, `~/.ssh/id_rsa`, and `~/.stoat/shared//../../id_stoat`. -- **allow_exec=false is honoured on a real VM**: both `exec` and `copy_to` - refused, before either reached the binary. +- **A VM without exec access is honoured on a real VM**: both `exec` and + `copy_to` refused, before either reached the binary. ### The finding worth keeping: mapped-xattr works The guest ran `ln -sf /etc /mnt/work/escape` inside its own writable share. -On the host, `os.path.islink()` on that entry is **False**: QEMU stored the -link as an extended attribute instead of creating a real host symlink. That -is the traversal defence §10.2 of core-api.md claimed, now demonstrated -rather than assumed. +On the host, the entry is not a real symlink: QEMU stored the link as an +extended attribute instead of creating one. That is the traversal defence +§10.2 of core-api.md claimed, demonstrated rather than assumed. -It does not make `check_host_path`'s symlink resolution redundant. §10.2 also +It does not make `checkHostPath`'s symlink resolution redundant. §10.2 also requires that `mapped-xattr` be DETECTED and degrade loudly rather than silently falling back to `security_model=none`. If that detection ever regresses, the guest's symlink becomes a real host symlink again and the diff --git a/docs/reference/json.md b/docs/reference/json.md index ab6b509c..8de3ce0f 100644 --- a/docs/reference/json.md +++ b/docs/reference/json.md @@ -1,13 +1,18 @@ # JSON Output Reference -`--json` turns any subcommand into a machine interface. It exists because -stoat's MCP server is a separate Python process that reaches `internal/core` -only by running this binary and reading its output, so everything a caller -would otherwise regex, guess at, or reconstruct is defined here instead. +`--json` turns any subcommand into a machine interface, so everything a +caller would otherwise regex, guess at, or reconstruct is defined here +instead. This document is the contract. The human-facing CLI is documented in [cli.md](cli.md). +`stoat mcp` serves the same contract over MCP from inside the same binary. +Every tool's output type in `internal/mcpsrv` is a `wire` struct, the same Go +type the matching `--json` command emits, so the two cannot drift: the MCP +schema is generated from these types, not maintained separately. See +`internal/mcpsrv/table_test.go` for the tool table. + ``` stoat --json ls {"v":3,"type":"result","cmd":"ls","ok":true,"data":{"vms":[...]}} @@ -190,7 +195,7 @@ VM {"name":"work","os":"alpine","mode":"cloud","backend":"cloudinit", "share":"/home/u/src","recipes":["xfce"], "ssh_port":2200,"ssh_user":"stoat","installed":false, "forwards":[{"host_port":8080,"guest_port":80}], - "allow_exec":true,"display":"vnc", + "allow_exec":true,"agent_access":"manage","display":"vnc", "error":"only on a broken VM"} VMStatus {"name":"work",...VM fields...,"health":"ok","recipes_detail":[ @@ -277,6 +282,12 @@ enforced one: `stoat exec`/`cp` do not check it, so a consumer that must refuse exec on a VM with `allow_exec:false` (the MCP server) has to check it itself before calling. +`agent_access` supersedes `allow_exec` with four levels (`none`, `observe`, +`manage`, `exec`) instead of a boolean; each level includes every tool the +ones below it allow. `allow_exec:true` loads as `exec`, `false` as `manage`, +so an old VM keeps its meaning under the new field. `stoat mcp`'s +`requireAccess` is what enforces it; `stoat exec`/`cp` still do not. + `Snapshot.size_display` and `created_display` are named that way because they are qemu's own formatted table output. They are opaque. Do not parse them. @@ -526,3 +537,9 @@ contents of any `*_display` field, or the absence of fields it does not know. a list of `{path, scope}` in search order, and `recipes` became a list of `RecipeEntry` objects rather than names. A consumer that read `data.recipes[]` as strings reads `data.recipes[].name` instead. + +The same version also adds `agent_access` to `VM`, additive alongside +`allow_exec`, and moves the MCP server from a separate Python process into +`stoat mcp` in this binary. Neither change removes or repurposes a field, so +neither bumped the version on its own; they are noted here only because they +landed in the same branch as the `recipe list` change. diff --git a/internal/cli/wire/envelope.go b/internal/cli/wire/envelope.go index 276b63cd..b086f83d 100644 --- a/internal/cli/wire/envelope.go +++ b/internal/cli/wire/envelope.go @@ -1,9 +1,7 @@ // Package wire is the JSON contract stoat's --json mode speaks (see -// docs/design/json-contract-draft.md). It exists because the MCP server is -// Python + fastmcp in a separate process and reaches internal/core only by -// running the stoat binary and reading its output: everything a Python -// caller would otherwise have to regex, guess at, or reconstruct is defined -// here instead. +// docs/design/json-contract-draft.md). internal/mcpsrv reuses these same +// DTOs as MCP tool output, so the --json contract and the MCP schema are one +// set of types and cannot drift apart. // // This package holds the envelope (§2), the error code table (§2), the DTOs // (§3) and the argv scan (§1). It does not touch internal/cli/cli.go: wiring From 1b86408b51de23183fc07a2e05c0191cd13aeb52 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 22:18:40 +0300 Subject: [PATCH 60/67] fix(mcp): stop tail_log escalating a caller's path Signed-off-by: NovusEdge --- docs/design/mcp-server.md | 5 +++- internal/mcpsrv/access_test.go | 14 +++++++++++ internal/mcpsrv/tools_guest.go | 8 ++++++- internal/mcpsrv/tools_guest_test.go | 37 +++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/docs/design/mcp-server.md b/docs/design/mcp-server.md index 440700d1..b84b4cf0 100644 --- a/docs/design/mcp-server.md +++ b/docs/design/mcp-server.md @@ -130,7 +130,10 @@ config file without an open shell. `pkg_install` runs `pkg.setup` once, then `pkg.install` plus the requested packages. `svc` runs `svc.` for `enable`, `start`, `stop`, or `restart`. `tail_log` runs `journalctl -u` on a systemd guest, `tail` on the init's own log path otherwise, or `tail` on an -explicit `path`, with `lines` clamped to 2000. +explicit `path`, with `lines` clamped to 2000. The first two escalate, +because their target comes from the guest file and carries no tool input. +An explicit `path` runs as the ssh user: a root read of any path an agent +names would hand `observe` more than `read_file` gives it. ### In-VM tools over ssh diff --git a/internal/mcpsrv/access_test.go b/internal/mcpsrv/access_test.go index 00c391d2..d2682022 100644 --- a/internal/mcpsrv/access_test.go +++ b/internal/mcpsrv/access_test.go @@ -27,6 +27,20 @@ func writeVM(t *testing.T, name, level string) { } } +// setSSHUser appends an sshuser to a VM written by writeVM. sshx.Escalate +// returns nothing for a root user, so a test about escalation needs one. +func setSSHUser(t *testing.T, name, user string) { + t.Helper() + path := filepath.Join(config.Root(), name, "vm.toml") + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(body, []byte("sshuser = \""+user+"\"\n")...), 0o644); err != nil { + t.Fatal(err) + } +} + // writeSecrets writes a VM's secrets.toml at the mode the loader requires. func writeSecrets(t *testing.T, vm string, kv map[string]string) { t.Helper() diff --git a/internal/mcpsrv/tools_guest.go b/internal/mcpsrv/tools_guest.go index 65258d81..9628b5dd 100644 --- a/internal/mcpsrv/tools_guest.go +++ b/internal/mcpsrv/tools_guest.go @@ -272,12 +272,18 @@ func (s *srv) registerGuestRead(server *mcp.Server) { } n := clampInt(in.Lines, 1, maxLogLines) var argv []string + // root is false for a caller-supplied path and true for the two + // branches whose target comes from the guest file. tail_log at + // observe would otherwise read any file as root, which is more + // than read_file grants at the same level. + root := true switch { case in.Path != "": path, err := checkGuestPath(in.Path) if err != nil { return wire.LogTail{}, err } + root = false argv = []string{"tail", "-n", strconv.Itoa(n), path} case in.Unit != "": unit, err := checkSvcName(in.Unit) @@ -296,7 +302,7 @@ func (s *srv) registerGuestRead(server *mcp.Server) { } argv = []string{"tail", "-n", strconv.Itoa(n), os.LogPath} } - out, errb, code, err := sshx.Run(ctx, v, true, argv, nil) + out, errb, code, err := sshx.Run(ctx, v, root, argv, nil) if err != nil { return wire.LogTail{}, err } diff --git a/internal/mcpsrv/tools_guest_test.go b/internal/mcpsrv/tools_guest_test.go index 1bcc5393..6b825129 100644 --- a/internal/mcpsrv/tools_guest_test.go +++ b/internal/mcpsrv/tools_guest_test.go @@ -88,6 +88,43 @@ func TestPSCapsRows(t *testing.T) { } } +// TestTailLogEscalatesOnlyForTheGuestsOwnLog pins where tail_log's root +// applies. The caller's own path runs as the ssh user, or an agent at +// observe would read any file as root through tail_log and get more than +// read_file grants at the same level. The guest file's own log path carries +// no tool input and still runs escalated. +func TestTailLogEscalatesOnlyForTheGuestsOwnLog(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "observe") + // sshx.Escalate is a no-op for a root ssh user, and writeVM leaves the + // user empty, which reads as root. + setSSHUser(t, "dev", "stoat") + + calls := testutil.FakeSSH(t, `echo line`) + if res := callTool(t, "tail_log", map[string]any{"vm": "dev", "path": "/etc/shadow"}); res.IsError { + t.Fatalf("tail_log failed: %+v", res.Content) + } + got := calls.Calls() + if len(got) != 1 { + t.Fatalf("got %d ssh calls, want 1", len(got)) + } + if strings.Contains(got[0].Remote, "sudo") { + t.Fatalf("tail_log escalated for a caller-supplied path: %q", got[0].Remote) + } + + calls = testutil.FakeSSH(t, `echo line`) + if res := callTool(t, "tail_log", map[string]any{"vm": "dev"}); res.IsError { + t.Fatalf("tail_log failed: %+v", res.Content) + } + got = calls.Calls() + if len(got) != 1 { + t.Fatalf("got %d ssh calls, want 1", len(got)) + } + if !strings.Contains(got[0].Remote, "sudo") { + t.Fatalf("tail_log did not escalate for the guest's own log: %q", got[0].Remote) + } +} + func TestGuestReadToolsRefusedAtNone(t *testing.T) { t.Setenv("STOAT_HOME", t.TempDir()) writeVM(t, "locked", "none") From 75fecfea4d5ded6e9cd0e103fb0b49060eb1756d Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 22:20:29 +0300 Subject: [PATCH 61/67] fix(mcp): split list_dir names on newline Signed-off-by: NovusEdge --- internal/mcpsrv/tools_guest.go | 15 ++++++++++++++- internal/mcpsrv/tools_guest_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/internal/mcpsrv/tools_guest.go b/internal/mcpsrv/tools_guest.go index 9628b5dd..54925e02 100644 --- a/internal/mcpsrv/tools_guest.go +++ b/internal/mcpsrv/tools_guest.go @@ -177,7 +177,7 @@ func (s *srv) registerGuestRead(server *mcp.Server) { if code != 0 { return wire.DirListing{}, fmt.Errorf("%s: %s", v.Name, strings.TrimSpace(string(errb))) } - names := capNames(strings.Fields(string(nameOut))) + names := capNames(splitNames(nameOut)) if len(names) == 0 { return wire.DirListing{Entries: []wire.DirEntry{}}, nil } @@ -461,6 +461,19 @@ func runToResult(ctx context.Context, v *config.VM, root bool, argv []string) (w return wire.CommandResult{Stdout: string(out), Stderr: string(errb), ExitCode: code}, nil } +// splitNames reads ls -A's output. ls writes one name per line when its +// stdout is a pipe, so the split is on newline: splitting on whitespace +// turns "my file" into two entries that then both fail to stat. +func splitNames(raw []byte) []string { + var out []string + for _, line := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") { + if line != "" { + out = append(out, line) + } + } + return out +} + func capNames(names []string) []string { return names[:min(len(names), maxDirEntries)] } diff --git a/internal/mcpsrv/tools_guest_test.go b/internal/mcpsrv/tools_guest_test.go index 6b825129..c313856e 100644 --- a/internal/mcpsrv/tools_guest_test.go +++ b/internal/mcpsrv/tools_guest_test.go @@ -70,6 +70,33 @@ func TestListDirCapsEntries(t *testing.T) { } } +// TestListDirKeepsANameWithASpace pins that ls -A output is split on +// newline. A whitespace split reports "my file" as two entries. +func TestListDirKeepsANameWithASpace(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "observe") + testutil.FakeSSH(t, `case "$1" in + ls) printf 'my file\nplain\n';; + stat) shift 3; for p do printf '%s\tregular file\t3\t81a4\t1\n' "$p"; done;; +esac`) + res := callTool(t, "list_dir", map[string]any{"vm": "dev", "path": "/d"}) + if res.IsError { + t.Fatalf("list_dir failed: %+v", res.Content) + } + raw, _ := json.Marshal(res.StructuredContent) + var out struct { + Entries []struct { + Name string `json:"name"` + } `json:"entries"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + if len(out.Entries) != 2 || out.Entries[0].Name != "/d/my file" { + t.Fatalf("got %+v, want two entries the first of which is /d/my file", out.Entries) + } +} + func TestPSCapsRows(t *testing.T) { t.Setenv("STOAT_HOME", t.TempDir()) writeVM(t, "dev", "observe") From c451b8fe21c7d67ffe9e41fd86a130abf4cfcbe6 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 22:22:36 +0300 Subject: [PATCH 62/67] fix(mcp): accept a slash in add_recipe's git ref Signed-off-by: NovusEdge --- internal/mcpsrv/guards.go | 37 ++++++++++++++++++++++------ internal/mcpsrv/guards_test.go | 5 +++- internal/mcpsrv/tools_recipe_test.go | 2 +- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/internal/mcpsrv/guards.go b/internal/mcpsrv/guards.go index 303c4f41..7d24f10a 100644 --- a/internal/mcpsrv/guards.go +++ b/internal/mcpsrv/guards.go @@ -161,29 +161,52 @@ func stripForbidden(patch map[string]any) map[string]any { // checkIndexName splits "" or "@" from the recipe index. A // URL is refused here rather than in core: add_recipe is the tool an agent -// reaches, and a URL is a repository nobody curated. +// reaches, and a URL is a repository nobody curated. The name has no +// separator; the ref may hold slashes, because a branch is named +// "feature/topic". func checkIndexName(ref string) (string, string, error) { if strings.TrimSpace(ref) == "" { return "", "", fmt.Errorf("recipe name is required") } - if strings.ContainsAny(ref, ":/\\") { - return "", "", fmt.Errorf("invalid recipe name %q: index names only, not a URL", ref) - } name, gitRef, hasRef := strings.Cut(ref, "@") if strings.Contains(gitRef, "@") { return "", "", fmt.Errorf("invalid recipe name %q: at most one @ref", ref) } if !indexNameRE.MatchString(name) { - return "", "", fmt.Errorf("invalid recipe name %q: must match %s", name, indexNameRE) + return "", "", fmt.Errorf("invalid recipe name %q: index names only, not a URL, and it must match %s", ref, indexNameRE) } if hasRef { - if !gitRefRE.MatchString(gitRef) || strings.Contains(gitRef, "..") { - return "", "", fmt.Errorf("invalid ref %q for recipe %q", gitRef, name) + if err := checkGitRef(gitRef); err != nil { + return "", "", fmt.Errorf("invalid ref %q for recipe %q: %w", gitRef, name, err) } } return name, gitRef, nil } +// checkGitRef bounds the ref add_recipe pins. The rules are git's own +// check-ref-format rules that matter here: no traversal, no component that +// git itself refuses. +func checkGitRef(ref string) error { + if !gitRefRE.MatchString(ref) { + return fmt.Errorf("must match %s", gitRefRE) + } + if strings.Contains(ref, "..") { + return fmt.Errorf("contains a traversal") + } + for _, part := range strings.Split(ref, "/") { + if part == "" { + return fmt.Errorf("has an empty path component") + } + if strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".") { + return fmt.Errorf("component %q starts or ends with a dot", part) + } + if strings.HasSuffix(part, ".lock") { + return fmt.Errorf("component %q ends with .lock", part) + } + } + return nil +} + func checkParamName(name string) (string, error) { if !paramNameRE.MatchString(name) { return "", fmt.Errorf("invalid param name %q: must match %s", name, paramNameRE) diff --git a/internal/mcpsrv/guards_test.go b/internal/mcpsrv/guards_test.go index 3d5cbef1..41004d76 100644 --- a/internal/mcpsrv/guards_test.go +++ b/internal/mcpsrv/guards_test.go @@ -204,6 +204,7 @@ func TestCheckIndexName(t *testing.T) { {"tailscale", "tailscale", ""}, {"tailscale@v1.2", "tailscale", "v1.2"}, {"my-recipe@main", "my-recipe", "main"}, + {"tailscale@feature/topic", "tailscale", "feature/topic"}, } { name, ref, err := checkIndexName(c.in) if err != nil || name != c.name || ref != c.ref { @@ -215,7 +216,9 @@ func TestCheckIndexName(t *testing.T) { for _, s := range []string{ "", "https://github.com/x/y", "git@github.com:x/y.git", "x/y", "../evil", "a@b@c", "tailscale@", "@v1", "Tailscale", - "tail scale", "tailscale@../evil", + "tail scale", "tailscale@../evil", "-y", "tailscale@feature..topic", + "tailscale@feature/.hidden", "tailscale@feature/topic.lock", + "tailscale@feature/", } { if _, _, err := checkIndexName(s); err == nil { t.Errorf("checkIndexName(%q) accepted", s) diff --git a/internal/mcpsrv/tools_recipe_test.go b/internal/mcpsrv/tools_recipe_test.go index 1f106919..29322b21 100644 --- a/internal/mcpsrv/tools_recipe_test.go +++ b/internal/mcpsrv/tools_recipe_test.go @@ -23,7 +23,7 @@ func TestAddRecipeRefusesAURL(t *testing.T) { continue } raw, _ := json.Marshal(res.Content) - if !strings.Contains(string(raw), "index names only") && !strings.Contains(string(raw), "invalid recipe name") { + if !strings.Contains(string(raw), "invalid recipe name") && !strings.Contains(string(raw), "invalid ref") { t.Errorf("add_recipe(%q) refusal did not name the guard: %s", ref, raw) } } From 4068ba9aaac0daae2dc5623862912d5b32860ed7 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 22:24:30 +0300 Subject: [PATCH 63/67] test(mcp): finish the port table for recipe tools Signed-off-by: NovusEdge --- internal/mcpsrv/porttable_test.go | 9 +++++++++ internal/mcpsrv/tools_recipe.go | 11 +++++++++-- internal/mcpsrv/tools_recipe_test.go | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/internal/mcpsrv/porttable_test.go b/internal/mcpsrv/porttable_test.go index 0e603a35..15a29c84 100644 --- a/internal/mcpsrv/porttable_test.go +++ b/internal/mcpsrv/porttable_test.go @@ -58,6 +58,15 @@ var portedTests = map[string]string{ "test_wait_clamps_the_timeout": "TestWaitClampsTimeout", "test_logs_clamps_the_line_count": "TestLogsClampsLines", "test_forward_refuses_a_pair_that_kong_reads_as_a_flag": "TestForwardRefusesFlagPair", + "test_check_index_name_accepts_name_and_optional_ref": "TestCheckIndexName", + "test_check_index_name_rejects_urls_paths_options_and_bad_refs": "TestCheckIndexName", + "test_add_recipe_accepts_a_slash_containing_ref_and_uses_variadic_argv": "TestCheckIndexName", + "test_recipe_tools_refuse_unsafe_names_before_cli": "TestRecipeToolsRefuseUnsafeNames", + "test_update_and_remove_recipe_use_plain_names_and_remove_has_no_force": "TestRecipeToolsRefuseUnsafeNames", + // The Go server refuses a search term that starts with a dash instead of + // passing it after "--": search_recipes takes one term through + // checkFlagFree, the same guard forward and check_recipes use. + "test_search_recipes_preserves_a_leading_dash_as_data": "TestSearchRecipesRefusesAFlagTerm", } // TestEveryPortedTestExists asserts each named Go test is in this package's diff --git a/internal/mcpsrv/tools_recipe.go b/internal/mcpsrv/tools_recipe.go index 51a590f0..17257315 100644 --- a/internal/mcpsrv/tools_recipe.go +++ b/internal/mcpsrv/tools_recipe.go @@ -2,6 +2,7 @@ package mcpsrv import ( "context" + "fmt" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/novusedge/stoat/internal/cli/wire" @@ -47,10 +48,13 @@ func (s *srv) registerRecipe(server *mcp.Server) { func(ctx context.Context, in recipeNameIn) (wire.RecipeCatalog, error) { name := in.Name if name != "" { - n, _, err := checkIndexName(name) + n, ref, err := checkIndexName(name) if err != nil { return wire.RecipeCatalog{}, err } + if ref != "" { + return wire.RecipeCatalog{}, fmt.Errorf("update_recipe takes a plain recipe name; pin a ref with add_recipe") + } name = n } if err := core.UpdateRecipe(name); err != nil { @@ -62,10 +66,13 @@ func (s *srv) registerRecipe(server *mcp.Server) { register(server, "remove_recipe", classMutate, "Remove a remote recipe: its declaration, its lock entry and its directory. It refuses while any VM lists that recipe. There is no force option on this tool; a person removes a recipe a VM still uses, from the CLI. Mutating and not reversible from here, though add_recipe reinstalls it.", func(ctx context.Context, in removeRecipeIn) (wire.RecipeCatalog, error) { - name, _, err := checkIndexName(in.Name) + name, ref, err := checkIndexName(in.Name) if err != nil { return wire.RecipeCatalog{}, err } + if ref != "" { + return wire.RecipeCatalog{}, fmt.Errorf("remove_recipe takes a plain recipe name, without @ref") + } // force is deliberately absent rather than false-by-default: a // parameter that exists is eventually reachable. if err := core.RemoveRecipe(name, false); err != nil { diff --git a/internal/mcpsrv/tools_recipe_test.go b/internal/mcpsrv/tools_recipe_test.go index 29322b21..41a235aa 100644 --- a/internal/mcpsrv/tools_recipe_test.go +++ b/internal/mcpsrv/tools_recipe_test.go @@ -29,6 +29,23 @@ func TestAddRecipeRefusesAURL(t *testing.T) { } } +// TestRecipeToolsRefuseUnsafeNames pins that update_recipe and +// remove_recipe take a plain index name. Both reach core with a name only, +// so an @ref is refused rather than dropped without a word. +func TestRecipeToolsRefuseUnsafeNames(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + for _, c := range []struct{ tool, name string }{ + {"update_recipe", "tailscale@v1.2"}, + {"remove_recipe", "../tailscale"}, + {"remove_recipe", "-y"}, + {"remove_recipe", "tailscale@v1.2"}, + } { + if res := callTool(t, c.tool, map[string]any{"name": c.name}); !res.IsError { + t.Errorf("%s accepted %q", c.tool, c.name) + } + } +} + // TestRemoveRecipeHasNoForce pins the spec's rule that remove_recipe has no // force parameter: a person, not an agent, removes a recipe a VM still // uses. From 2071e3a80ab2f92100d54b6ca30d603cd12af3e9 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 22:26:22 +0300 Subject: [PATCH 64/67] fix(mcp): read a client config with mixed top keys Signed-off-by: NovusEdge --- internal/mcpsrv/doctor.go | 13 +++++++++-- internal/mcpsrv/doctor_test.go | 40 ++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/internal/mcpsrv/doctor.go b/internal/mcpsrv/doctor.go index 12abf83f..f766b841 100644 --- a/internal/mcpsrv/doctor.go +++ b/internal/mcpsrv/doctor.go @@ -35,12 +35,21 @@ func DoctorReport(version string) wire.MCPDoctor { r.Clients = append(r.Clients, row) continue } - var doc map[string]map[string]entry + // Only the server map is decoded as entries. ~/.claude.json holds + // numbers, strings and unrelated objects beside mcpServers, so + // decoding the whole document as entries fails on the first of them + // and reports an installed client as missing. + var doc map[string]json.RawMessage if err := json.Unmarshal(raw, &doc); err != nil { r.Clients = append(r.Clients, row) continue } - e, ok := doc[c.Key]["stoat"] + var servers map[string]entry + if err := json.Unmarshal(doc[c.Key], &servers); err != nil { + r.Clients = append(r.Clients, row) + continue + } + e, ok := servers["stoat"] if !ok { r.Clients = append(r.Clients, row) continue diff --git a/internal/mcpsrv/doctor_test.go b/internal/mcpsrv/doctor_test.go index 3bf38036..ebf22f9b 100644 --- a/internal/mcpsrv/doctor_test.go +++ b/internal/mcpsrv/doctor_test.go @@ -44,6 +44,42 @@ func TestDoctorReportsTheContractAndTheClients(t *testing.T) { t.Fatalf("cursor entry points at %q, not the running binary", c.Command) } } - _ = filepath.Join - _ = os.Stat +} + +// TestDoctorReadsAConfigWithUnrelatedKeys pins the shape of a real +// ~/.claude.json: numbers, strings and objects beside mcpServers. Decoding +// the whole document as server entries fails on the first of them, and the +// client then reads as not installed. +func TestDoctorReadsAConfigWithUnrelatedKeys(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + chdir(t, t.TempDir()) + + bin, err := os.Executable() + if err != nil { + t.Fatal(err) + } + bin, err = filepath.Abs(bin) + if err != nil { + t.Fatal(err) + } + body := `{"numStartups":7,"theme":"dark","projects":{"/tmp":{"history":[]}},` + + `"mcpServers":{"stoat":{"command":"` + bin + `","args":["mcp"],"cwd":"/tmp"}}}` + if err := os.WriteFile(filepath.Join(home, ".claude.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + for _, c := range DoctorReport("test").Clients { + if c.Client != "claude-code" { + continue + } + if !c.Installed { + t.Fatal("claude-code is not reported as installed") + } + if !c.Current { + t.Fatalf("entry points at %q, not the running binary %q", c.Command, bin) + } + return + } + t.Fatal("claude-code is missing from the report") } From 4046281cbd0ba3adcfa39557d4a3d9e6105c4ea9 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 22:28:10 +0300 Subject: [PATCH 65/67] docs(mcp): match the doc to the shipped server Signed-off-by: NovusEdge --- docs/design/mcp-server.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/design/mcp-server.md b/docs/design/mcp-server.md index b84b4cf0..a297be87 100644 --- a/docs/design/mcp-server.md +++ b/docs/design/mcp-server.md @@ -115,7 +115,7 @@ its meaning under the new field. `requireAccess(vm, level)` gates every guest-touching tool. `core.Exec` does not enforce it, because `core` is a library the CLI and TUI also call and a blanket refusal there would be the wrong layer. A refusal names both levels: -`vm "dev" has agent_access = observe; write_file needs manage`. +`vm "dev" has agent_access = observe; needs manage`. MCP's `update` tool may lower a VM's `agent_access` and never raise it. Raising is CLI or TUI only, so an agent cannot grant itself more access than @@ -209,6 +209,8 @@ user and carry no hidden instructions; `TestNoEmDashInDescription` and both ways. `search_recipes` and `add_recipe` use the curated remote index; `add_recipe` accepts an index name and an optional tag or branch ref, including slash-containing branch refs, and never a repository URL. +`search_recipes` refuses a term that starts with a dash, through the same +`checkFlagFree` guard, so the term never reads as a flag. `remove_recipe` has no `force` argument and refuses while a VM still lists the recipe. @@ -247,21 +249,25 @@ session tool and a guest agent binary over vsock are candidates for their own design after this one; the agent would also cover guests without sshd (Windows) and streaming output. -## 9. Live gate: CLEARED (2026-08-04) +## 9. Live gate -Every claim below came from real VMs booted through the MCP tools, not from -mocks, against the design's original build. A throwaway `mcpgate` -(alpine-cloud) and `mcplocked` were created, exercised and destroyed; the -data root was left exactly as found. These are facts about real VMs and -still hold. +The Go server has not been booted against a real VM yet. It changes +`internal/sshx` and `internal/core/exec.go`, so a live boot exercising +`exec`, `write_file` and `exec_bg` gates the merge. + +The findings below came from real VMs booted through the MCP tools, not from +mocks, against the Python build this package replaced (2026-08-04). A +throwaway `mcpgate` (alpine-cloud) and `mcplocked` were created, exercised +and destroyed; the data root was left exactly as found. They are facts about +the design, and the live boot above is what confirms the Go port keeps them. - **create, start, wait**: reachable in 13.4s. - **exec** returns real guest output, running as `uid=1001(stoat)`, the cloud-init account rather than root. - **Quoting survives the whole chain**, which is the thing only a live test proves: `exec(argv=["touch", "/tmp/qt/my file"])` created ONE file. The - argv goes through `sshx.Run`'s own quoting to the guest's ash, and any - layer dropping a word boundary shows up here. + argv goes through one quoter to the guest's ash, `sshx.Run` in the Go + port, and any layer dropping a word boundary shows up here. - **A nonzero guest status is DATA**: `sh -c 'exit 42'` returned `exit_code: 42` with no exception raised, matching the contract's rule that exec exits 0 whenever the command ran. From 91aac228b9a0b681ee2b8b16aa4286252446f67a Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 22:30:19 +0300 Subject: [PATCH 66/67] docs(json): document the mcp command results Signed-off-by: NovusEdge --- docs/reference/json.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/reference/json.md b/docs/reference/json.md index 8de3ce0f..95db4de3 100644 --- a/docs/reference/json.md +++ b/docs/reference/json.md @@ -257,8 +257,15 @@ Guest {"name":"fedora","init":"systemd","shell":"/bin/bash", "svc":{"enable":"systemctl enable {name}", ...}, "cmd":{},"backend":{"cloudinit":{"skip_9p":false}}, "source":"bundled"} + +MCPClient {"client":"cursor","path":"/home/u/.cursor/mcp.json", + "installed":true,"command":"/home/u/.local/bin/stoat", + "current":true} ``` +`MCPClient.current` is false when the client's entry names a different +binary than the running one, which is the stale entry `mcp doctor` reports. + `RecipeEntry` has `name`, `description`, `scope`, `source`, `ref`, and `commit`. `scope` is one of `bundled`, `local`, `global`, or `project`; only `global` and `project` entries carry `source`, `ref`, and the seven-character @@ -400,6 +407,8 @@ so a leak fails the build rather than shipping. | `logs` (no VM) | `{"lines":[...]}` (stoat's own log) | | `logs ` | `{"vm":"work","which":"console","lines":[...]}` | | `doctor` | `{"healthy":false,"checks":[Check,...]}` | +| `mcp doctor` | `{"contract":3,"version":"1.2.3","transport":"stdio","binary":"/home/u/.local/bin/stoat","clients":[MCPClient,...]}` | +| `mcp install` | `{"client":"cursor","path":"/home/u/.cursor/mcp.json","json":"{...}"}` | | `version` | `{"version":"1.2.3","contract":3}` | | `help` | `{"usage":"..."}` | | `ssh` | **refused**, see below | From 756bb380ff4bd05424ccf68c9d14026a04e68def Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sat, 5 Sep 2026 22:41:04 +0300 Subject: [PATCH 67/67] fix(mcp): make the job dir escalated and run under nohup A live Debian 13 boot showed exec_bg failing with 'cannot create /run/stoat/jobs/': /run/stoat is root-owned, and the mkdir ran as the ssh user. The runner also had no nohup, so a job could die with the ssh session. Signed-off-by: NovusEdge --- internal/mcpsrv/tools_exec.go | 11 ++++++++--- internal/mcpsrv/tools_exec_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/internal/mcpsrv/tools_exec.go b/internal/mcpsrv/tools_exec.go index a860a147..c160c486 100644 --- a/internal/mcpsrv/tools_exec.go +++ b/internal/mcpsrv/tools_exec.go @@ -137,7 +137,10 @@ func (s *srv) registerExec(server *mcp.Server) { } id := newJobID() dir := path.Join(jobRoot, id) - if _, _, code, err := sshx.Run(ctx, v, false, []string{"mkdir", "-p", dir}, nil); err != nil { + // /run/stoat is root-owned on every guest, so the directory is made + // escalated and handed to the ssh user, who then runs the job. + const mkjob = `mkdir -p "$1" && chown "$2" "$1"` + if _, _, code, err := sshx.Run(ctx, v, true, []string{"sh", "-c", mkjob, "stoat_jobdir", dir, sshx.User(v)}, nil); err != nil { return wire.JobStarted{}, err } else if code != 0 { if runErr := requireRunning(v); runErr != nil { @@ -147,8 +150,10 @@ func (s *srv) registerExec(server *mcp.Server) { } // The runner is a constant shell body. The job directory is $1 // and the command is the remaining positional arguments, so no - // tool input becomes shell syntax. - const runner = `d="$1"; shift; { "$@" >"$d/out" 2>"$d/err"; echo $? >"$d/exit"; } & echo $! >"$d/pid"` + // tool input becomes shell syntax. nohup keeps the wrapper alive + // after the ssh session ends; the pid file names the command + // itself, so job_kill signals it and the wrapper records its exit. + const runner = `d="$1"; shift; nohup sh -c 'd="$1"; shift; "$@" >"$d/out" 2>"$d/err" & c=$!; echo $c >"$d/pid"; wait $c; echo $? >"$d/exit"' stoat_job "$d" "$@" >/dev/null 2>&1 &` start := append([]string{"sh", "-c", runner, "stoat_job", dir}, argv...) if _, errb, code, err := sshx.Run(ctx, v, false, start, nil); err != nil { return wire.JobStarted{}, err diff --git a/internal/mcpsrv/tools_exec_test.go b/internal/mcpsrv/tools_exec_test.go index 0b59daa4..7513873b 100644 --- a/internal/mcpsrv/tools_exec_test.go +++ b/internal/mcpsrv/tools_exec_test.go @@ -137,6 +137,35 @@ esac`) } } +// /run/stoat is root-owned on a real guest, so the job directory must be made +// escalated and chowned to the ssh user, and the runner must survive the ssh +// session ending. A live Debian 13 boot found both gaps. +func TestExecBgMakesTheJobDirEscalatedAndRunsUnderNohup(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + writeVM(t, "dev", "exec") + setSSHUser(t, "dev", "stoat") + calls := testutil.FakeSSH(t, `exit 0`) + + if res := callTool(t, "exec_bg", map[string]any{"vm": "dev", "argv": []string{"sleep", "60"}}); res.IsError { + t.Fatalf("exec_bg failed: %+v", res.Content) + } + got := calls.Calls() + if len(got) != 2 { + t.Fatalf("got %d ssh calls, want mkdir then start", len(got)) + } + mk := got[0].Remote + if !strings.Contains(mk, "sudo") || !strings.Contains(mk, "chown") || !strings.Contains(mk, "'stoat'") { + t.Fatalf("job dir was not made escalated and chowned to the ssh user: %q", mk) + } + start := got[1].Remote + if strings.Contains(start, "sudo") { + t.Fatalf("the job itself ran escalated: %q", start) + } + if !strings.Contains(start, "nohup") { + t.Fatalf("the runner does not survive the ssh session: %q", start) + } +} + func TestJobStatusIsUnknownAfterAReboot(t *testing.T) { t.Setenv("STOAT_HOME", t.TempDir()) writeVM(t, "dev", "exec")