Skip to content

Commit 81bb2f9

Browse files
Copilotlpcox
andauthored
fix: improve WASM guard trap detection and logging for integrity audit W-2
Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/420f2df5-ad00-457a-869c-8f0e41731e6f Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
1 parent 21a01f4 commit 81bb2f9

3 files changed

Lines changed: 111 additions & 8 deletions

File tree

.github/workflows/integrity-filtering-audit.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,25 @@ For each downloaded artifact set, check:
9898
over-filtering or misconfiguration.
9999

100100
3. **Guard errors**: Search gateway logs for `ERROR`, `Phase .* failed`,
101-
`guard not initialized`, or `unknown REST endpoint`.
101+
`guard not initialized`, `unknown REST endpoint`, or `WASM guard trap`.
102102

103-
4. **Scope violations**: Check if any response contains data from repositories
103+
4. **WASM guard panics**: Search for `wasm error:` in gateway logs. A Rust guard
104+
panic produces a `wasm error: unreachable` trap. After such a trap, the guard
105+
marks itself permanently failed and all subsequent requests return an error until
106+
the gateway is restarted. Look for `WASM guard trap` entries in `mcp-gateway.log`.
107+
108+
5. **Scope violations**: Check if any response contains data from repositories
104109
NOT in the workflow's `allowed-repos` policy.
105110

106111
```bash
107112
# Example: Count DIFC events in JSONL
108113
grep -c 'difc_integrity' "$TMPDIR"/*/mcp-logs/rpc-messages.jsonl 2>/dev/null || echo "0"
109114

110-
# Example: Find guard errors
111-
grep -iE 'error|failed|blocked|unknown' "$TMPDIR"/*/mcp-logs/mcp-gateway.log 2>/dev/null | head -20
115+
# Example: Find guard errors (including WASM traps)
116+
grep -iE 'error|failed|blocked|unknown|wasm error:|WASM guard trap' "$TMPDIR"/*/mcp-logs/mcp-gateway.log 2>/dev/null | head -20
117+
118+
# Example: Specifically search for WASM guard panics
119+
grep -iE 'wasm error:|WASM guard trap|unreachable' "$TMPDIR"/*/mcp-logs/mcp-gateway.log 2>/dev/null
112120
```
113121

114122
### Step 4: Classify Findings
@@ -117,7 +125,7 @@ Classify each finding by severity:
117125
- 🔴 **Critical**: Data leak (out-of-scope data returned), guard bypass, or
118126
labeling failure that could expose unauthorized data
119127
- 🟡 **Warning**: Over-filtering (legitimate data blocked), unscoped tags,
120-
or zero DIFC events in a run that should have filtering
128+
zero DIFC events in a run that should have filtering, or WASM guard trap
121129
- 🟢 **Info**: Normal filtering behavior, expected blocks, or configuration notes
122130

123131
### Step 5: Create Summary Issue

internal/guard/wasm.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,19 @@ type WasmGuard struct {
4040
// Backend caller provided to the guard via host functions
4141
backend BackendCaller
4242

43-
// mu serializes all calls to the WASM module
44-
// WASM modules are single-threaded and cannot handle concurrent calls
43+
// mu serializes all calls to the WASM module.
44+
// WASM modules are single-threaded and cannot handle concurrent calls.
45+
// All exported methods (LabelAgent, LabelResource, LabelResponse) hold mu
46+
// for their entire duration, including any nested calls to callWasmFunction.
4547
mu sync.Mutex
48+
49+
// failed and failedErr are set when the WASM module encounters a trap
50+
// (e.g. unreachable instruction from a Rust panic). Once failed, all
51+
// subsequent calls return an error immediately because the module's
52+
// internal state may be corrupted.
53+
// Both fields are accessed only while mu is held.
54+
failed bool
55+
failedErr error
4656
}
4757

4858
// NewWasmGuard creates a new WASM guard from a WASM binary file
@@ -781,8 +791,25 @@ func parsePathLabeledResponse(responseJSON []byte, originalData interface{}) (di
781791
return pld.ToCollectionLabeledData(), nil
782792
}
783793

