From eb23ba7f601a930d6d48227da109d0a2c467ddda Mon Sep 17 00:00:00 2001 From: lr00rl Date: Tue, 14 Jul 2026 22:36:34 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20the=20reference=20host-risk?= =?UTF-8?q?=20operation=20flow=20(=C2=A79.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template declared it was runtime-backed but only answered describe/health/plan. Now it implements the whole §9.3 flow end to end, so a new plugin has a correct shape to copy and so the server's execute protocol has a real caller. The plan-effect method returns a PluginOperationPlan — summary, targets, redacted preview, steps, rollback, opaque data — that the server turns into a pending approval. The new execute action, which only the approval executor can reach, enqueues one bounded task per approved target through the task.enqueue host call. The plugin applies nothing itself and never sees the operation grant: it asks the host to enqueue, and the host refuses anything outside the approved plan. The reference is deliberately careful where a real plugin must be: it single-quotes the approval id and node id into the sh command so neither can break out, it treats an empty-target plan as a validation error rather than an unsupported method (so the conformance probe stays honest), and it surfaces a host refusal verbatim rather than swallowing it. README documents the flow and the rules a production plugin keeps. The manifest gains task:run, is bumped to 0.2.1-alpha.4 in lockstep with the go const and ui/package.json, and is re-packed and re-signed: the bundle digest was recomputed with the CI toolchain (go1.26.4) and verified to match a deterministic double-pack, so the digest gate passes without a rebuild in CI. Tests: system-go green (describe/health/plan/execute/injection-quoting/conformance); UI test + typecheck + build green; double-pack digest == manifest digest. --- README.md | 41 +++++- manifest.json | 9 +- system-go/conformance_test.go | 14 +- system-go/main.go | 253 +++++++++++++++++++++++++++++++--- system-go/main_test.go | 231 +++++++++++++++++++------------ ui/package.json | 2 +- 6 files changed, 433 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 17d6839..7b0fcbe 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Lattice Bundle v2 Reference Plugin `lattice-plugin-template` is the canonical self-contained Bundle v2 reference -plugin for Lattice `0.2.1-alpha.3`. +plugin for Lattice `0.2.1-alpha.4`. It demonstrates four boundaries that production plugins must keep explicit: @@ -16,6 +16,45 @@ It demonstrates four boundaries that production plugins must keep explicit: artifact digest and signed by the LatticeNet publisher key. Local development must clear the digest and signature before repackaging different bytes. +## The host-risk operation flow (spec §9.3) + +A plugin may compile intent into a plan; it may never apply one. This reference +implements the full flow, and it is the shape production plugins copy: + +1. **plan** — the `plan`-effect interface method (`example.lattice-plugin/reference` + / `plan`) returns a `PluginOperationPlan`: a summary, the target nodes, a redacted + preview, ordered steps, a rollback statement, and opaque `data`. It applies + nothing. The server bounds it, authorizes every target, and stores it as a + **pending approval** whose typed columns record the plugin version, artifact + digest, service, method, request hash, and targets. + +2. **approve** — an operator reads the preview and approves the exact plan hash. The + server re-checks every bound column against live state at approve/execute time; a + plugin that was upgraded, re-signed, disabled, or whose targets are no longer + authorized is refused. + +3. **execute** — the approval executor, and nothing else, invokes the plugin's + `execute` action with a one-time operation grant **bound on the host side**. The + plugin never receives the grant. It reads the approved targets and calls the + `task.enqueue` host call once per node. + +4. **enqueue** — `task:run` is *eligibility*, not authorization. The grant says which + nodes, under which approval, and how many times. The host refuses any task aimed + at an unapproved node, past the budget, or belonging to another plugin — and + applies the operator's own task validation, so a plugin can reach no wider an + interpreter set or script than an operator could. + +Rules a production plugin must keep: + +- **Never apply directly.** The only way to change a host is `plan` → operator + approval → `execute` → `task.enqueue`. There is no in-plugin apply. +- **Redact the preview.** Secrets never appear in a plan; put reversible material in + the encrypted secret store (§9.4), not in the plan or a log. +- **Quote everything you interpolate into a script.** The reference single-quotes the + approval id and node id so neither can break out of the `sh` command. +- **Declare `task:run`** to enqueue, and — for a runtime-backed operation service — + declare the service `backing: "runtime"`. + ## Bundle Layout The packaged artifact must contain exactly the host-facing runtime and UI assets: diff --git a/manifest.json b/manifest.json index 14e0494..344457d 100644 --- a/manifest.json +++ b/manifest.json @@ -4,14 +4,15 @@ "name": "Lattice Bundle Reference Plugin", "type": "system", "capabilities": [ - "network:plan" + "network:plan", + "task:run" ], - "version": "0.2.1-alpha.3", + "version": "0.2.1-alpha.4", "publisher": "latticenet", - "signature_ed25519": "qmzNktLpq5tGDulNK4hFqPLLxs/qhXC4tiQhx2kc+x746He7y7ZwLrRARe7MwcE8ih4lGIm4xox3KrtzPXqJCw==", + "signature_ed25519": "LwoiIGnuDhSjXIhW8LKeBihhkOtHH6L6jOFCDzJWlcpA7zFIiTLB0GVBzQsiB1r6gVW7nqYwhDetyC4ogf8TAQ==", "bundle": { "format": "tar+gzip", - "digest_sha256": "a7631567e67d0b0d2f8c971af3b3b5414cf8f2a00c88c396774c4190adb689d1" + "digest_sha256": "7817d1e778ffc17278968b6e4d03096bdf4264dc7bc932cac2daaeac80eaefdc" }, "runtime": { "protocol": "stdio-json-v1", diff --git a/system-go/conformance_test.go b/system-go/conformance_test.go index caa13bc..cc3c5fc 100644 --- a/system-go/conformance_test.go +++ b/system-go/conformance_test.go @@ -20,13 +20,14 @@ import ( // Copy this file into every plugin repository. It is the one test that cannot be // satisfied by a plugin that lies about itself. func TestManifestInterfacesAreServedAsDeclared(t *testing.T) { + rt := &runtime{host: refuseHostCalls{t}} for _, iface := range loadManifestInterfaces(t) { for _, method := range iface.Methods { - resp := handle(request{ + resp := rt.handle(request{ Action: "call", Service: iface.Service, Method: method.Name, - Payload: map[string]any{}, + Payload: json.RawMessage("{}"), }) served := !refusedAsUnknown(resp) @@ -59,6 +60,15 @@ func TestManifestInterfacesAreServedAsDeclared(t *testing.T) { // refusedAsUnknown separates "I do not implement this" from "I implement this and your // payload is wrong". Only the former means the artifact cannot serve the method — a // validation error proves the method is wired up. +// refuseHostCalls fails the test if the dispatcher reaches for the host. Probing which +// methods exist must never have a side effect. +type refuseHostCalls struct{ t *testing.T } + +func (h refuseHostCalls) call(method string, _ any) (json.RawMessage, error) { + h.t.Fatalf("conformance probe must not reach the host, but it called %q", method) + return nil, nil +} + func refusedAsUnknown(resp response) bool { if resp.OK { return false diff --git a/system-go/main.go b/system-go/main.go index 4c58c31..d7a7f68 100644 --- a/system-go/main.go +++ b/system-go/main.go @@ -1,28 +1,47 @@ +// Command plugin is the reference Bundle V2 system runtime. It speaks the +// stdio-json-v1 protocol: one request object per stdin line, one response object per +// stdout line, and — for host calls — a {"host_call":{...}} line answered on the fd in +// LATTICE_HOST_RESPONSE_FD. +// +// It is deliberately runtime-backed and implements the full §9.3 host-risk flow end to +// end, so a new plugin has a correct shape to copy: +// +// - a `plan`-effect interface method that returns a deterministic PluginOperationPlan +// (the server turns it into a pending approval; nothing is applied); +// - an `execute` action that the approval executor — and only it — invokes, which +// enqueues bounded agent work through the task.enqueue host call. +// +// The plugin never applies anything itself and never sees the approval grant: it asks +// the host to enqueue a task, and the host refuses any task outside the approved plan. package main import ( "bufio" "encoding/json" "fmt" + "io" "os" "sort" + "strconv" "strings" ) const ( pluginID = "example.lattice-plugin" pluginName = "Lattice Bundle Reference Plugin" - pluginVersion = "0.2.1-alpha.3" + pluginVersion = "0.2.1-alpha.4" + + referenceService = "example.lattice-plugin/reference" ) var interfaces = []string{"example.describe", "example.plan"} var requiredScopes = []string{"network:plan"} type request struct { - Action string `json:"action"` - Service string `json:"service,omitempty"` - Method string `json:"method,omitempty"` - Payload map[string]any `json:"payload"` + Action string `json:"action"` + Service string `json:"service,omitempty"` + Method string `json:"method,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` } type response struct { @@ -33,20 +52,33 @@ type response struct { Error string `json:"error,omitempty"` } +// hostCaller makes one brokered host call and returns its result. The runtime depends +// on this interface so tests can drive execute without a real host fd. +type hostCaller interface { + call(method string, params any) (json.RawMessage, error) +} + +type runtime struct { + host hostCaller +} + func main() { scanner := bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) + responses, closeResponses := hostResponseScanner() + defer closeResponses() + rt := &runtime{host: &stdioHostCaller{responses: responses, output: os.Stdout}} for scanner.Scan() { var req request if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { write(response{OK: false, Error: "invalid request: " + err.Error()}) continue } - write(handle(req)) + write(rt.handle(req)) } } -func handle(req request) response { +func (rt *runtime) handle(req request) response { switch req.Action { case "describe": body, _ := json.Marshal(describeBody()) @@ -56,31 +88,128 @@ func handle(req request) response { case "plan": return response{OK: true, Plan: renderPlan(req.Payload), Message: "dry-run plan generated"} case "call": - return handleCall(req) + return rt.handleCall(req) + case "execute": + // Reached ONLY from the server's approval executor, with an approved operation + // grant bound to the invocation on the host side. There is no way for an + // operator or a plugin author to reach this without an approval. + return rt.handleExecute(req.Payload) default: return response{OK: false, Error: fmt.Sprintf("unsupported action %q", req.Action)} } } -func handleCall(req request) response { - if req.Service != "example.lattice-plugin/reference" { +func (rt *runtime) handleCall(req request) response { + if req.Service != referenceService { return response{OK: false, Error: fmt.Sprintf("unsupported service %q", req.Service)} } - switch req.Method { case "describe": body, _ := json.Marshal(describeBody()) return response{OK: true, Result: body, Message: "describe result generated"} case "plan": - body, _ := json.Marshal(map[string]any{ - "plan": renderPlan(req.Payload), - }) - return response{OK: true, Result: body, Message: "plan result generated"} + return rt.planOperation(req.Payload) default: return response{OK: false, Error: fmt.Sprintf("unsupported method %q", req.Method)} } } +// planPayload is what an operator sends to the plan-effect method: the nodes they intend +// to act on. The plugin proposes; the server authorizes each target and stores the plan +// as a pending approval. +type planPayload struct { + Targets []string `json:"targets"` +} + +// operationPlan mirrors the server's plugin.PluginOperationPlan. The plan-effect method +// returns exactly this shape; the server unmarshals, bounds, and stores it. +type operationPlan struct { + Summary string `json:"summary"` + Targets []string `json:"targets"` + Preview string `json:"preview,omitempty"` + Steps []string `json:"steps,omitempty"` + Rollback string `json:"rollback,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +func (rt *runtime) planOperation(payload json.RawMessage) response { + var in planPayload + if len(payload) > 0 { + if err := json.Unmarshal(payload, &in); err != nil { + return response{OK: false, Error: "invalid plan payload: " + err.Error()} + } + } + if len(in.Targets) == 0 { + // A validation error, not an "unsupported method": the method IS served, the + // input is incomplete. The server would refuse an empty-target plan anyway. + return response{OK: false, Error: "plan requires at least one target node"} + } + // The opaque data rides through approval back into execute unchanged. A real plugin + // puts the compiled desired state here; the reference carries a marker. + data, _ := json.Marshal(map[string]any{"reference": true}) + plan := operationPlan{ + Summary: fmt.Sprintf("reference apply on %d node(s)", len(in.Targets)), + Targets: in.Targets, + Preview: "# reference plugin apply\n# writes nothing sensitive; enqueues one no-op task per node", + Steps: []string{"enqueue a bounded no-op task on each target"}, + Rollback: "none required; the reference task makes no host changes", + Data: data, + } + body, err := json.Marshal(plan) + if err != nil { + return response{OK: false, Error: "render plan: " + err.Error()} + } + return response{OK: true, Result: body, Message: "operation plan generated"} +} + +// executeRequest is what the approval executor hands the plugin: the approved plan's +// opaque data and the approved targets. It is a convenience, not an authority — every +// task the plugin then enqueues is checked by the host against the invocation's grant. +type executeRequest struct { + ApprovalID string `json:"approval_id"` + Targets []string `json:"targets"` + Data json.RawMessage `json:"data,omitempty"` +} + +// taskEnqueueParams is the task.enqueue host-call shape. node_id must be one the +// operator approved, or the host refuses it. +type taskEnqueueParams struct { + NodeID string `json:"node_id"` + Interpreter string `json:"interpreter"` + Script string `json:"script"` + TimeoutSec int `json:"timeout_sec"` +} + +func (rt *runtime) handleExecute(payload json.RawMessage) response { + var req executeRequest + if err := json.Unmarshal(payload, &req); err != nil { + return response{OK: false, Error: "invalid execute payload: " + err.Error()} + } + if len(req.Targets) == 0 { + return response{OK: false, Error: "execute received no targets"} + } + enqueued := 0 + for _, node := range req.Targets { + script := fmt.Sprintf("echo 'lattice reference plugin applied approval %s on %s'", + shellSingleQuote(req.ApprovalID), shellSingleQuote(node)) + if _, err := rt.host.call("task.enqueue", taskEnqueueParams{ + NodeID: node, Interpreter: "sh", Script: script, TimeoutSec: 60, + }); err != nil { + // A host refusal (unapproved node, exhausted grant, kill switch) surfaces here + // verbatim; the plugin does not get to override it. + return response{OK: false, Error: fmt.Sprintf("enqueue apply task on %s: %v", node, err)} + } + enqueued++ + } + return response{OK: true, Message: fmt.Sprintf("enqueued %d reference apply task(s)", enqueued)} +} + +// shellSingleQuote makes a value safe inside a single-quoted sh string, so an approval +// id or node id can never break out of the reference command. +func shellSingleQuote(v string) string { + return strings.ReplaceAll(v, "'", `'\''`) +} + func describeBody() map[string]any { return map[string]any{ "id": pluginID, @@ -89,23 +218,25 @@ func describeBody() map[string]any { "interfaces": interfaces, "required_scopes": requiredScopes, "manages": []string{ - "example deterministic dry-run plans", + "example deterministic operation plans", + "the reference plan -> approve -> execute -> task.enqueue flow (spec §9.3)", "self-contained bundle packaging and sandbox bridge patterns", }, "engine": "bundle v2 stdio-json-v1 system runtime", } } -func renderPlan(payload map[string]any) string { +func renderPlan(payload json.RawMessage) string { + values := map[string]any{} + _ = json.Unmarshal(payload, &values) parts := []string{"# Example Lattice system plugin plan"} - keys := make([]string, 0, len(payload)) - for key := range payload { + keys := make([]string, 0, len(values)) + for key := range values { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { - value := payload[key] - parts = append(parts, fmt.Sprintf("# %s = %v", key, value)) + parts = append(parts, fmt.Sprintf("# %s = %v", key, values[key])) } parts = append(parts, "# No host changes are made by this template.") return strings.Join(parts, "\n") @@ -114,3 +245,83 @@ func renderPlan(payload map[string]any) string { func write(resp response) { _ = json.NewEncoder(os.Stdout).Encode(resp) } + +// --- host call client (stdio-json-v1) ------------------------------------------------ + +type hostCallEnvelope struct { + HostCall hostCall `json:"host_call"` +} + +type hostCall struct { + ID string `json:"id"` + Method string `json:"method"` + Params any `json:"params,omitempty"` +} + +type hostResponseEnvelope struct { + HostResponse hostResponse `json:"host_response"` +} + +type hostResponse struct { + ID string `json:"id"` + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + Error string `json:"error"` +} + +type stdioHostCaller struct { + responses *bufio.Scanner + nextID int + output io.Writer +} + +func (host *stdioHostCaller) call(method string, params any) (json.RawMessage, error) { + if host == nil || host.responses == nil || host.output == nil { + return nil, fmt.Errorf("host response fd unavailable") + } + host.nextID++ + id := fmt.Sprintf("h%d", host.nextID) + if err := json.NewEncoder(host.output).Encode(hostCallEnvelope{ + HostCall: hostCall{ID: id, Method: method, Params: params}, + }); err != nil { + return nil, fmt.Errorf("write host_call: %w", err) + } + if !host.responses.Scan() { + if err := host.responses.Err(); err != nil { + return nil, fmt.Errorf("read host_response: %w", err) + } + return nil, fmt.Errorf("read host_response: eof") + } + var env hostResponseEnvelope + if err := json.Unmarshal(host.responses.Bytes(), &env); err != nil { + return nil, fmt.Errorf("decode host_response: %w", err) + } + if env.HostResponse.ID != id { + return nil, fmt.Errorf("host_response id mismatch: got %q want %q", env.HostResponse.ID, id) + } + if !env.HostResponse.OK { + if env.HostResponse.Error == "" { + env.HostResponse.Error = "host call failed" + } + return nil, fmt.Errorf("%s: %s", method, env.HostResponse.Error) + } + return env.HostResponse.Result, nil +} + +func hostResponseScanner() (*bufio.Scanner, func()) { + fd := 3 + if raw := strings.TrimSpace(os.Getenv("LATTICE_HOST_RESPONSE_FD")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 3 { + return nil, func() {} + } + fd = parsed + } + file := os.NewFile(uintptr(fd), "lattice-host-response") + if file == nil { + return nil, func() {} + } + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1<<20) + return scanner, func() { _ = file.Close() } +} diff --git a/system-go/main_test.go b/system-go/main_test.go index 575c97b..7a51e6f 100644 --- a/system-go/main_test.go +++ b/system-go/main_test.go @@ -7,6 +7,18 @@ import ( "testing" ) +// failHost fails the test if the plugin reaches for the host on a path that should never +// need it (describe, health, plan). Only execute talks to the host. +type failHost struct{ t *testing.T } + +func (h failHost) call(method string, _ any) (json.RawMessage, error) { + h.t.Helper() + h.t.Fatalf("this path must not call the host, but it called %q", method) + return nil, nil +} + +func offlineRuntime(t *testing.T) *runtime { return &runtime{host: failHost{t}} } + type manifestContract struct { ID string `json:"id"` Name string `json:"name"` @@ -22,8 +34,7 @@ func TestDescribeMatchesManifestContract(t *testing.T) { if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatal(err) } - - resp := handle(request{Action: "describe"}) + resp := offlineRuntime(t).handle(request{Action: "describe"}) if !resp.OK { t.Fatalf("describe ok = false, error = %q", resp.Error) } @@ -35,124 +46,168 @@ func TestDescribeMatchesManifestContract(t *testing.T) { if err := json.Unmarshal(resp.Result, &body); err != nil { t.Fatal(err) } - if body.ID != manifest.ID { - t.Fatalf("describe id = %q, manifest id = %q", body.ID, manifest.ID) - } - if body.Name != manifest.Name { - t.Fatalf("describe name = %q, manifest name = %q", body.Name, manifest.Name) - } - if body.Version != manifest.Version { - t.Fatalf("describe version = %q, manifest version = %q", body.Version, manifest.Version) + if body.ID != manifest.ID || body.Name != manifest.Name || body.Version != manifest.Version { + t.Fatalf("describe %+v does not match manifest %+v", body, manifest) } } func TestHealthReportsReady(t *testing.T) { - resp := handle(request{Action: "health"}) - - if !resp.OK { - t.Fatalf("health ok = false, error = %q", resp.Error) - } - if !strings.Contains(resp.Message, "healthy") { - t.Fatalf("health message = %q, want healthy", resp.Message) + resp := offlineRuntime(t).handle(request{Action: "health"}) + if !resp.OK || !strings.Contains(resp.Message, "healthy") { + t.Fatalf("health = %+v", resp) } } -func TestRenderPlanIsDeterministicAndNonMutating(t *testing.T) { - plan := renderPlan(map[string]any{ - "public_tcp": []any{80, 443}, - "node_id": "node-a", +// The plan-effect method returns a PluginOperationPlan the server can bound and store as +// a pending approval. It names the operator's targets and applies nothing itself. +func TestPlanReturnsAnOperationPlan(t *testing.T) { + resp := offlineRuntime(t).handle(request{ + Action: "call", Service: referenceService, Method: "plan", + Payload: json.RawMessage(`{"targets":["node-a","node-b"]}`), }) - - nodeAt := strings.Index(plan, "# node_id = node-a") - tcpAt := strings.Index(plan, "# public_tcp = [80 443]") - if nodeAt < 0 || tcpAt < 0 { - t.Fatalf("plan missing expected keys:\n%s", plan) + if !resp.OK { + t.Fatalf("plan ok = false, error = %q", resp.Error) } - if nodeAt > tcpAt { - t.Fatalf("plan keys are not sorted:\n%s", plan) + var plan operationPlan + if err := json.Unmarshal(resp.Result, &plan); err != nil { + t.Fatal(err) } - if !strings.Contains(plan, "No host changes are made by this template.") { - t.Fatalf("plan must state dry-run behavior:\n%s", plan) + if plan.Summary == "" || len(plan.Targets) != 2 || plan.Targets[0] != "node-a" { + t.Fatalf("unexpected plan: %+v", plan) + } + if plan.Rollback == "" { + t.Fatal("a reviewable plan must state its rollback") } } -func TestCallActionSupportsReferenceDescribeAndPlan(t *testing.T) { - describeResp := handle(request{ - Action: "call", - Service: "example.lattice-plugin/reference", - Method: "describe", +// An empty-target plan is a validation error, not an unsupported method: the method is +// served, the input is incomplete. This is what keeps the conformance probe honest. +func TestPlanRejectsEmptyTargets(t *testing.T) { + resp := offlineRuntime(t).handle(request{ + Action: "call", Service: referenceService, Method: "plan", + Payload: json.RawMessage(`{}`), }) - if !describeResp.OK { - t.Fatalf("call describe ok = false, error = %q", describeResp.Error) - } - var describeBody struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - } - if err := json.Unmarshal(describeResp.Result, &describeBody); err != nil { - t.Fatal(err) + if resp.OK { + t.Fatal("a plan with no targets must not succeed") } - if describeBody.ID != pluginID || describeBody.Name != pluginName || describeBody.Version != pluginVersion { - t.Fatalf("unexpected describe body: %+v", describeBody) + if strings.Contains(resp.Error, "unsupported") { + t.Fatalf("empty targets must be a validation error, not unsupported: %q", resp.Error) } +} + +// recordHost captures task.enqueue calls so the execute flow can be verified without a +// real runner. +type recordHost struct { + calls []taskEnqueueParams + failOn string // node id to reject, mimicking a host refusal +} - planResp := handle(request{ - Action: "call", - Service: "example.lattice-plugin/reference", - Method: "plan", - Payload: map[string]any{ - "node_id": "node-a", - "public_tcp": []any{80, 443}, - }, +func (h *recordHost) call(method string, params any) (json.RawMessage, error) { + if method != "task.enqueue" { + return nil, nil + } + raw, _ := json.Marshal(params) + var p taskEnqueueParams + _ = json.Unmarshal(raw, &p) + if p.NodeID == h.failOn { + return nil, &hostRefusal{p.NodeID} + } + h.calls = append(h.calls, p) + return json.RawMessage(`{"task_id":"task-1"}`), nil +} + +type hostRefusal struct{ node string } + +func (e *hostRefusal) Error() string { + return "node " + e.node + " is not among the approved targets" +} + +// execute enqueues one bounded task per approved target and never applies anything +// itself. +func TestExecuteEnqueuesOneTaskPerTarget(t *testing.T) { + host := &recordHost{} + rt := &runtime{host: host} + resp := rt.handle(request{ + Action: "execute", + Payload: json.RawMessage(`{"approval_id":"appr-1","targets":["node-a","node-b"]}`), }) - if !planResp.OK { - t.Fatalf("call plan ok = false, error = %q", planResp.Error) - } - var planBody struct { - Plan string `json:"plan"` + if !resp.OK { + t.Fatalf("execute ok = false, error = %q", resp.Error) } - if err := json.Unmarshal(planResp.Result, &planBody); err != nil { - t.Fatal(err) + if len(host.calls) != 2 { + t.Fatalf("want one task per target, got %d", len(host.calls)) } - if !strings.Contains(planBody.Plan, "# node_id = node-a") { - t.Fatalf("plan result missing node id:\n%s", planBody.Plan) + for i, node := range []string{"node-a", "node-b"} { + if host.calls[i].NodeID != node { + t.Fatalf("task %d aimed at %q, want %q", i, host.calls[i].NodeID, node) + } + if host.calls[i].Interpreter != "sh" || host.calls[i].Script == "" { + t.Fatalf("task %d malformed: %+v", i, host.calls[i]) + } } } -func TestUnsupportedActionFailsClosed(t *testing.T) { - resp := handle(request{Action: "apply"}) - +// A host refusal — an unapproved node, an exhausted grant, the kill switch — surfaces +// verbatim; the plugin does not get to override it. +func TestExecuteSurfacesHostRefusal(t *testing.T) { + host := &recordHost{failOn: "node-b"} + rt := &runtime{host: host} + resp := rt.handle(request{ + Action: "execute", + Payload: json.RawMessage(`{"approval_id":"appr-1","targets":["node-a","node-b"]}`), + }) if resp.OK { - t.Fatal("unsupported action returned ok=true") + t.Fatal("execute must fail when the host refuses a task") } - if !strings.Contains(resp.Error, `unsupported action "apply"`) { - t.Fatalf("unexpected error: %q", resp.Error) + if !strings.Contains(resp.Error, "node-b") { + t.Fatalf("the refusal must surface: %q", resp.Error) } } -func TestCallActionFailsClosedForUnknownServiceOrMethod(t *testing.T) { - serviceResp := handle(request{ - Action: "call", - Service: "example.lattice-plugin/other", - Method: "plan", +func TestExecuteInjectionSafeShellQuoting(t *testing.T) { + host := &recordHost{} + rt := &runtime{host: host} + resp := rt.handle(request{ + Action: "execute", + Payload: json.RawMessage(`{"approval_id":"a'; rm -rf /; echo '","targets":["node-a"]}`), }) - if serviceResp.OK { - t.Fatal("unknown service returned ok=true") + if !resp.OK { + t.Fatalf("execute ok = false: %q", resp.Error) } - if !strings.Contains(serviceResp.Error, "unsupported service") { - t.Fatalf("unexpected service error: %q", serviceResp.Error) + // The injected closing quote is escaped, so the malicious text stays a string literal + // and no second command exists. + if !strings.Contains(host.calls[0].Script, `'\''`) { + t.Fatalf("approval id was not shell-quoted: %s", host.calls[0].Script) } +} - methodResp := handle(request{ - Action: "call", - Service: "example.lattice-plugin/reference", - Method: "apply", - }) - if methodResp.OK { - t.Fatal("unknown method returned ok=true") +func TestRenderPlanIsDeterministicAndNonMutating(t *testing.T) { + plan := renderPlan(json.RawMessage(`{"public_tcp":[80,443],"node_id":"node-a"}`)) + nodeAt := strings.Index(plan, "# node_id = node-a") + tcpAt := strings.Index(plan, "# public_tcp =") + if nodeAt < 0 || tcpAt < 0 || nodeAt > tcpAt { + t.Fatalf("plan keys missing or unsorted:\n%s", plan) } - if !strings.Contains(methodResp.Error, "unsupported method") { - t.Fatalf("unexpected method error: %q", methodResp.Error) + if !strings.Contains(plan, "No host changes are made by this template.") { + t.Fatalf("plan must state dry-run behavior:\n%s", plan) + } +} + +func TestUnsupportedActionFailsClosed(t *testing.T) { + resp := offlineRuntime(t).handle(request{Action: "apply"}) + if resp.OK || !strings.Contains(resp.Error, `unsupported action "apply"`) { + t.Fatalf("unexpected: %+v", resp) + } +} + +func TestCallActionFailsClosedForUnknownServiceOrMethod(t *testing.T) { + rt := offlineRuntime(t) + serviceResp := rt.handle(request{Action: "call", Service: "example.lattice-plugin/other", Method: "plan"}) + if serviceResp.OK || !strings.Contains(serviceResp.Error, "unsupported service") { + t.Fatalf("unknown service: %+v", serviceResp) + } + methodResp := rt.handle(request{Action: "call", Service: referenceService, Method: "apply"}) + if methodResp.OK || !strings.Contains(methodResp.Error, "unsupported method") { + t.Fatalf("unknown method: %+v", methodResp) } } diff --git a/ui/package.json b/ui/package.json index e2ef4ac..a9cfb3d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "lattice-plugin-template-ui", - "version": "0.2.1-alpha.3", + "version": "0.2.1-alpha.4", "private": true, "type": "module", "scripts": {