diff --git a/.gitignore b/.gitignore index dfc913bc..5557a85a 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,7 @@ test/serena-mcp-tests/results/ test/serena-mcp-tests/results-gateway/ test/serena-mcp-tests/**/__pycache__/ test/serena-mcp-tests/**/*.pyc + +# WASM guard build artifacts +examples/guards/*/guard.wasm +*.wasm diff --git a/Makefile b/Makefile index 15720777..75bd4453 100644 --- a/Makefile +++ b/Makefile @@ -21,15 +21,15 @@ build: lint: @echo "Running linters..." @go mod tidy - @go vet ./... + @go vet $$(go list ./... | grep -v '/examples/guards/') @echo "Running gofmt check..." - @test -z "$$(gofmt -l .)" || (echo "The following files are not formatted:"; gofmt -l .; exit 1) + @test -z "$$(gofmt -l $$(find . -name '*.go' -not -path './examples/guards/*'))" || (echo "The following files are not formatted:"; gofmt -l $$(find . -name '*.go' -not -path './examples/guards/*'); exit 1) @echo "Running golangci-lint..." @GOPATH=$$(go env GOPATH); \ if [ -f "$$GOPATH/bin/golangci-lint" ]; then \ - $$GOPATH/bin/golangci-lint run --timeout=5m || echo "⚠ Warning: golangci-lint failed (compatibility issue with Go 1.25.0). Continuing with other checks..."; \ + $$GOPATH/bin/golangci-lint run --timeout=5m --skip-dirs examples/guards || echo "⚠ Warning: golangci-lint failed (compatibility issue with Go 1.25.0). Continuing with other checks..."; \ elif command -v golangci-lint >/dev/null 2>&1; then \ - golangci-lint run --timeout=5m || echo "⚠ Warning: golangci-lint failed (compatibility issue with Go 1.25.0). Continuing with other checks..."; \ + golangci-lint run --timeout=5m --skip-dirs examples/guards || echo "⚠ Warning: golangci-lint failed (compatibility issue with Go 1.25.0). Continuing with other checks..."; \ else \ echo "⚠ Warning: golangci-lint not found. Run 'make install' to install it."; \ echo " Skipping golangci-lint checks..."; \ diff --git a/examples/guards/EXTERNAL_GUARD_QUICKSTART.md b/examples/guards/EXTERNAL_GUARD_QUICKSTART.md new file mode 100644 index 00000000..823234bc --- /dev/null +++ b/examples/guards/EXTERNAL_GUARD_QUICKSTART.md @@ -0,0 +1,377 @@ +# External WASM Guard Quick Start Guide + +This guide explains how to create, build, and host WASM guards in a separate repository from the MCP Gateway. + +## Overview + +WASM guards can be developed and maintained in separate repositories, then loaded by the gateway at runtime. This allows: +- Independent versioning and development +- Team-specific guard implementations +- Secure distribution via GitHub Releases or Packages + +## GitHub Storage Options for WASM Modules + +GitHub provides several secure ways to host WASM modules: + +### 1. GitHub Releases (Recommended) +**Best for**: Versioned guard releases +- Attach `.wasm` files as release assets +- Access via stable URLs: `https://github.com/owner/repo/releases/download/v1.0.0/guard.wasm` +- Supports checksums for verification +- Public or private repositories + +### 2. GitHub Packages (Container Registry) +**Best for**: OCI-compatible workflows +- Package WASM as OCI artifacts +- Access via `ghcr.io/owner/guard:tag` +- Requires OCI tooling to extract WASM +- More complex but consistent with container workflows + +### 3. Git LFS (Large File Storage) +**Best for**: Development/testing +- Store WASM in repository with Git LFS +- Clone repository to access guards +- Less suitable for production distribution + +**Recommendation**: Use **GitHub Releases** for production guard distribution. It's simple, secure, and provides stable URLs. + +## Quick Start: Creating a Separate Guard Repository + +### Step 1: Fork or Create Guard Repository + +```bash +# Option A: Fork the sample guard +gh repo fork githubnext/gh-aw-mcpg --clone +cd gh-aw-mcpg/examples/guards/sample-guard + +# Option B: Create from scratch +mkdir my-difc-guard && cd my-difc-guard +git init +``` + +### Step 2: Set Up Guard Project + +If starting from scratch, create the minimal structure: + +```bash +# Create guard source +cat > main.go << 'EOF' +package main + +import ( + "encoding/json" + "fmt" + "unsafe" +) + +//go:wasmimport env call_backend +func callBackend(toolNamePtr, toolNameLen, argsPtr, argsLen, resultPtr, resultSize uint32) int32 + +//export label_resource +func labelResource(inputPtr, inputLen, outputPtr, outputSize uint32) int32 { + // Read input + input := readBytes(inputPtr, inputLen) + var req map[string]interface{} + json.Unmarshal(input, &req) + + // Create response + output := map[string]interface{}{ + "resource": map[string]interface{}{ + "description": fmt.Sprintf("resource:%s", req["tool_name"]), + "secrecy": []string{"public"}, + "integrity": []string{"untrusted"}, + }, + "operation": "read", + } + + // Write output + outputJSON, _ := json.Marshal(output) + copy(readBytes(outputPtr, uint32(len(outputJSON))), outputJSON) + return int32(len(outputJSON)) +} + +//export label_response +func labelResponse(inputPtr, inputLen, outputPtr, outputSize uint32) int32 { + return 0 // No fine-grained labeling +} + +func readBytes(ptr, length uint32) []byte { + return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), length) +} + +func main() {} +EOF + +# Create Makefile +cat > Makefile << 'EOF' +.PHONY: build clean + +build: + @echo "Building WASM guard with TinyGo + Go 1.23..." + @for go_bin in go1.23 go1.23.9 go1.23.10; do \ + if command -v $$go_bin >/dev/null 2>&1; then \ + GOROOT=$$($$go_bin env GOROOT) tinygo build -o guard.wasm -target=wasi main.go && \ + echo "✓ Built with $$go_bin" && exit 0; \ + fi; \ + done; \ + echo "Error: Go 1.23 required. Install: go install golang.org/dl/go1.23.9@latest && go1.23.9 download" + +clean: + rm -f guard.wasm +EOF + +# Create README +cat > README.md << 'EOF' +# My DIFC Guard + +Custom DIFC guard for MCP Gateway. + +## Build + +Requires: +- Go 1.23: `go install golang.org/dl/go1.23.9@latest && go1.23.9 download` +- TinyGo 0.34+: https://tinygo.org + +Build: `make build` +EOF +``` + +### Step 3: Build Guard + +```bash +# Install Go 1.23 (if not already installed) +go install golang.org/dl/go1.23.9@latest +go1.23.9 download + +# Install TinyGo (if not already installed) +# See: https://tinygo.org/getting-started/install/ + +# Build the guard +make build +# Creates: guard.wasm +``` + +### Step 4: Verify Guard + +```bash +# Check the WASM file +file guard.wasm +# Should show: guard.wasm: WebAssembly (wasm) binary module version 0x1 (MVP) + +# Check size (should be reasonable, typically < 5MB) +ls -lh guard.wasm +``` + +### Step 5: Create GitHub Repository and Release + +```bash +# Initialize git (if not already done) +git init +git add . +git commit -m "Initial guard implementation" + +# Create GitHub repository +gh repo create my-org/my-difc-guard --private --source=. --push + +# Create a release with the WASM file +git tag v1.0.0 +git push origin v1.0.0 +gh release create v1.0.0 guard.wasm \ + --title "v1.0.0" \ + --notes "Initial release of DIFC guard" +``` + +### Step 6: Configure Gateway to Use External Guard + +Update your gateway configuration to reference the guard: + +**Option A: Local file** (for development): +```toml +[servers.github] +container = "ghcr.io/github/github-mcp-server" +guard = "myguard" + +[guards.myguard] +type = "wasm" +path = "/path/to/local/guard.wasm" +``` + +**Option B: GitHub Release URL** (for production): +```toml +[servers.github] +container = "ghcr.io/github/github-mcp-server" +guard = "myguard" + +[guards.myguard] +type = "wasm" +url = "https://github.com/my-org/my-difc-guard/releases/download/v1.0.0/guard.wasm" +sha256 = "abc123..." # Optional but recommended for security +``` + +**Note**: The `url` field is not yet implemented in the current framework. See "Future Enhancement" section below. + +## Security Best Practices + +### 1. Verify WASM Integrity + +Always verify downloaded WASM modules: + +```bash +# Generate checksum when building +sha256sum guard.wasm > guard.wasm.sha256 + +# Include checksum in release notes +gh release create v1.0.0 guard.wasm guard.wasm.sha256 \ + --title "v1.0.0" \ + --notes "SHA256: $(cat guard.wasm.sha256)" + +# Verify before loading (in deployment scripts) +echo "expected_sha256 guard.wasm" | sha256sum -c - +``` + +### 2. Use Private Repositories + +For sensitive guard logic: +```bash +# Create private repository +gh repo create my-org/my-difc-guard --private --source=. --push + +# Private releases require authentication +# Set GITHUB_TOKEN in gateway environment +export GITHUB_TOKEN="ghp_..." +``` + +### 3. Sign Releases + +Use GPG to sign releases: +```bash +# Sign the WASM file +gpg --detach-sign --armor guard.wasm + +# Include signature in release +gh release create v1.0.0 guard.wasm guard.wasm.asc \ + --title "v1.0.0 (signed)" \ + --notes "GPG signed release" +``` + +### 4. Audit Guard Code + +Before using external guards: +- Review source code +- Verify build reproducibility +- Test in isolated environment +- Monitor guard behavior + +## Development Workflow + +### Iterative Development + +```bash +# 1. Make changes to guard logic +vi main.go + +# 2. Build and test locally +make build +# Test with local gateway configuration + +# 3. Commit and create new release +git add main.go +git commit -m "Update guard logic" +git push +git tag v1.0.1 +git push origin v1.0.1 +gh release create v1.0.1 guard.wasm --title "v1.0.1" + +# 4. Update gateway configuration to new version +# Change url to: .../releases/download/v1.0.1/guard.wasm +``` + +### Testing Guards + +```bash +# Test guard locally before releasing +cd /path/to/gateway +cat > test-config.toml << EOF +[servers.testserver] +container = "test-mcp-server" +guard = "testguard" + +[guards.testguard] +type = "wasm" +path = "/path/to/your/guard.wasm" +EOF + +# Run gateway with test config +./awmg --config test-config.toml +``` + +## Future Enhancement: URL Loading + +The framework currently supports local `path` but not remote `url` loading. To add URL support: + +**Proposed configuration**: +```toml +[guards.myguard] +type = "wasm" +url = "https://github.com/my-org/my-difc-guard/releases/download/v1.0.0/guard.wasm" +sha256 = "expected_checksum" # Required for URL loading +cache_dir = "/var/cache/mcp-guards" # Optional cache location +``` + +**Implementation would include**: +1. HTTP client to download WASM from URL +2. SHA256 verification (required for security) +3. Local caching to avoid repeated downloads +4. Support for GitHub authentication (`GITHUB_TOKEN` env var) +5. Retry logic for network failures + +**Workaround until implemented**: +```bash +# Download guard in deployment script +wget https://github.com/my-org/my-difc-guard/releases/download/v1.0.0/guard.wasm \ + -O /var/lib/mcp-guards/myguard.wasm + +# Verify checksum +echo "expected_sha256 /var/lib/mcp-guards/myguard.wasm" | sha256sum -c - + +# Reference local path in config +# path = "/var/lib/mcp-guards/myguard.wasm" +``` + +## Example: Complete Guard Repository + +See the sample guard in the main repository: +```bash +# View the complete example +git clone https://github.com/githubnext/gh-aw-mcpg +cd gh-aw-mcpg/examples/guards/sample-guard +cat main.go # Review guard implementation +cat Makefile # Review build process +make build # Build the guard +``` + +## Troubleshooting + +### Build fails with "requires go version 1.19 through 1.23" +**Solution**: Install Go 1.23 specifically for guard compilation: +```bash +go install golang.org/dl/go1.23.9@latest +go1.23.9 download +``` + +### TinyGo not found +**Solution**: Install TinyGo from https://tinygo.org/getting-started/install/ + +### Guard doesn't export functions +**Problem**: Compiled with standard Go instead of TinyGo +**Solution**: Ensure TinyGo is in PATH and Makefile uses it + +### "failed to read WASM file" +**Solution**: Check file path in configuration is absolute or relative to gateway working directory + +## Resources + +- TinyGo documentation: https://tinygo.org/docs/ +- WASI specification: https://wasi.dev/ +- WebAssembly documentation: https://webassembly.org/ +- GitHub Releases API: https://docs.github.com/en/rest/releases diff --git a/examples/guards/sample-guard-js/guard.js b/examples/guards/sample-guard-js/guard.js new file mode 100644 index 00000000..d5a09164 --- /dev/null +++ b/examples/guards/sample-guard-js/guard.js @@ -0,0 +1,76 @@ +// Sample DIFC Guard implemented in JavaScript +// This demonstrates that JavaScript guards are easier than Go guards: +// - No TinyGo requirement +// - Works with any wazero version +// - Native WASM support +// - Easy to compile and use + +// Host function import (provided by gateway via wazero) +// Note: This is imported automatically by the WASM runtime + +// Guard function: label_resource +// Called before accessing a resource to determine its DIFC labels +function label_resource(inputPtr, inputLen, outputPtr, outputSize) { + try { + // Read input JSON from WASM memory + const inputBytes = new Uint8Array(memory.buffer, inputPtr, inputLen); + const inputStr = new TextDecoder().decode(inputBytes); + const input = JSON.parse(inputStr); + + // Default labels + const output = { + resource: { + description: `resource:${input.tool_name}`, + secrecy: ["public"], + integrity: ["untrusted"] + }, + operation: "read" + }; + + // Label based on tool name + switch (input.tool_name) { + case "create_issue": + case "update_issue": + case "create_pull_request": + output.operation = "write"; + output.resource.integrity = ["contributor"]; + break; + + case "merge_pull_request": + output.operation = "read-write"; + output.resource.integrity = ["maintainer"]; + break; + + case "list_issues": + case "get_issue": + case "list_pull_requests": + output.operation = "read"; + output.resource.secrecy = ["public"]; + break; + } + + // Write output JSON + const outputStr = JSON.stringify(output); + const outputBytes = new TextEncoder().encode(outputStr); + + if (outputBytes.length > outputSize) { + return -1; // Output too large + } + + new Uint8Array(memory.buffer, outputPtr, outputBytes.length).set(outputBytes); + return outputBytes.length; + } catch (e) { + return -1; // Error + } +} + +// Guard function: label_response +// Called after a backend call to label response data +function label_response(inputPtr, inputLen, outputPtr, outputSize) { + try { + // For this sample, we don't do fine-grained labeling + return 0; + } catch (e) { + return -1; + } +} diff --git a/examples/guards/sample-guard/Makefile b/examples/guards/sample-guard/Makefile new file mode 100644 index 00000000..ae38814d --- /dev/null +++ b/examples/guards/sample-guard/Makefile @@ -0,0 +1,27 @@ +.PHONY: build clean + +build: + @echo "Building WASM guard..." + @if command -v tinygo >/dev/null 2>&1; then \ + echo "TinyGo found, attempting build..."; \ + for go_bin in go1.23 go1.23.9 go1.23.10 go1.23.8; do \ + if command -v $$go_bin >/dev/null 2>&1; then \ + echo "Found $$go_bin, using for TinyGo..."; \ + GOROOT=$$($$go_bin env GOROOT) tinygo build -o guard.wasm -target=wasi main.go 2>&1 && \ + echo "✓ Successfully built guard with TinyGo + $$go_bin" && exit 0; \ + fi; \ + done; \ + echo "No Go 1.23 found. Trying TinyGo with system Go..."; \ + tinygo build -o guard.wasm -target=wasi main.go 2>&1 && \ + echo "✓ Successfully built guard with TinyGo" && exit 0; \ + echo "TinyGo build failed (likely Go version incompatibility)"; \ + echo "Install Go 1.23: go install golang.org/dl/go1.23.9@latest && go1.23.9 download"; \ + else \ + echo "TinyGo not found. Install from: https://tinygo.org"; \ + fi; \ + echo "Falling back to standard Go (function exports won't work)..."; \ + GOOS=wasip1 GOARCH=wasm go build -o guard.wasm main.go; \ + echo "⚠ Warning: Guard compiled with standard Go won't export functions properly" + +clean: + rm -f guard.wasm diff --git a/examples/guards/sample-guard/README.md b/examples/guards/sample-guard/README.md new file mode 100644 index 00000000..13627fcd --- /dev/null +++ b/examples/guards/sample-guard/README.md @@ -0,0 +1,190 @@ +# Sample DIFC Guard for WASM + +This is a sample DIFC guard written in Go that compiles to WebAssembly (WASM). + +## Requirements and Limitations + +### TinyGo + Go 1.23 Requirement + +**TinyGo is required** for proper WASM function exports. Standard Go's `wasip1` target does not support the `//export` directive needed for guard functions. + +**Version Compatibility**: +- **Gateway**: Go 1.25 (current project version) +- **Guards**: Go 1.23 (for TinyGo compatibility) +- **TinyGo**: 0.34+ (supports Go 1.19-1.23) + +**Key insight**: WASM is version-independent! A guard compiled with Go 1.23 works perfectly with a gateway compiled with Go 1.25. The gateway and guard communicate only through: +- JSON data in linear memory +- Function calls via exported symbols + +There is no Go version coupling between the gateway and guards. + +### Setup + +**For Gateway Development** (Go 1.25): +```bash +# Already installed - use for gateway +go version # Should show go1.25 +``` + +**For Guard Development** (Go 1.23): +```bash +# Install Go 1.23 alongside Go 1.25 +go install golang.org/dl/go1.23@latest +go1.23 download + +# Install TinyGo +# See: https://tinygo.org/getting-started/install/ +curl -sSfL https://github.com/tinygo-org/tinygo/releases/download/v0.34.0/tinygo_0.34.0_amd64.deb +sudo dpkg -i tinygo_0.34.0_amd64.deb +``` + +### Building + +To compile this guard to WASM using TinyGo with Go 1.23: + +```bash +# Set GOROOT to use Go 1.23 +export GOROOT=$(go1.23 env GOROOT) +tinygo build -o guard.wasm -target=wasi main.go +``` + +Or use the Makefile (tries Go 1.23 automatically): +```bash +make build +``` + +## Overview + +WASM guards run **inside the gateway process** in a sandboxed wazero runtime. They cannot make direct network calls or access the filesystem. + +### Guard Execution Model + +``` +┌─────────────────────────────────────┐ +│ Gateway Process │ +│ ┌────────────────────────────────┐ │ +│ │ WasmGuard (Go) │ │ +│ │ ┌──────────────────────────┐ │ │ +│ │ │ guard.wasm │ │ │ +│ │ │ (sandboxed in wazero) │ │ │ +│ │ │ │ │ │ +│ │ │ - label_resource() │ │ │ +│ │ │ - label_response() │ │ │ +│ │ │ - call_backend() ───────┐│ │ │ +│ │ └──────────────────────────┘│ │ │ +│ │ │ │ │ │ +│ │ └─────────────────┼──┼─┼─► BackendCaller +│ └────────────────────────────────┘ │ │ │ +│ │ │ ▼ +│ │ │ MCP Backend +└──────────────────────────────────────┘ └─────────── +``` + +Guards: +- Run in-process (not separate CLI) +- Execute in sandboxed wazero runtime +- Cannot make direct network/file I/O +- Call backend via controlled host function + +## Interface + +### Exported Functions (from WASM to Gateway) + +#### `label_resource(inputPtr, inputLen, outputPtr, outputSize uint32) int32` +Labels a resource before access. + +**Input** (JSON at inputPtr): +```json +{ + "tool_name": "create_issue", + "tool_args": {"owner": "org", "repo": "repo", "title": "Bug"} +} +``` + +**Output** (JSON at outputPtr): +```json +{ + "resource": { + "description": "resource:create_issue", + "secrecy": ["public"], + "integrity": ["contributor"] + }, + "operation": "write" +} +``` + +**Returns**: Length of output JSON (>0), 0 for empty, or negative for error + +#### `label_response(inputPtr, inputLen, outputPtr, outputSize uint32) int32` +Labels response data for fine-grained filtering. + +**Input** (JSON at inputPtr): +```json +{ + "tool_name": "list_issues", + "tool_result": [...] +} +``` + +**Output** (JSON at outputPtr): +```json +{ + "items": [ + {"data": {...}, "labels": {"secrecy": ["public"]}} + ] +} +``` + +**Returns**: Length of output JSON, 0 for no labeling, or negative for error + +### Host Functions (from WASM to Gateway) + +#### `call_backend(toolNamePtr, toolNameLen, argsPtr, argsLen, resultPtr, resultSize uint32) int32` +Makes read-only calls to backend MCP server. + +**Parameters**: +- Tool name and args as JSON in WASM memory +- Result buffer for backend response + +**Returns**: Length of result JSON, or negative on error + +**Example**: +```go +// Inside WASM guard +repoInfo, err := callBackendHelper("search_repositories", map[string]interface{}{ + "query": "repo:owner/name", +}) +``` + +## Example Configuration + +```toml +[servers.github] +container = "ghcr.io/github/github-mcp-server" +guard = "github" + +[guards.github] +type = "wasm" +path = "./examples/guards/sample-guard/guard.wasm" +``` + +## Implementation Notes + +- **In-process execution**: Guard runs inside gateway, not as separate process +- **Sandboxed**: wazero runtime prevents direct I/O and network access +- **TinyGo required**: Standard Go doesn't support `//export` for WASM +- **JSON-based**: All data exchange uses JSON (TinyGo-compatible) +- **Simple types**: No complex Go types across WASM boundary +- **Read-only backend**: Guards can only read from backend, not write + +## TinyGo Limitations + +TinyGo has some standard library limitations: +- ✓ encoding/json - Works +- ✓ fmt - Works +- ✓ Basic stdlib - Works +- ✗ Reflection - Limited +- ✗ Some stdlib packages - Not available + +The guard interface is designed to work within these constraints using simple JSON data exchange. diff --git a/examples/guards/sample-guard/go.mod b/examples/guards/sample-guard/go.mod new file mode 100644 index 00000000..406b3e4d --- /dev/null +++ b/examples/guards/sample-guard/go.mod @@ -0,0 +1,3 @@ +module guard + +go 1.23 diff --git a/examples/guards/sample-guard/main.go b/examples/guards/sample-guard/main.go new file mode 100644 index 00000000..b4ed8125 --- /dev/null +++ b/examples/guards/sample-guard/main.go @@ -0,0 +1,201 @@ +package main + +import ( + "encoding/json" + "fmt" + "unsafe" +) + +// This is a sample DIFC guard that runs as a WASM module inside the gateway +// It uses exported functions and host function imports for sandbox security + +// callBackend is imported from the host (gateway) environment +// It allows the guard to make read-only calls to the backend MCP server +// +//go:wasmimport env call_backend +func callBackend(toolNamePtr, toolNameLen, argsPtr, argsLen, resultPtr, resultSize uint32) int32 + +// Request structures +type LabelResourceInput struct { + ToolName string `json:"tool_name"` + ToolArgs map[string]interface{} `json:"tool_args"` + Capabilities interface{} `json:"capabilities,omitempty"` +} + +type LabelResponseInput struct { + ToolName string `json:"tool_name"` + ToolResult interface{} `json:"tool_result"` + Capabilities interface{} `json:"capabilities,omitempty"` +} + +// Response structures +type LabelResourceOutput struct { + Resource ResourceLabels `json:"resource"` + Operation string `json:"operation"` +} + +type ResourceLabels struct { + Description string `json:"description"` + Secrecy []string `json:"secrecy"` + Integrity []string `json:"integrity"` +} + +type LabelResponseOutput struct { + Items []LabeledItem `json:"items,omitempty"` +} + +type LabeledItem struct { + Data interface{} `json:"data"` + Labels ResourceLabels `json:"labels"` +} + +// label_resource is called by the gateway to label a resource before access +// +//export label_resource +func labelResource(inputPtr, inputLen, outputPtr, outputSize uint32) int32 { + // Read input JSON from WASM memory + input := readBytes(inputPtr, inputLen) + var req LabelResourceInput + if err := json.Unmarshal(input, &req); err != nil { + return -1 + } + + // Default labels + output := LabelResourceOutput{ + Resource: ResourceLabels{ + Description: fmt.Sprintf("resource:%s", req.ToolName), + Secrecy: []string{"public"}, + Integrity: []string{"untrusted"}, + }, + Operation: "read", + } + + // Determine labels based on tool name + switch req.ToolName { + case "create_issue", "update_issue", "create_pull_request": + output.Operation = "write" + output.Resource.Integrity = []string{"contributor"} + + case "merge_pull_request": + output.Operation = "read-write" + output.Resource.Integrity = []string{"maintainer"} + + case "list_issues", "get_issue", "list_pull_requests": + output.Operation = "read" + + // Call backend to check repository visibility + // This demonstrates calling the backend from within the WASM guard + if owner, ok := req.ToolArgs["owner"].(string); ok { + if repo, ok := req.ToolArgs["repo"].(string); ok { + // Call the backend via host function + repoInfo, err := callBackendHelper("search_repositories", map[string]interface{}{ + "query": fmt.Sprintf("repo:%s/%s", owner, repo), + }) + + if err == nil { + // Check if repository is private + if repoData, ok := repoInfo.(map[string]interface{}); ok { + if items, ok := repoData["items"].([]interface{}); ok && len(items) > 0 { + if firstItem, ok := items[0].(map[string]interface{}); ok { + if private, ok := firstItem["private"].(bool); ok && private { + // Repository is private + output.Resource.Secrecy = []string{"repo_private"} + } + } + } + } + } + } + } + } + + // Marshal output + outputJSON, err := json.Marshal(output) + if err != nil { + return -1 + } + + // Check output size + if uint32(len(outputJSON)) > outputSize { + return -1 // Output too large + } + + // Write output to WASM memory + writeBytes(outputPtr, outputJSON) + return int32(len(outputJSON)) +} + +// label_response is called by the gateway to label response data +// +//export label_response +func labelResponse(inputPtr, inputLen, outputPtr, outputSize uint32) int32 { + // Read input JSON from WASM memory + input := readBytes(inputPtr, inputLen) + var req LabelResponseInput + if err := json.Unmarshal(input, &req); err != nil { + return -1 + } + + // For this sample, we don't do fine-grained labeling + // Return 0 to indicate no fine-grained labeling + return 0 +} + +// Helper functions + +func readBytes(ptr, length uint32) []byte { + return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), length) +} + +func writeBytes(ptr uint32, data []byte) { + dest := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), len(data)) + copy(dest, data) +} + +// callBackendHelper wraps the call_backend host function with a nicer interface +func callBackendHelper(toolName string, args interface{}) (interface{}, error) { + // Marshal args to JSON + argsJSON, err := json.Marshal(args) + if err != nil { + return nil, fmt.Errorf("failed to marshal args: %w", err) + } + + // Allocate buffers + toolNameBytes := []byte(toolName) + resultBuf := make([]byte, 1024*1024) // 1MB result buffer + + // Get pointers + var toolNamePtr, argsJSONPtr *byte + if len(toolNameBytes) > 0 { + toolNamePtr = &toolNameBytes[0] + } + if len(argsJSON) > 0 { + argsJSONPtr = &argsJSON[0] + } + + // Call the host function + resultLen := callBackend( + uint32(uintptr(unsafe.Pointer(toolNamePtr))), + uint32(len(toolNameBytes)), + uint32(uintptr(unsafe.Pointer(argsJSONPtr))), + uint32(len(argsJSON)), + uint32(uintptr(unsafe.Pointer(&resultBuf[0]))), + uint32(len(resultBuf)), + ) + + if resultLen < 0 { + return nil, fmt.Errorf("backend call failed with error code: %d", resultLen) + } + + // Parse result + var result interface{} + if err := json.Unmarshal(resultBuf[:resultLen], &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal backend result: %w", err) + } + + return result, nil +} + +func main() { + // Required for WASM compilation, but not called when used as a library +} diff --git a/go.mod b/go.mod index d1f49b13..12de9e78 100644 --- a/go.mod +++ b/go.mod @@ -13,4 +13,18 @@ require ( github.com/itchyny/gojq v0.12.18 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/stretchr/testify v1.11.1 + github.com/tetratelabs/wazero v1.11.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/jsonschema-go v0.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.7 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.39.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d4512b7e..ff2d4bd1 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,18 @@ -cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/itchyny/go-yaml v0.0.0-20251001235044-fca9a0999f15/go.mod h1:Tmbz8uw5I/I6NvVpEGuhzlElCGS5hPoXJkt7l+ul6LE= github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -35,103 +24,22 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= 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= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index e9e00222..f4157673 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ var logConfig = logger.New("config:config") // Config represents the MCPG configuration type Config struct { Servers map[string]*ServerConfig `toml:"servers"` + Guards map[string]*GuardConfig `toml:"guards"` // Guard configurations (optional, experimental) EnableDIFC bool `toml:"enable_difc"` // When true, enables DIFC enforcement and requires sys___init call before tool access. Default is false for standard MCP client compatibility. Gateway *GatewayConfig `toml:"gateway"` // Gateway configuration (port, API key, etc.) } @@ -41,11 +42,20 @@ type ServerConfig struct { Headers map[string]string `toml:"headers"` // HTTP headers for authentication // Tool filtering (applies to both stdio and http servers) Tools []string `toml:"tools"` // Tool filter: ["*"] for all tools, or list of specific tool names + // Guard binding (optional, experimental) + Guard string `toml:"guard"` // Guard ID to use for this server (references a guard in the guards section) +} + +// GuardConfig represents a DIFC guard configuration (experimental) +type GuardConfig struct { + Type string `toml:"type"` // "wasm" for WebAssembly guards + Path string `toml:"path"` // Path to WASM file } // StdinConfig represents JSON configuration from stdin type StdinConfig struct { MCPServers map[string]*StdinServerConfig `json:"mcpServers"` + Guards map[string]*StdinGuardConfig `json:"guards,omitempty"` // Guard configurations (optional, experimental) Gateway *StdinGatewayConfig `json:"gateway,omitempty"` CustomSchemas map[string]string `json:"customSchemas,omitempty"` // Map of custom server type names to JSON Schema URLs } @@ -63,6 +73,13 @@ type StdinServerConfig struct { URL string `json:"url,omitempty"` // For HTTP-based MCP servers Headers map[string]string `json:"headers,omitempty"` // HTTP headers for authentication Tools []string `json:"tools,omitempty"` // Tool filter: ["*"] for all tools, or list of specific tool names + Guard string `json:"guard,omitempty"` // Guard ID to use for this server (references a guard in the guards section) +} + +// StdinGuardConfig represents a DIFC guard configuration from stdin JSON (experimental) +type StdinGuardConfig struct { + Type string `json:"type"` // "wasm" for WebAssembly guards + Path string `json:"path"` // Path to WASM file } // StdinGatewayConfig represents gateway configuration from stdin JSON @@ -269,10 +286,38 @@ func LoadFromStdin() (*Config, error) { Args: args, Env: make(map[string]string), Tools: server.Tools, + Guard: server.Guard, // Bind guard to server + } + } + + // Convert guards configuration + if len(stdinCfg.Guards) > 0 { + cfg.Guards = make(map[string]*GuardConfig) + for name, guard := range stdinCfg.Guards { + logConfig.Printf("Processing guard: name=%s, type=%s", name, guard.Type) + + // Validate guard type + if guard.Type != "wasm" { + return nil, fmt.Errorf("guard '%s': unsupported type '%s' (only 'wasm' is supported)", name, guard.Type) + } + + // Validate path + if guard.Path == "" { + return nil, fmt.Errorf("guard '%s': path is required for wasm guards", name) + } + + // Expand path (support ${VAR} syntax) + expandedPath := os.ExpandEnv(guard.Path) + + cfg.Guards[name] = &GuardConfig{ + Type: guard.Type, + Path: expandedPath, + } + logConfig.Printf("Configured WASM guard: name=%s, path=%s", name, expandedPath) } } - logConfig.Printf("Converted stdin config to internal format with %d servers", len(cfg.Servers)) + logConfig.Printf("Converted stdin config to internal format with %d servers and %d guards", len(cfg.Servers), len(cfg.Guards)) return cfg, nil } diff --git a/internal/guard/wasm.go b/internal/guard/wasm.go new file mode 100644 index 00000000..f50b219b --- /dev/null +++ b/internal/guard/wasm.go @@ -0,0 +1,474 @@ +package guard + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/githubnext/gh-aw-mcpg/internal/difc" + "github.com/githubnext/gh-aw-mcpg/internal/logger" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +var logWasm = logger.New("guard:wasm") + +// WasmGuard implements Guard interface by executing a WASM module in-process +// The WASM module runs sandboxed within the gateway using wazero runtime +// Guards cannot make direct network calls - they receive a BackendCaller interface via host functions +type WasmGuard struct { + name string + runtime wazero.Runtime + module api.Module + + // Backend caller provided to the guard via host functions + backend BackendCaller + ctx context.Context +} + +// NewWasmGuard creates a new WASM guard from a WASM binary file +func NewWasmGuard(ctx context.Context, name string, wasmPath string, backend BackendCaller) (*WasmGuard, error) { + logWasm.Printf("Creating WASM guard: name=%s, path=%s", name, wasmPath) + + // Read WASM binary + wasmBytes, err := os.ReadFile(wasmPath) + if err != nil { + return nil, fmt.Errorf("failed to read WASM file: %w", err) + } + + // Create WASM runtime + runtime := wazero.NewRuntime(ctx) + + // Instantiate WASI + if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + runtime.Close(ctx) + return nil, fmt.Errorf("failed to instantiate WASI: %w", err) + } + + guard := &WasmGuard{ + name: name, + runtime: runtime, + backend: backend, + ctx: ctx, + } + + // Create host functions for the guard to call + if err := guard.instantiateHostFunctions(ctx); err != nil { + runtime.Close(ctx) + return nil, fmt.Errorf("failed to instantiate host functions: %w", err) + } + + // Compile and instantiate the WASM module + module, err := runtime.InstantiateWithConfig(ctx, wasmBytes, + wazero.NewModuleConfig().WithName("guard").WithStartFunctions()) + if err != nil { + runtime.Close(ctx) + return nil, fmt.Errorf("failed to instantiate WASM module: %w", err) + } + + guard.module = module + + // Verify required functions are exported + labelResourceFn := module.ExportedFunction("label_resource") + labelResponseFn := module.ExportedFunction("label_response") + + if labelResourceFn == nil || labelResponseFn == nil { + runtime.Close(ctx) + + // Check if this was compiled with standard Go (only _start is exported) + if module.ExportedFunction("_start") != nil && labelResourceFn == nil { + return nil, fmt.Errorf("WASM module does not export guard functions. " + + "This usually means the guard was compiled with standard Go instead of TinyGo. " + + "TinyGo is required for proper function exports. " + + "Note: TinyGo 0.34 supports Go 1.19-1.23 (not yet compatible with Go 1.25). " + + "See examples/guards/sample-guard/README.md for details") + } + + return nil, fmt.Errorf("WASM module must export label_resource and label_response functions") + } + + logWasm.Printf("WASM guard created successfully: name=%s", name) + return guard, nil +} + +// instantiateHostFunctions creates the host functions that the WASM module can call +func (g *WasmGuard) instantiateHostFunctions(ctx context.Context) error { + // Create a host module with functions the guard can call + _, err := g.runtime.NewHostModuleBuilder("env"). + NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(g.hostCallBackend), []api.ValueType{ + api.ValueTypeI32, // ptr to tool name + api.ValueTypeI32, // tool name length + api.ValueTypeI32, // ptr to args JSON + api.ValueTypeI32, // args length + api.ValueTypeI32, // ptr to result buffer + api.ValueTypeI32, // result buffer size + }, []api.ValueType{api.ValueTypeI32}). // returns result length or negative error + Export("call_backend"). + Instantiate(ctx) + + return err +} + +// hostCallBackend is called by the WASM module to make backend MCP calls +func (g *WasmGuard) hostCallBackend(ctx context.Context, m api.Module, stack []uint64) { + toolNamePtr := uint32(stack[0]) + toolNameLen := uint32(stack[1]) + argsPtr := uint32(stack[2]) + argsLen := uint32(stack[3]) + resultPtr := uint32(stack[4]) + resultSize := uint32(stack[5]) + + // Helper to set error return value + setError := func() { + stack[0] = uint64(^uint32(0)) // Max uint32 represents error + } + + // Read tool name from WASM memory + toolNameBytes, ok := m.Memory().Read(toolNamePtr, toolNameLen) + if !ok { + setError() + return + } + toolName := string(toolNameBytes) + + // Read args JSON from WASM memory + argsBytes, ok := m.Memory().Read(argsPtr, argsLen) + if !ok { + setError() + return + } + + // Parse args + var args interface{} + if len(argsBytes) > 0 { + if err := json.Unmarshal(argsBytes, &args); err != nil { + logWasm.Printf("Failed to unmarshal backend call args: %v", err) + setError() + return + } + } + + logWasm.Printf("WASM guard calling backend: tool=%s", toolName) + + // Call backend + result, err := g.backend.CallTool(ctx, toolName, args) + if err != nil { + logWasm.Printf("Backend call failed: %v", err) + setError() + return + } + + // Marshal result to JSON + resultJSON, err := json.Marshal(result) + if err != nil { + logWasm.Printf("Failed to marshal backend result: %v", err) + setError() + return + } + + // Check if result fits in buffer + if uint32(len(resultJSON)) > resultSize { + logWasm.Printf("Result too large: %d > %d", len(resultJSON), resultSize) + setError() + return + } + + // Write result to WASM memory + if !m.Memory().Write(resultPtr, resultJSON) { + logWasm.Printf("Failed to write result to WASM memory") + setError() + return + } + + // Return result length + stack[0] = uint64(uint32(len(resultJSON))) +} + +// Name returns the identifier for this guard +func (g *WasmGuard) Name() string { + return g.name +} + +// LabelResource calls the WASM module's label_resource function +func (g *WasmGuard) LabelResource(ctx context.Context, toolName string, args interface{}, backend BackendCaller, caps *difc.Capabilities) (*difc.LabeledResource, difc.OperationType, error) { + logWasm.Printf("LabelResource called: toolName=%s", toolName) + + // Update backend caller for this request + g.backend = backend + + // Prepare input + input := map[string]interface{}{ + "tool_name": toolName, + "tool_args": args, + } + if caps != nil { + input["capabilities"] = caps + } + + inputJSON, err := json.Marshal(input) + if err != nil { + return nil, difc.OperationWrite, fmt.Errorf("failed to marshal input: %w", err) + } + + // Call WASM function + resultJSON, err := g.callWasmFunction("label_resource", inputJSON) + if err != nil { + return nil, difc.OperationWrite, err + } + + // Parse result + var response map[string]interface{} + if err := json.Unmarshal(resultJSON, &response); err != nil { + return nil, difc.OperationWrite, fmt.Errorf("failed to unmarshal WASM response: %w", err) + } + + return parseResourceResponse(response) +} + +// LabelResponse calls the WASM module's label_response function +func (g *WasmGuard) LabelResponse(ctx context.Context, toolName string, result interface{}, backend BackendCaller, caps *difc.Capabilities) (difc.LabeledData, error) { + logWasm.Printf("LabelResponse called: toolName=%s", toolName) + + // Update backend caller for this request + g.backend = backend + + // Prepare input + input := map[string]interface{}{ + "tool_name": toolName, + "tool_result": result, + } + if caps != nil { + input["capabilities"] = caps + } + + inputJSON, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("failed to marshal input: %w", err) + } + + // Call WASM function + resultJSON, err := g.callWasmFunction("label_response", inputJSON) + if err != nil { + return nil, err + } + + // If empty result, return nil (no fine-grained labeling) + if len(resultJSON) == 0 { + return nil, nil + } + + // Parse result + var responseMap map[string]interface{} + if err := json.Unmarshal(resultJSON, &responseMap); err != nil { + return nil, fmt.Errorf("failed to unmarshal WASM response: %w", err) + } + + // Check if it's a collection + if items, ok := responseMap["items"].([]interface{}); ok && len(items) > 0 { + return parseCollectionLabeledData(items) + } + + // No fine-grained labeling + return nil, nil +} + +// callWasmFunction calls an exported function in the WASM module +func (g *WasmGuard) callWasmFunction(funcName string, inputJSON []byte) ([]byte, error) { + fn := g.module.ExportedFunction(funcName) + if fn == nil { + return nil, fmt.Errorf("function %s not exported from WASM module", funcName) + } + + mem := g.module.Memory() + if mem == nil { + return nil, fmt.Errorf("WASM module has no memory") + } + + // Allocate memory regions + // We use the end of memory for our buffers to avoid conflicts + memSize := mem.Size() + minSize := uint32(4 * 1024 * 1024) // 4MB minimum + + if memSize < minSize { + // Try to grow memory + pages := (minSize - memSize + 65535) / 65536 // Round up to pages + _, success := mem.Grow(pages) + if !success { + return nil, fmt.Errorf("failed to grow WASM memory from %d to %d bytes", memSize, minSize) + } + memSize = mem.Size() + } + + // Use last 2MB for buffers + outputPtr := memSize - 2*1024*1024 + outputSize := uint32(1024 * 1024) + inputPtr := memSize - 1*1024*1024 + + if uint32(len(inputJSON)) > 1024*1024 { + return nil, fmt.Errorf("input too large: %d bytes", len(inputJSON)) + } + + // Write input to WASM memory + if !mem.Write(inputPtr, inputJSON) { + return nil, fmt.Errorf("failed to write input to WASM memory") + } + + // Call the WASM function + results, err := fn.Call(g.ctx, + uint64(inputPtr), + uint64(len(inputJSON)), + uint64(outputPtr), + uint64(outputSize)) + if err != nil { + return nil, fmt.Errorf("WASM function call failed: %w", err) + } + + // Check result (negative = error) + resultLen := int32(results[0]) + if resultLen < 0 { + return nil, fmt.Errorf("WASM function returned error code: %d", resultLen) + } + + if resultLen == 0 { + // Empty result + return []byte{}, nil + } + + // Read output from WASM memory + outputJSON, ok := mem.Read(outputPtr, uint32(resultLen)) + if !ok { + return nil, fmt.Errorf("failed to read output from WASM memory (len=%d)", resultLen) + } + + return outputJSON, nil +} + +// parseResourceResponse converts guard response to LabeledResource +func parseResourceResponse(response map[string]interface{}) (*difc.LabeledResource, difc.OperationType, error) { + resourceData, ok := response["resource"].(map[string]interface{}) + if !ok { + return nil, difc.OperationWrite, fmt.Errorf("invalid resource format in guard response") + } + + resource := &difc.LabeledResource{} + + if desc, ok := resourceData["description"].(string); ok { + resource.Description = desc + } + + // Parse secrecy tags + if secrecy, ok := resourceData["secrecy"].([]interface{}); ok { + tags := make([]difc.Tag, 0, len(secrecy)) + for _, t := range secrecy { + if tagStr, ok := t.(string); ok { + tags = append(tags, difc.Tag(tagStr)) + } + } + resource.Secrecy = *difc.NewSecrecyLabelWithTags(tags) + } else { + resource.Secrecy = *difc.NewSecrecyLabel() + } + + // Parse integrity tags + if integrity, ok := resourceData["integrity"].([]interface{}); ok { + tags := make([]difc.Tag, 0, len(integrity)) + for _, t := range integrity { + if tagStr, ok := t.(string); ok { + tags = append(tags, difc.Tag(tagStr)) + } + } + resource.Integrity = *difc.NewIntegrityLabelWithTags(tags) + } else { + resource.Integrity = *difc.NewIntegrityLabel() + } + + // Parse operation type + operation := difc.OperationWrite // default to most restrictive + if opStr, ok := response["operation"].(string); ok { + switch opStr { + case "read": + operation = difc.OperationRead + case "write": + operation = difc.OperationWrite + case "read-write": + operation = difc.OperationReadWrite + } + } + + return resource, operation, nil +} + +// parseCollectionLabeledData converts an array of items to CollectionLabeledData +func parseCollectionLabeledData(items []interface{}) (*difc.CollectionLabeledData, error) { + collection := &difc.CollectionLabeledData{ + Items: make([]difc.LabeledItem, 0, len(items)), + } + + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + labeledItem := difc.LabeledItem{ + Data: itemMap["data"], + } + + // Parse labels + if labelsData, ok := itemMap["labels"].(map[string]interface{}); ok { + labels := &difc.LabeledResource{} + + if desc, ok := labelsData["description"].(string); ok { + labels.Description = desc + } + + // Parse secrecy tags + if secrecy, ok := labelsData["secrecy"].([]interface{}); ok { + tags := make([]difc.Tag, 0, len(secrecy)) + for _, t := range secrecy { + if tagStr, ok := t.(string); ok { + tags = append(tags, difc.Tag(tagStr)) + } + } + labels.Secrecy = *difc.NewSecrecyLabelWithTags(tags) + } else { + labels.Secrecy = *difc.NewSecrecyLabel() + } + + // Parse integrity tags + if integrity, ok := labelsData["integrity"].([]interface{}); ok { + tags := make([]difc.Tag, 0, len(integrity)) + for _, t := range integrity { + if tagStr, ok := t.(string); ok { + tags = append(tags, difc.Tag(tagStr)) + } + } + labels.Integrity = *difc.NewIntegrityLabelWithTags(tags) + } else { + labels.Integrity = *difc.NewIntegrityLabel() + } + + labeledItem.Labels = labels + } + + collection.Items = append(collection.Items, labeledItem) + } + + return collection, nil +} + +// Close releases WASM runtime resources +func (g *WasmGuard) Close(ctx context.Context) error { + if g.module != nil { + if err := g.module.Close(ctx); err != nil { + logWasm.Printf("Error closing module: %v", err) + } + } + if g.runtime != nil { + return g.runtime.Close(ctx) + } + return nil +} diff --git a/internal/logger/common.go b/internal/logger/common.go index 524bb8e3..cf8fa75b 100644 --- a/internal/logger/common.go +++ b/internal/logger/common.go @@ -64,3 +64,55 @@ func initLogFile(logDir, fileName string, flags int) (*os.File, error) { return file, nil } + +// loggerSetupFunc is a function type that sets up a logger instance after the log file is opened. +// It receives the opened file, logDir, and fileName, and returns the configured logger. +type loggerSetupFunc[T closableLogger] func(file *os.File, logDir, fileName string) (T, error) + +// loggerErrorHandlerFunc is a function type that handles errors during logger initialization. +// It receives the error and returns a configured logger (possibly a fallback) or an error. +type loggerErrorHandlerFunc[T closableLogger] func(err error, logDir, fileName string) (T, error) + +// initLogger is a generic function that handles common logger initialization logic. +// It reduces code duplication across FileLogger, JSONLLogger, and MarkdownLogger initialization. +// +// Type parameters: +// - T: Any type that satisfies the closableLogger constraint +// +// Parameters: +// - logDir: Directory where the log file should be created +// - fileName: Name of the log file +// - flags: File opening flags (e.g., os.O_APPEND, os.O_TRUNC) +// - setup: Function to configure the logger after the file is opened +// - onError: Function to handle initialization errors (can return fallback or error) +// +// Returns: +// - T: The initialized logger instance +// - error: Any error that occurred during initialization +// +// This function: +// 1. Attempts to open the log file with the specified flags +// 2. If successful, calls the setup function to configure the logger +// 3. If unsuccessful, calls the error handler to decide on fallback behavior +func initLogger[T closableLogger]( + logDir, fileName string, + flags int, + setup loggerSetupFunc[T], + onError loggerErrorHandlerFunc[T], +) (T, error) { + file, err := initLogFile(logDir, fileName, flags) + if err != nil { + return onError(err, logDir, fileName) + } + + logger, err := setup(file, logDir, fileName) + if err != nil { + // If setup fails, close the file and return the error + file.Close() + // Return zero value for T (nil for pointer types) + var zero T + return zero, err + } + + return logger, nil +} diff --git a/internal/logger/common_test.go b/internal/logger/common_test.go index d243b314..0d262ab9 100644 --- a/internal/logger/common_test.go +++ b/internal/logger/common_test.go @@ -403,3 +403,311 @@ func TestInitLogFile_ConcurrentCreation(t *testing.T) { } } } + +// Tests for initLogger generic function + +// TestInitLogger_FileLogger verifies that the generic initLogger function +// works correctly for FileLogger initialization +func TestInitLogger_FileLogger(t *testing.T) { + tmpDir := t.TempDir() + logDir := filepath.Join(tmpDir, "logs") + fileName := "test.log" + + // Test successful initialization + logger, err := initLogger( + logDir, fileName, os.O_APPEND, + func(file *os.File, logDir, fileName string) (*FileLogger, error) { + fl := &FileLogger{ + logDir: logDir, + fileName: fileName, + logFile: file, + } + return fl, nil + }, + func(err error, logDir, fileName string) (*FileLogger, error) { + // Should not be called on success + t.Errorf("Error handler should not be called on successful initialization") + return nil, err + }, + ) + + require.NoError(t, err, "initLogger should not return error") + require.NotNil(t, logger, "logger should not be nil") + assert.Equal(t, logDir, logger.logDir, "logDir should match") + assert.Equal(t, fileName, logger.fileName, "fileName should match") + assert.NotNil(t, logger.logFile, "logFile should not be nil") + + // Verify the log file was created + logPath := filepath.Join(logDir, fileName) + _, err = os.Stat(logPath) + assert.NoError(t, err, "Log file should exist") + + // Clean up + logger.Close() +} + +// TestInitLogger_FileLoggerFallback verifies error handling for FileLogger +func TestInitLogger_FileLoggerFallback(t *testing.T) { + // Use a non-writable directory to trigger error + logDir := "/root/nonexistent/directory" + fileName := "test.log" + + errorHandlerCalled := false + + logger, err := initLogger( + logDir, fileName, os.O_APPEND, + func(file *os.File, logDir, fileName string) (*FileLogger, error) { + // Should not be called on error + t.Errorf("Setup handler should not be called on error") + return nil, nil + }, + func(err error, logDir, fileName string) (*FileLogger, error) { + errorHandlerCalled = true + assert.Error(t, err, "Error should be passed to handler") + // Return fallback logger + fl := &FileLogger{ + logDir: logDir, + fileName: fileName, + useFallback: true, + } + return fl, nil + }, + ) + + assert.True(t, errorHandlerCalled, "Error handler should be called") + require.NoError(t, err, "initLogger should not return error for fallback") + require.NotNil(t, logger, "logger should not be nil") + assert.True(t, logger.useFallback, "useFallback should be true") + assert.Nil(t, logger.logFile, "logFile should be nil for fallback") +} + +// TestInitLogger_JSONLLogger verifies that the generic initLogger function +// works correctly for JSONLLogger initialization +func TestInitLogger_JSONLLogger(t *testing.T) { + tmpDir := t.TempDir() + logDir := filepath.Join(tmpDir, "logs") + fileName := "test.jsonl" + + logger, err := initLogger( + logDir, fileName, os.O_APPEND, + func(file *os.File, logDir, fileName string) (*JSONLLogger, error) { + jl := &JSONLLogger{ + logDir: logDir, + fileName: fileName, + logFile: file, + } + return jl, nil + }, + func(err error, logDir, fileName string) (*JSONLLogger, error) { + // Should not be called on success + t.Errorf("Error handler should not be called on successful initialization") + return nil, err + }, + ) + + require.NoError(t, err, "initLogger should not return error") + require.NotNil(t, logger, "logger should not be nil") + assert.Equal(t, logDir, logger.logDir, "logDir should match") + assert.Equal(t, fileName, logger.fileName, "fileName should match") + assert.NotNil(t, logger.logFile, "logFile should not be nil") + + // Verify the log file was created + logPath := filepath.Join(logDir, fileName) + _, err = os.Stat(logPath) + assert.NoError(t, err, "Log file should exist") + + // Clean up + logger.Close() +} + +// TestInitLogger_JSONLLoggerError verifies error handling for JSONLLogger +func TestInitLogger_JSONLLoggerError(t *testing.T) { + // Use a non-writable directory to trigger error + logDir := "/root/nonexistent/directory" + fileName := "test.jsonl" + + errorHandlerCalled := false + + logger, err := initLogger( + logDir, fileName, os.O_APPEND, + func(file *os.File, logDir, fileName string) (*JSONLLogger, error) { + // Should not be called on error + t.Errorf("Setup handler should not be called on error") + return nil, nil + }, + func(err error, logDir, fileName string) (*JSONLLogger, error) { + errorHandlerCalled = true + assert.Error(t, err, "Error should be passed to handler") + // Return error (no fallback for JSONL) + return nil, err + }, + ) + + assert.True(t, errorHandlerCalled, "Error handler should be called") + assert.Error(t, err, "initLogger should return error") + assert.Nil(t, logger, "logger should be nil on error") +} + +// TestInitLogger_MarkdownLogger verifies that the generic initLogger function +// works correctly for MarkdownLogger initialization +func TestInitLogger_MarkdownLogger(t *testing.T) { + tmpDir := t.TempDir() + logDir := filepath.Join(tmpDir, "logs") + fileName := "test.md" + + logger, err := initLogger( + logDir, fileName, os.O_TRUNC, + func(file *os.File, logDir, fileName string) (*MarkdownLogger, error) { + ml := &MarkdownLogger{ + logDir: logDir, + fileName: fileName, + logFile: file, + initialized: false, + } + return ml, nil + }, + func(err error, logDir, fileName string) (*MarkdownLogger, error) { + // Should not be called on success + t.Errorf("Error handler should not be called on successful initialization") + return nil, err + }, + ) + + require.NoError(t, err, "initLogger should not return error") + require.NotNil(t, logger, "logger should not be nil") + assert.Equal(t, logDir, logger.logDir, "logDir should match") + assert.Equal(t, fileName, logger.fileName, "fileName should match") + assert.NotNil(t, logger.logFile, "logFile should not be nil") + assert.False(t, logger.initialized, "initialized should be false") + + // Verify the log file was created + logPath := filepath.Join(logDir, fileName) + _, err = os.Stat(logPath) + assert.NoError(t, err, "Log file should exist") + + // Clean up + logger.Close() +} + +// TestInitLogger_MarkdownLoggerFallback verifies error handling for MarkdownLogger +func TestInitLogger_MarkdownLoggerFallback(t *testing.T) { + // Use a non-writable directory to trigger error + logDir := "/root/nonexistent/directory" + fileName := "test.md" + + errorHandlerCalled := false + + logger, err := initLogger( + logDir, fileName, os.O_TRUNC, + func(file *os.File, logDir, fileName string) (*MarkdownLogger, error) { + // Should not be called on error + t.Errorf("Setup handler should not be called on error") + return nil, nil + }, + func(err error, logDir, fileName string) (*MarkdownLogger, error) { + errorHandlerCalled = true + assert.Error(t, err, "Error should be passed to handler") + // Return fallback logger + ml := &MarkdownLogger{ + logDir: logDir, + fileName: fileName, + useFallback: true, + } + return ml, nil + }, + ) + + assert.True(t, errorHandlerCalled, "Error handler should be called") + require.NoError(t, err, "initLogger should not return error for fallback") + require.NotNil(t, logger, "logger should not be nil") + assert.True(t, logger.useFallback, "useFallback should be true") + assert.Nil(t, logger.logFile, "logFile should be nil for fallback") +} + +// TestInitLogger_SetupError verifies that setup errors are handled correctly +func TestInitLogger_SetupError(t *testing.T) { + tmpDir := t.TempDir() + logDir := filepath.Join(tmpDir, "logs") + fileName := "test.log" + + logger, err := initLogger( + logDir, fileName, os.O_APPEND, + func(file *os.File, logDir, fileName string) (*FileLogger, error) { + // Simulate setup error + return nil, assert.AnError + }, + func(err error, logDir, fileName string) (*FileLogger, error) { + // Should not be called for setup errors + t.Errorf("Error handler should not be called for setup errors") + return nil, err + }, + ) + + assert.Error(t, err, "initLogger should return error on setup failure") + assert.Equal(t, assert.AnError, err, "Error should match setup error") + assert.Nil(t, logger, "logger should be nil on setup error") + + // Verify the log file was created but then closed + logPath := filepath.Join(logDir, fileName) + _, err = os.Stat(logPath) + assert.NoError(t, err, "Log file should exist even after setup error") +} + +// TestInitLogger_FileFlags verifies that different file flags are respected +func TestInitLogger_FileFlags(t *testing.T) { + tmpDir := t.TempDir() + logDir := filepath.Join(tmpDir, "logs") + fileName := "test-flags.log" + logPath := filepath.Join(logDir, fileName) + + // Create initial file with some content + err := os.MkdirAll(logDir, 0755) + require.NoError(t, err, "Failed to create log directory") + err = os.WriteFile(logPath, []byte("initial content\n"), 0644) + require.NoError(t, err, "Failed to write initial content") + + // Test O_APPEND - should preserve content + logger1, err := initLogger( + logDir, fileName, os.O_APPEND, + func(file *os.File, logDir, fileName string) (*FileLogger, error) { + // Write additional content + _, err := file.WriteString("appended content\n") + require.NoError(t, err, "Failed to write content") + return &FileLogger{logFile: file}, nil + }, + func(err error, logDir, fileName string) (*FileLogger, error) { + return nil, err + }, + ) + require.NoError(t, err, "initLogger should not return error") + logger1.Close() + + // Read file and verify content was appended + content, err := os.ReadFile(logPath) + require.NoError(t, err, "Failed to read file") + assert.Contains(t, string(content), "initial content", "File should contain initial content") + assert.Contains(t, string(content), "appended content", "File should contain appended content") + + // Test O_TRUNC - should replace content + logger2, err := initLogger( + logDir, fileName, os.O_TRUNC, + func(file *os.File, logDir, fileName string) (*MarkdownLogger, error) { + // Write new content + _, err := file.WriteString("new content\n") + require.NoError(t, err, "Failed to write content") + return &MarkdownLogger{logFile: file}, nil + }, + func(err error, logDir, fileName string) (*MarkdownLogger, error) { + return nil, err + }, + ) + require.NoError(t, err, "initLogger should not return error") + logger2.Close() + + // Read file and verify content was truncated + content, err = os.ReadFile(logPath) + require.NoError(t, err, "Failed to read file") + assert.NotContains(t, string(content), "initial content", "File should not contain initial content") + assert.NotContains(t, string(content), "appended content", "File should not contain appended content") + assert.Contains(t, string(content), "new content", "File should contain new content") +} diff --git a/internal/logger/file_logger.go b/internal/logger/file_logger.go index f01917db..6f4f60ee 100644 --- a/internal/logger/file_logger.go +++ b/internal/logger/file_logger.go @@ -28,30 +28,35 @@ var ( // InitFileLogger initializes the global file logger // If the log directory doesn't exist and can't be created, falls back to stdout func InitFileLogger(logDir, fileName string) error { - fl := &FileLogger{ - logDir: logDir, - fileName: fileName, - } - - // Try to initialize the log file - file, err := initLogFile(logDir, fileName, os.O_APPEND) - if err != nil { - // File initialization failed - fallback to stdout - log.Printf("WARNING: Failed to initialize log file: %v", err) - log.Printf("WARNING: Falling back to stdout for logging") - fl.useFallback = true - fl.logger = log.New(os.Stdout, "", 0) // We'll add our own timestamp - initGlobalFileLogger(fl) - return nil - } - - fl.logFile = file - fl.logger = log.New(file, "", 0) - - log.Printf("Logging to file: %s", filepath.Join(logDir, fileName)) - - initGlobalFileLogger(fl) - return nil + logger, err := initLogger( + logDir, fileName, os.O_APPEND, + // Setup function: configure the logger after file is opened + func(file *os.File, logDir, fileName string) (*FileLogger, error) { + fl := &FileLogger{ + logDir: logDir, + fileName: fileName, + logFile: file, + logger: log.New(file, "", 0), + } + log.Printf("Logging to file: %s", filepath.Join(logDir, fileName)) + return fl, nil + }, + // Error handler: fallback to stdout on error + func(err error, logDir, fileName string) (*FileLogger, error) { + log.Printf("WARNING: Failed to initialize log file: %v", err) + log.Printf("WARNING: Falling back to stdout for logging") + fl := &FileLogger{ + logDir: logDir, + fileName: fileName, + useFallback: true, + logger: log.New(os.Stdout, "", 0), // We'll add our own timestamp + } + return fl, nil + }, + ) + + initGlobalFileLogger(logger) + return err } // Close closes the log file diff --git a/internal/logger/jsonl_logger.go b/internal/logger/jsonl_logger.go index 4308089e..5ed1b6d5 100644 --- a/internal/logger/jsonl_logger.go +++ b/internal/logger/jsonl_logger.go @@ -37,22 +37,32 @@ type JSONLRPCMessage struct { // InitJSONLLogger initializes the global JSONL logger func InitJSONLLogger(logDir, fileName string) error { - jl := &JSONLLogger{ - logDir: logDir, - fileName: fileName, + logger, err := initLogger( + logDir, fileName, os.O_APPEND, + // Setup function: configure the logger after file is opened + func(file *os.File, logDir, fileName string) (*JSONLLogger, error) { + jl := &JSONLLogger{ + logDir: logDir, + fileName: fileName, + logFile: file, + encoder: json.NewEncoder(file), + } + return jl, nil + }, + // Error handler: return error immediately (no fallback) + func(err error, logDir, fileName string) (*JSONLLogger, error) { + return nil, err + }, + ) + + // Only initialize global logger if successful (no error) + // Unlike FileLogger/MarkdownLogger which return fallback loggers, + // JSONLLogger has no fallback mode, so we should not initialize + // the global logger when initialization fails + if err == nil { + initGlobalJSONLLogger(logger) } - - // Try to initialize the log file - file, err := initLogFile(logDir, fileName, os.O_APPEND) - if err != nil { - return err - } - - jl.logFile = file - jl.encoder = json.NewEncoder(file) - - initGlobalJSONLLogger(jl) - return nil + return err } // Close closes the JSONL log file diff --git a/internal/logger/markdown_logger.go b/internal/logger/markdown_logger.go index 36db1786..60e52a36 100644 --- a/internal/logger/markdown_logger.go +++ b/internal/logger/markdown_logger.go @@ -26,25 +26,31 @@ var ( // InitMarkdownLogger initializes the global markdown logger func InitMarkdownLogger(logDir, fileName string) error { - ml := &MarkdownLogger{ - logDir: logDir, - fileName: fileName, - } - - // Try to initialize the log file - file, err := initLogFile(logDir, fileName, os.O_TRUNC) - if err != nil { - // File initialization failed - set fallback mode - ml.useFallback = true - initGlobalMarkdownLogger(ml) - return nil - } - - ml.logFile = file - ml.initialized = false // Will be initialized on first write - - initGlobalMarkdownLogger(ml) - return nil + logger, err := initLogger( + logDir, fileName, os.O_TRUNC, + // Setup function: configure the logger after file is opened + func(file *os.File, logDir, fileName string) (*MarkdownLogger, error) { + ml := &MarkdownLogger{ + logDir: logDir, + fileName: fileName, + logFile: file, + initialized: false, // Will be initialized on first write + } + return ml, nil + }, + // Error handler: set fallback mode (no stdout redirect) + func(err error, logDir, fileName string) (*MarkdownLogger, error) { + ml := &MarkdownLogger{ + logDir: logDir, + fileName: fileName, + useFallback: true, + } + return ml, nil + }, + ) + + initGlobalMarkdownLogger(logger) + return err } // initializeFile writes the HTML details header on first write diff --git a/internal/server/unified.go b/internal/server/unified.go index cab380c8..331f28e2 100644 --- a/internal/server/unified.go +++ b/internal/server/unified.go @@ -101,7 +101,7 @@ type UnifiedServer struct { // NewUnified creates a new unified MCP server func NewUnified(ctx context.Context, cfg *config.Config) (*UnifiedServer, error) { - logUnified.Printf("Creating new unified server: enableDIFC=%v, servers=%d", cfg.EnableDIFC, len(cfg.Servers)) + logUnified.Printf("Creating new unified server: enableDIFC=%v, servers=%d, guards=%d", cfg.EnableDIFC, len(cfg.Servers), len(cfg.Guards)) l := launcher.New(ctx, cfg) us := &UnifiedServer{ @@ -129,7 +129,7 @@ func NewUnified(ctx context.Context, cfg *config.Config) (*UnifiedServer, error) // Register guards for all backends for _, serverID := range l.ServerIDs() { - us.registerGuard(serverID) + us.registerGuard(serverID, cfg) } // Register aggregated tools from all backends @@ -444,13 +444,56 @@ func (us *UnifiedServer) registerSysTools() error { } // registerGuard registers a guard for a specific backend server -func (us *UnifiedServer) registerGuard(serverID string) { - // For now, use noop guards for all servers - // In the future, this will load guards based on configuration - // or use guard.CreateGuard() with a guard name from config - g := guard.NewNoopGuard() - us.guardRegistry.Register(serverID, g) - log.Printf("[DIFC] Registered guard '%s' for server '%s'", g.Name(), serverID) +func (us *UnifiedServer) registerGuard(serverID string, cfg *config.Config) { + // Check if server specifies a guard binding + serverCfg, ok := cfg.Servers[serverID] + if !ok || serverCfg.Guard == "" { + // No guard binding, use noop guard + g := guard.NewNoopGuard() + us.guardRegistry.Register(serverID, g) + log.Printf("[DIFC] Registered noop guard for server '%s'", serverID) + return + } + + guardID := serverCfg.Guard + + // Check if we have a guard configuration + guardCfg, ok := cfg.Guards[guardID] + if !ok { + // Guard not configured, use noop guard as fallback + log.Printf("[DIFC] Warning: guard '%s' specified for server '%s' but not configured, using noop guard", guardID, serverID) + g := guard.NewNoopGuard() + us.guardRegistry.Register(serverID, g) + return + } + + // Create appropriate guard based on type + switch guardCfg.Type { + case "wasm": + // Create backend caller for this guard + backendCaller := &guardBackendCaller{ + server: us, + serverID: serverID, + ctx: us.ctx, + } + + // Create WASM guard + wasmGuard, err := guard.NewWasmGuard(us.ctx, guardID, guardCfg.Path, backendCaller) + if err != nil { + log.Printf("[DIFC] Failed to create WASM guard '%s': %v, using noop guard", guardID, err) + g := guard.NewNoopGuard() + us.guardRegistry.Register(serverID, g) + return + } + + us.guardRegistry.Register(serverID, wasmGuard) + log.Printf("[DIFC] Registered WASM guard '%s' for server '%s' (path: %s)", guardID, serverID, guardCfg.Path) + + default: + log.Printf("[DIFC] Warning: unsupported guard type '%s' for guard '%s', using noop guard", guardCfg.Type, guardID) + g := guard.NewNoopGuard() + us.guardRegistry.Register(serverID, g) + } } // guardBackendCaller implements guard.BackendCaller for guards to query backend metadata diff --git a/test/integration/wasm_guard_test.go b/test/integration/wasm_guard_test.go new file mode 100644 index 00000000..f77e1f58 --- /dev/null +++ b/test/integration/wasm_guard_test.go @@ -0,0 +1,433 @@ +package integration + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/githubnext/gh-aw-mcpg/internal/config" + "github.com/githubnext/gh-aw-mcpg/internal/difc" + "github.com/githubnext/gh-aw-mcpg/internal/guard" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockBackendCaller implements guard.BackendCaller for testing +type mockBackendCaller struct { + calls []mockCall +} + +type mockCall struct { + toolName string + args interface{} + result interface{} + err error +} + +func (m *mockBackendCaller) CallTool(ctx context.Context, toolName string, args interface{}) (interface{}, error) { + // Record the call + call := mockCall{ + toolName: toolName, + args: args, + } + + // Return mock data based on tool name + switch toolName { + case "search_repositories": + // Mock a private repository response + call.result = map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "name": "test-repo", + "private": true, + "owner": map[string]interface{}{ + "login": "test-owner", + }, + }, + }, + } + case "get_issue": + // Mock issue response + call.result = map[string]interface{}{ + "number": 42, + "title": "Test Issue", + "state": "open", + } + default: + call.result = map[string]interface{}{} + } + + m.calls = append(m.calls, call) + return call.result, call.err +} + +// isTinyGoAvailable checks if TinyGo is available and compatible +func isTinyGoAvailable() bool { + cmd := exec.Command("tinygo", "version") + return cmd.Run() == nil +} + +// getGo123Binary returns the command to use for Go 1.23 +func getGo123Binary() string { + binaries := []string{"go1.23", "go1.23.9", "go1.23.10", "go1.23.8"} + for _, bin := range binaries { + if _, err := exec.LookPath(bin); err == nil { + return bin + } + } + return "" +} + +// buildWasmGuard builds the sample guard with TinyGo + Go 1.23 if available +func buildWasmGuard(t *testing.T) string { + guardDir := filepath.Join("..", "..", "examples", "guards", "sample-guard") + wasmFile := filepath.Join(guardDir, "guard.wasm") + + // Clean up any existing wasm file + os.Remove(wasmFile) + + // Try to compile with TinyGo first + // TinyGo needs Go 1.23 for compatibility (doesn't support Go 1.25 yet) + if isTinyGoAvailable() { + // Try with Go 1.23 if available + go123 := getGo123Binary() + if go123 != "" { + t.Logf("Found Go 1.23: %s", go123) + cmd := exec.Command("tinygo", "build", "-o", "guard.wasm", "-target=wasi", "main.go") + cmd.Dir = guardDir + // Set GOROOT to use Go 1.23 + goRootCmd := exec.Command(go123, "env", "GOROOT") + goRootBytes, err := goRootCmd.Output() + if err == nil { + cmd.Env = append(os.Environ(), "GOROOT="+strings.TrimSpace(string(goRootBytes))) + output, err := cmd.CombinedOutput() + if err == nil { + t.Logf("✓ Successfully built guard with TinyGo using %s", go123) + return wasmFile + } + t.Logf("TinyGo build with %s failed: %s", go123, output) + } + } else { + t.Log("Go 1.23 not found - install with: go install golang.org/dl/go1.23.9@latest && go1.23.9 download") + } + + // Try with default Go version + cmd := exec.Command("tinygo", "build", "-o", "guard.wasm", "-target=wasi", "main.go") + cmd.Dir = guardDir + output, err := cmd.CombinedOutput() + if err == nil { + t.Log("Successfully built guard with TinyGo") + return wasmFile + } + t.Logf("TinyGo build failed (may not support current Go version): %s", output) + } + + // Fall back to standard Go (won't work but useful for testing error handling) + cmd := exec.Command("make", "build") + cmd.Dir = guardDir + output, err := cmd.CombinedOutput() + if err != nil { + t.Logf("Standard Go build output: %s", output) + t.Logf("Note: Standard Go WASM will not export guard functions properly") + } + + return wasmFile +} + +// TestWasmGuardCompilation tests that the sample guard can be compiled +func TestWasmGuardCompilation(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + wasmFile := buildWasmGuard(t) + defer os.Remove(wasmFile) + + // Verify the WASM file exists + _, err := os.Stat(wasmFile) + require.NoError(t, err, "WASM file not created") +} + +// TestWasmGuardLoading tests loading a WASM guard +func TestWasmGuardLoading(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + if !isTinyGoAvailable() { + t.Skip("TinyGo not available or not compatible with Go 1.25 - skipping WASM guard tests") + } + + wasmFile := buildWasmGuard(t) + defer os.Remove(wasmFile) + + // Create a mock backend caller + backend := &mockBackendCaller{} + + // Create a WASM guard + ctx := context.Background() + wasmGuard, err := guard.NewWasmGuard(ctx, "test-guard", wasmFile, backend) + + if err != nil { + // If standard Go was used, we expect this error + if !isTinyGoAvailable() { + t.Logf("Expected error with standard Go WASM: %v", err) + t.Skip("TinyGo required for functional WASM guards") + } + require.NoError(t, err, "Failed to create WASM guard") + } + + if wasmGuard != nil { + defer wasmGuard.Close(ctx) + // Verify guard name + assert.Equal(t, "test-guard", wasmGuard.Name()) + } +} + +// TestWasmGuardLabelResource tests the label_resource function +func TestWasmGuardLabelResource(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + if !isTinyGoAvailable() { + t.Skip("TinyGo not available or not compatible - required for WASM guard function exports") + } + + wasmFile := buildWasmGuard(t) + defer os.Remove(wasmFile) + + // Create a mock backend caller + backend := &mockBackendCaller{} + + // Create a WASM guard + ctx := context.Background() + wasmGuard, err := guard.NewWasmGuard(ctx, "test-guard", wasmFile, backend) + if err != nil { + t.Skipf("Could not create WASM guard (TinyGo may not support Go 1.25): %v", err) + } + defer wasmGuard.Close(ctx) + + tests := []struct { + name string + toolName string + args map[string]interface{} + expectedOperation difc.OperationType + expectedSecrecy []string + expectedIntegrity []string + expectBackendCall bool + }{ + { + name: "create_issue - write operation", + toolName: "create_issue", + args: map[string]interface{}{"title": "Test"}, + expectedOperation: difc.OperationWrite, + expectedSecrecy: []string{"public"}, + expectedIntegrity: []string{"contributor"}, + expectBackendCall: false, + }, + { + name: "merge_pull_request - read-write operation", + toolName: "merge_pull_request", + args: map[string]interface{}{"number": 1}, + expectedOperation: difc.OperationReadWrite, + expectedSecrecy: []string{"public"}, + expectedIntegrity: []string{"maintainer"}, + expectBackendCall: false, + }, + { + name: "list_issues - calls backend for repo visibility", + toolName: "list_issues", + args: map[string]interface{}{ + "owner": "test-owner", + "repo": "test-repo", + }, + expectedOperation: difc.OperationRead, + expectedSecrecy: []string{"repo_private"}, // Set via backend call + expectedIntegrity: []string{"untrusted"}, + expectBackendCall: true, + }, + { + name: "list_issues - without owner/repo args", + toolName: "list_issues", + args: map[string]interface{}{}, + expectedOperation: difc.OperationRead, + expectedSecrecy: []string{"public"}, + expectedIntegrity: []string{"untrusted"}, + expectBackendCall: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset backend calls + backend.calls = nil + + // Call LabelResource + resource, operation, err := wasmGuard.LabelResource( + ctx, + tt.toolName, + tt.args, + backend, + difc.NewCapabilities(), + ) + + require.NoError(t, err) + assert.Equal(t, tt.expectedOperation, operation) + + // Check secrecy tags + secrecyTags := resource.Secrecy.Label.GetTags() + for _, expectedTag := range tt.expectedSecrecy { + assert.Contains(t, secrecyTags, difc.Tag(expectedTag), + "Expected secrecy tag %s not found", expectedTag) + } + + // Check integrity tags + integrityTags := resource.Integrity.Label.GetTags() + for _, expectedTag := range tt.expectedIntegrity { + assert.Contains(t, integrityTags, difc.Tag(expectedTag), + "Expected integrity tag %s not found", expectedTag) + } + + // Verify backend call was made if expected + if tt.expectBackendCall { + assert.NotEmpty(t, backend.calls, "Expected backend call but none were made") + if len(backend.calls) > 0 { + assert.Equal(t, "search_repositories", backend.calls[0].toolName) + } + } else { + assert.Empty(t, backend.calls, "Unexpected backend call") + } + }) + } +} + +// TestWasmGuardLabelResponse tests the label_response function +func TestWasmGuardLabelResponse(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + if !isTinyGoAvailable() { + t.Skip("TinyGo not available or not compatible - required for WASM guard function exports") + } + + wasmFile := buildWasmGuard(t) + defer os.Remove(wasmFile) + + // Create a mock backend caller + backend := &mockBackendCaller{} + + // Create a WASM guard + ctx := context.Background() + wasmGuard, err := guard.NewWasmGuard(ctx, "test-guard", wasmFile, backend) + if err != nil { + t.Skipf("Could not create WASM guard: %v", err) + } + defer wasmGuard.Close(ctx) + + // Call LabelResponse + result, err := wasmGuard.LabelResponse( + ctx, + "list_issues", + []interface{}{ + map[string]interface{}{"number": 1, "title": "Issue 1"}, + map[string]interface{}{"number": 2, "title": "Issue 2"}, + }, + backend, + difc.NewCapabilities(), + ) + + require.NoError(t, err) + // Sample guard returns nil (no fine-grained labeling) + assert.Nil(t, result) +} + +// TestWasmGuardConfiguration tests loading guard configuration +func TestWasmGuardConfiguration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // For configuration testing, we just need the file to exist + wasmFile := buildWasmGuard(t) + defer os.Remove(wasmFile) + + // Create a config with guard + absWasmPath, err := filepath.Abs(wasmFile) + require.NoError(t, err) + + stdinConfig := config.StdinConfig{ + MCPServers: map[string]*config.StdinServerConfig{ + "test": { + Type: "stdio", + Container: "test-container", + Guard: "test-guard", + }, + }, + Guards: map[string]*config.StdinGuardConfig{ + "test-guard": { + Type: "wasm", + Path: absWasmPath, + }, + }, + } + + // Convert to JSON and parse + configJSON, err := json.Marshal(stdinConfig) + require.NoError(t, err) + + // This tests that the configuration is valid + var parsed config.StdinConfig + err = json.Unmarshal(configJSON, &parsed) + require.NoError(t, err) + + assert.Equal(t, "wasm", parsed.Guards["test-guard"].Type) + assert.Equal(t, absWasmPath, parsed.Guards["test-guard"].Path) + assert.Equal(t, "test-guard", parsed.MCPServers["test"].Guard) +} + +// TestWasmGuardErrorHandling tests error handling in WASM guards +func TestWasmGuardErrorHandling(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + // Test loading non-existent WASM file + ctx := context.Background() + backend := &mockBackendCaller{} + _, err := guard.NewWasmGuard(ctx, "test-guard", "/nonexistent/guard.wasm", backend) + assert.Error(t, err, "Should fail to load non-existent WASM file") + assert.Contains(t, err.Error(), "failed to read WASM file") +} + +// TestWasmGuardStandardGoError tests the helpful error when using standard Go WASM +func TestWasmGuardStandardGoError(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + guardDir := filepath.Join("..", "..", "examples", "guards", "sample-guard") + wasmFile := filepath.Join(guardDir, "guard.wasm") + + // Build with standard Go (will not export functions) + cmd := exec.Command("sh", "-c", "GOOS=wasip1 GOARCH=wasm go build -o guard.wasm main.go") + cmd.Dir = guardDir + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to compile with standard Go: %s", output) + defer os.Remove(wasmFile) + + // Try to create guard - should fail with helpful error + ctx := context.Background() + backend := &mockBackendCaller{} + _, err = guard.NewWasmGuard(ctx, "test-guard", wasmFile, backend) + + require.Error(t, err) + assert.Contains(t, err.Error(), "TinyGo is required") + assert.Contains(t, err.Error(), "standard Go") + t.Logf("Helpful error message: %v", err) +}