784-
// callWasmFunction calls an exported function in the WASM module
794+
// isWasmTrap reports whether err is a WASM execution trap such as the
795+
// "wasm error: unreachable" produced when a Rust-compiled guard panics.
796+
func isWasmTrap(err error) bool {
797+
return err != nil && strings.Contains(err.Error(), "wasm error:")
798+
}
799+
800+
// callWasmFunction calls an exported function in the WASM module.
801+
// Precondition: g.mu must be held by the caller. All public methods
802+
// (LabelAgent, LabelResource, LabelResponse) hold g.mu for their entire
803+
// duration, satisfying this requirement.
785804
func (g *WasmGuard) callWasmFunction(ctx context.Context, funcName string, inputJSON []byte) ([]byte, error) {
805+
// If the module has already trapped, refuse further calls immediately.
806+
// A WASM trap may corrupt the module's internal state (e.g. the global
807+
// policy context stored by label_agent), so all subsequent calls are
808+
// unsafe until the guard is reloaded.
809+
if g.failed {
810+
return nil, fmt.Errorf("WASM guard '%s' is unavailable after a previous trap: %w", g.name, g.failedErr)
811+
}
812+
786813
fn := g.module.ExportedFunction(funcName)
787814
if fn == nil {
788815
return nil, fmt.Errorf("function %s not exported from WASM module", funcName)
@@ -809,6 +836,14 @@ func (g *WasmGuard) callWasmFunction(ctx context.Context, funcName string, input
809836
for attempt := 0; attempt < maxRetries; attempt++ {
810837
result, requiredSize, err := g.tryCallWasmFunction(ctx, fn, mem, inputJSON, outputSize)
811838
if err != nil {
839+
if isWasmTrap(err) {
840+
// A WASM trap (e.g. unreachable from a Rust panic) leaves the
841+
// module in an undefined state. Log it prominently and mark the
842+
// guard as permanently failed so callers get a clear error.
843+
logger.LogError("backend", "WASM guard trap: guard=%s, func=%s, error=%v", g.name, funcName, err)
844+
g.failed = true
845+
g.failedErr = err
846+
}
812847
return nil, err
813848
}
814849

internal/guard/wasm_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"encoding/binary"
77
"encoding/json"
8+
"errors"
89
"testing"
910
"time"
1011

@@ -1021,3 +1022,62 @@ func TestJSONMarshaling(t *testing.T) {
10211022
assert.Contains(t, string(inputJSON), "tool_result")
10221023
})
10231024
}
1025+
1026+
func TestIsWasmTrap(t *testing.T) {
1027+
t.Run("nil error is not a trap", func(t *testing.T) {
1028+
assert.False(t, isWasmTrap(nil))
1029+
})
1030+
1031+
t.Run("generic error is not a trap", func(t *testing.T) {
1032+
assert.False(t, isWasmTrap(errors.New("some error")))
1033+
})
1034+
1035+
t.Run("wrapped error containing wasm error is a trap", func(t *testing.T) {
1036+
err := errors.New("WASM function call failed: wasm error: unreachable")
1037+
assert.True(t, isWasmTrap(err))
1038+
})
1039+
1040+
t.Run("wasm error integer divide by zero is a trap", func(t *testing.T) {
1041+
err := errors.New("wasm error: integer divide by zero")
1042+
assert.True(t, isWasmTrap(err))
1043+
})
1044+
1045+
t.Run("wasm error out of bounds is a trap", func(t *testing.T) {
1046+
err := errors.New("wasm error: out of bounds memory access")
1047+
assert.True(t, isWasmTrap(err))
1048+
})
1049+
}
1050+
1051+
func TestWasmGuardFailedState(t *testing.T) {
1052+
t.Run("failed guard returns error immediately for callWasmFunction", func(t *testing.T) {
1053+
// Build a minimal valid WasmGuard by hand to exercise the failed-state path
1054+
// without needing a full WASM binary.
1055+
originalErr := errors.New("WASM function call failed: wasm error: unreachable")
1056+
g := &WasmGuard{
1057+
name: "test-guard",
1058+
failed: true,
1059+
failedErr: originalErr,
1060+
}
1061+
1062+
ctx := context.Background()
1063+
_, err := g.callWasmFunction(ctx, "label_response", []byte(`{}`))
1064+
require.Error(t, err)
1065+
assert.Contains(t, err.Error(), "unavailable after a previous trap")
1066+
assert.Contains(t, err.Error(), "test-guard")
1067+
})
1068+
1069+
t.Run("failed guard wraps original trap error", func(t *testing.T) {
1070+
originalErr := errors.New("WASM function call failed: wasm error: unreachable")
1071+
g := &WasmGuard{
1072+
name: "my-guard",
1073+
failed: true,
1074+
failedErr: originalErr,
1075+
}
1076+
1077+
ctx := context.Background()
1078+
_, err := g.callWasmFunction(ctx, "label_agent", []byte(`{}`))
1079+
require.Error(t, err)
1080+
// The original trap error should be reachable via errors.Is / errors.As
1081+
assert.ErrorIs(t, err, originalErr)
1082+
})
1083+
}

0 commit comments

Comments
 (0)