Skip to content

feat: add proxy mode for GitHub API DIFC filtering#2176

Merged
lpcox merged 10 commits intomainfrom
feat/proxy-mode
Mar 19, 2026
Merged

feat: add proxy mode for GitHub API DIFC filtering#2176
lpcox merged 10 commits intomainfrom
feat/proxy-mode

Conversation

@lpcox
Copy link
Collaborator

@lpcox lpcox commented Mar 19, 2026

Summary

Adds proxy mode (awmg proxy) — an HTTP forward proxy that intercepts GitHub API requests (e.g., from gh CLI) 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 api and gh issue list bypass it entirely. Proxy mode closes this gap by sitting between gh CLI and api.github.com, applying guard policies to raw REST and GraphQL requests.

New Files

File LOC Purpose
internal/proxy/proxy.go 212 Core server, WASM guard loading, upstream forwarding
internal/proxy/router.go 295 ~25 REST URL patterns → guard tool name mapping
internal/proxy/graphql.go 166 GraphQL query → tool name mapping with owner/repo extraction
internal/proxy/handler.go 336 HTTP handler with full 6-phase DIFC pipeline
internal/proxy/proxy_test.go 312 Unit tests (40+ tests for routing and GraphQL parsing)
internal/cmd/proxy.go 141 awmg proxy Cobra subcommand
test/integration/proxy_test.go 578 Integration tests (7 suites against real GitHub API)

Architecture

gh CLI → GH_HOST=proxy:8080 → awmg proxy → api.github.com
                                  ↓
                          6-phase DIFC pipeline
                          (same guard WASM module)

DIFC Pipeline (reuses MCP gateway phases):

  • Phase 0: Extract agent labels
  • Phase 1: Guard.LabelResource() — coarse-grained access check
  • Phase 2: Evaluator.Evaluate() — secrecy/integrity evaluation
  • Phase 3: Forward to GitHub API (proxy-specific — replaces MCP backend call)
  • Phase 4: Guard.LabelResponse() — per-item labeling
  • Phase 5: Evaluator.FilterCollection() — fine-grained filtering

Integration Test Results (7 suites, all passing)

Suite Policy Validates Result
RepoScope repos:["octocat/hello-world"] In-scope returns data, out-of-scope returns [], global APIs blocked ✅ 8/8
OwnerScope repos:["octocat/*"] Wildcard owner matching, cross-owner blocked ✅ 3/3
IntegrityFiltering min-integrity:"approved" Non-collaborator issues filtered, owner commits preserved ✅ 2/2
GraphQL repos:["octocat/hello-world"] In-scope queries, variable-based queries, out-of-scope blocked ✅ 4/4
GhCLI repos:["octocat/hello-world"] gh CLI through proxy (skips — gh requires HTTPS) ⏭️ 2 skipped
HealthAndPassthrough repos:"public" Health endpoint, passthrough mode ✅ 4/4
MultiRepoPolicy Two repos Both accessible, others blocked ✅ 3/3

Container Verified

  • Docker image builds with proxy code ✅
  • awmg proxy works in container via --entrypoint /app/awmg
  • DIFC filtering works end-to-end through containerized proxy ✅
  • findBinary() / findWasmGuard() support container paths + env var overrides ✅

Usage

# Start the proxy
awmg proxy \
  --guard-wasm guards/github-guard/github_guard.wasm \
  --policy '{"allow-only":{"repos":["org/repo"],"min-integrity":"approved"}}' \
  --github-token "$GITHUB_TOKEN" \
  --listen localhost:8080

# Point gh at the proxy
GH_HOST=localhost:8080 gh api /repos/org/repo/issues

Known Limitations

  • gh CLI forces HTTPS to GH_HOST — direct CLI interception requires TLS termination (future work)
  • GraphQL responses are filtered at the top level; nested object filtering depends on guard support
  • Policy repo names must be lowercase (guard validation requirement)

Copilot AI review requested due to automatic review settings March 19, 2026 18:21
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/proxy package implementing request routing (REST/GraphQL), upstream forwarding, and DIFC filtering.
  • Adds a Cobra subcommand awmg proxy to 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 (MatchGraphQL nil → 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.

Comment on lines +69 to +73
match := MatchRoute(path)
if match == nil {
// Unknown REST endpoint — pass through
h.passthrough(w, r, path)
return
copyResponseHeaders(w, resp)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write([]byte("[]"))
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]}
},
},
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{}{}
},
},
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)

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
lpcox and others added 3 commits March 19, 2026 13:01
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>
@lpcox lpcox force-pushed the feat/proxy-mode branch from 1f09cec to 8643a26 Compare March 19, 2026 20:01
- 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>
@lpcox
Copy link
Collaborator Author

lpcox commented Mar 19, 2026

@copilot open a new pull request to apply changes based on the comments in this thread

Copy link
Contributor

Copilot AI commented Mar 19, 2026

@lpcox I've opened a new pull request, #2188, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 5 commits March 19, 2026 21:23
- 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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants