From 2d97a2ed76063e09f4cdf99d12f3f1410b8df256 Mon Sep 17 00:00:00 2001 From: kanywst Date: Wed, 27 May 2026 23:24:48 +0900 Subject: [PATCH 1/8] feat: initial MCP server fronting an OpenID AuthZEN 1.0 PDP Provides the `authzen_evaluate` tool: POST a subject/resource/action/context bundle to the configured PDP and return the AuthZEN decision JSON. - PDP endpoint configurable via AUTHZEN_PDP_URL env or per-call pdp_url arg - Unit tests cover allow/deny, missing PDP, malformed JSON, 5xx upstream - goreleaser config with SBOM (syft) and keyless cosign signing - CI workflow (vet, test -race, golangci-lint) + release workflow --- .github/workflows/ci.yml | 31 ++++++ .github/workflows/release.yml | 38 +++++++ .goreleaser.yml | 51 +++++++++ go.mod | 14 +++ go.sum | 34 ++++++ main.go | 192 ++++++++++++++++++++++++++++++++++ main_test.go | 131 +++++++++++++++++++++++ 7 files changed, 491 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7112ba6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - run: go vet ./... + - run: go test -race -count=1 -v ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@v6 + with: + version: latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..81ab5a6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + # Required for cosign keyless signing via GitHub OIDC + id-token: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Install syft + uses: anchore/sbom-action/download-syft@v0 + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..cc75ef0 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,51 @@ +version: 2 + +project_name: mcp-authzen + +before: + hooks: + - go mod tidy + +builds: + - id: mcp-authzen + binary: mcp-authzen + main: ./ + goos: + - darwin + - linux + goarch: + - amd64 + - arm64 + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - formats: + - tar.gz + name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" + files: + - LICENSE + - README.md + +checksum: + name_template: "{{ .ProjectName }}-{{ .Version }}-checksums.txt" + +release: + prerelease: auto + +sboms: + - artifacts: archive + +signs: + - cmd: cosign + certificate: "${artifact}.pem" + args: + - "sign-blob" + - "--output-certificate=${certificate}" + - "--output-signature=${signature}" + - "${artifact}" + - "--yes" + artifacts: checksum + output: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3eadc0c --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/0-draft/mcp-authzen + +go 1.26.3 + +require github.com/mark3labs/mcp-go v0.54.1 + +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d7a17e9 --- /dev/null +++ b/go.sum @@ -0,0 +1,34 @@ +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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +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.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0= +github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +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/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= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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/main.go b/main.go new file mode 100644 index 0000000..a607196 --- /dev/null +++ b/main.go @@ -0,0 +1,192 @@ +// mcp-authzen is a Model Context Protocol (MCP) server that fronts an +// OpenID AuthZEN 1.0 compliant Policy Decision Point (PDP). It exposes +// `authzen_evaluate` as an MCP tool so an LLM agent can ask "can subject S +// perform action A on resource R?" and route the question to a real PDP. +// +// The PDP endpoint can be configured via the AUTHZEN_PDP_URL environment +// variable or overridden per-call via the optional `pdp_url` argument. +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var version = "dev" + +// authzenRequest matches the OpenID AuthZEN 1.0 Evaluation API request body. +// +// https://openid.net/specs/authorization-api-1_0.html +type authzenRequest struct { + Subject json.RawMessage `json:"subject"` + Resource json.RawMessage `json:"resource"` + Action json.RawMessage `json:"action"` + Context json.RawMessage `json:"context,omitempty"` +} + +type authzenResponse struct { + Decision bool `json:"decision"` + Context json.RawMessage `json:"context,omitempty"` +} + +func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "--version", "-v", "version": + fmt.Printf("mcp-authzen %s\n", version) + return + case "--help", "-h", "help": + fmt.Println(`mcp-authzen — MCP server fronting an OpenID AuthZEN 1.0 PDP. + +Usage: + mcp-authzen Run as an MCP stdio server. + mcp-authzen --version Print version. + +Configuration: + AUTHZEN_PDP_URL Default PDP evaluation endpoint, e.g. + http://localhost:8181/access/v1/evaluation + +Tools exposed: + authzen_evaluate POST to the PDP's evaluation endpoint with a + subject/resource/action/context bundle.`) + return + } + } + + s := server.NewMCPServer( + "mcp-authzen", + version, + server.WithToolCapabilities(false), + ) + + s.AddTool( + mcp.NewTool("authzen_evaluate", + mcp.WithDescription( + "Ask an OpenID AuthZEN 1.0 PDP whether a subject is allowed "+ + "to perform an action on a resource. Returns the PDP's "+ + "decision (true/false) and optional context."), + mcp.WithString("subject", + mcp.Required(), + mcp.Description(`JSON object describing the principal. Per AuthZEN: `+ + `{"type": "user", "id": "alice", "properties": {...}}.`), + ), + mcp.WithString("resource", + mcp.Required(), + mcp.Description(`JSON object describing the target. Per AuthZEN: `+ + `{"type": "document", "id": "doc-1", "properties": {...}}.`), + ), + mcp.WithString("action", + mcp.Required(), + mcp.Description(`JSON object describing the action. Per AuthZEN: `+ + `{"name": "read", "properties": {...}}.`), + ), + mcp.WithString("context", + mcp.Description(`Optional JSON object with runtime context `+ + `(IP, time, MFA strength, etc).`), + ), + mcp.WithString("pdp_url", + mcp.Description(`Override the AUTHZEN_PDP_URL env. Must be a `+ + `full URL to the evaluation endpoint.`), + ), + ), + evaluate, + ) + + if err := server.ServeStdio(s); err != nil { + log.Fatalf("mcp-authzen: %v", err) + } +} + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +func evaluate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + pdpURL := req.GetString("pdp_url", "") + if pdpURL == "" { + pdpURL = os.Getenv("AUTHZEN_PDP_URL") + } + if pdpURL == "" { + return mcp.NewToolResultError( + "no PDP URL: set AUTHZEN_PDP_URL or pass pdp_url"), nil + } + + subject, err := parseJSONArg(req, "subject", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + resource, err := parseJSONArg(req, "resource", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + action, err := parseJSONArg(req, "action", true) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + contextRaw, err := parseJSONArg(req, "context", false) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + body, _ := json.Marshal(authzenRequest{ + Subject: subject, + Resource: resource, + Action: action, + Context: contextRaw, + }) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, pdpURL, bytes.NewReader(body)) + if err != nil { + return mcp.NewToolResultError("build request: " + err.Error()), nil + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + res, err := httpClient.Do(httpReq) + if err != nil { + return mcp.NewToolResultError("PDP request failed: " + err.Error()), nil + } + defer res.Body.Close() + + raw, err := io.ReadAll(res.Body) + if err != nil { + return mcp.NewToolResultError("read PDP response: " + err.Error()), nil + } + + if res.StatusCode >= 400 { + return mcp.NewToolResultError(fmt.Sprintf( + "PDP returned %d: %s", res.StatusCode, strings.TrimSpace(string(raw)))), nil + } + + var decoded authzenResponse + if err := json.Unmarshal(raw, &decoded); err != nil { + return mcp.NewToolResultError("PDP response is not valid AuthZEN JSON: " + err.Error()), nil + } + + out, _ := json.MarshalIndent(decoded, "", " ") + return mcp.NewToolResultText(string(out)), nil +} + +func parseJSONArg(req mcp.CallToolRequest, name string, required bool) (json.RawMessage, error) { + s := req.GetString(name, "") + if s == "" { + if required { + return nil, fmt.Errorf("missing required arg %q", name) + } + return nil, nil + } + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + return nil, fmt.Errorf("arg %q is not valid JSON: %w", name, err) + } + return json.RawMessage(s), nil +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..75e1e37 --- /dev/null +++ b/main_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +func callEval(t *testing.T, args map[string]any) *mcp.CallToolResult { + t.Helper() + req := mcp.CallToolRequest{} + req.Params.Name = "authzen_evaluate" + req.Params.Arguments = args + res, err := evaluate(context.Background(), req) + if err != nil { + t.Fatalf("evaluate returned non-nil err: %v", err) + } + return res +} + +func resultText(t *testing.T, res *mcp.CallToolResult) string { + t.Helper() + if len(res.Content) == 0 { + t.Fatal("empty content") + } + tc, ok := mcp.AsTextContent(res.Content[0]) + if !ok { + t.Fatalf("first content is not text: %T", res.Content[0]) + } + return tc.Text +} + +// fakePDP returns the configured decision and echoes the request for inspection. +func fakePDP(t *testing.T, decision bool, capture *authzenRequest) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if capture != nil { + _ = json.Unmarshal(body, capture) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(authzenResponse{Decision: decision}) + })) +} + +func TestEvaluate_Allow(t *testing.T) { + got := &authzenRequest{} + pdp := fakePDP(t, true, got) + defer pdp.Close() + + res := callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `{"type":"user","id":"alice"}`, + "resource": `{"type":"doc","id":"doc-1"}`, + "action": `{"name":"read"}`, + }) + if res.IsError { + t.Fatalf("expected success, got error: %s", resultText(t, res)) + } + if !strings.Contains(resultText(t, res), `"decision": true`) { + t.Fatalf("expected decision=true in output: %s", resultText(t, res)) + } + if string(got.Subject) != `{"type":"user","id":"alice"}` { + t.Fatalf("subject not forwarded: %s", string(got.Subject)) + } +} + +func TestEvaluate_Deny(t *testing.T) { + pdp := fakePDP(t, false, nil) + defer pdp.Close() + + res := callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `{"type":"user","id":"bob"}`, + "resource": `{"type":"doc","id":"doc-1"}`, + "action": `{"name":"delete"}`, + }) + if !strings.Contains(resultText(t, res), `"decision": false`) { + t.Fatalf("expected decision=false: %s", resultText(t, res)) + } +} + +func TestEvaluate_MissingPDP(t *testing.T) { + t.Setenv("AUTHZEN_PDP_URL", "") + res := callEval(t, map[string]any{ + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error when PDP URL unset") + } +} + +func TestEvaluate_BadJSON(t *testing.T) { + res := callEval(t, map[string]any{ + "pdp_url": "http://example.invalid", + "subject": `{not json}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error for malformed subject JSON") + } +} + +func TestEvaluate_PDP5xx(t *testing.T) { + pdp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer pdp.Close() + + res := callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error from 5xx response") + } + if !strings.Contains(resultText(t, res), "500") { + t.Fatalf("expected status 500 in error: %s", resultText(t, res)) + } +} From ebe044219476445889665b35c88b90738fa93444 Mon Sep 17 00:00:00 2001 From: kanywst Date: Wed, 27 May 2026 23:34:22 +0900 Subject: [PATCH 2/8] chore: smoke test, Makefile, dependabot; README de-fluff - scripts/smoke.sh spins a Python fake PDP, runs mcp-authzen pointed at it, drives an MCP initialize -> tools/call(authzen_evaluate) sequence, and asserts the PDP's decision was forwarded back. - Makefile targets: build, test, vet, lint, smoke, fmt, tidy, clean - .github/dependabot.yml for gomod + github-actions (weekly, grouped) - README rewritten: shorter; cites AuthZEN spec sections 6 / 5.5 it conforms to; documents tested-against-opa-authzen-plugin path. - main.go: silence errcheck on response Body.Close() --- .github/dependabot.yml | 21 ++++++++++ Makefile | 29 ++++++++++++++ README.md | 91 ++++++++++++++++++++++++++---------------- main.go | 2 +- scripts/smoke.sh | 75 ++++++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 36 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 Makefile create mode 100755 scripts/smoke.sh diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0f76c65 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + go-deps: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + actions: + patterns: ["*"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5c8e2af --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +BIN := mcp-authzen +PKG := ./... + +.PHONY: build test vet lint smoke clean fmt tidy + +build: + go build -trimpath -ldflags "-s -w" -o $(BIN) . + +test: + go test -race -count=1 -v $(PKG) + +vet: + go vet $(PKG) + +lint: + golangci-lint run $(PKG) + +smoke: build + ./scripts/smoke.sh ./$(BIN) + +fmt: + gofmt -s -w . + +tidy: + go mod tidy + +clean: + rm -f $(BIN) + rm -rf dist/ diff --git a/README.md b/README.md index 7a7a2a6..4df7bcb 100644 --- a/README.md +++ b/README.md @@ -2,46 +2,34 @@ [![ci](https://github.com/0-draft/mcp-authzen/actions/workflows/ci.yml/badge.svg)](https://github.com/0-draft/mcp-authzen/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/0-draft/mcp-authzen.svg)](https://pkg.go.dev/github.com/0-draft/mcp-authzen) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -A [Model Context Protocol](https://modelcontextprotocol.io) server that fronts an [OpenID AuthZEN 1.0](https://openid.net/specs/authorization-api-1_0.html) Policy Decision Point (PDP). +[MCP](https://modelcontextprotocol.io) server that fronts an [OpenID AuthZEN 1.0](https://openid.net/specs/authorization-api-1_0.html) PDP. -Lets an LLM agent ask **"can subject S perform action A on resource R?"** and have the answer come from a real authorization decision point — your OPA-AuthZEN, your Topaz, your in-house PDP — instead of from the model's training data. +Sends a `subject + resource + action + context` bundle to a real Policy Decision Point (OPA-AuthZEN, Topaz, your own) and returns the decision. Use it when "can alice delete this?" should be answered by policy code, not by the model's guess. -## Tool +Conforms to AuthZEN 1.0 §6 (single-evaluation request/response) and §5.5 (decision entity). Batch evaluation (§7) is not yet exposed as a tool — file an issue if you need it. -### `authzen_evaluate` - -POST the bundle to the configured PDP's `/access/v1/evaluation` (or equivalent) endpoint, return the decision. - -| Parameter | Required | Description | -| ----------- | -------- | ----------------------------------------------------------------------------------------------- | -| `subject` | yes | JSON object: `{"type": "user", "id": "alice", "properties": {...}}` | -| `resource` | yes | JSON object: `{"type": "document", "id": "doc-1", "properties": {...}}` | -| `action` | yes | JSON object: `{"name": "read", "properties": {...}}` | -| `context` | no | Free-form JSON object with runtime context (IP, time, MFA strength, etc). | -| `pdp_url` | no | Override the default PDP URL for this call. | - -Returns AuthZEN's `{"decision": , "context": {...}}` as JSON. - -## Configuration - -Set the default PDP endpoint via env: +## Install ```bash -export AUTHZEN_PDP_URL=http://localhost:8181/access/v1/evaluation +go install github.com/0-draft/mcp-authzen@latest ``` -Or pass `pdp_url` on every call. If neither is set, the tool returns an error. +Pre-built signed binaries are on the [releases page](https://github.com/0-draft/mcp-authzen/releases). -## Install +## Quickstart ```bash -go install github.com/0-draft/mcp-authzen@latest -``` +# Point at a real PDP (or your local opa-authzen-plugin on :8181) +export AUTHZEN_PDP_URL=http://localhost:8181/access/v1/evaluation -Or grab a signed binary from the [releases page](https://github.com/0-draft/mcp-authzen/releases). +# Run the smoke test (spins up an in-process fake PDP, exercises the +# full MCP handshake, asserts the decision is forwarded correctly) +make smoke +``` -## Use with Claude Code +## Wire it to Claude Code ```bash AUTHZEN_PDP_URL=http://localhost:8181/access/v1/evaluation \ @@ -52,11 +40,11 @@ Then in a session: > Can `alice` (role=admin) `delete` `doc-42` (owner=bob)? -Claude builds the AuthZEN request, calls `authzen_evaluate`, returns the PDP's verdict — auditable, deterministic, and your policy never leaves your PDP. +The model builds the AuthZEN bundle, calls `authzen_evaluate`, returns the PDP's decision. -## Use with Cursor / other MCP clients +## Wire it to Cursor / other clients -```json +```jsonc { "mcpServers": { "authzen": { @@ -67,17 +55,50 @@ Claude builds the AuthZEN request, calls `authzen_evaluate`, returns the PDP's v } ``` -## Test against a local PDP +## Tool: `authzen_evaluate` + +| Param | Required | Description | +| ---------- | -------- | -------------------------------------------------------------------------- | +| `subject` | yes | JSON object. AuthZEN §5.1, e.g. `{"type":"user","id":"alice"}`. | +| `resource` | yes | JSON object. AuthZEN §5.2, e.g. `{"type":"doc","id":"doc-1"}`. | +| `action` | yes | JSON object. AuthZEN §5.3, e.g. `{"name":"read"}`. | +| `context` | no | JSON object with runtime context. AuthZEN §5.4. | +| `pdp_url` | no | Override `AUTHZEN_PDP_URL` for this call. | + +Returns AuthZEN's `{"decision": , "context": {...}}` as JSON. + +## Test against opa-authzen-plugin -Spin up [`opa-authzen-plugin`](https://github.com/kanywst/opa-authzen-plugin) (or any AuthZEN PDP) on `:8181`, then: +[`kanywst/opa-authzen-plugin`](https://github.com/kanywst/opa-authzen-plugin) is a reference AuthZEN PDP built on OPA. ```bash -AUTHZEN_PDP_URL=http://localhost:8181/access/v1/evaluation mcp-authzen +# In one terminal — start the PDP on :8181 +git clone https://github.com/kanywst/opa-authzen-plugin +cd opa-authzen-plugin && make run + +# In another — wire mcp-authzen +export AUTHZEN_PDP_URL=http://localhost:8181/access/v1/evaluation +mcp-authzen +``` + +## Layout + +Flat by design. A single-binary MCP server with one tool does not need +`cmd/`, `internal/`, or `pkg/`. When batch (`/access/v1/evaluations`) or +the search APIs (§8) land, they get sibling files, not subpackages. + +```text +. +├── main.go # server bootstrap + tool registration +├── main_test.go # httptest-driven PDP round-trips +├── scripts/smoke.sh +├── .goreleaser.yml +└── .github/ ``` -## Verifying a release +## Verify a release -Each release ships a `cosign`-signed checksum file (keyless, Sigstore via GitHub OIDC) and a CycloneDX SBOM. To verify before installing: +Releases ship a `cosign`-signed checksum file (Sigstore keyless via GitHub OIDC) and a CycloneDX SBOM per archive. ```bash TAG=v0.1.0 diff --git a/main.go b/main.go index a607196..e69adac 100644 --- a/main.go +++ b/main.go @@ -155,7 +155,7 @@ func evaluate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult if err != nil { return mcp.NewToolResultError("PDP request failed: " + err.Error()), nil } - defer res.Body.Close() + defer func() { _ = res.Body.Close() }() raw, err := io.ReadAll(res.Body) if err != nil { diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..2516398 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# End-to-end smoke test for mcp-authzen. +# +# 1. Starts a fake AuthZEN PDP (Python) on a free port that always returns +# {"decision": true}. +# 2. Runs mcp-authzen pointed at it. +# 3. Drives an MCP initialize → tools/list → tools/call(authzen_evaluate) +# sequence over stdio. +# 4. Asserts the decision was forwarded back to the caller. +# +# Exit codes: +# 0 decision propagated correctly +# 1 decision missing or wrong +# 2 protocol / setup failure + +set -euo pipefail + +BIN="${1:-./mcp-authzen}" +if [[ ! -x "$BIN" ]]; then + echo "build first: go build ." >&2 + exit 2 +fi + +PORT=18181 +PDP_LOG=$(mktemp) +# shellcheck disable=SC2329 # registered as EXIT trap below +cleanup() { + if [[ -n "${PDP_PID:-}" ]] && kill -0 "$PDP_PID" 2>/dev/null; then + kill "$PDP_PID" 2>/dev/null || true + fi + rm -f "$PDP_LOG" +} +trap cleanup EXIT + +python3 - "$PORT" >"$PDP_LOG" 2>&1 <<'PY' & +import json, sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +class FakePDP(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + _ = self.rfile.read(length) + body = json.dumps({"decision": True}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): + pass + +HTTPServer(("127.0.0.1", int(sys.argv[1])), FakePDP).serve_forever() +PY +PDP_PID=$! +disown "$PDP_PID" 2>/dev/null || true + +# Wait for the fake PDP to be listening (pure-bash TCP probe) +for _ in {1..30}; do + (echo >/dev/tcp/127.0.0.1/$PORT) 2>/dev/null && break + sleep 0.1 +done + +OUT=$(printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"authzen_evaluate","arguments":{"subject":"{\"type\":\"user\",\"id\":\"alice\"}","resource":"{\"type\":\"doc\",\"id\":\"d1\"}","action":"{\"name\":\"read\"}"}}}' \ + | AUTHZEN_PDP_URL="http://127.0.0.1:${PORT}/access/v1/evaluation" "$BIN") + +DECISION=$(printf '%s\n' "$OUT" | tail -1 | jq -r '.result.content[0].text' | jq -r '.decision // empty') + +case "$DECISION" in + true) echo "✓ smoke: decision=true forwarded from fake PDP"; exit 0 ;; + false) echo "✗ smoke: decision=false (fake PDP returned true; mismatch)"; exit 1 ;; + *) echo "✗ smoke: no decision field in response. payload:"; printf '%s\n' "$OUT"; exit 2 ;; +esac From 0529abcfdb3953e6c9f6363f26a24e0285d35a76 Mon Sep 17 00:00:00 2001 From: kanywst Date: Wed, 27 May 2026 23:36:19 +0900 Subject: [PATCH 3/8] =?UTF-8?q?ci:=20pin=20golangci-lint=20v2.12.2=20(acti?= =?UTF-8?q?on=20v9)=20=E2=80=94=20v1.64=20was=20built=20with=20Go=201.24,?= =?UTF-8?q?=20can't=20read=20go1.26=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7112ba6..021eb8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,6 @@ jobs: - uses: actions/setup-go@v6 with: go-version-file: go.mod - - uses: golangci/golangci-lint-action@v6 + - uses: golangci/golangci-lint-action@v9 with: - version: latest + version: v2.12.2 From 8730ff6c30180b954d3fea70769c466b5194b63c Mon Sep 17 00:00:00 2001 From: kanywst Date: Wed, 27 May 2026 23:43:35 +0900 Subject: [PATCH 4/8] chore: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.go: optional AUTHZEN_PDP_TOKEN env. Bare value auto-prefixed with "Bearer "; "Bearer " or "Basic " prefixes pass through verbatim - main.go: io.LimitReader(1 MiB) on PDP response body to bound memory - main.go: validate JSON args with json.Valid instead of a throwaway Unmarshal into any (idiomatic, fewer allocations) - .goreleaser.yml: add windows/{amd64,arm64} build target; zip on windows - workflows: pin actions to full commit SHAs and set persist-credentials: false on checkout (zizmor / CodeRabbit hardening) - README: document AUTHZEN_PDP_TOKEN - tests: cover bearer-token auto-prefixing and verbatim-prefix passthrough (Gemini suggested downgrading go.mod to 1.23 because "Go 1.26 is not yet released" — that was based on stale training data; Go 1.26.3 is the current toolchain. Test step on Linux CI uses it without issue.) --- .github/workflows/ci.yml | 14 +++++++---- .github/workflows/release.yml | 11 +++++---- .goreleaser.yml | 6 ++++- README.md | 4 ++++ main.go | 15 ++++++++---- main_test.go | 44 +++++++++++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 021eb8d..f7b92cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,10 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: go.mod - run: go vet ./... @@ -22,10 +24,12 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: go.mod - - uses: golangci/golangci-lint-action@v9 + - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 with: version: v2.12.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81ab5a6..b90150f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,22 +14,23 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: fetch-depth: 0 + persist-credentials: false - - uses: actions/setup-go@v6 + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: go.mod - name: Install syft - uses: anchore/sbom-action/download-syft@v0 + uses: anchore/sbom-action/download-syft@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 - name: Install cosign - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 with: distribution: goreleaser version: latest diff --git a/.goreleaser.yml b/.goreleaser.yml index cc75ef0..08876f6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -13,6 +13,7 @@ builds: goos: - darwin - linux + - windows goarch: - amd64 - arm64 @@ -22,7 +23,10 @@ builds: - -s -w -X main.version={{.Version}} archives: - - formats: + - format_overrides: + - goos: windows + formats: [zip] + formats: - tar.gz name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" files: diff --git a/README.md b/README.md index 4df7bcb..a58525c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ Pre-built signed binaries are on the [releases page](https://github.com/0-draft/ # Point at a real PDP (or your local opa-authzen-plugin on :8181) export AUTHZEN_PDP_URL=http://localhost:8181/access/v1/evaluation +# Optional: PDP behind auth. Bare values are auto-prefixed with "Bearer "; +# values starting with "Bearer " or "Basic " pass through verbatim. +# export AUTHZEN_PDP_TOKEN=eyJhbGciOi... + # Run the smoke test (spins up an in-process fake PDP, exercises the # full MCP handshake, asserts the decision is forwarded correctly) make smoke diff --git a/main.go b/main.go index e69adac..bc356fd 100644 --- a/main.go +++ b/main.go @@ -150,6 +150,12 @@ func evaluate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json") + if token := os.Getenv("AUTHZEN_PDP_TOKEN"); token != "" { + if !strings.HasPrefix(token, "Bearer ") && !strings.HasPrefix(token, "Basic ") { + token = "Bearer " + token + } + httpReq.Header.Set("Authorization", token) + } res, err := httpClient.Do(httpReq) if err != nil { @@ -157,7 +163,9 @@ func evaluate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult } defer func() { _ = res.Body.Close() }() - raw, err := io.ReadAll(res.Body) + // Cap the response at 1 MiB; AuthZEN responses are tiny and we don't want + // a misbehaving PDP to OOM the server. + raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) if err != nil { return mcp.NewToolResultError("read PDP response: " + err.Error()), nil } @@ -184,9 +192,8 @@ func parseJSONArg(req mcp.CallToolRequest, name string, required bool) (json.Raw } return nil, nil } - var v any - if err := json.Unmarshal([]byte(s), &v); err != nil { - return nil, fmt.Errorf("arg %q is not valid JSON: %w", name, err) + if !json.Valid([]byte(s)) { + return nil, fmt.Errorf("arg %q is not valid JSON", name) } return json.RawMessage(s), nil } diff --git a/main_test.go b/main_test.go index 75e1e37..d5b903e 100644 --- a/main_test.go +++ b/main_test.go @@ -110,6 +110,50 @@ func TestEvaluate_BadJSON(t *testing.T) { } } +func TestEvaluate_BearerToken(t *testing.T) { + t.Setenv("AUTHZEN_PDP_TOKEN", "s3cret") + + var gotAuth string + pdp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(authzenResponse{Decision: true}) + })) + defer pdp.Close() + + _ = callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if gotAuth != "Bearer s3cret" { + t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer s3cret") + } +} + +func TestEvaluate_BearerToken_PreservesPrefix(t *testing.T) { + t.Setenv("AUTHZEN_PDP_TOKEN", "Bearer already-prefixed") + + var gotAuth string + pdp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(authzenResponse{Decision: true}) + })) + defer pdp.Close() + + _ = callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if gotAuth != "Bearer already-prefixed" { + t.Fatalf("Authorization header = %q; expected raw passthrough", gotAuth) + } +} + func TestEvaluate_PDP5xx(t *testing.T) { pdp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) From a606ed146e2ddf6034d661e445e0fccb8d9ef3fb Mon Sep 17 00:00:00 2001 From: kanywst Date: Wed, 27 May 2026 23:46:49 +0900 Subject: [PATCH 5/8] chore: address remaining CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.go: validate pdp_url before issuing the request — require absolute http(s) URL with a host. Closes the SSRF / arbitrary-target footgun when a model supplies pdp_url at call time. - main.go: enforce JSON object for subject/resource/action/context args. Arrays and scalars previously slipped through and only failed at the PDP; AuthZEN §5 entities are objects, so reject earlier. - .goreleaser.yml: switch `before.hooks` from `go mod tidy` to `go mod verify`. tidy can mutate go.mod / go.sum during release, breaking reproducibility. verify is read-only. - tests: cover non-http scheme, relative URL, and array-as-subject rejection paths. --- .goreleaser.yml | 4 +++- main.go | 16 ++++++++++++++-- main_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 08876f6..bd8e97b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,7 +4,9 @@ project_name: mcp-authzen before: hooks: - - go mod tidy + # `verify` checks checksums without mutating go.mod / go.sum, so a + # release stays bit-reproducible. CI / dev should run `go mod tidy`. + - go mod verify builds: - id: mcp-authzen diff --git a/main.go b/main.go index bc356fd..61a0dcd 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "io" "log" "net/http" + "net/url" "os" "strings" "time" @@ -119,6 +120,11 @@ func evaluate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult return mcp.NewToolResultError( "no PDP URL: set AUTHZEN_PDP_URL or pass pdp_url"), nil } + // Constrain the outbound target so a model-supplied `pdp_url` can't be used + // to scan internal addresses with non-HTTP schemes or omitted hosts. + if u, perr := url.Parse(pdpURL); perr != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return mcp.NewToolResultError("invalid pdp_url: must be an absolute http(s) URL with a host"), nil + } subject, err := parseJSONArg(req, "subject", true) if err != nil { @@ -192,8 +198,14 @@ func parseJSONArg(req mcp.CallToolRequest, name string, required bool) (json.Raw } return nil, nil } - if !json.Valid([]byte(s)) { - return nil, fmt.Errorf("arg %q is not valid JSON", name) + // AuthZEN entities (subject, resource, action, context) are all JSON + // objects — reject arrays / scalars early instead of letting the PDP do it. + var v any + if err := json.Unmarshal([]byte(s), &v); err != nil { + return nil, fmt.Errorf("arg %q is not valid JSON: %w", name, err) + } + if _, ok := v.(map[string]any); !ok { + return nil, fmt.Errorf("arg %q must be a JSON object", name) } return json.RawMessage(s), nil } diff --git a/main_test.go b/main_test.go index d5b903e..8dbe087 100644 --- a/main_test.go +++ b/main_test.go @@ -154,6 +154,47 @@ func TestEvaluate_BearerToken_PreservesPrefix(t *testing.T) { } } +func TestEvaluate_RejectsNonHTTPScheme(t *testing.T) { + res := callEval(t, map[string]any{ + "pdp_url": "file:///etc/passwd", + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error for non-http(s) scheme") + } +} + +func TestEvaluate_RejectsRelativeURL(t *testing.T) { + res := callEval(t, map[string]any{ + "pdp_url": "/local/path", + "subject": `{"id":"a"}`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error for relative URL (missing host)") + } +} + +func TestEvaluate_RejectsArraySubject(t *testing.T) { + pdp := fakePDP(t, true, nil) + defer pdp.Close() + res := callEval(t, map[string]any{ + "pdp_url": pdp.URL, + "subject": `["alice"]`, + "resource": `{"id":"r"}`, + "action": `{"name":"x"}`, + }) + if !res.IsError { + t.Fatal("expected error: subject must be a JSON object") + } + if !strings.Contains(resultText(t, res), "must be a JSON object") { + t.Fatalf("expected object-only error in: %s", resultText(t, res)) + } +} + func TestEvaluate_PDP5xx(t *testing.T) { pdp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) From 9411c7ddb77c71c26871baad469d158207dd5eb3 Mon Sep 17 00:00:00 2001 From: kanywst Date: Wed, 27 May 2026 23:49:29 +0900 Subject: [PATCH 6/8] ci: add smoke / vuln / actionlint / coverage jobs - smoke: build and run `make smoke` (in-process Python fake PDP, full MCP handshake, decision propagation assertion). - vuln: govulncheck (Go reachable-CVE scan) + osv-scanner (OSV.dev cross- ecosystem scan). - actionlint: lint the workflow files themselves. - test: emit coverage with -covermode=atomic and print summary. All actions pinned to SHAs with version comments for Dependabot tracking. --- .github/workflows/ci.yml | 47 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7b92cb..8a6baf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,10 @@ jobs: with: go-version-file: go.mod - run: go vet ./... - - run: go test -race -count=1 -v ./... + - name: Run tests with coverage + run: go test -race -count=1 -covermode=atomic -coverprofile=coverage.out -v ./... + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -n 1 lint: runs-on: ubuntu-latest @@ -33,3 +36,45 @@ jobs: - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 with: version: v2.12.2 + + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + - name: Run end-to-end MCP smoke test (uses Python fake PDP) + run: make smoke + + vuln: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + - name: govulncheck (Go module CVE scan) + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + - name: osv-scanner (OSV.dev cross-ecosystem scan) + uses: google/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + with: + scan-args: |- + --recursive + --skip-git + ./ + + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - name: Lint GitHub Actions workflows + uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2 From b45e05a1a7e49256a35674aa1ef90490cacf6662 Mon Sep 17 00:00:00 2001 From: kanywst Date: Wed, 27 May 2026 23:54:51 +0900 Subject: [PATCH 7/8] ci(vuln): correct osv-scanner-action path (uses subaction at osv-scanner-action/action.yml, not repo root) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a6baf1..5b354b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: go install golang.org/x/vuln/cmd/govulncheck@latest govulncheck ./... - name: osv-scanner (OSV.dev cross-ecosystem scan) - uses: google/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 with: scan-args: |- --recursive From f8e24007ae90bcfcb58d8cf3edf25e0f5d439d4f Mon Sep 17 00:00:00 2001 From: kanywst Date: Wed, 27 May 2026 23:56:47 +0900 Subject: [PATCH 8/8] ci(vuln): drop --skip-git arg (removed in osv-scanner v2) --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b354b0..633ff6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,6 @@ jobs: with: scan-args: |- --recursive - --skip-git ./ actionlint: