feat: add proxy mode for GitHub API DIFC filtering#2176
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Adds an awmg proxy mode that runs a local HTTP forward proxy for GitHub’s REST/GraphQL API (via GH_HOST), applying the DIFC guard pipeline to gh CLI traffic so pre-agent workflow fetches don’t bypass enforcement.
Changes:
- Introduces a new
internal/proxypackage implementing request routing (REST/GraphQL), upstream forwarding, and DIFC filtering. - Adds a Cobra subcommand
awmg proxyto run the proxy with guard WASM + policy + token configuration. - Adds routing + GraphQL parsing unit tests.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/proxy/router.go | REST path → guard tool-name routing and GH_HOST path prefix stripping |
| internal/proxy/graphql.go | Heuristic GraphQL query → guard tool-name routing and owner/repo extraction |
| internal/proxy/handler.go | HTTP handler implementing the 6-phase DIFC pipeline around upstream forwarding |
| internal/proxy/proxy.go | Proxy server construction: guard loading, policy init, upstream client, backend stub |
| internal/proxy/proxy_test.go | Unit tests for REST routing, prefix stripping, GraphQL matching |
| internal/cmd/proxy.go | awmg proxy CLI wiring, flags, server lifecycle |
Comments suppressed due to low confidence (1)
internal/proxy/handler.go:65
- For unknown GraphQL queries, the handler currently forwards the request without any DIFC enforcement (
MatchGraphQLnil →forwardGraphQL). This creates a policy bypass for any unmapped query and contradicts the intended “fail-safe” behavior. Instead of passthrough, apply a conservative labeling/denial strategy (e.g., map to an explicit "unknown_graphql" tool that the guard treats as maximally secret/lowest integrity, or block/return an empty response in filter mode).
match := MatchGraphQL(graphQLBody)
if match == nil {
// Unknown GraphQL query — pass through with conservative labeling
h.forwardGraphQL(w, r, path, graphQLBody)
return
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
internal/proxy/handler.go
Outdated
Comment on lines
+69
to
+73
| match := MatchRoute(path) | ||
| if match == nil { | ||
| // Unknown REST endpoint — pass through | ||
| h.passthrough(w, r, path) | ||
| return |
internal/proxy/handler.go
Outdated
| copyResponseHeaders(w, resp) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(resp.StatusCode) | ||
| w.Write([]byte("[]")) |
internal/proxy/proxy.go
Outdated
Comment on lines
+99
to
+110
| guard: g, | ||
| evaluator: difc.NewEvaluatorWithMode(difcMode), | ||
| agentRegistry: difc.NewAgentRegistryWithDefaults(nil, nil), | ||
| capabilities: difc.NewCapabilities(), | ||
| githubToken: cfg.GitHubToken, | ||
| githubAPIURL: apiURL, | ||
| httpClient: &http.Client{ | ||
| Timeout: 60 * time.Second, | ||
| Transport: &http.Transport{ | ||
| TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, | ||
| }, | ||
| }, |
Comment on lines
+40
to
+53
| { | ||
| pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/issues/(\d+)/comments$`), | ||
| toolName: "get_comments", | ||
| extractArgs: func(m []string) map[string]interface{} { | ||
| return map[string]interface{}{"owner": m[1], "repo": m[2], "issue_number": m[3]} | ||
| }, | ||
| }, | ||
| { | ||
| pattern: regexp.MustCompile(`^/repos/([^/]+)/([^/]+)/issues/(\d+)/labels$`), | ||
| toolName: "get_labels", | ||
| extractArgs: func(m []string) map[string]interface{} { | ||
| return map[string]interface{}{"owner": m[1], "repo": m[2], "issue_number": m[3]} | ||
| }, | ||
| }, |
internal/proxy/router.go
Outdated
Comment on lines
+239
to
+246
| // User API | ||
| { | ||
| pattern: regexp.MustCompile(`^/user$`), | ||
| toolName: "get_me", | ||
| extractArgs: func(_ []string) map[string]interface{} { | ||
| return map[string]interface{}{} | ||
| }, | ||
| }, |
internal/proxy/handler.go
Outdated
Comment on lines
+90
to
+95
| log.Printf("[proxy] WARNING: guard not initialized, passing through") | ||
| if graphQLBody != nil { | ||
| h.forwardGraphQL(w, r, path, graphQLBody) | ||
| } else { | ||
| h.passthrough(w, r, path) | ||
| } |
Comment on lines
+184
to
+192
| // stubBackendCaller is a no-op BackendCaller for the proxy. | ||
| // The guard receives the full API response in LabelResponse, so it | ||
| // does not need to make recursive backend calls. | ||
| type stubBackendCaller struct{} | ||
|
|
||
| func (s *stubBackendCaller) CallTool(_ context.Context, toolName string, _ interface{}) (interface{}, error) { | ||
| logProxy.Printf("stub BackendCaller: ignoring CallTool(%s) — proxy provides full responses", toolName) | ||
| return nil, fmt.Errorf("CallTool not supported in proxy mode") | ||
| } |
Comment on lines
+23
to
+27
| // Strip the /api/v3 prefix that GH_HOST adds | ||
| path := StripGHHostPrefix(r.URL.Path) | ||
|
|
||
| logHandler.Printf("incoming %s %s", r.Method, path) | ||
|
|
internal/proxy/handler.go
Outdated
Comment on lines
+107
to
+113
| logHandler.Printf("[DIFC] Phase 1 failed: %v", err) | ||
| // On labeling failure, pass through (fail-open for read operations) | ||
| if graphQLBody != nil { | ||
| h.forwardGraphQL(w, r, path, graphQLBody) | ||
| } else { | ||
| h.passthrough(w, r, path) | ||
| } |
| // Parse enforcement mode | ||
| difcMode, err := difc.ParseEnforcementMode(cfg.DIFCMode) | ||
| if err != nil { | ||
| difcMode = difc.EnforcementFilter // default to filter for proxy |
Add an `awmg proxy` subcommand that runs as an HTTP forward proxy,
intercepting gh CLI requests (via GH_HOST redirect) and applying
the same DIFC filtering pipeline as the MCP gateway.
Architecture:
- Reuses the existing WASM guard, DIFC evaluator, agent registry,
and capabilities — ~80% code reuse from the gateway
- Only Phase 3 (backend call) differs: forwards HTTP to GitHub API
instead of calling an MCP backend server
- Stub BackendCaller since the guard gets full API responses in
LabelResponse
New files:
- internal/proxy/proxy.go — core proxy server with guard init
- internal/proxy/handler.go — HTTP handler with 6-phase DIFC pipeline
- internal/proxy/router.go — REST URL pattern → guard tool name mapping
- internal/proxy/graphql.go — GraphQL query → guard tool name mapping
- internal/proxy/proxy_test.go — 40+ test cases for routing/GraphQL
- internal/cmd/proxy.go — `awmg proxy` Cobra subcommand
Usage:
awmg proxy \
--guard-wasm guards/github-guard/github_guard.wasm \
--policy '{"allow-only":{"repos":["org/repo"]}}' \
--github-token "$GITHUB_TOKEN" \
--listen localhost:8080
GH_HOST=localhost:8080 gh issue list -R org/repo
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add 7 integration test suites for proxy mode: - TestProxyRepoScope: repo-scoped allow-only policy - TestProxyOwnerScope: owner-scoped wildcard policy - TestProxyIntegrityFiltering: min-integrity filtering - TestProxyGraphQL: GraphQL query routing and filtering - TestProxyGhCLI: gh CLI through proxy (skips if HTTP unsupported) - TestProxyHealthAndPassthrough: basic proxy operation - TestProxyMultiRepoPolicy: multiple repos in policy - Fix handler to preserve query strings when forwarding to GitHub (was stripping ?q=... params, causing 422 on /search endpoints) - Use lowercase repo names in policies (guard validation requirement) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add AWMG_BINARY_PATH env var override to findBinary() for CI/container use - Add AWMG_WASM_GUARD_PATH env var override to findWasmGuard() for CI/container use - Add container image paths (/app/awmg, /guards/github/00-github-guard.wasm) to search lists for in-container test execution - Verified: Docker image builds, proxy subcommand works in container, DIFC filtering works end-to-end through containerized proxy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Create docs/PROXY_MODE.md with full proxy mode reference: flags, DIFC pipeline, REST route mapping, GraphQL support, policy notes, container usage, and known limitations - Add Proxy Mode section to README.md with quick start example - Link to proxy docs in Further Reading table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Collaborator
Author
|
@copilot open a new pull request to apply changes based on the comments in this thread |
Contributor
- Fail closed (403/502/503) for unknown endpoints, uninitialized guard,
and LabelResource failures instead of passing through unfiltered
- Initialize Server.enforcementMode from parsed CLI/config mode at
construction time; log warning for invalid DIFC mode values
- Fix writeEmptyResponse to return shape-matched empty response:
[] for arrays, {"data":null} for GraphQL objects, {} for other objects
- Map issue comments/labels routes to guard-recognized tool "issue_read"
(was "get_comments"/"get_labels") with method arg
- Remove /user route and viewer GraphQL pattern (guard does not recognize
"get_me"; these now fail closed via unknown-endpoint handler)
Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/0b55b205-bccb-40cb-af8f-963cb6aae51f
…2188) The proxy mode had multiple fail-open paths that could bypass DIFC enforcement: unknown endpoints passed through unfiltered, an uninitialized guard silently forwarded all traffic, `LabelResource` failures forwarded the request, `writeEmptyResponse` always returned `[]` regardless of response shape, and `enforcementMode` was never seeded from the CLI flag. ## Fail-closed enforcement - **Unknown REST endpoints** → `403 Forbidden` (was: passthrough) - **Unknown GraphQL queries** → `{"errors":[{"message":"access denied: ..."}],"data":null}` with 403 (was: passthrough) - **Guard not initialized** → `503 Service Unavailable` (was: passthrough) - **Phase 1 `LabelResource` failure** → `502 Bad Gateway` (was: passthrough) ## Tool name mapping - Issue comments/labels (`/issues/{n}/comments`, `/issues/{n}/labels`) remapped from unrecognized `get_comments`/`get_labels` → `issue_read` with a `method` arg, consistent with MCP server tool naming and guard recognition - `/user` REST route and `viewer` GraphQL pattern **removed** — `get_me` is not recognized by the guard, risking under-labeling of private account data (email, etc.); these paths now hit the fail-closed handler ## Enforcement mode - `Server.enforcementMode` now initialized from parsed CLI/config mode at construction; previously left at zero value (`strict`) unless `LabelAgent` returned an override - Invalid `--guards-mode` values log a warning instead of silently defaulting to `filter` ## Response shape - `writeEmptyResponse` now returns the correct empty shape per upstream response type: `[]` for arrays, `{"data":null}` for GraphQL responses (detected by `"data"` key presence), `{}` for other objects <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs.
…ype switch Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/7feddbce-4f91-48ec-9a03-dba7585fca09
Two staticcheck violations introduced in `internal/proxy/handler.go`
were causing the lint CI job to fail.
## Changes
- **Remove `forwardGraphQL`**: Dead code — defined but never called.
GraphQL forwarding is handled inline in `handleWithDIFC`.
- **Fix S1034 type switch**: Eliminate redundant type assertion inside
the `map[string]interface{}` case by using the idiomatic assigned type
switch form:
```go
// Before
switch originalData.(type) {
case map[string]interface{}:
obj := originalData.(map[string]interface{}) // redundant assertion
...
}
// After
switch obj := originalData.(type) {
case map[string]interface{}:
// obj is already the asserted type
...
}
```
> [!WARNING]
>
> <details>
> <summary>Firewall rules blocked me from connecting to one or more
addresses (expand for details)</summary>
>
> #### I tried to connect to the following addresses, but was blocked by
firewall rules:
>
> - `example.com`
> - Triggering command: `/tmp/go-build1696049945/b333/launcher.test
/tmp/go-build1696049945/b333/launcher.test
-test.testlogfile=/tmp/go-build1696049945/b333/testlog.txt
-test.paniconexit0 -test.timeout=10m0s
/tmp/go-build1696049945/b238/vet.cfg go o x_amd64/compile` (dns block)
> - `invalid-host-that-does-not-exist-12345.com`
> - Triggering command: `/tmp/go-build1696049945/b318/config.test
/tmp/go-build1696049945/b318/config.test
-test.testlogfile=/tmp/go-build1696049945/b318/testlog.txt
-test.paniconexit0 -test.timeout=10m0s ncod�� 64/src/runtime/cgo
64/src/encoding/asn1/asn1.go x_amd64/asm` (dns block)
> - `nonexistent.local`
> - Triggering command: `/tmp/go-build1696049945/b333/launcher.test
/tmp/go-build1696049945/b333/launcher.test
-test.testlogfile=/tmp/go-build1696049945/b333/testlog.txt
-test.paniconexit0 -test.timeout=10m0s
/tmp/go-build1696049945/b238/vet.cfg go o x_amd64/compile` (dns block)
> - `slow.example.com`
> - Triggering command: `/tmp/go-build1696049945/b333/launcher.test
/tmp/go-build1696049945/b333/launcher.test
-test.testlogfile=/tmp/go-build1696049945/b333/testlog.txt
-test.paniconexit0 -test.timeout=10m0s
/tmp/go-build1696049945/b238/vet.cfg go o x_amd64/compile` (dns block)
> - `this-host-does-not-exist-12345.com`
> - Triggering command: `/tmp/go-build1696049945/b342/mcp.test
/tmp/go-build1696049945/b342/mcp.test
-test.testlogfile=/tmp/go-build1696049945/b342/testlog.txt
-test.paniconexit0 -test.timeout=10m0s
/tmp/go-build1696049945/b215/vet.cfg p/go-build linux.go x_amd64/compile
--exclude-hidden/opt/hostedtoolcache/go/1.25.8/x64/pkg/tool/linux_amd64/vet
terpreter --quiet x_amd64/compile 2599�� ache/go/1.25.8/x-errorsas
AcPk/Gl1SDLLxjjl-ifaceassert x_amd64/vet` (dns block)
>
> If you need me to access, download, or install something from one of
these locations, you can either:
>
> - Configure [Actions setup
steps](https://gh.io/copilot/actions-setup-steps) to set up my
environment, which run before the firewall is enabled
> - Add the appropriate URLs or hosts to the custom allowlist in this
repository's [Copilot coding agent
settings](https://github.com/github/gh-aw-mcpg/settings/copilot/coding_agent)
(admins only)
>
> </details>
<!-- START COPILOT CODING AGENT SUFFIX -->
<!-- START COPILOT ORIGINAL PROMPT -->
<details>
<summary>Original prompt</summary>
> Fix the failing GitHub Actions workflow lint
> Analyze the workflow logs, identify the root cause of the failure, and
implement a fix.
> Job ID: 67821217215
> Job URL:
https://github.com/github/gh-aw-mcpg/actions/runs/23317651473/job/67821217215
</details>
<!-- START COPILOT CODING AGENT TIPS -->
---
💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.
This was referenced Mar 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds proxy mode (
awmg proxy) — an HTTP forward proxy that intercepts GitHub API requests (e.g., fromghCLI) and applies the same DIFC filtering used by the MCP gateway.Motivation
The MCP gateway enforces DIFC on tool calls, but pre-gateway paths like
gh apiandgh issue listbypass it entirely. Proxy mode closes this gap by sitting betweenghCLI andapi.github.com, applying guard policies to raw REST and GraphQL requests.New Files
internal/proxy/proxy.gointernal/proxy/router.gointernal/proxy/graphql.gointernal/proxy/handler.gointernal/proxy/proxy_test.gointernal/cmd/proxy.goawmg proxyCobra subcommandtest/integration/proxy_test.goArchitecture
DIFC Pipeline (reuses MCP gateway phases):
Guard.LabelResource()— coarse-grained access checkEvaluator.Evaluate()— secrecy/integrity evaluationGuard.LabelResponse()— per-item labelingEvaluator.FilterCollection()— fine-grained filteringIntegration Test Results (7 suites, all passing)
repos:["octocat/hello-world"][], global APIs blockedrepos:["octocat/*"]min-integrity:"approved"repos:["octocat/hello-world"]repos:["octocat/hello-world"]ghCLI through proxy (skips — gh requires HTTPS)repos:"public"Container Verified
awmg proxyworks in container via--entrypoint /app/awmg✅findBinary()/findWasmGuard()support container paths + env var overrides ✅Usage
Known Limitations
ghCLI forces HTTPS toGH_HOST— direct CLI interception requires TLS termination (future work)