diff --git a/.changeset/patch-add-mcp-actor-validation.md b/.changeset/patch-add-mcp-actor-validation.md new file mode 100644 index 0000000000..4128c961cd --- /dev/null +++ b/.changeset/patch-add-mcp-actor-validation.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Documented and added the MCP server actor validation flag plus env var support diff --git a/.github/workflows/agent-performance-analyzer.lock.yml b/.github/workflows/agent-performance-analyzer.lock.yml index a3766bf7b9..078858888f 100644 --- a/.github/workflows/agent-performance-analyzer.lock.yml +++ b/.github/workflows/agent-performance-analyzer.lock.yml @@ -623,7 +623,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/.github/workflows/agent-persona-explorer.lock.yml b/.github/workflows/agent-persona-explorer.lock.yml index 77e43d9977..61f34c748c 100644 --- a/.github/workflows/agent-persona-explorer.lock.yml +++ b/.github/workflows/agent-persona-explorer.lock.yml @@ -516,7 +516,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/.github/workflows/audit-workflows.lock.yml b/.github/workflows/audit-workflows.lock.yml index 23065d4cf5..252e871720 100644 --- a/.github/workflows/audit-workflows.lock.yml +++ b/.github/workflows/audit-workflows.lock.yml @@ -582,7 +582,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "$GITHUB_TOKEN" + "GITHUB_TOKEN": "$GITHUB_TOKEN", + "GITHUB_ACTOR": "$GITHUB_ACTOR", + "GITHUB_REPOSITORY": "$GITHUB_REPOSITORY" } }, "github": { diff --git a/.github/workflows/cloclo.lock.yml b/.github/workflows/cloclo.lock.yml index f642d652e7..2bcbceb3e9 100644 --- a/.github/workflows/cloclo.lock.yml +++ b/.github/workflows/cloclo.lock.yml @@ -632,7 +632,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "$GITHUB_TOKEN" + "GITHUB_TOKEN": "$GITHUB_TOKEN", + "GITHUB_ACTOR": "$GITHUB_ACTOR", + "GITHUB_REPOSITORY": "$GITHUB_REPOSITORY" } }, "github": { diff --git a/.github/workflows/daily-cli-tools-tester.lock.yml b/.github/workflows/daily-cli-tools-tester.lock.yml index 6eea48d42d..9e7cc3d20c 100644 --- a/.github/workflows/daily-cli-tools-tester.lock.yml +++ b/.github/workflows/daily-cli-tools-tester.lock.yml @@ -523,7 +523,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/.github/workflows/daily-firewall-report.lock.yml b/.github/workflows/daily-firewall-report.lock.yml index 279f37b3dc..5260b5f395 100644 --- a/.github/workflows/daily-firewall-report.lock.yml +++ b/.github/workflows/daily-firewall-report.lock.yml @@ -568,7 +568,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/.github/workflows/daily-observability-report.lock.yml b/.github/workflows/daily-observability-report.lock.yml index 3745415247..6bae159055 100644 --- a/.github/workflows/daily-observability-report.lock.yml +++ b/.github/workflows/daily-observability-report.lock.yml @@ -569,7 +569,7 @@ jobs: [mcp_servers.agenticworkflows] container = "localhost/gh-aw:dev" mounts = ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"] - env_vars = ["DEBUG", "GH_TOKEN", "GITHUB_TOKEN"] + env_vars = ["DEBUG", "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_ACTOR", "GITHUB_REPOSITORY"] [mcp_servers.github] user_agent = "daily-observability-report-for-awf-firewall-and-mcp-gateway" @@ -597,7 +597,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "$GITHUB_TOKEN" + "GITHUB_TOKEN": "$GITHUB_TOKEN", + "GITHUB_ACTOR": "$GITHUB_ACTOR", + "GITHUB_REPOSITORY": "$GITHUB_REPOSITORY" } }, "github": { diff --git a/.github/workflows/daily-safe-output-optimizer.lock.yml b/.github/workflows/daily-safe-output-optimizer.lock.yml index 432519cdcf..e3ab27c7a7 100644 --- a/.github/workflows/daily-safe-output-optimizer.lock.yml +++ b/.github/workflows/daily-safe-output-optimizer.lock.yml @@ -549,7 +549,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "$GITHUB_TOKEN" + "GITHUB_TOKEN": "$GITHUB_TOKEN", + "GITHUB_ACTOR": "$GITHUB_ACTOR", + "GITHUB_REPOSITORY": "$GITHUB_REPOSITORY" } }, "github": { diff --git a/.github/workflows/deep-report.lock.yml b/.github/workflows/deep-report.lock.yml index 38970da0c0..2b24df663b 100644 --- a/.github/workflows/deep-report.lock.yml +++ b/.github/workflows/deep-report.lock.yml @@ -641,7 +641,7 @@ jobs: [mcp_servers.agenticworkflows] container = "localhost/gh-aw:dev" mounts = ["\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw", "/tmp/gh-aw:/tmp/gh-aw:rw"] - env_vars = ["DEBUG", "GH_TOKEN", "GITHUB_TOKEN"] + env_vars = ["DEBUG", "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_ACTOR", "GITHUB_REPOSITORY"] [mcp_servers.github] user_agent = "deepreport-intelligence-gathering-agent" @@ -669,7 +669,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "$GITHUB_TOKEN" + "GITHUB_TOKEN": "$GITHUB_TOKEN", + "GITHUB_ACTOR": "$GITHUB_ACTOR", + "GITHUB_REPOSITORY": "$GITHUB_REPOSITORY" } }, "github": { diff --git a/.github/workflows/dev-hawk.lock.yml b/.github/workflows/dev-hawk.lock.yml index 8ebd2ae497..058b900ad2 100644 --- a/.github/workflows/dev-hawk.lock.yml +++ b/.github/workflows/dev-hawk.lock.yml @@ -494,7 +494,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/.github/workflows/example-workflow-analyzer.lock.yml b/.github/workflows/example-workflow-analyzer.lock.yml index b5aa4c0ae4..ca23486cbf 100644 --- a/.github/workflows/example-workflow-analyzer.lock.yml +++ b/.github/workflows/example-workflow-analyzer.lock.yml @@ -507,7 +507,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "$GITHUB_TOKEN" + "GITHUB_TOKEN": "$GITHUB_TOKEN", + "GITHUB_ACTOR": "$GITHUB_ACTOR", + "GITHUB_REPOSITORY": "$GITHUB_REPOSITORY" } }, "github": { diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml index 547588598f..94e220f38b 100644 --- a/.github/workflows/mcp-inspector.lock.yml +++ b/.github/workflows/mcp-inspector.lock.yml @@ -584,7 +584,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "arxiv": { diff --git a/.github/workflows/metrics-collector.lock.yml b/.github/workflows/metrics-collector.lock.yml index 2978e267e8..ddc323e81c 100644 --- a/.github/workflows/metrics-collector.lock.yml +++ b/.github/workflows/metrics-collector.lock.yml @@ -292,7 +292,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/.github/workflows/portfolio-analyst.lock.yml b/.github/workflows/portfolio-analyst.lock.yml index 51fe8a3cae..0b2ea99f63 100644 --- a/.github/workflows/portfolio-analyst.lock.yml +++ b/.github/workflows/portfolio-analyst.lock.yml @@ -575,7 +575,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/.github/workflows/prompt-clustering-analysis.lock.yml b/.github/workflows/prompt-clustering-analysis.lock.yml index 6904bea2cd..51f37e173f 100644 --- a/.github/workflows/prompt-clustering-analysis.lock.yml +++ b/.github/workflows/prompt-clustering-analysis.lock.yml @@ -572,7 +572,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "$GITHUB_TOKEN" + "GITHUB_TOKEN": "$GITHUB_TOKEN", + "GITHUB_ACTOR": "$GITHUB_ACTOR", + "GITHUB_REPOSITORY": "$GITHUB_REPOSITORY" } }, "github": { diff --git a/.github/workflows/python-data-charts.lock.yml b/.github/workflows/python-data-charts.lock.yml index de1b0d8f40..6e48390030 100644 --- a/.github/workflows/python-data-charts.lock.yml +++ b/.github/workflows/python-data-charts.lock.yml @@ -564,7 +564,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/.github/workflows/q.lock.yml b/.github/workflows/q.lock.yml index 2a042b318f..56526aa1bb 100644 --- a/.github/workflows/q.lock.yml +++ b/.github/workflows/q.lock.yml @@ -617,7 +617,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/.github/workflows/safe-output-health.lock.yml b/.github/workflows/safe-output-health.lock.yml index 0005def700..fe9ad1c994 100644 --- a/.github/workflows/safe-output-health.lock.yml +++ b/.github/workflows/safe-output-health.lock.yml @@ -525,7 +525,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "$GITHUB_TOKEN" + "GITHUB_TOKEN": "$GITHUB_TOKEN", + "GITHUB_ACTOR": "$GITHUB_ACTOR", + "GITHUB_REPOSITORY": "$GITHUB_REPOSITORY" } }, "github": { diff --git a/.github/workflows/security-review.lock.yml b/.github/workflows/security-review.lock.yml index 06a1564e0d..9db097f5ca 100644 --- a/.github/workflows/security-review.lock.yml +++ b/.github/workflows/security-review.lock.yml @@ -607,7 +607,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 1ada5f82a9..df52fa1f7a 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -1205,7 +1205,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "$GITHUB_TOKEN" + "GITHUB_TOKEN": "$GITHUB_TOKEN", + "GITHUB_ACTOR": "$GITHUB_ACTOR", + "GITHUB_REPOSITORY": "$GITHUB_REPOSITORY" } }, "github": { diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index d739081d59..53c8bd1327 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -1194,7 +1194,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/.github/workflows/static-analysis-report.lock.yml b/.github/workflows/static-analysis-report.lock.yml index 962f561a02..bc43b8db00 100644 --- a/.github/workflows/static-analysis-report.lock.yml +++ b/.github/workflows/static-analysis-report.lock.yml @@ -524,7 +524,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "$GITHUB_TOKEN" + "GITHUB_TOKEN": "$GITHUB_TOKEN", + "GITHUB_ACTOR": "$GITHUB_ACTOR", + "GITHUB_REPOSITORY": "$GITHUB_REPOSITORY" } }, "github": { diff --git a/.github/workflows/workflow-normalizer.lock.yml b/.github/workflows/workflow-normalizer.lock.yml index e2fd00af1f..214bf2a822 100644 --- a/.github/workflows/workflow-normalizer.lock.yml +++ b/.github/workflows/workflow-normalizer.lock.yml @@ -524,7 +524,9 @@ jobs: "args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"], "env": { "DEBUG": "*", - "GITHUB_TOKEN": "\${GITHUB_TOKEN}" + "GITHUB_TOKEN": "\${GITHUB_TOKEN}", + "GITHUB_ACTOR": "\${GITHUB_ACTOR}", + "GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}" } }, "github": { diff --git a/Dockerfile b/Dockerfile index 830bb088c5..c0681ff33b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,9 +35,10 @@ WORKDIR /workspace # Set the entrypoint to gh-aw ENTRYPOINT ["gh-aw"] -# Default command runs MCP server -# Note: Binary path detection is automatic via os.Executable() -CMD ["mcp-server"] +# Default command runs MCP server with actor validation enabled +# The GITHUB_ACTOR environment variable must be set for logs and audit tools to be available +# Binary path detection is automatic via os.Executable() +CMD ["mcp-server", "--validate-actor"] # Metadata labels LABEL org.opencontainers.image.source="https://github.com/github/gh-aw" diff --git a/pkg/cli/mcp_server.go b/pkg/cli/mcp_server.go index 0ef3b63aed..48ad929900 100644 --- a/pkg/cli/mcp_server.go +++ b/pkg/cli/mcp_server.go @@ -37,10 +37,136 @@ func mcpErrorData(v any) json.RawMessage { return data } +// actorPermissionCache stores cached actor permission lookups with TTL +type actorPermissionCache struct { + permission string + timestamp time.Time +} + +// repositoryCache stores cached repository information with TTL +type repositoryCache struct { + repository string + timestamp time.Time +} + +var ( + permissionCache = make(map[string]*actorPermissionCache) + permissionCacheTTL = 1 * time.Hour + repoCache *repositoryCache + repoCacheTTL = 1 * time.Hour +) + +// getRepository retrieves the current repository name (owner/repo format). +// Results are cached for 1 hour to avoid repeated queries. +// Checks GITHUB_REPOSITORY environment variable first, then falls back to gh repo view. +func getRepository() (string, error) { + // Check cache first + if repoCache != nil && time.Since(repoCache.timestamp) < repoCacheTTL { + mcpLog.Printf("Using cached repository: %s (age: %v)", repoCache.repository, time.Since(repoCache.timestamp)) + return repoCache.repository, nil + } + + // Try GITHUB_REPOSITORY environment variable first + repo := os.Getenv("GITHUB_REPOSITORY") + if repo != "" { + mcpLog.Printf("Got repository from GITHUB_REPOSITORY: %s", repo) + // Cache the result + repoCache = &repositoryCache{ + repository: repo, + timestamp: time.Now(), + } + return repo, nil + } + + // Fall back to gh repo view + mcpLog.Print("Querying repository using gh repo view") + cmd := workflow.ExecGH("repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner") + output, err := cmd.Output() + if err != nil { + mcpLog.Printf("Failed to get repository: %v", err) + return "", fmt.Errorf("failed to get repository: %w", err) + } + + repo = strings.TrimSpace(string(output)) + if repo == "" { + return "", fmt.Errorf("repository not found") + } + + mcpLog.Printf("Got repository from gh repo view: %s", repo) + // Cache the result + repoCache = &repositoryCache{ + repository: repo, + timestamp: time.Now(), + } + return repo, nil +} + +// queryActorRole queries the GitHub API to determine the actor's role in the repository. +// Returns the permission level (admin, maintain, write, triage, read) or an error. +// Results are cached for 1 hour to avoid excessive API calls. +func queryActorRole(ctx context.Context, actor string, repo string) (string, error) { + if actor == "" { + return "", fmt.Errorf("actor not specified") + } + if repo == "" { + return "", fmt.Errorf("repository not specified") + } + + // Check cache first + cacheKey := fmt.Sprintf("%s:%s", actor, repo) + if cached, ok := permissionCache[cacheKey]; ok { + if time.Since(cached.timestamp) < permissionCacheTTL { + mcpLog.Printf("Using cached permission for %s in %s: %s (age: %v)", actor, repo, cached.permission, time.Since(cached.timestamp)) + return cached.permission, nil + } + // Cache expired, remove it + delete(permissionCache, cacheKey) + mcpLog.Printf("Permission cache expired for %s in %s", actor, repo) + } + + // Query GitHub API for user's permission level + // GET /repos/{owner}/{repo}/collaborators/{username}/permission + apiPath := fmt.Sprintf("/repos/%s/collaborators/%s/permission", repo, actor) + mcpLog.Printf("Querying GitHub API for %s's permission in %s", actor, repo) + + cmd := workflow.ExecGHContext(ctx, "api", apiPath, "--jq", ".permission") + output, err := cmd.Output() + if err != nil { + mcpLog.Printf("Failed to query actor permission: %v", err) + return "", fmt.Errorf("failed to query actor permission: %w", err) + } + + permission := strings.TrimSpace(string(output)) + if permission == "" { + return "", fmt.Errorf("no permission found for actor %s in repository %s", actor, repo) + } + + // Cache the result + permissionCache[cacheKey] = &actorPermissionCache{ + permission: permission, + timestamp: time.Now(), + } + mcpLog.Printf("Cached permission for %s in %s: %s", actor, repo, permission) + + return permission, nil +} + +// hasWriteAccess checks if the given permission level is write or higher. +// Permission levels from highest to lowest: admin, maintain, write, triage, read +func hasWriteAccess(permission string) bool { + switch permission { + case "admin", "maintain", "write": + return true + default: + return false + } +} + // NewMCPServerCommand creates the mcp-server command func NewMCPServerCommand() *cobra.Command { var port int var cmdPath string + var validateActor bool cmd := &cobra.Command{ Use: "mcp-server", @@ -54,28 +180,41 @@ secrets are not shared with the MCP server process itself. The server provides the following tools: - status - Show status of agentic workflow files - compile - Compile Markdown workflows to GitHub Actions YAML - - logs - Download and analyze workflow logs - - audit - Investigate a workflow run, job, or step and generate a report + - logs - Download and analyze workflow logs (requires write+ access) + - audit - Investigate a workflow run, job, or step and generate a report (requires write+ access) - mcp-inspect - Inspect MCP servers in workflows and list available tools - add - Add workflows from remote repositories to .github/workflows - update - Update workflows from their source repositories - fix - Apply automatic codemod-style fixes to workflow files +Access Control: + The GITHUB_ACTOR environment variable specifies the GitHub username for role-based + access control. The actor's repository role (admin, maintain, write, etc.) determines + which tools are available. Tools requiring elevated permissions (logs, audit) are always + mounted but will return permission denied errors if the actor lacks write+ access. + + Use the --validate-actor flag to enforce actor validation. When enabled, logs and audit + tools will return permission denied errors if GITHUB_ACTOR is not set. When disabled + (default), these tools will work without actor validation. + By default, the server uses stdio transport. Use the --port flag to run an HTTP server with SSE (Server-Sent Events) transport instead. Examples: - gh aw mcp-server # Run with stdio transport (default for MCP clients) - gh aw mcp-server --port 8080 # Run HTTP server on port 8080 (for web-based clients) - gh aw mcp-server --cmd ./gh-aw # Use custom gh-aw binary path - DEBUG=mcp:* gh aw mcp-server # Run with verbose logging for debugging`, + gh aw mcp-server # Run with stdio transport (default for MCP clients) + gh aw mcp-server --validate-actor # Run with actor validation enforced + gh aw mcp-server --port 8080 # Run HTTP server on port 8080 (for web-based clients) + gh aw mcp-server --cmd ./gh-aw # Use custom gh-aw binary path + GITHUB_ACTOR=octocat gh aw mcp-server # Set actor via environment variable for access control + DEBUG=mcp:* GITHUB_ACTOR=octocat gh aw mcp-server # Run with verbose logging and actor`, RunE: func(cmd *cobra.Command, args []string) error { - return runMCPServer(port, cmdPath) + return runMCPServer(port, cmdPath, validateActor) }, } cmd.Flags().IntVarP(&port, "port", "p", 0, "Port to run HTTP server on (uses stdio if not specified)") cmd.Flags().StringVar(&cmdPath, "cmd", "", "Path to gh aw command to use (defaults to 'gh aw')") + cmd.Flags().BoolVar(&validateActor, "validate-actor", false, "Enforce actor validation (logs/audit tools return errors without GITHUB_ACTOR)") return cmd } @@ -101,7 +240,27 @@ func checkAndLogGHVersion() { } // runMCPServer starts the MCP server on stdio or HTTP transport -func runMCPServer(port int, cmdPath string) error { +func runMCPServer(port int, cmdPath string, validateActor bool) error { + // Get actor from environment variable + actor := os.Getenv("GITHUB_ACTOR") + + if validateActor { + mcpLog.Printf("Actor validation enabled (--validate-actor flag)") + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Actor validation enabled")) + } + + if actor != "" { + mcpLog.Printf("Using actor: %s", actor) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Actor: %s", actor))) + } else { + mcpLog.Print("No actor specified (GITHUB_ACTOR environment variable)") + if validateActor { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("No actor specified - logs and audit tools will not be mounted (actor validation enabled)")) + } else { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("No actor specified - all tools will be mounted (actor validation disabled)")) + } + } + if port > 0 { mcpLog.Printf("Starting MCP server on HTTP port %d", port) } else { @@ -141,7 +300,7 @@ func runMCPServer(port int, cmdPath string) error { } // Create the server configuration - server := createMCPServer(cmdPath) + server := createMCPServer(cmdPath, actor, validateActor) if port > 0 { // Run HTTP server with SSE transport @@ -153,8 +312,88 @@ func runMCPServer(port int, cmdPath string) error { return server.Run(context.Background(), &mcp.StdioTransport{}) } +// checkActorPermission validates if the actor has sufficient permissions for restricted tools. +// Returns nil if access is allowed, or a jsonrpc.Error if access is denied. +// Uses GitHub API to query the actor's actual repository role with 1-hour caching. +func checkActorPermission(actor string, validateActor bool, toolName string) error { + // If validation is disabled, always allow access + if !validateActor { + mcpLog.Printf("Tool %s: access allowed (validation disabled)", toolName) + return nil + } + + // If validation is enabled but no actor is specified, deny access + if actor == "" { + mcpLog.Printf("Tool %s: access denied (no actor specified, validation enabled)", toolName) + return &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidRequest, + Message: "permission denied: insufficient role", + Data: mcpErrorData(map[string]any{ + "error": "GITHUB_ACTOR environment variable not set", + "tool": toolName, + "reason": "This tool requires at least write access to the repository. Set GITHUB_ACTOR environment variable to enable access.", + }), + } + } + + // Get repository using cached lookup + repo, err := getRepository() + if err != nil { + mcpLog.Printf("Tool %s: failed to get repository context, allowing access: %v", toolName, err) + // If we can't determine the repository, allow access (fail open) + return nil + } + + if repo == "" { + mcpLog.Printf("Tool %s: no repository context, allowing access", toolName) + // No repository context, allow access + return nil + } + + // Query actor's role in the repository with caching + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + permission, err := queryActorRole(ctx, actor, repo) + if err != nil { + mcpLog.Printf("Tool %s: failed to query actor role, denying access: %v", toolName, err) + return &jsonrpc.Error{ + Code: jsonrpc.CodeInternalError, + Message: "permission denied: unable to verify repository access", + Data: mcpErrorData(map[string]any{ + "error": err.Error(), + "tool": toolName, + "actor": actor, + "repository": repo, + "reason": "Failed to query actor's repository permissions from GitHub API.", + }), + } + } + + // Check if the actor has write+ access + if !hasWriteAccess(permission) { + mcpLog.Printf("Tool %s: access denied for actor %s (permission: %s, requires: write+)", toolName, actor, permission) + return &jsonrpc.Error{ + Code: jsonrpc.CodeInvalidRequest, + Message: "permission denied: insufficient role", + Data: mcpErrorData(map[string]any{ + "error": "insufficient repository permissions", + "tool": toolName, + "actor": actor, + "repository": repo, + "role": permission, + "required": "write, maintain, or admin", + "reason": fmt.Sprintf("Actor %s has %s access to %s. This tool requires at least write access.", actor, permission, repo), + }), + } + } + + mcpLog.Printf("Tool %s: access allowed for actor %s (permission: %s)", toolName, actor, permission) + return nil +} + // createMCPServer creates and configures the MCP server with all tools -func createMCPServer(cmdPath string) *mcp.Server { +func createMCPServer(cmdPath string, actor string, validateActor bool) *mcp.Server { // Helper function to execute command with proper path execCmd := func(ctx context.Context, args ...string) *exec.Cmd { if cmdPath != "" { @@ -165,6 +404,21 @@ func createMCPServer(cmdPath string) *mcp.Server { return workflow.ExecGHContext(ctx, append([]string{"aw"}, args...)...) } + // Log actor and validation settings + if validateActor { + if actor != "" { + mcpLog.Printf("Actor validation enabled: actor=%s (logs/audit tools will check permissions)", actor) + } else { + mcpLog.Print("Actor validation enabled: no actor specified (logs/audit tools will deny access)") + } + } else { + if actor != "" { + mcpLog.Printf("Actor validation disabled: actor=%s (logs/audit tools will allow access)", actor) + } else { + mcpLog.Print("Actor validation disabled: no actor specified (logs/audit tools will allow access)") + } + } + // Create MCP server with capabilities and logging server := mcp.NewServer(&mcp.Implementation{ Name: "gh-aw", @@ -387,7 +641,7 @@ Returns JSON array with validation results for each workflow: }, nil, nil }) - // Add logs tool + // Add logs tool (requires write+ access) type logsArgs struct { WorkflowName string `json:"workflow_name,omitempty" jsonschema:"Name of the workflow to download logs for (empty for all)"` Count int `json:"count,omitempty" jsonschema:"Number of workflow runs to download (default: 100)"` @@ -438,6 +692,11 @@ return a schema description instead of the full output. Adjust the 'max_tokens' {Source: "📜"}, }, }, func(ctx context.Context, req *mcp.CallToolRequest, args logsArgs) (*mcp.CallToolResult, any, error) { + // Check actor permissions first + if err := checkActorPermission(actor, validateActor, "logs"); err != nil { + return nil, nil, err + } + // Check for cancellation before starting select { case <-ctx.Done(): @@ -557,7 +816,7 @@ return a schema description instead of the full output. Adjust the 'max_tokens' }, nil, nil }) - // Add audit tool + // Add audit tool (requires write+ access) type auditArgs struct { RunIDOrURL string `json:"run_id_or_url" jsonschema:"GitHub Actions workflow run ID or URL. Accepts: numeric run ID (e.g., 1234567890), run URL (https://github.com/owner/repo/actions/runs/1234567890), job URL (https://github.com/owner/repo/actions/runs/1234567890/job/9876543210), or job URL with step (https://github.com/owner/repo/actions/runs/1234567890/job/9876543210#step:7:1)"` } @@ -600,6 +859,11 @@ Returns JSON with the following structure: {Source: "🔍"}, }, }, func(ctx context.Context, req *mcp.CallToolRequest, args auditArgs) (*mcp.CallToolResult, any, error) { + // Check actor permissions first + if err := checkActorPermission(actor, validateActor, "audit"); err != nil { + return nil, nil, err + } + // Check for cancellation before starting select { case <-ctx.Done(): diff --git a/pkg/workflow/mcp_config_builtin.go b/pkg/workflow/mcp_config_builtin.go index 26f4669bfd..6b0aeaae15 100644 --- a/pkg/workflow/mcp_config_builtin.go +++ b/pkg/workflow/mcp_config_builtin.go @@ -168,8 +168,10 @@ func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bo value string isLiteral bool }{ - {"DEBUG", "*", true}, // Literal value "*" - {"GITHUB_TOKEN", "GITHUB_TOKEN", false}, // Variable reference (gh CLI auto-sets GH_TOKEN from GITHUB_TOKEN if needed) + {"DEBUG", "*", true}, // Literal value "*" + {"GITHUB_TOKEN", "GITHUB_TOKEN", false}, // Variable reference (gh CLI auto-sets GH_TOKEN from GITHUB_TOKEN if needed) + {"GITHUB_ACTOR", "GITHUB_ACTOR", false}, // Variable reference for actor-based access control + {"GITHUB_REPOSITORY", "GITHUB_REPOSITORY", false}, // Variable reference for repository context } // Use MCP Gateway spec format with container, entrypoint, entrypointArgs, and mounts @@ -188,7 +190,7 @@ func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bo if actionMode.IsDev() { // Dev mode: Use locally built Docker image which includes gh-aw binary and gh CLI - // The Dockerfile sets ENTRYPOINT ["gh-aw"] and CMD ["mcp-server"] + // The Dockerfile sets ENTRYPOINT ["gh-aw"] and CMD ["mcp-server", "--validate-actor"] // Binary path is automatically detected via os.Executable() // So we don't need to specify entrypoint or entrypointArgs containerImage = constants.DevModeGhAwImage @@ -199,8 +201,9 @@ func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bo } else { // Release mode: Use minimal Alpine image with mounted binaries // The gh-aw binary is mounted from /opt/gh-aw and executed directly + // Pass --validate-actor flag to enable role-based access control entrypoint = "/opt/gh-aw/gh-aw" - entrypointArgs = []string{"mcp-server"} + entrypointArgs = []string{"mcp-server", "--validate-actor"} // Mount gh-aw binary, gh CLI binary, workspace, and temp directory mounts = []string{constants.DefaultGhAwMount, constants.DefaultGhBinaryMount, constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} } @@ -216,7 +219,14 @@ func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bo // Only write entrypointArgs if specified (release mode) // In dev mode, use the container's default CMD if entrypointArgs != nil { - yaml.WriteString(" \"entrypointArgs\": [\"mcp-server\"],\n") + yaml.WriteString(" \"entrypointArgs\": [") + for i, arg := range entrypointArgs { + if i > 0 { + yaml.WriteString(", ") + } + yaml.WriteString("\"" + arg + "\"") + } + yaml.WriteString("],\n") } // Write mounts @@ -300,7 +310,7 @@ func renderAgenticWorkflowsMCPConfigTOML(yaml *strings.Builder, actionMode Actio if actionMode.IsDev() { // Dev mode: Use locally built Docker image which includes gh-aw binary and gh CLI - // The Dockerfile sets ENTRYPOINT ["gh-aw"] and CMD ["mcp-server", "--cmd", "gh-aw"] + // The Dockerfile sets ENTRYPOINT ["gh-aw"] and CMD ["mcp-server"] // So we don't need to specify entrypoint or entrypointArgs containerImage = constants.DevModeGhAwImage entrypoint = "" // Use container's default ENTRYPOINT @@ -309,8 +319,9 @@ func renderAgenticWorkflowsMCPConfigTOML(yaml *strings.Builder, actionMode Actio mounts = []string{constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} } else { // Release mode: Use minimal Alpine image with mounted binaries + // Pass --validate-actor flag to enable role-based access control entrypoint = "/opt/gh-aw/gh-aw" - entrypointArgs = []string{"mcp-server"} + entrypointArgs = []string{"mcp-server", "--validate-actor"} // Mount gh-aw binary, gh CLI binary, workspace, and temp directory mounts = []string{constants.DefaultGhAwMount, constants.DefaultGhBinaryMount, constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} } @@ -326,7 +337,14 @@ func renderAgenticWorkflowsMCPConfigTOML(yaml *strings.Builder, actionMode Actio // Only write entrypointArgs if specified (release mode) // In dev mode, use the container's default CMD if entrypointArgs != nil { - yaml.WriteString(" entrypointArgs = [\"mcp-server\"]\n") + yaml.WriteString(" entrypointArgs = [") + for i, arg := range entrypointArgs { + if i > 0 { + yaml.WriteString(", ") + } + yaml.WriteString("\"" + arg + "\"") + } + yaml.WriteString("]\n") } // Write mounts @@ -346,5 +364,6 @@ func renderAgenticWorkflowsMCPConfigTOML(yaml *strings.Builder, actionMode Actio yaml.WriteString(" args = [\"--network\", \"host\", \"-w\", \"${GITHUB_WORKSPACE}\"]\n") // Use env_vars array to reference environment variables instead of embedding secrets - yaml.WriteString(" env_vars = [\"DEBUG\", \"GITHUB_TOKEN\"]\n") + // Include GITHUB_ACTOR for role-based access control and GITHUB_REPOSITORY for repository context + yaml.WriteString(" env_vars = [\"DEBUG\", \"GITHUB_TOKEN\", \"GITHUB_ACTOR\", \"GITHUB_REPOSITORY\"]\n") } diff --git a/pkg/workflow/mcp_config_refactor_test.go b/pkg/workflow/mcp_config_refactor_test.go index 5dba5403a0..8e9aa4ce6a 100644 --- a/pkg/workflow/mcp_config_refactor_test.go +++ b/pkg/workflow/mcp_config_refactor_test.go @@ -133,7 +133,7 @@ func TestRenderAgenticWorkflowsMCPConfigWithOptions(t *testing.T) { `"type": "stdio"`, `"container": "alpine:latest"`, `"entrypoint": "/opt/gh-aw/gh-aw"`, - `"entrypointArgs": ["mcp-server"]`, + `"entrypointArgs": ["mcp-server", "--validate-actor"]`, `"/opt/gh-aw:/opt/gh-aw:ro"`, // gh-aw binary mount (read-only) `"/usr/bin/gh:/usr/bin/gh:ro"`, // gh CLI binary mount (read-only) `"\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw"`, // workspace mount (read-write) @@ -141,6 +141,8 @@ func TestRenderAgenticWorkflowsMCPConfigWithOptions(t *testing.T) { `"args": ["--network", "host", "-w", "\${GITHUB_WORKSPACE}"]`, // Network access + working directory `"DEBUG": "*"`, `"GITHUB_TOKEN": "\${GITHUB_TOKEN}"`, + `"GITHUB_ACTOR": "\${GITHUB_ACTOR}"`, // Actor for role-based access control + `"GITHUB_REPOSITORY": "\${GITHUB_REPOSITORY}"`, // Repository context ` },`, }, unexpectedContent: []string{ @@ -278,12 +280,12 @@ func TestRenderAgenticWorkflowsMCPConfigTOML(t *testing.T) { expectedContainer: `container = "alpine:latest"`, shouldHaveEntrypoint: true, expectedMounts: []string{ - `entrypoint = "/opt/gh-aw/gh-aw"`, // Entrypoint needed in release mode - `entrypointArgs = ["mcp-server"]`, // EntrypointArgs needed in release mode - `"/opt/gh-aw:/opt/gh-aw:ro"`, // gh-aw binary mount - `"/usr/bin/gh:/usr/bin/gh:ro"`, // gh CLI binary mount - `"\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw"`, // workspace mount - `"/tmp/gh-aw:/tmp/gh-aw:rw"`, // temp directory mount + `entrypoint = "/opt/gh-aw/gh-aw"`, // Entrypoint needed in release mode + `entrypointArgs = ["mcp-server", "--validate-actor"]`, // EntrypointArgs needed in release mode with validate-actor flag + `"/opt/gh-aw:/opt/gh-aw:ro"`, // gh-aw binary mount + `"/usr/bin/gh:/usr/bin/gh:ro"`, // gh CLI binary mount + `"\${GITHUB_WORKSPACE}:\${GITHUB_WORKSPACE}:rw"`, // workspace mount + `"/tmp/gh-aw:/tmp/gh-aw:rw"`, // temp directory mount }, unexpectedContent: []string{ `--cmd`, @@ -303,7 +305,7 @@ func TestRenderAgenticWorkflowsMCPConfigTOML(t *testing.T) { `[mcp_servers.agenticworkflows]`, tt.expectedContainer, `args = ["--network", "host", "-w", "${GITHUB_WORKSPACE}"]`, // Network access + working directory - `env_vars = ["DEBUG", "GITHUB_TOKEN"]`, + `env_vars = ["DEBUG", "GITHUB_TOKEN", "GITHUB_ACTOR", "GITHUB_REPOSITORY"]`, } expectedContent = append(expectedContent, tt.expectedMounts...) diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go index 6418939e4a..d74bb15adf 100644 --- a/pkg/workflow/mcp_renderer.go +++ b/pkg/workflow/mcp_renderer.go @@ -423,7 +423,7 @@ func (r *MCPConfigRendererUnified) renderAgenticWorkflowsTOML(yaml *strings.Buil if r.options.ActionMode.IsDev() { // Dev mode: Use locally built Docker image which includes gh-aw binary and gh CLI - // The Dockerfile sets ENTRYPOINT ["gh-aw"] and CMD ["mcp-server", "--cmd", "gh-aw"] + // The Dockerfile sets ENTRYPOINT ["gh-aw"] and CMD ["mcp-server", "--validate-actor"] // So we don't need to specify entrypoint or entrypointArgs containerImage = constants.DevModeGhAwImage entrypoint = "" // Use container's default ENTRYPOINT @@ -433,7 +433,7 @@ func (r *MCPConfigRendererUnified) renderAgenticWorkflowsTOML(yaml *strings.Buil } else { // Release mode: Use minimal Alpine image with mounted binaries entrypoint = "/opt/gh-aw/gh-aw" - entrypointArgs = []string{"mcp-server"} + entrypointArgs = []string{"mcp-server", "--validate-actor"} // Mount gh-aw binary, gh CLI binary, workspace, and temp directory mounts = []string{constants.DefaultGhAwMount, constants.DefaultGhBinaryMount, constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} } @@ -449,7 +449,14 @@ func (r *MCPConfigRendererUnified) renderAgenticWorkflowsTOML(yaml *strings.Buil // Only write entrypointArgs if specified (release mode) // In dev mode, use the container's default CMD if entrypointArgs != nil { - yaml.WriteString(" entrypointArgs = [\"mcp-server\"]\n") + yaml.WriteString(" entrypointArgs = [") + for i, arg := range entrypointArgs { + if i > 0 { + yaml.WriteString(", ") + } + yaml.WriteString("\"" + arg + "\"") + } + yaml.WriteString("]\n") } // Write mounts @@ -462,7 +469,7 @@ func (r *MCPConfigRendererUnified) renderAgenticWorkflowsTOML(yaml *strings.Buil } yaml.WriteString("]\n") - yaml.WriteString(" env_vars = [\"DEBUG\", \"GH_TOKEN\", \"GITHUB_TOKEN\"]\n") + yaml.WriteString(" env_vars = [\"DEBUG\", \"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_ACTOR\", \"GITHUB_REPOSITORY\"]\n") } // renderGitHubTOML generates GitHub MCP configuration in TOML format (for Codex engine)