Files
mcp/readonly_test.go
zeekay 0d9516147f mcp: decomplect into 3 orthogonal packages (transport / evmread / governance)
Split the flat governance MCP package into three independently-complete
packages so the generic parts are written ONCE and future MCP surfaces
(mining, treasury, bridge) are just a bag of read tools, never re-implementing
transport. One and only one way:

  github.com/luxfi/mcp           (root)  - domain-agnostic READ-ONLY MCP
                                           transport (Serve, Tool, Surface,
                                           dispatch, per-call timeout, line
                                           cap, panic->JSON-RPC recover) +
                                           ChainObservation. Imports NO geth.
  github.com/luxfi/mcp/evmread           - the SOLE go-ethereum importer: a
                                           minimal read-only Caller, Contract,
                                           Call/CallStruct[T], and the
                                           per-request Bounded call ceiling.
  github.com/luxfi/mcp/governance        - a Surface of the 8 read tools;
                                           reads via evmread, contributes
                                           []mcp.Tool to the shared transport.
  github.com/luxfi/mcp/cmd/lux-mcp       - the binary (was aivm-gov-mcp).

What this generalizes (the real point, not just a move):
- Tool is now a value carrying its own Read closure; Serve composes
  ...Surface and rejects duplicate / empty / nil-Read tools.
- ChainObservation lifted to the root and WIRED INTO ALL 8 TOOLS: every read
  returns (value, *ChainObservation) so an operator-LLM's signed verdict can
  bind Observation.Hash() and re-verify the chain state it claims to have seen
  - domain-independently. (Closes the previously-deferred MED-8.)
- The read-only AST gate is now MODULE-WIDE: it proves go-ethereum is imported
  by exactly one package (evmread), no package imports bind/geth-crypto/
  geth-rpc/reflect, the string-fragment denylist holds, and evmread.Caller's
  method set is exactly {CallContract,ChainID,BlockNumber,HeaderByNumber}.

Behavior-preserving and adversarially re-verified (blue->red loop):
- HIGH-1 bonded-quorum tally still matches AIGovernor.settle()'s _bonded
  filter exactly (PoC drives real compiled Solidity via core/vm/runtime).
- Parity tests remain real (independent ABI decode vs MCP output), struct
  mirrors still field-for-field aligned with luxfi/standard.
- Per-request bounded ceiling cannot leak/bypass; line cap + timeout + recover
  preserved. Read-only invariant intact and STRONGER (adding a write method to
  Caller is now a compile failure).
- Tests: 33 pass, 0 fail, 0 skip across the 3 packages (was 23 pre-split;
  none dropped, several strengthened); go build / vet / -race clean.
2026-06-24 23:46:17 -07:00

