diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs
index 38dbed8f17..282138af20 100644
--- a/actions/setup/js/safe_output_handler_manager.cjs
+++ b/actions/setup/js/safe_output_handler_manager.cjs
@@ -16,6 +16,7 @@ const { generateMissingInfoSections } = require("./missing_info_formatter.cjs");
const { setCollectedMissings } = require("./missing_messages_helper.cjs");
const { writeSafeOutputSummaries } = require("./safe_output_summary.cjs");
const { getIssuesToAssignCopilot } = require("./create_issue.cjs");
+const { validateTokenPermissions, generatePermissionErrorMessage, generatePermissionWarningMessage } = require("./token_permissions.cjs");
const DEFAULT_AGENTIC_CAMPAIGN_LABEL = "agentic-campaign";
@@ -826,6 +827,51 @@ async function main() {
return;
}
+ // Pre-flight token permission validation
+ // Extract operation types from messages to validate permissions
+ const operationTypes = new Set();
+ for (const message of agentOutput.items) {
+ if (message.type && messageHandlers.has(message.type)) {
+ operationTypes.add(message.type);
+ }
+ }
+
+ if (operationTypes.size > 0) {
+ core.info("\n=== Pre-flight Token Permission Validation ===");
+ core.info(`Validating permissions for ${operationTypes.size} operation type(s): ${Array.from(operationTypes).join(", ")}`);
+
+ try {
+ // Get repository context
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const token = process.env.GITHUB_TOKEN || "";
+
+ // Validate token permissions
+ const validationResult = await validateTokenPermissions(token, owner, repo, Array.from(operationTypes));
+
+ // Check for missing required permissions (errors)
+ const failedOperations = validationResult.results.filter(r => !r.valid);
+ if (failedOperations.length > 0) {
+ const errorMessage = generatePermissionErrorMessage(validationResult.results, validationResult.tokenType);
+ core.error(errorMessage);
+ core.setFailed("Token lacks required permissions for safe output operations");
+ return;
+ }
+
+ // Check for missing optional permissions (warnings)
+ const operationsWithOptional = validationResult.results.filter(r => r.optional && r.optional.length > 0);
+ if (operationsWithOptional.length > 0) {
+ const warningMessage = generatePermissionWarningMessage(validationResult.results);
+ core.warning(warningMessage);
+ }
+
+ core.info("✅ Token permissions validated successfully");
+ } catch (error) {
+ // Log validation error but don't fail - permission check is best-effort
+ core.warning(`Permission validation failed (continuing with operation): ${getErrorMessage(error)}`);
+ }
+ }
+
// Process all messages in order of appearance
const processingResult = await processMessages(messageHandlers, agentOutput.items);
diff --git a/actions/setup/js/safe_output_project_handler_manager.cjs b/actions/setup/js/safe_output_project_handler_manager.cjs
index 3f9bbe477a..dc72a9380e 100644
--- a/actions/setup/js/safe_output_project_handler_manager.cjs
+++ b/actions/setup/js/safe_output_project_handler_manager.cjs
@@ -16,6 +16,7 @@
const { loadAgentOutput } = require("./load_agent_output.cjs");
const { getErrorMessage } = require("./error_helpers.cjs");
const { writeSafeOutputSummaries } = require("./safe_output_summary.cjs");
+const { validateTokenPermissions, generatePermissionErrorMessage, generatePermissionWarningMessage } = require("./token_permissions.cjs");
/**
* Handler map configuration for project-related safe outputs
@@ -224,6 +225,51 @@ async function main() {
return;
}
+ // Pre-flight token permission validation for project operations
+ // Extract project operation types from messages
+ const projectOperationTypes = new Set();
+ for (const message of messages) {
+ if (message.type && messageHandlers.has(message.type)) {
+ projectOperationTypes.add(message.type);
+ }
+ }
+
+ if (projectOperationTypes.size > 0) {
+ core.info("\n=== Pre-flight Token Permission Validation (Project Operations) ===");
+ core.info(`Validating permissions for ${projectOperationTypes.size} project operation type(s): ${Array.from(projectOperationTypes).join(", ")}`);
+
+ try {
+ // Get repository context
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const token = process.env.GH_AW_PROJECT_GITHUB_TOKEN || "";
+
+ // Validate token permissions
+ const validationResult = await validateTokenPermissions(token, owner, repo, Array.from(projectOperationTypes));
+
+ // Check for missing required permissions (errors)
+ const failedOperations = validationResult.results.filter(r => !r.valid);
+ if (failedOperations.length > 0) {
+ const errorMessage = generatePermissionErrorMessage(validationResult.results, validationResult.tokenType);
+ core.error(errorMessage);
+ core.setFailed("GH_AW_PROJECT_GITHUB_TOKEN lacks required permissions for project operations");
+ return;
+ }
+
+ // Check for missing optional permissions (warnings)
+ const operationsWithOptional = validationResult.results.filter(r => r.optional && r.optional.length > 0);
+ if (operationsWithOptional.length > 0) {
+ const warningMessage = generatePermissionWarningMessage(validationResult.results);
+ core.warning(warningMessage);
+ }
+
+ core.info("✅ Project token permissions validated successfully");
+ } catch (error) {
+ // Log validation error but don't fail - permission check is best-effort
+ core.warning(`Permission validation failed (continuing with operation): ${getErrorMessage(error)}`);
+ }
+ }
+
// Process messages
const { results, processedCount, temporaryProjectMap } = await processMessages(messageHandlers, messages);
diff --git a/actions/setup/js/token_permissions.cjs b/actions/setup/js/token_permissions.cjs
new file mode 100644
index 0000000000..d04b2cbd05
--- /dev/null
+++ b/actions/setup/js/token_permissions.cjs
@@ -0,0 +1,496 @@
+// @ts-check
+///
+
+/**
+ * Token Permissions Validation Module
+ *
+ * This module provides utilities to check GitHub token scopes and permissions
+ * before executing safe output operations. It validates that tokens have the
+ * required permissions to perform specific GitHub API operations.
+ *
+ * Supports both classic personal access tokens (OAuth scopes) and fine-grained
+ * personal access tokens (repository permissions).
+ */
+
+const { getErrorMessage } = require("./error_helpers.cjs");
+
+/**
+ * Required permissions for each safe output operation type
+ * Maps operation types to their permission requirements
+ */
+const OPERATION_PERMISSIONS = {
+ create_issue: {
+ required: ["issues:write"],
+ description: "Create issues",
+ },
+ update_issue: {
+ required: ["issues:write"],
+ description: "Update issues",
+ },
+ close_issue: {
+ required: ["issues:write"],
+ description: "Close issues",
+ },
+ add_comment: {
+ required: ["issues:write", "pull_requests:write"],
+ requiresAny: true, // Only need one of these
+ description: "Add comments to issues or pull requests",
+ },
+ hide_comment: {
+ required: ["issues:write", "pull_requests:write"],
+ requiresAny: true,
+ description: "Hide comments on issues or pull requests",
+ },
+ add_labels: {
+ required: ["issues:write"],
+ description: "Add labels to issues or pull requests",
+ },
+ remove_labels: {
+ required: ["issues:write"],
+ description: "Remove labels from issues or pull requests",
+ },
+ assign_milestone: {
+ required: ["issues:write"],
+ description: "Assign milestones to issues",
+ },
+ assign_to_user: {
+ required: ["issues:write"],
+ description: "Assign users to issues",
+ },
+ assign_to_agent: {
+ required: ["issues:write"],
+ description: "Assign Copilot agents to issues",
+ },
+ add_reviewer: {
+ required: ["pull_requests:write"],
+ description: "Request pull request reviews",
+ },
+ link_sub_issue: {
+ required: ["issues:write"],
+ description: "Link sub-issues to parent issues",
+ },
+ create_pull_request: {
+ required: ["pull_requests:write", "contents:write"],
+ description: "Create pull requests",
+ },
+ update_pull_request: {
+ required: ["pull_requests:write"],
+ description: "Update pull requests",
+ },
+ close_pull_request: {
+ required: ["pull_requests:write"],
+ description: "Close pull requests",
+ },
+ mark_pull_request_as_ready_for_review: {
+ required: ["pull_requests:write"],
+ description: "Mark pull requests as ready for review",
+ },
+ create_discussion: {
+ required: ["discussions:write"],
+ description: "Create discussions",
+ },
+ update_discussion: {
+ required: ["discussions:write"],
+ description: "Update discussions",
+ },
+ close_discussion: {
+ required: ["discussions:write"],
+ description: "Close discussions",
+ },
+ create_project: {
+ required: ["projects:write"],
+ description: "Create GitHub Projects",
+ },
+ update_project: {
+ required: ["projects:write"],
+ optional: ["issues:write"], // Optional for label operations
+ description: "Update GitHub Projects",
+ },
+ copy_project: {
+ required: ["projects:write"],
+ description: "Copy GitHub Projects",
+ },
+ create_project_status_update: {
+ required: ["projects:write"],
+ description: "Create project status updates",
+ },
+ update_release: {
+ required: ["contents:write"],
+ description: "Update releases",
+ },
+ upload_assets: {
+ required: ["contents:write"],
+ description: "Upload assets to orphaned branches",
+ },
+};
+
+/**
+ * Check if a token has the required scopes for classic PATs
+ * Returns information about available scopes from x-oauth-scopes header
+ * @param {string} token - GitHub token to check
+ * @param {string} owner - Repository owner
+ * @param {string} repo - Repository name
+ * @returns {Promise<{hasScopes: boolean, scopes: string[], error?: string}>}
+ */
+async function checkClassicTokenScopes(token, owner, repo) {
+ try {
+ // Make a lightweight API call to get token scopes from headers
+ const response = await github.rest.repos.get({
+ owner,
+ repo,
+ });
+
+ // Extract scopes from response headers
+ // The x-oauth-scopes header contains comma-separated list of scopes
+ const scopesHeader = response.headers["x-oauth-scopes"] || "";
+ const scopes = scopesHeader
+ .split(",")
+ .map(s => s.trim())
+ .filter(s => s.length > 0);
+
+ core.debug(`Classic token scopes: ${scopes.join(", ")}`);
+
+ return {
+ hasScopes: scopes.length > 0,
+ scopes,
+ };
+ } catch (error) {
+ return {
+ hasScopes: false,
+ scopes: [],
+ error: getErrorMessage(error),
+ };
+ }
+}
+
+/**
+ * Check if a token has required permissions for fine-grained PATs
+ * Fine-grained tokens use repository permissions instead of scopes
+ * @param {string} token - GitHub token to check
+ * @param {string} owner - Repository owner
+ * @param {string} repo - Repository name
+ * @returns {Promise<{permissions: Object, error?: string}>}
+ */
+async function checkFineGrainedTokenPermissions(token, owner, repo) {
+ try {
+ // Try to access repository metadata which will reveal if we have access
+ const repoResponse = await github.rest.repos.get({
+ owner,
+ repo,
+ });
+
+ // For fine-grained tokens, we can infer permissions by checking the x-accepted-github-permissions header
+ const permissionsHeader = repoResponse.headers["x-accepted-github-permissions"] || "";
+
+ core.debug(`Fine-grained token permissions header: ${permissionsHeader}`);
+
+ // We'll also try to check specific endpoints to validate permissions
+ const permissions = {
+ metadata: "read", // We successfully read metadata
+ contents: "unknown",
+ issues: "unknown",
+ pull_requests: "unknown",
+ discussions: "unknown",
+ projects: "unknown",
+ };
+
+ return { permissions };
+ } catch (error) {
+ return {
+ permissions: {},
+ error: getErrorMessage(error),
+ };
+ }
+}
+
+/**
+ * Map OAuth scopes to permission categories
+ * Classic tokens use broad scopes that map to multiple permissions
+ * @param {string[]} scopes - Array of OAuth scopes
+ * @returns {Object} Map of permission categories to access levels
+ */
+function mapScopesToPermissions(scopes) {
+ const permissions = {
+ contents: "none",
+ issues: "none",
+ pull_requests: "none",
+ discussions: "none",
+ projects: "none",
+ };
+
+ for (const scope of scopes) {
+ switch (scope) {
+ case "repo":
+ case "public_repo":
+ // Full repository access includes contents, issues, and PRs
+ permissions.contents = "write";
+ permissions.issues = "write";
+ permissions.pull_requests = "write";
+ break;
+ case "repo:status":
+ // Commit status access
+ permissions.contents = "read";
+ break;
+ case "repo_deployment":
+ // Deployment access
+ permissions.contents = "read";
+ break;
+ case "public_repo":
+ // Public repository access
+ permissions.contents = "write";
+ permissions.issues = "write";
+ permissions.pull_requests = "write";
+ break;
+ case "repo:invite":
+ // Repository invitations
+ break;
+ case "security_events":
+ // Security events
+ break;
+ case "write:discussion":
+ case "read:discussion":
+ permissions.discussions = scope.startsWith("write") ? "write" : "read";
+ break;
+ case "project":
+ permissions.projects = "write";
+ break;
+ case "read:project":
+ permissions.projects = "read";
+ break;
+ }
+ }
+
+ return permissions;
+}
+
+/**
+ * Validate if permissions meet requirements for a specific operation
+ * @param {Object} permissions - Current token permissions
+ * @param {string} operationType - Safe output operation type
+ * @returns {{valid: boolean, missing: string[], optional: string[], description: string}}
+ */
+function validateOperationPermissions(permissions, operationType) {
+ const operationReq = OPERATION_PERMISSIONS[operationType];
+
+ if (!operationReq) {
+ // Unknown operation type - allow it through
+ return {
+ valid: true,
+ missing: [],
+ optional: [],
+ description: `Unknown operation type: ${operationType}`,
+ };
+ }
+
+ const missing = [];
+ const optional = [];
+
+ // Check required permissions
+ if (operationReq.requiresAny) {
+ // Need at least one of the required permissions
+ const hasAny = operationReq.required.some(req => {
+ const [category, level] = req.split(":");
+ return permissions[category] === level || permissions[category] === "write";
+ });
+
+ if (!hasAny) {
+ missing.push(...operationReq.required);
+ }
+ } else {
+ // Need all required permissions
+ for (const req of operationReq.required) {
+ const [category, level] = req.split(":");
+ const currentLevel = permissions[category] || "none";
+
+ if (currentLevel === "none" || (level === "write" && currentLevel !== "write")) {
+ missing.push(req);
+ }
+ }
+ }
+
+ // Check optional permissions (permissions that enable extra features)
+ if (operationReq.optional && Array.isArray(operationReq.optional)) {
+ const optionalPerms = operationReq.optional;
+
+ for (const opt of optionalPerms) {
+ const [category, level] = opt.split(":");
+ const currentLevel = permissions[category] || "none";
+
+ if (currentLevel === "none" || (level === "write" && currentLevel !== "write")) {
+ optional.push(opt);
+ }
+ }
+ }
+
+ return {
+ valid: missing.length === 0,
+ missing,
+ optional,
+ description: operationReq.description,
+ };
+}
+
+/**
+ * Validate token permissions for a list of safe output operations
+ * @param {string} token - GitHub token to validate
+ * @param {string} owner - Repository owner
+ * @param {string} repo - Repository name
+ * @param {string[]} operationTypes - Array of operation types to validate
+ * @returns {Promise<{valid: boolean, results: Array