package mcp import ( "os" "path/filepath" "strings" "testing" ) // TestFenceGuardrail_NoBareCallToolResult is the regression guardrail for // Bundle-3 / Audit H-002, H-003, M-003, M-004, M-005 / CWE-1039 (LLM Prompt // Injection). // // The wrapper-layer fencing strategy (textResult / errorResult in tools.go) // only provides defense-in-depth if EVERY MCP tool routes its response // through those wrappers. A new tool that constructs its own // `gomcp.CallToolResult{...}` literal — or returns a bare `fmt.Errorf` from // the tool handler signature — would silently bypass the fence and re-open // every finding in this bundle. // // This guardrail walks every .go file in the mcp package and fails CI if it // finds a `gomcp.CallToolResult{` literal outside `tools.go` (which defines // textResult). It is intentionally cheap and string-based — a real Go AST // scan would be more precise but would also be more brittle to refactor. // // To add a new MCP tool: route through textResult / errorResult and this // test stays green. To deliberately bypass: explicitly add the file to the // allowlist below with a comment explaining why. func TestFenceGuardrail_NoBareCallToolResult(t *testing.T) { // Files allowed to construct CallToolResult directly. // tools.go defines the textResult wrapper and is the ONLY legitimate // site. Tests are also allowed (they exercise the wrapper output). // // tools_certificates.go is allowlisted post-Sprint-10 (Phase 9 // ARCH-M2 closure, 2026-05-14) for the two pre-existing CRL/OCSP // CallToolResult literals inside registerCRLOCSPTools: each returns // a server-built status string of the form "DER CRL retrieved (%d // bytes, content-type: %s)" / "OCSP response retrieved (...)" — // the byte-count is `len(raw)` from the GetRaw response (no // attacker influence) and the content-type comes from the HTTP // Content-Type header on the upstream PKI endpoint (server- // controlled in self-hosted deployments). Both predate Bundle-3 // fencing; Sprint 10 relocated the registerCRLOCSPTools function // from tools.go to tools_certificates.go and preserved the // literals byte-for-byte (pure mechanical relocation, no behavior // change). Tightening these two sites to route through textResult // is a follow-up concern — open question on whether the binary- // pass-through status string format breaks compatibility for // existing MCP consumers that parse the description text. allow := map[string]bool{ "tools.go": true, "tools_certificates.go": true, } entries, err := os.ReadDir(".") if err != nil { t.Fatalf("read package dir: %v", err) } violations := []string{} for _, e := range entries { name := e.Name() if e.IsDir() || !strings.HasSuffix(name, ".go") { continue } if strings.HasSuffix(name, "_test.go") { continue } if allow[name] { continue } body, err := os.ReadFile(filepath.Join(".", name)) if err != nil { t.Fatalf("read %s: %v", name, err) } text := string(body) if strings.Contains(text, "gomcp.CallToolResult{") || strings.Contains(text, "mcp.CallToolResult{") { violations = append(violations, name+": constructs CallToolResult literal — must route through textResult/errorResult (Bundle-3 fence)") } } if len(violations) > 0 { t.Errorf("Bundle-3 fence guardrail violated. Add allowlist entry only with security review.\n - %s", strings.Join(violations, "\n - ")) } }