426 lines
14 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mcp_test
import (
"context"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"math/big"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
gethereum "github.com/luxfi/geth"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/mcp"
"github.com/luxfi/mcp/evmread"
"github.com/luxfi/mcp/governance"
)
// TestModuleIsReadOnly is the read-only acceptance gate for the WHOLE shared module
// (root mcp + evmread + governance + cmd). It proves five things:
//
// 1. The module's non-test code REFERENCES no transaction-signing or transaction-
// submitting symbol (AST scan — prose in comments/strings is ignored).
// 2. The ROOT go-ethereum package (github.com/luxfi/geth) is imported by exactly ONE
// package: the evmread adapter. Nothing else may import it.
// 3. NO package imports accounts/abi/bind, geth/crypto, geth/rpc, or reflect.
// 4. evmread.Caller's method set is EXACTLY the four read verbs — a positive assertion
// stronger than the denylist (a new write method fails here even if unnamed on a list).
// 5. tools/list exposes EXACTLY the eight governance read tools — no more.
//
// The scan is scoped to non-test files because the test harness legitimately signs and
// sends transactions to set up chain state; the invariant is about the LIBRARY the
// operator runs, not the test scaffolding.
func TestModuleIsReadOnly(t *testing.T) {
// ALWAYS-forbidden symbols — these names have no legitimate read-only use, so any
// reference (qualified or bare) is a failure.
alwaysForbidden := map[string]bool{
"SendTransaction": true, // ethclient / bind send a tx
"sendRawTransaction": true, // raw JSON-RPC tx submit
"SendRawTransaction": true,
"NewKeyedTransactor": true, // keyed transactor (signing)
"NewKeyedTransactorWithChainID": true,
"DeployContract": true, // sends a creation tx
"PrivateKey": true, // ecdsa.PrivateKey / *.PrivateKey field
}
// CONTEXT-forbidden: a method/func named like a signing op (e.g. "Sign", "GenerateKey")
// is forbidden ONLY when qualified by a crypto/tx package. This avoids false positives on
// legitimate, unrelated methods such as (*big.Int).Sign(), which reports a number's sign
// and is read-only.
signingNames := map[string]bool{
"Sign": true,
"SignTx": true,
"SignText": true,
"SignHash": true,
"GenerateKey": true,
}
signingPkgs := map[string]bool{
"crypto": true, // luxfi/crypto or geth/crypto signing
"ecdsa": true, // crypto/ecdsa
"bind": true, // accounts/abi/bind
"types": true, // types.SignTx
}
// Forbidden imports — packages that exist to sign or send txs (plus reflect, which could
// call a write method past the static denylist).
forbiddenImports := map[string]bool{
"github.com/luxfi/geth/accounts/abi/bind": true, // transactors / DeployContract
"github.com/luxfi/geth/crypto": true, // signing primitives
"github.com/luxfi/geth/rpc": true, // rpc.Client.CallContext can send raw tx
"reflect": true, // reflect.MethodByName could call a write method past the static denylist
}
// Forbidden RPC method strings — even passed as a literal to a generic RPC caller, these
// are write methods; a read-only server must never name them.
forbiddenRPCStrings := map[string]bool{
"eth_sendTransaction": true,
"eth_sendRawTransaction": true,
}
// Substring markers: any literal CONTAINING one of these is a write-path smell, which
// defeats string-concatenation evasion ("eth_send"+"RawTransaction") and the bare method
// verbs ("SendTransaction") that reflect.MethodByName would consume.
forbiddenRPCStringFragments := map[string]bool{
"eth_send": true,
"SendTransaction": true,
"SendRawTransaction": true,
}
fset := token.NewFileSet()
// Walk the WHOLE module tree from the module root (this package's dir): root mcp +
// evmread + governance + cmd. A write path added anywhere cannot slip past a single-dir
// scan.
var files []string
if err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
// Skip testdata (compiled-contract JSON, not Go) — nothing to parse there.
if d.Name() == "testdata" {
return fs.SkipDir
}
return nil
}
name := d.Name()
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
return nil
}
files = append(files, path)
return nil
}); err != nil {
t.Fatalf("walk module tree: %v", err)
}
scanned := 0
sawCmd := false
sawEvmread := false
sawGovernance := false
gethImporters := map[string]bool{} // package dir -> imports root geth
for _, path := range files {
sp := filepath.ToSlash(path)
if strings.Contains(sp, "cmd/") {
sawCmd = true
}
if strings.Contains(sp, "evmread/") {
sawEvmread = true
}
if strings.Contains(sp, "governance/") {
sawGovernance = true
}
src, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
// Parse WITHOUT comments so prose can never trip the check; we walk the AST, which
// contains only real code nodes (plus string literals, checked explicitly).
f, err := parser.ParseFile(fset, path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
scanned++
pkgDir := filepath.Dir(sp)
// Imports.
for _, imp := range f.Imports {
ip := strings.Trim(imp.Path.Value, `"`)
// observation.go imports github.com/luxfi/crypto ONLY for Keccak256 (a pure hash,
// not signing). That import path is NOT in forbiddenImports, so it is allowed; we
// additionally assert below that the only crypto symbol it uses is the hash.
if forbiddenImports[ip] {
t.Errorf("FORBIDDEN import %q in %s (a signing/tx-send package)", ip, path)
}
// The ONE allowed importer of the ROOT go-ethereum package is evmread.
if ip == "github.com/luxfi/geth" {
gethImporters[pkgDir] = true
}
}
// Selector, identifier, and string-literal references in real code.
ast.Inspect(f, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.SelectorExpr:
if alwaysForbidden[x.Sel.Name] {
t.Errorf("FORBIDDEN write-path symbol %q referenced in %s — the module must be read-only",
selString(x), path)
}
// A signing-named method is forbidden only under a crypto/tx package.
if signingNames[x.Sel.Name] {
if pkg, ok := x.X.(*ast.Ident); ok && signingPkgs[pkg.Name] {
t.Errorf("FORBIDDEN signing call %q in %s — the module must not sign",
selString(x), path)
}
}
case *ast.Ident:
// Bare identifiers (e.g. a local named PrivateKey). Only the always-forbidden
// names are checked bare; signing-named methods are only meaningful when
// package-qualified (handled above).
if alwaysForbidden[x.Name] {
t.Errorf("FORBIDDEN write-path identifier %q in %s", x.Name, path)
}
case *ast.BasicLit:
// Belt-and-suspenders: catch a write JSON-RPC method name passed as a string
// literal to any generic caller, even if the caller package were allowed. The
// module legitimately uses no raw RPC method strings. Substring (not exact)
// match so a FRAGMENTED literal — e.g. "eth_send" + "RawTransaction" — is still
// caught on each fragment.
if x.Kind == token.STRING {
if lit, lerr := unquote(x.Value); lerr == nil {
if forbiddenRPCStrings[lit] {
t.Errorf("FORBIDDEN write RPC method string %q in %s — read-only module must not name it", lit, path)
}
for marker := range forbiddenRPCStringFragments {
if strings.Contains(lit, marker) {
t.Errorf("FORBIDDEN write-path string fragment %q (in %q) in %s — read-only module must not name it", marker, lit, path)
}
}
}
}
}
return true
})
// The one allowed crypto use (observation.go) must be Keccak256 ONLY.
if filepath.Base(path) == "observation.go" {
assertCryptoHashOnly(t, f, path)
}
}
if scanned == 0 {
t.Fatal("scanned 0 source files — scan is broken")
}
if !sawCmd {
t.Fatal("scan never visited cmd/ — the recursive walk is broken (a write path could hide there)")
}
if !sawEvmread {
t.Fatal("scan never visited evmread/ — the recursive walk is broken")
}
if !sawGovernance {
t.Fatal("scan never visited governance/ — the recursive walk is broken")
}
t.Logf("AST-scanned %d non-test source files (root + evmread + governance + cmd): no write-path symbol referenced", scanned)
// The ROOT go-ethereum package must be imported by EXACTLY the evmread adapter.
if len(gethImporters) != 1 || !gethImporters["evmread"] {
t.Errorf("root github.com/luxfi/geth must be imported ONLY by evmread; importers=%v", keysOf(gethImporters))
} else {
t.Logf("root github.com/luxfi/geth imported ONLY by evmread (the sole adapter): %v", keysOf(gethImporters))
}
// POSITIVE assertion: the ONLY chain dependency is evmread.Caller, and its method set is
// EXACTLY the four read methods — no Send*/sign verb.
assertCallerIsReadOnly(t, fset)
// tools/list surface: exactly the eight read tools, by name. Tools() returns the static
// descriptors regardless of the caller, so a non-dialing fake caller suffices here.
addr := common.HexToAddress("0x000000000000000000000000000000000000dEaD")
g, err := governance.NewWithCaller(noopCaller{}, governance.Config{
AIParams: addr,
AIGovernor: addr,
AIThoughtRegistry: addr,
AIReputation: addr,
})
if err != nil {
t.Fatalf("build governance surface: %v", err)
}
srv, err := mcp.NewServer(g)
if err != nil {
t.Fatalf("build server: %v", err)
}
got := make([]string, 0, len(srv.Tools()))
for _, tl := range srv.Tools() {
got = append(got, tl.Name)
}
sort.Strings(got)
want := []string{
"chain_state",
"operator_reputation",
"param_history",
"param_value",
"pending_operations",
"quorum_status",
"receipt_lookup",
"thought_status",
}
sort.Strings(want)
if len(got) != len(want) {
t.Fatalf("tools/list returned %d tools, want exactly 8: %v", len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("tool[%d] = %q, want %q (full set: %v)", i, got[i], want[i], got)
}
}
}
// assertCryptoHashOnly verifies that the only github.com/luxfi/crypto symbol used in `f`
// is Keccak256 (a pure hash). Any other crypto.* selector (e.g. crypto.Sign) fails —
// proving observation.go's crypto dependency is hashing, never signing.
func assertCryptoHashOnly(t *testing.T, f *ast.File, name string) {
t.Helper()
alias := ""
for _, imp := range f.Imports {
if strings.Trim(imp.Path.Value, `"`) == "github.com/luxfi/crypto" {
if imp.Name != nil {
alias = imp.Name.Name
} else {
alias = "crypto"
}
}
}
if alias == "" {
return // crypto not imported here
}
ast.Inspect(f, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
pkgIdent, ok := sel.X.(*ast.Ident)
if !ok || pkgIdent.Name != alias {
return true
}
if sel.Sel.Name != "Keccak256" && sel.Sel.Name != "Keccak256Hash" {
t.Errorf("%s uses crypto.%s — only the keccak hash is permitted (no signing)", name, sel.Sel.Name)
}
return true
})
}
// assertCallerIsReadOnly parses the evmread adapter, finds the Caller interface, and
// asserts its method set is EXACTLY the four read methods. FAILS if any method is added or
// renamed (e.g. a SendTransaction slipped onto the chain seam), so the read-only surface
// cannot silently grow. The four methods — CallContract (read-only eth_call), ChainID,
// BlockNumber, HeaderByNumber — are all read verbs with no transaction-submitting
// capability.
func assertCallerIsReadOnly(t *testing.T, fset *token.FileSet) {
t.Helper()
const path = "evmread/evmread.go"
src, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
f, err := parser.ParseFile(fset, path, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
want := map[string]bool{
"CallContract": true,
"ChainID": true,
"BlockNumber": true,
"HeaderByNumber": true,
}
var methods []string
found := false
ast.Inspect(f, func(n ast.Node) bool {
ts, ok := n.(*ast.TypeSpec)
if !ok || ts.Name.Name != "Caller" {
return true
}
iface, ok := ts.Type.(*ast.InterfaceType)
if !ok {
t.Fatalf("Caller is not an interface type")
}
found = true
for _, m := range iface.Methods.List {
// Embedded interfaces (no Names) would widen the surface invisibly — reject.
if len(m.Names) == 0 {
t.Errorf("evmread.Caller embeds another interface — read-only surface must be explicit, not embedded")
continue
}
for _, nm := range m.Names {
methods = append(methods, nm.Name)
}
}
return false
})
if !found {
t.Fatal("evmread.Caller interface not found — the read-only chain seam is the invariant's anchor")
}
sort.Strings(methods)
got := map[string]bool{}
for _, m := range methods {
got[m] = true
if !want[m] {
t.Errorf("evmread.Caller exposes unexpected method %q — only read methods are allowed on the chain seam", m)
}
}
for m := range want {
if !got[m] {
t.Errorf("evmread.Caller is missing expected read method %q", m)
}
}
t.Logf("evmread.Caller method set verified read-only: %v", methods)
}
// selString renders a selector expression like "pkg.Symbol" for error messages.
func selString(s *ast.SelectorExpr) string {
if id, ok := s.X.(*ast.Ident); ok {
return id.Name + "." + s.Sel.Name
}
return s.Sel.Name
}
// unquote strips Go string-literal quoting (handles both "..." and `...`).
func unquote(lit string) (string, error) {
return strconv.Unquote(lit)
}
func keysOf(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// noopCaller is a non-dialing evmread.Caller for the tools/list assertion (Tools() never
// touches the chain, so these methods are never called). It exists only to satisfy the
// read-only seam without a network dial.
type noopCaller struct{}
func (noopCaller) CallContract(_ context.Context, _ gethereum.CallMsg, _ *big.Int) ([]byte, error) {
return nil, nil
}
func (noopCaller) ChainID(context.Context) (*big.Int, error) { return big.NewInt(1), nil }
func (noopCaller) BlockNumber(context.Context) (uint64, error) { return 0, nil }
func (noopCaller) HeaderByNumber(context.Context, *big.Int) (*types.Header, error) {
return nil, nil
}
var _ evmread.Caller = noopCaller{}